aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/sound/Osd.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-14 12:24:13 +0200
committerDanilo M. <danix@danix.xyz>2026-09-14 12:24:13 +0200
commitd203d938f005d7542bf13e217ac51917866a8ace (patch)
treec4a6d8728461099638a3fc3378ba7fb34a2b5874 /desktop/modules/sound/Osd.qml
parent55619e7dc830697d2929b0bd5cf1c6dc7435720c (diff)
downloadquickshell-d203d938f005d7542bf13e217ac51917866a8ace.tar.gz
quickshell-d203d938f005d7542bf13e217ac51917866a8ace.zip
feat(desktop): move volume-osd in as the sound module
VolumeOsd.qml did three jobs in one file: PipeWire tracking, the OSD surface and the player transport. They become Service, Osd and the page. The OSD keeps its namespace so the existing Hyprland blur rule still matches, and the service stays always-active because the OSD has to answer a keypress with no drawer open.
Diffstat (limited to 'desktop/modules/sound/Osd.qml')
-rw-r--r--desktop/modules/sound/Osd.qml254
1 files changed, 254 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()
+ }
+ }
+ }
+ }
+}