From 18fd64134f51ec99b123c91f79790c12ad5e1bb4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 11 Sep 2026 15:48:27 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7 --- volume-osd/README.md | 92 +++++++++++++++++++++++++++ volume-osd/Theme.qml | 44 +++++++++++++ volume-osd/VolumeOsd.qml | 162 +++++++++++++++++++++++++++++++++++++++++++++++ volume-osd/shell.qml | 16 +++++ 4 files changed, 314 insertions(+) create mode 100644 volume-osd/README.md create mode 100644 volume-osd/Theme.qml create mode 100644 volume-osd/VolumeOsd.qml create mode 100644 volume-osd/shell.qml (limited to 'volume-osd') diff --git a/volume-osd/README.md b/volume-osd/README.md new file mode 100644 index 0000000..3ef0212 --- /dev/null +++ b/volume-osd/README.md @@ -0,0 +1,92 @@ +# volume-osd + +An on-screen display for volume, covering both output (speakers) and input +(microphone). It appears at the bottom of the screen when the level or mute +state changes, and fades out 1.5 seconds later. + + ┌──────────────────────────────────┐ + │ 🔊 Output 75% │ + │ ████████████████░░░░░░░░ │ + └──────────────────────────────────┘ + +## Running it + + qs -p . + +From Hyprland, to start it with the session: + + exec-once = qs -p ~/Programming/GIT/quickshell/volume-osd + +## No keybinds to change + +The OSD watches PipeWire rather than being triggered by a hotkey, so existing +volume binds keep working untouched: + + bind = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ + +Because the source of truth is PipeWire and not the keypress, the OSD also +appears for volume changed from anywhere else: pavucontrol, a per-application +slider, or another machine's remote control. + +One widget serves both directions. Whichever device changed last is the one +displayed, with a speaker icon for output and a microphone for input. + +Icons are Nerd Font glyphs (speaker and microphone), so the font stack needs a +Nerd Font available for fallback. Inconsolata Nerd Font Mono, which +unified-desktop-theme already installs, covers them. + +## Frosted glass + +The panel is drawn translucent (65% over the Macchiato base) and the blur +behind it comes from the compositor, not from QML. Hyprland blurs a layer +surface only when a rule says to, matched on the namespace this window sets +(`quickshell-volume-osd`): + + hl.layer_rule({ + name = "blur-volume-osd", + match = { namespace = "^(quickshell-volume-osd)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) + +`xray = false` frosts the windows actually behind the OSD rather than jumping +straight to the wallpaper. `ignore_alpha = 0.1` leaves near-transparent pixels +unblurred, which keeps the rounded corners from picking up a halo. + +Doing it this way costs nothing in the shell: no `MultiEffect`, no live +blur pass in QML, no offscreen buffer. Without the rule the OSD still works, +it just renders flat translucent instead of frosted. + +## Theme + +`Theme.qml` holds the Catppuccin Macchiato palette. The accent is read from +`~/.cache/wal/udt-accent.rasi`, the file `udt-accent` writes on every wallpaper +change, and is watched, so the OSD recolours without a restart. Lavender +(`#b7bdf8`) is the fallback when that file is absent, which is also what makes +this directory runnable on a machine that has no unified-desktop-theme. + +## Two details worth knowing + +**A node's volume arrives before it is ready.** When a `PwNode` binds, its +volume populates and emits a change signal, and that happens while `ready` is +still false. Those first signals are state being read, not the user turning a +knob, so `show()` checks `ready` and ignores them, which is why there is no +OSD at login. + +That one check is the whole guard, and it is tempting to add a second. An +earlier version also swallowed the first change per node, on the assumption +that the startup values arrived *after* ready. They do not, so the extra guard +ate the user's first keypress instead: the OSD only appeared from the second +change onward. If this symptom comes back, trace the signal order before +adding a filter. + +**`PwObjectTracker` is not optional.** PipeWire node properties are only kept +current while something binds the node. Without the tracker the volume reads +once and then goes stale, which looks like an OSD that displays a number from +several changes ago. + +## Volume above 100% + +PipeWire allows volume over 1.0. The percentage is reported as-is, so it can +read above 100%, while the bar stops at full rather than overflowing its track. diff --git a/volume-osd/Theme.qml b/volume-osd/Theme.qml new file mode 100644 index 0000000..4b9af5c --- /dev/null +++ b/volume-osd/Theme.qml @@ -0,0 +1,44 @@ +// Copyright (C) 2026 Danilo M. +// +// 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. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + // Catppuccin Macchiato. Fixed, same base as unified-desktop-theme. + readonly property color base: "#24273a" + readonly property color surface: "#363a4f" + readonly property color text: "#cad3f5" + readonly property color subtext: "#a5adcb" + readonly property color red: "#ed8796" + + // Tracks the wallpaper, like rofi and dunst do. Lavender until read. + property color accent: "#b7bdf8" + + readonly property string fontFamily: "Noto Sans" + readonly property int fontSize: 16 + + // udt-accent writes one line: `* { accent: #rrggbbaa; }` + FileView { + path: `${Quickshell.env("HOME")}/.cache/wal/udt-accent.rasi` + watchChanges: true + onFileChanged: reload() + onLoaded: { + const m = text().match(/accent:\s*(#[0-9a-fA-F]{6})/); + if (m) root.accent = m[1]; + } + } +} 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. +// +// 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 } } + } + } + } + } + } + } +} diff --git a/volume-osd/shell.qml b/volume-osd/shell.qml new file mode 100644 index 0000000..dbbd96f --- /dev/null +++ b/volume-osd/shell.qml @@ -0,0 +1,16 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 + +ShellRoot { + VolumeOsd {} +} -- cgit v1.2.3