Fixes for Lunar <3

This commit is contained in:
2026-08-15 23:36:14 +10:00
parent c86d18a75c
commit 7f06969bef
3 changed files with 158 additions and 20 deletions
@@ -31,6 +31,8 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
aiSyllables: false, aiSyllables: false,
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
syllableLogging: false, 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, hideUIEnabled: true,
playerBarVisible: false, playerBarVisible: false,
qualityProgressColor: true, qualityProgressColor: true,
+32 -1
View File
@@ -67,6 +67,25 @@ const measureLuminance = (source: CanvasImageSource): number | null => {
} }
}; };
// Load a cover as an <img>. Works on for Lunar (Fetch didn't work..)
const loadCoverElement = async (src: string): Promise<HTMLImageElement> => {
const attempt = (anonymous: boolean) =>
new Promise<HTMLImageElement>((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 { export class KawarpLayer {
private host: HTMLElement; private host: HTMLElement;
private canvas: HTMLCanvasElement; private canvas: HTMLCanvasElement;
@@ -232,9 +251,20 @@ export class KawarpLayer {
} }
this.updateBrightness(); this.updateBrightness();
} catch { } catch {
// Fallback for Lunar
try { try {
if (token !== this.loadToken || this.kawarp !== engine) return; if (token !== this.loadToken || this.kawarp !== engine) return;
// Fallback for data:/blob: sources & i guess CORS 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 {
try {
if (token !== this.loadToken || this.kawarp !== engine) return;
// Last resort (Darkening is unavailable)
this.coverLuminance = null; this.coverLuminance = null;
await engine.loadImage(src); await engine.loadImage(src);
this.updateBrightness(); this.updateBrightness();
@@ -244,6 +274,7 @@ export class KawarpLayer {
} }
} }
} }
}
/** Contrast & auto-darken use canvas itself so for once no need for a million elements <3 */ /** Contrast & auto-darken use canvas itself so for once no need for a million elements <3 */
private applyFilter(): void { private applyFilter(): void {
+120 -15
View File
@@ -1137,6 +1137,18 @@ const sylTrace = (...args: unknown[]) => {
`[Radiant Lyrics] Syllable logging ${value ? "enabled" : "disabled"}`, `[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) // MARKER: Syllable animations (WIP coming soon)
get style() { get style() {
return settings.syllableStyle; return settings.syllableStyle;
@@ -1480,7 +1492,8 @@ const getReduxState = (preferOriginal = false): any => {
if (preferOriginal && originalReduxGetState) { if (preferOriginal && originalReduxGetState) {
return 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 => const getNativeTrackEntity = (trackId: string): any | null =>
@@ -1615,21 +1628,30 @@ const overlaySyntheticNativeLyricsState = (state: any): any => {
return cachedOvlState ?? state; return cachedOvlState ?? state;
}; };
const installNativeLyricsOverlay = (): void => { const installNativeLyricsOverlay = (): boolean => {
if (nativeLyricsOverlayInstalled) return; if (nativeLyricsOverlayInstalled) return true;
const original = redux.store.getState.bind(redux.store); // 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; originalReduxGetState = original;
(redux.store as any).getState = () => overlaySyntheticNativeLyricsState(original());
nativeLyricsOverlayInstalled = true; nativeLyricsOverlayInstalled = true;
unloads.add(() => { unloads.add(() => {
if (originalReduxGetState) { if (originalReduxGetState) {
(redux.store as any).getState = originalReduxGetState; (reduxStore as any).getState = originalReduxGetState;
} }
nativeLyricsOverlayInstalled = false; nativeLyricsOverlayInstalled = false;
originalReduxGetState = null; originalReduxGetState = null;
syntheticNativeLyrics = null; syntheticNativeLyrics = null;
invalidateOverlayCache(); invalidateOverlayCache();
}); });
return true;
}; };
const setNowPlayingActiveView = (view: string): boolean => { const setNowPlayingActiveView = (view: string): boolean => {
@@ -1680,7 +1702,7 @@ const registerSyntheticNativeLyrics = (
trackInfo: TrackInfo, trackInfo: TrackInfo,
response: LyricsApiResponse, response: LyricsApiResponse,
): boolean => { ): boolean => {
installNativeLyricsOverlay(); if (!installNativeLyricsOverlay()) return false;
const track = getNativeTrackEntity(trackInfo.trackId); const track = getNativeTrackEntity(trackInfo.trackId);
if (!track) return false; if (!track) return false;
@@ -2065,7 +2087,22 @@ const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => {
return { trackId: productId, title, artist, isrc }; 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 lastPlayerTime = 0;
let lastPlayerTimeAt = 0; let lastPlayerTimeAt = 0;
let wasPlaying = false; let wasPlaying = false;
@@ -2105,12 +2142,12 @@ const getRemotePlaybackMs = (state = getReduxState()): number => {
return playerTime * 1000; return playerTime * 1000;
}; };
const getPlaybackMs = (): number => { const getLegacyPlaybackMs = (): number => {
const state = getReduxState(); const state = getReduxState();
if (isRemotePlayback(state)) { if (isRemotePlayback(state)) {
return getRemotePlaybackMs(state); return getRemotePlaybackMs(state);
} }
const playerTime = PlayState.currentTime; const playerTime = playerCurrentTime();
const playing = PlayState.playing; const playing = PlayState.playing;
const now = performance.now(); const now = performance.now();
@@ -2136,9 +2173,75 @@ const getPlaybackMs = (): number => {
return playerTime * 1000; 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). */ /** Re Sync Lyrics to getPlaybackMs (PlayState/Redux on Connect). */
const snapPlaybackInterpolationToPlayer = (): void => { const snapPlaybackInterpolationToPlayer = (): void => {
const state = getReduxState(); const state = getReduxState();
if (IS_NATIVE_CLIENT) {
clockAnchorMs = nativeRawPlaybackMs(state);
clockAnchorAt = performance.now();
clockLastOut = clockAnchorMs;
clockWasPlaying = nativeIsPlaying(state);
return;
}
if (isRemotePlayback(state)) { if (isRemotePlayback(state)) {
const pc = state?.playbackControls; const pc = state?.playbackControls;
const raw = Number(pc?.latestCurrentTime); const raw = Number(pc?.latestCurrentTime);
@@ -2147,7 +2250,7 @@ const snapPlaybackInterpolationToPlayer = (): void => {
wasRemotePlaying = reduxPlaybackIsPlaying(state); wasRemotePlaying = reduxPlaybackIsPlaying(state);
return; return;
} }
lastPlayerTime = PlayState.currentTime; lastPlayerTime = playerCurrentTime();
lastPlayerTimeAt = performance.now(); lastPlayerTimeAt = performance.now();
wasPlaying = PlayState.playing; wasPlaying = PlayState.playing;
}; };
@@ -3568,7 +3671,8 @@ const startTickLoop = (): void => {
sylLog("[RL-Syllable] Tick loop started"); sylLog("[RL-Syllable] Tick loop started");
let lastLogTime = 0; 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( tickLoopUnload = safeInterval(
unloads, unloads,
@@ -4003,10 +4107,11 @@ const onTrackChange = async (): Promise<void> => {
} else { } else {
safeTimeout(unloads, () => { safeTimeout(unloads, () => {
if (token !== trackChangeToken) return; if (token !== trackChangeToken) return;
const alreadyOnLyricsTab = currentTrackWantsLyricsPanel();
syncNativeLyricsAvailability(); syncNativeLyricsAvailability();
if (!nativeHasLyrics) { if (!nativeHasLyrics && !alreadyOnLyricsTab) {
// Track had no native lyrics but API found them // Track had no native lyrics but API found them
// force navigate to lyrics view so the panel mounts // force navigate to lyrics view so the panel mounts.
setNowPlayingActiveView("lyrics"); setNowPlayingActiveView("lyrics");
} }
}, 0); }, 0);