diff options
| -rw-r--r-- | desktop/modules/sound/Osd.qml (renamed from volume-osd/VolumeOsd.qml) | 196 | ||||
| -rw-r--r-- | desktop/modules/sound/Player.qml (renamed from volume-osd/Player.qml) | 0 | ||||
| -rw-r--r-- | desktop/modules/sound/README.md (renamed from volume-osd/README.md) | 0 | ||||
| -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 (renamed from volume-osd/shell.qml) | 17 | ||||
| -rw-r--r-- | desktop/modules/sound/TransportButton.qml (renamed from volume-osd/TransportButton.qml) | 1 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 | ||||
| l--------- | volume-osd/Theme.qml | 1 |
10 files changed, 346 insertions, 126 deletions
diff --git a/volume-osd/VolumeOsd.qml b/desktop/modules/sound/Osd.qml index f739aec..677d211 100644 --- a/volume-osd/VolumeOsd.qml +++ b/desktop/modules/sound/Osd.qml @@ -13,95 +13,48 @@ 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 - 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); } - } + property bool visibleNow: 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(); } - } + // Hovering freezes the countdown so the transport buttons can be clicked; + // leaving starts it again. + property bool hovered: false 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(); + target: root.service + function onChanged() { + root.visibleNow = true; + 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 + running: root.visibleNow && !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 + onTriggered: root.visibleNow = false } PanelWindow { id: win - visible: root.active !== null + visible: root.visibleNow - readonly property PwNode node: root.active + 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 @@ -129,7 +82,6 @@ Scope { 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 } @@ -152,70 +104,70 @@ Scope { 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) + "%" + 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 ? "" : ""; } } - Rectangle { - width: parent.width - height: 6 - radius: 3 - color: Theme.surface + 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 { - 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 } } + 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 } } + } } } } } - } } } diff --git a/volume-osd/Player.qml b/desktop/modules/sound/Player.qml index fab8083..fab8083 100644 --- a/volume-osd/Player.qml +++ b/desktop/modules/sound/Player.qml diff --git a/volume-osd/README.md b/desktop/modules/sound/README.md index f0a8313..f0a8313 100644 --- a/volume-osd/README.md +++ b/desktop/modules/sound/README.md 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/volume-osd/shell.qml b/desktop/modules/sound/SoundTile.qml index dbbd96f..ec4659b 100644 --- a/volume-osd/shell.qml +++ b/desktop/modules/sound/SoundTile.qml @@ -9,8 +9,19 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -import Quickshell +import QtQuick +import "../.." -ShellRoot { - VolumeOsd {} +// 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/volume-osd/TransportButton.qml b/desktop/modules/sound/TransportButton.qml index 07753b6..2c1f498 100644 --- a/volume-osd/TransportButton.qml +++ b/desktop/modules/sound/TransportButton.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import QtQuick +import "../.." Rectangle { id: btn 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 {}, ] } 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 |
