AI Indicator <3

This commit is contained in:
meoware.exe committed 2026-10-11 21:59:04 +11:00
1 parent 0f6c70017c
commit edb5c06315
11 files changed
+196 -151

No files matched your search

+3 -6
View File
@@ -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);
+1 -1
View File
@@ -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);
+5 -7
View File
@@ -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,
); );
+1 -1
View File
@@ -210,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;
+9 -27
View File
@@ -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[];
} }
@@ -209,24 +209,15 @@ export interface AnimatedArtwork {
export interface ArtworkLookup { export interface ArtworkLookup {
artwork: AnimatedArtwork | null; artwork: AnimatedArtwork | null;
/** // Album key if album matched, else title key
* Scope the result is valid for, as a dedupe key: the album key only when
* the answer was matched by album (by name or via the track's ISRC, which
* the server resolves to the same album). A title-only fallback (or a
* miss) is specific to this track, so its key scopes to the title — the
* album's other tracks must not inherit it.
*/
key: string; key: string;
} }
/** Title-scoped artwork key: a result only valid for this exact track. */ // Key for this exact track only
export const artworkTitleKey = (artist: string, title: string): string => export const artworkTitleKey = (artist: string, title: string): string =>
`${artist}\u0000\u0000${title}`; `${artist}\u0000\u0000${title}`;
/** // ISRC lookup, immune to name mismatches
* ISRC lookup: the server resolves the exact song by ISRC and answers
* from its album, immune to title/album name mismatches.
*/
const artworkIsrcUrl = (isrc: string): string => const artworkIsrcUrl = (isrc: string): string =>
`https://ama.trainswift.net/api/v1/artwork/search?isrc=${encodeURIComponent(isrc)}`; `https://ama.trainswift.net/api/v1/artwork/search?isrc=${encodeURIComponent(isrc)}`;
@@ -241,10 +232,7 @@ function artworkSearchUrl(
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`; return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
} }
// Cached including misses; most tracks have none and the answer never changes. // Cache hits and misses, album key only for album matches
// Only album-matched results (by name or ISRC) go under the album key — a
// title-only fallback must not stand in for the album's other tracks (see
// ArtworkLookup).
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>(); const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
const ARTWORK_CACHE_MAX = 200; const ARTWORK_CACHE_MAX = 200;
@@ -258,8 +246,7 @@ export async function fetchAnimatedArtwork(
const albumName = album?.trim() || ""; const albumName = album?.trim() || "";
const albumPresent = albumName !== ""; const albumPresent = albumName !== "";
const isrcTrimmed = isrc?.trim() || ""; const isrcTrimmed = isrc?.trim() || "";
// Album key, matching artworkKey() in index.ts; without an album the // Album key, or title key without an album
// answer is only ever matched by title, so key by title directly.
const cacheKey = albumPresent const cacheKey = albumPresent
? `${artist}\u0000${albumName}` ? `${artist}\u0000${albumName}`
: artworkTitleKey(artist, title); : artworkTitleKey(artist, title);
@@ -312,10 +299,7 @@ export async function fetchAnimatedArtwork(
}; };
try { try {
// ISRC first: the exact match. A hit is resolved through the track's // ISRC first, misses fall through uncached
// album, so it is album-scoped (cacheKey); a miss says nothing about
// the album (the ISRC may simply be unknown to the source), so fall
// through to the name flow without caching it.
if (isrcTrimmed) { if (isrcTrimmed) {
const byIsrc = await tryFetch(artworkIsrcUrl(isrcTrimmed)); const byIsrc = await tryFetch(artworkIsrcUrl(isrcTrimmed));
if (byIsrc) return { artwork: remember(byIsrc), key: cacheKey }; if (byIsrc) return { artwork: remember(byIsrc), key: cacheKey };
@@ -327,9 +311,7 @@ export async function fetchAnimatedArtwork(
// Album-matched: cacheable, and its scope is the whole album. // Album-matched: cacheable, and its scope is the whole album.
if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey }; if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey };
if (albumPresent) { if (albumPresent) {
// Album lookup missed: the title-only answer — hit or miss — is // Title-only result, scope it to this track
// matched by title, so never cache it under the album key (that
// would poison the album's other tracks) and scope it to the title.
const artwork = await tryFetch(artworkSearchUrl(title, artist)); const artwork = await tryFetch(artworkSearchUrl(title, artist));
return { artwork, key: artworkTitleKey(artist, title) }; return { artwork, key: artworkTitleKey(artist, title) };
} }
+30 -52
View File
@@ -1,6 +1,5 @@
// MARKER: Animated Artwork (live artwork in the lyrics view) // MARKER: Animated Artwork (live artwork in the lyrics view)
// AnimatedArtworkLayer resolves the art for the playing track (bound to its // Layer finds the art, surfaces draw it on covers.
// 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";
@@ -17,27 +16,23 @@ 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). */ // Player bar thumbnail
export const MINI_ART_SELECTOR = export const MINI_ART_SELECTOR =
'[data-test="footer-player"] [data-test="current-media-imagery"] img'; '[data-test="footer-player"] [data-test="current-media-imagery"] img';
/** Elements that cannot render child nodes, so the video must go beside them. */ // Can't hold children, so the video goes 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. // Skip the 2160px rung (91MB a loop)
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. */ // Same as the CSS fade
const ART_FADE_MS = 300; const ART_FADE_MS = 300;
/** // One video per cover, moved (not rebuilt) on remount
* 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 { 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 for replaced elements
private mount: HTMLElement | null = null; private mount: HTMLElement | null = null;
/** 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;
@@ -45,7 +40,7 @@ class ArtworkSurface {
private resizeRaf = 0; private resizeRaf = 0;
private artwork: AnimatedArtwork | null = null; private artwork: AnimatedArtwork | null = null;
private liveSrc: string | null = null; private liveSrc: string | null = null;
/** A stream that errored; not retried until the artwork changes. */ // Errored stream, skipped until the art changes
private failedSrc: string | null = null; private failedSrc: string | null = null;
private fadeTimer = 0; private fadeTimer = 0;
private visible = true; private visible = true;
@@ -53,11 +48,11 @@ class ArtworkSurface {
constructor( constructor(
private readonly selector: string, private readonly selector: string,
/** Use the portrait stream when the cover is taller than wide. */ // Use the tall stream on portrait covers
private readonly allowTall: boolean, private readonly allowTall: boolean,
) {} ) {}
/** Show `artwork`, or fade the current video out when null. */ // Show art, or fade out on null
show(artwork: AnimatedArtwork | null): void { show(artwork: AnimatedArtwork | null): void {
if (artwork?.url !== this.artwork?.url) this.failedSrc = null; if (artwork?.url !== this.artwork?.url) this.failedSrc = null;
this.artwork = artwork; this.artwork = artwork;
@@ -65,10 +60,7 @@ class ArtworkSurface {
else this.fadeOut(); else this.fadeOut();
} }
/** // Attach to the cover on screen (moves, never rebuilds)
* 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 { bind(): void {
if (!this.artwork || !settings.animatedArtwork) return; if (!this.artwork || !settings.animatedArtwork) return;
const tile = document.querySelector<HTMLElement>(this.selector); const tile = document.querySelector<HTMLElement>(this.selector);
@@ -81,7 +73,7 @@ class ArtworkSurface {
this.build(src); this.build(src);
return; return;
} }
// Same stream came back mid fade-out: keep it rather than rebuild. // Came back mid fade, keep it
if (this.fadeTimer !== 0) { if (this.fadeTimer !== 0) {
clearTimeout(this.fadeTimer); clearTimeout(this.fadeTimer);
this.fadeTimer = 0; this.fadeTimer = 0;
@@ -89,21 +81,21 @@ class ArtworkSurface {
this.video.classList.add("rl-animated-art-visible"); this.video.classList.add("rl-animated-art-visible");
} }
} }
// Appending moves the node; its MediaSource and buffer come along. // Moving keeps the buffered stream
if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video); if (this.video.parentNode !== this.mount) this.mount.appendChild(this.video);
this.syncGeometry(); this.syncGeometry();
this.ensurePlaying(); this.ensurePlaying();
} }
setVisible(visible: boolean): void { setVisible(visible: boolean): void {
// Called from the 200ms tick, so bail unless it actually flipped. // 200ms tick, bail unless it flipped
if (visible === this.visible) return; if (visible === this.visible) return;
this.visible = visible; this.visible = visible;
// Must resume explicitly; nothing else restarts a paused video. // Must resume explicitly; nothing else restarts a paused video.
this.ensurePlaying(); this.ensurePlaying();
} }
/** On-screen check for covers with no panel of their own to test. */ // Is the cover on screen
tileVisible(): boolean { tileVisible(): boolean {
const tile = this.tile; const tile = this.tile;
if (!tile?.isConnected) return false; if (!tile?.isConnected) return false;
@@ -158,7 +150,7 @@ class ArtworkSurface {
video.crossOrigin = "anonymous"; video.crossOrigin = "anonymous";
video.tabIndex = -1; video.tabIndex = -1;
video.setAttribute("aria-hidden", "true"); video.setAttribute("aria-hidden", "true");
// Fade in on the first decoded frame, so the static cover shows until then. // Fade in once the first frame is ready
video.addEventListener( video.addEventListener(
"loadeddata", "loadeddata",
() => video.classList.add("rl-animated-art-visible"), () => video.classList.add("rl-animated-art-visible"),
@@ -178,8 +170,7 @@ class ArtworkSurface {
this.video = video; this.video = video;
this.syncGeometry(); this.syncGeometry();
// hls.js over MSE only: Chromium claims native HLS support but fails to // hls.js only, native HLS is broken in Chromium
// demux the playlist, so there is no native fallback.
if (typeof Hls === "function" && Hls.isSupported()) { if (typeof Hls === "function" && Hls.isSupported()) {
const hls = new Hls({ const hls = new Hls({
autoStartLoad: true, autoStartLoad: true,
@@ -212,7 +203,7 @@ class ArtworkSurface {
this.ensurePlaying(); this.ensurePlaying();
} }
// CPU tracks presented fps and nothing else, so this is the only perf knob. // FPS is the only CPU knob
private applyRate(): void { private applyRate(): void {
if (!this.video) return; if (!this.video) return;
const target = Number(settings.animatedArtworkFps) || ART_NATIVE_FPS; const target = Number(settings.animatedArtworkFps) || ART_NATIVE_FPS;
@@ -220,7 +211,7 @@ class ArtworkSurface {
if (this.video.playbackRate !== rate) this.video.playbackRate = rate; if (this.video.playbackRate !== rate) this.video.playbackRate = rate;
} }
/** Fade the video out, then drop it, unless bind() claims it back first. */ // Fade out, then drop the video
private fadeOut(): void { private fadeOut(): void {
const video = this.video; const video = this.video;
if (!video || this.fadeTimer !== 0) return; if (!video || this.fadeTimer !== 0) return;
@@ -261,8 +252,7 @@ class ArtworkSurface {
private host(tile: HTMLElement): void { private host(tile: HTMLElement): void {
this.tile = tile; this.tile = tile;
// Covers are <img>s; a replaced element silently drops children, so // Covers are <img>s, so host the video in the parent
// 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;
if (mount !== this.mount) this.releaseMount(); if (mount !== this.mount) this.releaseMount();
@@ -280,7 +270,7 @@ class ArtworkSurface {
this.mount = null; this.mount = null;
} }
/** Lay the video over the tile when it is not a direct child of it. */ // Lay the video over the tile
private syncGeometry(): void { private syncGeometry(): void {
const { video, tile, mount } = this; const { video, tile, mount } = this;
if (!video || !tile || !mount) return; if (!video || !tile || !mount) return;
@@ -321,31 +311,20 @@ export interface ArtworkTrack {
isrc?: string; isrc?: string;
} }
/** // Finds art for the playing track, feeds every cover
* 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 { export class AnimatedArtworkLayer {
private readonly nowPlaying = new ArtworkSurface(ART_TILE_SELECTOR, true); private readonly nowPlaying = new ArtworkSurface(ART_TILE_SELECTOR, true);
private readonly mini = new ArtworkSurface(MINI_ART_SELECTOR, false); private readonly mini = new ArtworkSurface(MINI_ART_SELECTOR, false);
private readonly surfaces = [this.nowPlaying, this.mini]; private readonly surfaces = [this.nowPlaying, this.mini];
private trackId: string | null = null; private trackId: string | null = null;
/** // Album key if album matched, else title key
* 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 artworkKey: string | null = null;
private loadToken = 0; private loadToken = 0;
private fetchAbort: AbortController | null = null; private fetchAbort: AbortController | null = null;
/** // Called on track change
* Called from the shared track-change listener. `albumKey` is the
* album-scoped request key for this track (artworkKey() in index.ts).
*/
async setTrack(track: ArtworkTrack, albumKey: string): Promise<void> { async setTrack(track: ArtworkTrack, albumKey: string): Promise<void> {
// Same track again (initial fire + transition, repeat-one): keep playing. // Same track, keep playing
if (track.trackId === this.trackId) { if (track.trackId === this.trackId) {
this.rebind(); this.rebind();
return; return;
@@ -355,7 +334,7 @@ export class AnimatedArtworkLayer {
this.fetchAbort?.abort(); this.fetchAbort?.abort();
this.fetchAbort = null; this.fetchAbort = null;
// Album-matched art covers the album's other tracks: nothing to fetch. // Same album art, nothing to fetch
if ( if (
this.artworkKey !== null && this.artworkKey !== null &&
(albumKey === this.artworkKey || (albumKey === this.artworkKey ||
@@ -365,8 +344,7 @@ export class AnimatedArtworkLayer {
return; return;
} }
// Different art: drop the old track's video now rather than letting it // New art, drop the old video now
// play over the new cover for the length of the lookup.
this.apply(null, null); this.apply(null, null);
const abort = new AbortController(); const abort = new AbortController();
@@ -386,19 +364,19 @@ export class AnimatedArtworkLayer {
this.apply(lookup.artwork, lookup.key); this.apply(lookup.artwork, lookup.key);
} }
/** Re-bind to the covers after TIDAL (re)mounted one. */ // Rebind after TIDAL remounts a cover
rebind(): void { rebind(): void {
for (const surface of this.surfaces) surface.bind(); for (const surface of this.surfaces) surface.bind();
} }
/** Called from the 200ms tick. */ // Called from the 200ms tick
syncVisibility(nowPlayingVisible: boolean): void { syncVisibility(nowPlayingVisible: boolean): void {
const pageVisible = !document.hidden; const pageVisible = !document.hidden;
this.nowPlaying.setVisible(pageVisible && nowPlayingVisible); this.nowPlaying.setVisible(pageVisible && nowPlayingVisible);
this.mini.setVisible(pageVisible && this.mini.tileVisible()); this.mini.setVisible(pageVisible && this.mini.tileVisible());
} }
/** Settings changed (fps). */ // FPS setting changed
refresh(): void { refresh(): void {
for (const surface of this.surfaces) surface.refresh(); for (const surface of this.surfaces) surface.refresh();
} }
+1 -2
View File
@@ -1,5 +1,4 @@
// hls.js ships types for its main entry only. The light build is the same public // hls.js only types its main entry, reuse them here
// API with alt-audio, subtitles and EME stripped, so point it at those types.
declare module "hls.js/light" { declare module "hls.js/light" {
export * from "hls.js"; export * from "hls.js";
export { default } from "hls.js"; export { default } from "hls.js";
+42 -42
View File
@@ -124,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"]',
@@ -200,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();
@@ -543,7 +543,7 @@ const toggleRadiantLyrics = function (): void {
let flushLocked = false; let flushLocked = false;
let flushSpinning = false; let flushSpinning = false;
// Swap the flush button icon between the lyrics-sync glyph and a loading spinner // Swap flush icon and loading spinner
const setFlushSpinner = (on: boolean): void => { const setFlushSpinner = (on: boolean): void => {
flushSpinning = on; flushSpinning = on;
const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement | null; const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement | null;
@@ -697,7 +697,7 @@ const flushBtn = function (): void {
svgEl.appendChild(useEl); svgEl.appendChild(useEl);
spanWrapper.appendChild(svgEl); spanWrapper.appendChild(svgEl);
flushButton.appendChild(spanWrapper); flushButton.appendChild(spanWrapper);
// Button recreated while lyrics are still loading — show the spinner again // Recreated while loading, show the spinner again
if (flushSpinning) { if (flushSpinning) {
flushButton.setAttribute("data-tip", "Loading lyrics…"); flushButton.setAttribute("data-tip", "Loading lyrics…");
flushButton.setAttribute("aria-label", "Loading lyrics…"); flushButton.setAttribute("aria-label", "Loading lyrics…");
@@ -838,7 +838,7 @@ const syncBackdropActivity = (): void => {
now, now,
); );
// Pause artwork videos whose cover is off screen. Guarded: this ticks 5x/second. // Pause art when off screen (ticks 5x/sec)
if (animatedArtworkLayer) { if (animatedArtworkLayer) {
if (!cachedNowPlayingPanel?.isConnected) { if (!cachedNowPlayingPanel?.isConnected) {
cachedNowPlayingPanel = document.querySelector( cachedNowPlayingPanel = document.querySelector(
@@ -1111,18 +1111,13 @@ let lastArtworkTile: Element | null = null;
let lastMiniArtworkTile: Element | null = null; let lastMiniArtworkTile: Element | null = null;
let artworkUpdateSeq = 0; let artworkUpdateSeq = 0;
/** // Album key, or title key if no album
* Album-scoped request key for an animated-artwork lookup (skips within an
* album are free when the art was album-matched). Without an album it falls
* back to the title key, matching artworkTitleKey() in api.ts.
*/
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 playing track. Only the shared track-change // Load art for the playing track
// listener (and the setting) drive lookups; cover remounts just rebind.
const updateAnimatedArtwork = async (): Promise<void> => { const updateAnimatedArtwork = async (): Promise<void> => {
const seq = ++artworkUpdateSeq; const seq = ++artworkUpdateSeq;
if (!settings.animatedArtwork) { if (!settings.animatedArtwork) {
@@ -1134,7 +1129,7 @@ const updateAnimatedArtwork = async (): Promise<void> => {
} }
animatedArtworkLayer ??= new AnimatedArtworkLayer(); animatedArtworkLayer ??= new AnimatedArtworkLayer();
const target = await getTrackInfo(); const target = await getTrackInfo();
// A newer track change won the race, or the setting flipped meanwhile. // Newer track change won, bail
if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return; if (seq !== artworkUpdateSeq || !target || !animatedArtworkLayer) return;
await animatedArtworkLayer.setTrack(target, artworkKey(target)); await animatedArtworkLayer.setTrack(target, artworkKey(target));
}; };
@@ -1238,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(
@@ -1249,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[]) => {
@@ -1449,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 [
@@ -1487,6 +1498,7 @@ const createLyricsMenu = (): void => {
); );
lyricsToggle.appendChild(trigger); lyricsToggle.appendChild(trigger);
syncAiIndicator();
if (lyricsMenuOpen) { if (lyricsMenuOpen) {
positionDropdown(); positionDropdown();
@@ -1520,11 +1532,9 @@ function setupLyricsMenuObserver(): void {
} }
}); });
// Covers remount as views open and close: move the live artwork video onto // Covers remount, move the video over (no refetch)
// 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 check, ignores our own video
if (tile === lastArtworkTile) return; if (tile === lastArtworkTile) return;
lastArtworkTile = tile; lastArtworkTile = tile;
animatedArtworkLayer?.rebind(); animatedArtworkLayer?.rebind();
@@ -1617,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) {
@@ -1628,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_"));
}; };
@@ -2245,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;
@@ -2675,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 }>,
@@ -2898,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`);
@@ -2915,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++) {
@@ -2930,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) : [];
@@ -3288,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;
@@ -3318,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();
@@ -3395,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");
@@ -3616,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();
@@ -3627,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"]',
@@ -4223,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`,
); );
@@ -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 */
+102 -11
View File
@@ -61,7 +61,7 @@
} }
} }
/* Flush lyrics button — hover tooltip (native title doesn't show reliably) */ /* Flush button tooltip (native title is unreliable) */
.flush-lyrics-button { .flush-lyrics-button {
position: relative !important; position: relative !important;
} }
@@ -140,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;
@@ -289,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;
@@ -305,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;
} }
@@ -315,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;
@@ -348,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"],
@@ -536,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;
@@ -700,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,
@@ -807,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));