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:
@@ -0,0 +1,160 @@
|
||||
pragma Singleton
|
||||
|
||||
// Sending one application's audio to a different output.
|
||||
//
|
||||
// PipeWire routes a playback stream either by following the system default or
|
||||
// by a per-stream target the session manager remembers. Quickshell's PipeWire
|
||||
// bindings expose the graph but not that routing decision, so moving a stream
|
||||
// means shelling out -- which is why this lives beside AudioDevices rather than
|
||||
// inside it, on the same reasoning as SoundTest.
|
||||
//
|
||||
// ── Reading: native ─────────────────────────────────────────────────────────
|
||||
// `currentSinkFor` walks `Pipewire.linkGroups`, which already says which sink a
|
||||
// stream's links land on. Nothing is shelled out to read.
|
||||
//
|
||||
// It reports "" -- follows the default -- whenever the stream is linked to the
|
||||
// sink that is currently the default. That is a deliberate simplification: an
|
||||
// application explicitly pinned to the device that also happens to be the
|
||||
// default is indistinguishable from one that is merely following it, and the
|
||||
// difference is inaudible for as long as it lasts. The moment the default moves
|
||||
// away, the pinned stream stays put and starts reporting its real sink, which
|
||||
// is the case the picker actually needs to get right. The alternative --
|
||||
// polling `pw-metadata` for per-node target keys -- buys a distinction nobody
|
||||
// can hear, at the cost of a timer.
|
||||
//
|
||||
// ── Writing: two different mechanisms, deliberately ─────────────────────────
|
||||
// `moveApplication` uses `pactl move-sink-input <serial> <sink>`. The serial is
|
||||
// `object.serial`, which is exactly what pipewire-pulse presents as a PulseAudio
|
||||
// index (verified: `pactl -f json list` prints `index` equal to the object's
|
||||
// `object.serial`), so no id translation is needed.
|
||||
//
|
||||
// `routeToDefault` cannot use the same call, because moving a stream *to* the
|
||||
// default sink pins it there -- it would stop following, and quietly stay behind
|
||||
// the next time the default changed. Pinning is stored as a `target.object` key
|
||||
// on the stream's node id in PipeWire's "default" metadata, so releasing it is
|
||||
// deleting that key: `pw-metadata -n default -d <node-id> target.object`. The
|
||||
// session manager sees the metadata change and re-links the stream to whatever
|
||||
// the default is now. The legacy `target.node` key is deleted alongside it,
|
||||
// because streams pinned by older tooling carry that one instead.
|
||||
//
|
||||
// Note the id asymmetry: pactl speaks object serials, pw-metadata speaks node
|
||||
// ids. They are different numbers for the same stream.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: mover.running || root.queue.length > 0
|
||||
|
||||
// Pending commands, run one at a time. An application on three streams is
|
||||
// three calls, and letting them overlap means three processes racing for
|
||||
// the same metadata.
|
||||
property var queue: []
|
||||
|
||||
function streamSerial(node: var): string {
|
||||
const serial = String(node?.properties?.["object.serial"] ?? "").trim();
|
||||
return serial !== "" ? serial : String(node?.id ?? "");
|
||||
}
|
||||
|
||||
// Name of the sink this application's audio is reaching, or "" when it is
|
||||
// simply following the system default. See the note above on why those two
|
||||
// answers merge while the default *is* that sink.
|
||||
function currentSinkFor(group: var): string {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return "";
|
||||
|
||||
const stream = nodes[0];
|
||||
const defaultSink = Pipewire.defaultAudioSink;
|
||||
for (const linkGroup of Pipewire.linkGroups.values) {
|
||||
if (linkGroup?.source !== stream || !linkGroup?.target)
|
||||
continue;
|
||||
if (defaultSink && linkGroup.target === defaultSink)
|
||||
return "";
|
||||
return String(linkGroup.target.name ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Pin every stream of this application to one sink.
|
||||
function moveApplication(group: var, sinkName: string): void {
|
||||
const sink = String(sinkName ?? "").trim();
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (sink === "" || nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const serial = root.streamSerial(node);
|
||||
if (serial !== "")
|
||||
commands.push(["pactl", "move-sink-input", serial, sink]);
|
||||
}
|
||||
root.enqueue(commands, "That application's audio could not be moved.");
|
||||
}
|
||||
|
||||
// Release the pin, so the application follows the system default again.
|
||||
//
|
||||
// Mechanism: delete the stream's routing key from PipeWire's "default"
|
||||
// metadata -- `pw-metadata -n default -d <node-id> target.object`, plus the
|
||||
// legacy `target.node` for streams older tooling pinned. The session
|
||||
// manager re-links on the metadata change.
|
||||
//
|
||||
// Deliberately NOT `pactl move-sink-input <serial> <default sink>`: that
|
||||
// writes the pin rather than clearing it, so the application would sit on
|
||||
// today's default forever and quietly stop following tomorrow's.
|
||||
//
|
||||
// Note the id asymmetry -- pw-metadata takes the node id, pactl takes the
|
||||
// object serial, and they are different numbers for the same stream.
|
||||
function routeToDefault(group: var): void {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const id = String(node?.id ?? "").trim();
|
||||
if (id === "")
|
||||
continue;
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.object"]);
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.node"]);
|
||||
}
|
||||
root.enqueue(commands, "That application could not be returned to the default output.");
|
||||
}
|
||||
|
||||
function enqueue(commands: var, failureMessage: string): void {
|
||||
if (commands.length === 0)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.queue = root.queue.concat(commands.map(command => ({
|
||||
command: command,
|
||||
failureMessage: failureMessage
|
||||
})));
|
||||
root.pump();
|
||||
}
|
||||
|
||||
property string pendingFailure: ""
|
||||
|
||||
function pump(): void {
|
||||
if (mover.running || root.queue.length === 0)
|
||||
return;
|
||||
const next = root.queue[0];
|
||||
root.queue = root.queue.slice(1);
|
||||
root.pendingFailure = next.failureMessage;
|
||||
mover.command = next.command;
|
||||
mover.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mover
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = root.pendingFailure;
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user