Merge pull request #154 from frostplexx/dev

feat: animated artwork
This commit is contained in:
meoware.exe authored and GitHub committed 2026-10-06 23:41:32 +11:00
commit cb3f6f15d2
9 files changed
+640 -3

No files matched your search

+3
View File
@@ -19,5 +19,8 @@
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tsx": "^4.19.4", "tsx": "^4.19.4",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"dependencies": {
"hls.js": "^1.7.2"
} }
} }
+2 -1
View File
@@ -9,6 +9,7 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@kawarp/core": "*" "@kawarp/core": "*",
"hls.js": "^1.7.2"
} }
} }
@@ -8,6 +8,8 @@ declare global {
updateRadiantLyricsTextGlow?: () => void; updateRadiantLyricsTextGlow?: () => void;
updateRadiantLyricsPlayerBarTint?: () => void; updateRadiantLyricsPlayerBarTint?: () => void;
updateRadiantLyricsBackdrop?: () => void; updateRadiantLyricsBackdrop?: () => void;
updateAnimatedArtwork?: (target?: unknown) => void;
updateAnimatedArtworkRate?: () => void;
updateQualityProgressColor?: () => void; updateQualityProgressColor?: () => void;
updateIntegratedSeekBar?: () => void; updateIntegratedSeekBar?: () => void;
updateLyricsStyle?: () => void; updateLyricsStyle?: () => void;
@@ -48,6 +50,8 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
playerBarTintCustomColors: [] as string[], playerBarTintCustomColors: [] as string[],
// Master switch // Master switch
backdropEnabled: true, backdropEnabled: true,
animatedArtwork: true,
animatedArtworkFps: 25,
backdropStyle: 0, backdropStyle: 0,
backdropPlaybackReactive: true, backdropPlaybackReactive: true,
CoverEverywhere: true, CoverEverywhere: true,
@@ -82,6 +86,12 @@ export const Settings = () => {
const [backdropEnabled, setBackdropEnabled] = React.useState( const [backdropEnabled, setBackdropEnabled] = React.useState(
settings.backdropEnabled, settings.backdropEnabled,
); );
const [animatedArtwork, setAnimatedArtwork] = React.useState(
settings.animatedArtwork,
);
const [animatedArtworkFps, setAnimatedArtworkFps] = React.useState(
settings.animatedArtworkFps,
);
const [backdropStyle, setBackdropStyle] = React.useState( const [backdropStyle, setBackdropStyle] = React.useState(
settings.backdropStyle, settings.backdropStyle,
); );
@@ -915,6 +925,31 @@ export const Settings = () => {
refreshBackdrop(); 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?.();
}}
/>
{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
+120
View File
@@ -202,6 +202,126 @@ export async function fetchLyrics(
return null; return null;
} }
export interface AnimatedArtwork {
url: string;
url_tall: string;
}
export interface ArtworkLookup {
artwork: AnimatedArtwork | null;
/**
* Scope the result is valid for, as a dedupe key: the album key only when
* the answer was matched by album. A title-only fallback (or a miss) is
* specific to this track, so its key scopes to the title — the album's
* other tracks must not inherit it.
*/
key: string;
}
/** Title-scoped artwork key: a result only valid for this exact track. */
export const artworkTitleKey = (artist: string, title: string): string =>
`${artist}\u0000\u0000${title}`;
function artworkSearchUrl(
title: string,
artist: string,
album?: string,
): string {
let q = `title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
// Omit the album param entirely when absent (a literal "undefined" string makes the endpoint miss)
if (album && album.trim() !== "") q += `&album=${encodeURIComponent(album.trim())}`;
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
}
// Cached including misses; most tracks have none and the answer never changes.
// Only album-matched results go under the album key — a title-only fallback
// must not stand in for the album's other tracks (see ArtworkLookup).
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
const ARTWORK_CACHE_MAX = 200;
export async function fetchAnimatedArtwork(
title: string,
artist: string,
album?: string,
signal?: AbortSignal,
): Promise<ArtworkLookup> {
const albumName = album?.trim() || "";
const albumPresent = albumName !== "";
// Album key, matching artworkKey() in index.ts; without an album the
// answer is only ever matched by title, so key by title directly.
const cacheKey = albumPresent
? `${artist}\u0000${albumName}`
: artworkTitleKey(artist, title);
if (animatedArtworkCache.has(cacheKey)) {
return { artwork: animatedArtworkCache.get(cacheKey) ?? null, key: cacheKey };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const onAbort = () => controller.abort();
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) controller.abort();
const tryFetch = async (url: string): Promise<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 => {
// A cancelled attempt says nothing about the track.
if (controller.signal.aborted) return result;
if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) {
const oldest = animatedArtworkCache.keys().next().value;
if (oldest !== undefined) animatedArtworkCache.delete(oldest);
}
animatedArtworkCache.set(cacheKey, result);
return result;
};
try {
// Exact album first; on a miss retry once without the album (server-side disambiguation is imperfect)
const withAlbum = await tryFetch(
artworkSearchUrl(title, artist, album),
);
// Album-matched: cacheable, and its scope is the whole album.
if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey };
if (albumPresent) {
// Album lookup missed: the title-only answer — hit or miss — is
// matched by title, so never cache it under the album key (that
// would poison the album's other tracks) and scope it to the title.
const artwork = await tryFetch(artworkSearchUrl(title, artist));
return { artwork, key: artworkTitleKey(artist, title) };
}
// No album: the title key is already track-specific.
return { artwork: remember(null), key: cacheKey };
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
}
}
export async function flushLyrics(track: { export async function flushLyrics(track: {
title: string; title: string;
artist: string; artist: string;
+337
View File
@@ -0,0 +1,337 @@
// MARKER: Animated Artwork (live artwork in the lyrics view)
// Renders the API HLS stream over TIDAL's static artwork tile.
// Orchestration (track change, visibility, settings) lives in index.ts.
import { Tracer } from "@luna/core";
// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts.
import Hls from "hls.js/light";
import { artworkTitleKey, fetchAnimatedArtwork } from "./api";
import { settings } from "./Settings";
const { trace } = Tracer("[Radiant Lyrics]");
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
/** Elements that cannot render child nodes, so the video must go beside them. */
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
// Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU.
const ART_MAX_DPR = 1;
const ART_NATIVE_FPS = 25;
export class AnimatedArtworkLayer {
private video: HTMLVideoElement | null = null;
private hls: Hls | null = null;
private tile: HTMLElement | null = null;
/** The tile, or its parent when the tile is a replaced element. */
private mount: HTMLElement | null = null;
private resizeObs: ResizeObserver | null = null;
private resizeRaf = 0;
private artwork: { url: string; url_tall: string } | null = null;
/**
* Scope key of the displayed artwork (see fetchAnimatedArtwork): the album
* key only when the answer was matched by album, else the title key.
*/
private artworkKey: string | null = null;
private liveSrc: string | null = null;
private loadToken = 0;
private nowPlayingVisible = true;
/** True when the mount's inline position is ours to revert. */
private mountPositioned = false;
private fetchAbort: AbortController | null = null;
private nativeFps = ART_NATIVE_FPS;
// CPU tracks presented fps and nothing else, so this is the only perf knob.
private applyRate(): void {
if (!this.video) return;
const target = Number(settings.animatedArtworkFps) || ART_NATIVE_FPS;
const rate = Math.min(1, Math.max(0.05, target / this.nativeFps));
if (this.video.playbackRate !== rate) this.video.playbackRate = rate;
}
private releaseMount(): void {
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
this.mountPositioned = false;
this.mount = null;
}
/** True while a matching artwork tile is alive. */
private tileAlive(): boolean {
if (this.tile?.isConnected) return true;
const tile = document.querySelector<HTMLElement>(ART_TILE_SELECTOR);
if (!tile) return false;
this.host(tile);
return true;
}
private host(tile: HTMLElement): void {
this.tile = tile;
// 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") {
this.mountPositioned = true;
this.mount.style.position = "relative";
}
this.watchSizing();
}
/** Lay the video over the tile when it is not a direct child of it. */
private syncGeometry(): void {
const { video, tile, mount } = this;
if (!video || !tile || !mount) return;
if (mount === tile) return;
video.style.left = `${tile.offsetLeft}px`;
video.style.top = `${tile.offsetTop}px`;
video.style.width = `${tile.offsetWidth}px`;
video.style.height = `${tile.offsetHeight}px`;
video.style.right = "auto";
video.style.bottom = "auto";
video.style.borderRadius = getComputedStyle(tile).borderRadius;
}
private watchSizing(): void {
if (!this.tile) return;
// host() can re-run against a fresh tile.
this.resizeObs?.disconnect();
this.resizeObs ??= new ResizeObserver(() => {
if (!this.artwork || !this.video) return;
// Deferred: syncGeometry writes styles and would retrigger us.
if (this.resizeRaf !== 0) return;
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = 0;
// Only rebuild the stream if the aspect actually flipped.
if (this.pickSrc() !== this.liveSrc) this.mountVideo(false);
this.syncGeometry();
});
});
this.resizeObs.observe(this.tile);
}
/**
* Mount the artwork for `key`, leaving any playing video alone until a
* replacement resolves. `done: false` means no tile was on screen, so
* retry later. Returns the scope `key` the result is valid under (see
* fetchAnimatedArtwork), which may be narrower than the request key.
*/
async load(
key: string,
title: string,
artist: string,
album: string | undefined,
): Promise<{ done: boolean; key: string }> {
// Already showing artwork that covers this track (album-matched, or the
// same track again) — keep playing, touch nothing.
if (
(key === this.artworkKey ||
artworkTitleKey(artist, title) === this.artworkKey) &&
this.artwork &&
this.video?.isConnected
) {
this.ensurePlaying();
return { done: true, key: this.artworkKey };
}
const token = ++this.loadToken;
this.fetchAbort?.abort();
this.fetchAbort = new AbortController();
const signal = this.fetchAbort.signal;
if (!settings.animatedArtwork) return { done: true, key };
// Only check a tile exists; host after the fetch so the old video keeps playing.
if (!document.querySelector<HTMLElement>(ART_TILE_SELECTOR))
return { done: false, key };
const lookup = await fetchAnimatedArtwork(title, artist, album, signal);
if (token !== this.loadToken) return { done: true, key: lookup.key };
if (!this.tileAlive()) return { done: false, key: lookup.key };
if (!lookup.artwork) {
trace.log(`Animated Artwork: no animated art for "${title}"`);
this.detach(false);
// After detach(), which clears the key too.
this.artworkKey = lookup.key;
return { done: true, key: lookup.key };
}
// Same stream already on screen: adopt the key, leave the video be.
const unchanged =
this.artwork?.url === lookup.artwork.url &&
this.artwork?.url_tall === lookup.artwork.url_tall &&
this.video?.isConnected === true;
this.artwork = lookup.artwork;
this.artworkKey = lookup.key;
if (unchanged) {
this.ensurePlaying();
return { done: true, key: lookup.key };
}
this.mountVideo(false);
return { done: true, key: lookup.key };
}
/** Re-bind to the artwork tile after the Now Playing view (re)mounted. */
reattach(): void {
if (!this.artwork || !settings.animatedArtwork) return;
const tile = document.querySelector<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 says "maybe" to canPlayType for HLS then fails to
// demux the playlist, so native is only a fallback for engines without MSE.
if (typeof Hls === "function" && Hls.isSupported()) {
const hls = new Hls({
autoStartLoad: true,
capLevelToPlayerSize: true,
// H.264 ladder; the artwork has no use for 10-bit HEVC.
videoPreference: { videoCodec: "avc1", allowedVideoRanges: ["SDR"] },
maxDevicePixelRatio: ART_MAX_DPR,
});
this.hls = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
const fps = hls.levels[hls.currentLevel]?.frameRate;
if (fps && fps > 0) this.nativeFps = fps;
this.applyRate();
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
this.fail(
`hls ${data.type}/${data.details}${data.reason ? ` (${data.reason})` : ""}${data.error ? ` (${data.error.message})` : ""}`,
);
});
} else if (video.canPlayType("application/x-mpegurl")) {
video.src = src;
} else {
trace.log("Animated Artwork: HLS unsupported on this client");
this.detach(false);
return;
}
video.classList.add("rl-animated-art-visible");
this.applyRate();
this.ensurePlaying();
}
private pickSrc(): string {
if (!this.artwork || !this.tile) return "";
const rect = this.tile.getBoundingClientRect();
if (rect.width && rect.height && rect.height > rect.width) {
return this.artwork.url_tall;
}
return this.artwork.url;
}
private fail = (reason: string): void => {
trace.log(`Animated Artwork: ${reason} — hiding video (src: ${this.liveSrc})`);
this.detach(false);
};
ensurePlaying(): void {
if (
this.artwork &&
this.video &&
settings.animatedArtwork &&
this.nowPlayingVisible &&
!document.hidden &&
this.tile?.isConnected
) {
void this.video.play().catch(() => {});
} else {
this.video?.pause();
}
}
setNowPlayingVisible(visible: boolean): void {
// Called from the 200ms tick, so bail unless it actually flipped.
if (visible === this.nowPlayingVisible) return;
this.nowPlayingVisible = visible;
// Must resume explicitly; nothing else restarts a paused video.
this.ensurePlaying();
}
refresh(): void {
if (!this.liveSrc) return;
if (settings.animatedArtwork) {
this.applyRate();
if (this.video) this.ensurePlaying();
else this.mountVideo(true);
} else {
this.detach(true);
}
}
private detach(keepData: boolean): void {
if (this.video?.parentNode) this.video.remove();
this.video = null;
this.hls?.destroy();
this.hls = null;
this.liveSrc = keepData ? this.liveSrc : null;
if (!keepData) {
this.artwork = null;
this.artworkKey = null;
}
}
dispose(): void {
this.loadToken++;
this.fetchAbort?.abort();
this.fetchAbort = null;
this.resizeObs?.disconnect();
this.resizeObs = null;
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = 0;
this.detach(false);
this.releaseMount();
this.tile = null;
}
}
+6
View File
@@ -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";
}
+108 -2
View File
@@ -15,11 +15,13 @@ import {
type LyricsApiResponse, type LyricsApiResponse,
type WordLine, type WordLine,
type WordTiming, type WordTiming,
artworkTitleKey,
fetchLyrics as fetchLyricsApi, fetchLyrics as fetchLyricsApi,
flushLyrics as flushLyricsApi, flushLyrics as flushLyricsApi,
romanizeLyrics as romanizeLyricsApi, romanizeLyrics as romanizeLyricsApi,
} from "./api"; } from "./api";
import { KawarpLayer } from "./backdrop"; import { KawarpLayer } from "./backdrop";
import { AnimatedArtworkLayer, ART_TILE_SELECTOR } from "./artwork";
import { Settings, settings } from "./Settings"; import { Settings, settings } from "./Settings";
import backdropStylesCss from "file://backdrop-styles.css?minify"; import backdropStylesCss from "file://backdrop-styles.css?minify";
@@ -789,6 +791,17 @@ 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 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 => { const syncBackdropActivity = (): void => {
// Stock backdrop keeps current (no shader loop) // Stock backdrop keeps current (no shader loop)
if (stockContainer) applyStockCoverEverywhere(); if (stockContainer) applyStockCoverEverywhere();
@@ -821,6 +834,18 @@ const syncBackdropActivity = (): void => {
(global?.isVisible() ?? false), (global?.isVisible() ?? false),
now, now,
); );
// Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second.
if (animatedArtworkLayer) {
if (!cachedNowPlayingPanel?.isConnected) {
cachedNowPlayingPanel = document.querySelector(
'[class*="_nowPlayingContainer"]',
) as HTMLElement | null;
}
animatedArtworkLayer.setNowPlayingVisible(
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
);
}
}; };
/** cover art @ resolution worth sampling */ /** cover art @ resolution worth sampling */
@@ -1077,6 +1102,68 @@ const updateRadiantLyricsBackdrop = function (): void {
} }
}; };
// MARKER: Animated Artwork (live artwork on the artwork tile)
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
let lastArtworkKey: string | null = null;
let lastArtworkTile: Element | null = null;
/**
* Request key for an animated-artwork lookup: album-scoped when an album is
* present (skips within one are free). A result may still be scoped tighter —
* see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the
* effective scope, not this request key.
*/
const artworkKey = (t: TrackInfo): string =>
t.album && t.album.trim() !== ""
? `${t.artist}\u0000${t.album.trim()}`
: `${t.artist}\u0000\u0000${t.title}`;
// Load + show animated art for the current (or given) track
const updateAnimatedArtwork = async (
target?: TrackInfo | null,
rehost = false,
): Promise<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 already.
if (!settings.animatedArtwork || !animatedArtworkLayer) return;
const key = artworkKey(target);
// Free skip: the shown artwork's scope covers this track — it was matched
// by album (album key), or it is the same track again (title key).
if (
key === lastArtworkKey ||
artworkTitleKey(target.artist, target.title) === lastArtworkKey
) {
// Same artwork, but the view may have remounted underneath us.
if (rehost) animatedArtworkLayer.reattach();
return;
}
lastArtworkKey = key;
const res = await animatedArtworkLayer.load(
key,
target.title,
target.artist,
target.album,
);
// Adopt the effective scope — a title-matched result (or a miss) must not
// stand in for the album's other tracks — unless a newer trigger claimed.
if (lastArtworkKey === key) lastArtworkKey = res.key;
// No tile on screen yet; clear the key so the tile observer retries.
if (!res.done && lastArtworkKey === res.key) lastArtworkKey = null;
};
(window as any).updateAnimatedArtwork = updateAnimatedArtwork;
(window as any).updateAnimatedArtworkRate = (): void =>
animatedArtworkLayer?.refresh();
// Make these functions available globally so Settings can call them // Make these functions available globally so Settings can call them
(window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles; (window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles;
(window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop; (window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop;
@@ -1111,8 +1198,14 @@ updateRadiantLyricsTextGlow();
// Init global background // Init global background
updateCoverArtBackground(1); updateCoverArtBackground(1);
// Deferred: getTrackInfo() is declared further down and would be uninitialised.
queueMicrotask(() => void updateAnimatedArtwork());
// Cleanups // Cleanups
unloads.add(() => { unloads.add(() => {
animatedArtworkLayer?.dispose();
animatedArtworkLayer = null;
cachedNowPlayingPanel = null;
cleanUpDynamicArt(); cleanUpDynamicArt();
cleanUpStockCoverEverywhere(); cleanUpStockCoverEverywhere();
document.body.classList.remove("rl-backdrop-active"); document.body.classList.remove("rl-backdrop-active");
@@ -1448,6 +1541,15 @@ function setupLyricsMenuObserver(): void {
} }
}); });
// The tile only exists once the view is open, so load from here too. This fires
// on nearly every DOM change, so bail synchronously unless the tile is new.
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
// Identity only; also stops us reacting to our own <video> insertion.
if (tile === lastArtworkTile) return;
lastArtworkTile = tile;
void updateAnimatedArtwork(undefined, true);
});
// Apply word lyrics when lyrics container appears or reappears // Apply word lyrics when lyrics container appears or reappears
observe<HTMLElement>(unloads, '[data-test="now-playing-lyrics"]', () => { observe<HTMLElement>(unloads, '[data-test="now-playing-lyrics"]', () => {
if (isTrackChangeRunning) return; if (isTrackChangeRunning) return;
@@ -1482,6 +1584,7 @@ interface TrackInfo {
title: string; title: string;
artist: string; artist: string;
isrc?: string; isrc?: string;
album?: string;
} }
interface SyntheticNativeLyricsState { interface SyntheticNativeLyricsState {
@@ -2141,8 +2244,9 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => {
const title = String(attr.title ?? ""); const title = String(attr.title ?? "");
const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? ""); const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? "");
const isrc = attr.isrc ?? undefined; const isrc = attr.isrc ?? undefined;
const album = attr.album?.title ?? undefined;
if (!title || !artist) return null; if (!title || !artist) return null;
return { trackId: productId, title, artist, isrc }; return { trackId: productId, title, artist, isrc, album };
}; };
// MARKER: Playback clock // MARKER: Playback clock
@@ -2359,9 +2463,10 @@ const getTrackInfo = async (): Promise<TrackInfo | null> => {
mi.tidalItem.artist?.name ?? getPrimaryArtistName(mi.tidalItem.artists) ?? ""; // REMIX Detection mi.tidalItem.artist?.name ?? getPrimaryArtistName(mi.tidalItem.artists) ?? ""; // REMIX Detection
const isrc = mi.tidalItem.isrc ?? undefined; const isrc = mi.tidalItem.isrc ?? undefined;
const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? ""); const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? "");
const album = mi.tidalItem.album?.title ?? undefined;
if (!baseTitle || !artist || !trackId) return null; if (!baseTitle || !artist || !trackId) return null;
return { trackId, title, artist, isrc }; return { trackId, title, artist, isrc, album };
} }
const state = getReduxState(); const state = getReduxState();
@@ -4362,6 +4467,7 @@ function setupHeaderObserver(): void {
// Apply seeker color on track change // Apply seeker color on track change
onGlobalTrackChange(() => { onGlobalTrackChange(() => {
updateCoverArtBackground(); updateCoverArtBackground();
void updateAnimatedArtwork();
if (settings.qualityProgressColor) applyQualityProgressColor(); if (settings.qualityProgressColor) applyQualityProgressColor();
}); });
@@ -879,6 +879,23 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
border-radius: 10px !important; 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) */ /* Hide the Overlay Scrollbar (people just use mouse scroll) */
.os-scrollbar { .os-scrollbar {
display: none !important; display: none !important;
+12
View File
@@ -7,6 +7,10 @@ settings:
importers: importers:
.: .:
dependencies:
hls.js:
specifier: ^1.7.2
version: 1.7.2
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.15.29 specifier: ^22.15.29
@@ -52,6 +56,9 @@ importers:
'@kawarp/core': '@kawarp/core':
specifier: '*' specifier: '*'
version: 1.2.0 version: 1.2.0
hls.js:
specifier: ^1.7.2
version: 1.7.2
packages: packages:
@@ -462,6 +469,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
hls.js@1.7.2:
resolution: {integrity: sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==}
html-encoding-sniffer@3.0.0: html-encoding-sniffer@3.0.0:
resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1068,6 +1078,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
hls.js@1.7.2: {}
html-encoding-sniffer@3.0.0: html-encoding-sniffer@3.0.0:
dependencies: dependencies:
whatwg-encoding: 2.0.0 whatwg-encoding: 2.0.0