Files
Panama/config/dot/quickshell/services/SoundRouting.qml
T

161 lines
6.8 KiB
QML

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