161 lines
6.8 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|