mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-15 07:53:13 +11:00
commit
cb3f6f15d2
9 files changed
+640
-3
No files matched your search
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Generated
+12
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user