diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index ea2345af0..c685926b3 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -294,7 +294,19 @@ Item { id: bgContainer anchors.fill: parent - readonly property string curArt: TrackArtService.resolvedArtUrl + // Fall back to the live mpris url so the background is never blank. + readonly property string curArt: { + const resolved = TrackArtService.resolvedArtUrl; + if (resolved !== "") + return resolved; + const p = root.activePlayer; + if (!p) + return ""; + if (p.trackArtUrl) + return p.trackArtUrl; + const m = p.metadata; + return m && m["mpris:artUrl"] ? m["mpris:artUrl"].toString() : ""; + } // Two layers crossfade: new art loads into the hidden one and fades in once decoded. property bool _showA: true visible: layerA.ready || layerB.ready @@ -305,18 +317,29 @@ Item { function syncArt() { if (curArt === "") return; - const frontArt = _showA ? layerA.art : layerB.art; - const backArt = _showA ? layerB.art : layerA.art; - if (frontArt == curArt) + const front = _showA ? layerA : layerB; + const back = _showA ? layerB : layerA; + if (front.art == curArt) return; - if (backArt == curArt) { - _showA = !_showA; + if (back.art == curArt) { + // Already decoded in the hidden layer; flip once ready (else promote() does). + if (back.ready) + _showA = !_showA; return; } - if (_showA) - layerB.art = curArt; - else - layerA.art = curArt; + back.art = curArt; + } + + // Flip to the hidden layer only when it holds the current art, ignoring stale + // Ready re-emits (e.g. popout re-expose) that would otherwise ping-pong _showA. + function promote(layer) { + const back = _showA ? layerB : layerA; + if (layer !== back) + return; + if (layer.art != curArt) + return; + _showA = (layer === layerA); + root.maybeFinishSwitch(); } component BgBlurLayer: ClippingRectangle { @@ -370,23 +393,13 @@ Item { BgBlurLayer { id: layerA front: bgContainer._showA - onLoaded: { - if (!bgContainer._showA) { - bgContainer._showA = true; - root.maybeFinishSwitch(); - } - } + onLoaded: bgContainer.promote(layerA) } BgBlurLayer { id: layerB front: !bgContainer._showA - onLoaded: { - if (bgContainer._showA) { - bgContainer._showA = false; - root.maybeFinishSwitch(); - } - } + onLoaded: bgContainer.promote(layerB) } Rectangle { diff --git a/quickshell/Services/MediaAccentService.qml b/quickshell/Services/MediaAccentService.qml index 456c125c4..9212503ff 100644 --- a/quickshell/Services/MediaAccentService.qml +++ b/quickshell/Services/MediaAccentService.qml @@ -26,13 +26,9 @@ Singleton { readonly property color accentTrack: Theme.withAlpha(accent, 0.28) readonly property color accentSubtle: Theme.withAlpha(accent, 0.55) - // Plain-named alias: underscore-prefixed props with onChanged handlers crash config load. readonly property string artUrl: TrackArtService.resolvedArtUrl - onArtUrlChanged: { - if (artUrl === "") - _accent = null; - } + // Hold the last accent across the brief artUrl blank between tracks; never reset to primary. property var _accent: null ColorQuantizer { @@ -40,7 +36,11 @@ Singleton { source: root.artUrl depth: 4 rescaleSize: 64 - onColorsChanged: root._accent = root._pickAccent(colors) + onColorsChanged: { + const a = root._pickAccent(colors); + if (a !== null) + root._accent = a; + } } function _pickAccent(colors) { diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index 0d6f8e3fc..6962a01a5 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -13,6 +13,8 @@ Singleton { property string resolvedArtUrl: "" property alias _bgArtSource: root.resolvedArtUrl property bool loading: false + // sha1s of placeholder art to reject (Chrome's own logo, shown before real cover). + readonly property var _artHashDenylist: ["764a730860c5b8a7bbee690ee5a443672ae37dc8"] function djb2Hash(str) { if (!str) return ""; @@ -54,6 +56,11 @@ Singleton { return ""; } + function _commit(u) { + resolvedArtUrl = u; + _committedArtKey = u !== "" ? _pendingArtKey : ""; + } + function loadArtwork(url) { if (!url || url === "") { // Keep stale art; only blank once the empty url debounce settles. @@ -81,7 +88,7 @@ Singleton { return; if (exitCode === 0) { - resolvedArtUrl = localFileUrl; + _commit(localFileUrl); loading = false; } else { const dlCmd = "mkdir -p \"$(dirname \"$1\")\" && curl -f -s -L -o \"$1\" \"$2\" && mv \"$1\" \"$3\" || { rm -f \"$1\"; exit 1; }"; @@ -98,18 +105,14 @@ Singleton { return; if (maxExitCode === 0) { - resolvedArtUrl = localFileUrl; + _commit(localFileUrl); loading = false; } else { Proc.runCommand(null, ["sh", "-c", dlCmd, "sh", tmpPath, mqUrl, filePath], (mqOutput, mqExitCode) => { if (_lastArtUrl !== targetUrl) return; - if (mqExitCode === 0) { - resolvedArtUrl = localFileUrl; - } else { - resolvedArtUrl = targetUrl; // Ultimate fallback - } + _commit(mqExitCode === 0 ? localFileUrl : targetUrl); loading = false; }, 50, 15000); } @@ -121,11 +124,7 @@ Singleton { if (_lastArtUrl !== targetUrl) return; - if (dlExitCode === 0) { - resolvedArtUrl = localFileUrl; - } else { - resolvedArtUrl = targetUrl; // Fallback to raw URL - } + _commit(dlExitCode === 0 ? localFileUrl : targetUrl); loading = false; }, 50, 15000); } @@ -137,11 +136,22 @@ Singleton { loading = true; const localUrl = url; const filePath = url.startsWith("file://") ? url.substring(7) : url; - // Cover file often lands after the metadata update, so poll briefly. - Proc.runCommand(null, ["sh", "-c", "for i in $(seq 20); do [ -f \"$1\" ] && exit 0; sleep 0.15; done; exit 1", "sh", filePath], (output, exitCode) => { + // Cover lands after metadata, so poll; hash only to reject placeholder art. + const script = "f=\"$1\"; for i in $(seq 20); do [ -f \"$f\" ] && break; sleep 0.15; done; [ -f \"$f\" ] || exit 1; sha1sum \"$f\" | cut -c1-40"; + Proc.runCommand(null, ["sh", "-c", script, "sh", filePath], (output, exitCode) => { if (_lastArtUrl !== localUrl) return; - resolvedArtUrl = exitCode === 0 ? localUrl : ""; + if (exitCode !== 0) { + // Keep current art rather than blanking (avoids an accent/art flash). + loading = false; + return; + } + // Placeholder (Chrome logo): skip without committing so the real cover still resolves. + if (_artHashDenylist.indexOf((output || "").trim()) !== -1) { + loading = false; + return; + } + _commit(localUrl); loading = false; }, 50, 5000); } @@ -151,13 +161,14 @@ Singleton { interval: 800 onTriggered: { if (root._lastArtUrl === "") - root.resolvedArtUrl = ""; + root._commit(""); } } property MprisPlayer activePlayer: MprisController.activePlayer - property string _resolvedTrackKey: "" + property string _committedArtKey: "" + property string _pendingArtKey: "" onActivePlayerChanged: _updateArtUrl() @@ -173,17 +184,21 @@ Singleton { const p = activePlayer; if (!p) return ""; + // Prefer the stable track id; title/artist/album fill in progressively (Chrome). + const tid = p.metadata && p.metadata["mpris:trackid"] ? p.metadata["mpris:trackid"].toString() : ""; + if (tid !== "") + return tid; return (p.trackTitle || "") + "" + (p.trackArtist || "") + "" + (p.trackAlbum || ""); } function _updateArtUrl() { - const url = getArtworkUrl(activePlayer); const key = _trackKey(); - // Ignore metadata jitter once resolved, but only when the url still matches - // (trackArtUrl can update before the title, and skipping then wedges old art). - if (key !== "" && key === _resolvedTrackKey && url === _lastArtUrl && resolvedArtUrl !== "") + // Skip once real art is committed for this track (dedup Chrome's multi-size + // re-publish). The lock is set in _commit(), never optimistically, so a rejected + // placeholder or a short-circuited duplicate url can't wedge the real cover out. + if (key !== "" && key === _committedArtKey) return; - _resolvedTrackKey = key; - loadArtwork(url); + _pendingArtKey = key; + loadArtwork(getArtworkUrl(activePlayer)); } } diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index ab3f3384e..31ce0ebfb 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -9,7 +9,22 @@ Item { property MprisPlayer activePlayer property string artUrl: TrackArtService.resolvedArtUrl property string lastValidArtUrl: "" - property alias albumArtStatus: albumArt.imageStatus + // 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 @@ -48,10 +63,49 @@ Item { lastValidArtUrl = ""; } - onArtUrlChanged: { - if (artUrl && albumArtStatus !== Image.Error) { - lastValidArtUrl = artUrl; + 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() { @@ -166,21 +220,36 @@ Item { } DankCircularImage { - id: albumArt + id: mainArt width: albumSize height: albumSize anchors.centerIn: parent z: 1 - - imageSource: artUrl || lastValidArtUrl || "" + imageSource: root._mainSrc fallbackIcon: "album" border.color: MediaAccentService.accent border.width: 2 - onImageSourceChanged: { - if (imageSource && imageStatus !== Image.Error) { - lastValidArtUrl = imageSource; - } + 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: MediaAccentService.accent + border.width: 2 + opacity: 0 + visible: opacity > 0 + } }