mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-14 23:43:13 +11:00
feat: animated artwork
Animated artwork over the Now Playing artwork tile, behind a new "Animated Artwork" setting.
This commit is contained in:
1 parent
7f06969bef
commit
8ef69f57a2
9 files changed
+595
-3
No files matched your search
@@ -19,5 +19,8 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.7.2"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"main": "./src/index.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@kawarp/core": "*"
|
||||
"@kawarp/core": "*",
|
||||
"hls.js": "^1.7.2"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ declare global {
|
||||
updateRadiantLyricsTextGlow?: () => void;
|
||||
updateRadiantLyricsPlayerBarTint?: () => void;
|
||||
updateRadiantLyricsBackdrop?: () => void;
|
||||
updateAnimatedArtwork?: (target?: unknown) => void;
|
||||
updateQualityProgressColor?: () => void;
|
||||
updateIntegratedSeekBar?: () => void;
|
||||
updateLyricsStyle?: () => void;
|
||||
@@ -48,6 +49,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
||||
playerBarTintCustomColors: [] as string[],
|
||||
// Master switch
|
||||
backdropEnabled: true,
|
||||
animatedArtwork: true,
|
||||
backdropStyle: 0,
|
||||
backdropPlaybackReactive: true,
|
||||
CoverEverywhere: true,
|
||||
@@ -82,6 +84,9 @@ export const Settings = () => {
|
||||
const [backdropEnabled, setBackdropEnabled] = React.useState(
|
||||
settings.backdropEnabled,
|
||||
);
|
||||
const [animatedArtwork, setAnimatedArtwork] = React.useState(
|
||||
settings.animatedArtwork,
|
||||
);
|
||||
const [backdropStyle, setBackdropStyle] = React.useState(
|
||||
settings.backdropStyle,
|
||||
);
|
||||
@@ -915,6 +920,16 @@ export const Settings = () => {
|
||||
refreshBackdrop();
|
||||
}}
|
||||
/>
|
||||
<AnySwitch
|
||||
title="Animated Artwork"
|
||||
desc="Show animated artwork over the artwork tile in the Now Playing view"
|
||||
checked={animatedArtwork}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
settings.animatedArtwork = checked;
|
||||
setAnimatedArtwork(checked);
|
||||
window.updateAnimatedArtwork?.();
|
||||
}}
|
||||
/>
|
||||
{backdropEnabled && (
|
||||
<>
|
||||
<LunaNumberSetting
|
||||
|
||||
@@ -202,6 +202,110 @@ export async function fetchLyrics(
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AnimatedArtwork {
|
||||
url: string;
|
||||
url_tall: string;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ARTWORK_CACHE_MAX = 200;
|
||||
|
||||
export async function fetchAnimatedArtwork(
|
||||
title: string,
|
||||
artist: string,
|
||||
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.
|
||||
const cacheKey =
|
||||
album && album.trim() !== ""
|
||||
? `${artist}\u0000${album.trim()}`
|
||||
: `${artist}\u0000\u0000${title}`;
|
||||
if (animatedArtworkCache.has(cacheKey)) {
|
||||
return animatedArtworkCache.get(cacheKey) ?? null;
|
||||
}
|
||||
|
||||
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();
|
||||
const tryFetch = async (url: string): Promise<AnimatedArtwork | null> => {
|
||||
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 => {
|
||||
// Never cache a cancelled attempt — it 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),
|
||||
);
|
||||
if (withAlbum) return remember(withAlbum);
|
||||
if (album && album.trim() !== "") {
|
||||
const withoutAlbum = await tryFetch(
|
||||
artworkSearchUrl(title, artist),
|
||||
);
|
||||
if (withoutAlbum) return remember(withoutAlbum);
|
||||
}
|
||||
return remember(null);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushLyrics(track: {
|
||||
title: string;
|
||||
artist: string;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// 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";
|
||||
// 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.
|
||||
import Hls from "hls.js/light";
|
||||
|
||||
import { 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"]);
|
||||
|
||||
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. */
|
||||
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). */
|
||||
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. */
|
||||
private mountPositioned = false;
|
||||
/** Aborts the in-flight lookup when the track changes underneath us. */
|
||||
private fetchAbort: AbortController | null = null;
|
||||
|
||||
/** 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;
|
||||
this.mount = null;
|
||||
}
|
||||
|
||||
/** True while a matching artwork tile is alive. */
|
||||
private tileAlive(): boolean {
|
||||
if (this.tile?.isConnected) return true;
|
||||
const tile = document.querySelector<HTMLElement>(ART_TILE_SELECTOR);
|
||||
if (!tile) return false;
|
||||
this.host(tile);
|
||||
return true;
|
||||
}
|
||||
|
||||
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.
|
||||
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";
|
||||
}
|
||||
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, so always re-target the observer.
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async load(
|
||||
key: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
album: string | undefined,
|
||||
): Promise<boolean> {
|
||||
// Already showing this album's artwork — keep playing, touch nothing.
|
||||
if (key === this.artworkKey && this.artwork && this.video?.isConnected) {
|
||||
this.ensurePlaying();
|
||||
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.
|
||||
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR)) return false;
|
||||
|
||||
const artwork = await fetchAnimatedArtwork(title, artist, album, signal);
|
||||
if (token !== this.loadToken) return true;
|
||||
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.
|
||||
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.
|
||||
const unchanged =
|
||||
this.artwork?.url === artwork.url &&
|
||||
this.artwork?.url_tall === artwork.url_tall &&
|
||||
this.video?.isConnected === true;
|
||||
this.artwork = artwork;
|
||||
this.artworkKey = key;
|
||||
if (unchanged) {
|
||||
this.ensurePlaying();
|
||||
return true;
|
||||
}
|
||||
this.mountVideo(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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<HTMLElement>(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 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.
|
||||
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,
|
||||
});
|
||||
this.hls = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
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.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);
|
||||
};
|
||||
|
||||
/** Respect settings + panel visibility when toggling play/pause. */
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
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.
|
||||
this.ensurePlaying();
|
||||
}
|
||||
|
||||
/** Keep the video mounted but re-evaluate play state (settings / visibility). */
|
||||
refresh(): void {
|
||||
if (!this.liveSrc) return;
|
||||
if (settings.animatedArtwork) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full teardown, including tile references. */
|
||||
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;
|
||||
// detach(true) keeps nothing on disk, but we are disposing
|
||||
this.detach(false);
|
||||
this.releaseMount();
|
||||
this.tile = null;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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";
|
||||
@@ -731,6 +732,18 @@ 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.
|
||||
let cachedNowPlayingPanel: HTMLElement | null = null;
|
||||
|
||||
// Is the Now Playing view actually on screen? (visibility, 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 +776,19 @@ const syncBackdropActivity = (): void => {
|
||||
(global?.isVisible() ?? false),
|
||||
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.
|
||||
if (animatedArtworkLayer) {
|
||||
if (!cachedNowPlayingPanel?.isConnected) {
|
||||
cachedNowPlayingPanel = document.querySelector(
|
||||
'[class*="_nowPlayingContainer"]',
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
animatedArtworkLayer.setNowPlayingVisible(
|
||||
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** cover art @ resolution worth sampling */
|
||||
@@ -1019,6 +1045,60 @@ 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.
|
||||
*/
|
||||
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<void> => {
|
||||
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 track already.
|
||||
if (!settings.animatedArtwork || !animatedArtworkLayer) return;
|
||||
const key = artworkKey(target);
|
||||
if (key === lastArtworkKey) {
|
||||
// Same artwork, but the view may have remounted underneath us.
|
||||
if (rehost) animatedArtworkLayer.reattach();
|
||||
return;
|
||||
}
|
||||
lastArtworkKey = key;
|
||||
const done = await animatedArtworkLayer.load(
|
||||
key,
|
||||
target.title,
|
||||
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.
|
||||
if (!done && lastArtworkKey === key) lastArtworkKey = null;
|
||||
};
|
||||
|
||||
(window as any).updateAnimatedArtwork = updateAnimatedArtwork;
|
||||
|
||||
// Make these functions available globally so Settings can call them
|
||||
(window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles;
|
||||
(window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop;
|
||||
@@ -1053,8 +1133,16 @@ 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.
|
||||
queueMicrotask(() => void updateAnimatedArtwork());
|
||||
|
||||
// Cleanups
|
||||
unloads.add(() => {
|
||||
animatedArtworkLayer?.dispose();
|
||||
animatedArtworkLayer = null;
|
||||
cachedNowPlayingPanel = null;
|
||||
cleanUpDynamicArt();
|
||||
cleanUpStockCoverEverywhere();
|
||||
document.body.classList.remove("rl-backdrop-active");
|
||||
@@ -1390,6 +1478,19 @@ 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.
|
||||
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.
|
||||
if (tile === lastArtworkTile) return;
|
||||
lastArtworkTile = tile;
|
||||
void updateAnimatedArtwork(undefined, true);
|
||||
});
|
||||
|
||||
// Apply word lyrics when lyrics container appears or reappears
|
||||
observe<HTMLElement>(unloads, '[data-test="now-playing-lyrics"]', () => {
|
||||
if (isTrackChangeRunning) return;
|
||||
@@ -1424,6 +1525,7 @@ interface TrackInfo {
|
||||
title: string;
|
||||
artist: string;
|
||||
isrc?: string;
|
||||
album?: string;
|
||||
}
|
||||
|
||||
interface SyntheticNativeLyricsState {
|
||||
@@ -2083,8 +2185,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
|
||||
@@ -2301,9 +2404,10 @@ const getTrackInfo = async (): Promise<TrackInfo | null> => {
|
||||
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();
|
||||
@@ -4303,6 +4407,7 @@ function setupHeaderObserver(): void {
|
||||
// Apply seeker color on track change
|
||||
onGlobalTrackChange(() => {
|
||||
updateCoverArtBackground();
|
||||
void updateAnimatedArtwork();
|
||||
if (settings.qualityProgressColor) applyQualityProgressColor();
|
||||
});
|
||||
|
||||
|
||||
@@ -837,6 +837,23 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
/* Animated artwork (live art) overlays the tile */
|
||||
.rl-animated-art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
.rl-animated-art-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide the Overlay Scrollbar (people just use mouse scroll) */
|
||||
.os-scrollbar {
|
||||
display: none !important;
|
||||
|
||||
Generated
+12
@@ -7,6 +7,10 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
hls.js:
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.15.29
|
||||
@@ -52,6 +56,9 @@ importers:
|
||||
'@kawarp/core':
|
||||
specifier: '*'
|
||||
version: 1.2.0
|
||||
hls.js:
|
||||
specifier: ^1.7.2
|
||||
version: 1.7.2
|
||||
|
||||
packages:
|
||||
|
||||
@@ -462,6 +469,9 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hls.js@1.7.2:
|
||||
resolution: {integrity: sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==}
|
||||
|
||||
html-encoding-sniffer@3.0.0:
|
||||
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1068,6 +1078,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hls.js@1.7.2: {}
|
||||
|
||||
html-encoding-sniffer@3.0.0:
|
||||
dependencies:
|
||||
whatwg-encoding: 2.0.0
|
||||
|
||||
Reference in new issue
Block a user