feat: add fps setting

This commit is contained in:
Daniel committed 2026-09-26 10:11:26 +02:00
1 parent 8ef69f57a2
commit 47813e0559
4 files changed
+74 -77

No files matched your search

@@ -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 && (
<LunaNumberSetting
title="Animated Artwork Frame Rate"
desc="Frames per second for the animated artwork. Lower is cheaper but choppier; 25 is native speed."
min={1}
max={25}
step={1}
value={animatedArtworkFps}
onNumber={(value: number) => {
settings.animatedArtworkFps = value;
setAnimatedArtworkFps(value);
window.updateAnimatedArtworkRate?.();
}}
/>
)}
{backdropEnabled && (
<>
<LunaNumberSetting
+3 -9
View File
@@ -218,11 +218,7 @@ function artworkSearchUrl(
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
}
/**
* Lookups keyed by track, including misses (`null`): most tracks have no animated
* art, and re-opening the lyrics view or revisiting a track would otherwise hit
* the network again for an answer that does not change.
*/
// Cached including misses; most tracks have none and the answer never changes.
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
const ARTWORK_CACHE_MAX = 200;
@@ -232,8 +228,7 @@ export async function fetchAnimatedArtwork(
album?: string,
signal?: AbortSignal,
): Promise<AnimatedArtwork | null> {
// 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;
+39 -43
View File
@@ -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 <img>: a replaced element never renders children,
// so an appended <video> would vanish without error. Host it in the parent and
// position it over the tile instead.
// The tile is an <img>; 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") {
// Remember that the inline style is ours so teardown can undo it.
this.mountPositioned = true;
this.mount.style.position = "relative";
}
@@ -84,12 +91,11 @@ export class AnimatedArtworkLayer {
private watchSizing(): void {
if (!this.tile) return;
// host() can re-run against a fresh tile, so always re-target the observer.
// host() can re-run against a fresh tile.
this.resizeObs?.disconnect();
this.resizeObs ??= new ResizeObserver(() => {
if (!this.artwork || !this.video) return;
// Deferred to the next frame: syncGeometry reads layout and then writes
// styles, which inside the callback would retrigger the observer.
// Deferred: syncGeometry writes styles and would retrigger us.
if (this.resizeRaf !== 0) return;
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = 0;
@@ -102,14 +108,8 @@ export class AnimatedArtworkLayer {
}
/**
* Resolve and mount the artwork identified by `key`. Uses the exact album
* first and retries without it on a miss (handled inside the fetch).
*
* The currently playing video is deliberately left alone until a replacement
* is known: animated artwork belongs to the album, so skipping tracks within
* one must not tear the video down and refetch it. Returns false when the
* attempt could not be completed because no artwork tile was on screen, so
* the caller can retry once the view appears.
* Mount the artwork for `key`, leaving any playing video alone until a
* replacement resolves. False means no tile was on screen, so retry later.
*/
async load(
key: string,
@@ -123,14 +123,12 @@ export class AnimatedArtworkLayer {
return true;
}
const token = ++this.loadToken;
// Stop the previous lookup from holding a connection open for a track we left.
this.fetchAbort?.abort();
this.fetchAbort = new AbortController();
const signal = this.fetchAbort.signal;
if (!settings.animatedArtwork) return true;
// Only confirm a tile exists; hosting happens after the fetch resolves so
// the outgoing video keeps its mount (and keeps playing) until then.
// Only check a tile exists; host after the fetch so the old video keeps playing.
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR)) return false;
const artwork = await fetchAnimatedArtwork(title, artist, album, signal);
@@ -138,15 +136,13 @@ export class AnimatedArtworkLayer {
if (!this.tileAlive()) return false;
if (!artwork) {
// A definitive miss — no point retrying when the view remounts.
trace.log(`Animated Artwork: no animated art for "${title}"`);
this.detach(false);
// Set after detach(), which clears the key along with the artwork.
// After detach(), which clears the key too.
this.artworkKey = key;
return true;
}
// Same stream as what is already on screen (e.g. a compilation whose
// tracks resolve to one album): adopt the key and leave the video be.
// Same stream already on screen: adopt the key, leave the video be.
const unchanged =
this.artwork?.url === artwork.url &&
this.artwork?.url_tall === artwork.url_tall &&
@@ -218,20 +214,24 @@ export class AnimatedArtworkLayer {
this.video = video;
this.syncGeometry();
// MSE first: Chromium answers "maybe" to canPlayType for HLS but then fails
// the native demuxer on the playlist itself (DEMUXER_ERROR_COULD_NOT_PARSE),
// so native playback is only a fallback for engines without MSE.
// 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,
// The tile is ~516 CSS px; without this, ABR happily picks the
// 2160x2160 ladder rung (91MB for a 34s loop, vs 6MB at 768x768)
// and decodes 4x more pixels than the display can show.
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(
@@ -246,6 +246,7 @@ export class AnimatedArtworkLayer {
return;
}
video.classList.add("rl-animated-art-visible");
this.applyRate();
this.ensurePlaying();
}
@@ -263,7 +264,6 @@ export class AnimatedArtworkLayer {
this.detach(false);
};
/** Respect settings + panel visibility when toggling play/pause. */
ensurePlaying(): void {
if (
this.artwork &&
@@ -279,20 +279,18 @@ export class AnimatedArtworkLayer {
}
}
/** Set whether the Now Playing view is currently visible on screen. */
setNowPlayingVisible(visible: boolean): void {
// Called from the 200ms activity tick: bail unless the state actually flipped.
// Called from the 200ms tick, so bail unless it actually flipped.
if (visible === this.nowPlayingVisible) return;
this.nowPlayingVisible = visible;
// Re-show has to resume explicitly: the view is hidden by CSS rather than
// unmounted, so the tile observer never fires and nothing else would restart it.
// Must resume explicitly; nothing else restarts a paused video.
this.ensurePlaying();
}
/** Keep the video mounted but re-evaluate play state (settings / visibility). */
refresh(): void {
if (!this.liveSrc) return;
if (settings.animatedArtwork) {
this.applyRate();
if (this.video) this.ensurePlaying();
else this.mountVideo(true);
} else {
@@ -312,7 +310,6 @@ export class AnimatedArtworkLayer {
}
}
/** Full teardown, including tile references. */
dispose(): void {
this.loadToken++;
this.fetchAbort?.abort();
@@ -321,7 +318,6 @@ export class AnimatedArtworkLayer {
this.resizeObs = null;
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = 0;
// detach(true) keeps nothing on disk, but we are disposing
this.detach(false);
this.releaseMount();
this.tile = null;
+12 -25
View File
@@ -732,11 +732,10 @@ const setLayerRunning = (
if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true);
};
// Cached so the 200ms activity tick does not re-run a document-wide
// attribute-substring query; invalidated as soon as the node detaches.
// Cached so the 200ms tick skips a document-wide query.
let cachedNowPlayingPanel: HTMLElement | null = null;
// Is the Now Playing view actually on screen? (visibility, not just mounted)
// On screen, not just mounted.
const artPanelVisible = (panel: HTMLElement): boolean => {
if (typeof panel.checkVisibility === "function") {
return panel.checkVisibility({ checkVisibilityCSS: true });
@@ -777,8 +776,7 @@ const syncBackdropActivity = (): void => {
now,
);
// Animated artwork only runs while the Now Playing view is visible.
// Guarded: without a layer there is nothing to tell, and this runs 5x/second.
// Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second.
if (animatedArtworkLayer) {
if (!cachedNowPlayingPanel?.isConnected) {
cachedNowPlayingPanel = document.querySelector(
@@ -1048,15 +1046,9 @@ const updateRadiantLyricsBackdrop = function (): void {
// MARKER: Animated Artwork (live artwork on the artwork tile)
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
let lastArtworkKey: string | null = null;
// The tile element we have already reacted to, so the DOM observer stays O(1).
let lastArtworkTile: Element | null = null;
/**
* Identity of a track's animated artwork. Animated art is an *album*
* asset, so every track on one album shares it: keying by album means skipping
* within an album neither refetches nor restarts the video. Falls back to the
* title only when the album is unknown, which is the finest identity available.
*/
/** Animated art is an album asset, so key by album and skips within one are free. */
const artworkKey = (t: TrackInfo): string =>
t.album && t.album.trim() !== ""
? `${t.artist}\u0000${t.album.trim()}`
@@ -1077,7 +1069,7 @@ const updateAnimatedArtwork = async (
if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer();
target ??= await getTrackInfo();
if (!target) return;
// Re-check after the await: another caller may have claimed this track already.
// Re-check after the await; another caller may have claimed this already.
if (!settings.animatedArtwork || !animatedArtworkLayer) return;
const key = artworkKey(target);
if (key === lastArtworkKey) {
@@ -1092,12 +1084,13 @@ const updateAnimatedArtwork = async (
target.artist,
target.album,
);
// No artwork tile was on screen (lyrics view not open yet) — clear the key so
// the tile observer can retry instead of treating this track as handled.
// No tile on screen yet; clear the key so the tile observer retries.
if (!done && lastArtworkKey === 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;
@@ -1133,9 +1126,7 @@ updateRadiantLyricsTextGlow();
// Init global background
updateCoverArtBackground(1);
// Init animated artwork (handles already-playing track on startup).
// Deferred: getTrackInfo() is a `const` declared further down this module, so
// calling it during module evaluation would hit the uninitialised binding.
// Deferred: getTrackInfo() is declared further down and would be uninitialised.
queueMicrotask(() => void updateAnimatedArtwork());
// Cleanups
@@ -1478,14 +1469,10 @@ function setupLyricsMenuObserver(): void {
}
});
// Animated artwork: the tile only exists once the Now Playing view is open,
// so the initial load has to run from here as well as on track change.
// `observe` is a document-wide childList MutationObserver, so this fires on
// virtually every DOM change. Bail synchronously unless the tile is genuinely
// new — anything heavier (or anything that mutates) would feed itself.
// 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<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
// Identity check only: cheap, and it stops us reacting to our own <video>
// insertion, whose mutation target (the tile's parent) re-matches the tile.
// Identity only; also stops us reacting to our own <video> insertion.
if (tile === lastArtworkTile) return;
lastArtworkTile = tile;
void updateAnimatedArtwork(undefined, true);