From 7f06969bef0d476033ab6ac10bc3db459830bede Mon Sep 17 00:00:00 2001 From: meowarex Date: Sat, 15 Aug 2026 23:36:14 +1000 Subject: [PATCH] Fixes for Lunar <3 --- plugins/radiant-lyrics-luna/src/Settings.tsx | 2 + plugins/radiant-lyrics-luna/src/backdrop.ts | 41 +++++- plugins/radiant-lyrics-luna/src/index.ts | 135 ++++++++++++++++--- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 7cd3b1e..88b2baf 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -31,6 +31,8 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { aiSyllables: false, syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) syllableLogging: false, + // Playback clock calibration in ms (i assume Lunar will break playback one day since MPV is wierd so i added this for that) + lyricsOffsetMs: 0, hideUIEnabled: true, playerBarVisible: false, qualityProgressColor: true, diff --git a/plugins/radiant-lyrics-luna/src/backdrop.ts b/plugins/radiant-lyrics-luna/src/backdrop.ts index 97394fd..37df1bf 100644 --- a/plugins/radiant-lyrics-luna/src/backdrop.ts +++ b/plugins/radiant-lyrics-luna/src/backdrop.ts @@ -67,6 +67,25 @@ const measureLuminance = (source: CanvasImageSource): number | null => { } }; + +// Load a cover as an . Works on for Lunar (Fetch didn't work..) + +const loadCoverElement = async (src: string): Promise => { + const attempt = (anonymous: boolean) => + new Promise((resolve, reject) => { + const img = new Image(); + if (anonymous) img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + img.src = src; + }); + try { + return await attempt(true); + } catch { + return await attempt(false); + } +}; + export class KawarpLayer { private host: HTMLElement; private canvas: HTMLCanvasElement; @@ -232,15 +251,27 @@ export class KawarpLayer { } this.updateBrightness(); } catch { + // Fallback for Lunar try { if (token !== this.loadToken || this.kawarp !== engine) return; - // Fallback for data:/blob: sources & i guess CORS - this.coverLuminance = null; - await engine.loadImage(src); + const img = await loadCoverElement(src); + if (token !== this.loadToken || this.kawarp !== engine) return; + this.coverLuminance = measureLuminance(img); + engine.loadImageElement(img); + this.transitionUntil = performance.now() + CROSSFADE_RENDER_MS; + this.ensureLoop(); this.updateBrightness(); } catch { - // Keep previous frame & later retry - if (token === this.loadToken) this.currentSrc = null; + try { + if (token !== this.loadToken || this.kawarp !== engine) return; + // Last resort (Darkening is unavailable) + this.coverLuminance = null; + await engine.loadImage(src); + this.updateBrightness(); + } catch { + // Keep previous frame & later retry + if (token === this.loadToken) this.currentSrc = null; + } } } } diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index e48971a..569f62f 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -1137,6 +1137,18 @@ const sylTrace = (...args: unknown[]) => { `[Radiant Lyrics] Syllable logging ${value ? "enabled" : "disabled"}`, ); }, + // Playback clock calibration in ms (i assume Lunar will break playback one day since MPV is wierd so i added this for that) + get offset() { + return settings.lyricsOffsetMs; + }, + set offset(value: number) { + const ms = Math.round(Number(value)); + if (!Number.isFinite(ms)) return; + settings.lyricsOffsetMs = ms; + console.log( + `[Radiant Lyrics] Lyrics offset: ${ms > 0 ? "+" : ""}${ms} ms (${ms === 0 ? "no shift" : ms > 0 ? "earlier" : "later"})`, + ); + }, // MARKER: Syllable animations (WIP coming soon) get style() { return settings.syllableStyle; @@ -1480,7 +1492,8 @@ const getReduxState = (preferOriginal = false): any => { if (preferOriginal && originalReduxGetState) { return originalReduxGetState(); } - return redux.store.getState() as any; + // Read through the real store (Lunar) + return reduxStore.getState() as any; }; const getNativeTrackEntity = (trackId: string): any | null => @@ -1615,21 +1628,30 @@ const overlaySyntheticNativeLyricsState = (state: any): any => { return cachedOvlState ?? state; }; -const installNativeLyricsOverlay = (): void => { - if (nativeLyricsOverlayInstalled) return; - const original = redux.store.getState.bind(redux.store); +const installNativeLyricsOverlay = (): boolean => { + if (nativeLyricsOverlayInstalled) return true; + // Lunar hands @luna/libs `redux.store` out as a restricted clone so Lunar needs babysitting.. + const rawGetState = reduxStore.getState; + const original = rawGetState.bind(reduxStore); + (reduxStore as any).getState = () => overlaySyntheticNativeLyricsState(original()); + if (reduxStore.getState === rawGetState) { + trace.warn( + "could not install native lyrics overlay — redux getState is not writable on this client", + ); + return false; + } originalReduxGetState = original; - (redux.store as any).getState = () => overlaySyntheticNativeLyricsState(original()); nativeLyricsOverlayInstalled = true; unloads.add(() => { if (originalReduxGetState) { - (redux.store as any).getState = originalReduxGetState; + (reduxStore as any).getState = originalReduxGetState; } nativeLyricsOverlayInstalled = false; originalReduxGetState = null; syntheticNativeLyrics = null; invalidateOverlayCache(); }); + return true; }; const setNowPlayingActiveView = (view: string): boolean => { @@ -1680,7 +1702,7 @@ const registerSyntheticNativeLyrics = ( trackInfo: TrackInfo, response: LyricsApiResponse, ): boolean => { - installNativeLyricsOverlay(); + if (!installNativeLyricsOverlay()) return false; const track = getNativeTrackEntity(trackInfo.trackId); if (!track) return false; @@ -2065,7 +2087,22 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { return { trackId: productId, title, artist, isrc }; }; -// playback time in ms (interpolated between currentTime updates) +// MARKER: Playback clock + +// Two raw position sources exist and behave very differently per client.. +// PlayState.currentTime -> activePlayer.currentTime +// playbackControls.latestCurrentTime -> redux mirror + +// Electron (official client): activePlayer.currentTime is backed by a media element +// Lunar: audio is decoded natively and activePlayer.currentTime is only refreshed roughly once a second AND isn't monotonic +// So.. pick the clock per client <3 +const IS_NATIVE_CLIENT = /TidaLunar/i.test(navigator.userAgent); + +// currentTime is present at runtime but missing from the pinned luna typings +const playerCurrentTime = (): number => + (PlayState as unknown as { currentTime: number }).currentTime; + +// Original interpolation (Electron) let lastPlayerTime = 0; let lastPlayerTimeAt = 0; let wasPlaying = false; @@ -2105,12 +2142,12 @@ const getRemotePlaybackMs = (state = getReduxState()): number => { return playerTime * 1000; }; -const getPlaybackMs = (): number => { +const getLegacyPlaybackMs = (): number => { const state = getReduxState(); if (isRemotePlayback(state)) { return getRemotePlaybackMs(state); } - const playerTime = PlayState.currentTime; + const playerTime = playerCurrentTime(); const playing = PlayState.playing; const now = performance.now(); @@ -2136,9 +2173,75 @@ const getPlaybackMs = (): number => { return playerTime * 1000; }; +// Monotonic clock (Lunar) + +// Never coast further than this past the last raw update. +const MAX_EXTRAPOLATION_MS = 1200; +// Movement larger than this is real seek or track change (not drift) +const SEEK_THRESHOLD_MS = 750; + +let clockAnchorMs = 0; // raw source position at the last anchor +let clockAnchorAt = 0; // performance.now() at the last anchor +let clockLastOut = 0; // last value handed to the tick loop +let clockWasPlaying = false; + +const nativeRawPlaybackMs = (state = getReduxState()): number => { + const latest = Number(state?.playbackControls?.latestCurrentTime); + if (Number.isFinite(latest)) return latest * 1000; + const current = Number(playerCurrentTime()); + return Number.isFinite(current) ? current * 1000 : 0; +}; + +const nativeIsPlaying = (state = getReduxState()): boolean => + isRemotePlayback(state) ? reduxPlaybackIsPlaying(state) : PlayState.playing; + +const getNativePlaybackMs = (): number => { + const state = getReduxState(); + const raw = nativeRawPlaybackMs(state); + const playing = nativeIsPlaying(state); + const now = performance.now(); + + // Re-anchor hard on play/pause and on genuine seeks. These *should* surface to the tick loop as a scrub so it resyncs.. maybe. + if ( + playing !== clockWasPlaying || + Math.abs(raw - clockLastOut) > SEEK_THRESHOLD_MS + ) { + clockWasPlaying = playing; + clockAnchorMs = raw; + clockAnchorAt = now; + clockLastOut = raw; + } else { + // Follow the raw source whenever it advances & interpolate between updates + if (raw !== clockAnchorMs) { + clockAnchorMs = raw; + clockAnchorAt = now; + } + if (!playing) { + clockLastOut = clockAnchorMs; + } else { + const out = clockAnchorMs + Math.min(now - clockAnchorAt, MAX_EXTRAPOLATION_MS); + // Stop Sub seek corrections dragging the lyrics backwards (Why Lunar lyrics flickered) + clockLastOut = Math.max(out, clockLastOut); + } + } + return clockLastOut; +}; + +const getPlaybackMs = (): number => + // Calibration for Lunar (if oneday the audio breaks timings) + (IS_NATIVE_CLIENT ? getNativePlaybackMs() : getLegacyPlaybackMs()) + + settings.lyricsOffsetMs; + /** Re Sync Lyrics to getPlaybackMs (PlayState/Redux on Connect). */ const snapPlaybackInterpolationToPlayer = (): void => { const state = getReduxState(); + if (IS_NATIVE_CLIENT) { + clockAnchorMs = nativeRawPlaybackMs(state); + clockAnchorAt = performance.now(); + clockLastOut = clockAnchorMs; + clockWasPlaying = nativeIsPlaying(state); + return; + } if (isRemotePlayback(state)) { const pc = state?.playbackControls; const raw = Number(pc?.latestCurrentTime); @@ -2147,7 +2250,7 @@ const snapPlaybackInterpolationToPlayer = (): void => { wasRemotePlaying = reduxPlaybackIsPlaying(state); return; } - lastPlayerTime = PlayState.currentTime; + lastPlayerTime = playerCurrentTime(); lastPlayerTimeAt = performance.now(); wasPlaying = PlayState.playing; }; @@ -3568,7 +3671,8 @@ const startTickLoop = (): void => { sylLog("[RL-Syllable] Tick loop started"); let lastLogTime = 0; - let lastTickMs = 0; + // -1 so the first tick can't be read as a scrub (the guard below checks >= 0) + let lastTickMs = -1; tickLoopUnload = safeInterval( unloads, @@ -4003,10 +4107,11 @@ const onTrackChange = async (): Promise => { } else { safeTimeout(unloads, () => { if (token !== trackChangeToken) return; + const alreadyOnLyricsTab = currentTrackWantsLyricsPanel(); syncNativeLyricsAvailability(); - if (!nativeHasLyrics) { - // Track had no native lyrics but API found them — - // force navigate to lyrics view so the panel mounts + if (!nativeHasLyrics && !alreadyOnLyricsTab) { + // Track had no native lyrics but API found them + // force navigate to lyrics view so the panel mounts. setNowPlayingActiveView("lyrics"); } }, 0);