// 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 import QtQuick.Effects 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 and the device line. implicitWidth: 360 implicitHeight: Player.active ? 178 : 100 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) // Album art as the whole background, lightly blurred, when the // player has any. It is opaque, so it hides the compositor's // frosting; without art the panel keeps the translucent look. // Inset by the border width so the border stays visible. Image { id: bgArt anchors.fill: parent anchors.margins: 1 source: Player.active ? Player.artUrl : "" cache: false asynchronous: true fillMode: Image.PreserveAspectCrop visible: false } // clip only cuts to the bounding box, so the corners come from a // mask the size of the art with the panel's radius. Rectangle { id: bgMask anchors.fill: bgArt radius: parent.radius - 1 layer.enabled: true visible: false } MultiEffect { id: bgEffect anchors.fill: bgArt visible: bgArt.status === Image.Ready && bgArt.source != "" source: bgArt autoPaddingEnabled: false blurEnabled: true blur: 0.5 maskEnabled: true maskSource: bgMask maskThresholdMin: 0.5 maskSpreadAtMin: 1.0 } // Keeps text and the accent bar legible on a bright cover. A // sibling, not a child: nested inside the MultiEffect it did not // paint, and a light cover showed through at full brightness. Rectangle { anchors.fill: bgArt visible: bgEffect.visible radius: bgMask.radius color: Qt.alpha(Theme.base, 0.82) } // Frost on the scrim, faked: a faint top-down sheen and fixed // grain. The art is opaque, so the compositor's frosting cannot // show here without costing the scrim its legibility. Rectangle { anchors.fill: bgArt visible: bgEffect.visible radius: bgMask.radius gradient: Gradient { GradientStop { position: 0.0; color: Qt.alpha(Theme.text, 0.08) } GradientStop { position: 0.6; color: "transparent" } } } // ponytail: grain painted once with Canvas; a ShaderEffect would // animate or scale better but needs a compiled .qsb beside it. Canvas { anchors.fill: bgArt visible: bgEffect.visible layer.enabled: true layer.effect: MultiEffect { maskEnabled: true maskSource: bgMask maskThresholdMin: 0.5 maskSpreadAtMin: 1.0 } onPaint: { if (width <= 0 || height <= 0) return; const ctx = getContext("2d"); const img = ctx.createImageData(width, height); for (let i = 0; i < img.data.length; i += 4) { const v = Math.random() * 255; img.data[i] = v; img.data[i + 1] = v; img.data[i + 2] = v; img.data[i + 3] = 14; } ctx.putImageData(img, 0, 0); } } HoverHandler { onHoveredChanged: root.hovered = hovered } Column { anchors.fill: parent anchors.margins: 16 spacing: 12 // A Loader keeps its last height after going inactive, so a // player vanishing (a closed browser tab) left 46px of empty // row on top and pushed the volume row out of the window. // Hidden, the Column skips it. Loader { active: Player.active visible: 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.iconFamily 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 } } } } // The device the active node represents. Same fallback // order as SoundPage.devLabel. Text { width: parent.width elide: Text.ElideRight text: win.node?.description || win.node?.nickname || win.node?.name || "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.subtext } } } } } } 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.iconFamily; 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() } } } } }