Tidal Connect Support <3

This commit is contained in:
2026-04-07 18:00:51 +10:00
parent e890c86b85
commit 83ef103118
3 changed files with 172 additions and 22 deletions
-3
View File
@@ -18,8 +18,5 @@
"rimraf": "^6.0.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"dependencies": {
"pnpm": "^10.14.0"
}
}
+167 -18
View File
@@ -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<TrackInfo | null> => {
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<void> => {
lines = result.lines;
watchForRerender();
startTickLoop();
scheduleRemoteTrackChangeResync();
} else {
safeTimeout(unloads, () => {
if (token !== trackChangeToken) return;
@@ -4010,6 +4158,7 @@ const onTrackChange = async (): Promise<void> => {
lines = result.lines;
watchForRerender();
startTickLoop();
scheduleRemoteTrackChangeResync();
}
} else if (++panelRetries < 20) {
safeTimeout(unloads, waitForPanel, 250);
+5 -1
View File
@@ -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: {}