import Quickshell import Quickshell.Services.Pipewire import QtQuick import QtQuick.Layouts import "../components" Item { id: root implicitWidth: chip.implicitWidth + 10 implicitHeight: 24 // Track all PipeWire nodes so their properties are populated. // Without this, nodes in Pipewire.nodes are "unbound" and have null audio/type. PwObjectTracker { objects: Pipewire.nodes.values } // ── helpers ──────────────────────────────────────────────────────── function safeVolume(node) { if (!node || !node.ready || !node.audio) return 0 const v = node.audio.volume return (v !== undefined && !isNaN(v)) ? v : 0 } function setVolume(node, v) { if (!node || !node.ready || !node.audio) return node.audio.volume = Math.max(0, Math.min(1, v)) } function volIcon(vol, muted) { if (muted) return "\uf6a9" // fa-volume-mute if (vol > 0.6) return "\uf028" // fa-volume-up if (vol > 0.2) return "\uf027" // fa-volume-down return "\uf026" // fa-volume-off } // ── bar chip (default sink) ───────────────────────────────────────── readonly property var defaultSink: Pipewire.defaultAudioSink readonly property real defaultVolume: safeVolume(defaultSink) readonly property bool defaultMuted: defaultSink?.audio?.muted ?? false Row { id: chip anchors.centerIn: parent spacing: 5 Text { anchors.verticalCenter: parent.verticalCenter text: root.volIcon(root.defaultVolume, root.defaultMuted) font.family: "JetBrainsMono Nerd Font Mono" font.pixelSize: 14 color: root.defaultMuted ? Theme.textDim : Theme.text } Text { anchors.verticalCenter: parent.verticalCenter text: root.defaultMuted ? "mute" : Math.round(root.defaultVolume * 100) + "%" font.pixelSize: 11 color: Theme.textDim } } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: mouse => { if (mouse.button === Qt.RightButton) { if (root.defaultSink?.ready && root.defaultSink?.audio) root.defaultSink.audio.muted = !root.defaultSink.audio.muted } else { mixerPopup.visible = !mixerPopup.visible } } onWheel: wheel => { if (!root.defaultSink?.ready) return root.setVolume(root.defaultSink, root.defaultVolume + wheel.angleDelta.y / 120 * 0.05) } } // ── mixer popup ───────────────────────────────────────────────────── PopupWindow { id: mixerPopup visible: false anchor.item: root anchor.edges: Edges.Bottom anchor.gravity: Edges.Bottom implicitWidth: mixerBg.implicitWidth implicitHeight: mixerBg.implicitHeight Rectangle { id: mixerBg anchors.fill: parent color: Theme.bg border.color: Theme.border border.width: Theme.borderWidth radius: Theme.radius implicitWidth: 300 implicitHeight: mixerCol.implicitHeight + 24 ColumnLayout { id: mixerCol anchors { fill: parent; margins: 12 } spacing: 4 // ── Output devices ────────────────────────────────────── Text { text: "Output Devices" color: Theme.textDim font.pixelSize: 10 Layout.fillWidth: true Layout.bottomMargin: 2 } Repeater { model: Pipewire.nodes delegate: NodeRow { required property var modelData Layout.fillWidth: true node: modelData visible: modelData.isSink && !modelData.isStream && modelData.ready } } // ── Application streams ──────────────────────────────── Text { text: "Applications" color: Theme.textDim font.pixelSize: 10 Layout.fillWidth: true Layout.topMargin: 6 Layout.bottomMargin: 2 } Repeater { model: Pipewire.nodes delegate: NodeRow { required property var modelData Layout.fillWidth: true node: modelData // exclude monitor sources; include only audio output streams visible: modelData.isStream && !modelData.isSink && modelData.ready && !(modelData.description ?? "").toLowerCase().includes("monitor") } } } } } // ── per-node row component ────────────────────────────────────────── component NodeRow: RowLayout { id: row required property var node spacing: 8 implicitHeight: 28 // mute toggle Text { id: nodeIcon text: root.volIcon(root.safeVolume(row.node), row.node.audio?.muted ?? false) font.family: "JetBrainsMono Nerd Font Mono" font.pixelSize: 13 color: (row.node.audio?.muted ?? false) ? Theme.textDim : Theme.text MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { if (row.node.ready && row.node.audio) row.node.audio.muted = !row.node.audio.muted } } } // name Text { text: row.node.description || row.node.nickname || row.node.name || "?" font.pixelSize: 11 color: Theme.textDim Layout.preferredWidth: 90 elide: Text.ElideRight } // horizontal slider Item { Layout.fillWidth: true implicitHeight: 16 Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.width height: 3 radius: 2 color: Theme.progressTrack Rectangle { width: parent.width * Math.min(1, root.safeVolume(row.node)) height: parent.height radius: parent.radius color: Theme.accent } } MouseArea { anchors { fill: parent; topMargin: -4; bottomMargin: -4 } hoverEnabled: true cursorShape: Qt.PointingHandCursor function seek(mx) { root.setVolume(row.node, mx / width) } onClicked: seek(mouseX) onPositionChanged: if (pressed) seek(mouseX) } } // percentage Text { text: Math.round(root.safeVolume(row.node) * 100) + "%" font.pixelSize: 10 color: Theme.textDim Layout.preferredWidth: 30 horizontalAlignment: Text.AlignRight } } }