diff --git a/package-lock.json b/package-lock.json index 8afe48d..3e78246 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,6 @@ "packages": { "": { "name": "@meowarex/TidalLuna-Plugins", - "dependencies": { - "pnpm": "^10.14.0" - }, "devDependencies": { "@types/node": "^22.15.29", "@types/react": "^19.1.6", @@ -1641,22 +1638,6 @@ "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": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", diff --git a/plugins/audio-visualizer-luna/src/audio.ts b/plugins/audio-visualizer-luna/src/audio.ts index 08c5cba..1c02f6b 100644 --- a/plugins/audio-visualizer-luna/src/audio.ts +++ b/plugins/audio-visualizer-luna/src/audio.ts @@ -33,10 +33,7 @@ export interface AudioData { binCount: number; } -// Connection states -// disconnected - no audio source -// pending - wired but audio isn't detected (track muted / loading) -// live - audio is detected +// Connection states: disconnected, pending (no audio yet), live export type ConnectionState = "disconnected" | "pending" | "live"; let state: ConnectionState = "disconnected"; let onStateChange: ((state: ConnectionState) => void) | null = null; @@ -202,13 +199,13 @@ const captureFromEl = (el: HTMLMediaElement): boolean => { track.removeEventListener("ended", onEnded); }; - // Captured tracks start muted until samples flow; wait for "unmute" instead of guessing. + // Captured tracks start muted, wait for unmute loggedCaptureFailure = false; setState(track.muted ? "pending" : "live"); return true; }; -// An element is worth capturing from when it's actually advancing audio. +// Only capture elements that are playing const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2; diff --git a/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts b/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts index 3b51282..ad0896f 100644 --- a/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts +++ b/plugins/audio-visualizer-luna/src/visualizers/spectrum-bars.ts @@ -36,7 +36,7 @@ void main() { float barMask = smoothstep(0.0, u_gap, cellPos) * smoothstep(0.0, u_gap, 1.0 - cellPos); - // Hard cut at bottom, soft feather only at the top edge + // Hard bottom, feathered top float feather = 1.5 / u_resolution.y; float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y); diff --git a/plugins/copy-lyrics-luna/src/index.ts b/plugins/copy-lyrics-luna/src/index.ts index d472142..2025724 100644 --- a/plugins/copy-lyrics-luna/src/index.ts +++ b/plugins/copy-lyrics-luna/src/index.ts @@ -181,7 +181,7 @@ const onClickHooked = (event: MouseEvent): boolean | undefined => { return false; }; -// Add event listener with capture phase to intercept events before they reach other handlers +// Capture phase, runs before other handlers document.addEventListener("click", onClickHooked, true); diff --git a/plugins/element-hider-luna/src/index.ts b/plugins/element-hider-luna/src/index.ts index fbdba33..afc9204 100644 --- a/plugins/element-hider-luna/src/index.ts +++ b/plugins/element-hider-luna/src/index.ts @@ -20,9 +20,7 @@ new StyleTag("Element-Hider", unloads, styles); let targetElement: HTMLElement | null = null; let hiddenElements = new WeakSet(); -// Count of elements currently hidden in the live DOM. The `.element-hider-hidden` -// class is the source of truth — querying it avoids retaining detached nodes -// across SPA navigations. +// Count hidden elements in the live DOM function getHiddenCount(): number { return document.querySelectorAll(".element-hider-hidden").length; } @@ -371,7 +369,7 @@ let customMenu: HTMLElement | null = null; let contextMenuTimeout: number | null = null; let waitingForBuiltInMenu = false; -// Listen for right-click events to capture the target for context menu +// Grab the right-clicked element document.addEventListener( "contextmenu", (event: MouseEvent) => { @@ -403,18 +401,18 @@ document.addEventListener( const eventX = event.clientX; const eventY = event.clientY; - // Allow native context menu by default; we'll show our custom menu only if needed + // Native menu first, ours only if needed // Wait to see if the built-in context menu appears contextMenuTimeout = window.setTimeout(() => { - // If we're still waiting and no built-in menu appeared, show our custom menu + // No built-in menu appeared, show ours if (waitingForBuiltInMenu && currentContextElement) { showCustomMenu(eventX, eventY); } waitingForBuiltInMenu = false; }, 150); // Wait 150ms for built-in menu - // Don't prevent default initially - let Luna try to handle the context menu + // Let Luna handle the context menu first }, true, ); 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..b15fc2a 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, ); @@ -200,7 +210,7 @@ export const Settings = () => { window.updateRadiantLyricsBackdrop?.(); }; - // Derive props and override onChange to accept a broader first param type + // Loosen onChange's first param type type BaseSwitchProps = React.ComponentProps; type AnySwitchProps = Omit & { onChange: (_: unknown, checked: boolean) => void; @@ -915,6 +925,31 @@ export const Settings = () => { refreshBackdrop(); }} /> + { + settings.animatedArtwork = checked; + setAnimatedArtwork(checked); + window.updateAnimatedArtwork?.(); + }} + /> + {animatedArtwork && ( + { + settings.animatedArtworkFps = value; + setAnimatedArtworkFps(value); + window.updateAnimatedArtworkRate?.(); + }} + /> + )} {backdropEnabled && ( <> ; }; _cached?: boolean; - /** true when timings came from the hosted Radiant AI model (?synthesize=true) */ + // Timings came from Radiant AI _synthesized?: boolean; - /** original line-level data, kept so AI timings can be toggled off without a refetch */ + // Original lines, so AI can be turned off without refetch lines?: ApiLine[]; } @@ -202,6 +202,127 @@ export async function fetchLyrics( return null; } +export interface AnimatedArtwork { + url: string; + url_tall: string; +} + +export interface ArtworkLookup { + artwork: AnimatedArtwork | null; + // Album key if album matched, else title key + key: string; +} + +// Key for this exact track only +export const artworkTitleKey = (artist: string, title: string): string => + `${artist}\u0000\u0000${title}`; + +// ISRC lookup, immune to name mismatches +const artworkIsrcUrl = (isrc: string): string => + `https://ama.trainswift.net/api/v1/artwork/search?isrc=${encodeURIComponent(isrc)}`; + +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}`; +} + +// Cache hits and misses, album key only for album matches +const animatedArtworkCache = new Map(); +const ARTWORK_CACHE_MAX = 200; + +export async function fetchAnimatedArtwork( + title: string, + artist: string, + album?: string, + isrc?: string, + signal?: AbortSignal, +): Promise { + const albumName = album?.trim() || ""; + const albumPresent = albumName !== ""; + const isrcTrimmed = isrc?.trim() || ""; + // Album key, or title key without an album + 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 { + // ISRC first, misses fall through uncached + if (isrcTrimmed) { + const byIsrc = await tryFetch(artworkIsrcUrl(isrcTrimmed)); + if (byIsrc) return { artwork: remember(byIsrc), key: cacheKey }; + } + // Fallback: 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) { + // Title-only result, scope it to this track + 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..28cd91e --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/artwork.ts @@ -0,0 +1,397 @@ +// MARKER: Animated Artwork (live artwork in the lyrics view) +// Layer finds the art, surfaces draw it on covers. +// 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 { + type AnimatedArtwork, + artworkTitleKey, + fetchAnimatedArtwork, +} from "./api"; +import { settings } from "./Settings"; + +const { trace } = Tracer("[Radiant Lyrics]"); + +export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]'; +// Player bar thumbnail +export const MINI_ART_SELECTOR = + '[data-test="footer-player"] [data-test="current-media-imagery"] img'; +// Can't hold children, so the video goes beside them +const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]); +// Skip the 2160px rung (91MB a loop) +const ART_MAX_DPR = 1; +const ART_NATIVE_FPS = 25; +// Same as the CSS fade +const ART_FADE_MS = 300; + +// One video per cover, moved (not rebuilt) on remount +class ArtworkSurface { + private video: HTMLVideoElement | null = null; + private hls: Hls | null = null; + private tile: HTMLElement | null = null; + // The tile, or its parent for replaced elements + private mount: HTMLElement | null = null; + /** True when the mount's inline position is ours to revert. */ + private mountPositioned = false; + private resizeObs: ResizeObserver | null = null; + private resizeRaf = 0; + private artwork: AnimatedArtwork | null = null; + private liveSrc: string | null = null; + // Errored stream, skipped until the art changes + private failedSrc: string | null = null; + private fadeTimer = 0; + private visible = true; + private nativeFps = ART_NATIVE_FPS; + + constructor( + private readonly selector: string, + // Use the tall stream on portrait covers + private readonly allowTall: boolean, + ) {} + + // Show art, or fade out on 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 (moves, never rebuilds) + bind(): void { + if (!this.artwork || !settings.animatedArtwork) return; + const tile = document.querySelector(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; + } + // Came back mid fade, keep it + 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"); + } + } + // Moving keeps the buffered stream + if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video); + this.syncGeometry(); + this.ensurePlaying(); + } + + setVisible(visible: boolean): void { + // 200ms tick, bail unless it flipped + if (visible === this.visible) return; + this.visible = visible; + // Must resume explicitly; nothing else restarts a paused video. + this.ensurePlaying(); + } + + // Is the cover on screen + 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 once the first frame is ready + 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 only, native HLS is broken in Chromium + 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(); + } + + // FPS is the only CPU 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; + } + + // Fade out, then drop the video + private fadeOut(): void { + const video = this.video; + 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); + } + + private teardown(): void { + if (this.fadeTimer !== 0) clearTimeout(this.fadeTimer); + this.fadeTimer = 0; + this.video?.remove(); + this.video = null; + this.hls?.destroy(); + 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 { + this.tile = tile; + // Covers are s, so host the video in the parent + 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(); + } + + 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 + 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.bind(); + this.syncGeometry(); + }); + }); + this.resizeObs.observe(this.tile); + } +} + +export interface ArtworkTrack { + trackId: string; + title: string; + artist: string; + album?: string; + isrc?: string; +} + +// Finds art for the playing track, feeds every cover +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; + // Album key if album matched, else title key + private artworkKey: string | null = null; + private loadToken = 0; + private fetchAbort: AbortController | null = null; + + // Called on track change + async setTrack(track: ArtworkTrack, albumKey: string): Promise { + // Same track, keep playing + if (track.trackId === this.trackId) { + this.rebind(); + return; + } + this.trackId = track.trackId; + const token = ++this.loadToken; + this.fetchAbort?.abort(); + this.fetchAbort = null; + + // Same album art, nothing to fetch + if ( + this.artworkKey !== null && + (albumKey === this.artworkKey || + artworkTitleKey(track.artist, track.title) === this.artworkKey) + ) { + this.rebind(); + return; + } + + // New art, drop the old video now + this.apply(null, null); + + const abort = new AbortController(); + 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); + } + + // Rebind after TIDAL remounts a cover + 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()); + } + + // FPS setting changed + refresh(): void { + for (const surface of this.surfaces) surface.refresh(); + } + + dispose(): void { + this.loadToken++; + this.fetchAbort?.abort(); + this.fetchAbort = null; + this.trackId = null; + this.artworkKey = null; + for (const surface of this.surfaces) surface.dispose(); + } + + private apply(artwork: AnimatedArtwork | null, key: string | null): void { + this.artworkKey = key; + for (const surface of this.surfaces) surface.show(artwork); + } +} 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..17270d8 --- /dev/null +++ b/plugins/radiant-lyrics-luna/src/hls-light.d.ts @@ -0,0 +1,5 @@ +// hls.js only types its main entry, reuse them here +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 569f62f..b4c96ac 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -20,6 +20,11 @@ import { romanizeLyrics as romanizeLyricsApi, } from "./api"; import { KawarpLayer } from "./backdrop"; +import { + AnimatedArtworkLayer, + ART_TILE_SELECTOR, + MINI_ART_SELECTOR, +} from "./artwork"; import { Settings, settings } from "./Settings"; import backdropStylesCss from "file://backdrop-styles.css?minify"; @@ -119,7 +124,7 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } => { }; }; -/** CSS var for integrated seekbar top corners — matches Floating Bar Corner Radius when floating is on */ +// Seekbar top corners match the floating bar radius const applyIntegratedSeekbarChrome = (): void => { const footer = document.querySelector( '[data-test="footer-player"]', @@ -195,7 +200,7 @@ const applyPlayerBarTintToElement = (): void => { applyIntegratedSeekbarChrome(); }; -// When floating is disabled, inject square-bar CSS to override Tidal's native floating styles +// Floating off: inject square bar CSS const applyFloatingPlayerBar = (): void => { if (settings.floatingPlayerBar) { floatingPlayerBarStyleTag.remove(); @@ -536,14 +541,59 @@ const toggleRadiantLyrics = function (): void { // Create buttons let flushLocked = false; +let flushSpinning = false; + +// Swap flush icon and loading spinner +const setFlushSpinner = (on: boolean): void => { + flushSpinning = on; + const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement | null; + if (!btn) return; + const svg = btn.querySelector("svg"); + if (!svg) return; + const svgClass = (svg.getAttribute("class") ?? "") + .split(/\s+/) + .filter((c) => c && c !== "rl-spinner"); + if (on) { + if (svg.classList.contains("rl-spinner")) return; + btn.setAttribute("data-tip", "Loading lyrics…"); + btn.setAttribute("aria-label", "Loading lyrics…"); + const spinner = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + if (svgClass.length) spinner.setAttribute("class", svgClass.join(" ")); + spinner.classList.add("rl-spinner"); + spinner.setAttribute("viewBox", "0 0 20 20"); + spinner.setAttribute("aria-hidden", "true"); + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", "10"); + circle.setAttribute("cy", "10"); + circle.setAttribute("r", "8"); + circle.setAttribute( + "style", + "fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-dasharray:42 8", + ); + spinner.appendChild(circle); + svg.replaceWith(spinner); + } else { + const spinner = btn.querySelector("svg.rl-spinner"); + if (!spinner) return; + const syncSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + if (svgClass.length) syncSvg.setAttribute("class", svgClass.join(" ")); + syncSvg.setAttribute("viewBox", "0 0 20 20"); + const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); + use.setAttribute("href", "#general__lyrics-sync"); + syncSvg.appendChild(use); + spinner.replaceWith(syncSvg); + } +}; + const unlockFlush = (): void => { flushLocked = false; + setFlushSpinner(false); const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = false; btn.style.opacity = ""; btn.style.cursor = ""; - btn.setAttribute("title", "Flush Lyrics"); + btn.setAttribute("data-tip", "Flush Lyrics"); btn.setAttribute("aria-label", "Flush Lyrics"); } }; @@ -555,15 +605,17 @@ const lockFlush = (): void => { btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; } + setFlushSpinner(true); }; const disableFlushNoLyrics = (): void => { flushLocked = true; + setFlushSpinner(false); const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = true; btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; - btn.setAttribute("title", "Track has no lyrics"); + btn.setAttribute("data-tip", "Track has no lyrics"); btn.setAttribute("aria-label", "Track has no lyrics"); } }; @@ -584,9 +636,11 @@ const flushLyrics = async (): Promise => { if (!result.ok) { if (result.notFound) { toast("No lyrics found for this track"); + unlockFlush(); return; } toastErr(`Flush failed (${result.status})`); + unlockFlush(); return; } const data = result.data; @@ -602,9 +656,12 @@ const flushLyrics = async (): Promise => { cachedLyricsKey = null; cachedLyricsData = null; onTrackChange(); + } else { + unlockFlush(); } } catch (err) { toastErr(`Flush error: ${err instanceof Error ? err.message : String(err)}`); + unlockFlush(); } }; @@ -622,7 +679,7 @@ const flushBtn = function (): void { flushButton.removeAttribute("data-test"); flushButton.setAttribute("type", "button"); flushButton.setAttribute("aria-label", "Flush Lyrics"); - flushButton.setAttribute("title", "Flush Lyrics"); + flushButton.setAttribute("data-tip", "Flush Lyrics"); flushButton.disabled = true; flushButton.style.opacity = "0.3"; flushButton.style.cursor = "not-allowed"; @@ -640,6 +697,12 @@ const flushBtn = function (): void { svgEl.appendChild(useEl); spanWrapper.appendChild(svgEl); flushButton.appendChild(spanWrapper); + // Recreated while loading, show the spinner again + if (flushSpinning) { + flushButton.setAttribute("data-tip", "Loading lyrics…"); + flushButton.setAttribute("aria-label", "Loading lyrics…"); + setFlushSpinner(true); + } flushButton.onclick = () => flushLyrics(); @@ -731,6 +794,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(); @@ -763,6 +837,18 @@ const syncBackdropActivity = (): void => { (global?.isVisible() ?? false), now, ); + + // Pause art when off screen (ticks 5x/sec) + if (animatedArtworkLayer) { + if (!cachedNowPlayingPanel?.isConnected) { + cachedNowPlayingPanel = document.querySelector( + '[class*="_nowPlayingContainer"]', + ) as HTMLElement | null; + } + animatedArtworkLayer.syncVisibility( + !!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel), + ); + } }; /** cover art @ resolution worth sampling */ @@ -1019,6 +1105,39 @@ const updateRadiantLyricsBackdrop = function (): void { } }; +// MARKER: Animated Artwork (live artwork on the artwork tiles) +let animatedArtworkLayer: AnimatedArtworkLayer | null = null; +let lastArtworkTile: Element | null = null; +let lastMiniArtworkTile: Element | null = null; +let artworkUpdateSeq = 0; + +// Album key, or title key if no album +const artworkKey = (t: TrackInfo): string => + t.album && t.album.trim() !== "" + ? `${t.artist}\u0000${t.album.trim()}` + : `${t.artist}\u0000\u0000${t.title}`; + +// Load art for the playing track +const updateAnimatedArtwork = async (): Promise => { + const seq = ++artworkUpdateSeq; + if (!settings.animatedArtwork) { + animatedArtworkLayer?.dispose(); + animatedArtworkLayer = null; + lastArtworkTile = null; + lastMiniArtworkTile = null; + return; + } + animatedArtworkLayer ??= new AnimatedArtworkLayer(); + const target = await getTrackInfo(); + // Newer track change won, bail + if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return; + await animatedArtworkLayer.setTrack(target, artworkKey(target)); +}; + +(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; @@ -1053,8 +1172,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"); @@ -1108,7 +1233,7 @@ unloads.add(() => { // MARKER: Lyrics Menu (dropdown on the lyrics toggle) const LYRICS_MENU_ICON = - ''; + ''; const applyLyricsMenuIcon = (): void => { const trigger = document.querySelector( @@ -1119,6 +1244,22 @@ const applyLyricsMenuIcon = (): void => { trigger.style.paddingLeft = "5px"; }; +const LYRICS_MENU_TIP = "Lyrics Options"; + +// Rainbow sparkle when syllables are AI generated +const syncAiIndicator = (): void => { + document.body.classList.toggle("rl-ai-lyrics", lyricsIsAiGenerated); + document + .querySelector(".rl-lyrics-menu-trigger") + ?.setAttribute( + "title", + lyricsIsAiGenerated + ? "Syllable Lyrics were generated using AI" + : LYRICS_MENU_TIP, + ); +}; +unloads.add(() => document.body.classList.remove("rl-ai-lyrics")); + // Console: Syllables.log = true/false // Verbose logging for word/syllable lyrics (hidden setting) const sylLog = (...args: unknown[]) => { @@ -1319,7 +1460,7 @@ const createLyricsMenu = (): void => { const trigger = document.createElement("div"); trigger.className = "rl-lyrics-menu-trigger"; - trigger.setAttribute("title", "Lyrics Options"); + trigger.setAttribute("title", LYRICS_MENU_TIP); trigger.innerHTML = LYRICS_MENU_ICON; for (const evtName of [ @@ -1357,6 +1498,7 @@ const createLyricsMenu = (): void => { ); lyricsToggle.appendChild(trigger); + syncAiIndicator(); if (lyricsMenuOpen) { positionDropdown(); @@ -1390,6 +1532,19 @@ function setupLyricsMenuObserver(): void { } }); + // Covers remount, move the video over (no refetch) + observe(unloads, ART_TILE_SELECTOR, (tile) => { + // Identity check, ignores our own video + if (tile === lastArtworkTile) return; + lastArtworkTile = tile; + animatedArtworkLayer?.rebind(); + }); + observe(unloads, MINI_ART_SELECTOR, (tile) => { + if (tile === lastMiniArtworkTile) return; + lastMiniArtworkTile = tile; + animatedArtworkLayer?.rebind(); + }); + // Apply word lyrics when lyrics container appears or reappears observe(unloads, '[data-test="now-playing-lyrics"]', () => { if (isTrackChangeRunning) return; @@ -1424,6 +1579,7 @@ interface TrackInfo { title: string; artist: string; isrc?: string; + album?: string; } interface SyntheticNativeLyricsState { @@ -1471,9 +1627,7 @@ const getLyricsStyle = (): number => (isWordMode() ? settings.lyricsStyle : 0); const getNowPlayingLyricsPanel = (): HTMLElement | null => document.querySelector('[data-test="now-playing-lyrics"]') as HTMLElement | null; -// Find the lyrics text container (wraps the individual lyrics-line spans). -// In the new player-market UI this element has no data-test; we locate it by -// walking up from the first lyrics-line span. +// Find the lyrics container (no data-test in new UI) const findLyricsContainer = (): HTMLElement | null => { const line = document.querySelector('span[data-test="lyrics-line"]'); if (line?.parentElement?.parentElement) { @@ -1482,8 +1636,7 @@ const findLyricsContainer = (): HTMLElement | null => { return null; }; -// Check whether a tidal lyrics span is currently the active/highlighted line. -// Player-market UI uses a CSS class matching _current_*. +// Is this Tidal line active (_current_ class) const isTidalSpanActive = (span: HTMLElement): boolean => { return Array.from(span.classList).some((c) => c.startsWith("_current_")); }; @@ -2083,8 +2236,9 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { const title = String(attr.title ?? ""); const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? ""); const isrc = attr.isrc ?? undefined; + const album = attr.album?.title ?? undefined; if (!title || !artist) return null; - return { trackId: productId, title, artist, isrc }; + return { trackId: productId, title, artist, isrc, album }; }; // MARKER: Playback clock @@ -2098,7 +2252,7 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { // So.. pick the clock per client <3 const IS_NATIVE_CLIENT = /TidaLunar/i.test(navigator.userAgent); -// currentTime is present at runtime but missing from the pinned luna typings +// currentTime exists but isn't in luna's typings const playerCurrentTime = (): number => (PlayState as unknown as { currentTime: number }).currentTime; @@ -2301,9 +2455,10 @@ const getTrackInfo = async (): Promise => { mi.tidalItem.artist?.name ?? getPrimaryArtistName(mi.tidalItem.artists) ?? ""; // REMIX Detection const isrc = mi.tidalItem.isrc ?? undefined; const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? ""); + const album = mi.tidalItem.album?.title ?? undefined; if (!baseTitle || !artist || !trackId) return null; - return { trackId, title, artist, isrc }; + return { trackId, title, artist, isrc, album }; } const state = getReduxState(); @@ -2527,10 +2682,7 @@ const restoreTidalLyrics = (): void => { savedTidalClasses = null; }; -// compute left/right singer sides for duet positioning -// Uses a pre-computed fixed mapping: first person = left, second person = right, -// 3rd+ persons / group / other = left. Same singer always gets the same side. -// (thx Opus 4.6 for this <3) +// Duet sides: 2nd singer right, rest left (thx Opus 4.6 <3) const computeSingerSides = ( data: WordLine[], agents: Record, @@ -2750,8 +2902,7 @@ const buildWordSpans = (): { span.textContent = text; } forceStyle(span, WORD_SPAN_STYLE); - // Confidence-proportional wipe feather: crisp edges where the - // synthesizer had real evidence, soft where it guessed. SUPER WIP + // Feather wipe by AI confidence SUPER WIP if (confidence !== undefined) { const feather = 0.55 + (1 - Math.max(0, Math.min(1, confidence))) * 1.3; span.style.setProperty("--rl-wipe-feather", `${feather.toFixed(2)}em`); @@ -2767,7 +2918,7 @@ const buildWordSpans = (): { const sylDisplay = (s: WordTiming) => romanizedText(s); - // Group syllables into words: trailing whitespace in syl.text marks a word boundary + // Group syllables into words (trailing space = boundary) const wordGroups: number[][] = []; let currentGroup: number[] = []; for (let si = 0; si < syllabus.length; si++) { @@ -2782,7 +2933,7 @@ const buildWordSpans = (): { } if (effectiveStyle === 0) { - // Line mode: one span per container (main / bg) — no word splitting + // Line mode: one span per container, no word split const mainSyls = syllabus.filter((s) => !splitBg || !s.isBackground); const bgSyls = splitBg ? syllabus.filter((s) => s.isBackground) : []; @@ -3140,11 +3291,7 @@ const applyInactiveBlurState = ( } }; -// Re-apply active line + word state to freshly-built DOM elements after a -// rebuild (reapply / re-render observer) WITHOUT triggering CSS transitions. -// This prevents the padding-left "swipe-shift" animation from replaying when -// the container is reconstructed during scroll-unlock, resync, or React -// re-renders. +// Reapply active state after a rebuild, without transitions const applyActiveLineStateNoTransition = (): void => { if (primaryLineIdx < 0 || activeLineIdxs.size === 0 || lines.length === 0) return; @@ -3170,7 +3317,7 @@ const applyActiveLineStateNoTransition = (): void => { el.setAttribute("data-current", "true"); } - // Restore word-level state on active lines so the tick loop doesn't flash + // Restore word state so the tick loop doesn't flash const nowMs = getPlaybackMs(); activeWordEls.clear(); activeBgWordEls.clear(); @@ -3247,7 +3394,7 @@ const applyActiveLineStateNoTransition = (): void => { } } - // Force reflow so the suppressed transitions take effect, then restore them + // Force reflow, then restore transitions void document.body.offsetHeight; for (const line of lines) { line.el.style.removeProperty("transition"); @@ -3468,6 +3615,7 @@ const teardown = (): void => { lyricsData = null; lyricsResponse = null; lyricsIsAiGenerated = false; + syncAiIndicator(); lines = []; activeWordEls.clear(); activeBgWordEls.clear(); @@ -3479,8 +3627,7 @@ const teardown = (): void => { applyScrollOwnerState(); }; -// find scrollable parent -// to avoid scrolling a shared ancestor that would shift the play queue +// find scrollable parent (don't shift the play queue) const findScroller = (el: HTMLElement): HTMLElement => { const lyricsPanel = el.closest( '[data-test="now-playing-lyrics"]', @@ -4009,6 +4156,7 @@ const onTrackChange = async (): Promise => { if (token !== trackChangeToken) return; if (!trackInfo) { trace.log("could not get track info from playback state"); + setFlushSpinner(false); return; } const nativeHasLyrics = trackHasNativeLyrics(trackInfo.trackId); @@ -4074,6 +4222,7 @@ const onTrackChange = async (): Promise => { // MARKER: Radiant AI Syllables [RL API] if (response.type === "Word" && response._synthesized) { lyricsIsAiGenerated = true; + syncAiIndicator(); sylLog( `[RL-Syllable] AI generated syllable timings for ${response.data.length} lines`, ); @@ -4303,6 +4452,7 @@ function setupHeaderObserver(): void { // Apply seeker color on track change onGlobalTrackChange(() => { updateCoverArtBackground(); + void updateAnimatedArtwork(); if (settings.qualityProgressColor) applyQualityProgressColor(); }); diff --git a/plugins/radiant-lyrics-luna/src/lyrics-glow.css b/plugins/radiant-lyrics-luna/src/lyrics-glow.css index f6e23ad..81e0464 100644 --- a/plugins/radiant-lyrics-luna/src/lyrics-glow.css +++ b/plugins/radiant-lyrics-luna/src/lyrics-glow.css @@ -1,4 +1,4 @@ -/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */ +/* Text glow only (injected when Lyrics Glow is on) */ /* MARKER: Lyrics glow CSS */ diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index c84b9e2..268e536 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -49,6 +49,48 @@ /* MARKER: Lyrics Menu CSS */ /* Lyrics toggle button */ + +/* Flush lyrics button — loading spinner while waiting for lyrics */ +.flush-lyrics-button svg.rl-spinner { + animation: rl-spinner-rotate 0.9s linear infinite; +} + +@keyframes rl-spinner-rotate { + to { + transform: rotate(360deg); + } +} + +/* Flush button tooltip (native title is unreliable) */ +.flush-lyrics-button { + position: relative !important; +} + +.flush-lyrics-button::after { + content: attr(data-tip); + position: absolute; + top: calc(100% + 6px); + right: 0; + padding: 4px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 12px; + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + pointer-events: none; + z-index: 10000; + opacity: 0; + transform: translateY(-2px); + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.flush-lyrics-button:hover::after { + opacity: 1; + transform: translateY(0); +} + [data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) { position: relative !important; padding-right: 38px !important; @@ -98,6 +140,99 @@ color: rgba(0, 0, 0, 0.5); } +/* AI sparkle: rainbow gradient + glow */ +body.rl-ai-lyrics .rl-lyrics-menu-trigger { + --rl-ai-c1: #ff6b9d; + --rl-ai-c2: #ffd166; + --rl-ai-c3: #5ee7ff; + --rl-ai-c4: #a78bfa; + --rl-ai-glow: 60%; +} + +body.rl-ai-lyrics [data-test="toggle-lyrics"][aria-pressed="true"] .rl-lyrics-menu-trigger { + --rl-ai-c1: #e8336f; + --rl-ai-c2: #f08c00; + --rl-ai-c3: #0a9bd0; + --rl-ai-c4: #7048e8; + --rl-ai-glow: 40%; +} + +body.rl-ai-lyrics .rl-lyrics-menu-trigger svg path { + fill: url(#rl-ai-gradient); +} + +body.rl-ai-lyrics .rl-lyrics-menu-trigger svg { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c4) var(--rl-ai-glow), transparent)); + transition: opacity 0.2s ease; + animation: rl-ai-glow 6s linear infinite; +} + +body.rl-ai-lyrics [data-test="toggle-lyrics"][aria-pressed="true"] .rl-lyrics-menu-trigger:hover svg { + opacity: 0.7; +} + +body.rl-ai-lyrics .rl-ai-stop { + animation: rl-ai-flow 6s linear infinite; +} + +body.rl-ai-lyrics .rl-ai-stop:nth-child(1) { + stop-color: var(--rl-ai-c1); +} + +body.rl-ai-lyrics .rl-ai-stop:nth-child(2) { + stop-color: var(--rl-ai-c2); + animation-delay: -1.5s; +} + +body.rl-ai-lyrics .rl-ai-stop:nth-child(3) { + stop-color: var(--rl-ai-c3); + animation-delay: -3s; +} + +body.rl-ai-lyrics .rl-ai-stop:nth-child(4) { + stop-color: var(--rl-ai-c4); + animation-delay: -4.5s; +} + +@keyframes rl-ai-flow { + 0%, + 100% { + stop-color: var(--rl-ai-c1); + } + 25% { + stop-color: var(--rl-ai-c2); + } + 50% { + stop-color: var(--rl-ai-c3); + } + 75% { + stop-color: var(--rl-ai-c4); + } +} + +@keyframes rl-ai-glow { + 0%, + 100% { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c4) var(--rl-ai-glow), transparent)); + } + 25% { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c1) var(--rl-ai-glow), transparent)); + } + 50% { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c2) var(--rl-ai-glow), transparent)); + } + 75% { + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c3) var(--rl-ai-glow), transparent)); + } +} + +@media (prefers-reduced-motion: reduce) { + body.rl-ai-lyrics .rl-lyrics-menu-trigger svg, + body.rl-ai-lyrics .rl-ai-stop { + animation: none; + } +} + /* Animate widening when dropdown opens */ [data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) { transition: min-width 0.12s ease-out; @@ -247,14 +382,14 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] { /* MARKER: Integrated Seek Bar */ /* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */ -/* Scrubber row stays in flow — centers the time block as one unit */ +/* Keep scrubber row in flow, centered */ body.rl-integrated-seekbar .rl-seekbar-container { justify-content: center !important; align-items: center !important; gap: 0 !important; } -/* Single string: "current | duration" — synced from native