Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/Audio.qml

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