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

204 lines
8.7 KiB
QML

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