# Phase 2 Application Volume Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a live, non-persistent per-application playback mixer to Panama Settings.

**Architecture:** A small pure JavaScript module groups PipeWire playback nodes and owns aggregate calculations. `AudioDevices.qml` exposes the live groups, while focused QML components render and mutate the real tracked nodes.

**Tech Stack:** Quickshell 0.3, Qt 6 QML/JavaScript, `Quickshell.Services.Pipewire`, Bash contract harnesses

**Spec:** `docs/superpowers/specs/2026-08-18-phase2-expectation-gaps-design.md`

## Global Constraints

- PipeWire remains the source of truth; do not persist stream identity or volume.
- Include only ready `PwNodeType.AudioOutStream` nodes with writable audio state.
- Group by `application.id`, binary, name, then node ID, in that order.
- No polling, shell commands, or logging of stream metadata.
- Preserve the existing output, input, balance, and Fedora device-profile controls.

---

### Task 1: Pure application-stream model

**Files:**
- Create: `config/dot/quickshell/services/AudioStreams.js`
- Create: `config/dot/quickshell/audio-streams-harness.qml`
- Create: `tests/quickshell/application-volume-contract`

**Interfaces:**
- Consumes: PipeWire-shaped nodes with `id`, `ready`, `type`, `properties`, and `audio`.
- Produces: `group(nodes, audioOutStreamFlag)`, `volume(group)`, `muted(group)`, `setVolume(group, value)`, and `setMuted(group, muted)`.

- [ ] **Step 1: Write the failing grouping contract**

Create complete fixture nodes for two Chromium streams, one Spotify stream, an input stream, an unready stream, and a metadata-free stream. Through IPC, assert the literal groups and labels:

```json
[
  {"key":"org.chromium.Chromium","label":"Chromium","icon":"chromium","count":2},
  {"key":"spotify","label":"Spotify","icon":"audio-x-generic-symbolic","count":1},
  {"key":"node:99","label":"Unknown application","icon":"audio-x-generic-symbolic","count":1}
]
```

Also assert Chromium volume `0.6` from fixture values `0.4` and `0.8`, mixed mute reports `false`, setting volume writes both nodes, and setting mute normalizes both nodes.

- [ ] **Step 2: Run the contract and verify RED**

Run: `tests/quickshell/application-volume-contract`

Expected: FAIL because `AudioStreams.js` and the IPC target do not exist.

- [ ] **Step 3: Implement the minimal pure model**

Export these exact functions:

```javascript
function property(node, key) {
    const value = node && node.properties ? node.properties[key] : "";
    return typeof value === "string" ? value.trim() : "";
}

function groupKey(node) {
    return property(node, "application.id")
        || property(node, "application.process.binary")
        || property(node, "application.name")
        || `node:${node.id}`;
}

function label(node) {
    return property(node, "application.name")
        || String(node.description || "").trim()
        || property(node, "media.name")
        || "Unknown application";
}

function icon(node) {
    return property(node, "application.icon_name")
        || "audio-x-generic-symbolic";
}

function group(nodes, audioOutStreamFlag) {
    const groups = [];
    const byKey = {};
    for (const node of nodes || []) {
        if (!node || node.ready !== true || !node.audio
                || (node.type & audioOutStreamFlag) !== audioOutStreamFlag)
            continue;
        const key = groupKey(node);
        if (!byKey[key]) {
            byKey[key] = { key, label: label(node), icon: icon(node), nodes: [] };
            groups.push(byKey[key]);
        }
        byKey[key].nodes.push(node);
    }
    return groups;
}

function audioNodes(application) {
    return (application && application.nodes || []).filter(node => node && node.audio);
}

function volume(application) {
    const nodes = audioNodes(application);
    return nodes.length === 0 ? 0
        : nodes.reduce((sum, node) => sum + node.audio.volume, 0) / nodes.length;
}

function muted(application) {
    const nodes = audioNodes(application);
    return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
}

function setVolume(application, value) {
    const next = Math.max(0, Math.min(1, Number(value)));
    if (!Number.isFinite(next)) return false;
    const nodes = audioNodes(application);
    for (const node of nodes) {
        node.audio.muted = false;
        node.audio.volume = next;
    }
    return nodes.length > 0;
}

function setMuted(application, mutedValue) {
    const nodes = audioNodes(application);
    for (const node of nodes) node.audio.muted = mutedValue === true;
    return nodes.length > 0;
}
```

Use literal property lookups and `Number.isFinite`; do not import PipeWire into the pure module.

- [ ] **Step 4: Run the contract and verify GREEN**

Run: `tests/quickshell/application-volume-contract`

Expected: PASS for grouping, fallback metadata, aggregate volume, mixed mute, and writes.

- [ ] **Step 5: Commit the model**

```bash
git add config/dot/quickshell/services/AudioStreams.js config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract
git commit -m "Model live application audio streams"
```

### Task 2: Live PipeWire service boundary

**Files:**
- Modify: `config/dot/quickshell/services/AudioDevices.qml`
- Modify: `config/dot/quickshell/audio-streams-harness.qml`
- Modify: `tests/quickshell/application-volume-contract`

**Interfaces:**
- Consumes: `AudioStreams.group(nodes, PwNodeType.AudioOutStream)`.
- Produces: `readonly property var applications` and typed wrappers `applicationVolume`, `applicationMuted`, `setApplicationVolume`, `setApplicationMuted`.

- [ ] **Step 1: Extend the contract for the service API**

Assert the harness can report real `AudioDevices.applications` without starting playback and that every returned group contains only nodes with the AudioOutStream flag. Assert mutator wrappers reject `null` and a group with no live audio nodes.

- [ ] **Step 2: Run and verify RED**

Run: `tests/quickshell/application-volume-contract`

Expected: FAIL because `AudioDevices.applications` is undefined.

- [ ] **Step 3: Add the reactive service properties**

Implement:

```qml
readonly property var playbackStreams: Pipewire.nodes.values.filter(node =>
    node.ready && node.audio
    && (node.type & PwNodeType.AudioOutStream) === PwNodeType.AudioOutStream)
readonly property var applications: AudioStreams.group(root.playbackStreams, PwNodeType.AudioOutStream)
```

Delegate aggregate reads and writes to the pure model. Preserve `outputs`, `inputs`, `nodes()`, `current()`, and `select()` unchanged.

- [ ] **Step 4: Run and verify GREEN**

Run: `tests/quickshell/application-volume-contract`

Expected: PASS with zero or more real live applications and no mutation of the live streams.

- [ ] **Step 5: Commit the service boundary**

```bash
git add config/dot/quickshell/services/AudioDevices.qml config/dot/quickshell/audio-streams-harness.qml tests/quickshell/application-volume-contract
git commit -m "Expose live application audio groups"
```

### Task 3: Application mixer UI

**Files:**
- Create: `config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml`
- Create: `config/dot/quickshell/modules/settings/ApplicationMixer.qml`
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
- Modify: `tests/quickshell/application-volume-contract`
- Modify: `tests/quickshell/sound-page-contract`

**Interfaces:**
- Consumes: one `AudioDevices.applications` group per row.
- Produces: a real Sound **Applications** card and the empty-state sentence from the spec.

- [ ] **Step 1: Write the failing UI contract**

Construct `SoundPage.qml` in the existing isolated Settings harness. Assert no QML warnings and these rendered states: application rows when groups exist, **Applications playing sound will appear here** when empty, and **PipeWire is unavailable** when the service is not ready. Assert the advanced handoff label is exactly **Device profiles**.

- [ ] **Step 2: Run and verify RED**

Run: `tests/quickshell/application-volume-contract && tests/quickshell/sound-page-contract`

Expected: FAIL because the mixer components and Applications card are absent.

- [ ] **Step 3: Implement the row and card**

`ApplicationVolumeRow.qml` must declare a `PwObjectTracker` over `application.nodes`, use `ThemedIcon`, `IconButton`, `ValueSlider`, and a tabular percentage. Its only writes are `AudioDevices.setApplicationMuted(root.application, value)` and `AudioDevices.setApplicationVolume(root.application, value)`. `ApplicationMixer.qml` owns the Repeater and empty/unavailable copy. Put the card after Input and before Sound feedback.

- [ ] **Step 4: Run and verify GREEN**

Run: `tests/quickshell/application-volume-contract && tests/quickshell/sound-page-contract`

Expected: PASS with no QML warnings.

- [ ] **Step 5: Commit the finished application mixer**

```bash
git add config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml config/dot/quickshell/modules/settings/ApplicationMixer.qml config/dot/quickshell/modules/settings/SoundPage.qml tests/quickshell/application-volume-contract tests/quickshell/sound-page-contract
git commit -m "Add application volume mixer"
```

### Task 4: Slice verification

**Files:**
- Modify only if verification exposes a defect in the files above.

- [ ] **Step 1: Run the focused static and runtime contracts once**

Run:

```bash
tests/quickshell/application-volume-contract
tests/quickshell/sound-page-contract
tests/quickshell/settings-pages-contract
```

Expected: all PASS; runtime enumeration may report zero applications without failing.

- [ ] **Step 2: Inspect Quickshell output**

The isolated harness output must contain no `ReferenceError`, `TypeError`, binding loop, failed property assignment, or `PwObjectTracker` warning.

- [ ] **Step 3: Review the diff**

Run: `git diff --check && git status --short`

Expected: clean formatting and no uncommitted changes after the Task 3 commit.
