Animated Covers + Loading Spinner

This commit is contained in:
meoware.exe committed 2026-10-11 21:35:07 +11:00
1 parent e3d71806b1
commit 0f6c70017c
3 files changed
+346 -307

No files matched your search

-19
View File
@@ -5,9 +5,6 @@
"packages": { "packages": {
"": { "": {
"name": "@meowarex/TidalLuna-Plugins", "name": "@meowarex/TidalLuna-Plugins",
"dependencies": {
"pnpm": "^10.14.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
@@ -1641,22 +1638,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/pnpm": {
"version": "10.14.0",
"resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.14.0.tgz",
"integrity": "sha512-rSenlkG0nD5IGhaoBbqnGBegS74Go40X5g4urug/ahRsamiBJfV5LkjdW6MOfaUqXNpMOZK5zPMz+c4iOvhHSA==",
"license": "MIT",
"bin": {
"pnpm": "bin/pnpm.cjs",
"pnpx": "bin/pnpx.cjs"
},
"engines": {
"node": ">=18.12"
},
"funding": {
"url": "https://opencollective.com/pnpm"
}
},
"node_modules/portfinder": { "node_modules/portfinder": {
"version": "1.0.37", "version": "1.0.37",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz",
+314 -240
View File
@@ -1,46 +1,217 @@
// MARKER: Animated Artwork (live artwork in the lyrics view) // MARKER: Animated Artwork (live artwork in the lyrics view)
// Renders the API HLS stream over TIDAL's static artwork tile. // AnimatedArtworkLayer resolves the art for the playing track (bound to its
// track ID); each ArtworkSurface renders it over one of TIDAL's covers.
// Orchestration (track change, visibility, settings) lives in index.ts. // Orchestration (track change, visibility, settings) lives in index.ts.
import { Tracer } from "@luna/core"; import { Tracer } from "@luna/core";
// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts. // Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts.
import Hls from "hls.js/light"; import Hls from "hls.js/light";
import { artworkTitleKey, fetchAnimatedArtwork } from "./api"; import {
type AnimatedArtwork,
artworkTitleKey,
fetchAnimatedArtwork,
} from "./api";
import { settings } from "./Settings"; import { settings } from "./Settings";
const { trace } = Tracer("[Radiant Lyrics]"); const { trace } = Tracer("[Radiant Lyrics]");
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]'; export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
/** Player-bar thumbnail (same element the stock backdrop samples). */
export const MINI_ART_SELECTOR =
'[data-test="footer-player"] [data-test="current-media-imagery"] img';
/** Elements that cannot render child nodes, so the video must go beside them. */ /** Elements that cannot render child nodes, so the video must go beside them. */
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]); const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
// Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU. // Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU.
const ART_MAX_DPR = 1; const ART_MAX_DPR = 1;
const ART_NATIVE_FPS = 25; const ART_NATIVE_FPS = 25;
/** Matches the .rl-animated-art opacity transition. */
const ART_FADE_MS = 300;
export class AnimatedArtworkLayer { /**
* One <video> over one TIDAL cover. The video (and its buffered HLS stream)
* outlives the cover element: when TIDAL remounts the cover, the same node is
* moved into the new one, so reopening a view never drops to the static art.
*/
class ArtworkSurface {
private video: HTMLVideoElement | null = null; private video: HTMLVideoElement | null = null;
private hls: Hls | null = null; private hls: Hls | null = null;
private tile: HTMLElement | null = null; private tile: HTMLElement | null = null;
/** The tile, or its parent when the tile is a replaced element. */ /** The tile, or its parent when the tile is a replaced element. */
private mount: HTMLElement | null = null; private mount: HTMLElement | null = null;
private resizeObs: ResizeObserver | null = null;
private resizeRaf = 0;
private artwork: { url: string; url_tall: string } | null = null;
/**
* Scope key of the displayed artwork (see fetchAnimatedArtwork): the album
* key only when the answer was matched by album (name or ISRC), else the
* title key.
*/
private artworkKey: string | null = null;
private liveSrc: string | null = null;
private loadToken = 0;
private nowPlayingVisible = true;
/** True when the mount's inline position is ours to revert. */ /** True when the mount's inline position is ours to revert. */
private mountPositioned = false; private mountPositioned = false;
private fetchAbort: AbortController | null = null; private resizeObs: ResizeObserver | null = null;
private resizeRaf = 0;
private artwork: AnimatedArtwork | null = null;
private liveSrc: string | null = null;
/** A stream that errored; not retried until the artwork changes. */
private failedSrc: string | null = null;
private fadeTimer = 0;
private visible = true;
private nativeFps = ART_NATIVE_FPS; private nativeFps = ART_NATIVE_FPS;
constructor(
private readonly selector: string,
/** Use the portrait stream when the cover is taller than wide. */
private readonly allowTall: boolean,
) {}
/** Show `artwork`, or fade the current video out when null. */
show(artwork: AnimatedArtwork | null): void {
if (artwork?.url !== this.artwork?.url) this.failedSrc = null;
this.artwork = artwork;
if (artwork) this.bind();
else this.fadeOut();
}
/**
* Attach to the cover on screen. Cheap enough for the DOM observer: a live
* video is moved, never rebuilt, unless the stream itself changed.
*/
bind(): void {
if (!this.artwork || !settings.animatedArtwork) return;
const tile = document.querySelector<HTMLElement>(this.selector);
if (!tile) return;
if (tile !== this.tile) this.host(tile);
if (!this.mount) return;
const src = this.pickSrc();
if (src === this.failedSrc) return;
if (!this.video || src !== this.liveSrc) {
this.build(src);
return;
}
// Same stream came back mid fade-out: keep it rather than rebuild.
if (this.fadeTimer !== 0) {
clearTimeout(this.fadeTimer);
this.fadeTimer = 0;
if (this.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
this.video.classList.add("rl-animated-art-visible");
}
}
// Appending moves the node; its MediaSource and buffer come along.
if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video);
this.syncGeometry();
this.ensurePlaying();
}
setVisible(visible: boolean): void {
// Called from the 200ms tick, so bail unless it actually flipped.
if (visible === this.visible) return;
this.visible = visible;
// Must resume explicitly; nothing else restarts a paused video.
this.ensurePlaying();
}
/** On-screen check for covers with no panel of their own to test. */
tileVisible(): boolean {
const tile = this.tile;
if (!tile?.isConnected) return false;
if (typeof tile.checkVisibility === "function") {
return tile.checkVisibility({ checkVisibilityCSS: true });
}
return getComputedStyle(tile).visibility !== "hidden";
}
refresh(): void {
this.applyRate();
this.ensurePlaying();
}
ensurePlaying(): void {
const video = this.video;
if (!video) return;
if (
this.artwork &&
settings.animatedArtwork &&
this.visible &&
video.isConnected
) {
void video.play().catch(() => {});
} else {
video.pause();
}
}
dispose(): void {
this.artwork = null;
this.failedSrc = null;
this.teardown();
this.resizeObs?.disconnect();
this.resizeObs = null;
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = 0;
this.releaseMount();
this.tile = null;
}
private build(src: string): void {
this.teardown();
if (!this.mount) return;
this.liveSrc = src;
const video = document.createElement("video");
video.className = "rl-animated-art";
video.muted = true;
video.loop = true;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.tabIndex = -1;
video.setAttribute("aria-hidden", "true");
// Fade in on the first decoded frame, so the static cover shows until then.
video.addEventListener(
"loadeddata",
() => video.classList.add("rl-animated-art-visible"),
{ once: true },
);
video.addEventListener(
"error",
() => {
const err = video.error;
this.fail(
err ? `media error ${err.code}: ${err.message}` : "media error",
);
},
{ once: true },
);
this.mount.appendChild(video);
this.video = video;
this.syncGeometry();
// hls.js over MSE only: Chromium claims native HLS support but fails to
// demux the playlist, so there is no native fallback.
if (typeof Hls === "function" && Hls.isSupported()) {
const hls = new Hls({
autoStartLoad: true,
capLevelToPlayerSize: true,
// H.264 ladder; the artwork has no use for 10-bit HEVC.
videoPreference: { videoCodec: "avc1", allowedVideoRanges: ["SDR"] },
maxDevicePixelRatio: ART_MAX_DPR,
});
this.hls = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
const fps = hls.levels[hls.currentLevel]?.frameRate;
if (fps && fps > 0) this.nativeFps = fps;
this.applyRate();
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
this.fail(
`hls ${data.type}/${data.details}${data.reason ? ` (${data.reason})` : ""}${data.error ? ` (${data.error.message})` : ""}`,
);
});
} else {
trace.log("Animated Artwork: HLS unsupported on this client");
this.failedSrc = src;
this.teardown();
return;
}
this.applyRate();
this.ensurePlaying();
}
// CPU tracks presented fps and nothing else, so this is the only perf knob. // CPU tracks presented fps and nothing else, so this is the only perf knob.
private applyRate(): void { private applyRate(): void {
if (!this.video) return; if (!this.video) return;
@@ -49,24 +220,48 @@ export class AnimatedArtworkLayer {
if (this.video.playbackRate !== rate) this.video.playbackRate = rate; if (this.video.playbackRate !== rate) this.video.playbackRate = rate;
} }
private releaseMount(): void { /** Fade the video out, then drop it, unless bind() claims it back first. */
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position"); private fadeOut(): void {
this.mountPositioned = false; const video = this.video;
this.mount = null; if (!video || this.fadeTimer !== 0) return;
video.classList.remove("rl-animated-art-visible");
this.fadeTimer = window.setTimeout(() => {
this.fadeTimer = 0;
if (this.video === video) this.teardown();
}, ART_FADE_MS);
} }
/** True while a matching artwork tile is alive. */ private teardown(): void {
private tileAlive(): boolean { if (this.fadeTimer !== 0) clearTimeout(this.fadeTimer);
if (this.tile?.isConnected) return true; this.fadeTimer = 0;
const tile = document.querySelector<HTMLElement>(ART_TILE_SELECTOR); this.video?.remove();
if (!tile) return false; this.video = null;
this.host(tile); this.hls?.destroy();
return true; this.hls = null;
this.liveSrc = null;
this.nativeFps = ART_NATIVE_FPS;
}
private fail = (reason: string): void => {
trace.log(`Animated Artwork: ${reason} — hiding video (src: ${this.liveSrc})`);
this.failedSrc = this.liveSrc;
this.teardown();
};
private pickSrc(): string {
if (!this.artwork) return "";
if (this.allowTall && this.tile) {
const rect = this.tile.getBoundingClientRect();
if (rect.width && rect.height && rect.height > rect.width) {
return this.artwork.url_tall;
}
}
return this.artwork.url;
} }
private host(tile: HTMLElement): void { private host(tile: HTMLElement): void {
this.tile = tile; this.tile = tile;
// The tile is an <img>; a replaced element silently drops children, so // Covers are <img>s; a replaced element silently drops children, so
// host the video in the parent and lay it over the tile. // host the video in the parent and lay it over the tile.
const replaced = REPLACED_TAGS.has(tile.tagName); const replaced = REPLACED_TAGS.has(tile.tagName);
const mount = replaced ? tile.parentElement : tile; const mount = replaced ? tile.parentElement : tile;
@@ -79,6 +274,12 @@ export class AnimatedArtworkLayer {
this.watchSizing(); this.watchSizing();
} }
private releaseMount(): void {
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
this.mountPositioned = false;
this.mount = null;
}
/** Lay the video over the tile when it is not a direct child of it. */ /** Lay the video over the tile when it is not a direct child of it. */
private syncGeometry(): void { private syncGeometry(): void {
const { video, tile, mount } = this; const { video, tile, mount } = this;
@@ -104,242 +305,115 @@ export class AnimatedArtworkLayer {
this.resizeRaf = requestAnimationFrame(() => { this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = 0; this.resizeRaf = 0;
// Only rebuild the stream if the aspect actually flipped. // Only rebuild the stream if the aspect actually flipped.
if (this.pickSrc() !== this.liveSrc) this.mountVideo(false); if (this.pickSrc() !== this.liveSrc) this.bind();
this.syncGeometry(); this.syncGeometry();
}); });
}); });
this.resizeObs.observe(this.tile); this.resizeObs.observe(this.tile);
} }
}
export interface ArtworkTrack {
trackId: string;
title: string;
artist: string;
album?: string;
isrc?: string;
}
/**
* Resolves the animated artwork for the playing track and feeds it to every
* surface. Bound to the track ID: one track's art never plays over another's
* cover, except across an album whose art was album-matched (TIDAL shows the
* same static cover there too).
*/
export class AnimatedArtworkLayer {
private readonly nowPlaying = new ArtworkSurface(ART_TILE_SELECTOR, true);
private readonly mini = new ArtworkSurface(MINI_ART_SELECTOR, false);
private readonly surfaces = [this.nowPlaying, this.mini];
private trackId: string | null = null;
/**
* Scope key of the current answer (see fetchAnimatedArtwork): the album
* key only when it was matched by album, else the title key.
*/
private artworkKey: string | null = null;
private loadToken = 0;
private fetchAbort: AbortController | null = null;
/** /**
* Mount the artwork for `key`, leaving any playing video alone until a * Called from the shared track-change listener. `albumKey` is the
* replacement resolves. `done: false` means no tile was on screen, so * album-scoped request key for this track (artworkKey() in index.ts).
* retry later. Returns the scope `key` the result is valid under (see
* fetchAnimatedArtwork), which may be narrower than the request key.
*/ */
async load( async setTrack(track: ArtworkTrack, albumKey: string): Promise<void> {
key: string, // Same track again (initial fire + transition, repeat-one): keep playing.
title: string, if (track.trackId === this.trackId) {
artist: string, this.rebind();
album: string | undefined, return;
isrc?: string,
): Promise<{ done: boolean; key: string }> {
// Already showing artwork that covers this track (album-matched, or the
// same track again) — keep playing, touch nothing.
if (
(key === this.artworkKey ||
artworkTitleKey(artist, title) === this.artworkKey) &&
this.artwork &&
this.video?.isConnected
) {
this.ensurePlaying();
return { done: true, key: this.artworkKey };
} }
this.trackId = track.trackId;
const token = ++this.loadToken; const token = ++this.loadToken;
this.fetchAbort?.abort(); this.fetchAbort?.abort();
this.fetchAbort = new AbortController(); this.fetchAbort = null;
const signal = this.fetchAbort.signal;
if (!settings.animatedArtwork) return { done: true, key }; // Album-matched art covers the album's other tracks: nothing to fetch.
// Only check a tile exists; host after the fetch so the old video keeps playing.
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR))
return { done: false, key };
const lookup = await fetchAnimatedArtwork(
title,
artist,
album,
isrc,
signal,
);
if (token !== this.loadToken) return { done: true, key: lookup.key };
if (!this.tileAlive()) return { done: false, key: lookup.key };
if (!lookup.artwork) {
trace.log(`Animated Artwork: no animated art for "${title}"`);
this.detach(false);
// After detach(), which clears the key too.
this.artworkKey = lookup.key;
return { done: true, key: lookup.key };
}
// Same stream already on screen: adopt the key, leave the video be.
const unchanged =
this.artwork?.url === lookup.artwork.url &&
this.artwork?.url_tall === lookup.artwork.url_tall &&
this.video?.isConnected === true;
this.artwork = lookup.artwork;
this.artworkKey = lookup.key;
if (unchanged) {
this.ensurePlaying();
return { done: true, key: lookup.key };
}
this.mountVideo(false);
return { done: true, key: lookup.key };
}
/** Re-bind to the artwork tile after the Now Playing view (re)mounted. */
reattach(): void {
if (!this.artwork || !settings.animatedArtwork) return;
const tile = document.querySelector<HTMLElement>(ART_TILE_SELECTOR);
if (!tile) return;
if (tile === this.tile && this.video?.isConnected) {
this.ensurePlaying();
return;
}
this.host(tile);
this.mountVideo(true);
}
/** (Re)mount the video into the tile, honouring the current aspect + settings. */
mountVideo(force: boolean): void {
if (!this.tileAlive() || !this.artwork || !settings.animatedArtwork) {
this.detach(false);
return;
}
const src = this.pickSrc();
if (!this.tile || !this.mount) {
this.detach(false);
return;
}
if (src === this.liveSrc && this.video && !force) {
this.ensurePlaying();
return;
}
this.video?.remove();
this.hls?.destroy();
this.hls = null;
this.video = null;
this.liveSrc = src;
const video = document.createElement("video");
video.className = "rl-animated-art";
video.muted = true;
video.autoplay = true;
video.loop = true;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.tabIndex = -1;
video.setAttribute("aria-hidden", "true");
video.addEventListener(
"error",
() => {
const err = video.error;
this.fail(
err ? `media error ${err.code}: ${err.message}` : "media error",
);
},
{ once: true },
);
this.mount.appendChild(video);
this.video = video;
this.syncGeometry();
// MSE first: Chromium says "maybe" to canPlayType for HLS then fails to
// demux the playlist, so native is only a fallback for engines without MSE.
if (typeof Hls === "function" && Hls.isSupported()) {
const hls = new Hls({
autoStartLoad: true,
capLevelToPlayerSize: true,
// H.264 ladder; the artwork has no use for 10-bit HEVC.
videoPreference: { videoCodec: "avc1", allowedVideoRanges: ["SDR"] },
maxDevicePixelRatio: ART_MAX_DPR,
});
this.hls = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
const fps = hls.levels[hls.currentLevel]?.frameRate;
if (fps && fps > 0) this.nativeFps = fps;
this.applyRate();
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
this.fail(
`hls ${data.type}/${data.details}${data.reason ? ` (${data.reason})` : ""}${data.error ? ` (${data.error.message})` : ""}`,
);
});
} else if (video.canPlayType("application/x-mpegurl")) {
video.src = src;
} else {
trace.log("Animated Artwork: HLS unsupported on this client");
this.detach(false);
return;
}
video.classList.add("rl-animated-art-visible");
this.applyRate();
this.ensurePlaying();
}
private pickSrc(): string {
if (!this.artwork || !this.tile) return "";
const rect = this.tile.getBoundingClientRect();
if (rect.width && rect.height && rect.height > rect.width) {
return this.artwork.url_tall;
}
return this.artwork.url;
}
private fail = (reason: string): void => {
trace.log(`Animated Artwork: ${reason} — hiding video (src: ${this.liveSrc})`);
this.detach(false);
};
ensurePlaying(): void {
if ( if (
this.artwork && this.artworkKey !== null &&
this.video && (albumKey === this.artworkKey ||
settings.animatedArtwork && artworkTitleKey(track.artist, track.title) === this.artworkKey)
this.nowPlayingVisible &&
!document.hidden &&
this.tile?.isConnected
) { ) {
void this.video.play().catch(() => {}); this.rebind();
} else { return;
this.video?.pause();
}
} }
setNowPlayingVisible(visible: boolean): void { // Different art: drop the old track's video now rather than letting it
// Called from the 200ms tick, so bail unless it actually flipped. // play over the new cover for the length of the lookup.
if (visible === this.nowPlayingVisible) return; this.apply(null, null);
this.nowPlayingVisible = visible;
// Must resume explicitly; nothing else restarts a paused video. const abort = new AbortController();
this.ensurePlaying(); this.fetchAbort = abort;
const lookup = await fetchAnimatedArtwork(
track.title,
track.artist,
track.album,
track.isrc,
abort.signal,
);
if (token !== this.loadToken) return;
this.fetchAbort = null;
if (!lookup.artwork) {
trace.log(`Animated Artwork: no animated art for "${track.title}"`);
}
this.apply(lookup.artwork, lookup.key);
} }
/** Re-bind to the covers after TIDAL (re)mounted one. */
rebind(): void {
for (const surface of this.surfaces) surface.bind();
}
/** Called from the 200ms tick. */
syncVisibility(nowPlayingVisible: boolean): void {
const pageVisible = !document.hidden;
this.nowPlaying.setVisible(pageVisible && nowPlayingVisible);
this.mini.setVisible(pageVisible && this.mini.tileVisible());
}
/** Settings changed (fps). */
refresh(): void { refresh(): void {
if (!this.liveSrc) return; for (const surface of this.surfaces) surface.refresh();
if (settings.animatedArtwork) {
this.applyRate();
if (this.video) this.ensurePlaying();
else this.mountVideo(true);
} else {
this.detach(true);
}
}
private detach(keepData: boolean): void {
if (this.video?.parentNode) this.video.remove();
this.video = null;
this.hls?.destroy();
this.hls = null;
this.liveSrc = keepData ? this.liveSrc : null;
if (!keepData) {
this.artwork = null;
this.artworkKey = null;
}
} }
dispose(): void { dispose(): void {
this.loadToken++; this.loadToken++;
this.fetchAbort?.abort(); this.fetchAbort?.abort();
this.fetchAbort = null; this.fetchAbort = null;
this.resizeObs?.disconnect(); this.trackId = null;
this.resizeObs = null; this.artworkKey = null;
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf); for (const surface of this.surfaces) surface.dispose();
this.resizeRaf = 0; }
this.detach(false);
this.releaseMount(); private apply(artwork: AnimatedArtwork | null, key: string | null): void {
this.tile = null; this.artworkKey = key;
for (const surface of this.surfaces) surface.show(artwork);
} }
} }
+32 -48
View File
@@ -15,13 +15,16 @@ import {
type LyricsApiResponse, type LyricsApiResponse,
type WordLine, type WordLine,
type WordTiming, type WordTiming,
artworkTitleKey,
fetchLyrics as fetchLyricsApi, fetchLyrics as fetchLyricsApi,
flushLyrics as flushLyricsApi, flushLyrics as flushLyricsApi,
romanizeLyrics as romanizeLyricsApi, romanizeLyrics as romanizeLyricsApi,
} from "./api"; } from "./api";
import { KawarpLayer } from "./backdrop"; import { KawarpLayer } from "./backdrop";
import { AnimatedArtworkLayer, ART_TILE_SELECTOR } from "./artwork"; import {
AnimatedArtworkLayer,
ART_TILE_SELECTOR,
MINI_ART_SELECTOR,
} from "./artwork";
import { Settings, settings } from "./Settings"; import { Settings, settings } from "./Settings";
import backdropStylesCss from "file://backdrop-styles.css?minify"; import backdropStylesCss from "file://backdrop-styles.css?minify";
@@ -835,14 +838,14 @@ const syncBackdropActivity = (): void => {
now, now,
); );
// Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second. // Pause artwork videos whose cover is off screen. Guarded: this ticks 5x/second.
if (animatedArtworkLayer) { if (animatedArtworkLayer) {
if (!cachedNowPlayingPanel?.isConnected) { if (!cachedNowPlayingPanel?.isConnected) {
cachedNowPlayingPanel = document.querySelector( cachedNowPlayingPanel = document.querySelector(
'[class*="_nowPlayingContainer"]', '[class*="_nowPlayingContainer"]',
) as HTMLElement | null; ) as HTMLElement | null;
} }
animatedArtworkLayer.setNowPlayingVisible( animatedArtworkLayer.syncVisibility(
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel), !!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
); );
} }
@@ -1102,63 +1105,38 @@ const updateRadiantLyricsBackdrop = function (): void {
} }
}; };
// MARKER: Animated Artwork (live artwork on the artwork tile) // MARKER: Animated Artwork (live artwork on the artwork tiles)
let animatedArtworkLayer: AnimatedArtworkLayer | null = null; let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
let lastArtworkKey: string | null = null;
let lastArtworkTile: Element | null = null; let lastArtworkTile: Element | null = null;
let lastMiniArtworkTile: Element | null = null;
let artworkUpdateSeq = 0;
/** /**
* Request key for an animated-artwork lookup: album-scoped when an album is * Album-scoped request key for an animated-artwork lookup (skips within an
* present (skips within one are free). A result may still be scoped tighter — * album are free when the art was album-matched). Without an album it falls
* see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the * back to the title key, matching artworkTitleKey() in api.ts.
* effective scope, not this request key.
*/ */
const artworkKey = (t: TrackInfo): string => const artworkKey = (t: TrackInfo): string =>
t.album && t.album.trim() !== "" t.album && t.album.trim() !== ""
? `${t.artist}\u0000${t.album.trim()}` ? `${t.artist}\u0000${t.album.trim()}`
: `${t.artist}\u0000\u0000${t.title}`; : `${t.artist}\u0000\u0000${t.title}`;
// Load + show animated art for the current (or given) track // Load + show animated art for the playing track. Only the shared track-change
const updateAnimatedArtwork = async ( // listener (and the setting) drive lookups; cover remounts just rebind.
target?: TrackInfo | null, const updateAnimatedArtwork = async (): Promise<void> => {
rehost = false, const seq = ++artworkUpdateSeq;
): Promise<void> => {
if (!settings.animatedArtwork) { if (!settings.animatedArtwork) {
animatedArtworkLayer?.dispose(); animatedArtworkLayer?.dispose();
animatedArtworkLayer = null; animatedArtworkLayer = null;
lastArtworkKey = null;
lastArtworkTile = null; lastArtworkTile = null;
lastMiniArtworkTile = null;
return; return;
} }
if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer(); animatedArtworkLayer ??= new AnimatedArtworkLayer();
target ??= await getTrackInfo(); const target = await getTrackInfo();
if (!target) return; // A newer track change won the race, or the setting flipped meanwhile.
// Re-check after the await; another caller may have claimed this already. if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return;
if (!settings.animatedArtwork || !animatedArtworkLayer) return; await animatedArtworkLayer.setTrack(target, artworkKey(target));
const key = artworkKey(target);
// Free skip: the shown artwork's scope covers this track — it was matched
// by album (album key), or it is the same track again (title key).
if (
key === lastArtworkKey ||
artworkTitleKey(target.artist, target.title) === lastArtworkKey
) {
// Same artwork, but the view may have remounted underneath us.
if (rehost) animatedArtworkLayer.reattach();
return;
}
lastArtworkKey = key;
const res = await animatedArtworkLayer.load(
key,
target.title,
target.artist,
target.album,
target.isrc,
);
// Adopt the effective scope — a title-matched result (or a miss) must not
// stand in for the album's other tracks — unless a newer trigger claimed.
if (lastArtworkKey === key) lastArtworkKey = res.key;
// No tile on screen yet; clear the key so the tile observer retries.
if (!res.done && lastArtworkKey === res.key) lastArtworkKey = null;
}; };
(window as any).updateAnimatedArtwork = updateAnimatedArtwork; (window as any).updateAnimatedArtwork = updateAnimatedArtwork;
@@ -1542,13 +1520,19 @@ function setupLyricsMenuObserver(): void {
} }
}); });
// The tile only exists once the view is open, so load from here too. This fires // Covers remount as views open and close: move the live artwork video onto
// on nearly every DOM change, so bail synchronously unless the tile is new. // the new element (no refetch). These fire on nearly every DOM change, so
// bail synchronously unless the cover is new.
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => { observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
// Identity only; also stops us reacting to our own <video> insertion. // Identity only; also stops us reacting to our own <video> insertion.
if (tile === lastArtworkTile) return; if (tile === lastArtworkTile) return;
lastArtworkTile = tile; lastArtworkTile = tile;
void updateAnimatedArtwork(undefined, true); animatedArtworkLayer?.rebind();
});
observe<HTMLElement>(unloads, MINI_ART_SELECTOR, (tile) => {
if (tile === lastMiniArtworkTile) return;
lastMiniArtworkTile = tile;
animatedArtworkLayer?.rebind();
}); });
// Apply word lyrics when lyrics container appears or reappears // Apply word lyrics when lyrics container appears or reappears