aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 16:46:20 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 16:46:20 +0200
commit4c4ed4e3ce3dd25395866cb0bb71a8187f359dbe (patch)
treeb34dc7585d31f2b8002b3d8edf4af9117e9f31f8
parent4f01824832da8455a40446dc2ee949545f470ec5 (diff)
downloadquickshell-4c4ed4e3ce3dd25395866cb0bb71a8187f359dbe.tar.gz
quickshell-4c4ed4e3ce3dd25395866cb0bb71a8187f359dbe.zip
feat(volume-osd): show what is playing
The OSD gains a track row above the volume bar when an MPRIS player is running: album art, title, artist, and prev/play/next. With no player it is the volume OSD it was, at the same size. A track change or a play/pause shows the panel as well, so the row is not something you only see by happening to touch the volume. Hovering freezes the fade so the buttons can be clicked and leaving restarts it, which keeps the passive behaviour intact for anyone not reaching for the mouse. The panel still takes no keyboard focus. playerctld proxies whichever player is active and republishes it under its own bus name, so every player is enumerated twice. Player.qml drops that name rather than deduplicating by identity, and controls the real player, which works whether or not playerctld is running. Album art is loaded with cache off and a per-track suffix on the URL. Audacious extracts embedded art into one temp file in its cache and rewrites that same path for each track, so the URL repeats while the image changes, and a cached Image would keep showing the last cover. Theme gains overlay, used by the art placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
-rw-r--r--volume-osd/Player.qml48
-rw-r--r--volume-osd/README.md42
-rw-r--r--volume-osd/Theme.qml2
-rw-r--r--volume-osd/TransportButton.qml38
-rw-r--r--volume-osd/VolumeOsd.qml135
5 files changed, 257 insertions, 8 deletions
diff --git a/volume-osd/Player.qml b/volume-osd/Player.qml
new file mode 100644
index 0000000..73f86af
--- /dev/null
+++ b/volume-osd/Player.qml
@@ -0,0 +1,48 @@
+// 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
+
+ // playerctld proxies whatever is playing and publishes it under its own
+ // name, so every real player shows up twice. Its own entry is skipped:
+ // controlling the player directly works whether or not playerctld is up.
+ readonly property var real:
+ Mpris.players.values.filter(p => !p.dbusName.endsWith(".playerctld"))
+
+ // 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
index f651c1d..d99140f 100644
--- a/volume-osd/README.md
+++ b/volume-osd/README.md
@@ -4,10 +4,15 @@ 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% │
- │ ████████████████░░░░░░░░ │
- └──────────────────────────────────┘
+ ┌────────────────────────────────────┐
+ │ ▪ Outside World ⏮ ⏸ ⏭ │
+ │ Sunbeam │
+ │ ──────────────────────────────── │
+ │ 🔊 Output 75% │
+ │ ████████████████░░░░░░░░░░ │
+ └────────────────────────────────────┘
+
+ (the track row only exists while a player does)
## Running it
@@ -17,6 +22,29 @@ 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:
+
+**playerctld publishes a duplicate.** It proxies whichever player is active
+and republishes the metadata under `org.mpris.MediaPlayer2.playerctld`, so
+every player appears twice. `Player.qml` drops that name and talks to the real
+player, which works whether or not playerctld is running.
+
+**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.
+
## No keybinds to change
The OSD watches PipeWire rather than being triggered by a hotkey, so existing
@@ -90,6 +118,12 @@ 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 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
diff --git a/volume-osd/Theme.qml b/volume-osd/Theme.qml
index 21e6c1a..bee17b4 100644
--- a/volume-osd/Theme.qml
+++ b/volume-osd/Theme.qml
@@ -30,6 +30,7 @@ Singleton {
property color text: "#cad3f5"
property color subtext: "#a5adcb"
property color red: "#ed8796"
+ property color overlay: "#6e738d"
property color accent: "#b7bdf8"
readonly property string fontFamily: "Noto Sans"
@@ -52,6 +53,7 @@ Singleton {
root.text = pick("text") ?? root.text;
root.subtext = pick("subtext0") ?? root.subtext;
root.red = pick("red") ?? root.red;
+ root.overlay = pick("overlay0") ?? root.overlay;
root.accent = pick("accent") ?? root.accent;
}
}
diff --git a/volume-osd/TransportButton.qml b/volume-osd/TransportButton.qml
new file mode 100644
index 0000000..07753b6
--- /dev/null
+++ b/volume-osd/TransportButton.qml
@@ -0,0 +1,38 @@
+// 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
index 9ee21ff..ab7eb62 100644
--- a/volume-osd/VolumeOsd.qml
+++ b/volume-osd/VolumeOsd.qml
@@ -47,14 +47,36 @@ Scope {
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
}
@@ -72,15 +94,16 @@ Scope {
anchors.bottom: true
margins.bottom: 120
+ // Grows to fit the track row; the volume-only size is unchanged.
implicitWidth: 360
- implicitHeight: 72
+ implicitHeight: Player.active ? 150 : 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.
+ // 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 {
@@ -93,9 +116,31 @@ Scope {
border.width: 1
border.color: Qt.alpha(Theme.text, 0.12)
- Row {
+ // 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 {
@@ -157,6 +202,88 @@ Scope {
}
}
}
+ }
+ }
+ }
+
+ 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()
+ }
+ }
}
}
}