190 lines
7.9 KiB
QML
190 lines
7.9 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;
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (reader.running)
|
|
return;
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|