Files

177 lines
6.8 KiB
QML

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") || ""
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue, so a refresh that arrived mid-read was simply lost --
// and a profile switch's own re-read is exactly the refresh most likely to
// land on top of one, leaving the list showing the profile the card no
// longer has. Remembered here and re-run from lister.onExited instead.
property bool refreshPending: false
function refresh(): void {
if (lister.running) {
root.refreshPending = true;
return;
}
root.refreshPending = false;
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;
}
// Deferred a turn so this listing's stdout is parsed before the
// next one starts filling the same collector.
if (root.refreshPending)
Qt.callLater(() => root.refresh());
}
}
Process {
id: writer
onExited: (code, status) => {
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
root.refresh();
}
}
}