diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/modules/sound/Osd.qml | 254 | ||||
| -rw-r--r-- | desktop/modules/sound/Player.qml | 56 | ||||
| -rw-r--r-- | desktop/modules/sound/README.md | 159 | ||||
| -rw-r--r-- | desktop/modules/sound/Service.qml | 75 | ||||
| -rw-r--r-- | desktop/modules/sound/SoundModule.qml | 40 | ||||
| -rw-r--r-- | desktop/modules/sound/SoundPage.qml | 140 | ||||
| -rw-r--r-- | desktop/modules/sound/SoundTile.qml | 27 | ||||
| -rw-r--r-- | desktop/modules/sound/TransportButton.qml | 39 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
9 files changed, 792 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() + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index c654cd4..2168a1b 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -13,6 +13,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import "modules/appearance" +import "modules/sound" ShellRoot { // Quickshell exits once no window is visible, and the drawer is closed @@ -30,6 +31,7 @@ ShellRoot { Drawer { id: drawer modules: [ + SoundModule {}, AppearanceModule {}, ] } |
