aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sound
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/sound')
-rw-r--r--desktop/modules/sound/Osd.qml254
-rw-r--r--desktop/modules/sound/Player.qml56
-rw-r--r--desktop/modules/sound/README.md159
-rw-r--r--desktop/modules/sound/Service.qml75
-rw-r--r--desktop/modules/sound/SoundModule.qml40
-rw-r--r--desktop/modules/sound/SoundPage.qml140
-rw-r--r--desktop/modules/sound/SoundTile.qml27
-rw-r--r--desktop/modules/sound/TransportButton.qml39
8 files changed, 790 insertions, 0 deletions
diff --git a/desktop/modules/sound/Osd.qml b/desktop/modules/sound/Osd.qml
new file mode 100644
index 0000000..677d211
--- /dev/null
+++ b/desktop/modules/sound/Osd.qml
@@ -0,0 +1,254 @@
+// 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
+import "../.."
+
+// The transient on-screen display. Unchanged in behaviour from volume-osd,
+// including its namespace, so the existing Hyprland blur rule still matches.
+Scope {
+ id: root
+
+ required property var service
+
+ // Milliseconds the OSD stays up after the last change.
+ property int timeout: 1500
+
+ property bool visibleNow: false
+
+ // Hovering freezes the countdown so the transport buttons can be clicked;
+ // leaving starts it again.
+ property bool hovered: false
+
+ Connections {
+ target: root.service
+ function onChanged() {
+ root.visibleNow = true;
+ hideTimer.restart();
+ }
+ }
+
+ Timer {
+ id: hideTimer
+ running: root.visibleNow && !root.hovered
+ interval: root.timeout
+ onTriggered: root.visibleNow = false
+ }
+
+ PanelWindow {
+ id: win
+
+ visible: root.visibleNow
+
+ readonly property PwNode node: root.service.active
+ readonly property real volume: node?.audio?.volume ?? 0
+ readonly property bool muted: node?.audio?.muted ?? false
+ readonly property bool isInput: root.service.isInput
+
+ // 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)
+
+ 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 (win.isInput) return win.muted ? "" : "";
+ if (win.muted || win.volume <= 0) return "";
+ return win.volume < 0.5 ? "" : "";
+ }
+ }
+
+ 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: win.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/desktop/modules/sound/Player.qml b/desktop/modules/sound/Player.qml
new file mode 100644
index 0000000..fab8083
--- /dev/null
+++ b/desktop/modules/sound/Player.qml
@@ -0,0 +1,56 @@
+// 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/desktop/modules/sound/README.md b/desktop/modules/sound/README.md
new file mode 100644
index 0000000..f0a8313
--- /dev/null
+++ b/desktop/modules/sound/README.md
@@ -0,0 +1,159 @@
+# 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/desktop/modules/sound/Service.qml b/desktop/modules/sound/Service.qml
new file mode 100644
index 0000000..e509a94
--- /dev/null
+++ b/desktop/modules/sound/Service.qml
@@ -0,0 +1,75 @@
+// 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.Services.Pipewire
+import QtQuick
+
+// The PipeWire half of what used to be VolumeOsd.qml. Always active: the OSD
+// has to react to a volume keypress with no drawer open.
+Scope {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ readonly property real volume: sink?.audio?.volume ?? 0
+ readonly property bool muted: sink?.audio?.muted ?? false
+
+ // Which node changed last, and whether it was the input. The OSD draws
+ // this one; null means nothing to show.
+ property PwNode active: null
+ property bool isInput: false
+
+ // Keeping the nodes bound is what makes volume/muted actually update.
+ // Without the tracker the value reads once and goes stale.
+ PwObjectTracker { objects: [root.sink, root.source].filter(n => n !== null) }
+
+ signal changed()
+
+ // 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;
+ root.changed();
+ }
+
+ function showTrack() {
+ if (!Player.active) return;
+ root.active = root.sink;
+ root.isInput = false;
+ root.changed();
+ }
+
+ 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); }
+ }
+
+ // 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(); }
+ }
+}
diff --git a/desktop/modules/sound/SoundModule.qml b/desktop/modules/sound/SoundModule.qml
new file mode 100644
index 0000000..420414c
--- /dev/null
+++ b/desktop/modules/sound/SoundModule.qml
@@ -0,0 +1,40 @@
+// 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
+import "../.."
+
+// Always active: the OSD must react to a volume keypress with no drawer open,
+// which is the whole reason this module's service cannot be lazy.
+Module {
+ id: mod
+
+ name: "sound"
+ icon: ""
+ label: "Sound"
+ alwaysActive: true
+
+ readonly property Service service: Service {}
+
+ // The OSD is this module's own window, outside the drawer entirely.
+ readonly property Osd osd: Osd { service: mod.service }
+
+ tileContent: Component {
+ SoundTile { service: mod.service }
+ }
+
+ page: Component {
+ Page {
+ title: "Sound"
+ SoundPage { width: parent.width; service: mod.service }
+ }
+ }
+}
diff --git a/desktop/modules/sound/SoundPage.qml b/desktop/modules/sound/SoundPage.qml
new file mode 100644
index 0000000..d530e9d
--- /dev/null
+++ b/desktop/modules/sound/SoundPage.qml
@@ -0,0 +1,140 @@
+// 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.Services.Pipewire
+import QtQuick
+import "../.."
+
+Column {
+ id: page
+
+ required property var service
+ signal back
+
+ spacing: 16
+
+ // Output and input, each with its own slider.
+ Repeater {
+ model: [
+ { label: "Output", node: page.service.sink },
+ { label: "Input", node: page.service.source },
+ ]
+
+ Column {
+ required property var modelData
+ readonly property var audio: modelData.node?.audio ?? null
+
+ width: page.width
+ spacing: 6
+
+ Item {
+ width: parent.width
+ implicitHeight: name.implicitHeight
+
+ Text {
+ id: name
+ anchors.left: parent.left
+ text: modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ anchors.right: parent.right
+ text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 }
+ color: audio?.muted ? Theme.red : Theme.subtext
+ }
+ }
+
+ Text {
+ width: parent.width
+ elide: Text.ElideRight
+ text: modelData.node?.description ?? modelData.node?.name ?? ""
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.overlay
+ }
+
+ // Click or drag anywhere on the bar to set the level.
+ Rectangle {
+ width: parent.width
+ height: 8
+ radius: 4
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ width: parent.width * Math.min(audio?.volume ?? 0, 1)
+ color: audio?.muted ? Theme.red : Theme.accent
+ opacity: audio?.muted ? 0.5 : 1
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: audio !== null
+ onPositionChanged: mouse => set(mouse.x)
+ onPressed: mouse => set(mouse.x)
+ function set(x) {
+ if (audio) audio.volume = Math.max(0, Math.min(1, x / width));
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ // What is playing, with transport. Same Player singleton the OSD uses,
+ // so playerctld's duplicate is already filtered out by dbusName.
+ Column {
+ width: parent.width
+ spacing: 8
+ visible: Player.active
+
+ 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 {
+ spacing: 4
+ 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/desktop/modules/sound/SoundTile.qml b/desktop/modules/sound/SoundTile.qml
new file mode 100644
index 0000000..ec4659b
--- /dev/null
+++ b/desktop/modules/sound/SoundTile.qml
@@ -0,0 +1,27 @@
+// 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
+import "../.."
+
+// The tile's state line: volume, or what is playing.
+Text {
+ required property var service
+
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ text: {
+ if (service.muted) return "muted";
+ const pct = Math.round(service.volume * 100) + "%";
+ return Player.active && Player.title ? `${pct} · ${Player.title}` : pct;
+ }
+}
diff --git a/desktop/modules/sound/TransportButton.qml b/desktop/modules/sound/TransportButton.qml
new file mode 100644
index 0000000..2c1f498
--- /dev/null
+++ b/desktop/modules/sound/TransportButton.qml
@@ -0,0 +1,39 @@
+// 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
+import "../.."
+
+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()
+ }
+}