diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index a931ce7..139195d 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -9,6 +9,7 @@ declare global { updateRadiantLyricsPlayerBarTint?: () => void; updateRadiantLyricsBackdrop?: () => void; updateAnimatedArtwork?: (target?: unknown) => void; + updateAnimatedArtworkRate?: () => void; updateQualityProgressColor?: () => void; updateIntegratedSeekBar?: () => void; updateLyricsStyle?: () => void; @@ -50,6 +51,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { // Master switch backdropEnabled: true, animatedArtwork: true, + animatedArtworkFps: 25, backdropStyle: 0, backdropPlaybackReactive: true, CoverEverywhere: true, @@ -87,6 +89,9 @@ export const Settings = () => { const [animatedArtwork, setAnimatedArtwork] = React.useState( settings.animatedArtwork, ); + const [animatedArtworkFps, setAnimatedArtworkFps] = React.useState( + settings.animatedArtworkFps, + ); const [backdropStyle, setBackdropStyle] = React.useState( settings.backdropStyle, ); @@ -930,6 +935,21 @@ export const Settings = () => { window.updateAnimatedArtwork?.(); }} /> + {animatedArtwork && ( + { + settings.animatedArtworkFps = value; + setAnimatedArtworkFps(value); + window.updateAnimatedArtworkRate?.(); + }} + /> + )} {backdropEnabled && ( <> (); const ARTWORK_CACHE_MAX = 200; @@ -232,8 +228,7 @@ export async function fetchAnimatedArtwork( 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. + // Keyed by album, matching artworkKey() in index.ts. const cacheKey = album && album.trim() !== "" ? `${artist}\u0000${album.trim()}` @@ -244,7 +239,6 @@ export async function fetchAnimatedArtwork( 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(); @@ -277,7 +271,7 @@ export async function fetchAnimatedArtwork( }; const remember = (result: AnimatedArtwork | null): AnimatedArtwork | null => { - // Never cache a cancelled attempt — it says nothing about the track. + // A cancelled attempt says nothing about the track. if (controller.signal.aborted) return result; if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) { const oldest = animatedArtworkCache.keys().next().value; diff --git a/plugins/radiant-lyrics-luna/src/artwork.ts b/plugins/radiant-lyrics-luna/src/artwork.ts index 0aa2f37..8d777a9 100644 --- a/plugins/radiant-lyrics-luna/src/artwork.ts +++ b/plugins/radiant-lyrics-luna/src/artwork.ts @@ -3,8 +3,7 @@ // 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. +// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts. import Hls from "hls.js/light"; import { fetchAnimatedArtwork } from "./api"; @@ -15,27 +14,37 @@ 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"]); +// Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU. +const ART_MAX_DPR = 1; +const ART_NATIVE_FPS = 25; 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. */ + /** The tile, 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). */ + /** 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. */ + /** True when the mount's inline position is ours to revert. */ private mountPositioned = false; - /** Aborts the in-flight lookup when the track changes underneath us. */ private fetchAbort: AbortController | null = null; + private nativeFps = ART_NATIVE_FPS; + + // CPU tracks presented fps and nothing else, so this is the only perf knob. + private applyRate(): void { + if (!this.video) return; + const target = Number(settings.animatedArtworkFps) || ART_NATIVE_FPS; + const rate = Math.min(1, Math.max(0.05, target / this.nativeFps)); + if (this.video.playbackRate !== rate) this.video.playbackRate = rate; + } - /** 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; @@ -53,15 +62,13 @@ export class AnimatedArtworkLayer { private host(tile: HTMLElement): void { this.tile = tile; - // TIDAL's artwork tile is an : a replaced element never renders children, - // so an appended