aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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.qml75
-rw-r--r--desktop/modules/sound/SoundModule.qml40
-rw-r--r--desktop/modules/sound/SoundPage.qml140
-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.qml2
l---------volume-osd/Theme.qml1
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