mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
40db6b08d2
* chore(playback): remove Preload Next Track setting The configurable next-track RAM preload duplicated hot cache and is obsolete now that ranged streaming starts playback sooner. Gapless and crossfade still use the internal audio_preload backup when hot cache is off. * docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
481 lines
19 KiB
TypeScript
481 lines
19 KiB
TypeScript
import { reportNowPlaying, scrobbleSong } from '../api/subsonicScrobble';
|
||
import type { Track } from './playerStoreTypes';
|
||
import { resolveQueueTrack } from '../utils/library/queueTrackView';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
|
||
import {
|
||
playListenSessionFinalize,
|
||
playListenSessionOnProgress,
|
||
playListenSessionOnTrackSwitched,
|
||
playListenSessionOpen,
|
||
} from './playListenSession';
|
||
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||
import {
|
||
getPlaybackCacheServerKey,
|
||
getPlaybackIndexKey,
|
||
getPlaybackServerId,
|
||
} from '../utils/playback/playbackServer';
|
||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||
import { showToast } from '../utils/ui/toast';
|
||
import { useAuthStore } from './authStore';
|
||
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||
import {
|
||
clearPreloadingIds,
|
||
getBytePreloadingId,
|
||
getGaplessPreloadingId,
|
||
getLastGaplessSwitchTime,
|
||
markGaplessSwitch,
|
||
setBytePreloadingId,
|
||
setGaplessPreloadingId,
|
||
} from './gaplessPreloadState';
|
||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||
import {
|
||
isReplayGainActive,
|
||
loudnessGainDbForEngineBind,
|
||
} from './loudnessGainCache';
|
||
import { refreshLoudnessForTrack } from './loudnessRefresh';
|
||
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
|
||
import { emitNormalizationDebug } from './normalizationDebug';
|
||
import {
|
||
emitPlaybackProgress,
|
||
getPlaybackProgressSnapshot,
|
||
} from './playbackProgress';
|
||
import {
|
||
LIVE_PROGRESS_EMIT_MIN_DELTA_SEC,
|
||
LIVE_PROGRESS_EMIT_MIN_MS,
|
||
STORE_PROGRESS_COMMIT_MIN_DELTA_SEC,
|
||
STORE_PROGRESS_COMMIT_MIN_MS,
|
||
getLastLiveProgressEmitAt,
|
||
getLastStoreProgressCommitAt,
|
||
markLiveProgressEmit,
|
||
markStoreProgressCommit,
|
||
resetProgressEmitThrottles,
|
||
} from './playbackThrottles';
|
||
import {
|
||
playbackSourceHintForResolvedUrl,
|
||
} from './playbackUrlRouting';
|
||
import { usePlayerStore } from './playerStore';
|
||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||
import {
|
||
flushQueueSyncToServer,
|
||
getLastQueueHeartbeatAt,
|
||
syncQueueToServer,
|
||
} from './queueSync';
|
||
import { isSeekDebouncePending } from './seekDebounce';
|
||
import {
|
||
SEEK_FALLBACK_VISUAL_GUARD_MS,
|
||
getSeekFallbackVisualTarget,
|
||
setSeekFallbackVisualTarget,
|
||
} from './seekFallbackState';
|
||
import {
|
||
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
||
clearSeekTarget,
|
||
getSeekTarget,
|
||
getSeekTargetSetAt,
|
||
} from './seekTargetState';
|
||
import { refreshWaveformForTrack } from './waveformRefresh';
|
||
|
||
/** Rust-side `audio:normalization-state` event payload. */
|
||
export type NormalizationStatePayload = {
|
||
engine: 'off' | 'replaygain' | 'loudness' | string;
|
||
currentGainDb: number | null;
|
||
targetLufs: number;
|
||
};
|
||
|
||
export function handleAudioPlaying(duration: number): void {
|
||
setDeferHotCachePrefetch(false);
|
||
resetProgressEmitThrottles();
|
||
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
|
||
notifyLibraryPlaybackHint('playing');
|
||
const track = usePlayerStore.getState().currentTrack;
|
||
if (track) {
|
||
void playListenSessionOpen(track, getPlaybackServerId(), duration);
|
||
}
|
||
}
|
||
|
||
export function handleAudioProgress(
|
||
current_time: number,
|
||
duration: number,
|
||
buffering = false,
|
||
): void {
|
||
bumpPerfCounter('audioProgressEvents');
|
||
const perfFlags = getPerfProbeFlags();
|
||
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
|
||
// While a seek is pending, the store already holds the optimistic target
|
||
// position. Accepting stale progress from the Rust engine would briefly
|
||
// snap the waveform back to the old position before the seek completes.
|
||
if (isSeekDebouncePending()) return;
|
||
// After the debounce fires, Rust may still emit 1–2 ticks with the old
|
||
// position before the seek takes effect. Block until current_time is
|
||
// within 2 s of the requested target, then clear the guard.
|
||
const activeSeekTarget = getSeekTarget();
|
||
if (activeSeekTarget !== null) {
|
||
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||
clearSeekTarget();
|
||
} else {
|
||
clearSeekTarget();
|
||
}
|
||
}
|
||
|
||
const store = usePlayerStore.getState();
|
||
const track = store.currentTrack;
|
||
if (!track) return;
|
||
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
|
||
usePlayerStore.setState({ isPlaybackBuffering: buffering });
|
||
}
|
||
// Some backends can emit stale progress ticks shortly after pause/stop.
|
||
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
|
||
const transportActive = store.isPlaying || store.currentRadio != null;
|
||
let visualTarget = getSeekFallbackVisualTarget();
|
||
if (!transportActive && !visualTarget) return;
|
||
if (visualTarget && visualTarget.trackId !== track.id) {
|
||
setSeekFallbackVisualTarget(null);
|
||
visualTarget = null;
|
||
}
|
||
let displayTime = buffering ? 0 : current_time;
|
||
if (visualTarget && visualTarget.trackId === track.id) {
|
||
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
|
||
if (nearTarget) {
|
||
setSeekFallbackVisualTarget(null);
|
||
visualTarget = null;
|
||
} else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||
// Keep UI at the requested position while backend catches up.
|
||
displayTime = visualTarget.seconds;
|
||
} else {
|
||
setSeekFallbackVisualTarget(null);
|
||
visualTarget = null;
|
||
}
|
||
}
|
||
const dur = duration > 0 ? duration : track.duration;
|
||
if (dur <= 0) return;
|
||
const progress = displayTime / dur;
|
||
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
|
||
if (!progressUiDisabled) {
|
||
const nowLive = Date.now();
|
||
const live = getPlaybackProgressSnapshot();
|
||
const liveTimeDelta = Math.abs(live.currentTime - displayTime);
|
||
if (
|
||
nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS ||
|
||
liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC ||
|
||
visualTarget != null
|
||
) {
|
||
emitPlaybackProgress({
|
||
currentTime: displayTime,
|
||
progress: buffering ? 0 : progress,
|
||
buffered: 0,
|
||
buffering,
|
||
});
|
||
markLiveProgressEmit(nowLive);
|
||
}
|
||
}
|
||
// Heartbeat: push current position to the server every 15 s while playing so
|
||
// cross-device resume works even on a hard close — pause() and the close
|
||
// handler flush on top of this for clean shutdowns.
|
||
if (store.isPlaying && !store.currentRadio) {
|
||
const now = Date.now();
|
||
if (now - getLastQueueHeartbeatAt() >= 15_000) {
|
||
void flushQueueSyncToServer(store.queueItems, track, displayTime);
|
||
}
|
||
}
|
||
|
||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||
if (progress >= 0.5 && !store.scrobbled) {
|
||
usePlayerStore.setState({ scrobbled: true });
|
||
scrobbleSong(track.id, Date.now(), getPlaybackServerId());
|
||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||
if (scrobblingEnabled && lastfmSessionKey) {
|
||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||
}
|
||
}
|
||
if (progressUiDisabled) return;
|
||
// Critical architectural guard: avoid high-frequency writes to the persisted
|
||
// Zustand store (each write serializes queue state). Keep only coarse commits.
|
||
const nowCommit = Date.now();
|
||
const commitDelta = Math.abs(store.currentTime - displayTime);
|
||
const shouldCommitStore =
|
||
visualTarget != null ||
|
||
nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS ||
|
||
commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC;
|
||
if (shouldCommitStore) {
|
||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||
markStoreProgressCommit(nowCommit);
|
||
}
|
||
|
||
// Pre-buffer / pre-chain next track for gapless and crossfade.
|
||
const {
|
||
gaplessEnabled,
|
||
hotCacheEnabled,
|
||
crossfadeEnabled,
|
||
crossfadeSecs,
|
||
} = useAuthStore.getState();
|
||
const remaining = dur - current_time;
|
||
|
||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||
// Crossfade needs the next track bytes ready before the fade window.
|
||
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
||
const shouldBytePreloadForCrossfade =
|
||
!hotCacheEnabled && !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
||
|
||
if (shouldChainGapless || shouldBytePreloadForCrossfade || gaplessEnabled) {
|
||
const { queueItems, queueIndex, repeatMode } = store;
|
||
const nextIdx = queueIndex + 1;
|
||
// Next track for preload/chain. The resolver bridge keeps the window around
|
||
// queueIndex warm, so the next ref is cache-hot; resolveQueueTrack falls
|
||
// back to a placeholder (correct trackId, so URL building still works) on a
|
||
// cold miss. current track = `track` (full) — never resolved.
|
||
const nextRef = repeatMode === 'one'
|
||
? null
|
||
: (nextIdx < queueItems.length ? queueItems[nextIdx] : (repeatMode === 'all' ? queueItems[0] : null));
|
||
const nextTrack = repeatMode === 'one'
|
||
? track
|
||
: (nextRef ? resolveQueueTrack(nextRef) : null);
|
||
if (!nextTrack || nextTrack.id === track.id) return;
|
||
|
||
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||
// the boundary. Start earlier for larger files / slower conservative link.
|
||
const estBytes = (() => {
|
||
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||
return nextTrack.size;
|
||
}
|
||
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||
? nextTrack.bitRate
|
||
: 320;
|
||
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||
})();
|
||
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||
const shouldBytePreloadForGaplessBackup =
|
||
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||
|
||
const serverId = getPlaybackCacheServerKey();
|
||
const analysisServerId = getPlaybackIndexKey();
|
||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||
|
||
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
|
||
if (
|
||
(shouldBytePreloadForCrossfade || shouldBytePreloadForGaplessBackup)
|
||
&& nextTrack.id !== getBytePreloadingId()
|
||
) {
|
||
setBytePreloadingId(nextTrack.id);
|
||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||
// waveformBins and would replace the current track's seekbar while still playing it.
|
||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||
if (import.meta.env.DEV) {
|
||
console.info('[psysonic][preload-request]', {
|
||
nextTrackId: nextTrack.id,
|
||
nextUrl,
|
||
shouldBytePreloadForCrossfade,
|
||
shouldBytePreloadForGaplessBackup,
|
||
remaining,
|
||
gaplessEnabled,
|
||
});
|
||
}
|
||
invoke('audio_preload', {
|
||
url: nextUrl,
|
||
durationHint: nextTrack.duration,
|
||
analysisTrackId: nextTrack.id,
|
||
serverId: analysisServerId || null,
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
|
||
setGaplessPreloadingId(nextTrack.id);
|
||
// Ensure loudness gain is already cached for the chained request payload.
|
||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||
const authState = useAuthStore.getState();
|
||
// Auto-mode neighbours for the *next* track: current track on its left,
|
||
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
|
||
// — only its replaygain tags matter, which a placeholder lacks → fallback).
|
||
const nextNeighbourRef = nextIdx + 1 < queueItems.length
|
||
? queueItems[nextIdx + 1]
|
||
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
|
||
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
|
||
const replayGainDb = resolveReplayGainDb(
|
||
nextTrack, track, nextNeighbour,
|
||
isReplayGainActive(), authState.replayGainMode,
|
||
);
|
||
const replayGainPeak = isReplayGainActive()
|
||
? (nextTrack.replayGainPeak ?? null)
|
||
: null;
|
||
invoke('audio_chain_preload', {
|
||
url: nextUrl,
|
||
volume: store.volume,
|
||
durationHint: nextTrack.duration,
|
||
replayGainDb,
|
||
replayGainPeak,
|
||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||
preGainDb: authState.replayGainPreGainDb,
|
||
fallbackDb: authState.replayGainFallbackDb,
|
||
hiResEnabled: authState.enableHiRes,
|
||
analysisTrackId: nextTrack.id,
|
||
serverId: analysisServerId || null,
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
}
|
||
|
||
export function handleAudioEnded(): void {
|
||
notifyLibraryPlaybackHint('idle');
|
||
|
||
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
||
return;
|
||
}
|
||
|
||
void playListenSessionFinalize('ended');
|
||
|
||
// Radio stream disconnected — just stop; don't advance queue.
|
||
if (usePlayerStore.getState().currentRadio) {
|
||
setIsAudioPaused(false);
|
||
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
|
||
return;
|
||
}
|
||
|
||
const { repeatMode, currentTrack, queueIndex } = usePlayerStore.getState();
|
||
setIsAudioPaused(false);
|
||
usePlayerStore.setState({
|
||
isPlaying: false,
|
||
isPlaybackBuffering: false,
|
||
progress: 0,
|
||
currentTime: 0,
|
||
buffered: 0,
|
||
});
|
||
setTimeout(() => {
|
||
void (async () => {
|
||
if (repeatMode === 'one' && currentTrack) {
|
||
const authState = useAuthStore.getState();
|
||
const repeatPromoteSid = getPlaybackCacheServerKey();
|
||
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,
|
||
);
|
||
}
|
||
// Pin to the current slot — the track may appear elsewhere in the queue.
|
||
// No-arg queue: playTrack keeps the canonical refs and just re-binds.
|
||
usePlayerStore.getState().playTrack(currentTrack, undefined, false, false, queueIndex);
|
||
} else {
|
||
usePlayerStore.getState().next(false);
|
||
}
|
||
})();
|
||
}, 150);
|
||
}
|
||
|
||
/**
|
||
* Handle gapless auto-advance: the Rust engine has already switched to the
|
||
* next source sample-accurately. We just need to update the UI state without
|
||
* touching the audio stream (no playTrack() call!).
|
||
*/
|
||
export function handleAudioTrackSwitched(_duration: number): void {
|
||
markGaplessSwitch();
|
||
clearPreloadingIds(); // allow preloading for the track after this one
|
||
setIsAudioPaused(false);
|
||
|
||
const store = usePlayerStore.getState();
|
||
if (store.currentTrack?.id) {
|
||
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
|
||
}
|
||
const { queueItems, queueIndex, repeatMode } = store;
|
||
const nextIdx = queueIndex + 1;
|
||
let nextTrack: Track | null = null;
|
||
let newIndex = queueIndex;
|
||
|
||
if (repeatMode === 'one' && store.currentTrack) {
|
||
nextTrack = store.currentTrack;
|
||
// queueIndex stays the same
|
||
} else if (nextIdx < queueItems.length) {
|
||
// The Rust engine already chained this source sample-accurately, so it must
|
||
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
|
||
// returns the full Track from cache (placeholder only on an unexpected miss).
|
||
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
|
||
newIndex = nextIdx;
|
||
} else if (repeatMode === 'all' && queueItems.length > 0) {
|
||
nextTrack = resolveQueueTrack(queueItems[0]);
|
||
newIndex = 0;
|
||
}
|
||
|
||
if (!nextTrack) return;
|
||
|
||
void playListenSessionOnTrackSwitched(nextTrack);
|
||
|
||
const switchServerId = getPlaybackCacheServerKey();
|
||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||
|
||
// Neighbour window for normalization (replaygain album-mode reads prev/next).
|
||
// current track on the left, the track after `nextTrack` on the right.
|
||
const switchPrev = store.currentTrack;
|
||
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
|
||
const switchNeighbourWindow: Track[] = [
|
||
switchPrev ?? nextTrack,
|
||
nextTrack,
|
||
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
|
||
];
|
||
|
||
usePlayerStore.setState({
|
||
currentTrack: nextTrack,
|
||
waveformBins: null,
|
||
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
|
||
normalizationDbgSource: 'track-switched',
|
||
normalizationDbgTrackId: nextTrack.id,
|
||
queueIndex: newIndex,
|
||
isPlaying: true,
|
||
isPlaybackBuffering: switchPlaybackSource === 'stream',
|
||
progress: 0,
|
||
currentTime: 0,
|
||
buffered: 0,
|
||
scrobbled: false,
|
||
lastfmLoved: false,
|
||
currentPlaybackSource: switchPlaybackSource,
|
||
});
|
||
emitNormalizationDebug('track-switched', {
|
||
trackId: nextTrack.id,
|
||
queueIndex: newIndex,
|
||
engineRequested: useAuthStore.getState().normalizationEngine,
|
||
});
|
||
void refreshWaveformForTrack(nextTrack.id);
|
||
void refreshLoudnessForTrack(nextTrack.id);
|
||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||
|
||
// Report Now Playing to Navidrome + Last.fm
|
||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId());
|
||
if (lastfmSessionKey) {
|
||
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
|
||
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
|
||
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
|
||
usePlayerStore.setState(s => ({
|
||
lastfmLoved: loved,
|
||
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
|
||
}));
|
||
});
|
||
}
|
||
syncQueueToServer(queueItems, nextTrack, 0);
|
||
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
|
||
}
|
||
|
||
export function handleAudioError(message: string): void {
|
||
console.error('[psysonic] Audio error from backend:', message);
|
||
setIsAudioPaused(false);
|
||
|
||
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
|
||
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
|
||
|
||
const gen = getPlayGeneration();
|
||
usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
|
||
setTimeout(() => {
|
||
if (getPlayGeneration() !== gen) return;
|
||
usePlayerStore.getState().next(false);
|
||
}, 1500);
|
||
}
|