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,162 @@
|
||||
pragma Singleton
|
||||
|
||||
// Native device profiles -- the sound card's own idea of what it can be.
|
||||
//
|
||||
// A card is one piece of hardware; a profile is one wiring of it. The rear
|
||||
// Realtek can be analog stereo out, or S/PDIF out, or duplex with the line-in
|
||||
// live, but only one at a time, and switching costs the sinks and sources the
|
||||
// old profile provided. That choice is not a PipeWire node property, so
|
||||
// Quickshell's PipeWire bindings cannot see it at all -- which is why this file
|
||||
// shells out where AudioDevices must not.
|
||||
//
|
||||
// `pactl -f json list cards` is the read side (verified against pactl 17 /
|
||||
// pipewire-pulse, which prints profiles and ports as objects keyed by name, not
|
||||
// as arrays). `pactl set-card-profile` is the write side. Both talk to
|
||||
// pipewire-pulse, which owns card state -- so nothing here races the PipeWire
|
||||
// node objects AudioDevices holds.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, description, activeProfile,
|
||||
// profiles: [{ name, description, available }], portHint }]
|
||||
property var cards: []
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: lister.running || writer.running
|
||||
|
||||
// True once a listing has completed, however it went, so the UI can tell
|
||||
// "not looked yet" apart from "no cards on this machine".
|
||||
property bool loaded: false
|
||||
|
||||
// A test seam, matching scripts/panama-brightness: point this at a file
|
||||
// holding canned `pactl -f json list cards` output and the service parses
|
||||
// that instead of the live daemon.
|
||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
|
||||
|
||||
function refresh(): void {
|
||||
if (lister.running)
|
||||
return;
|
||||
lister.command = root.fixturePath !== ""
|
||||
? ["cat", root.fixturePath]
|
||||
: ["pactl", "-f", "json", "list", "cards"];
|
||||
lister.running = true;
|
||||
}
|
||||
|
||||
function cardFor(name: string): var {
|
||||
return root.cards.find(card => card.name === name) ?? null;
|
||||
}
|
||||
|
||||
// Switching profile is immediate and lossy: the sinks the old profile
|
||||
// provided disappear. The list is re-read afterwards rather than assumed,
|
||||
// because a card may refuse a profile its ports cannot currently support.
|
||||
function setProfile(cardName: string, profileName: string): void {
|
||||
const card = String(cardName ?? "").trim();
|
||||
const profile = String(profileName ?? "").trim();
|
||||
if (card === "" || profile === "" || writer.running)
|
||||
return;
|
||||
writer.command = ["pactl", "set-card-profile", card, profile];
|
||||
writer.running = true;
|
||||
}
|
||||
|
||||
// The most useful single line about where sound physically comes out.
|
||||
//
|
||||
// Port availability is the only part of a card's state that answers "is
|
||||
// anything plugged in", and it is per-port, so the highest-priority
|
||||
// available port is the one worth naming. A card whose ports all report
|
||||
// "availability unknown" -- USB devices, mostly -- gets no line rather than
|
||||
// a guess, because "unknown" is not "disconnected".
|
||||
function portHintFor(ports: var): string {
|
||||
let best = null;
|
||||
let anyKnown = false;
|
||||
for (const key in ports ?? {}) {
|
||||
const port = ports[key];
|
||||
const availability = String(port?.availability ?? "");
|
||||
if (availability === "available" || availability === "not available")
|
||||
anyKnown = true;
|
||||
if (availability !== "available")
|
||||
continue;
|
||||
if (!best || Number(port.priority ?? 0) > Number(best.priority ?? 0))
|
||||
best = port;
|
||||
}
|
||||
if (best)
|
||||
return `${String(best.description ?? "Port").trim()} connected`;
|
||||
return anyKnown ? "No port connected" : "";
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
root.cards = parsed.map(card => {
|
||||
const properties = card.properties ?? {};
|
||||
const profiles = [];
|
||||
for (const key in card.profiles ?? {}) {
|
||||
const profile = card.profiles[key];
|
||||
profiles.push({
|
||||
name: key,
|
||||
description: String(profile?.description ?? key),
|
||||
available: profile?.available !== false,
|
||||
priority: Number(profile?.priority ?? 0)
|
||||
});
|
||||
}
|
||||
// pactl prints profiles in an object, so their order is whatever
|
||||
// the daemon happened to build. Priority is the order PulseAudio
|
||||
// and GNOME both present them in, and it puts "Off" last for free.
|
||||
profiles.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
return {
|
||||
name: String(card.name ?? ""),
|
||||
description: String(properties["device.description"]
|
||||
?? properties["device.nick"]
|
||||
?? card.name
|
||||
?? "Sound card"),
|
||||
activeProfile: String(card.active_profile ?? ""),
|
||||
profiles: profiles,
|
||||
portHint: root.portHintFor(card.ports)
|
||||
};
|
||||
}).filter(card => card.name !== "");
|
||||
|
||||
root.lastError = "";
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: lister
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: (code, status) => {
|
||||
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user