repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
quickshell/nuguland/Audio.qml
Rawimport QtQuick
import Quickshell.Services.Pipewire
import "audio.js" as Audio
Item {
id: root
property Osd osdService
property var lastAction: ({})
property var lastOsd: ({})
readonly property bool pipewireReady: Pipewire.ready
readonly property var boundedActions: ["increase", "decrease", "muteOutput", "muteInput", "set-volume", "toggle-mute", "set-default-sink", "set-default-source"]
function pipewireNodes() {
return Pipewire.nodes.values || [];
}
function nodeId(node) {
return node ? String(node.id) : "";
}
function nodeTypeText(node) {
return String(PwNodeType.toString(node && node.type ? node.type : PwNodeType.Untracked));
}
function nodeProps(node) {
return node && node.properties ? node.properties : ({});
}
function nodeName(node) {
if (!node)
return Audio.nodeName(null);
const props = nodeProps(node);
return Audio.nodeName({
"description": node.description,
"nickname": node.nickname,
"nodeDescription": props["node.description"],
"applicationName": props["application.name"],
"mediaName": props["media.name"],
"name": node.name
});
}
function channelNames(node) {
if (!node || !node.audio)
return [];
const channels = node.audio.channels || [];
const strings = [];
for (let i = 0; i < channels.length; i++)
strings.push(String(PwAudioChannel.toString(channels[i])));
return Audio.channelNames(strings);
}
function nodeVolume(node) {
return Audio.clampVolume(node && node.audio ? node.audio.volume : 0);
}
function nodeMuted(node) {
return Audio.nodeMuted({
"muted": node && node.audio ? node.audio.muted : false
});
}
function isAudioSink(node) {
if (!node || !node.audio || node.isStream)
return false;
return !!node.isSink || nodeTypeText(node).indexOf("AudioSink") >= 0;
}
function isAudioSource(node) {
if (!node || !node.audio || node.isStream)
return false;
const type = nodeTypeText(node);
return type.indexOf("AudioSource") >= 0 || (!node.isSink && type.indexOf("Source") >= 0 && type.indexOf("Video") < 0);
}
function isAudioStream(node) {
if (!node || !node.audio)
return false;
const type = nodeTypeText(node);
return !!node.isStream || type.indexOf("AudioOutStream") >= 0 || type.indexOf("AudioInStream") >= 0;
}
function audioSinks() {
return pipewireNodes().filter(isAudioSink);
}
function audioSources() {
return pipewireNodes().filter(isAudioSource);
}
function audioStreams() {
return pipewireNodes().filter(isAudioStream);
}
function defaultSinkNode() {
return Pipewire.defaultAudioSink || audioSinks()[0] || null;
}
function defaultSourceNode() {
return Pipewire.defaultAudioSource || audioSources()[0] || null;
}
function nodeById(id) {
const wanted = String(id);
const nodes = pipewireNodes();
for (let i = 0; i < nodes.length; i++)
if (nodeId(nodes[i]) === wanted) {
return nodes[i];
}
return null;
}
function linkActive(link) {
return String(PwLinkState.toString(link && link.state ? link.state : PwLinkState.Unlinked)).toLowerCase() === "active";
}
function linkedNodeId(node) {
const id = nodeId(node);
const links = Pipewire.links.values || [];
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (!linkActive(link))
continue;
if (nodeId(link.source) === id)
return nodeId(link.target);
if (nodeId(link.target) === id)
return nodeId(link.source);
}
return "";
}
function nodeActive(node) {
return linkedNodeId(node).length > 0;
}
// RAW native bridge: lift plain fields off the native node so audio.js can shape them.
// No name/role/percent resolution here — Audio.shapeRow() owns that.
function nodeRow(node, kind) {
const props = nodeProps(node);
return {
"id": nodeId(node),
"description": String(node ? node.description || "" : ""),
"nickname": String(node ? node.nickname || "" : ""),
"nodeDescription": String(props["node.description"] || ""),
"applicationName": String(props["application.name"] || ""),
"mediaName": String(props["media.name"] || ""),
"name": String(node ? node.name || "" : ""),
"volume": Number(node && node.audio ? node.audio.volume : 0),
"muted": !!(node && node.audio && node.audio.muted),
"channels": channelNames(node),
"isDefault": kind === "sink" ? node === Pipewire.defaultAudioSink : (kind === "source" ? node === Pipewire.defaultAudioSource : false),
"active": nodeActive(node),
"target": kind === "stream" ? linkedNodeId(node) : "",
"app": String(props["application.name"] || ""),
"title": String(props["media.name"] || "")
};
}
function rowsById(rows) {
return Audio.rowsById(rows);
}
function roleForSink(sink) {
return Audio.roleForSink({
"muted": nodeMuted(sink),
"volume": nodeVolume(sink)
});
}
function microphoneActive() {
const sources = audioSources();
for (let i = 0; i < sources.length; i++)
if (nodeActive(sources[i]) && !nodeMuted(sources[i])) {
return true;
}
return false;
}
function microphoneMuted() {
const source = defaultSourceNode();
return source ? nodeMuted(source) : true;
}
function microphoneRole() {
return microphoneActive() ? "warning" : "ui.minor";
}
function cameraNodeActive(node) {
if (!node || !node.ready)
return false;
const type = nodeTypeText(node).toLowerCase();
const props = nodeProps(node);
const mediaClass = String(props["media.class"] || "").toLowerCase();
return type.indexOf("videosource") >= 0 || mediaClass.indexOf("video/source") >= 0 || mediaClass.indexOf("camera") >= 0;
}
function nativeCameraActive() {
const nodes = pipewireNodes();
for (let i = 0; i < nodes.length; i++)
if (cameraNodeActive(nodes[i])) {
return true;
}
return false;
}
function cameraActive() {
return nativeCameraActive();
}
function privacyLabel() {
return Audio.privacyLabel({
"mic": microphoneActive(),
"cam": cameraActive()
});
}
function privacyActive() {
return microphoneActive() || cameraActive();
}
function privacyWidgetState() {
const mic = microphoneActive();
const cam = cameraActive();
return {
"backend": "Audio.qml",
"compact": {
"label": privacyLabel(),
"role": mic || cam ? "warning" : roleForSink(defaultSinkNode()),
"opens": "audio"
},
"privacy": {
"active": mic || cam,
"microphoneActive": mic,
"cameraActive": cam
}
};
}
function privacyWidgetJson() {
return JSON.stringify(privacyWidgetState());
}
function osd(event, value) {
if (!osdService)
return {
"event": event,
"value": value
};
try {
return JSON.parse(osdService.trigger(event, value));
} catch (error) {
return {
"event": event,
"value": value
};
}
}
function stateObject(osdState) {
const sink = defaultSinkNode();
const source = defaultSourceNode();
const sinkRows = audioSinks().map(node => {
return Audio.shapeRow(nodeRow(node, "sink"), "sink");
});
const sourceRows = audioSources().map(node => {
return Audio.shapeRow(nodeRow(node, "source"), "source");
});
const streamRows = audioStreams().map(node => {
const raw = nodeRow(node, "stream");
const row = Audio.shapeRow(raw, "stream");
row.app = String(raw.app || row.name);
row.title = String(raw.title || row.name);
row.target = String(raw.target || "");
return row;
});
return {
"pipewireReady": pipewireReady,
"serviceOwnedActions": true,
"boundedActions": boundedActions,
"defaults": {
"sink": nodeId(sink),
"source": nodeId(source)
},
"sinks": sinkRows,
"sources": sourceRows,
"streams": streamRows,
"sinksById": rowsById(sinkRows),
"sourcesById": rowsById(sourceRows),
"streamsById": rowsById(streamRows),
"compact": {
"label": privacyLabel(),
"sink": nodeName(sink),
"volumeLabel": Audio.volumePercent(nodeVolume(sink)) + "%",
"muted": nodeMuted(sink),
"volumeRole": roleForSink(sink)
},
"privacy": Object.assign({
"microphoneActive": microphoneActive(),
"microphoneMuted": microphoneMuted(),
"microphoneRole": microphoneRole(),
"cameraActive": cameraActive(),
"active": privacyActive()
}, privacyWidgetState().privacy),
"lastAction": lastAction,
"osd": osdState || lastOsd || {}
};
}
function selfTest() {
var cases = [];
var pass = 0;
var fail = 0;
var check = function (label, got, want) {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (ok)
pass += 1;
else
fail += 1;
cases.push({
"case": label,
"ok": ok,
"got": got,
"want": want
});
};
check("clampVolume(-1)", Audio.clampVolume(-1), 0);
check("clampVolume(2)", Audio.clampVolume(2), 1.5);
check("clampVolume(0.5)", Audio.clampVolume(0.5), 0.5);
check("clampVolume(null)", Audio.clampVolume(null), 0);
check("volumePercent(1)", Audio.volumePercent(1), 100);
check("volumePercent(1.5)", Audio.volumePercent(1.5), 150);
check("volumePercent(0)", Audio.volumePercent(0), 0);
check("roleForSink(muted)", Audio.roleForSink({
"muted": true,
"volume": 0.5
}), "warning");
check("roleForSink(vol0)", Audio.roleForSink({
"muted": false,
"volume": 0
}), "ui.minor");
check("roleForSink(vol0.5)", Audio.roleForSink({
"muted": false,
"volume": 0.5
}), "ui.accent");
check("privacyLabel(mic+cam)", Audio.privacyLabel({
"mic": true,
"cam": true
}), "Mic Cam");
check("privacyLabel(mic)", Audio.privacyLabel({
"mic": true,
"cam": false
}), "Mic");
check("privacyLabel(cam)", Audio.privacyLabel({
"mic": false,
"cam": true
}), "Cam");
check("privacyLabel(none)", Audio.privacyLabel({
"mic": false,
"cam": false
}), "Audio");
const row = Audio.shapeRow({
"id": 42,
"description": "Speakers",
"volume": 0.5,
"muted": false,
"isDefault": true
}, "sink");
check("shapeRow.percent", row.percent, 50);
check("shapeRow.role", row.role, "ui.accent");
check("shapeRow.muted", row.muted, false);
return JSON.stringify({
"pass": pass,
"fail": fail,
"cases": cases
});
}
function stateJson() {
return JSON.stringify(stateObject({}));
}
function setVolume(id, volume) {
const node = nodeById(id);
const value = Audio.clampVolume(volume);
if (node && node.audio)
node.audio.volume = value;
lastAction = {
"kind": node ? "set-volume" : "missing-node",
"id": String(id),
"volume": Math.round(value * 100) / 100
};
lastOsd = osd(isAudioSource(node) ? "microphone" : "volume", String(Audio.volumePercent(value)));
return JSON.stringify(stateObject(lastOsd));
}
function toggleMute(id) {
const node = nodeById(id);
let muted = false;
if (node && node.audio) {
muted = !node.audio.muted;
node.audio.muted = muted;
lastOsd = osd(isAudioSource(node) ? "microphone" : "volume", muted ? "muted" : "unmuted");
}
lastAction = {
"kind": node ? "toggle-mute" : "missing-node",
"id": String(id),
"muted": muted
};
return JSON.stringify(stateObject(lastOsd));
}
function increase() {
const sink = defaultSinkNode();
return sink ? setVolume(nodeId(sink), Math.min(1.5, nodeVolume(sink) + 0.05)) : stateJson();
}
function decrease() {
const sink = defaultSinkNode();
return sink ? setVolume(nodeId(sink), Math.max(0, nodeVolume(sink) - 0.05)) : stateJson();
}
function muteOutput() {
const sink = defaultSinkNode();
return sink ? toggleMute(nodeId(sink)) : stateJson();
}
function muteInput() {
const source = defaultSourceNode();
return source ? toggleMute(nodeId(source)) : stateJson();
}
function route(action) {
const name = String(action);
if (name === "increase")
return increase();
if (name === "decrease")
return decrease();
if (name === "muteOutput")
return muteOutput();
if (name === "muteInput")
return muteInput();
lastAction = {
"kind": "unknown-volume-action",
"action": name
};
return stateJson();
}
function setDefaultSink(id) {
const node = nodeById(id);
if (node && isAudioSink(node))
Pipewire.preferredDefaultAudioSink = node;
lastAction = {
"kind": node && isAudioSink(node) ? "set-default-sink" : "missing-sink",
"id": String(id)
};
return stateJson();
}
function setDefaultSource(id) {
const node = nodeById(id);
if (node && isAudioSource(node))
Pipewire.preferredDefaultAudioSource = node;
lastAction = {
"kind": node && isAudioSource(node) ? "set-default-source" : "missing-source",
"id": String(id)
};
return stateJson();
}
function moveStream(id, target) {
lastAction = {
"kind": "unsupported-native-stream-move",
"id": String(id),
"target": String(target)
};
return stateJson();
}
PwObjectTracker {
objects: [Pipewire.defaultAudioSink, Pipewire.defaultAudioSource]
}
}