Files
Panama/docs/superpowers/plans/2026-08-18-phase2-application-volume.md
T

261 lines
10 KiB
Markdown

# 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.sh`
**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.sh`
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.sh`
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.sh
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.sh`
**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.sh`
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.sh`
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.sh
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.sh`
- Modify: `tests/quickshell/sound-page-contract.sh`
**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.sh && tests/quickshell/sound-page-contract.sh`
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.sh && tests/quickshell/sound-page-contract.sh`
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.sh tests/quickshell/sound-page-contract.sh
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.sh
tests/quickshell/sound-page-contract.sh
tests/quickshell/settings-pages-contract.sh
```
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.