163 lines
6.1 KiB
QML
163 lines
6.1 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") || ""
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|