Files
dms-gamebar/widgets/VisualizerWidget.qml

144 lines
4.5 KiB
QML

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<int>, 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
}
}