1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00
Files
2026-07-26 23:22:22 -04:00

260 lines
9.0 KiB
QML

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