import QtQuick import Quickshell.Services.Mpris import qs.Common import qs.Services import qs.Widgets Item { id: widget readonly property var activePlayer: MprisController.activePlayer readonly property bool isPlaying: activePlayer !== null && activePlayer !== undefined && activePlayer.playbackState === MprisPlaybackState.Playing // Follow whatever is actually coming out of this machine - games do not // publish MPRIS, and a remote MPRIS player produces no local audio at all. readonly property bool live: visible readonly property bool hasSignal: { const values = CavaService.values; if (!values || values.length < 6) return false; for (let i = 0; i < values.length; i++) { if (values[i] > 0) return true; } return false; } // Cava reports six bands; mirroring them reads as a classic visualiser. // CavaService.values is a list, so follow its change signal rather than // binding to it (this is how the shell's own visualiser reads it). property var bands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] function refreshBands() { const values = CavaService.values; if (!values || values.length < 6) { widget.bands = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; return; } const left = [values[5], values[4], values[3], values[2], values[1], values[0]]; widget.bands = left.concat([values[0], values[1], values[2], values[3], values[4], values[5]]); } Connections { target: CavaService enabled: widget.live function onValuesChanged() { widget.refreshBands(); } } onLiveChanged: { if (!widget.live) widget.bands = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; else widget.refreshBands(); } // Holding a Ref keeps the cava process alive only while this widget is up. Loader { active: widget.live sourceComponent: Component { Ref { service: CavaService } } } Row { id: barRow anchors.top: parent.top anchors.left: parent.left width: parent.width height: parent.height - trackLabel.height - Theme.spacingS spacing: Math.max(2, width * 0.012) readonly property real barWidth: (width - spacing * 11) / 12 Repeater { model: widget.bands // A positioner manages x, so the growing bar lives inside a // full-height cell rather than anchoring to the Row itself. delegate: Item { required property var modelData readonly property real level: { if (!widget.live) return 0; // Square-root curve, as the shell's own visualiser uses - // linear levels look flat for anything but loud passages. const raw = Math.max(0, Math.min(100, modelData || 0)); return Math.sqrt(raw * 0.01); } width: barRow.barWidth height: barRow.height Rectangle { anchors.bottom: parent.bottom width: parent.width height: Math.max(3, parent.height * parent.level) radius: width / 2 color: Theme.primary opacity: 0.55 + parent.level * 0.45 Behavior on height { NumberAnimation { duration: 90 easing.type: Easing.OutQuad } } } } } } StyledText { id: trackLabel anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right horizontalAlignment: Text.AlignHCenter text: { if (!CavaService.cavaAvailable) return "cava is not installed"; if (widget.isPlaying) { const title = widget.activePlayer.trackTitle || ""; const artist = widget.activePlayer.trackArtist || ""; return artist !== "" ? title + " — " + artist : title; } return widget.hasSignal ? "Playing" : "No audio playing"; } font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText elide: Text.ElideRight } }