// 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); } } Connections { target: root.source?.audio ?? null function onVolumeChanged() { root.show(root.source, true); } function onMutedChanged() { root.show(root.source, true); } } Timer { id: hideTimer interval: root.timeout onTriggered: root.active = null } 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 implicitWidth: 360 implicitHeight: 72 color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-volume-osd" // No keyboard focus: the OSD must never steal input from the window // the user is typing 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) Row { anchors.fill: parent anchors.margins: 16 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 } } } } } } } } }