mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
260 lines
9.0 KiB
QML
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
|
|
}
|
|
}
|