mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-15 07:53:13 +11:00
Animated Covers + Loading Spinner
This commit is contained in:
1 parent
e3d71806b1
commit
0f6c70017c
3 files changed
+345
-306
No files matched your search
Generated
-19
@@ -5,9 +5,6 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@meowarex/TidalLuna-Plugins",
|
"name": "@meowarex/TidalLuna-Plugins",
|
||||||
"dependencies": {
|
|
||||||
"pnpm": "^10.14.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
@@ -1641,22 +1638,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pnpm": {
|
|
||||||
"version": "10.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.14.0.tgz",
|
|
||||||
"integrity": "sha512-rSenlkG0nD5IGhaoBbqnGBegS74Go40X5g4urug/ahRsamiBJfV5LkjdW6MOfaUqXNpMOZK5zPMz+c4iOvhHSA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"pnpm": "bin/pnpm.cjs",
|
|
||||||
"pnpx": "bin/pnpx.cjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/pnpm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/portfinder": {
|
"node_modules/portfinder": {
|
||||||
"version": "1.0.37",
|
"version": "1.0.37",
|
||||||
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz",
|
||||||
|
|||||||
@@ -1,46 +1,217 @@
|
|||||||
// MARKER: Animated Artwork (live artwork in the lyrics view)
|
// MARKER: Animated Artwork (live artwork in the lyrics view)
|
||||||
// Renders the API HLS stream over TIDAL's static artwork tile.
|
// AnimatedArtworkLayer resolves the art for the playing track (bound to its
|
||||||
|
// track ID); each ArtworkSurface renders it over one of TIDAL's covers.
|
||||||
// Orchestration (track change, visibility, settings) lives in index.ts.
|
// Orchestration (track change, visibility, settings) lives in index.ts.
|
||||||
|
|
||||||
import { Tracer } from "@luna/core";
|
import { Tracer } from "@luna/core";
|
||||||
// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts.
|
// Light build: no alt-audio/subtitles/EME needed here. Types in hls-light.d.ts.
|
||||||
import Hls from "hls.js/light";
|
import Hls from "hls.js/light";
|
||||||
|
|
||||||
import { artworkTitleKey, fetchAnimatedArtwork } from "./api";
|
import {
|
||||||
|
type AnimatedArtwork,
|
||||||
|
artworkTitleKey,
|
||||||
|
fetchAnimatedArtwork,
|
||||||
|
} from "./api";
|
||||||
import { settings } from "./Settings";
|
import { settings } from "./Settings";
|
||||||
|
|
||||||
const { trace } = Tracer("[Radiant Lyrics]");
|
const { trace } = Tracer("[Radiant Lyrics]");
|
||||||
|
|
||||||
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
|
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
|
||||||
|
/** Player-bar thumbnail (same element the stock backdrop samples). */
|
||||||
|
export const MINI_ART_SELECTOR =
|
||||||
|
'[data-test="footer-player"] [data-test="current-media-imagery"] img';
|
||||||
/** Elements that cannot render child nodes, so the video must go beside them. */
|
/** Elements that cannot render child nodes, so the video must go beside them. */
|
||||||
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
|
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
|
||||||
// Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU.
|
// Keeps ABR off the 2160x2160 rung (91MB a loop). Bandwidth only, not CPU.
|
||||||
const ART_MAX_DPR = 1;
|
const ART_MAX_DPR = 1;
|
||||||
const ART_NATIVE_FPS = 25;
|
const ART_NATIVE_FPS = 25;
|
||||||
|
/** Matches the .rl-animated-art opacity transition. */
|
||||||
|
const ART_FADE_MS = 300;
|
||||||
|
|
||||||
export class AnimatedArtworkLayer {
|
/**
|
||||||
|
* One <video> over one TIDAL cover. The video (and its buffered HLS stream)
|
||||||
|
* outlives the cover element: when TIDAL remounts the cover, the same node is
|
||||||
|
* moved into the new one, so reopening a view never drops to the static art.
|
||||||
|
*/
|
||||||
|
class ArtworkSurface {
|
||||||
private video: HTMLVideoElement | null = null;
|
private video: HTMLVideoElement | null = null;
|
||||||
private hls: Hls | null = null;
|
private hls: Hls | null = null;
|
||||||
private tile: HTMLElement | null = null;
|
private tile: HTMLElement | null = null;
|
||||||
/** The tile, or its parent when the tile is a replaced element. */
|
/** The tile, or its parent when the tile is a replaced element. */
|
||||||
private mount: HTMLElement | null = null;
|
private mount: HTMLElement | null = null;
|
||||||
private resizeObs: ResizeObserver | null = null;
|
|
||||||
private 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 (name or ISRC), 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. */
|
/** True when the mount's inline position is ours to revert. */
|
||||||
private mountPositioned = false;
|
private mountPositioned = false;
|
||||||
private fetchAbort: AbortController | null = null;
|
private resizeObs: ResizeObserver | null = null;
|
||||||
|
private resizeRaf = 0;
|
||||||
|
private artwork: AnimatedArtwork | null = null;
|
||||||
|
private liveSrc: string | null = null;
|
||||||
|
/** A stream that errored; not retried until the artwork changes. */
|
||||||
|
private failedSrc: string | null = null;
|
||||||
|
private fadeTimer = 0;
|
||||||
|
private visible = true;
|
||||||
private nativeFps = ART_NATIVE_FPS;
|
private nativeFps = ART_NATIVE_FPS;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly selector: string,
|
||||||
|
/** Use the portrait stream when the cover is taller than wide. */
|
||||||
|
private readonly allowTall: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Show `artwork`, or fade the current video out when null. */
|
||||||
|
show(artwork: AnimatedArtwork | null): void {
|
||||||
|
if (artwork?.url !== this.artwork?.url) this.failedSrc = null;
|
||||||
|
this.artwork = artwork;
|
||||||
|
if (artwork) this.bind();
|
||||||
|
else this.fadeOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to the cover on screen. Cheap enough for the DOM observer: a live
|
||||||
|
* video is moved, never rebuilt, unless the stream itself changed.
|
||||||
|
*/
|
||||||
|
bind(): void {
|
||||||
|
if (!this.artwork || !settings.animatedArtwork) return;
|
||||||
|
const tile = document.querySelector<HTMLElement>(this.selector);
|
||||||
|
if (!tile) return;
|
||||||
|
if (tile !== this.tile) this.host(tile);
|
||||||
|
if (!this.mount) return;
|
||||||
|
const src = this.pickSrc();
|
||||||
|
if (src === this.failedSrc) return;
|
||||||
|
if (!this.video || src !== this.liveSrc) {
|
||||||
|
this.build(src);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Same stream came back mid fade-out: keep it rather than rebuild.
|
||||||
|
if (this.fadeTimer !== 0) {
|
||||||
|
clearTimeout(this.fadeTimer);
|
||||||
|
this.fadeTimer = 0;
|
||||||
|
if (this.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
this.video.classList.add("rl-animated-art-visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Appending moves the node; its MediaSource and buffer come along.
|
||||||
|
if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video);
|
||||||
|
this.syncGeometry();
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(visible: boolean): void {
|
||||||
|
// Called from the 200ms tick, so bail unless it actually flipped.
|
||||||
|
if (visible === this.visible) return;
|
||||||
|
this.visible = visible;
|
||||||
|
// Must resume explicitly; nothing else restarts a paused video.
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-screen check for covers with no panel of their own to test. */
|
||||||
|
tileVisible(): boolean {
|
||||||
|
const tile = this.tile;
|
||||||
|
if (!tile?.isConnected) return false;
|
||||||
|
if (typeof tile.checkVisibility === "function") {
|
||||||
|
return tile.checkVisibility({ checkVisibilityCSS: true });
|
||||||
|
}
|
||||||
|
return getComputedStyle(tile).visibility !== "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
this.applyRate();
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePlaying(): void {
|
||||||
|
const video = this.video;
|
||||||
|
if (!video) return;
|
||||||
|
if (
|
||||||
|
this.artwork &&
|
||||||
|
settings.animatedArtwork &&
|
||||||
|
this.visible &&
|
||||||
|
video.isConnected
|
||||||
|
) {
|
||||||
|
void video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.artwork = null;
|
||||||
|
this.failedSrc = null;
|
||||||
|
this.teardown();
|
||||||
|
this.resizeObs?.disconnect();
|
||||||
|
this.resizeObs = null;
|
||||||
|
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
|
||||||
|
this.resizeRaf = 0;
|
||||||
|
this.releaseMount();
|
||||||
|
this.tile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(src: string): void {
|
||||||
|
this.teardown();
|
||||||
|
if (!this.mount) return;
|
||||||
|
this.liveSrc = src;
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.className = "rl-animated-art";
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.crossOrigin = "anonymous";
|
||||||
|
video.tabIndex = -1;
|
||||||
|
video.setAttribute("aria-hidden", "true");
|
||||||
|
// Fade in on the first decoded frame, so the static cover shows until then.
|
||||||
|
video.addEventListener(
|
||||||
|
"loadeddata",
|
||||||
|
() => video.classList.add("rl-animated-art-visible"),
|
||||||
|
{ once: 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();
|
||||||
|
|
||||||
|
// hls.js over MSE only: Chromium claims native HLS support but fails to
|
||||||
|
// demux the playlist, so there is no native fallback.
|
||||||
|
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 {
|
||||||
|
trace.log("Animated Artwork: HLS unsupported on this client");
|
||||||
|
this.failedSrc = src;
|
||||||
|
this.teardown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.applyRate();
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
// CPU tracks presented fps and nothing else, so this is the only perf knob.
|
// CPU tracks presented fps and nothing else, so this is the only perf knob.
|
||||||
private applyRate(): void {
|
private applyRate(): void {
|
||||||
if (!this.video) return;
|
if (!this.video) return;
|
||||||
@@ -49,24 +220,48 @@ export class AnimatedArtworkLayer {
|
|||||||
if (this.video.playbackRate !== rate) this.video.playbackRate = rate;
|
if (this.video.playbackRate !== rate) this.video.playbackRate = rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseMount(): void {
|
/** Fade the video out, then drop it, unless bind() claims it back first. */
|
||||||
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
|
private fadeOut(): void {
|
||||||
this.mountPositioned = false;
|
const video = this.video;
|
||||||
this.mount = null;
|
if (!video || this.fadeTimer !== 0) return;
|
||||||
|
video.classList.remove("rl-animated-art-visible");
|
||||||
|
this.fadeTimer = window.setTimeout(() => {
|
||||||
|
this.fadeTimer = 0;
|
||||||
|
if (this.video === video) this.teardown();
|
||||||
|
}, ART_FADE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while a matching artwork tile is alive. */
|
private teardown(): void {
|
||||||
private tileAlive(): boolean {
|
if (this.fadeTimer !== 0) clearTimeout(this.fadeTimer);
|
||||||
if (this.tile?.isConnected) return true;
|
this.fadeTimer = 0;
|
||||||
const tile = document.querySelector<HTMLElement>(ART_TILE_SELECTOR);
|
this.video?.remove();
|
||||||
if (!tile) return false;
|
this.video = null;
|
||||||
this.host(tile);
|
this.hls?.destroy();
|
||||||
return true;
|
this.hls = null;
|
||||||
|
this.liveSrc = null;
|
||||||
|
this.nativeFps = ART_NATIVE_FPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail = (reason: string): void => {
|
||||||
|
trace.log(`Animated Artwork: ${reason} — hiding video (src: ${this.liveSrc})`);
|
||||||
|
this.failedSrc = this.liveSrc;
|
||||||
|
this.teardown();
|
||||||
|
};
|
||||||
|
|
||||||
|
private pickSrc(): string {
|
||||||
|
if (!this.artwork) return "";
|
||||||
|
if (this.allowTall && this.tile) {
|
||||||
|
const rect = this.tile.getBoundingClientRect();
|
||||||
|
if (rect.width && rect.height && rect.height > rect.width) {
|
||||||
|
return this.artwork.url_tall;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.artwork.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private host(tile: HTMLElement): void {
|
private host(tile: HTMLElement): void {
|
||||||
this.tile = tile;
|
this.tile = tile;
|
||||||
// The tile is an <img>; a replaced element silently drops children, so
|
// Covers are <img>s; a replaced element silently drops children, so
|
||||||
// host the video in the parent and lay it over the tile.
|
// host the video in the parent and lay it over the tile.
|
||||||
const replaced = REPLACED_TAGS.has(tile.tagName);
|
const replaced = REPLACED_TAGS.has(tile.tagName);
|
||||||
const mount = replaced ? tile.parentElement : tile;
|
const mount = replaced ? tile.parentElement : tile;
|
||||||
@@ -79,6 +274,12 @@ export class AnimatedArtworkLayer {
|
|||||||
this.watchSizing();
|
this.watchSizing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private releaseMount(): void {
|
||||||
|
if (this.mount && this.mountPositioned) this.mount.style.removeProperty("position");
|
||||||
|
this.mountPositioned = false;
|
||||||
|
this.mount = null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Lay the video over the tile when it is not a direct child of it. */
|
/** Lay the video over the tile when it is not a direct child of it. */
|
||||||
private syncGeometry(): void {
|
private syncGeometry(): void {
|
||||||
const { video, tile, mount } = this;
|
const { video, tile, mount } = this;
|
||||||
@@ -104,242 +305,115 @@ export class AnimatedArtworkLayer {
|
|||||||
this.resizeRaf = requestAnimationFrame(() => {
|
this.resizeRaf = requestAnimationFrame(() => {
|
||||||
this.resizeRaf = 0;
|
this.resizeRaf = 0;
|
||||||
// Only rebuild the stream if the aspect actually flipped.
|
// Only rebuild the stream if the aspect actually flipped.
|
||||||
if (this.pickSrc() !== this.liveSrc) this.mountVideo(false);
|
if (this.pickSrc() !== this.liveSrc) this.bind();
|
||||||
this.syncGeometry();
|
this.syncGeometry();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.resizeObs.observe(this.tile);
|
this.resizeObs.observe(this.tile);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtworkTrack {
|
||||||
|
trackId: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string;
|
||||||
|
isrc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the animated artwork for the playing track and feeds it to every
|
||||||
|
* surface. Bound to the track ID: one track's art never plays over another's
|
||||||
|
* cover, except across an album whose art was album-matched (TIDAL shows the
|
||||||
|
* same static cover there too).
|
||||||
|
*/
|
||||||
|
export class AnimatedArtworkLayer {
|
||||||
|
private readonly nowPlaying = new ArtworkSurface(ART_TILE_SELECTOR, true);
|
||||||
|
private readonly mini = new ArtworkSurface(MINI_ART_SELECTOR, false);
|
||||||
|
private readonly surfaces = [this.nowPlaying, this.mini];
|
||||||
|
private trackId: string | null = null;
|
||||||
|
/**
|
||||||
|
* Scope key of the current answer (see fetchAnimatedArtwork): the album
|
||||||
|
* key only when it was matched by album, else the title key.
|
||||||
|
*/
|
||||||
|
private artworkKey: string | null = null;
|
||||||
|
private loadToken = 0;
|
||||||
|
private fetchAbort: AbortController | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount the artwork for `key`, leaving any playing video alone until a
|
* Called from the shared track-change listener. `albumKey` is the
|
||||||
* replacement resolves. `done: false` means no tile was on screen, so
|
* album-scoped request key for this track (artworkKey() in index.ts).
|
||||||
* retry later. Returns the scope `key` the result is valid under (see
|
|
||||||
* fetchAnimatedArtwork), which may be narrower than the request key.
|
|
||||||
*/
|
*/
|
||||||
async load(
|
async setTrack(track: ArtworkTrack, albumKey: string): Promise<void> {
|
||||||
key: string,
|
// Same track again (initial fire + transition, repeat-one): keep playing.
|
||||||
title: string,
|
if (track.trackId === this.trackId) {
|
||||||
artist: string,
|
this.rebind();
|
||||||
album: string | undefined,
|
return;
|
||||||
isrc?: string,
|
|
||||||
): 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 };
|
|
||||||
}
|
}
|
||||||
|
this.trackId = track.trackId;
|
||||||
const token = ++this.loadToken;
|
const token = ++this.loadToken;
|
||||||
this.fetchAbort?.abort();
|
this.fetchAbort?.abort();
|
||||||
this.fetchAbort = new AbortController();
|
this.fetchAbort = null;
|
||||||
const signal = this.fetchAbort.signal;
|
|
||||||
|
|
||||||
if (!settings.animatedArtwork) return { done: true, key };
|
// Album-matched art covers the album's other tracks: nothing to fetch.
|
||||||
// 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,
|
|
||||||
isrc,
|
|
||||||
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 (
|
if (
|
||||||
this.artwork &&
|
this.artworkKey !== null &&
|
||||||
this.video &&
|
(albumKey === this.artworkKey ||
|
||||||
settings.animatedArtwork &&
|
artworkTitleKey(track.artist, track.title) === this.artworkKey)
|
||||||
this.nowPlayingVisible &&
|
|
||||||
!document.hidden &&
|
|
||||||
this.tile?.isConnected
|
|
||||||
) {
|
) {
|
||||||
void this.video.play().catch(() => {});
|
this.rebind();
|
||||||
} else {
|
return;
|
||||||
this.video?.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Different art: drop the old track's video now rather than letting it
|
||||||
|
// play over the new cover for the length of the lookup.
|
||||||
|
this.apply(null, null);
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
|
this.fetchAbort = abort;
|
||||||
|
const lookup = await fetchAnimatedArtwork(
|
||||||
|
track.title,
|
||||||
|
track.artist,
|
||||||
|
track.album,
|
||||||
|
track.isrc,
|
||||||
|
abort.signal,
|
||||||
|
);
|
||||||
|
if (token !== this.loadToken) return;
|
||||||
|
this.fetchAbort = null;
|
||||||
|
if (!lookup.artwork) {
|
||||||
|
trace.log(`Animated Artwork: no animated art for "${track.title}"`);
|
||||||
|
}
|
||||||
|
this.apply(lookup.artwork, lookup.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
setNowPlayingVisible(visible: boolean): void {
|
/** Re-bind to the covers after TIDAL (re)mounted one. */
|
||||||
// Called from the 200ms tick, so bail unless it actually flipped.
|
rebind(): void {
|
||||||
if (visible === this.nowPlayingVisible) return;
|
for (const surface of this.surfaces) surface.bind();
|
||||||
this.nowPlayingVisible = visible;
|
|
||||||
// Must resume explicitly; nothing else restarts a paused video.
|
|
||||||
this.ensurePlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Called from the 200ms tick. */
|
||||||
|
syncVisibility(nowPlayingVisible: boolean): void {
|
||||||
|
const pageVisible = !document.hidden;
|
||||||
|
this.nowPlaying.setVisible(pageVisible && nowPlayingVisible);
|
||||||
|
this.mini.setVisible(pageVisible && this.mini.tileVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings changed (fps). */
|
||||||
refresh(): void {
|
refresh(): void {
|
||||||
if (!this.liveSrc) return;
|
for (const surface of this.surfaces) surface.refresh();
|
||||||
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 {
|
dispose(): void {
|
||||||
this.loadToken++;
|
this.loadToken++;
|
||||||
this.fetchAbort?.abort();
|
this.fetchAbort?.abort();
|
||||||
this.fetchAbort = null;
|
this.fetchAbort = null;
|
||||||
this.resizeObs?.disconnect();
|
this.trackId = null;
|
||||||
this.resizeObs = null;
|
this.artworkKey = null;
|
||||||
if (this.resizeRaf !== 0) cancelAnimationFrame(this.resizeRaf);
|
for (const surface of this.surfaces) surface.dispose();
|
||||||
this.resizeRaf = 0;
|
}
|
||||||
this.detach(false);
|
|
||||||
this.releaseMount();
|
private apply(artwork: AnimatedArtwork | null, key: string | null): void {
|
||||||
this.tile = null;
|
this.artworkKey = key;
|
||||||
|
for (const surface of this.surfaces) surface.show(artwork);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,16 @@ 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 {
|
||||||
|
AnimatedArtworkLayer,
|
||||||
|
ART_TILE_SELECTOR,
|
||||||
|
MINI_ART_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";
|
||||||
@@ -835,14 +838,14 @@ const syncBackdropActivity = (): void => {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second.
|
// Pause artwork videos whose cover is off screen. Guarded: this ticks 5x/second.
|
||||||
if (animatedArtworkLayer) {
|
if (animatedArtworkLayer) {
|
||||||
if (!cachedNowPlayingPanel?.isConnected) {
|
if (!cachedNowPlayingPanel?.isConnected) {
|
||||||
cachedNowPlayingPanel = document.querySelector(
|
cachedNowPlayingPanel = document.querySelector(
|
||||||
'[class*="_nowPlayingContainer"]',
|
'[class*="_nowPlayingContainer"]',
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
}
|
}
|
||||||
animatedArtworkLayer.setNowPlayingVisible(
|
animatedArtworkLayer.syncVisibility(
|
||||||
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
|
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1102,63 +1105,38 @@ const updateRadiantLyricsBackdrop = function (): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// MARKER: Animated Artwork (live artwork on the artwork tile)
|
// MARKER: Animated Artwork (live artwork on the artwork tiles)
|
||||||
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
|
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
|
||||||
let lastArtworkKey: string | null = null;
|
|
||||||
let lastArtworkTile: Element | null = null;
|
let lastArtworkTile: Element | null = null;
|
||||||
|
let lastMiniArtworkTile: Element | null = null;
|
||||||
|
let artworkUpdateSeq = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request key for an animated-artwork lookup: album-scoped when an album is
|
* Album-scoped request key for an animated-artwork lookup (skips within an
|
||||||
* present (skips within one are free). A result may still be scoped tighter —
|
* album are free when the art was album-matched). Without an album it falls
|
||||||
* see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the
|
* back to the title key, matching artworkTitleKey() in api.ts.
|
||||||
* effective scope, not this request key.
|
|
||||||
*/
|
*/
|
||||||
const artworkKey = (t: TrackInfo): string =>
|
const artworkKey = (t: TrackInfo): string =>
|
||||||
t.album && t.album.trim() !== ""
|
t.album && t.album.trim() !== ""
|
||||||
? `${t.artist}\u0000${t.album.trim()}`
|
? `${t.artist}\u0000${t.album.trim()}`
|
||||||
: `${t.artist}\u0000\u0000${t.title}`;
|
: `${t.artist}\u0000\u0000${t.title}`;
|
||||||
|
|
||||||
// Load + show animated art for the current (or given) track
|
// Load + show animated art for the playing track. Only the shared track-change
|
||||||
const updateAnimatedArtwork = async (
|
// listener (and the setting) drive lookups; cover remounts just rebind.
|
||||||
target?: TrackInfo | null,
|
const updateAnimatedArtwork = async (): Promise<void> => {
|
||||||
rehost = false,
|
const seq = ++artworkUpdateSeq;
|
||||||
): Promise<void> => {
|
|
||||||
if (!settings.animatedArtwork) {
|
if (!settings.animatedArtwork) {
|
||||||
animatedArtworkLayer?.dispose();
|
animatedArtworkLayer?.dispose();
|
||||||
animatedArtworkLayer = null;
|
animatedArtworkLayer = null;
|
||||||
lastArtworkKey = null;
|
|
||||||
lastArtworkTile = null;
|
lastArtworkTile = null;
|
||||||
|
lastMiniArtworkTile = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer();
|
animatedArtworkLayer ??= new AnimatedArtworkLayer();
|
||||||
target ??= await getTrackInfo();
|
const target = await getTrackInfo();
|
||||||
if (!target) return;
|
// A newer track change won the race, or the setting flipped meanwhile.
|
||||||
// Re-check after the await; another caller may have claimed this already.
|
if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return;
|
||||||
if (!settings.animatedArtwork || !animatedArtworkLayer) return;
|
await animatedArtworkLayer.setTrack(target, artworkKey(target));
|
||||||
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,
|
|
||||||
target.isrc,
|
|
||||||
);
|
|
||||||
// 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).updateAnimatedArtwork = updateAnimatedArtwork;
|
||||||
@@ -1542,13 +1520,19 @@ function setupLyricsMenuObserver(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The tile only exists once the view is open, so load from here too. This fires
|
// Covers remount as views open and close: move the live artwork video onto
|
||||||
// on nearly every DOM change, so bail synchronously unless the tile is new.
|
// the new element (no refetch). These fire on nearly every DOM change, so
|
||||||
|
// bail synchronously unless the cover is new.
|
||||||
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
|
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
|
||||||
// Identity only; also stops us reacting to our own <video> insertion.
|
// Identity only; also stops us reacting to our own <video> insertion.
|
||||||
if (tile === lastArtworkTile) return;
|
if (tile === lastArtworkTile) return;
|
||||||
lastArtworkTile = tile;
|
lastArtworkTile = tile;
|
||||||
void updateAnimatedArtwork(undefined, true);
|
animatedArtworkLayer?.rebind();
|
||||||
|
});
|
||||||
|
observe<HTMLElement>(unloads, MINI_ART_SELECTOR, (tile) => {
|
||||||
|
if (tile === lastMiniArtworkTile) return;
|
||||||
|
lastMiniArtworkTile = tile;
|
||||||
|
animatedArtworkLayer?.rebind();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply word lyrics when lyrics container appears or reappears
|
// Apply word lyrics when lyrics container appears or reappears
|
||||||
|
|||||||
Reference in new issue
Block a user