import QtQuick import Quickshell import Quickshell.Services.Pipewire import "." as M M.BarSection { id: root spacing: M.Theme.moduleSpacing tooltip: "" PwObjectTracker { objects: [Pipewire.defaultAudioSink, ...root._streamList] } readonly property var sink: Pipewire.defaultAudioSink readonly property real volume: sink?.audio?.volume ?? 0 readonly property bool muted: sink?.audio?.muted ?? false readonly property string _volumeIcon: muted ? "\uF026" : (volume > 0.5 ? "\uF028" : (volume > 0 ? "\uF027" : "\uF026")) readonly property color _volumeColor: muted ? M.Theme.base04 : root.accentColor readonly property var _sinkList: { const sinks = []; if (Pipewire.nodes) { for (const node of Pipewire.nodes.values) if (!node.isStream && node.isSink) sinks.push(node); } return sinks; } readonly property var _streamList: { const streams = []; if (Pipewire.nodes) { for (const node of Pipewire.nodes.values) if (node.isStream && node.audio) streams.push(node); } return streams; } property bool _expanded: false property bool _osdActive: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _showPanel: _anyHover || _expanded || _osdActive on_ShowPanelChanged: { console.log("[vol] showPanel:", _showPanel, "| anyHover:", _anyHover, "| expanded:", _expanded, "| osd:", _osdActive); if (!_showPanel) _expanded = false; } on_ExpandedChanged: console.log("[vol] expanded →", _expanded) on_AnyHoverChanged: console.log("[vol] anyHover →", _anyHover, "| barHover:", root._hovered, "| panelHover:", hoverPanel.panelHovered) onVolumeChanged: _flashPanel() onMutedChanged: _flashPanel() function _flashPanel() { _osdActive = true; _osdTimer.restart(); } Timer { id: _osdTimer interval: 1500 onTriggered: root._osdActive = false } on_AnyHoverChanged: { if (_anyHover) _collapseTimer.stop(); else if (_expanded) _collapseTimer.start(); } Timer { id: _collapseTimer interval: 1500 onTriggered: root._expanded = false } M.BarIcon { icon: root._volumeIcon minIcon: "\uF028" color: root._volumeColor anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: if (root.sink?.audio) root.sink.audio.muted = !root.sink.audio.muted } } M.BarLabel { label: Math.round(root.volume * 100) + "%" minText: "100%" color: root._volumeColor anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: if (root.sink?.audio) root.sink.audio.muted = !root.sink.audio.muted } } WheelHandler { onWheel: event => { if (!root.sink?.audio) return; root.sink.audio.volume = Math.max(0, root.sink.audio.volume + (event.angleDelta.y > 0 ? 0.05 : -0.05)); } } // Unified volume panel — hover shows slider, click expands to show devices M.HoverPanel { id: hoverPanel showPanel: root._showPanel screen: QsWindow.window?.screen ?? null anchorItem: root accentColor: root.accentColor panelNamespace: "nova-volume" contentWidth: 220 // Compact: slider row Item { width: parent.width height: 36 // Mute toggle Text { id: muteIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: root._volumeIcon color: root._volumeColor font.pixelSize: M.Theme.fontSize + 2 font.family: M.Theme.iconFontFamily TapHandler { cursorShape: Qt.PointingHandCursor onTapped: if (root.sink?.audio) root.sink.audio.muted = !root.sink.audio.muted } } // Slider Item { id: slider anchors.left: muteIcon.right anchors.leftMargin: 8 anchors.right: volLabel.left anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter height: 6 Rectangle { anchors.fill: parent color: M.Theme.base02 radius: 3 } Rectangle { width: parent.width * Math.min(1, Math.max(0, root.volume)) height: parent.height color: root._volumeColor radius: 3 Behavior on width { NumberAnimation { duration: 80 } } } MouseArea { anchors.fill: parent anchors.margins: -6 cursorShape: Qt.PointingHandCursor onPressed: mouse => _setVol(mouse) onPositionChanged: mouse => { if (pressed) _setVol(mouse); } function _setVol(mouse) { if (!root.sink?.audio) return; root.sink.audio.volume = Math.max(0, Math.min(1, mouse.x / slider.width)); } } } Text { id: volLabel anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: Math.round(root.volume * 100) + "%" color: root.muted ? M.Theme.base04 : M.Theme.base05 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily width: 30 } } // Sink name — click to expand/collapse device list Item { width: parent.width height: 22 Text { anchors.left: parent.left anchors.right: chevron.left anchors.leftMargin: 12 anchors.rightMargin: 4 anchors.verticalCenter: parent.verticalCenter text: root.sink?.description ?? root.sink?.name ?? "" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily elide: Text.ElideRight } Text { id: chevron anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: root._expanded ? "\uF077" : "\uF078" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 3 font.family: M.Theme.iconFontFamily } TapHandler { cursorShape: Qt.PointingHandCursor onTapped: root._expanded = true } } // Expanded: output device list Column { id: deviceList width: parent.width visible: root._expanded clip: true property real _targetHeight: root._expanded ? implicitHeight : 0 height: _targetHeight Behavior on height { NumberAnimation { duration: 200 easing.type: Easing.OutCubic } } // Separator Rectangle { width: parent.width - 16 height: 1 anchors.horizontalCenter: parent.horizontalCenter color: M.Theme.base03 } // Header Text { width: parent.width height: 24 verticalAlignment: Text.AlignVCenter leftPadding: 12 text: "Output Devices" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily } Repeater { model: root._sinkList delegate: Item { required property var modelData width: deviceList.width height: 28 readonly property bool _active: modelData === root.sink Rectangle { anchors.fill: parent anchors.leftMargin: 4 anchors.rightMargin: 4 color: deviceHover.hovered ? M.Theme.base02 : "transparent" radius: M.Theme.radius } Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: modelData.description || modelData.name || "Unknown" color: parent._active ? root.accentColor : M.Theme.base05 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily font.bold: parent._active elide: Text.ElideRight } HoverHandler { id: deviceHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: Pipewire.preferredDefaultAudioSink = modelData } } } // Streams header (only if there are streams) Item { visible: root._streamList.length > 0 width: parent.width height: visible ? streamSep.height + streamHeader.height : 0 Rectangle { id: streamSep width: parent.width - 16 height: 1 anchors.horizontalCenter: parent.horizontalCenter color: M.Theme.base03 } Text { id: streamHeader anchors.top: streamSep.bottom width: parent.width height: 24 verticalAlignment: Text.AlignVCenter leftPadding: 12 text: "Applications" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily } } Repeater { model: root._streamList delegate: Item { id: streamEntry required property var modelData width: deviceList.width height: 32 readonly property string _appName: modelData.properties["application.name"] || modelData.description || modelData.name || "Unknown" readonly property real _vol: modelData.audio?.volume ?? 0 readonly property bool _muted: modelData.audio?.muted ?? false Text { id: streamIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: streamEntry._muted ? "\uF026" : "\uF028" color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05 font.pixelSize: M.Theme.fontSize font.family: M.Theme.iconFontFamily TapHandler { cursorShape: Qt.PointingHandCursor onTapped: if (streamEntry.modelData.audio) streamEntry.modelData.audio.muted = !streamEntry.modelData.audio.muted } } Text { id: streamName anchors.left: streamIcon.right anchors.leftMargin: 6 anchors.verticalCenter: parent.verticalCenter text: streamEntry._appName color: M.Theme.base05 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily elide: Text.ElideRight width: 70 } Item { id: streamSlider anchors.left: streamName.right anchors.leftMargin: 6 anchors.right: streamVol.left anchors.rightMargin: 6 anchors.verticalCenter: parent.verticalCenter height: 4 Rectangle { anchors.fill: parent color: M.Theme.base02 radius: 2 } Rectangle { width: parent.width * Math.min(1, Math.max(0, streamEntry._vol)) height: parent.height color: streamEntry._muted ? M.Theme.base04 : root.accentColor radius: 2 } MouseArea { anchors.fill: parent anchors.margins: -6 cursorShape: Qt.PointingHandCursor onPressed: mouse => _set(mouse) onPositionChanged: mouse => { if (pressed) _set(mouse); } function _set(mouse) { if (!streamEntry.modelData.audio) return; streamEntry.modelData.audio.volume = Math.max(0, Math.min(1, mouse.x / streamSlider.width)); } } } Text { id: streamVol anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: Math.round(streamEntry._vol * 100) + "%" color: streamEntry._muted ? M.Theme.base04 : M.Theme.base05 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily width: 28 } } } // Bottom padding Item { width: 1 height: 4 } } } }