import 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] } }