mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-17 19:33:10 +10:00
@@ -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
|
||||||
@@ -2199,11 +2199,90 @@ let savedScroll: any = null;
|
|||||||
let savedScrollBy: any = null;
|
let savedScrollBy: any = null;
|
||||||
let scrollAllowed = false;
|
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)
|
// playback time in ms (interpolated between currentTime updates)
|
||||||
let lastPlayerTime = 0;
|
let lastPlayerTime = 0;
|
||||||
let lastPlayerTimeAt = 0;
|
let lastPlayerTimeAt = 0;
|
||||||
let wasPlaying = false;
|
let wasPlaying = false;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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,23 +2309,88 @@ const getPlaybackMs = (): number => {
|
|||||||
return playerTime * 1000;
|
return playerTime * 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Re Sync Lyrics to getPlaybackMs (PlayState/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 clearPostTrackChangeResync = (): void => {
|
||||||
|
if (postTrackChangeResyncTimeout) {
|
||||||
|
postTrackChangeResyncTimeout();
|
||||||
|
postTrackChangeResyncTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// MARKER: Lyrics API Fetching (Caching & Romanization)
|
||||||
|
|
||||||
// fetch syllables from the API (wiped on track change)
|
// fetch syllables from the API (wiped on track change)
|
||||||
let cachedLyricsKey: string | null = null;
|
let cachedLyricsKey: string | null = null;
|
||||||
let cachedLyricsData: LyricsApiResponse | null = null;
|
let cachedLyricsData: LyricsApiResponse | null = null;
|
||||||
@@ -2719,15 +2863,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 +2903,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 +3495,7 @@ const clearTickLoop = (): void => {
|
|||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
trackChangeToken++;
|
trackChangeToken++;
|
||||||
clearTickLoop();
|
clearTickLoop();
|
||||||
|
clearPostTrackChangeResync(); // Tidal Connect (see MARKER block)
|
||||||
stopTidalFollowLoop();
|
stopTidalFollowLoop();
|
||||||
clearScrollAnim();
|
clearScrollAnim();
|
||||||
unwatchRerender();
|
unwatchRerender();
|
||||||
@@ -3985,6 +4137,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
lines = result.lines;
|
lines = result.lines;
|
||||||
watchForRerender();
|
watchForRerender();
|
||||||
startTickLoop();
|
startTickLoop();
|
||||||
|
scheduleRemoteTrackChangeResync(); // Tidal Connect (see MARKER block)
|
||||||
} else {
|
} else {
|
||||||
safeTimeout(unloads, () => {
|
safeTimeout(unloads, () => {
|
||||||
if (token !== trackChangeToken) return;
|
if (token !== trackChangeToken) return;
|
||||||
@@ -4010,6 +4163,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
lines = result.lines;
|
lines = result.lines;
|
||||||
watchForRerender();
|
watchForRerender();
|
||||||
startTickLoop();
|
startTickLoop();
|
||||||
|
scheduleRemoteTrackChangeResync(); // Tidal Connect (see MARKER block)
|
||||||
}
|
}
|
||||||
} 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