Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user