mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
Environment upgrade & hot-cache playback (#463)
* chore: upgrade dependencies and migrate playback to rodio 0.22 Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags, and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and cpal device descriptions; drop the unused cpal patch. Align Vite 8 build targets and chunking; remove redundant dynamic imports and fix hot-cache debug logging imports. * perf(build): lazy-load routes and restore default chunk warnings Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite uses the default 500 kB threshold. * fix(windows): tray double-click without spurious menu; clean unused import Disable tray menu on left mouse-up on Windows so a double-click to hide the main window does not immediately reopen the context menu (tray-icon default menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for /proc-only code so Windows builds stay warning-free. * fix(sidebar): preserve new-releases read state under storage cap When merging seen album ids, keep the current newest sample first so the 500-id localStorage limit does not truncate freshly marked reads and bring back the unread badge. * fix(audio): hot-cache replay, analysis no-op skips, playback source UI Retain stream_completed_cache across audio_stop so end-of-queue replay can use RAM promote or disk hot file instead of re-ranging HTTP. Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote checks generation after await before filling the slot. Frontend: promote on same-track and cold resume; set currentPlaybackSource on resume, queue undo restore, and gapless track switch so cache/stream icons stay accurate. Import tauri::Manager for try_state in audio_play. * fix(ts): narrow activeServerId for hot-cache promote calls promoteCompletedStreamToHotCache expects a string; bind non-null server ids in repeat-one, playTrack prev/same-track, and cold resume paths so tauri production build (tsc) succeeds. * fix(player): handle same-track hot-cache promote promise chain Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync throws and unexpected rejections do not surface as unhandled in DevTools; reset defer-hot-cache prefetch and isPlaying on failure. * chore(nix): sync npmDepsHash with package-lock.json * chore(release): finalize 1.46.0 CHANGELOG with PR #463 links Document the release with full GitHub PR #463 on every subsection so entries stay attributable if sections are reordered. Fix ContextMenu lines where dynamic imports were accidentally merged onto one line. * docs(contributors): credit cucadmuh for #463
This commit is contained in:
+259
-110
@@ -478,6 +478,10 @@ function queueUndoRestoreAudioEngine(opts: {
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, authState.activeServerId ?? '', url),
|
||||
});
|
||||
const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id;
|
||||
setDeferHotCachePrefetch(true);
|
||||
invoke('audio_play', {
|
||||
@@ -492,6 +496,7 @@ function queueUndoRestoreAudioEngine(opts: {
|
||||
manual: false,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== generation) return;
|
||||
@@ -1187,6 +1192,31 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the **actual** `audio_play` URL family: `getPlaybackSourceKind()` can
|
||||
* report `hot` for RAM preload or disk index while the engine still uses HTTP.
|
||||
* Rebind-to-local seeks need this, not the UI hint alone.
|
||||
*/
|
||||
let lastOpenedWithHttpTrackId: string | null = null;
|
||||
|
||||
function recordEnginePlayUrl(trackId: string, url: string): void {
|
||||
lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId;
|
||||
}
|
||||
|
||||
/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */
|
||||
function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind {
|
||||
if (!url.startsWith('psysonic-local://')) return 'stream';
|
||||
return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot';
|
||||
}
|
||||
|
||||
function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean {
|
||||
if (!serverId) return false;
|
||||
if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) {
|
||||
return false;
|
||||
}
|
||||
return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://');
|
||||
}
|
||||
|
||||
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||
let gaplessPreloadingId: string | null = null;
|
||||
// Track ID that has already been sent to audio_preload (byte pre-download).
|
||||
@@ -1479,11 +1509,24 @@ function handleAudioEnded() {
|
||||
buffered: 0,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
} else {
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
void (async () => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
const authState = useAuthStore.getState();
|
||||
const repeatPromoteSid = authState.activeServerId;
|
||||
if (authState.hotCacheEnabled && repeatPromoteSid) {
|
||||
// Same-track repeat never hit `playTrack`'s prev→promote path; flush
|
||||
// Rust `stream_completed_cache` to disk so `resolvePlaybackUrl` uses local.
|
||||
await promoteCompletedStreamToHotCache(
|
||||
currentTrack,
|
||||
repeatPromoteSid,
|
||||
authState.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
} else {
|
||||
usePlayerStore.getState().next(false);
|
||||
}
|
||||
})();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
@@ -1519,6 +1562,10 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
const switchServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
@@ -1532,6 +1579,7 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
buffered: 0,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
currentPlaybackSource: switchPlaybackSource,
|
||||
});
|
||||
emitNormalizationDebug('track-switched', {
|
||||
trackId: nextTrack.id,
|
||||
@@ -2436,110 +2484,168 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === track.id;
|
||||
const playbackSourceHint = getPlaybackSourceKind(
|
||||
track.id,
|
||||
authState.activeServerId ?? '',
|
||||
keepPreloadHint ? track.id : null,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][playTrack-source]', {
|
||||
trackId: track.id,
|
||||
resolvedUrl: url,
|
||||
preloadedTrackId,
|
||||
keepPreloadHint,
|
||||
playbackSourceHint,
|
||||
});
|
||||
}
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: initialProgress,
|
||||
buffered: 0,
|
||||
currentTime: initialTime,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||
});
|
||||
|
||||
if (
|
||||
prevTrack
|
||||
&& prevTrack.id !== track.id
|
||||
&& authState.hotCacheEnabled
|
||||
&& authState.activeServerId
|
||||
) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
prevTrack,
|
||||
authState.activeServerId,
|
||||
authState.hotCacheDownloadDir || null,
|
||||
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
|
||||
// `take()` once; a second replay would full HTTP-range again unless we flush
|
||||
// RAM to hot disk first (promote was only run when switching to another track).
|
||||
const needSameTrackHotPromote =
|
||||
Boolean(
|
||||
prevTrack
|
||||
&& sameQueueTrackId(prevTrack.id, track.id)
|
||||
&& authState.hotCacheEnabled
|
||||
&& authState.activeServerId,
|
||||
);
|
||||
}
|
||||
void refreshWaveformForTrack(track.id);
|
||||
void refreshLoudnessForTrack(track.id);
|
||||
setDeferHotCachePrefetch(true);
|
||||
const playIdx = idx >= 0 ? idx : 0;
|
||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, prevTrack, nextNeighbour,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
|
||||
const runPlayTrackBody = () => {
|
||||
const authStateNow = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authStateNow.activeServerId ?? '');
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === track.id;
|
||||
const playbackSourceHint = playbackSourceHintForResolvedUrl(
|
||||
track.id,
|
||||
authStateNow.activeServerId ?? '',
|
||||
url,
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][playTrack-source]', {
|
||||
trackId: track.id,
|
||||
resolvedUrl: url,
|
||||
preloadedTrackId,
|
||||
keepPreloadHint,
|
||||
playbackSourceHint,
|
||||
});
|
||||
}
|
||||
|
||||
// Set state immediately so the UI updates before the download completes.
|
||||
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
|
||||
set({
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: initialProgress,
|
||||
buffered: 0,
|
||||
currentTime: initialTime,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
currentPlaybackSource: playbackSourceHint,
|
||||
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(track.id);
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
const cacheKey = `${track.title}::${track.artist}`;
|
||||
usePlayerStore.setState(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
if (
|
||||
prevTrack
|
||||
&& !sameQueueTrackId(prevTrack.id, track.id)
|
||||
&& authStateNow.hotCacheEnabled
|
||||
) {
|
||||
const prevPromoteSid = authStateNow.activeServerId;
|
||||
if (prevPromoteSid) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
prevTrack,
|
||||
prevPromoteSid,
|
||||
authStateNow.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
}
|
||||
void refreshWaveformForTrack(track.id);
|
||||
void refreshLoudnessForTrack(track.id);
|
||||
setDeferHotCachePrefetch(true);
|
||||
const playIdx = idx >= 0 ? idx : 0;
|
||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, prevTrack, nextNeighbour,
|
||||
isReplayGainActive(), authStateNow.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authStateNow.replayGainPreGainDb,
|
||||
fallbackDb: authStateNow.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authStateNow.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
if (keepPreloadHint) {
|
||||
usePlayerStore.setState({ enginePreloadedTrackId: null });
|
||||
}
|
||||
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
|
||||
const seekTo = initialTime;
|
||||
const canSeekAfterPlay =
|
||||
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
|
||||
if (canSeekAfterPlay) {
|
||||
void invoke('audio_seek', { seconds: seekTo })
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
setSeekTarget(seekTo);
|
||||
if (seekFallbackVisualTarget?.trackId === track.id) {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (seekFallbackVisualTarget?.trackId === track.id) {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] audio_play failed:', err);
|
||||
set({ isPlaying: false });
|
||||
setTimeout(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
get().next(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
|
||||
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
|
||||
if (npEnabled) reportNowPlaying(track.id);
|
||||
if (lfmKey) {
|
||||
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
|
||||
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
|
||||
const cacheKey = `${track.title}::${track.artist}`;
|
||||
usePlayerStore.setState(s => ({
|
||||
lastfmLoved: loved,
|
||||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||||
}));
|
||||
});
|
||||
}
|
||||
syncQueueToServer(newQueue, track, initialTime);
|
||||
touchHotCacheOnPlayback(track.id, authStateNow.activeServerId ?? '');
|
||||
};
|
||||
|
||||
const hotPromoteSid = authState.activeServerId;
|
||||
if (needSameTrackHotPromote && hotPromoteSid) {
|
||||
void promoteCompletedStreamToHotCache(
|
||||
track,
|
||||
hotPromoteSid,
|
||||
authState.hotCacheDownloadDir || null,
|
||||
)
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
runPlayTrackBody();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
console.error('[psysonic] same-track hot promote / play body failed:', err);
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
} else {
|
||||
runPlayTrackBody();
|
||||
}
|
||||
syncQueueToServer(newQueue, track, initialTime);
|
||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||
},
|
||||
|
||||
reseedQueueForInstantMix: (track) => {
|
||||
@@ -2666,13 +2772,28 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({ isPlaying: true });
|
||||
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
|
||||
} else {
|
||||
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
|
||||
// Engine has no loaded paused stream (app relaunch, or track ended and user
|
||||
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any
|
||||
// `stream_completed_cache` from the prior play to hot disk before resolving URL.
|
||||
const gen = ++playGeneration;
|
||||
const vol = get().volume;
|
||||
set({ isPlaying: true });
|
||||
|
||||
// Fetch fresh track data from server to get replay gain metadata
|
||||
getSong(currentTrack.id).then(freshSong => {
|
||||
|
||||
void (async () => {
|
||||
const authHot = useAuthStore.getState();
|
||||
const resumePromoteSid = authHot.activeServerId;
|
||||
if (authHot.hotCacheEnabled && resumePromoteSid) {
|
||||
await promoteCompletedStreamToHotCache(
|
||||
currentTrack,
|
||||
resumePromoteSid,
|
||||
authHot.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
if (playGeneration !== gen) return;
|
||||
|
||||
// Fetch fresh track data from server to get replay gain metadata
|
||||
getSong(currentTrack.id).then(freshSong => {
|
||||
if (playGeneration !== gen) return;
|
||||
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
|
||||
// Update store with fresh track data if available
|
||||
if (freshSong) set({ currentTrack: trackToPlay });
|
||||
@@ -2685,6 +2806,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
@@ -2698,6 +2821,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
analysisTrackId: trackToPlay.id,
|
||||
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
@@ -2721,6 +2845,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(currentTrack.id, coldUrl);
|
||||
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
|
||||
invoke('audio_play', {
|
||||
url: coldUrl,
|
||||
@@ -2734,6 +2860,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
analysisTrackId: currentTrack.id,
|
||||
streamFormatSuffix: currentTrack.suffix ?? null,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
@@ -2742,6 +2869,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
});
|
||||
syncQueueToServer(queue, currentTrack, currentTime);
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2923,10 +3051,17 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
previous: () => {
|
||||
const { queue, queueIndex } = get();
|
||||
const { queue, queueIndex, currentTrack } = get();
|
||||
const currentTime = getPlaybackProgressSnapshot().currentTime;
|
||||
if (currentTime > 3) {
|
||||
// Restart current track from the beginning.
|
||||
const authState = useAuthStore.getState();
|
||||
const sid = authState.activeServerId ?? '';
|
||||
if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) {
|
||||
seekFallbackVisualTarget = { trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() };
|
||||
get().playTrack(currentTrack, queue, true);
|
||||
return;
|
||||
}
|
||||
invoke('audio_seek', { seconds: 0 }).catch(console.error);
|
||||
set({ progress: 0, currentTime: 0 });
|
||||
return;
|
||||
@@ -2947,6 +3082,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
const s0 = get();
|
||||
if (!s0.currentTrack) return;
|
||||
const authSeek = useAuthStore.getState();
|
||||
const sidSeek = authSeek.activeServerId ?? '';
|
||||
if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) {
|
||||
seekFallbackVisualTarget = {
|
||||
trackId: s0.currentTrack.id,
|
||||
seconds: time,
|
||||
setAtMs: Date.now(),
|
||||
};
|
||||
clearSeekFallbackRetry();
|
||||
s0.playTrack(s0.currentTrack, s0.queue, true);
|
||||
return;
|
||||
}
|
||||
invoke('audio_seek', { seconds: time }).then(() => {
|
||||
// Arm stale-progress guard only after backend acknowledged seek.
|
||||
setSeekTarget(time);
|
||||
|
||||
Reference in New Issue
Block a user