diff options
Diffstat (limited to 'volume-osd/VolumeOsd.qml')
| -rw-r--r-- | volume-osd/VolumeOsd.qml | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/volume-osd/VolumeOsd.qml b/volume-osd/VolumeOsd.qml new file mode 100644 index 0000000..9ee21ff --- /dev/null +++ b/volume-osd/VolumeOsd.qml @@ -0,0 +1,162 @@ +// 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 + +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 } } + } + } + } + } + } + } +} |
