From 83ef103118a5650c612af44d46b4eae407af9529 Mon Sep 17 00:00:00 2001 From: meowarex Date: Tue, 7 Apr 2026 18:00:51 +1000 Subject: [PATCH 1/2] Tidal Connect Support <3 --- package.json | 3 - plugins/radiant-lyrics-luna/src/index.ts | 185 ++++++++++++++++++++--- pnpm-lock.yaml | 6 +- 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index e3f8965..a26c22a 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,5 @@ "rimraf": "^6.0.1", "tsx": "^4.19.4", "typescript": "^5.8.3" - }, - "dependencies": { - "pnpm": "^10.14.0" } } diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 0dd4765..1b0fc8f 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -1668,6 +1668,24 @@ const getReduxState = (preferOriginal = false): any => { return redux.store.getState() as any; }; +/** Tidal Connect / cast: Luna PlayState often does not track remote playback; Redux matches the in-app progress bar. */ +const isRemotePlayback = (state = getReduxState()): boolean => { + if (state?.activePlayer?.activePlayer === "REMOTE_PLAYBACK") return true; + return state?.playbackControls?.playbackContext?.playbackSessionId === "tidal-connect"; +}; + +const reduxPlaybackIsPlaying = (state = getReduxState()): boolean => { + const pc = state?.playbackControls; + if (!pc) return false; + if (pc.playbackState === "NOT_PLAYING") return false; + if (pc.playbackState === "PLAYING") return true; + const apt = state?.accumulatedPlaybackTime?.playbackState; + if (apt === "NOT_PLAYING") return false; + if (apt === "PLAYING") return true; + // Connect often leaves playbackState on IDLE while desiredPlaybackState is PLAYING. + return pc.desiredPlaybackState === "PLAYING"; +}; + const getNativeTrackEntity = (trackId: string): any | null => getReduxState(true)?.entities?.tracks?.entities?.[trackId] ?? null; @@ -2055,6 +2073,7 @@ let scrollAnimPending: { } | null = null; let scrollUnlockTimeout: LunaUnload | null = null; let scrollCleanupTimeout: LunaUnload | null = null; +let postTrackChangeResyncTimeout: LunaUnload | null = null; let animatingEls: HTMLElement[] = []; const clearScrollAnim = (): void => { @@ -2076,6 +2095,13 @@ const clearScrollAnim = (): void => { scrollAnimPending = null; }; +const clearPostTrackChangeResync = (): void => { + if (postTrackChangeResyncTimeout) { + postTrackChangeResyncTimeout(); + postTrackChangeResyncTimeout = null; + } +}; + const applyScrollBounce = ( scrollParent: HTMLElement, referenceIdx: number, @@ -2203,7 +2229,45 @@ let scrollAllowed = false; let lastPlayerTime = 0; let lastPlayerTimeAt = 0; let wasPlaying = false; + +// Remote playback: same interpolation idea, fed from Redux playbackControls.latestCurrentTime (seconds). +let lastRemotePlayerTime = 0; +let lastRemotePlayerTimeAt = 0; +let wasRemotePlaying = false; + +const getRemotePlaybackMs = (state = getReduxState()): number => { + const pc = state?.playbackControls; + const raw = Number(pc?.latestCurrentTime); + const playerTime = Number.isFinite(raw) ? raw : 0; + const playing = reduxPlaybackIsPlaying(state); + const now = performance.now(); + + if (playing !== wasRemotePlaying) { + wasRemotePlaying = playing; + lastRemotePlayerTimeAt = now; + lastRemotePlayerTime = playerTime; + return playerTime * 1000; + } + + if (playerTime !== lastRemotePlayerTime) { + lastRemotePlayerTime = playerTime; + lastRemotePlayerTimeAt = now; + return playerTime * 1000; + } + + if (playing && lastRemotePlayerTimeAt > 0) { + const elapsed = now - lastRemotePlayerTimeAt; + return lastRemotePlayerTime * 1000 + elapsed; + } + + return playerTime * 1000; +}; + const getPlaybackMs = (): number => { + const state = getReduxState(); + if (isRemotePlayback(state)) { + return getRemotePlaybackMs(state); + } const playerTime = PlayState.currentTime; const playing = PlayState.playing; const now = performance.now(); @@ -2230,21 +2294,96 @@ const getPlaybackMs = (): number => { return playerTime * 1000; }; +/** Re-anchor lyric time to the same clock as getPlaybackMs (PlayState or Redux on Connect). */ +const snapPlaybackInterpolationToPlayer = (): void => { + const state = getReduxState(); + if (isRemotePlayback(state)) { + const pc = state?.playbackControls; + const raw = Number(pc?.latestCurrentTime); + lastRemotePlayerTime = Number.isFinite(raw) ? raw : 0; + lastRemotePlayerTimeAt = performance.now(); + wasRemotePlaying = reduxPlaybackIsPlaying(state); + return; + } + lastPlayerTime = PlayState.currentTime; + lastPlayerTimeAt = performance.now(); + wasPlaying = PlayState.playing; +}; + +const hasCurrentSyncAnchor = (): boolean => + primaryLineIdx >= 0 && primaryLineIdx < lines.length && activeLineIdxs.size > 0; + +const getPrimaryArtistName = (value: unknown): string => { + if (!Array.isArray(value) || value.length === 0) return ""; + const first = value[0] as { name?: unknown; artist?: { name?: unknown } } | undefined; + if (!first) return ""; + if (typeof first.name === "string") return first.name; + if (typeof first.artist?.name === "string") return first.artist.name; + return ""; +}; + +const scheduleRemoteTrackChangeResync = (attempt = 0): void => { + clearPostTrackChangeResync(); + postTrackChangeResyncTimeout = safeTimeout( + unloads, + () => { + postTrackChangeResyncTimeout = null; + if (!isActive || lines.length === 0) return; + const state = getReduxState(); + if (!isRemotePlayback(state)) return; + if (!reduxPlaybackIsPlaying(state)) { + if (attempt < 10) scheduleRemoteTrackChangeResync(attempt + 1); + return; + } + if (!hasCurrentSyncAnchor()) { + if (attempt < 10) scheduleRemoteTrackChangeResync(attempt + 1); + return; + } + snapPlaybackInterpolationToPlayer(); + resync(false); + sylLog("[RL-Syllable] Post-track-change resync"); + }, + 150, + ); +}; + +const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { + const ent = getNativeTrackEntity(productId); + if (!ent) return null; + const attr = ent.attributes ?? ent; + const title = String(attr.title ?? ""); + const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? ""); + const isrc = attr.isrc ?? undefined; + if (!title || !artist) return null; + return { trackId: productId, title, artist, isrc }; +}; + // get title + artist from media item (Used everywhere now <3) const getTrackInfo = async (): Promise => { const mi = await MediaItem.fromPlaybackContext(); - if (!mi?.tidalItem) return null; + if (mi?.tidalItem) { + const baseTitle = mi.tidalItem.title ?? ""; + const version = mi.tidalItem.version; // REMIX Detection + const title = version ? `${baseTitle} (${version})` : baseTitle; + const artist = + mi.tidalItem.artist?.name ?? getPrimaryArtistName(mi.tidalItem.artists) ?? ""; // REMIX Detection + const isrc = mi.tidalItem.isrc ?? undefined; + const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? ""); - const baseTitle = mi.tidalItem.title ?? ""; - const version = mi.tidalItem.version; // REMIX Detection - const title = version ? `${baseTitle} (${version})` : baseTitle; - const artist = - mi.tidalItem.artist?.name ?? mi.tidalItem.artists?.[0]?.name ?? ""; // REMIX Detection - const isrc = mi.tidalItem.isrc ?? undefined; - const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? ""); + if (!baseTitle || !artist || !trackId) return null; + return { trackId, title, artist, isrc }; + } - if (!baseTitle || !artist || !trackId) return null; - return { trackId, title, artist, isrc }; + const state = getReduxState(); + if (isRemotePlayback(state)) { + const pid = String(state?.playbackControls?.mediaProduct?.productId ?? ""); + if (pid) { + const fromRedux = trackInfoFromReduxProductId(pid); + if (fromRedux) return fromRedux; + } + } + + return null; }; // fetch syllables from the API (wiped on track change) @@ -2719,15 +2858,22 @@ const buildWordSpans = (): { } } else { for (const group of wordGroups) { - const groupIsBg = splitBg && syllabus[group[0]].isBackground; + if (!Array.isArray(group) || group.length === 0) continue; + const firstIndex = group[0]; + const lastIndex = group[group.length - 1]; + const firstSyl = syllabus[firstIndex]; + const lastSyl = syllabus[lastIndex]; + if (!firstSyl || !lastSyl) continue; + const groupIsBg = splitBg && firstSyl.isBackground; const targetContainer = groupIsBg ? bgContainer! : mainContainer; const targetWords = groupIsBg ? lineBgWords : lineWords; if (isSylMode) { - const wordStartMs = syllabus[group[0]].time; + const wordStartMs = firstSyl.time; const groupSpans: HTMLSpanElement[] = []; for (const si of group) { const syl = syllabus[si]; + if (!syl) continue; const span = makeSpan( sylDisplay(syl).trimEnd(), wordStartMs, @@ -2752,13 +2898,13 @@ const buildWordSpans = (): { } } else { const mergedText = group - .map((si) => sylDisplay(syllabus[si]).trimEnd()) + .map((si) => syllabus[si]) + .filter((syl): syl is WordTiming => Boolean(syl)) + .map((syl) => sylDisplay(syl).trimEnd()) .join(""); - const first = syllabus[group[0]]; - const last = syllabus[group[group.length - 1]]; - const start = first.time; - const end = last.time + last.duration; - const bg = first.isBackground; + const start = firstSyl.time; + const end = lastSyl.time + lastSyl.duration; + const bg = firstSyl.isBackground; const span = makeSpan(mergedText, start, bg); targetContainer.appendChild(span); const entry: WordEntry = { @@ -3344,6 +3490,7 @@ const clearTickLoop = (): void => { const teardown = (): void => { trackChangeToken++; clearTickLoop(); + clearPostTrackChangeResync(); stopTidalFollowLoop(); clearScrollAnim(); unwatchRerender(); @@ -3985,6 +4132,7 @@ const onTrackChange = async (): Promise => { lines = result.lines; watchForRerender(); startTickLoop(); + scheduleRemoteTrackChangeResync(); } else { safeTimeout(unloads, () => { if (token !== trackChangeToken) return; @@ -4010,6 +4158,7 @@ const onTrackChange = async (): Promise => { lines = result.lines; watchForRerender(); startTickLoop(); + scheduleRemoteTrackChangeResync(); } } else if (++panelRetries < 20) { safeTimeout(unloads, waitForPanel, 250); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d26244..cf60634 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,9 +39,13 @@ importers: specifier: ^5.8.3 version: 5.8.3 + plugins/audio-visualizer-luna: {} + + plugins/colorama-lyrics-luna: {} + plugins/copy-lyrics-luna: {} - plugins/oled-theme-luna: {} + plugins/element-hider-luna: {} plugins/radiant-lyrics-luna: {} From b28e245019c0e232ccc76c1853d420a6982f57dd Mon Sep 17 00:00:00 2001 From: meowarex Date: Tue, 7 Apr 2026 18:02:07 +1000 Subject: [PATCH 2/2] Cleanup & Normalize Endings --- .gitattributes | 7 ++ plugins/radiant-lyrics-luna/src/index.ts | 103 ++++++++++++----------- 2 files changed, 61 insertions(+), 49 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..352d379 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.css text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yaml text eol=lf \ No newline at end of file diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 1b0fc8f..d748200 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -1668,24 +1668,6 @@ const getReduxState = (preferOriginal = false): any => { return redux.store.getState() as any; }; -/** Tidal Connect / cast: Luna PlayState often does not track remote playback; Redux matches the in-app progress bar. */ -const isRemotePlayback = (state = getReduxState()): boolean => { - if (state?.activePlayer?.activePlayer === "REMOTE_PLAYBACK") return true; - return state?.playbackControls?.playbackContext?.playbackSessionId === "tidal-connect"; -}; - -const reduxPlaybackIsPlaying = (state = getReduxState()): boolean => { - const pc = state?.playbackControls; - if (!pc) return false; - if (pc.playbackState === "NOT_PLAYING") return false; - if (pc.playbackState === "PLAYING") return true; - const apt = state?.accumulatedPlaybackTime?.playbackState; - if (apt === "NOT_PLAYING") return false; - if (apt === "PLAYING") return true; - // Connect often leaves playbackState on IDLE while desiredPlaybackState is PLAYING. - return pc.desiredPlaybackState === "PLAYING"; -}; - const getNativeTrackEntity = (trackId: string): any | null => getReduxState(true)?.entities?.tracks?.entities?.[trackId] ?? null; @@ -2073,7 +2055,6 @@ let scrollAnimPending: { } | null = null; let scrollUnlockTimeout: LunaUnload | null = null; let scrollCleanupTimeout: LunaUnload | null = null; -let postTrackChangeResyncTimeout: LunaUnload | null = null; let animatingEls: HTMLElement[] = []; const clearScrollAnim = (): void => { @@ -2095,13 +2076,6 @@ const clearScrollAnim = (): void => { scrollAnimPending = null; }; -const clearPostTrackChangeResync = (): void => { - if (postTrackChangeResyncTimeout) { - postTrackChangeResyncTimeout(); - postTrackChangeResyncTimeout = null; - } -}; - const applyScrollBounce = ( scrollParent: HTMLElement, referenceIdx: number, @@ -2225,16 +2199,57 @@ let savedScroll: any = null; let savedScrollBy: any = null; let scrollAllowed = false; +// MARKER: Tidal Connect (Casting & Remote Lyrics Syncing) +// Uses Redux to mirror Seek Bar progress because Luna PlayState doesn't track remote playback. +const isRemotePlayback = (state = getReduxState()): boolean => { + if (state?.activePlayer?.activePlayer === "REMOTE_PLAYBACK") return true; + return state?.playbackControls?.playbackContext?.playbackSessionId === "tidal-connect"; +}; + +const reduxPlaybackIsPlaying = (state = getReduxState()): boolean => { + const pc = state?.playbackControls; + if (!pc) return false; + if (pc.playbackState === "NOT_PLAYING") return false; + if (pc.playbackState === "PLAYING") return true; + const apt = state?.accumulatedPlaybackTime?.playbackState; + if (apt === "NOT_PLAYING") return false; + if (apt === "PLAYING") return true; + // Tidal Connect leaves playbackState on IDLE (that's why using desiredPlaybackState) + return pc.desiredPlaybackState === "PLAYING"; +}; + +const getPrimaryArtistName = (value: unknown): string => { + if (!Array.isArray(value) || value.length === 0) return ""; + const first = value[0] as { name?: unknown; artist?: { name?: unknown } } | undefined; + if (!first) return ""; + if (typeof first.name === "string") return first.name; + if (typeof first.artist?.name === "string") return first.artist.name; + return ""; +}; + +const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { + const ent = getNativeTrackEntity(productId); + if (!ent) return null; + const attr = ent.attributes ?? ent; + const title = String(attr.title ?? ""); + const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? ""); + const isrc = attr.isrc ?? undefined; + if (!title || !artist) return null; + return { trackId: productId, title, artist, isrc }; +}; + // playback time in ms (interpolated between currentTime updates) let lastPlayerTime = 0; let lastPlayerTimeAt = 0; let wasPlaying = false; -// Remote playback: same interpolation idea, fed from Redux playbackControls.latestCurrentTime (seconds). +// Remote playback time in seconds (interpolation [Redux playbackControls.latestCurrentTime]) let lastRemotePlayerTime = 0; let lastRemotePlayerTimeAt = 0; let wasRemotePlaying = false; +let postTrackChangeResyncTimeout: LunaUnload | null = null; + const getRemotePlaybackMs = (state = getReduxState()): number => { const pc = state?.playbackControls; const raw = Number(pc?.latestCurrentTime); @@ -2294,7 +2309,7 @@ const getPlaybackMs = (): number => { return playerTime * 1000; }; -/** Re-anchor lyric time to the same clock as getPlaybackMs (PlayState or Redux on Connect). */ +/** Re Sync Lyrics to getPlaybackMs (PlayState/Redux on Connect). */ const snapPlaybackInterpolationToPlayer = (): void => { const state = getReduxState(); if (isRemotePlayback(state)) { @@ -2313,13 +2328,11 @@ const snapPlaybackInterpolationToPlayer = (): void => { const hasCurrentSyncAnchor = (): boolean => primaryLineIdx >= 0 && primaryLineIdx < lines.length && activeLineIdxs.size > 0; -const getPrimaryArtistName = (value: unknown): string => { - if (!Array.isArray(value) || value.length === 0) return ""; - const first = value[0] as { name?: unknown; artist?: { name?: unknown } } | undefined; - if (!first) return ""; - if (typeof first.name === "string") return first.name; - if (typeof first.artist?.name === "string") return first.artist.name; - return ""; +const clearPostTrackChangeResync = (): void => { + if (postTrackChangeResyncTimeout) { + postTrackChangeResyncTimeout(); + postTrackChangeResyncTimeout = null; + } }; const scheduleRemoteTrackChangeResync = (attempt = 0): void => { @@ -2347,17 +2360,6 @@ const scheduleRemoteTrackChangeResync = (attempt = 0): void => { ); }; -const trackInfoFromReduxProductId = (productId: string): TrackInfo | null => { - const ent = getNativeTrackEntity(productId); - if (!ent) return null; - const attr = ent.attributes ?? ent; - const title = String(attr.title ?? ""); - const artist = String(attr.artist?.name ?? getPrimaryArtistName(attr.artists) ?? ""); - const isrc = attr.isrc ?? undefined; - if (!title || !artist) return null; - return { trackId: productId, title, artist, isrc }; -}; - // get title + artist from media item (Used everywhere now <3) const getTrackInfo = async (): Promise => { const mi = await MediaItem.fromPlaybackContext(); @@ -2386,6 +2388,9 @@ const getTrackInfo = async (): Promise => { return null; }; + +// MARKER: Lyrics API Fetching (Caching & Romanization) + // fetch syllables from the API (wiped on track change) let cachedLyricsKey: string | null = null; let cachedLyricsData: LyricsApiResponse | null = null; @@ -3490,7 +3495,7 @@ const clearTickLoop = (): void => { const teardown = (): void => { trackChangeToken++; clearTickLoop(); - clearPostTrackChangeResync(); + clearPostTrackChangeResync(); // Tidal Connect (see MARKER block) stopTidalFollowLoop(); clearScrollAnim(); unwatchRerender(); @@ -4132,7 +4137,7 @@ const onTrackChange = async (): Promise => { lines = result.lines; watchForRerender(); startTickLoop(); - scheduleRemoteTrackChangeResync(); + scheduleRemoteTrackChangeResync(); // Tidal Connect (see MARKER block) } else { safeTimeout(unloads, () => { if (token !== trackChangeToken) return; @@ -4158,7 +4163,7 @@ const onTrackChange = async (): Promise => { lines = result.lines; watchForRerender(); startTickLoop(); - scheduleRemoteTrackChangeResync(); + scheduleRemoteTrackChangeResync(); // Tidal Connect (see MARKER block) } } else if (++panelRetries < 20) { safeTimeout(unloads, waitForPanel, 250);