mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-15 07:53:13 +11:00
feat: add fps setting
This commit is contained in:
1 parent
8ef69f57a2
commit
47813e0559
4 files changed
+74
-77
No files matched your search
@@ -9,6 +9,7 @@ declare global {
|
|||||||
updateRadiantLyricsPlayerBarTint?: () => void;
|
updateRadiantLyricsPlayerBarTint?: () => void;
|
||||||
updateRadiantLyricsBackdrop?: () => void;
|
updateRadiantLyricsBackdrop?: () => void;
|
||||||
updateAnimatedArtwork?: (target?: unknown) => void;
|
updateAnimatedArtwork?: (target?: unknown) => void;
|
||||||
|
updateAnimatedArtworkRate?: () => void;
|
||||||
updateQualityProgressColor?: () => void;
|
updateQualityProgressColor?: () => void;
|
||||||
updateIntegratedSeekBar?: () => void;
|
updateIntegratedSeekBar?: () => void;
|
||||||
updateLyricsStyle?: () => void;
|
updateLyricsStyle?: () => void;
|
||||||
@@ -50,6 +51,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
|||||||
// Master switch
|
// Master switch
|
||||||
backdropEnabled: true,
|
backdropEnabled: true,
|
||||||
animatedArtwork: true,
|
animatedArtwork: true,
|
||||||
|
animatedArtworkFps: 25,
|
||||||
backdropStyle: 0,
|
backdropStyle: 0,
|
||||||
backdropPlaybackReactive: true,
|
backdropPlaybackReactive: true,
|
||||||
CoverEverywhere: true,
|
CoverEverywhere: true,
|
||||||
@@ -87,6 +89,9 @@ export const Settings = () => {
|
|||||||
const [animatedArtwork, setAnimatedArtwork] = React.useState(
|
const [animatedArtwork, setAnimatedArtwork] = React.useState(
|
||||||
settings.animatedArtwork,
|
settings.animatedArtwork,
|
||||||
);
|
);
|
||||||
|
const [animatedArtworkFps, setAnimatedArtworkFps] = React.useState(
|
||||||
|
settings.animatedArtworkFps,
|
||||||
|
);
|
||||||
const [backdropStyle, setBackdropStyle] = React.useState(
|
const [backdropStyle, setBackdropStyle] = React.useState(
|
||||||
settings.backdropStyle,
|
settings.backdropStyle,
|
||||||
);
|
);
|
||||||
@@ -930,6 +935,21 @@ export const Settings = () => {
|
|||||||
window.updateAnimatedArtwork?.();
|
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 && (
|
{backdropEnabled && (
|
||||||
<>
|
<>
|
||||||
<LunaNumberSetting
|
<LunaNumberSetting
|
||||||
|
|||||||
@@ -218,11 +218,7 @@ function artworkSearchUrl(
|
|||||||
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
|
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cached including misses; most tracks have none and the answer never changes.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
|
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
|
||||||
const ARTWORK_CACHE_MAX = 200;
|
const ARTWORK_CACHE_MAX = 200;
|
||||||
|
|
||||||
@@ -232,8 +228,7 @@ export async function fetchAnimatedArtwork(
|
|||||||
album?: string,
|
album?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<AnimatedArtwork | null> {
|
): Promise<AnimatedArtwork | null> {
|
||||||
// Keyed by album, matching artworkKey() in index.ts: the endpoint returns the
|
// Keyed by album, matching artworkKey() in index.ts.
|
||||||
// album's artwork, so every track on one album resolves to the same entry.
|
|
||||||
const cacheKey =
|
const cacheKey =
|
||||||
album && album.trim() !== ""
|
album && album.trim() !== ""
|
||||||
? `${artist}\u0000${album.trim()}`
|
? `${artist}\u0000${album.trim()}`
|
||||||
@@ -244,7 +239,6 @@ export async function fetchAnimatedArtwork(
|
|||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
// Caller-driven cancellation (track changed) on top of the timeout.
|
|
||||||
const onAbort = () => controller.abort();
|
const onAbort = () => controller.abort();
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
if (signal?.aborted) controller.abort();
|
if (signal?.aborted) controller.abort();
|
||||||
@@ -277,7 +271,7 @@ export async function fetchAnimatedArtwork(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remember = (result: AnimatedArtwork | null): AnimatedArtwork | null => {
|
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 (controller.signal.aborted) return result;
|
||||||
if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) {
|
if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) {
|
||||||
const oldest = animatedArtworkCache.keys().next().value;
|
const oldest = animatedArtworkCache.keys().next().value;
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
// Orchestration (track change, visibility, settings) lives in index.ts.
|
// Orchestration (track change, visibility, settings) lives in index.ts.
|
||||||
|
|
||||||
import { Tracer } from "@luna/core";
|
import { Tracer } from "@luna/core";
|
||||||
// The light build drops alt-audio, subtitles and EME — none of which a muted
|
// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts.
|
||||||
// artwork loop uses — and saves ~230kB of bundle. See hls-light.d.ts for types.
|
|
||||||
import Hls from "hls.js/light";
|
import Hls from "hls.js/light";
|
||||||
|
|
||||||
import { fetchAnimatedArtwork } from "./api";
|
import { fetchAnimatedArtwork } from "./api";
|
||||||
@@ -15,27 +14,37 @@ const { trace } = Tracer("[Radiant Lyrics]");
|
|||||||
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
|
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
|
||||||
/** Elements that cannot render child nodes, so the video must go beside them. */
|
/** Elements that cannot render child nodes, so the video must go beside them. */
|
||||||
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
|
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 {
|
export class AnimatedArtworkLayer {
|
||||||
private video: HTMLVideoElement | null = null;
|
private video: HTMLVideoElement | null = null;
|
||||||
private hls: Hls | null = null;
|
private hls: Hls | null = null;
|
||||||
private tile: HTMLElement | 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 mount: HTMLElement | null = null;
|
||||||
private resizeObs: ResizeObserver | null = null;
|
private resizeObs: ResizeObserver | null = null;
|
||||||
private resizeRaf = 0;
|
private resizeRaf = 0;
|
||||||
private artwork: { url: string; url_tall: string } | null = null;
|
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 artworkKey: string | null = null;
|
||||||
private liveSrc: string | null = null;
|
private liveSrc: string | null = null;
|
||||||
private loadToken = 0;
|
private loadToken = 0;
|
||||||
private nowPlayingVisible = true;
|
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;
|
private mountPositioned = false;
|
||||||
/** Aborts the in-flight lookup when the track changes underneath us. */
|
|
||||||
private fetchAbort: AbortController | null = null;
|
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 {
|
private releaseMount(): void {
|
||||||
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
|
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
|
||||||
this.mountPositioned = false;
|
this.mountPositioned = false;
|
||||||
@@ -53,15 +62,13 @@ export class AnimatedArtworkLayer {
|
|||||||
|
|
||||||
private host(tile: HTMLElement): void {
|
private host(tile: HTMLElement): void {
|
||||||
this.tile = tile;
|
this.tile = tile;
|
||||||
// TIDAL's artwork tile is an <img>: a replaced element never renders children,
|
// The tile is an <img>; a replaced element silently drops children, so
|
||||||
// so an appended <video> would vanish without error. Host it in the parent and
|
// host the video in the parent and lay it over the tile.
|
||||||
// position it over the tile instead.
|
|
||||||
const replaced = REPLACED_TAGS.has(tile.tagName);
|
const replaced = REPLACED_TAGS.has(tile.tagName);
|
||||||
const mount = replaced ? tile.parentElement : tile;
|
const mount = replaced ? tile.parentElement : tile;
|
||||||
if (mount !== this.mount) this.releaseMount();
|
if (mount !== this.mount) this.releaseMount();
|
||||||
this.mount = mount;
|
this.mount = mount;
|
||||||
if (this.mount && getComputedStyle(this.mount).position === "static") {
|
if (this.mount && getComputedStyle(this.mount).position === "static") {
|
||||||
// Remember that the inline style is ours so teardown can undo it.
|
|
||||||
this.mountPositioned = true;
|
this.mountPositioned = true;
|
||||||
this.mount.style.position = "relative";
|
this.mount.style.position = "relative";
|
||||||
}
|
}
|
||||||
@@ -84,12 +91,11 @@ export class AnimatedArtworkLayer {
|
|||||||
|
|
||||||
private watchSizing(): void {
|
private watchSizing(): void {
|
||||||
if (!this.tile) return;
|
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?.disconnect();
|
||||||
this.resizeObs ??= new ResizeObserver(() => {
|
this.resizeObs ??= new ResizeObserver(() => {
|
||||||
if (!this.artwork || !this.video) return;
|
if (!this.artwork || !this.video) return;
|
||||||
// Deferred to the next frame: syncGeometry reads layout and then writes
|
// Deferred: syncGeometry writes styles and would retrigger us.
|
||||||
// styles, which inside the callback would retrigger the observer.
|
|
||||||
if (this.resizeRaf !== 0) return;
|
if (this.resizeRaf !== 0) return;
|
||||||
this.resizeRaf = requestAnimationFrame(() => {
|
this.resizeRaf = requestAnimationFrame(() => {
|
||||||
this.resizeRaf = 0;
|
this.resizeRaf = 0;
|
||||||
@@ -102,14 +108,8 @@ export class AnimatedArtworkLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve and mount the artwork identified by `key`. Uses the exact album
|
* Mount the artwork for `key`, leaving any playing video alone until a
|
||||||
* first and retries without it on a miss (handled inside the fetch).
|
* replacement resolves. False means no tile was on screen, so retry later.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
async load(
|
async load(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -123,14 +123,12 @@ export class AnimatedArtworkLayer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const token = ++this.loadToken;
|
const token = ++this.loadToken;
|
||||||
// Stop the previous lookup from holding a connection open for a track we left.
|
|
||||||
this.fetchAbort?.abort();
|
this.fetchAbort?.abort();
|
||||||
this.fetchAbort = new AbortController();
|
this.fetchAbort = new AbortController();
|
||||||
const signal = this.fetchAbort.signal;
|
const signal = this.fetchAbort.signal;
|
||||||
|
|
||||||
if (!settings.animatedArtwork) return true;
|
if (!settings.animatedArtwork) return true;
|
||||||
// Only confirm a tile exists; hosting happens after the fetch resolves so
|
// Only check a tile exists; host after the fetch so the old video keeps playing.
|
||||||
// the outgoing video keeps its mount (and keeps playing) until then.
|
|
||||||
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR)) return false;
|
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR)) return false;
|
||||||
|
|
||||||
const artwork = await fetchAnimatedArtwork(title, artist, album, signal);
|
const artwork = await fetchAnimatedArtwork(title, artist, album, signal);
|
||||||
@@ -138,15 +136,13 @@ export class AnimatedArtworkLayer {
|
|||||||
if (!this.tileAlive()) return false;
|
if (!this.tileAlive()) return false;
|
||||||
|
|
||||||
if (!artwork) {
|
if (!artwork) {
|
||||||
// A definitive miss — no point retrying when the view remounts.
|
|
||||||
trace.log(`Animated Artwork: no animated art for "${title}"`);
|
trace.log(`Animated Artwork: no animated art for "${title}"`);
|
||||||
this.detach(false);
|
this.detach(false);
|
||||||
// Set after detach(), which clears the key along with the artwork.
|
// After detach(), which clears the key too.
|
||||||
this.artworkKey = key;
|
this.artworkKey = key;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Same stream as what is already on screen (e.g. a compilation whose
|
// Same stream already on screen: adopt the key, leave the video be.
|
||||||
// tracks resolve to one album): adopt the key and leave the video be.
|
|
||||||
const unchanged =
|
const unchanged =
|
||||||
this.artwork?.url === artwork.url &&
|
this.artwork?.url === artwork.url &&
|
||||||
this.artwork?.url_tall === artwork.url_tall &&
|
this.artwork?.url_tall === artwork.url_tall &&
|
||||||
@@ -218,20 +214,24 @@ export class AnimatedArtworkLayer {
|
|||||||
this.video = video;
|
this.video = video;
|
||||||
this.syncGeometry();
|
this.syncGeometry();
|
||||||
|
|
||||||
// MSE first: Chromium answers "maybe" to canPlayType for HLS but then fails
|
// MSE first: Chromium says "maybe" to canPlayType for HLS then fails to
|
||||||
// the native demuxer on the playlist itself (DEMUXER_ERROR_COULD_NOT_PARSE),
|
// demux the playlist, so native is only a fallback for engines without MSE.
|
||||||
// so native playback is only a fallback for engines without MSE.
|
|
||||||
if (typeof Hls === "function" && Hls.isSupported()) {
|
if (typeof Hls === "function" && Hls.isSupported()) {
|
||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
autoStartLoad: true,
|
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,
|
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;
|
this.hls = hls;
|
||||||
hls.loadSource(src);
|
hls.loadSource(src);
|
||||||
hls.attachMedia(video);
|
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) => {
|
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||||
if (!data.fatal) return;
|
if (!data.fatal) return;
|
||||||
this.fail(
|
this.fail(
|
||||||
@@ -246,6 +246,7 @@ export class AnimatedArtworkLayer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
video.classList.add("rl-animated-art-visible");
|
video.classList.add("rl-animated-art-visible");
|
||||||
|
this.applyRate();
|
||||||
this.ensurePlaying();
|
this.ensurePlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +264,6 @@ export class AnimatedArtworkLayer {
|
|||||||
this.detach(false);
|
this.detach(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Respect settings + panel visibility when toggling play/pause. */
|
|
||||||
ensurePlaying(): void {
|
ensurePlaying(): void {
|
||||||
if (
|
if (
|
||||||
this.artwork &&
|
this.artwork &&
|
||||||
@@ -279,20 +279,18 @@ export class AnimatedArtworkLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set whether the Now Playing view is currently visible on screen. */
|
|
||||||
setNowPlayingVisible(visible: boolean): void {
|
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;
|
if (visible === this.nowPlayingVisible) return;
|
||||||
this.nowPlayingVisible = visible;
|
this.nowPlayingVisible = visible;
|
||||||
// Re-show has to resume explicitly: the view is hidden by CSS rather than
|
// Must resume explicitly; nothing else restarts a paused video.
|
||||||
// unmounted, so the tile observer never fires and nothing else would restart it.
|
|
||||||
this.ensurePlaying();
|
this.ensurePlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the video mounted but re-evaluate play state (settings / visibility). */
|
|
||||||
refresh(): void {
|
refresh(): void {
|
||||||
if (!this.liveSrc) return;
|
if (!this.liveSrc) return;
|
||||||
if (settings.animatedArtwork) {
|
if (settings.animatedArtwork) {
|
||||||
|
this.applyRate();
|
||||||
if (this.video) this.ensurePlaying();
|
if (this.video) this.ensurePlaying();
|
||||||
else this.mountVideo(true);
|
else this.mountVideo(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -312,7 +310,6 @@ export class AnimatedArtworkLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full teardown, including tile references. */
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.loadToken++;
|
this.loadToken++;
|
||||||
this.fetchAbort?.abort();
|
this.fetchAbort?.abort();
|
||||||
@@ -321,7 +318,6 @@ export class AnimatedArtworkLayer {
|
|||||||
this.resizeObs = null;
|
this.resizeObs = null;
|
||||||
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
|
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
|
||||||
this.resizeRaf = 0;
|
this.resizeRaf = 0;
|
||||||
// detach(true) keeps nothing on disk, but we are disposing
|
|
||||||
this.detach(false);
|
this.detach(false);
|
||||||
this.releaseMount();
|
this.releaseMount();
|
||||||
this.tile = null;
|
this.tile = null;
|
||||||
|
|||||||
@@ -732,11 +732,10 @@ const setLayerRunning = (
|
|||||||
if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true);
|
if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cached so the 200ms activity tick does not re-run a document-wide
|
// Cached so the 200ms tick skips a document-wide query.
|
||||||
// attribute-substring query; invalidated as soon as the node detaches.
|
|
||||||
let cachedNowPlayingPanel: HTMLElement | null = null;
|
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 => {
|
const artPanelVisible = (panel: HTMLElement): boolean => {
|
||||||
if (typeof panel.checkVisibility === "function") {
|
if (typeof panel.checkVisibility === "function") {
|
||||||
return panel.checkVisibility({ checkVisibilityCSS: true });
|
return panel.checkVisibility({ checkVisibilityCSS: true });
|
||||||
@@ -777,8 +776,7 @@ const syncBackdropActivity = (): void => {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Animated artwork only runs while the Now Playing view is visible.
|
// Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second.
|
||||||
// Guarded: without a layer there is nothing to tell, and this runs 5x/second.
|
|
||||||
if (animatedArtworkLayer) {
|
if (animatedArtworkLayer) {
|
||||||
if (!cachedNowPlayingPanel?.isConnected) {
|
if (!cachedNowPlayingPanel?.isConnected) {
|
||||||
cachedNowPlayingPanel = document.querySelector(
|
cachedNowPlayingPanel = document.querySelector(
|
||||||
@@ -1048,15 +1046,9 @@ const updateRadiantLyricsBackdrop = function (): void {
|
|||||||
// MARKER: Animated Artwork (live artwork on the artwork tile)
|
// MARKER: Animated Artwork (live artwork on the artwork tile)
|
||||||
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
|
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
|
||||||
let lastArtworkKey: string | 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;
|
let lastArtworkTile: Element | null = null;
|
||||||
|
|
||||||
/**
|
/** Animated art is an album asset, so key by album and skips within one are free. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
const artworkKey = (t: TrackInfo): string =>
|
const artworkKey = (t: TrackInfo): string =>
|
||||||
t.album && t.album.trim() !== ""
|
t.album && t.album.trim() !== ""
|
||||||
? `${t.artist}\u0000${t.album.trim()}`
|
? `${t.artist}\u0000${t.album.trim()}`
|
||||||
@@ -1077,7 +1069,7 @@ const updateAnimatedArtwork = async (
|
|||||||
if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer();
|
if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer();
|
||||||
target ??= await getTrackInfo();
|
target ??= await getTrackInfo();
|
||||||
if (!target) return;
|
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;
|
if (!settings.animatedArtwork || !animatedArtworkLayer) return;
|
||||||
const key = artworkKey(target);
|
const key = artworkKey(target);
|
||||||
if (key === lastArtworkKey) {
|
if (key === lastArtworkKey) {
|
||||||
@@ -1092,12 +1084,13 @@ const updateAnimatedArtwork = async (
|
|||||||
target.artist,
|
target.artist,
|
||||||
target.album,
|
target.album,
|
||||||
);
|
);
|
||||||
// No artwork tile was on screen (lyrics view not open yet) — clear the key so
|
// No tile on screen yet; clear the key so the tile observer retries.
|
||||||
// the tile observer can retry instead of treating this track as handled.
|
|
||||||
if (!done && lastArtworkKey === key) lastArtworkKey = null;
|
if (!done && lastArtworkKey === key) lastArtworkKey = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).updateAnimatedArtwork = updateAnimatedArtwork;
|
(window as any).updateAnimatedArtwork = updateAnimatedArtwork;
|
||||||
|
(window as any).updateAnimatedArtworkRate = (): void =>
|
||||||
|
animatedArtworkLayer?.refresh();
|
||||||
|
|
||||||
// Make these functions available globally so Settings can call them
|
// Make these functions available globally so Settings can call them
|
||||||
(window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles;
|
(window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles;
|
||||||
@@ -1133,9 +1126,7 @@ updateRadiantLyricsTextGlow();
|
|||||||
// Init global background
|
// Init global background
|
||||||
updateCoverArtBackground(1);
|
updateCoverArtBackground(1);
|
||||||
|
|
||||||
// Init animated artwork (handles already-playing track on startup).
|
// Deferred: getTrackInfo() is declared further down and would be uninitialised.
|
||||||
// Deferred: getTrackInfo() is a `const` declared further down this module, so
|
|
||||||
// calling it during module evaluation would hit the uninitialised binding.
|
|
||||||
queueMicrotask(() => void updateAnimatedArtwork());
|
queueMicrotask(() => void updateAnimatedArtwork());
|
||||||
|
|
||||||
// Cleanups
|
// Cleanups
|
||||||
@@ -1478,14 +1469,10 @@ function setupLyricsMenuObserver(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animated artwork: the tile only exists once the Now Playing view is open,
|
// The tile only exists once the view is open, so load from here too. This fires
|
||||||
// so the initial load has to run from here as well as on track change.
|
// on nearly every DOM change, so bail synchronously unless the tile is new.
|
||||||
// `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.
|
|
||||||
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
|
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
|
||||||
// Identity check only: cheap, and it stops us reacting to our own <video>
|
// Identity only; also stops us reacting to our own <video> insertion.
|
||||||
// insertion, whose mutation target (the tile's parent) re-matches the tile.
|
|
||||||
if (tile === lastArtworkTile) return;
|
if (tile === lastArtworkTile) return;
|
||||||
lastArtworkTile = tile;
|
lastArtworkTile = tile;
|
||||||
void updateAnimatedArtwork(undefined, true);
|
void updateAnimatedArtwork(undefined, true);
|
||||||
|
|||||||
Reference in new issue
Block a user