mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-17 19:33:10 +10:00
Tidal Connect Support <3
This commit is contained in:
@@ -18,8 +18,5 @@
|
|||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"pnpm": "^10.14.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1668,6 +1668,24 @@ const getReduxState = (preferOriginal = false): any => {
|
|||||||
return redux.store.getState() as 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 =>
|
const getNativeTrackEntity = (trackId: string): any | null =>
|
||||||
getReduxState(true)?.entities?.tracks?.entities?.[trackId] ?? null;
|
getReduxState(true)?.entities?.tracks?.entities?.[trackId] ?? null;
|
||||||
|
|
||||||
@@ -2055,6 +2073,7 @@ let scrollAnimPending: {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
let scrollUnlockTimeout: LunaUnload | null = null;
|
let scrollUnlockTimeout: LunaUnload | null = null;
|
||||||
let scrollCleanupTimeout: LunaUnload | null = null;
|
let scrollCleanupTimeout: LunaUnload | null = null;
|
||||||
|
let postTrackChangeResyncTimeout: LunaUnload | null = null;
|
||||||
let animatingEls: HTMLElement[] = [];
|
let animatingEls: HTMLElement[] = [];
|
||||||
|
|
||||||
const clearScrollAnim = (): void => {
|
const clearScrollAnim = (): void => {
|
||||||
@@ -2076,6 +2095,13 @@ const clearScrollAnim = (): void => {
|
|||||||
scrollAnimPending = null;
|
scrollAnimPending = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearPostTrackChangeResync = (): void => {
|
||||||
|
if (postTrackChangeResyncTimeout) {
|
||||||
|
postTrackChangeResyncTimeout();
|
||||||
|
postTrackChangeResyncTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyScrollBounce = (
|
const applyScrollBounce = (
|
||||||
scrollParent: HTMLElement,
|
scrollParent: HTMLElement,
|
||||||
referenceIdx: number,
|
referenceIdx: number,
|
||||||
@@ -2203,7 +2229,45 @@ let scrollAllowed = false;
|
|||||||
let lastPlayerTime = 0;
|
let lastPlayerTime = 0;
|
||||||
let lastPlayerTimeAt = 0;
|
let lastPlayerTimeAt = 0;
|
||||||
let wasPlaying = false;
|
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 getPlaybackMs = (): number => {
|
||||||
|
const state = getReduxState();
|
||||||
|
if (isRemotePlayback(state)) {
|
||||||
|
return getRemotePlaybackMs(state);
|
||||||
|
}
|
||||||
const playerTime = PlayState.currentTime;
|
const playerTime = PlayState.currentTime;
|
||||||
const playing = PlayState.playing;
|
const playing = PlayState.playing;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -2230,21 +2294,96 @@ const getPlaybackMs = (): number => {
|
|||||||
return playerTime * 1000;
|
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)
|
// get title + artist from media item (Used everywhere now <3)
|
||||||
const getTrackInfo = async (): Promise<TrackInfo | null> => {
|
const getTrackInfo = async (): Promise<TrackInfo | null> => {
|
||||||
const mi = await MediaItem.fromPlaybackContext();
|
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 ?? "";
|
if (!baseTitle || !artist || !trackId) return null;
|
||||||
const version = mi.tidalItem.version; // REMIX Detection
|
return { trackId, title, artist, isrc };
|
||||||
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;
|
const state = getReduxState();
|
||||||
return { trackId, title, artist, isrc };
|
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)
|
// fetch syllables from the API (wiped on track change)
|
||||||
@@ -2719,15 +2858,22 @@ const buildWordSpans = (): {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const group of wordGroups) {
|
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 targetContainer = groupIsBg ? bgContainer! : mainContainer;
|
||||||
const targetWords = groupIsBg ? lineBgWords : lineWords;
|
const targetWords = groupIsBg ? lineBgWords : lineWords;
|
||||||
|
|
||||||
if (isSylMode) {
|
if (isSylMode) {
|
||||||
const wordStartMs = syllabus[group[0]].time;
|
const wordStartMs = firstSyl.time;
|
||||||
const groupSpans: HTMLSpanElement[] = [];
|
const groupSpans: HTMLSpanElement[] = [];
|
||||||
for (const si of group) {
|
for (const si of group) {
|
||||||
const syl = syllabus[si];
|
const syl = syllabus[si];
|
||||||
|
if (!syl) continue;
|
||||||
const span = makeSpan(
|
const span = makeSpan(
|
||||||
sylDisplay(syl).trimEnd(),
|
sylDisplay(syl).trimEnd(),
|
||||||
wordStartMs,
|
wordStartMs,
|
||||||
@@ -2752,13 +2898,13 @@ const buildWordSpans = (): {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const mergedText = group
|
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("");
|
.join("");
|
||||||
const first = syllabus[group[0]];
|
const start = firstSyl.time;
|
||||||
const last = syllabus[group[group.length - 1]];
|
const end = lastSyl.time + lastSyl.duration;
|
||||||
const start = first.time;
|
const bg = firstSyl.isBackground;
|
||||||
const end = last.time + last.duration;
|
|
||||||
const bg = first.isBackground;
|
|
||||||
const span = makeSpan(mergedText, start, bg);
|
const span = makeSpan(mergedText, start, bg);
|
||||||
targetContainer.appendChild(span);
|
targetContainer.appendChild(span);
|
||||||
const entry: WordEntry = {
|
const entry: WordEntry = {
|
||||||
@@ -3344,6 +3490,7 @@ const clearTickLoop = (): void => {
|
|||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
trackChangeToken++;
|
trackChangeToken++;
|
||||||
clearTickLoop();
|
clearTickLoop();
|
||||||
|
clearPostTrackChangeResync();
|
||||||
stopTidalFollowLoop();
|
stopTidalFollowLoop();
|
||||||
clearScrollAnim();
|
clearScrollAnim();
|
||||||
unwatchRerender();
|
unwatchRerender();
|
||||||
@@ -3985,6 +4132,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
lines = result.lines;
|
lines = result.lines;
|
||||||
watchForRerender();
|
watchForRerender();
|
||||||
startTickLoop();
|
startTickLoop();
|
||||||
|
scheduleRemoteTrackChangeResync();
|
||||||
} else {
|
} else {
|
||||||
safeTimeout(unloads, () => {
|
safeTimeout(unloads, () => {
|
||||||
if (token !== trackChangeToken) return;
|
if (token !== trackChangeToken) return;
|
||||||
@@ -4010,6 +4158,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
lines = result.lines;
|
lines = result.lines;
|
||||||
watchForRerender();
|
watchForRerender();
|
||||||
startTickLoop();
|
startTickLoop();
|
||||||
|
scheduleRemoteTrackChangeResync();
|
||||||
}
|
}
|
||||||
} else if (++panelRetries < 20) {
|
} else if (++panelRetries < 20) {
|
||||||
safeTimeout(unloads, waitForPanel, 250);
|
safeTimeout(unloads, waitForPanel, 250);
|
||||||
|
|||||||
Generated
+5
-1
@@ -39,9 +39,13 @@ importers:
|
|||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
|
plugins/audio-visualizer-luna: {}
|
||||||
|
|
||||||
|
plugins/colorama-lyrics-luna: {}
|
||||||
|
|
||||||
plugins/copy-lyrics-luna: {}
|
plugins/copy-lyrics-luna: {}
|
||||||
|
|
||||||
plugins/oled-theme-luna: {}
|
plugins/element-hider-luna: {}
|
||||||
|
|
||||||
plugins/radiant-lyrics-luna: {}
|
plugins/radiant-lyrics-luna: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user