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: {}