pragma Singleton // What you *asked* for, as opposed to what you got. // // PipeWire keeps two different answers to "which is the default output". The // effective one -- the sink audio is actually reaching right now -- is what // Quickshell's `Pipewire.defaultAudioSink` exposes. The configured one is the // device you last chose, remembered by name, and it survives that device being // switched off, unpaired, or carried into another room. // // The two disagree constantly, and the disagreement is the whole story a sound // page should be telling. Choose the Bluetooth headphones, walk away, and the // configured default stays those headphones while the effective default falls // back to the speakers. A list that only renders present devices shows the // speakers selected and no trace of the headphones, which reads as "Panama // forgot" rather than "they are out of range". // // So this service reads the configured names and says, plainly, when one of // them maps to nothing that is currently here. The UI renders that as a ghost // row: visible, dimmed, not selectable, labelled "Returns when connected". // // Read through pw-metadata rather than wpctl. `wpctl status` prints the // *effective* defaults only -- the configured name appears nowhere in its // output -- so it cannot answer the question this file exists to answer. // `pw-metadata -n default 0` prints both, exits immediately, and mutates // nothing. // // Quickshell's `Pipewire.preferredDefaultAudioSink` is the same configured // value, but typed as a node pointer, so it reads null in exactly the case that // matters: the configured device is not here, and there is no node to point at. // A name survives where a pointer cannot, which is why the string is read // directly. import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire import QtQuick Singleton { id: root // The names PipeWire remembers as chosen. Empty until the first read // lands, and empty on a session that has never had a default set by hand. property string configuredSinkName: "" property string configuredSourceName: "" // The names audio is actually reaching. Kept alongside the configured pair // purely so the two can be compared without a second source of truth. property string effectiveSinkName: "" property string effectiveSourceName: "" property string lastError: "" readonly property bool busy: reader.running // True once a read has completed, however it went -- so the UI can tell // "not looked yet" apart from "looked, and nothing is configured". property bool loaded: false // A test seam, matching scripts/panama-brightness: point this at a file // holding canned pw-metadata output and the service parses that instead of // talking to the live graph. readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_DEFAULTS_FIXTURE") || "" readonly property var presentNames: { const names = {}; for (const node of Pipewire.nodes.values) { if (node && !node.isStream && node.name) names[String(node.name)] = true; } return names; } // The configured device that is not here, or null. This is exactly what the // ghost row renders; `null` means every configured default is present and // the list has nothing extra to say. readonly property var absentSink: root.configuredSinkName !== "" && root.presentNames[root.configuredSinkName] !== true ? ({ name: root.configuredSinkName, label: root.label(root.configuredSinkName) }) : null readonly property var absentSource: root.configuredSourceName !== "" && root.presentNames[root.configuredSourceName] !== true ? ({ name: root.configuredSourceName, label: root.label(root.configuredSourceName) }) : null function absent(output: bool): var { return output ? root.absentSink : root.absentSource; } // Assigning `running = true` to a Process that is already running is a // no-op, not a queue. The default sink and source change together, so the // two Pipewire signals below arrive back to back and the second read was // always the one dropped -- which is how a ghost row survived the device // coming back. Remembered here and re-run from reader.onExited instead. property bool refreshPending: false function refresh(): void { if (reader.running) { root.refreshPending = true; return; } root.refreshPending = false; reader.command = root.fixturePath !== "" ? ["cat", root.fixturePath] : ["pw-metadata", "-n", "default", "0"]; reader.running = true; } // A node name turned into something a person can read. // // An absent device has no description to borrow -- descriptions live on the // node, and the node is gone -- so the stored name is all there is. These // names are structured, though, and the structure carries the useful part: // the Bluetooth address, the AirPlay speaker's hostname, the USB device's // product string. Anything this does not recognize is tidied rather than // guessed at, because a wrong name is worse than an ugly one. function label(name: string): string { const raw = String(name ?? "").trim(); if (raw === "") return ""; // bluez_output.74_15_F5_13_A4_28.1 -- nothing but the address. const bluetooth = raw.match(/^bluez_[a-z]+\.([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})/); if (bluetooth) return `Bluetooth device (${bluetooth[1].replace(/_/g, ":").toUpperCase()})`; // raop_sink.Living-Room.local.192.168.1.162.7000 -- the mDNS hostname. const airplay = raw.match(/^raop_[a-z]+\.(.+?)\.local\b/); if (airplay) return airplay[1].replace(/[-_]+/g, " ").trim(); // alsa_output.usb-Generic_USB_Audio-00.analog-stereo -- the udev bus id // carries the product string, with a two-digit interface number glued // on the end that means nothing to anyone. const usb = raw.match(/^alsa_[a-z]+\.usb-(.+?)(?:-[0-9]{2})?\.[^.]*$/); if (usb) return usb[1].replace(/_+/g, " ").replace(/\s+/g, " ").trim(); const tidied = raw.replace(/^[a-z0-9]+_[a-z]+\./, "") .replace(/[._]+/g, " ") .replace(/\s+/g, " ") .trim(); return tidied || raw; } // pw-metadata prints one line per key: // update: id:0 key:'default.audio.sink' value:'{"name":"..."}' type:'...' // The value is JSON, and carries `name` -- and, on some setups, a // `description` PipeWire stored alongside it, which is a better label than // anything derivable from the name. function parse(text: string): void { const found = {}; for (const line of String(text ?? "").split("\n")) { const matched = line.match(/key:'([^']+)'\s+value:'(.*)'\s+type:'/); if (!matched) continue; try { const value = JSON.parse(matched[2]); const name = String(value?.name ?? "").trim(); if (name !== "") found[matched[1]] = name; } catch (error) { // A key whose value is not JSON is not our key. Skip it rather // than failing the whole read. } } root.configuredSinkName = found["default.configured.audio.sink"] ?? ""; root.configuredSourceName = found["default.configured.audio.source"] ?? ""; root.effectiveSinkName = found["default.audio.sink"] ?? ""; root.effectiveSourceName = found["default.audio.source"] ?? ""; root.loaded = true; } Process { id: reader stdout: StdioCollector { onStreamFinished: root.parse(this.text) } onExited: (code, status) => { root.lastError = code === 0 ? "" : "PipeWire's remembered default devices could not be read."; if (code !== 0) root.loaded = true; // Deferred a turn so this read's stdout is parsed before the next // one starts filling the same collector. if (root.refreshPending) Qt.callLater(() => root.refresh()); } } // The effective default moving is the signal that the configured one may // now be unreachable (or reachable again). Re-reading on that change is // what keeps the ghost row honest without polling. Connections { target: Pipewire function onDefaultAudioSinkChanged(): void { root.refresh(); } function onDefaultAudioSourceChanged(): void { root.refresh(); } } Component.onCompleted: root.refresh() }