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..139195d 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -8,6 +8,8 @@ declare global { updateRadiantLyricsTextGlow?: () => void; updateRadiantLyricsPlayerBarTint?: () => void; updateRadiantLyricsBackdrop?: () => void; + updateAnimatedArtwork?: (target?: unknown) => void; + updateAnimatedArtworkRate?: () => void; updateQualityProgressColor?: () => void; updateIntegratedSeekBar?: () => void; updateLyricsStyle?: () => void; @@ -48,6 +50,8 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { playerBarTintCustomColors: [] as string[], // Master switch backdropEnabled: true, + animatedArtwork: true, + animatedArtworkFps: 25, backdropStyle: 0, backdropPlaybackReactive: true, CoverEverywhere: true, @@ -82,6 +86,12 @@ export const Settings = () => { const [backdropEnabled, setBackdropEnabled] = React.useState( settings.backdropEnabled, ); + const [animatedArtwork, setAnimatedArtwork] = React.useState( + settings.animatedArtwork, + ); + const [animatedArtworkFps, setAnimatedArtworkFps] = React.useState( + settings.animatedArtworkFps, + ); const [backdropStyle, setBackdropStyle] = React.useState( settings.backdropStyle, ); @@ -915,6 +925,31 @@ export const Settings = () => { refreshBackdrop(); }} /> + { + settings.animatedArtwork = checked; + setAnimatedArtwork(checked); + window.updateAnimatedArtwork?.(); + }} + /> + {animatedArtwork && ( + { + settings.animatedArtworkFps = value; + setAnimatedArtworkFps(value); + window.updateAnimatedArtworkRate?.(); + }} + /> + )} {backdropEnabled && ( <> + `${artist}\u0000\u0000${title}`; + +function artworkSearchUrl( + title: string, + artist: string, + album?: string, +): string { + let q = `title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; + // Omit the album param entirely when absent (a literal "undefined" string makes the endpoint miss) + if (album && album.trim() !== "") q += `&album=${encodeURIComponent(album.trim())}`; + return `https://ama.trainswift.net/api/v1/artwork/search?${q}`; +} + +// Cached including misses; most tracks have none and the answer never changes. +// Only album-matched results go under the album key — a title-only fallback +// must not stand in for the album's other tracks (see ArtworkLookup). +const animatedArtworkCache = new Map(); +const ARTWORK_CACHE_MAX = 200; + +export async function fetchAnimatedArtwork( + title: string, + artist: string, + album?: string, + signal?: AbortSignal, +): Promise { + const albumName = album?.trim() || ""; + const albumPresent = albumName !== ""; + // Album key, matching artworkKey() in index.ts; without an album the + // answer is only ever matched by title, so key by title directly. + const cacheKey = albumPresent + ? `${artist}\u0000${albumName}` + : artworkTitleKey(artist, title); + if (animatedArtworkCache.has(cacheKey)) { + return { artwork: animatedArtworkCache.get(cacheKey) ?? null, key: cacheKey }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + 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 => { + // 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; + 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), + ); + // Album-matched: cacheable, and its scope is the whole album. + if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey }; + if (albumPresent) { + // Album lookup missed: the title-only answer — hit or miss — is + // matched by title, so never cache it under the album key (that + // would poison the album's other tracks) and scope it to the title. + const artwork = await tryFetch(artworkSearchUrl(title, artist)); + return { artwork, key: artworkTitleKey(artist, title) }; + } + // No album: the title key is already track-specific. + return { artwork: remember(null), key: cacheKey }; + } 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..4d46912 --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/artwork.ts @@ -0,0 +1,337 @@ +// 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"; +// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts. +import Hls from "hls.js/light"; + +import { artworkTitleKey, 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"]); +// 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; + /** 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; + /** + * Scope key of the displayed artwork (see fetchAnimatedArtwork): the album + * key only when the answer was matched by album, 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. */ + private mountPositioned = false; + 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; + } + + 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; + // The tile is an ; a replaced element silently drops children, so + // host the video in the parent and lay it over the tile. + const replaced = REPLACED_TAGS.has(tile.tagName); + const mount = replaced ? tile.parentElement : tile; + if (mount !== this.mount) this.releaseMount(); + this.mount = mount; + if (this.mount && getComputedStyle(this.mount).position === "static") { + this.mountPositioned = true; + this.mount.style.position = "relative"; + } + this.watchSizing(); + } + + /** Lay the video over the tile when it is not a direct child of it. */ + private syncGeometry(): void { + const { video, tile, mount } = this; + if (!video || !tile || !mount) return; + if (mount === tile) return; + video.style.left = `${tile.offsetLeft}px`; + video.style.top = `${tile.offsetTop}px`; + video.style.width = `${tile.offsetWidth}px`; + video.style.height = `${tile.offsetHeight}px`; + video.style.right = "auto"; + video.style.bottom = "auto"; + video.style.borderRadius = getComputedStyle(tile).borderRadius; + } + + private watchSizing(): void { + if (!this.tile) return; + // host() can re-run against a fresh tile. + this.resizeObs?.disconnect(); + this.resizeObs ??= new ResizeObserver(() => { + if (!this.artwork || !this.video) return; + // Deferred: syncGeometry writes styles and would retrigger us. + if (this.resizeRaf !== 0) return; + this.resizeRaf = requestAnimationFrame(() => { + this.resizeRaf = 0; + // Only rebuild the stream if the aspect actually flipped. + if (this.pickSrc() !== this.liveSrc) this.mountVideo(false); + this.syncGeometry(); + }); + }); + this.resizeObs.observe(this.tile); + } + + /** + * Mount the artwork for `key`, leaving any playing video alone until a + * replacement resolves. `done: false` means no tile was on screen, so + * retry later. Returns the scope `key` the result is valid under (see + * fetchAnimatedArtwork), which may be narrower than the request key. + */ + async load( + key: string, + title: string, + artist: string, + album: string | undefined, + ): 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 }; + } + const token = ++this.loadToken; + this.fetchAbort?.abort(); + this.fetchAbort = new AbortController(); + const signal = this.fetchAbort.signal; + + if (!settings.animatedArtwork) return { done: true, key }; + // Only check a tile exists; host after the fetch so the old video keeps playing. + if (!document.querySelector(ART_TILE_SELECTOR)) + return { done: false, key }; + + const lookup = await fetchAnimatedArtwork(title, artist, album, 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(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 ( + this.artwork && + this.video && + settings.animatedArtwork && + this.nowPlayingVisible && + !document.hidden && + this.tile?.isConnected + ) { + void this.video.play().catch(() => {}); + } else { + this.video?.pause(); + } + } + + setNowPlayingVisible(visible: boolean): void { + // Called from the 200ms tick, so bail unless it actually flipped. + if (visible === this.nowPlayingVisible) return; + this.nowPlayingVisible = visible; + // Must resume explicitly; nothing else restarts a paused video. + this.ensurePlaying(); + } + + refresh(): void { + if (!this.liveSrc) return; + 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 { + this.loadToken++; + this.fetchAbort?.abort(); + this.fetchAbort = null; + this.resizeObs?.disconnect(); + this.resizeObs = null; + if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf); + this.resizeRaf = 0; + this.detach(false); + this.releaseMount(); + this.tile = null; + } +} \ No newline at end of file diff --git a/plugins/radiant-lyrics-luna/src/hls-light.d.ts b/plugins/radiant-lyrics-luna/src/hls-light.d.ts new file mode 100644 index 0000000..420de0e --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/hls-light.d.ts @@ -0,0 +1,6 @@ +// hls.js ships types for its main entry only. The light build is the same public +// API with alt-audio, subtitles and EME stripped, so point it at those types. +declare module "hls.js/light" { + export * from "hls.js"; + export { default } from "hls.js"; +} diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 94b6d4c..f84a5a5 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -15,11 +15,13 @@ import { type LyricsApiResponse, type WordLine, type WordTiming, + artworkTitleKey, fetchLyrics as fetchLyricsApi, flushLyrics as flushLyricsApi, romanizeLyrics as romanizeLyricsApi, } from "./api"; import { KawarpLayer } from "./backdrop"; +import { AnimatedArtworkLayer, ART_TILE_SELECTOR } from "./artwork"; import { Settings, settings } from "./Settings"; import backdropStylesCss from "file://backdrop-styles.css?minify"; @@ -789,6 +791,17 @@ const setLayerRunning = ( if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true); }; +// Cached so the 200ms tick skips a document-wide query. +let cachedNowPlayingPanel: HTMLElement | null = null; + +// On screen, not just mounted. +const artPanelVisible = (panel: HTMLElement): boolean => { + if (typeof panel.checkVisibility === "function") { + return panel.checkVisibility({ checkVisibilityCSS: true }); + } + return getComputedStyle(panel).visibility !== "hidden"; +}; + const syncBackdropActivity = (): void => { // Stock backdrop keeps current (no shader loop) if (stockContainer) applyStockCoverEverywhere(); @@ -821,6 +834,18 @@ const syncBackdropActivity = (): void => { (global?.isVisible() ?? false), now, ); + + // Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second. + if (animatedArtworkLayer) { + if (!cachedNowPlayingPanel?.isConnected) { + cachedNowPlayingPanel = document.querySelector( + '[class*="_nowPlayingContainer"]', + ) as HTMLElement | null; + } + animatedArtworkLayer.setNowPlayingVisible( + !!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel), + ); + } }; /** cover art @ resolution worth sampling */ @@ -1077,6 +1102,68 @@ const updateRadiantLyricsBackdrop = function (): void { } }; +// MARKER: Animated Artwork (live artwork on the artwork tile) +let animatedArtworkLayer: AnimatedArtworkLayer | null = null; +let lastArtworkKey: string | null = null; +let lastArtworkTile: Element | null = null; + +/** + * Request key for an animated-artwork lookup: album-scoped when an album is + * present (skips within one are free). A result may still be scoped tighter — + * see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the + * effective scope, not this request key. + */ +const artworkKey = (t: TrackInfo): string => + t.album && t.album.trim() !== "" + ? `${t.artist}\u0000${t.album.trim()}` + : `${t.artist}\u0000\u0000${t.title}`; + +// Load + show animated art for the current (or given) track +const updateAnimatedArtwork = async ( + target?: TrackInfo | null, + rehost = false, +): Promise => { + if (!settings.animatedArtwork) { + animatedArtworkLayer?.dispose(); + animatedArtworkLayer = null; + lastArtworkKey = null; + lastArtworkTile = null; + return; + } + if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer(); + target ??= await getTrackInfo(); + if (!target) return; + // Re-check after the await; another caller may have claimed this already. + if (!settings.animatedArtwork || !animatedArtworkLayer) return; + 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, + ); + // 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).updateAnimatedArtworkRate = (): void => + animatedArtworkLayer?.refresh(); + // Make these functions available globally so Settings can call them (window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles; (window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop; @@ -1111,8 +1198,14 @@ updateRadiantLyricsTextGlow(); // Init global background updateCoverArtBackground(1); +// Deferred: getTrackInfo() is declared further down and would be uninitialised. +queueMicrotask(() => void updateAnimatedArtwork()); + // Cleanups unloads.add(() => { + animatedArtworkLayer?.dispose(); + animatedArtworkLayer = null; + cachedNowPlayingPanel = null; cleanUpDynamicArt(); cleanUpStockCoverEverywhere(); document.body.classList.remove("rl-backdrop-active"); @@ -1448,6 +1541,15 @@ function setupLyricsMenuObserver(): void { } }); + // The tile only exists once the view is open, so load from here too. This fires + // on nearly every DOM change, so bail synchronously unless the tile is new. + observe(unloads, ART_TILE_SELECTOR, (tile) => { + // Identity only; also stops us reacting to our own