aboutsummaryrefslogtreecommitdiffstats
path: root/volume-osd
diff options
context:
space:
mode:
Diffstat (limited to 'volume-osd')
-rw-r--r--volume-osd/Player.qml56
-rw-r--r--volume-osd/README.md159
l---------volume-osd/Theme.qml1
-rw-r--r--volume-osd/TransportButton.qml38
-rw-r--r--volume-osd/VolumeOsd.qml302
-rw-r--r--volume-osd/shell.qml16
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 {}
-}