From 9a7cf1242d296dbdb9c03df48ab09054960295aa Mon Sep 17 00:00:00 2001 From: Leander Scherer Date: Mon, 18 May 2026 21:48:24 +0200 Subject: feat(quickshell): basic bar, tray, notification --- modules/system/quickshell/Volume.qml | 206 +++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 modules/system/quickshell/Volume.qml (limited to 'modules/system/quickshell/Volume.qml') diff --git a/modules/system/quickshell/Volume.qml b/modules/system/quickshell/Volume.qml new file mode 100644 index 0000000..c4d1625 --- /dev/null +++ b/modules/system/quickshell/Volume.qml @@ -0,0 +1,206 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Services.Pipewire + +Item { + id: root + width: childrenRect.width + height: parent.height + + // Properties bound directly to the Pipewire service + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property bool muted: sink && sink.audio ? sink.audio.muted : true + readonly property real volume: sink && sink.audio ? sink.audio.volume : 0 + + // Pipewire nodes only emit property updates while tracked. + PwObjectTracker { + objects: root.sink ? [root.sink] : [] + } + + function setVolume(value) { + if (sink?.ready && sink?.audio) { + sink.audio.muted = false; + sink.audio.volume = value; + } else { + // Fallback for unbound nodes + Quickshell.execDetached(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", value.toFixed(2)]); + } + } + + function toggleMute() { + if (sink?.ready && sink?.audio) { + sink.audio.muted = !sink.audio.muted; + } else { + Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]); + } + } + + function getDeviceIcon(node) { + return node?.properties?.["device.icon-name"] ?? "audio-card" + } + + Row { + id: triggerRow + anchors { + verticalCenter: parent.verticalCenter + } + spacing: 4 + + Image { + width: 20 + height: 20 + source: Quickshell.iconPath(root.getDeviceIcon(root.sink) + "-symbolic") + sourceSize: Qt.size(width, height) + smooth: true + mipmap: true + opacity: root.muted ? 0.5 : 1.0 + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + GlobalState.toggle("Volume") + } else if (mouse.button === Qt.RightButton) { + root.toggleMute() + } + } + onWheel: wheel => { + const step = 0.05 + const next = wheel.angleDelta.y > 0 ? root.volume + step : root.volume - step + root.setVolume(Math.max(0.0, Math.min(1.0, next))) + } + } + + PopupWindow { + id: popup + visible: GlobalState.activePopup === "Volume" + grabFocus: true + implicitWidth: bgRect.width + implicitHeight: bgRect.height + + anchor { + window: barWindow + item: root + edges: Edges.Bottom + gravity: Edges.Bottom + margins.top: Theme.popupGap + } + + color: "transparent" + + onVisibleChanged: { + if (visible) anchor.updateAnchor() + } + + Squircle { + id: bgRect + width: 260 + height: contentCol.height + 24 + fillColor: Theme.bg + strokeColor: Theme.border + strokeWidth: 1 + cornerRadius: 8 + + Column { + id: contentCol + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 12 + } + spacing: 12 + + Text { + text: "Sound" + color: Theme.text + font { + family: Theme.mainFont + pixelSize: 14 + weight: Font.DemiBold + } + } + + PillSlider { + id: volumeSlider + width: parent.width + value: root.volume + onMoved: root.setVolume(value) + + Binding { + target: volumeSlider + property: "value" + value: root.volume + when: !volumeSlider.pressed + } + } + + Rectangle { width: parent.width; height: 1; color: Theme.border } + + Text { + text: "Output Devices" + color: Theme.textMuted + font { + family: Theme.mainFont + pixelSize: 12 + weight: Font.DemiBold + } + padding: 4 + } + + Repeater { + model: Pipewire.nodes + delegate: Item { + width: parent.width + property bool isOutput: modelData && modelData.isSink && !modelData.isStream && modelData.name !== "Dummy-Driver" + visible: isOutput + height: isOutput ? 40 : 0 + + Squircle { + anchors.fill: parent + fillColor: mouseArea.containsMouse ? Theme.surfaceLighter : Theme.transparent + cornerRadius: 6 + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 12 + + IconCircle { + size: 24 + source: root.getDeviceIcon(modelData) + active: root.sink && root.sink.id === modelData.id + } + + Text { + Layout.fillWidth: true + text: modelData.description || modelData.nickname || modelData.name + color: (root.sink && root.sink.id === modelData.id) ? Theme.text : Theme.textDim + font { + family: Theme.mainFont + pixelSize: 13 + } + elide: Text.ElideRight + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + Quickshell.execDetached(["wpctl", "set-default", modelData.id.toString()]) + } + } + } + } + } + } + } + } +} -- cgit v1.3.1