From 5dc966c9165ce927b2dc7e1d51e338605da95f9e Mon Sep 17 00:00:00 2001 From: Damocles Date: Sun, 12 Apr 2026 18:03:12 +0200 Subject: [PATCH] merge volume osd/tooltip into interactive hover panel with device switcher --- modules/Volume.qml | 275 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 266 insertions(+), 9 deletions(-) diff --git a/modules/Volume.qml b/modules/Volume.qml index 8ac631d..c631191 100644 --- a/modules/Volume.qml +++ b/modules/Volume.qml @@ -1,12 +1,14 @@ import QtQuick import Quickshell +import Quickshell.Wayland import Quickshell.Services.Pipewire import "." as M M.BarSection { id: root spacing: M.Theme.moduleSpacing - tooltip: (root.sink?.description ?? root.sink?.name ?? "Unknown sink") + "\nVolume: " + Math.round(root.volume * 100) + "%" + (root.muted ? "\nMuted" : "") + // No tooltip — the panel replaces it + tooltip: "" PwObjectTracker { objects: [Pipewire.defaultAudioSink] @@ -16,12 +18,9 @@ M.BarSection { readonly property real volume: sink?.audio?.volume ?? 0 readonly property bool muted: sink?.audio?.muted ?? false - function _showOsd() { - const ico = root.muted ? "\uF026" : (volume > 0.5 ? "\uF028" : "\uF027"); - M.OsdState.show(volume, ico, root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0), QsWindow.window?.screen ?? null); - } - onVolumeChanged: _showOsd() - onMutedChanged: _showOsd() + property bool _expanded: false + property bool _panelHovered: false + readonly property bool _showPanel: root._hovered || _panelHovered || _expanded M.BarIcon { icon: root.muted ? "\uF026" : (root.volume > 0.5 ? "\uF028" : (root.volume > 0 ? "\uF027" : "\uF026")) @@ -37,8 +36,7 @@ M.BarSection { TapHandler { cursorShape: Qt.PointingHandCursor - onTapped: if (root.sink?.audio) - root.sink.audio.muted = !root.sink.audio.muted + onTapped: root._expanded = !root._expanded } WheelHandler { @@ -48,4 +46,263 @@ M.BarSection { 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 + PanelWindow { + id: panel + + screen: QsWindow.window?.screen ?? null + visible: _winVisible + color: "transparent" + + property bool _winVisible: false + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusiveZone: 0 + WlrLayershell.namespace: "nova-volume" + + anchors.top: true + anchors.left: true + + margins.top: 0 + margins.left: Math.max(0, Math.min( + Math.round(root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) - implicitWidth / 2), + (panel.screen?.width ?? 1920) - implicitWidth + )) + + implicitWidth: panelContent.width + implicitHeight: panelContent.height + + // Show/hide logic + Connections { + target: root + function on_ShowPanelChanged() { + if (root._showPanel) { + panel._winVisible = true; + hideAnim.stop(); + showAnim.start(); + } else { + root._expanded = false; + showAnim.stop(); + hideAnim.start(); + } + } + } + + ParallelAnimation { + id: showAnim + NumberAnimation { target: panelContent; property: "opacity"; to: 1; duration: 120; easing.type: Easing.OutCubic } + NumberAnimation { target: panelContent; property: "y"; to: 0; duration: 150; easing.type: Easing.OutCubic } + } + + ParallelAnimation { + id: hideAnim + NumberAnimation { target: panelContent; property: "opacity"; to: 0; duration: 150; easing.type: Easing.InCubic } + NumberAnimation { target: panelContent; property: "y"; to: -panelContent.height; duration: 150; easing.type: Easing.InCubic } + onFinished: panel._winVisible = false + } + + // Keep panel open when mouse is over it + MouseArea { + anchors.fill: parent + hoverEnabled: true + onContainsMouseChanged: root._panelHovered = containsMouse + // Click inside panel doesn't dismiss + } + + Column { + id: panelContent + width: 220 + opacity: 0 + y: -height + + // Background + Rectangle { + width: parent.width + height: parent.height + color: M.Theme.base01 + opacity: Math.max(M.Theme.barOpacity, 0.85) + topLeftRadius: 0 + topRightRadius: 0 + bottomLeftRadius: M.Theme.radius + bottomRightRadius: M.Theme.radius + z: -1 + } + + // 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.muted ? "\uF026" : (root.volume > 0.5 ? "\uF028" : "\uF027") + color: root.muted ? M.Theme.base04 : M.Theme.base0E + font.pixelSize: M.Theme.fontSize + 2 + font.family: M.Theme.iconFontFamily + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: 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.muted ? M.Theme.base04 : M.Theme.base0E + 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 + Text { + width: parent.width + height: 18 + horizontalAlignment: Text.AlignHCenter + 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 + leftPadding: 12 + rightPadding: 12 + } + + // 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: { + const sinks = []; + if (Pipewire.nodes) { + for (const node of Pipewire.nodes.values) { + if (!node.isStream && node.isSink) + sinks.push(node); + } + } + return sinks; + } + + 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: deviceArea.containsMouse ? 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 ? M.Theme.base0E : M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: parent._active + elide: Text.ElideRight + } + + MouseArea { + id: deviceArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Pipewire.preferredDefaultAudioSink = modelData + } + } + } + + // Bottom padding + Item { width: 1; height: 4 } + } + } + } }