mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-15 07:53: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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user