# 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.