// Copyright (C) 2026 Danilo M. // // 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 Scope { id: root // 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); } } // 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(); } } 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(); } // 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 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 } PanelWindow { id: win visible: root.active !== null readonly property PwNode node: root.active readonly property real volume: node?.audio?.volume ?? 0 readonly property bool muted: node?.audio?.muted ?? false // 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) // Tracks the pointer over the whole panel so the fade can pause. 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 (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) + "%" } } 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() } } } } }