import QtQuick import Quickshell.Services.Mpris import qs.Common Item { id: root property MprisPlayer activePlayer property string artUrl: "" property color accentColor: Theme.primary property var cavaService: null property string lastValidArtUrl: "" // Live mpris url — always valid for the current track; fallback so art is never blank. readonly property string rawArtUrl: { const p = activePlayer; if (!p) return ""; if (p.trackArtUrl) return p.trackArtUrl; const m = p.metadata; return m && m["mpris:artUrl"] ? m["mpris:artUrl"].toString() : ""; } readonly property string curArt: artUrl || lastValidArtUrl || rawArtUrl property string _prevArt: "" property bool _fadePending: false property string _srcOverride: "" // forces the live url when the resolved one fails readonly property string _mainSrc: _srcOverride !== "" ? _srcOverride : curArt readonly property int albumArtStatus: mainArt.imageStatus property real albumSize: Math.min(width, height) * 0.88 property bool showAnimation: true property real animationScale: 1.0 readonly property real blobBaseRadiusFactor: 0.43 readonly property real blobAmplitudeFactor: 0.115 readonly property real blobOvershoot: 1.15 readonly property real blobEnergySensitivity: 1.15 readonly property real cavaFullScale: 45 readonly property real blobAttack: 0.75 readonly property real blobRelease: 0.2 readonly property real blobBeatBoost: 2.5 readonly property real blobBeatKick: 4 readonly property real blobOnsetThreshold: 1.4 readonly property real blobSpringStiffness: 220 readonly property real blobSpringDamping: 19 readonly property real blobMorphSpeed: 0.05 readonly property real blobMorphBoost: 1.7 readonly property real blobSpinSpeed: 0.03 readonly property bool onScreen: visible && (Window.window?.visible ?? false) readonly property bool blobActive: (cavaService?.cavaAvailable ?? false) && onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation && albumArtStatus === Image.Ready property var smoothedBands: [0, 0, 0, 0, 0, 0] property var slowBands: [0, 0, 0, 0, 0, 0] property var bandTargets: [0, 0, 0, 0, 0, 0] property var bandDisplay: [0, 0, 0, 0, 0, 0] property var prevLevels: [0, 0, 0, 0, 0, 0] readonly property var fluxWeights: [1.0, 1.0, 0.6, 0.6, 0.35, 0.35] property real fluxAvg: 0.02 property real loudCtx: 0.1 property int beatCooldown: 0 property real energyTarget: 0 property real energyPos: 0 property real energyVel: 0 onActivePlayerChanged: { lastValidArtUrl = ""; } onCurArtChanged: { _srcOverride = ""; // Keep the outgoing art covering mainArt until the new art decodes, then fade — // hides mainArt's placeholder base so no primary circle flashes mid-load. if (_prevArt !== "" && _prevArt !== curArt) { fadeArt.imageSource = _prevArt; fadeArt.opacity = 1; _fadePending = true; fadeSafety.restart(); Qt.callLater(_maybeStartFade); // catch cached (synchronous) loads } _prevArt = curArt; } function _maybeStartFade() { if (!_fadePending) return; if (mainArt.imageStatus !== Image.Ready && mainArt.imageStatus !== Image.Error) return; _fadePending = false; fadeSafety.stop(); fadeOut.restart(); } Timer { id: fadeSafety interval: 1200 onTriggered: { if (root._fadePending) { root._fadePending = false; fadeOut.restart(); } } } NumberAnimation { id: fadeOut target: fadeArt property: "opacity" from: 1 to: 0 duration: 300 easing.type: Easing.InOutQuad } function updateBands() { const vals = cavaService?.values; if (!vals || vals.length < 6) return; const s = smoothedBands; const slow = slowBands; const out = bandTargets; const prev = prevLevels; const w = fluxWeights; let flux = 0; for (let i = 0; i < 6; i++) { const level = Math.min(Math.max(vals[i], 0), cavaFullScale) / cavaFullScale; flux += Math.max(0, level - prev[i]) * w[i]; prev[i] = level; const alpha = level > s[i] ? blobAttack : blobRelease; s[i] += alpha * (level - s[i]); slow[i] += 0.05 * (level - slow[i]); const punch = Math.max(0, s[i] - slow[i]) * blobBeatBoost; out[i] = Math.min(1, (0.55 * s[i] + punch) * blobEnergySensitivity); } const ratio = flux / Math.max(fluxAvg, 0.004); fluxAvg += 0.06 * (flux - fluxAvg); if (beatCooldown > 0) { beatCooldown--; } else if (ratio > blobOnsetThreshold && flux > 0.008) { energyVel += blobBeatKick * Math.min(2.5, ratio - 1); beatCooldown = 3; } const loud = 0.7 * Math.max(prev[0], prev[1]) + 0.3 * Math.max(prev[2], prev[3]); loudCtx += 0.03 * (loud - loudCtx); const surge = Math.max(0, loud / Math.max(loudCtx, 0.05) - 1); energyTarget = Math.min(1, 0.5 * loud + 0.6 * Math.min(1, surge)); } function stepBlob(dt) { energyVel += (blobSpringStiffness * (energyTarget - energyPos) - blobSpringDamping * energyVel) * dt; energyPos = Math.max(0, Math.min(blobOvershoot, energyPos + energyVel * dt)); blobEffect.energy = energyPos; const d = bandDisplay; const t = bandTargets; const f = Math.min(1, dt * 14); for (let i = 0; i < 6; i++) { d[i] += f * (t[i] - d[i]); } blobEffect.bandsA = Qt.vector4d(d[0], d[1], d[2], d[3]); blobEffect.bandsB = Qt.vector2d(d[4], d[5]); const speed = 1 + energyPos * blobMorphBoost; blobEffect.phase = (blobEffect.phase + dt * blobMorphSpeed * speed) % 1; blobEffect.spin = (blobEffect.spin + dt * blobSpinSpeed) % 6.28318530718; } Loader { active: root.cavaService && root.onScreen && activePlayer?.playbackState === MprisPlaybackState.Playing && showAnimation sourceComponent: Component { Ref { service: root.cavaService } } } Connections { target: root.cavaService enabled: blobEffect.visible function onValuesChanged() { root.updateBands(); } } // Timer, not FrameAnimation — a running animation commits frames every vsync (#2863) Timer { running: blobEffect.visible && root.onScreen interval: 33 repeat: true onTriggered: root.stepBlob(0.033) } ShaderEffect { id: blobEffect readonly property real span: Math.min(root.width, root.height) width: span * (root.blobBaseRadiusFactor + root.blobAmplitudeFactor * root.blobOvershoot) * 2 * root.animationScale + 4 height: width anchors.centerIn: parent z: 0 visible: root.blobActive || activation > 0.004 property real phase: 0 property real spin: 0 property real sizePx: width property real baseRadiusPx: span * root.blobBaseRadiusFactor * root.animationScale property real amplitudePx: span * root.blobAmplitudeFactor * root.animationScale property real activation: root.blobActive ? 1 : 0 property real energy: 0 property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) property vector2d bandsB: Qt.vector2d(0, 0) property vector4d fillColor: Qt.vector4d(root.accentColor.r, root.accentColor.g, root.accentColor.b, root.accentColor.a) Behavior on activation { NumberAnimation { duration: 550 easing.type: Easing.InOutQuad } } fragmentShader: Qt.resolvedUrl("../Shaders/qsb/blob.frag.qsb") } DankCircularImage { id: mainArt width: albumSize height: albumSize anchors.centerIn: parent z: 1 imageSource: root._mainSrc fallbackIcon: "album" border.color: root.accentColor border.width: 2 onImageStatusChanged: { if (imageStatus === Image.Ready && imageSource !== "") root.lastValidArtUrl = imageSource; else if (imageStatus === Image.Error && root._srcOverride === "" && root.rawArtUrl !== "" && root.rawArtUrl !== imageSource) root._srcOverride = root.rawArtUrl; // resolved url dead → use live mpris url root._maybeStartFade(); } } // Outgoing art, shown on top only while fading out over the new mainArt. DankCircularImage { id: fadeArt width: albumSize height: albumSize anchors.centerIn: parent z: 2 fallbackIcon: "" border.color: root.accentColor border.width: 2 opacity: 0 visible: opacity > 0 } }