diff options
Diffstat (limited to 'volume-osd')
| -rw-r--r-- | volume-osd/README.md | 92 | ||||
| -rw-r--r-- | volume-osd/Theme.qml | 44 | ||||
| -rw-r--r-- | volume-osd/VolumeOsd.qml | 162 | ||||
| -rw-r--r-- | volume-osd/shell.qml | 16 |
4 files changed, 314 insertions, 0 deletions
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. <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. + +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. <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 } } + } + } + } + } + } + } +} 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. <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 + +ShellRoot { + VolumeOsd {} +} |
