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 `. 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 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 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 `: 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(); } } }