Give Sound the whole story, and keep the buttons inside the card

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -0,0 +1,129 @@
// Fixture-fed harness for the three sound services that shell out:
// SoundCards (pactl list cards / set-card-profile), SoundRouting (pactl
// move-sink-input) and SoundDefaults (pw-metadata -n default).
//
// Nothing here touches the real audio graph. The contracts that drive this file
// replace pactl and pw-metadata with recording stubs, so what is under test is
// the parsing and the argv -- not PipeWire, which has its own opinions and a
// different set on every machine.
//
// Application groups arrive as plain objects with the same shape AudioStreams.js
// produces, because that is what the services are handed at runtime and it is
// the part a refactor is most likely to break silently.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
// Two streams of one application, the way PipeWire numbers them: an
// `object.serial` that pactl accepts, distinct from the node id.
readonly property var twoStreamGroup: ({
key: "org.chromium.Chromium",
label: "Chromium",
icon: "chromium",
nodes: [
{ id: 61, properties: { "object.serial": "412" } },
{ id: 62, properties: { "object.serial": "418" } }
]
})
// A stream PipeWire never gave a serial. The node id is the fallback, and
// it has to be one: an application that cannot be moved is worse than one
// moved by a less stable handle.
readonly property var seriallessGroup: ({
key: "mpv",
label: "mpv",
icon: "mpv",
nodes: [{ id: 77, properties: {} }]
})
readonly property var emptyGroup: ({
key: "gone",
label: "Gone",
icon: "",
nodes: []
})
function groupNamed(name: string): var {
if (name === "twoStream")
return root.twoStreamGroup;
if (name === "serialless")
return root.seriallessGroup;
return root.emptyGroup;
}
IpcHandler {
target: "sound-services-test"
// ── SoundCards ──────────────────────────────────────────────────────
function cards(): string {
return JSON.stringify({
busy: SoundCards.busy,
lastError: SoundCards.lastError,
cards: SoundCards.cards
});
}
function refreshCards(): bool {
SoundCards.refresh();
return true;
}
function setProfile(card: string, profile: string): bool {
return SoundCards.setProfile(card, profile) !== false;
}
// ── SoundRouting ────────────────────────────────────────────────────
function routing(): string {
return JSON.stringify({
busy: SoundRouting.busy,
lastError: SoundRouting.lastError
});
}
function moveApplication(group: string, sink: string): bool {
return SoundRouting.moveApplication(root.groupNamed(group), sink) !== false;
}
function routeToDefault(group: string): bool {
return SoundRouting.routeToDefault(root.groupNamed(group)) !== false;
}
function currentSinkFor(group: string): string {
return String(SoundRouting.currentSinkFor(root.groupNamed(group)) ?? "");
}
// ── SoundDefaults ───────────────────────────────────────────────────
function defaults(): string {
return JSON.stringify({
sink: SoundDefaults.configuredSinkName,
source: SoundDefaults.configuredSourceName
});
}
function refreshDefaults(): bool {
SoundDefaults.refresh();
return true;
}
// What the ghost row would render, and what it would call the device.
// The name is all there is to go on: the node is gone, so there is no
// description to borrow.
function labelFor(name: string): string {
return SoundDefaults.label(name);
}
function absent(output: string): string {
const record = SoundDefaults.absent(output === "output");
return JSON.stringify(record === null ? { present: true } : {
present: false,
name: record.name,
label: record.label
});
}
}
}