diff options
Diffstat (limited to 'volume-osd')
| -rw-r--r-- | volume-osd/Player.qml | 56 | ||||
| -rw-r--r-- | volume-osd/README.md | 159 | ||||
| l--------- | volume-osd/Theme.qml | 1 | ||||
| -rw-r--r-- | volume-osd/TransportButton.qml | 38 | ||||
| -rw-r--r-- | volume-osd/VolumeOsd.qml | 302 | ||||
| -rw-r--r-- | volume-osd/shell.qml | 16 |
6 files changed, 0 insertions, 572 deletions
diff --git a/volume-osd/Player.qml b/volume-osd/Player.qml deleted file mode 100644 index fab8083..0000000 --- a/volume-osd/Player.qml +++ /dev/null @@ -1,56 +0,0 @@ -// 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.Services.Mpris -import QtQuick - -// Which MPRIS player the OSD should describe. -Singleton { - id: root - - // Two things proxy a real player and republish it under their own name, so - // one track can appear on three buses at once: a playing Navidrome tab - // showed up as firefox.instance_*, as plasma-browser-integration and as - // playerctld. Both proxies are skipped and the OSD talks to the browser's - // own entry, which is safe because none of the three exists until playback - // starts, and the browser's entry appeared whenever the plasma one did. An - // open but silent tab publishes nothing, so there is no row to lose. The - // plasma name is browser-only: Feishin on the same server publishes just - // its own entry and playerctld, so a native player loses nothing here. - readonly property var proxyNames: [".playerctld", ".plasma-browser-integration"] - - readonly property var real: - Mpris.players.values.filter(p => !root.proxyNames.some(n => p.dbusName.endsWith(n))) - - // Prefer something actually playing; otherwise keep the last one seen, so - // pausing does not make the track row vanish mid-look. - readonly property var current: - real.find(p => p.playbackState === MprisPlaybackState.Playing) - ?? real.find(p => p.playbackState === MprisPlaybackState.Paused) - ?? real[0] - ?? null - - readonly property bool active: current !== null - readonly property bool playing: current?.playbackState === MprisPlaybackState.Playing - - readonly property string title: current?.trackTitle ?? "" - readonly property string artist: current?.trackArtist ?? "" - - // Players that extract embedded art write it to a temp file they reuse - // per track, so the path can repeat while the image behind it changes. - // The cache buster makes Image reload instead of showing the last cover. - readonly property string artUrl: - (current?.trackArtUrl ?? "") === "" ? "" - : current.trackArtUrl + "#" + encodeURIComponent(title) -} diff --git a/volume-osd/README.md b/volume-osd/README.md deleted file mode 100644 index f0a8313..0000000 --- a/volume-osd/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# 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. - - ┌────────────────────────────────────┐ - │ ▪ Outside World ⏮ ⏸ ⏭ │ - │ Sunbeam │ - │ ──────────────────────────────── │ - │ 🔊 Output 75% │ - │ ████████████████░░░░░░░░░░ │ - └────────────────────────────────────┘ - - (the track row only exists while a player does) - -## Running it - - qs -p . - -From Hyprland, to start it with the session: - - exec-once = qs -p ~/Programming/GIT/quickshell/volume-osd - -## Now playing - -When an MPRIS player is running, a track row sits above the volume bar: album -art, title, artist, and prev/play/next. With no player the panel is exactly -the volume OSD, at its original size. A track change or a play/pause shows the -panel too, so the row is not something you only see by touching the volume. - -Hovering the panel freezes its fade so the buttons can be clicked; moving away -starts the countdown again. Without a hover it behaves exactly as it did before -there was anything clickable on it. It still takes no keyboard focus. - -Two things about MPRIS that are not obvious: - -**Proxies publish duplicates.** playerctld proxies whichever player is -active and republishes it under `org.mpris.MediaPlayer2.playerctld`. With a -browser, plasma-browser-integration does the same, so one Navidrome tab was -live on three bus names at once. `Player.qml` drops both proxies and talks to -the browser's own entry. None of the three exists until playback starts, and -an open but silent tab publishes nothing at all. The plasma name is a browser -thing only: Feishin, playing from the same server, publishes just its own -entry and playerctld, so dropping it costs a native player nothing. - -**Album art can be a reused temp path.** Audacious extracts embedded art to a -file in its cache and rewrites that same path on each track, so the URL repeats -while the image behind it changes. The source carries the track title as a -cache buster and `cache: false`, or the previous track's cover stays on screen. - -Verified against audacious, Feishin, and Firefox playing Navidrome. Feishin is -the reference case, the only player here that fills the metadata in properly: -a real `mpris:artUrl`, `xesam:artist`, and a clean `xesam:title`. It is also -the only one whose art is a remote HTTP URL rather than a local file, so the -cover depends on reaching the Navidrome host. - -The same server through Firefox gives much less, which is a browser limit and -not something this component can fix. Navidrome's web player sets no -`mpris:artUrl` at all, so the row renders with no cover, and it packs -everything into `xesam:title` ("Roxanne - The Police - Navidrome") leaving -`xesam:artist` empty. Neither is worked around, a title like that cannot be -split back apart without guessing where a real dash ends. - -Signal publishes no MPRIS bus at all: playing an attachment claims no -`org.mpris.MediaPlayer2.*` name, only its tray `StatusNotifierItem`, so the -track row stays hidden and there is nothing here to fix. Any player that never -publishes is invisible to this component by construction. - -## 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 no palette of its own beyond a fallback. The colours come -from `~/.cache/wal/udt-palette.qml`, which `udt-accent` generates on every -wallpaper change from unified-desktop-theme's `palette.rasi`, carrying the -whole Macchiato palette plus the accent snapped from the wallpaper. - -That file is watched, so editing the palette in unified-desktop-theme and -regenerating recolours a running OSD with no restart. Where the file does not -exist, the hardcoded defaults in `Theme.qml` apply, which is what keeps this -directory runnable on a machine without 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. - -**A config with no visible window exits.** The OSD is hidden most of the time, -so it holds itself open with a 1x1 transparent window with an empty mask, -which is click-through and draws nothing. Without it the shell loads, reports -no error, and quits, and the symptom is a keybind that appears to do nothing -or a panel that never paints. This was removed once during development after -misreading a process check, and the bug came straight back. - -**A Row sizes to its children, not to its parent.** The track row's text -column originally had a fixed width, and art + text + buttons + spacing came -to 356px inside a 328px content box, so the `next` button hung over the panel -edge. The column now takes whatever the art and transport buttons leave, which -holds at any panel width. Fixed widths inside a Row are worth distrusting. - -## 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 deleted file mode 120000 index 3d2e40f..0000000 --- a/volume-osd/Theme.qml +++ /dev/null @@ -1 +0,0 @@ -../shared/Theme.qml
\ No newline at end of file diff --git a/volume-osd/TransportButton.qml b/volume-osd/TransportButton.qml deleted file mode 100644 index 07753b6..0000000 --- a/volume-osd/TransportButton.qml +++ /dev/null @@ -1,38 +0,0 @@ -// 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 QtQuick - -Rectangle { - id: btn - property string glyph: "" - property bool enabled: true - signal clicked - - width: 30; height: 30; radius: 15 - color: area.containsMouse && enabled ? Qt.alpha(Theme.accent, 0.22) : "transparent" - opacity: enabled ? 1 : 0.35 - - Text { - anchors.centerIn: parent - text: btn.glyph - font { family: Theme.fontFamily; pixelSize: 14 } - color: Theme.text - } - - MouseArea { - id: area - anchors.fill: parent - hoverEnabled: true - cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (btn.enabled) btn.clicked() - } -} diff --git a/volume-osd/VolumeOsd.qml b/volume-osd/VolumeOsd.qml deleted file mode 100644 index f739aec..0000000 --- a/volume-osd/VolumeOsd.qml +++ /dev/null @@ -1,302 +0,0 @@ -// 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); } - } - - // A track change shows the OSD as well, so the row is not something you - // only see when you happen to touch the volume. - Connections { - target: Player.current ?? null - function onTrackTitleChanged() { if (Player.title) root.showTrack(); } - function onPlaybackStateChanged() { root.showTrack(); } - } - - Connections { - target: root.source?.audio ?? null - function onVolumeChanged() { root.show(root.source, true); } - function onMutedChanged() { root.show(root.source, true); } - } - - function showTrack() { - if (!Player.active) return; - root.active = root.sink; - root.isInput = false; - hideTimer.restart(); - } - - // Hovering freezes the countdown so the transport buttons can be clicked; - // leaving starts it again. Without a hover the OSD behaves exactly as it - // did before there was anything clickable on it. - property bool hovered: false - - // Only counts down while the OSD is up and the pointer is elsewhere. - Timer { - id: hideTimer - running: root.active !== null && !root.hovered - interval: root.timeout - onTriggered: root.active = null - } - - // Quickshell exits once no window is visible, and the OSD is hidden most - // of the time, so a 1x1 transparent window holds the process open. Its - // empty mask makes it click-through, so it cannot catch a stray click. - PanelWindow { - visible: true - implicitWidth: 1 - implicitHeight: 1 - color: "transparent" - exclusionMode: ExclusionMode.Ignore - mask: Region {} - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - } - - 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 - - // Grows to fit the track row; the volume-only size is unchanged. - implicitWidth: 360 - implicitHeight: Player.active ? 150 : 72 - color: "transparent" - - exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "quickshell-volume-osd" - // Still no keyboard focus: the transport buttons are pointer targets, - // and the OSD must never take keys from the window being typed 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) - - // Tracks the pointer over the whole panel so the fade can pause. - HoverHandler { - onHoveredChanged: root.hovered = hovered - } - - Column { - anchors.fill: parent - anchors.margins: 16 - spacing: 12 - - Loader { - active: Player.active - width: parent.width - sourceComponent: trackRow - } - - Rectangle { - visible: Player.active - width: parent.width - height: 1 - color: Qt.alpha(Theme.text, 0.1) - } - - Row { - width: parent.width - 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 } } - } - } - } - } - } - } - } - - Component { - id: trackRow - - Row { - id: trackLine - // A Row sizes to its children, so the panel width has to be - // pushed in: the text column below subtracts from it. - width: parent ? parent.width : 0 - spacing: 12 - - // Players that extract embedded art reuse one temp path, so the - // source carries a per-track suffix and caching is off. - Rectangle { - width: 46; height: 46; radius: 6 - color: Qt.alpha(Theme.surface, 0.8) - clip: true - - Image { - anchors.fill: parent - source: Player.artUrl - cache: false - asynchronous: true - fillMode: Image.PreserveAspectCrop - visible: status === Image.Ready - } - Text { - anchors.centerIn: parent - visible: Player.artUrl === "" || parent.children[0].status !== Image.Ready - text: "" - font { family: Theme.fontFamily; pixelSize: 20 } - color: Theme.overlay - } - } - - Column { - anchors.verticalCenter: parent.verticalCenter - // Whatever the art and transport buttons leave: a fixed width - // here overflowed the panel and pushed `next` past its edge. - width: trackLine.width - 46 - transport.width - 2 * trackLine.spacing - spacing: 3 - - Text { - width: parent.width - elide: Text.ElideRight - text: Player.title || "Nothing playing" - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } - color: Theme.text - } - Text { - width: parent.width - elide: Text.ElideRight - visible: Player.artist !== "" - text: Player.artist - font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } - color: Theme.subtext - } - } - - Row { - id: transport - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - TransportButton { - glyph: "" - enabled: Player.current?.canGoPrevious ?? false - onClicked: Player.current?.previous() - } - TransportButton { - glyph: Player.playing ? "" : "" - enabled: Player.current?.canTogglePlaying ?? false - onClicked: Player.current?.togglePlaying() - } - TransportButton { - glyph: "" - enabled: Player.current?.canGoNext ?? false - onClicked: Player.current?.next() - } - } - } - } -} diff --git a/volume-osd/shell.qml b/volume-osd/shell.qml deleted file mode 100644 index dbbd96f..0000000 --- a/volume-osd/shell.qml +++ /dev/null @@ -1,16 +0,0 @@ -// 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 {} -} |
