mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-08-26 14:27:44 +10:00
Fixes for Lunar <3
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user