aboutsummaryrefslogtreecommitdiffstats
path: root/volume-osd/VolumeOsd.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 15:48:27 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 15:48:27 +0200
commit18fd64134f51ec99b123c91f79790c12ad5e1bb4 (patch)
tree32110d27fe7c944b31535a70a01fa152bd9014da /volume-osd/VolumeOsd.qml
downloadquickshell-18fd64134f51ec99b123c91f79790c12ad5e1bb4.tar.gz
quickshell-18fd64134f51ec99b123c91f79790c12ad5e1bb4.zip
feat: quickshell repo with volume OSD
Scaffold the repo that will hold quickshell implementations, each in its own directory with its own README, and add the first one. volume-osd shows an on-screen display for output and input volume. It watches PipeWire through Quickshell's native service rather than polling wpctl, so it appears for any volume change, not only for the keybinds: pavucontrol, a per-application slider, or a phone acting as a remote. One widget serves both directions, showing whichever device moved last. Colours follow unified-desktop-theme: Catppuccin Macchiato fixed, with the accent read from the file udt-accent already writes and watched, so it tracks the wallpaper. Lavender is the fallback, which keeps the directory runnable where that file does not exist. The frosting is the compositor's: the panel draws translucent and Hyprland blurs behind it via a layerrule matched on the window's namespace. That avoids a MultiEffect and an offscreen blur pass in QML, and degrades to flat translucent without the rule. Two details are documented in the component README because both were wrong before they were right: PwObjectTracker is required or the volume reads stale, and a node reports its initial volume before `ready` goes true, so the ready check alone suppresses the startup values. An extra guard on top of it ate the user's first keypress instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
Diffstat (limited to 'volume-osd/VolumeOsd.qml')
-rw-r--r--volume-osd/VolumeOsd.qml162
1 files changed, 162 insertions, 0 deletions
diff --git a/volume-osd/VolumeOsd.qml b/volume-osd/VolumeOsd.qml
new file mode 100644
index 0000000..9ee21ff
--- /dev/null
+++ b/volume-osd/VolumeOsd.qml
@@ -0,0 +1,162 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Pipewire
+import QtQuick
+
+Scope {
+ id: root
+
+ // Milliseconds the OSD stays up after the last change.
+ property int timeout: 1500
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ // Which one to draw: set by whichever node changed last.
+ property PwNode active: null
+ property bool isInput: false
+
+ // Keeping the nodes bound is what makes volume/muted actually update.
+ PwObjectTracker { objects: [root.sink, root.source].filter(n => n !== null) }
+
+ // A node reports its initial volume while binding, before `ready` goes
+ // true, so the `ready` check alone suppresses the startup values. Nothing
+ // else may be swallowed: the next signal after that is the user's first
+ // keypress, and eating it costs the OSD its first appearance.
+ function show(node, input) {
+ if (!node?.ready || !node.audio) return;
+ root.active = node;
+ root.isInput = input;
+ hideTimer.restart();
+ }
+
+ Connections {
+ target: root.sink?.audio ?? null
+ function onVolumeChanged() { root.show(root.sink, false); }
+ function onMutedChanged() { root.show(root.sink, false); }
+ }
+
+ Connections {
+ target: root.source?.audio ?? null
+ function onVolumeChanged() { root.show(root.source, true); }
+ function onMutedChanged() { root.show(root.source, true); }
+ }
+
+ Timer {
+ id: hideTimer
+ interval: root.timeout
+ onTriggered: root.active = null
+ }
+
+ PanelWindow {
+ id: win
+
+ visible: root.active !== null
+
+ readonly property PwNode node: root.active
+ readonly property real volume: node?.audio?.volume ?? 0
+ readonly property bool muted: node?.audio?.muted ?? false
+
+ // Bottom centre. Move the anchor to relocate.
+ anchors.bottom: true
+ margins.bottom: 120
+
+ implicitWidth: 360
+ implicitHeight: 72
+ color: "transparent"
+
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-volume-osd"
+ // No keyboard focus: the OSD must never steal input from the window
+ // the user is typing in.
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 12
+ // Translucent so the compositor's blur shows through. The frosting
+ // itself is Hyprland's, applied by layerrule to this window's
+ // namespace: see the README.
+ color: Qt.alpha(Theme.base, 0.65)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ Row {
+ anchors.fill: parent
+ anchors.margins: 16
+ spacing: 14
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 30
+ horizontalAlignment: Text.AlignHCenter
+ font.family: Theme.fontFamily
+ font.pixelSize: 24
+ color: win.muted ? Theme.red : Theme.accent
+ text: {
+ if (root.isInput) return win.muted ? "\uf131" : "\uf130";
+ if (win.muted || win.volume <= 0) return "\uf026";
+ return win.volume < 0.5 ? "\uf027" : "\uf028";
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 30 - parent.spacing
+ spacing: 8
+
+ Item {
+ width: parent.width
+ height: label.implicitHeight
+
+ Text {
+ id: label
+ anchors.left: parent.left
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ color: Theme.subtext
+ text: root.isInput ? "Input" : "Output"
+ }
+
+ Text {
+ anchors.right: parent.right
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ color: Theme.text
+ text: win.muted ? "muted" : Math.round(win.volume * 100) + "%"
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 6
+ radius: 3
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ // Volume can exceed 1.0; the bar stops at full.
+ width: parent.width * Math.min(win.volume, 1)
+ color: win.muted ? Theme.red : Theme.accent
+ opacity: win.muted ? 0.5 : 1
+ Behavior on width { NumberAnimation { duration: 100 } }
+ }
+ }
+ }
+ }
+ }
+ }
+}