From 8ef69f57a2687d89f319892aa8f25e8c405a7c92 Mon Sep 17 00:00:00 2001 From: Daniel <62436912+frostplexx@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:16:37 +0200 Subject: [PATCH 1/3] feat: animated artwork Animated artwork over the Now Playing artwork tile, behind a new "Animated Artwork" setting. --- package.json | 3 + plugins/radiant-lyrics-luna/package.json | 3 +- plugins/radiant-lyrics-luna/src/Settings.tsx | 15 + plugins/radiant-lyrics-luna/src/api.ts | 104 ++++++ plugins/radiant-lyrics-luna/src/artwork.ts | 329 ++++++++++++++++++ .../radiant-lyrics-luna/src/hls-light.d.ts | 6 + plugins/radiant-lyrics-luna/src/index.ts | 109 +++++- plugins/radiant-lyrics-luna/src/styles.css | 17 + pnpm-lock.yaml | 12 + 9 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 plugins/radiant-lyrics-luna/src/artwork.ts create mode 100644 plugins/radiant-lyrics-luna/src/hls-light.d.ts diff --git a/package.json b/package.json index b164743..26efd05 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ "rimraf": "^6.0.1", "tsx": "^4.19.4", "typescript": "^5.8.3" + }, + "dependencies": { + "hls.js": "^1.7.2" } } diff --git a/plugins/radiant-lyrics-luna/package.json b/plugins/radiant-lyrics-luna/package.json index b465460..bf9a485 100644 --- a/plugins/radiant-lyrics-luna/package.json +++ b/plugins/radiant-lyrics-luna/package.json @@ -9,6 +9,7 @@ "main": "./src/index.ts", "type": "module", "dependencies": { - "@kawarp/core": "*" + "@kawarp/core": "*", + "hls.js": "^1.7.2" } } diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 88b2baf..a931ce7 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -8,6 +8,7 @@ declare global { updateRadiantLyricsTextGlow?: () => void; updateRadiantLyricsPlayerBarTint?: () => void; updateRadiantLyricsBackdrop?: () => void; + updateAnimatedArtwork?: (target?: unknown) => void; updateQualityProgressColor?: () => void; updateIntegratedSeekBar?: () => void; updateLyricsStyle?: () => void; @@ -48,6 +49,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { playerBarTintCustomColors: [] as string[], // Master switch backdropEnabled: true, + animatedArtwork: true, backdropStyle: 0, backdropPlaybackReactive: true, CoverEverywhere: true, @@ -82,6 +84,9 @@ export const Settings = () => { const [backdropEnabled, setBackdropEnabled] = React.useState( settings.backdropEnabled, ); + const [animatedArtwork, setAnimatedArtwork] = React.useState( + settings.animatedArtwork, + ); const [backdropStyle, setBackdropStyle] = React.useState( settings.backdropStyle, ); @@ -915,6 +920,16 @@ export const Settings = () => { refreshBackdrop(); }} /> + { + settings.animatedArtwork = checked; + setAnimatedArtwork(checked); + window.updateAnimatedArtwork?.(); + }} + /> {backdropEnabled && ( <> (); +const ARTWORK_CACHE_MAX = 200; + +export async function fetchAnimatedArtwork( + title: string, + artist: string, + album?: string, + signal?: AbortSignal, +): Promise { + // Keyed by album, matching artworkKey() in index.ts: the endpoint returns the + // album's artwork, so every track on one album resolves to the same entry. + const cacheKey = + album && album.trim() !== "" + ? `${artist}\u0000${album.trim()}` + : `${artist}\u0000\u0000${title}`; + if (animatedArtworkCache.has(cacheKey)) { + return animatedArtworkCache.get(cacheKey) ?? null; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + // Caller-driven cancellation (track changed) on top of the timeout. + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) controller.abort(); + const tryFetch = async (url: string): Promise => { + try { + sylTrace(`Animated Artwork: fetching ${url}`); + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) { + trace.log(`Animated Artwork: fetch failed ${res.status} | ${url}`); + return null; + } + const data = (await res.json()) as { + url?: string; + url_tall?: string; + message?: string; + }; + if (data.url && data.url_tall) { + return { url: data.url, url_tall: data.url_tall }; + } + trace.log(`Animated Artwork: none found | ${url}`); + return null; + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + trace.log(`Animated Artwork: request timed out | ${url}`); + } else { + trace.log(`Animated Artwork: request error | ${url} | ${err}`); + } + return null; + } + }; + + const remember = (result: AnimatedArtwork | null): AnimatedArtwork | null => { + // Never cache a cancelled attempt — it says nothing about the track. + if (controller.signal.aborted) return result; + if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) { + const oldest = animatedArtworkCache.keys().next().value; + if (oldest !== undefined) animatedArtworkCache.delete(oldest); + } + animatedArtworkCache.set(cacheKey, result); + return result; + }; + + try { + // Exact album first; on a miss retry once without the album (server-side disambiguation is imperfect) + const withAlbum = await tryFetch( + artworkSearchUrl(title, artist, album), + ); + if (withAlbum) return remember(withAlbum); + if (album && album.trim() !== "") { + const withoutAlbum = await tryFetch( + artworkSearchUrl(title, artist), + ); + if (withoutAlbum) return remember(withoutAlbum); + } + return remember(null); + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + } +} + export async function flushLyrics(track: { title: string; artist: string; diff --git a/plugins/radiant-lyrics-luna/src/artwork.ts b/plugins/radiant-lyrics-luna/src/artwork.ts new file mode 100644 index 0000000..0aa2f37 --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/artwork.ts @@ -0,0 +1,329 @@ +// MARKER: Animated Artwork (live artwork in the lyrics view) +// Renders the API HLS stream over TIDAL's static artwork tile. +// Orchestration (track change, visibility, settings) lives in index.ts. + +import { Tracer } from "@luna/core"; +// The light build drops alt-audio, subtitles and EME — none of which a muted +// artwork loop uses — and saves ~230kB of bundle. See hls-light.d.ts for types. +import Hls from "hls.js/light"; + +import { fetchAnimatedArtwork } from "./api"; +import { settings } from "./Settings"; + +const { trace } = Tracer("[Radiant Lyrics]"); + +export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]'; +/** Elements that cannot render child nodes, so the video must go beside them. */ +const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]); + +export class AnimatedArtworkLayer { + private video: HTMLVideoElement | null = null; + private hls: Hls | null = null; + private tile: HTMLElement | null = null; + /** Where the video actually lives — the tile itself, or its parent when the tile is a replaced element. */ + private mount: HTMLElement | null = null; + private resizeObs: ResizeObserver | null = null; + private resizeRaf = 0; + private artwork: { url: string; url_tall: string } | null = null; + /** Identity of the artwork currently mounted (see artworkKey() in index.ts). */ + private artworkKey: string | null = null; + private liveSrc: string | null = null; + private loadToken = 0; + private nowPlayingVisible = true; + /** True when we set `position: relative` on the mount and owe a revert. */ + private mountPositioned = false; + /** Aborts the in-flight lookup when the track changes underneath us. */ + private fetchAbort: AbortController | null = null; + + /** Give TIDAL's element back the inline style we found it with. */ + private releaseMount(): void { + if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position"); + this.mountPositioned = false; + this.mount = null; + } + + /** True while a matching artwork tile is alive. */ + private tileAlive(): boolean { + if (this.tile?.isConnected) return true; + const tile = document.querySelector(ART_TILE_SELECTOR); + if (!tile) return false; + this.host(tile); + return true; + } + + private host(tile: HTMLElement): void { + this.tile = tile; + // TIDAL's artwork tile is an : a replaced element never renders children, + // so an appended