mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-10-14 23:43:13 +11:00
Merge pull request #159 from meowarex/dev
AI Indicator + Animated Covers <3
This commit is contained in:
14 files changed
+924
-81
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",
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ export interface AudioData {
|
|||||||
binCount: number;
|
binCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection states
|
// Connection states: disconnected, pending (no audio yet), live
|
||||||
// disconnected - no audio source
|
|
||||||
// pending - wired but audio isn't detected (track muted / loading)
|
|
||||||
// live - audio is detected
|
|
||||||
export type ConnectionState = "disconnected" | "pending" | "live";
|
export type ConnectionState = "disconnected" | "pending" | "live";
|
||||||
let state: ConnectionState = "disconnected";
|
let state: ConnectionState = "disconnected";
|
||||||
let onStateChange: ((state: ConnectionState) => void) | null = null;
|
let onStateChange: ((state: ConnectionState) => void) | null = null;
|
||||||
@@ -202,13 +199,13 @@ const captureFromEl = (el: HTMLMediaElement): boolean => {
|
|||||||
track.removeEventListener("ended", onEnded);
|
track.removeEventListener("ended", onEnded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Captured tracks start muted until samples flow; wait for "unmute" instead of guessing.
|
// Captured tracks start muted, wait for unmute
|
||||||
loggedCaptureFailure = false;
|
loggedCaptureFailure = false;
|
||||||
setState(track.muted ? "pending" : "live");
|
setState(track.muted ? "pending" : "live");
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// An element is worth capturing from when it's actually advancing audio.
|
// Only capture elements that are playing
|
||||||
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
|
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ void main() {
|
|||||||
float barMask = smoothstep(0.0, u_gap, cellPos)
|
float barMask = smoothstep(0.0, u_gap, cellPos)
|
||||||
* smoothstep(0.0, u_gap, 1.0 - cellPos);
|
* smoothstep(0.0, u_gap, 1.0 - cellPos);
|
||||||
|
|
||||||
// Hard cut at bottom, soft feather only at the top edge
|
// Hard bottom, feathered top
|
||||||
float feather = 1.5 / u_resolution.y;
|
float feather = 1.5 / u_resolution.y;
|
||||||
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
|
float heightMask = 1.0 - smoothstep(amp - feather, amp + feather, uv.y);
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ const onClickHooked = (event: MouseEvent): boolean | undefined => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listener with capture phase to intercept events before they reach other handlers
|
// Capture phase, runs before other handlers
|
||||||
|
|
||||||
document.addEventListener("click", onClickHooked, true);
|
document.addEventListener("click", onClickHooked, true);
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ new StyleTag("Element-Hider", unloads, styles);
|
|||||||
let targetElement: HTMLElement | null = null;
|
let targetElement: HTMLElement | null = null;
|
||||||
let hiddenElements = new WeakSet<HTMLElement>();
|
let hiddenElements = new WeakSet<HTMLElement>();
|
||||||
|
|
||||||
// Count of elements currently hidden in the live DOM. The `.element-hider-hidden`
|
// Count hidden elements in the live DOM
|
||||||
// class is the source of truth — querying it avoids retaining detached nodes
|
|
||||||
// across SPA navigations.
|
|
||||||
function getHiddenCount(): number {
|
function getHiddenCount(): number {
|
||||||
return document.querySelectorAll(".element-hider-hidden").length;
|
return document.querySelectorAll(".element-hider-hidden").length;
|
||||||
}
|
}
|
||||||
@@ -371,7 +369,7 @@ let customMenu: HTMLElement | null = null;
|
|||||||
let contextMenuTimeout: number | null = null;
|
let contextMenuTimeout: number | null = null;
|
||||||
let waitingForBuiltInMenu = false;
|
let waitingForBuiltInMenu = false;
|
||||||
|
|
||||||
// Listen for right-click events to capture the target for context menu
|
// Grab the right-clicked element
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"contextmenu",
|
"contextmenu",
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
@@ -403,18 +401,18 @@ document.addEventListener(
|
|||||||
const eventX = event.clientX;
|
const eventX = event.clientX;
|
||||||
const eventY = event.clientY;
|
const eventY = event.clientY;
|
||||||
|
|
||||||
// Allow native context menu by default; we'll show our custom menu only if needed
|
// Native menu first, ours only if needed
|
||||||
|
|
||||||
// Wait to see if the built-in context menu appears
|
// Wait to see if the built-in context menu appears
|
||||||
contextMenuTimeout = window.setTimeout(() => {
|
contextMenuTimeout = window.setTimeout(() => {
|
||||||
// If we're still waiting and no built-in menu appeared, show our custom menu
|
// No built-in menu appeared, show ours
|
||||||
if (waitingForBuiltInMenu && currentContextElement) {
|
if (waitingForBuiltInMenu && currentContextElement) {
|
||||||
showCustomMenu(eventX, eventY);
|
showCustomMenu(eventX, eventY);
|
||||||
}
|
}
|
||||||
waitingForBuiltInMenu = false;
|
waitingForBuiltInMenu = false;
|
||||||
}, 150); // Wait 150ms for built-in menu
|
}, 150); // Wait 150ms for built-in menu
|
||||||
|
|
||||||
// Don't prevent default initially - let Luna try to handle the context menu
|
// Let Luna handle the context menu first
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
@@ -200,7 +210,7 @@ export const Settings = () => {
|
|||||||
window.updateRadiantLyricsBackdrop?.();
|
window.updateRadiantLyricsBackdrop?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derive props and override onChange to accept a broader first param type
|
// Loosen onChange's first param type
|
||||||
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
|
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
|
||||||
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
|
type AnySwitchProps = Omit<BaseSwitchProps, "onChange"> & {
|
||||||
onChange: (_: unknown, checked: boolean) => void;
|
onChange: (_: unknown, checked: boolean) => void;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ export interface WordLyricsResponse {
|
|||||||
songParts?: Array<{ name: string; time: number; duration: number }>;
|
songParts?: Array<{ name: string; time: number; duration: number }>;
|
||||||
};
|
};
|
||||||
_cached?: boolean;
|
_cached?: boolean;
|
||||||
/** true when timings came from the hosted Radiant AI model (?synthesize=true) */
|
// Timings came from Radiant AI
|
||||||
_synthesized?: boolean;
|
_synthesized?: boolean;
|
||||||
/** original line-level data, kept so AI timings can be toggled off without a refetch */
|
// Original lines, so AI can be turned off without refetch
|
||||||
lines?: ApiLine[];
|
lines?: ApiLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +202,127 @@ export async function fetchLyrics(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnimatedArtwork {
|
||||||
|
url: string;
|
||||||
|
url_tall: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtworkLookup {
|
||||||
|
artwork: AnimatedArtwork | null;
|
||||||
|
// Album key if album matched, else title key
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key for this exact track only
|
||||||
|
export const artworkTitleKey = (artist: string, title: string): string =>
|
||||||
|
`${artist}\u0000\u0000${title}`;
|
||||||
|
|
||||||
|
// ISRC lookup, immune to name mismatches
|
||||||
|
const artworkIsrcUrl = (isrc: string): string =>
|
||||||
|
`https://ama.trainswift.net/api/v1/artwork/search?isrc=${encodeURIComponent(isrc)}`;
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache hits and misses, album key only for album matches
|
||||||
|
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
|
||||||
|
const ARTWORK_CACHE_MAX = 200;
|
||||||
|
|
||||||
|
export async function fetchAnimatedArtwork(
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
album?: string,
|
||||||
|
isrc?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ArtworkLookup> {
|
||||||
|
const albumName = album?.trim() || "";
|
||||||
|
const albumPresent = albumName !== "";
|
||||||
|
const isrcTrimmed = isrc?.trim() || "";
|
||||||
|
// Album key, or title key without an album
|
||||||
|
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 {
|
||||||
|
// ISRC first, misses fall through uncached
|
||||||
|
if (isrcTrimmed) {
|
||||||
|
const byIsrc = await tryFetch(artworkIsrcUrl(isrcTrimmed));
|
||||||
|
if (byIsrc) return { artwork: remember(byIsrc), key: cacheKey };
|
||||||
|
}
|
||||||
|
// Fallback: 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) {
|
||||||
|
// Title-only result, scope it to this track
|
||||||
|
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,397 @@
|
|||||||
|
// MARKER: Animated Artwork (live artwork in the lyrics view)
|
||||||
|
// Layer finds the art, surfaces draw it on covers.
|
||||||
|
// 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 {
|
||||||
|
type AnimatedArtwork,
|
||||||
|
artworkTitleKey,
|
||||||
|
fetchAnimatedArtwork,
|
||||||
|
} from "./api";
|
||||||
|
import { settings } from "./Settings";
|
||||||
|
|
||||||
|
const { trace } = Tracer("[Radiant Lyrics]");
|
||||||
|
|
||||||
|
export const ART_TILE_SELECTOR = '[data-test="now-playing-artwork"]';
|
||||||
|
// Player bar thumbnail
|
||||||
|
export const MINI_ART_SELECTOR =
|
||||||
|
'[data-test="footer-player"] [data-test="current-media-imagery"] img';
|
||||||
|
// Can't hold children, so the video goes beside them
|
||||||
|
const REPLACED_TAGS = new Set(["IMG", "PICTURE", "VIDEO", "CANVAS", "SVG"]);
|
||||||
|
// Skip the 2160px rung (91MB a loop)
|
||||||
|
const ART_MAX_DPR = 1;
|
||||||
|
const ART_NATIVE_FPS = 25;
|
||||||
|
// Same as the CSS fade
|
||||||
|
const ART_FADE_MS = 300;
|
||||||
|
|
||||||
|
// One video per cover, moved (not rebuilt) on remount
|
||||||
|
class ArtworkSurface {
|
||||||
|
private video: HTMLVideoElement | null = null;
|
||||||
|
private hls: Hls | null = null;
|
||||||
|
private tile: HTMLElement | null = null;
|
||||||
|
// The tile, or its parent for replaced elements
|
||||||
|
private mount: HTMLElement | null = null;
|
||||||
|
/** True when the mount's inline position is ours to revert. */
|
||||||
|
private mountPositioned = false;
|
||||||
|
private resizeObs: ResizeObserver | null = null;
|
||||||
|
private resizeRaf = 0;
|
||||||
|
private artwork: AnimatedArtwork | null = null;
|
||||||
|
private liveSrc: string | null = null;
|
||||||
|
// Errored stream, skipped until the art changes
|
||||||
|
private failedSrc: string | null = null;
|
||||||
|
private fadeTimer = 0;
|
||||||
|
private visible = true;
|
||||||
|
private nativeFps = ART_NATIVE_FPS;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly selector: string,
|
||||||
|
// Use the tall stream on portrait covers
|
||||||
|
private readonly allowTall: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Show art, or fade out on 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 (moves, never rebuilds)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Came back mid fade, keep it
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Moving keeps the buffered stream
|
||||||
|
if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video);
|
||||||
|
this.syncGeometry();
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(visible: boolean): void {
|
||||||
|
// 200ms tick, bail unless it flipped
|
||||||
|
if (visible === this.visible) return;
|
||||||
|
this.visible = visible;
|
||||||
|
// Must resume explicitly; nothing else restarts a paused video.
|
||||||
|
this.ensurePlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is the cover on screen
|
||||||
|
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 once the first frame is ready
|
||||||
|
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 only, native HLS is broken in Chromium
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FPS is the only CPU 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade out, then drop the video
|
||||||
|
private fadeOut(): void {
|
||||||
|
const video = this.video;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown(): void {
|
||||||
|
if (this.fadeTimer !== 0) clearTimeout(this.fadeTimer);
|
||||||
|
this.fadeTimer = 0;
|
||||||
|
this.video?.remove();
|
||||||
|
this.video = null;
|
||||||
|
this.hls?.destroy();
|
||||||
|
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 {
|
||||||
|
this.tile = tile;
|
||||||
|
// Covers are <img>s, so host the video in the parent
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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.bind();
|
||||||
|
this.syncGeometry();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.resizeObs.observe(this.tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtworkTrack {
|
||||||
|
trackId: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string;
|
||||||
|
isrc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds art for the playing track, feeds every cover
|
||||||
|
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;
|
||||||
|
// Album key if album matched, else title key
|
||||||
|
private artworkKey: string | null = null;
|
||||||
|
private loadToken = 0;
|
||||||
|
private fetchAbort: AbortController | null = null;
|
||||||
|
|
||||||
|
// Called on track change
|
||||||
|
async setTrack(track: ArtworkTrack, albumKey: string): Promise<void> {
|
||||||
|
// Same track, keep playing
|
||||||
|
if (track.trackId === this.trackId) {
|
||||||
|
this.rebind();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.trackId = track.trackId;
|
||||||
|
const token = ++this.loadToken;
|
||||||
|
this.fetchAbort?.abort();
|
||||||
|
this.fetchAbort = null;
|
||||||
|
|
||||||
|
// Same album art, nothing to fetch
|
||||||
|
if (
|
||||||
|
this.artworkKey !== null &&
|
||||||
|
(albumKey === this.artworkKey ||
|
||||||
|
artworkTitleKey(track.artist, track.title) === this.artworkKey)
|
||||||
|
) {
|
||||||
|
this.rebind();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New art, drop the old video now
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebind after TIDAL remounts a cover
|
||||||
|
rebind(): void {
|
||||||
|
for (const surface of this.surfaces) surface.bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// FPS setting changed
|
||||||
|
refresh(): void {
|
||||||
|
for (const surface of this.surfaces) surface.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.loadToken++;
|
||||||
|
this.fetchAbort?.abort();
|
||||||
|
this.fetchAbort = null;
|
||||||
|
this.trackId = null;
|
||||||
|
this.artworkKey = null;
|
||||||
|
for (const surface of this.surfaces) surface.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private apply(artwork: AnimatedArtwork | null, key: string | null): void {
|
||||||
|
this.artworkKey = key;
|
||||||
|
for (const surface of this.surfaces) surface.show(artwork);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// hls.js only types its main entry, reuse them here
|
||||||
|
declare module "hls.js/light" {
|
||||||
|
export * from "hls.js";
|
||||||
|
export { default } from "hls.js";
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
romanizeLyrics as romanizeLyricsApi,
|
romanizeLyrics as romanizeLyricsApi,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { KawarpLayer } from "./backdrop";
|
import { KawarpLayer } from "./backdrop";
|
||||||
|
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";
|
||||||
@@ -119,7 +124,7 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** CSS var for integrated seekbar top corners — matches Floating Bar Corner Radius when floating is on */
|
// Seekbar top corners match the floating bar radius
|
||||||
const applyIntegratedSeekbarChrome = (): void => {
|
const applyIntegratedSeekbarChrome = (): void => {
|
||||||
const footer = document.querySelector(
|
const footer = document.querySelector(
|
||||||
'[data-test="footer-player"]',
|
'[data-test="footer-player"]',
|
||||||
@@ -195,7 +200,7 @@ const applyPlayerBarTintToElement = (): void => {
|
|||||||
applyIntegratedSeekbarChrome();
|
applyIntegratedSeekbarChrome();
|
||||||
};
|
};
|
||||||
|
|
||||||
// When floating is disabled, inject square-bar CSS to override Tidal's native floating styles
|
// Floating off: inject square bar CSS
|
||||||
const applyFloatingPlayerBar = (): void => {
|
const applyFloatingPlayerBar = (): void => {
|
||||||
if (settings.floatingPlayerBar) {
|
if (settings.floatingPlayerBar) {
|
||||||
floatingPlayerBarStyleTag.remove();
|
floatingPlayerBarStyleTag.remove();
|
||||||
@@ -536,14 +541,59 @@ const toggleRadiantLyrics = function (): void {
|
|||||||
|
|
||||||
// Create buttons
|
// Create buttons
|
||||||
let flushLocked = false;
|
let flushLocked = false;
|
||||||
|
let flushSpinning = false;
|
||||||
|
|
||||||
|
// Swap flush icon and loading spinner
|
||||||
|
const setFlushSpinner = (on: boolean): void => {
|
||||||
|
flushSpinning = on;
|
||||||
|
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
const svg = btn.querySelector("svg");
|
||||||
|
if (!svg) return;
|
||||||
|
const svgClass = (svg.getAttribute("class") ?? "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((c) => c && c !== "rl-spinner");
|
||||||
|
if (on) {
|
||||||
|
if (svg.classList.contains("rl-spinner")) return;
|
||||||
|
btn.setAttribute("data-tip", "Loading lyrics…");
|
||||||
|
btn.setAttribute("aria-label", "Loading lyrics…");
|
||||||
|
const spinner = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
if (svgClass.length) spinner.setAttribute("class", svgClass.join(" "));
|
||||||
|
spinner.classList.add("rl-spinner");
|
||||||
|
spinner.setAttribute("viewBox", "0 0 20 20");
|
||||||
|
spinner.setAttribute("aria-hidden", "true");
|
||||||
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
|
circle.setAttribute("cx", "10");
|
||||||
|
circle.setAttribute("cy", "10");
|
||||||
|
circle.setAttribute("r", "8");
|
||||||
|
circle.setAttribute(
|
||||||
|
"style",
|
||||||
|
"fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-dasharray:42 8",
|
||||||
|
);
|
||||||
|
spinner.appendChild(circle);
|
||||||
|
svg.replaceWith(spinner);
|
||||||
|
} else {
|
||||||
|
const spinner = btn.querySelector("svg.rl-spinner");
|
||||||
|
if (!spinner) return;
|
||||||
|
const syncSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
if (svgClass.length) syncSvg.setAttribute("class", svgClass.join(" "));
|
||||||
|
syncSvg.setAttribute("viewBox", "0 0 20 20");
|
||||||
|
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||||
|
use.setAttribute("href", "#general__lyrics-sync");
|
||||||
|
syncSvg.appendChild(use);
|
||||||
|
spinner.replaceWith(syncSvg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unlockFlush = (): void => {
|
const unlockFlush = (): void => {
|
||||||
flushLocked = false;
|
flushLocked = false;
|
||||||
|
setFlushSpinner(false);
|
||||||
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement;
|
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.style.opacity = "";
|
btn.style.opacity = "";
|
||||||
btn.style.cursor = "";
|
btn.style.cursor = "";
|
||||||
btn.setAttribute("title", "Flush Lyrics");
|
btn.setAttribute("data-tip", "Flush Lyrics");
|
||||||
btn.setAttribute("aria-label", "Flush Lyrics");
|
btn.setAttribute("aria-label", "Flush Lyrics");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -555,15 +605,17 @@ const lockFlush = (): void => {
|
|||||||
btn.style.opacity = "0.3";
|
btn.style.opacity = "0.3";
|
||||||
btn.style.cursor = "not-allowed";
|
btn.style.cursor = "not-allowed";
|
||||||
}
|
}
|
||||||
|
setFlushSpinner(true);
|
||||||
};
|
};
|
||||||
const disableFlushNoLyrics = (): void => {
|
const disableFlushNoLyrics = (): void => {
|
||||||
flushLocked = true;
|
flushLocked = true;
|
||||||
|
setFlushSpinner(false);
|
||||||
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement;
|
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.style.opacity = "0.3";
|
btn.style.opacity = "0.3";
|
||||||
btn.style.cursor = "not-allowed";
|
btn.style.cursor = "not-allowed";
|
||||||
btn.setAttribute("title", "Track has no lyrics");
|
btn.setAttribute("data-tip", "Track has no lyrics");
|
||||||
btn.setAttribute("aria-label", "Track has no lyrics");
|
btn.setAttribute("aria-label", "Track has no lyrics");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -584,9 +636,11 @@ const flushLyrics = async (): Promise<void> => {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.notFound) {
|
if (result.notFound) {
|
||||||
toast("No lyrics found for this track");
|
toast("No lyrics found for this track");
|
||||||
|
unlockFlush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toastErr(`Flush failed (${result.status})`);
|
toastErr(`Flush failed (${result.status})`);
|
||||||
|
unlockFlush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
@@ -602,9 +656,12 @@ const flushLyrics = async (): Promise<void> => {
|
|||||||
cachedLyricsKey = null;
|
cachedLyricsKey = null;
|
||||||
cachedLyricsData = null;
|
cachedLyricsData = null;
|
||||||
onTrackChange();
|
onTrackChange();
|
||||||
|
} else {
|
||||||
|
unlockFlush();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastErr(`Flush error: ${err instanceof Error ? err.message : String(err)}`);
|
toastErr(`Flush error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
unlockFlush();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -622,7 +679,7 @@ const flushBtn = function (): void {
|
|||||||
flushButton.removeAttribute("data-test");
|
flushButton.removeAttribute("data-test");
|
||||||
flushButton.setAttribute("type", "button");
|
flushButton.setAttribute("type", "button");
|
||||||
flushButton.setAttribute("aria-label", "Flush Lyrics");
|
flushButton.setAttribute("aria-label", "Flush Lyrics");
|
||||||
flushButton.setAttribute("title", "Flush Lyrics");
|
flushButton.setAttribute("data-tip", "Flush Lyrics");
|
||||||
flushButton.disabled = true;
|
flushButton.disabled = true;
|
||||||
flushButton.style.opacity = "0.3";
|
flushButton.style.opacity = "0.3";
|
||||||
flushButton.style.cursor = "not-allowed";
|
flushButton.style.cursor = "not-allowed";
|
||||||
@@ -640,6 +697,12 @@ const flushBtn = function (): void {
|
|||||||
svgEl.appendChild(useEl);
|
svgEl.appendChild(useEl);
|
||||||
spanWrapper.appendChild(svgEl);
|
spanWrapper.appendChild(svgEl);
|
||||||
flushButton.appendChild(spanWrapper);
|
flushButton.appendChild(spanWrapper);
|
||||||
|
// Recreated while loading, show the spinner again
|
||||||
|
if (flushSpinning) {
|
||||||
|
flushButton.setAttribute("data-tip", "Loading lyrics…");
|
||||||
|
flushButton.setAttribute("aria-label", "Loading lyrics…");
|
||||||
|
setFlushSpinner(true);
|
||||||
|
}
|
||||||
|
|
||||||
flushButton.onclick = () => flushLyrics();
|
flushButton.onclick = () => flushLyrics();
|
||||||
|
|
||||||
@@ -731,6 +794,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();
|
||||||
@@ -763,6 +837,18 @@ const syncBackdropActivity = (): void => {
|
|||||||
(global?.isVisible() ?? false),
|
(global?.isVisible() ?? false),
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pause art when off screen (ticks 5x/sec)
|
||||||
|
if (animatedArtworkLayer) {
|
||||||
|
if (!cachedNowPlayingPanel?.isConnected) {
|
||||||
|
cachedNowPlayingPanel = document.querySelector(
|
||||||
|
'[class*="_nowPlayingContainer"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
}
|
||||||
|
animatedArtworkLayer.syncVisibility(
|
||||||
|
!!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** cover art @ resolution worth sampling */
|
/** cover art @ resolution worth sampling */
|
||||||
@@ -1019,6 +1105,39 @@ const updateRadiantLyricsBackdrop = function (): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MARKER: Animated Artwork (live artwork on the artwork tiles)
|
||||||
|
let animatedArtworkLayer: AnimatedArtworkLayer | null = null;
|
||||||
|
let lastArtworkTile: Element | null = null;
|
||||||
|
let lastMiniArtworkTile: Element | null = null;
|
||||||
|
let artworkUpdateSeq = 0;
|
||||||
|
|
||||||
|
// Album key, or title key if no album
|
||||||
|
const artworkKey = (t: TrackInfo): string =>
|
||||||
|
t.album && t.album.trim() !== ""
|
||||||
|
? `${t.artist}\u0000${t.album.trim()}`
|
||||||
|
: `${t.artist}\u0000\u0000${t.title}`;
|
||||||
|
|
||||||
|
// Load art for the playing track
|
||||||
|
const updateAnimatedArtwork = async (): Promise<void> => {
|
||||||
|
const seq = ++artworkUpdateSeq;
|
||||||
|
if (!settings.animatedArtwork) {
|
||||||
|
animatedArtworkLayer?.dispose();
|
||||||
|
animatedArtworkLayer = null;
|
||||||
|
lastArtworkTile = null;
|
||||||
|
lastMiniArtworkTile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animatedArtworkLayer ??= new AnimatedArtworkLayer();
|
||||||
|
const target = await getTrackInfo();
|
||||||
|
// Newer track change won, bail
|
||||||
|
if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return;
|
||||||
|
await animatedArtworkLayer.setTrack(target, artworkKey(target));
|
||||||
|
};
|
||||||
|
|
||||||
|
(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;
|
||||||
@@ -1053,8 +1172,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");
|
||||||
@@ -1108,7 +1233,7 @@ unloads.add(() => {
|
|||||||
// MARKER: Lyrics Menu (dropdown on the lyrics toggle)
|
// MARKER: Lyrics Menu (dropdown on the lyrics toggle)
|
||||||
|
|
||||||
const LYRICS_MENU_ICON =
|
const LYRICS_MENU_ICON =
|
||||||
'<svg viewBox="0 0 512 512" width="16" height="16"><path fill="currentColor" d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Zm66.85-254.84h0Z"/><path fill="currentColor" d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z"/><path fill="currentColor" d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z"/></svg>';
|
'<svg viewBox="0 0 512 512" width="16" height="16"><defs><linearGradient id="rl-ai-gradient" gradientUnits="userSpaceOnUse" x1="0" y1="512" x2="512" y2="0"><stop class="rl-ai-stop" offset="0" stop-color="#ff6b9d"/><stop class="rl-ai-stop" offset="0.35" stop-color="#ffd166"/><stop class="rl-ai-stop" offset="0.65" stop-color="#5ee7ff"/><stop class="rl-ai-stop" offset="1" stop-color="#a78bfa"/></linearGradient></defs><path fill="currentColor" d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Zm66.85-254.84h0Z"/><path fill="currentColor" d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z"/><path fill="currentColor" d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z"/></svg>';
|
||||||
|
|
||||||
const applyLyricsMenuIcon = (): void => {
|
const applyLyricsMenuIcon = (): void => {
|
||||||
const trigger = document.querySelector(
|
const trigger = document.querySelector(
|
||||||
@@ -1119,6 +1244,22 @@ const applyLyricsMenuIcon = (): void => {
|
|||||||
trigger.style.paddingLeft = "5px";
|
trigger.style.paddingLeft = "5px";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LYRICS_MENU_TIP = "Lyrics Options";
|
||||||
|
|
||||||
|
// Rainbow sparkle when syllables are AI generated
|
||||||
|
const syncAiIndicator = (): void => {
|
||||||
|
document.body.classList.toggle("rl-ai-lyrics", lyricsIsAiGenerated);
|
||||||
|
document
|
||||||
|
.querySelector(".rl-lyrics-menu-trigger")
|
||||||
|
?.setAttribute(
|
||||||
|
"title",
|
||||||
|
lyricsIsAiGenerated
|
||||||
|
? "Syllable Lyrics were generated using AI"
|
||||||
|
: LYRICS_MENU_TIP,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
unloads.add(() => document.body.classList.remove("rl-ai-lyrics"));
|
||||||
|
|
||||||
// Console: Syllables.log = true/false
|
// Console: Syllables.log = true/false
|
||||||
// Verbose logging for word/syllable lyrics (hidden setting)
|
// Verbose logging for word/syllable lyrics (hidden setting)
|
||||||
const sylLog = (...args: unknown[]) => {
|
const sylLog = (...args: unknown[]) => {
|
||||||
@@ -1319,7 +1460,7 @@ const createLyricsMenu = (): void => {
|
|||||||
|
|
||||||
const trigger = document.createElement("div");
|
const trigger = document.createElement("div");
|
||||||
trigger.className = "rl-lyrics-menu-trigger";
|
trigger.className = "rl-lyrics-menu-trigger";
|
||||||
trigger.setAttribute("title", "Lyrics Options");
|
trigger.setAttribute("title", LYRICS_MENU_TIP);
|
||||||
trigger.innerHTML = LYRICS_MENU_ICON;
|
trigger.innerHTML = LYRICS_MENU_ICON;
|
||||||
|
|
||||||
for (const evtName of [
|
for (const evtName of [
|
||||||
@@ -1357,6 +1498,7 @@ const createLyricsMenu = (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
lyricsToggle.appendChild(trigger);
|
lyricsToggle.appendChild(trigger);
|
||||||
|
syncAiIndicator();
|
||||||
|
|
||||||
if (lyricsMenuOpen) {
|
if (lyricsMenuOpen) {
|
||||||
positionDropdown();
|
positionDropdown();
|
||||||
@@ -1390,6 +1532,19 @@ function setupLyricsMenuObserver(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Covers remount, move the video over (no refetch)
|
||||||
|
observe<HTMLElement>(unloads, ART_TILE_SELECTOR, (tile) => {
|
||||||
|
// Identity check, ignores our own video
|
||||||
|
if (tile === lastArtworkTile) return;
|
||||||
|
lastArtworkTile = tile;
|
||||||
|
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
|
||||||
observe<HTMLElement>(unloads, '[data-test="now-playing-lyrics"]', () => {
|
observe<HTMLElement>(unloads, '[data-test="now-playing-lyrics"]', () => {
|
||||||
if (isTrackChangeRunning) return;
|
if (isTrackChangeRunning) return;
|
||||||
@@ -1424,6 +1579,7 @@ interface TrackInfo {
|
|||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
isrc?: string;
|
isrc?: string;
|
||||||
|
album?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyntheticNativeLyricsState {
|
interface SyntheticNativeLyricsState {
|
||||||
@@ -1471,9 +1627,7 @@ const getLyricsStyle = (): number => (isWordMode() ? settings.lyricsStyle : 0);
|
|||||||
const getNowPlayingLyricsPanel = (): HTMLElement | null =>
|
const getNowPlayingLyricsPanel = (): HTMLElement | null =>
|
||||||
document.querySelector('[data-test="now-playing-lyrics"]') as HTMLElement | null;
|
document.querySelector('[data-test="now-playing-lyrics"]') as HTMLElement | null;
|
||||||
|
|
||||||
// Find the lyrics text container (wraps the individual lyrics-line spans).
|
// Find the lyrics container (no data-test in new UI)
|
||||||
// In the new player-market UI this element has no data-test; we locate it by
|
|
||||||
// walking up from the first lyrics-line span.
|
|
||||||
const findLyricsContainer = (): HTMLElement | null => {
|
const findLyricsContainer = (): HTMLElement | null => {
|
||||||
const line = document.querySelector('span[data-test="lyrics-line"]');
|
const line = document.querySelector('span[data-test="lyrics-line"]');
|
||||||
if (line?.parentElement?.parentElement) {
|
if (line?.parentElement?.parentElement) {
|
||||||
@@ -1482,8 +1636,7 @@ const findLyricsContainer = (): HTMLElement | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check whether a tidal lyrics span is currently the active/highlighted line.
|
// Is this Tidal line active (_current_ class)
|
||||||
// Player-market UI uses a CSS class matching _current_*.
|
|
||||||
const isTidalSpanActive = (span: HTMLElement): boolean => {
|
const isTidalSpanActive = (span: HTMLElement): boolean => {
|
||||||
return Array.from(span.classList).some((c) => c.startsWith("_current_"));
|
return Array.from(span.classList).some((c) => c.startsWith("_current_"));
|
||||||
};
|
};
|
||||||
@@ -2083,8 +2236,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
|
||||||
@@ -2098,7 +2252,7 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => {
|
|||||||
// So.. pick the clock per client <3
|
// So.. pick the clock per client <3
|
||||||
const IS_NATIVE_CLIENT = /TidaLunar/i.test(navigator.userAgent);
|
const IS_NATIVE_CLIENT = /TidaLunar/i.test(navigator.userAgent);
|
||||||
|
|
||||||
// currentTime is present at runtime but missing from the pinned luna typings
|
// currentTime exists but isn't in luna's typings
|
||||||
const playerCurrentTime = (): number =>
|
const playerCurrentTime = (): number =>
|
||||||
(PlayState as unknown as { currentTime: number }).currentTime;
|
(PlayState as unknown as { currentTime: number }).currentTime;
|
||||||
|
|
||||||
@@ -2301,9 +2455,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();
|
||||||
@@ -2527,10 +2682,7 @@ const restoreTidalLyrics = (): void => {
|
|||||||
savedTidalClasses = null;
|
savedTidalClasses = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// compute left/right singer sides for duet positioning
|
// Duet sides: 2nd singer right, rest left (thx Opus 4.6 <3)
|
||||||
// Uses a pre-computed fixed mapping: first person = left, second person = right,
|
|
||||||
// 3rd+ persons / group / other = left. Same singer always gets the same side.
|
|
||||||
// (thx Opus 4.6 for this <3)
|
|
||||||
const computeSingerSides = (
|
const computeSingerSides = (
|
||||||
data: WordLine[],
|
data: WordLine[],
|
||||||
agents: Record<string, { type: string; name: string; alias: string }>,
|
agents: Record<string, { type: string; name: string; alias: string }>,
|
||||||
@@ -2750,8 +2902,7 @@ const buildWordSpans = (): {
|
|||||||
span.textContent = text;
|
span.textContent = text;
|
||||||
}
|
}
|
||||||
forceStyle(span, WORD_SPAN_STYLE);
|
forceStyle(span, WORD_SPAN_STYLE);
|
||||||
// Confidence-proportional wipe feather: crisp edges where the
|
// Feather wipe by AI confidence SUPER WIP
|
||||||
// synthesizer had real evidence, soft where it guessed. SUPER WIP
|
|
||||||
if (confidence !== undefined) {
|
if (confidence !== undefined) {
|
||||||
const feather = 0.55 + (1 - Math.max(0, Math.min(1, confidence))) * 1.3;
|
const feather = 0.55 + (1 - Math.max(0, Math.min(1, confidence))) * 1.3;
|
||||||
span.style.setProperty("--rl-wipe-feather", `${feather.toFixed(2)}em`);
|
span.style.setProperty("--rl-wipe-feather", `${feather.toFixed(2)}em`);
|
||||||
@@ -2767,7 +2918,7 @@ const buildWordSpans = (): {
|
|||||||
|
|
||||||
const sylDisplay = (s: WordTiming) => romanizedText(s);
|
const sylDisplay = (s: WordTiming) => romanizedText(s);
|
||||||
|
|
||||||
// Group syllables into words: trailing whitespace in syl.text marks a word boundary
|
// Group syllables into words (trailing space = boundary)
|
||||||
const wordGroups: number[][] = [];
|
const wordGroups: number[][] = [];
|
||||||
let currentGroup: number[] = [];
|
let currentGroup: number[] = [];
|
||||||
for (let si = 0; si < syllabus.length; si++) {
|
for (let si = 0; si < syllabus.length; si++) {
|
||||||
@@ -2782,7 +2933,7 @@ const buildWordSpans = (): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveStyle === 0) {
|
if (effectiveStyle === 0) {
|
||||||
// Line mode: one span per container (main / bg) — no word splitting
|
// Line mode: one span per container, no word split
|
||||||
const mainSyls = syllabus.filter((s) => !splitBg || !s.isBackground);
|
const mainSyls = syllabus.filter((s) => !splitBg || !s.isBackground);
|
||||||
const bgSyls = splitBg ? syllabus.filter((s) => s.isBackground) : [];
|
const bgSyls = splitBg ? syllabus.filter((s) => s.isBackground) : [];
|
||||||
|
|
||||||
@@ -3140,11 +3291,7 @@ const applyInactiveBlurState = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-apply active line + word state to freshly-built DOM elements after a
|
// Reapply active state after a rebuild, without transitions
|
||||||
// rebuild (reapply / re-render observer) WITHOUT triggering CSS transitions.
|
|
||||||
// This prevents the padding-left "swipe-shift" animation from replaying when
|
|
||||||
// the container is reconstructed during scroll-unlock, resync, or React
|
|
||||||
// re-renders.
|
|
||||||
const applyActiveLineStateNoTransition = (): void => {
|
const applyActiveLineStateNoTransition = (): void => {
|
||||||
if (primaryLineIdx < 0 || activeLineIdxs.size === 0 || lines.length === 0) return;
|
if (primaryLineIdx < 0 || activeLineIdxs.size === 0 || lines.length === 0) return;
|
||||||
|
|
||||||
@@ -3170,7 +3317,7 @@ const applyActiveLineStateNoTransition = (): void => {
|
|||||||
el.setAttribute("data-current", "true");
|
el.setAttribute("data-current", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore word-level state on active lines so the tick loop doesn't flash
|
// Restore word state so the tick loop doesn't flash
|
||||||
const nowMs = getPlaybackMs();
|
const nowMs = getPlaybackMs();
|
||||||
activeWordEls.clear();
|
activeWordEls.clear();
|
||||||
activeBgWordEls.clear();
|
activeBgWordEls.clear();
|
||||||
@@ -3247,7 +3394,7 @@ const applyActiveLineStateNoTransition = (): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force reflow so the suppressed transitions take effect, then restore them
|
// Force reflow, then restore transitions
|
||||||
void document.body.offsetHeight;
|
void document.body.offsetHeight;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
line.el.style.removeProperty("transition");
|
line.el.style.removeProperty("transition");
|
||||||
@@ -3468,6 +3615,7 @@ const teardown = (): void => {
|
|||||||
lyricsData = null;
|
lyricsData = null;
|
||||||
lyricsResponse = null;
|
lyricsResponse = null;
|
||||||
lyricsIsAiGenerated = false;
|
lyricsIsAiGenerated = false;
|
||||||
|
syncAiIndicator();
|
||||||
lines = [];
|
lines = [];
|
||||||
activeWordEls.clear();
|
activeWordEls.clear();
|
||||||
activeBgWordEls.clear();
|
activeBgWordEls.clear();
|
||||||
@@ -3479,8 +3627,7 @@ const teardown = (): void => {
|
|||||||
applyScrollOwnerState();
|
applyScrollOwnerState();
|
||||||
};
|
};
|
||||||
|
|
||||||
// find scrollable parent
|
// find scrollable parent (don't shift the play queue)
|
||||||
// to avoid scrolling a shared ancestor that would shift the play queue
|
|
||||||
const findScroller = (el: HTMLElement): HTMLElement => {
|
const findScroller = (el: HTMLElement): HTMLElement => {
|
||||||
const lyricsPanel = el.closest(
|
const lyricsPanel = el.closest(
|
||||||
'[data-test="now-playing-lyrics"]',
|
'[data-test="now-playing-lyrics"]',
|
||||||
@@ -4009,6 +4156,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
if (token !== trackChangeToken) return;
|
if (token !== trackChangeToken) return;
|
||||||
if (!trackInfo) {
|
if (!trackInfo) {
|
||||||
trace.log("could not get track info from playback state");
|
trace.log("could not get track info from playback state");
|
||||||
|
setFlushSpinner(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nativeHasLyrics = trackHasNativeLyrics(trackInfo.trackId);
|
const nativeHasLyrics = trackHasNativeLyrics(trackInfo.trackId);
|
||||||
@@ -4074,6 +4222,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
// MARKER: Radiant AI Syllables [RL API]
|
// MARKER: Radiant AI Syllables [RL API]
|
||||||
if (response.type === "Word" && response._synthesized) {
|
if (response.type === "Word" && response._synthesized) {
|
||||||
lyricsIsAiGenerated = true;
|
lyricsIsAiGenerated = true;
|
||||||
|
syncAiIndicator();
|
||||||
sylLog(
|
sylLog(
|
||||||
`[RL-Syllable] AI generated syllable timings for ${response.data.length} lines`,
|
`[RL-Syllable] AI generated syllable timings for ${response.data.length} lines`,
|
||||||
);
|
);
|
||||||
@@ -4303,6 +4452,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Radiant Lyrics — text glow only (injected when Lyrics Glow is enabled) */
|
/* Text glow only (injected when Lyrics Glow is on) */
|
||||||
|
|
||||||
/* MARKER: Lyrics glow CSS */
|
/* MARKER: Lyrics glow CSS */
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,48 @@
|
|||||||
/* MARKER: Lyrics Menu CSS */
|
/* MARKER: Lyrics Menu CSS */
|
||||||
|
|
||||||
/* Lyrics toggle button */
|
/* Lyrics toggle button */
|
||||||
|
|
||||||
|
/* Flush lyrics button — loading spinner while waiting for lyrics */
|
||||||
|
.flush-lyrics-button svg.rl-spinner {
|
||||||
|
animation: rl-spinner-rotate 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rl-spinner-rotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flush button tooltip (native title is unreliable) */
|
||||||
|
.flush-lyrics-button {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-lyrics-button::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flush-lyrics-button:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
[data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) {
|
[data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
padding-right: 38px !important;
|
padding-right: 38px !important;
|
||||||
@@ -98,6 +140,99 @@
|
|||||||
color: rgba(0, 0, 0, 0.5);
|
color: rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI sparkle: rainbow gradient + glow */
|
||||||
|
body.rl-ai-lyrics .rl-lyrics-menu-trigger {
|
||||||
|
--rl-ai-c1: #ff6b9d;
|
||||||
|
--rl-ai-c2: #ffd166;
|
||||||
|
--rl-ai-c3: #5ee7ff;
|
||||||
|
--rl-ai-c4: #a78bfa;
|
||||||
|
--rl-ai-glow: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics [data-test="toggle-lyrics"][aria-pressed="true"] .rl-lyrics-menu-trigger {
|
||||||
|
--rl-ai-c1: #e8336f;
|
||||||
|
--rl-ai-c2: #f08c00;
|
||||||
|
--rl-ai-c3: #0a9bd0;
|
||||||
|
--rl-ai-c4: #7048e8;
|
||||||
|
--rl-ai-glow: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-lyrics-menu-trigger svg path {
|
||||||
|
fill: url(#rl-ai-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-lyrics-menu-trigger svg {
|
||||||
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c4) var(--rl-ai-glow), transparent));
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
animation: rl-ai-glow 6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics [data-test="toggle-lyrics"][aria-pressed="true"] .rl-lyrics-menu-trigger:hover svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop {
|
||||||
|
animation: rl-ai-flow 6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop:nth-child(1) {
|
||||||
|
stop-color: var(--rl-ai-c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop:nth-child(2) {
|
||||||
|
stop-color: var(--rl-ai-c2);
|
||||||
|
animation-delay: -1.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop:nth-child(3) {
|
||||||
|
stop-color: var(--rl-ai-c3);
|
||||||
|
animation-delay: -3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop:nth-child(4) {
|
||||||
|
stop-color: var(--rl-ai-c4);
|
||||||
|
animation-delay: -4.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rl-ai-flow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
stop-color: var(--rl-ai-c1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
stop-color: var(--rl-ai-c2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stop-color: var(--rl-ai-c3);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
stop-color: var(--rl-ai-c4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rl-ai-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c4) var(--rl-ai-glow), transparent));
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c1) var(--rl-ai-glow), transparent));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c2) var(--rl-ai-glow), transparent));
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--rl-ai-c3) var(--rl-ai-glow), transparent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
body.rl-ai-lyrics .rl-lyrics-menu-trigger svg,
|
||||||
|
body.rl-ai-lyrics .rl-ai-stop {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Animate widening when dropdown opens */
|
/* Animate widening when dropdown opens */
|
||||||
[data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) {
|
[data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) {
|
||||||
transition: min-width 0.12s ease-out;
|
transition: min-width 0.12s ease-out;
|
||||||
@@ -247,14 +382,14 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
|
|||||||
/* MARKER: Integrated Seek Bar */
|
/* MARKER: Integrated Seek Bar */
|
||||||
/* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */
|
/* Moves the seekbar to the top border of the player bar (inspired by Amethyst) */
|
||||||
|
|
||||||
/* Scrubber row stays in flow — centers the time block as one unit */
|
/* Keep scrubber row in flow, centered */
|
||||||
body.rl-integrated-seekbar .rl-seekbar-container {
|
body.rl-integrated-seekbar .rl-seekbar-container {
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
gap: 0 !important;
|
gap: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Single string: "current | duration" — synced from native <time> nodes */
|
/* Combined "current | duration" time */
|
||||||
body.rl-integrated-seekbar .rl-seekbar-combined-time {
|
body.rl-integrated-seekbar .rl-seekbar-combined-time {
|
||||||
opacity: 0.5 !important;
|
opacity: 0.5 !important;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
@@ -263,7 +398,7 @@ body.rl-integrated-seekbar .rl-seekbar-combined-time {
|
|||||||
font-variant-numeric: tabular-nums !important;
|
font-variant-numeric: tabular-nums !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Tidal's two time <p> cells (class + structural — survives React re-renders) */
|
/* Hide Tidal's native time cells */
|
||||||
body.rl-integrated-seekbar .rl-seekbar-native-time {
|
body.rl-integrated-seekbar .rl-seekbar-native-time {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -273,7 +408,7 @@ body.rl-integrated-seekbar [data-test="footer-player"] .rl-seekbar-container > p
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrubber bar — top strip: same width as footer player, top corners = Floating Bar Corner Radius */
|
/* Scrubber strip along the player bar top */
|
||||||
body.rl-integrated-seekbar .rl-seekbar-bar {
|
body.rl-integrated-seekbar .rl-seekbar-bar {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
@@ -306,7 +441,7 @@ body.rl-integrated-seekbar .rl-seekbar-bar:hover [class*="knob"] {
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track elements only: top corners follow player bar radius, bottom square */
|
/* Track: rounded top, square bottom */
|
||||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"],
|
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"],
|
||||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_wrapper"],
|
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_wrapper"],
|
||||||
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_range"],
|
body.rl-integrated-seekbar .rl-seekbar-bar [data-test="progress-bar"] [class*="_range"],
|
||||||
@@ -494,7 +629,7 @@ body.rl-integrated-seekbar [data-test="footer-player"] [class*="playerContent"]
|
|||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right singer: mirror shift + pre-wrap (inactive pl = shift, active pr = shift) */
|
/* Right singer: mirrored shift + pre-wrap */
|
||||||
.rl-wbw-line.rl-singer-right:not(.rl-wbw-line-active) {
|
.rl-wbw-line.rl-singer-right:not(.rl-wbw-line-active) {
|
||||||
padding-left: var(--rl-line-shift, 20px);
|
padding-left: var(--rl-line-shift, 20px);
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
@@ -658,9 +793,7 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
|
|||||||
filter: none !important;
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI-generated timing: soften word-mode color flips so a boundary that's off
|
/* AI timing: softer word color flips SUPER WIP */
|
||||||
by a bit has no crisp edge to expose it. Per-span --rl-wipe-feather is set
|
|
||||||
inline from the AI model's confidence. SUPER WIP */
|
|
||||||
.rl-wbw-container.rl-ai .rl-wbw-word {
|
.rl-wbw-container.rl-ai .rl-wbw-word {
|
||||||
transition:
|
transition:
|
||||||
text-shadow 0.25s ease-out,
|
text-shadow 0.25s ease-out,
|
||||||
@@ -765,7 +898,7 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
|
|||||||
transform-origin: right;
|
transform-origin: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Duet: 20% column inset + same shift / pre-wrap as single (inactive reserves along outer edge) */
|
/* Duet: 20% inset, same shift as single */
|
||||||
.rl-dual-side .rl-wbw-line.rl-singer-left:not(.rl-wbw-line-active) {
|
.rl-dual-side .rl-wbw-line.rl-singer-left:not(.rl-wbw-line-active) {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: calc(20% + var(--rl-line-shift, 20px));
|
padding-right: calc(20% + var(--rl-line-shift, 20px));
|
||||||
@@ -837,6 +970,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
+8
@@ -52,6 +52,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 +465,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 +1074,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