import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { showToast } from '../utils/toast'; import i18n from '../i18n'; import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, getSong, getSimilarSongs2, getTopSongs, setRating } from '../api/subsonic'; import { resolvePlaybackUrl, getPlaybackSourceKind } from '../utils/resolvePlaybackUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; import { useHotCacheStore } from './hotCacheStore'; import { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { useOrbitStore } from './orbitStore'; import { estimateLivePosition } from '../api/orbit'; import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder'; import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; import { queuesStructuralEqual, sameQueueTrackId, shallowCloneQueueTracks, } from '../utils/queueIdentity'; import { waveformBlobLenOk } from '../utils/waveformParse'; import { isRecoverableSeekError } from '../utils/seekErrors'; import { emitPlaybackProgress, getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot, } from './playbackProgress'; import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl, shouldRebindPlaybackToHotCache, } from './playbackUrlRouting'; import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { isInOrbitSession } from './orbitSession'; import { getCachedLoudnessGain, hasStableLoudness, isReplayGainActive, loudnessCacheStateKeysForTrackId, loudnessGainDbForEngineBind, } from './loudnessGainCache'; import { clearAllPlaybackScheduleTimers, clearScheduledPauseTimers, clearScheduledResumeTimers, schedulePauseTimer, scheduleResumeTimer, } from './scheduleTimers'; import { invokeAudioUpdateReplayGainDeduped } from './normalizationIpcDedupe'; import { touchHotCacheOnPlayback } from './hotCacheTouch'; import { applySkipStarOnManualNext } from './skipStarRating'; import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; import { flushPlayQueuePosition, flushQueueSyncToServer, syncQueueToServer, } from './queueSync'; import { clearPreloadingIds, getLastGaplessSwitchTime, } from './gaplessPreloadState'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { clearSeekTarget, setSeekTarget, } from './seekTargetState'; import { tryAcquireTogglePlayLock } from './togglePlayLock'; import { reseedLoudnessForTrackId } from './loudnessReseed'; import { refreshWaveformForTrack } from './waveformRefresh'; import { refreshLoudnessForTrack } from './loudnessRefresh'; import { clearRadioReconnectTimer, pauseRadio, playRadioStream, resumeRadio, setRadioVolume, stopRadio, } from './radioPlayer'; import { clearSeekFallbackRetry, getSeekFallbackRestartAt, getSeekFallbackTrackId, getSeekFallbackVisualTarget, scheduleSeekFallbackRetry, setSeekFallbackRestartAt, setSeekFallbackTrackId, setSeekFallbackVisualTarget, } from './seekFallbackState'; import { armSeekDebounce, clearSeekDebounce, } from './seekDebounce'; import { bumpPlayGeneration, getIsAudioPaused, getPlayGeneration, setIsAudioPaused, } from './engineState'; import { addRadioSessionSeen, clearRadioSessionSeenIds, deleteRadioSessionSeen, getCurrentRadioArtistId, hasRadioSessionSeen, isRadioFetching, setCurrentRadioArtistId, setRadioFetching, } from './radioSessionState'; import { isInfiniteQueueFetching, setInfiniteQueueFetching, } from './infiniteQueueState'; import { queueUndoRestoreAudioEngine } from './queueUndoAudioRestore'; import { prefetchLoudnessForEnqueuedTracks } from './loudnessPrefetch'; import { initAudioListeners } from './initAudioListeners'; import { installQueueUndoHotkey } from './queueUndoHotkey'; import { persistQueueVisibility, readInitialQueueVisibility, } from './queueVisibilityStorage'; // Re-export so MainApp + the 3 playerStore characterization tests keep // their existing `from './playerStore'` imports. export { initAudioListeners }; // Re-export so bootstrap.ts + bootstrap.test keep their existing // `from './playerStore'` imports. export { installQueueUndoHotkey }; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. export { flushPlayQueuePosition }; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, // TauriEventBridge, plus the progress characterization test) keep their // `from './playerStore'` imports working. export { getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot, }; import { _resetQueueUndoStacksForTest, consumePendingQueueListScrollTop, popQueueRedoSnapshot, popQueueUndoSnapshot, pushQueueRedoSnapshot, pushQueueUndoFromGetter, pushQueueUndoSnapshot, queueUndoSnapshotFromState, registerQueueListScrollTopReader, setPendingQueueListScrollTop, type QueueUndoSnapshot, } from './queueUndo'; // Re-export for backward compatibility with the ~30 call sites that still // import these helpers from playerStore. Phase E (store splits) will migrate // the imports to '../utils/*' directly and drop these re-exports. export { resolveReplayGainDb, shuffleArray, songToTrack }; // Re-export the queue-undo public API so existing callers (QueuePanel, // test/helpers/storeReset) keep their `from './playerStore'` imports. export { _resetQueueUndoStacksForTest, consumePendingQueueListScrollTop, registerQueueListScrollTopReader, }; import type { PlayerState, Track } from './playerStoreTypes'; export type { PlayerState, Track }; // ─── Module-level playback primitives ───────────────────────────────────────── // ─── Store ──────────────────────────────────────────────────────────────────── export const usePlayerStore = create()( persist( (set, get) => { function applyQueueHistorySnapshot(snap: QueueUndoSnapshot, prior: PlayerState): boolean { if (prior.currentRadio) { stopRadio(); } let nextQueue = shallowCloneQueueTracks(snap.queue); let nextIndex = snap.queueIndex; let nextTrack = snap.currentTrack ? { ...snap.currentTrack } : null; if (snap.currentTrack == null && prior.currentTrack) { const playing = prior.currentTrack; const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id)); if (pos === -1) { nextQueue = [{ ...playing }, ...nextQueue]; nextIndex = 0; nextTrack = { ...playing }; } else { nextTrack = { ...playing }; nextIndex = pos; } } nextIndex = Math.max(0, Math.min(nextIndex, Math.max(0, nextQueue.length - 1))); const keepPlaybackFromPrior = prior.currentTrack != null && nextTrack != null && sameQueueTrackId(prior.currentTrack.id, nextTrack.id) && nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id)) && ( (snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id)) || snap.currentTrack == null ); if (keepPlaybackFromPrior) { const playingKeep = prior.currentTrack; if (playingKeep) { const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id)); if (idxPrior >= 0) { nextIndex = idxPrior; nextTrack = { ...playingKeep }; } } } let tRestoreRaw = typeof snap.currentTime === 'number' && Number.isFinite(snap.currentTime) ? snap.currentTime : 0; let playingRestore = snap.isPlaying !== false; if (keepPlaybackFromPrior && prior.currentTrack) { tRestoreRaw = prior.currentTime; playingRestore = prior.isPlaying; } const durForProgress = nextTrack?.duration && nextTrack.duration > 0 ? nextTrack.duration : null; let pRestore = typeof snap.progress === 'number' && Number.isFinite(snap.progress) ? snap.progress : (durForProgress != null && durForProgress > 0 ? Math.max(0, Math.min(1, tRestoreRaw / durForProgress)) : 0); if (keepPlaybackFromPrior) { pRestore = prior.progress; } const tRestore = durForProgress != null ? Math.max(0, Math.min(tRestoreRaw, durForProgress)) : Math.max(0, tRestoreRaw); const keepWaveform = prior.currentTrack?.id != null && nextTrack?.id != null && sameQueueTrackId(prior.currentTrack.id, nextTrack.id); const norm = nextTrack != null ? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex) : ({ normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', } as Pick< PlayerState, 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' >); const authSnap = useAuthStore.getState(); const playbackSourceUndo = nextTrack ? getPlaybackSourceKind(nextTrack.id, authSnap.activeServerId ?? '', null) : null; const playbackSourceFinal = keepPlaybackFromPrior && prior.currentPlaybackSource != null ? prior.currentPlaybackSource : playbackSourceUndo; clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null, }); clearPreloadingIds(); let gen = getPlayGeneration(); const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior; if (resyncEngine || !nextTrack) { gen = bumpPlayGeneration(); if (resyncEngine) { setIsAudioPaused(false); } } set({ queue: nextQueue, queueIndex: nextIndex, currentTrack: nextTrack, currentRadio: null, currentTime: tRestore, progress: pRestore, isPlaying: playingRestore, waveformBins: keepWaveform ? prior.waveformBins : null, enginePreloadedTrackId: keepPlaybackFromPrior ? prior.enginePreloadedTrackId : null, currentPlaybackSource: playbackSourceFinal, ...norm, }); if (!nextTrack) { invoke('audio_stop').catch(console.error); setIsAudioPaused(false); syncQueueToServer(nextQueue, null, 0); if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); } return true; } void refreshWaveformForTrack(nextTrack.id); void refreshLoudnessForTrack(nextTrack.id); get().updateReplayGainForCurrentTrack(); if (!keepPlaybackFromPrior) { const { nowPlayingEnabled: npUndo } = useAuthStore.getState(); if (npUndo) reportNowPlaying(nextTrack.id); queueUndoRestoreAudioEngine({ generation: gen, track: nextTrack, queue: nextQueue, queueIndex: nextIndex, atSeconds: tRestore, wantPlaying: playingRestore, }); } if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); } syncQueueToServer(nextQueue, nextTrack, tRestore); return true; } return { currentTrack: null, waveformBins: null, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', normalizationDbgSource: null, normalizationDbgTrackId: null, normalizationDbgCacheGainDb: null, normalizationDbgCacheTargetLufs: null, normalizationDbgCacheUpdatedAt: null, normalizationDbgLastEventAt: null, currentRadio: null, currentPlaybackSource: null, enginePreloadedTrackId: null, queue: [], queueIndex: 0, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, volume: 0.8, scrobbled: false, lastfmLoved: false, lastfmLovedCache: {}, starredOverrides: {}, setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })), userRatingOverrides: {}, setUserRatingOverride: (id, rating) => set(s => { const nextOverrides = { ...s.userRatingOverrides }; if (rating === 0) delete nextOverrides[id]; else nextOverrides[id] = rating; return { userRatingOverrides: nextOverrides, queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)), currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack, }; }), isQueueVisible: readInitialQueueVisibility(), isFullscreenOpen: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null, repeatMode: 'off', contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null }, openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride) => set({ contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex, shareKindOverride }, }), closeContextMenu: () => set(state => ({ contextMenu: { ...state.contextMenu, isOpen: false }, })), songInfoModal: { isOpen: false, songId: null }, openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }), closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }), toggleQueue: () => set(state => { const next = !state.isQueueVisible; persistQueueVisibility(next); return { isQueueVisible: next }; }), setQueueVisible: (v: boolean) => { persistQueueVisibility(v); set({ isQueueVisible: v }); }, toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })), toggleLastfmLove: () => { const { currentTrack, lastfmLoved } = get(); const { lastfmSessionKey } = useAuthStore.getState(); if (!currentTrack || !lastfmSessionKey) return; const newLoved = !lastfmLoved; const cacheKey = `${currentTrack.title}::${currentTrack.artist}`; set(s => ({ lastfmLoved: newLoved, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: newLoved } })); if (newLoved) { lastfmLoveTrack(currentTrack, lastfmSessionKey); } else { lastfmUnloveTrack(currentTrack, lastfmSessionKey); } }, setLastfmLoved: (v) => { const { currentTrack } = get(); if (currentTrack) { const cacheKey = `${currentTrack.title}::${currentTrack.artist}`; set(s => ({ lastfmLoved: v, lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v } })); } else { set({ lastfmLoved: v }); } }, syncLastfmLovedTracks: async () => { const { lastfmSessionKey, lastfmUsername } = useAuthStore.getState(); if (!lastfmSessionKey || !lastfmUsername) return; const tracks = await lastfmGetAllLovedTracks(lastfmUsername, lastfmSessionKey); const newCache: Record = {}; for (const t of tracks) newCache[`${t.title}::${t.artist}`] = true; // Merge with existing cache (local likes take precedence) set(s => ({ lastfmLovedCache: { ...newCache, ...s.lastfmLovedCache } })); // Update current track's loved state if it's in the new cache const { currentTrack } = get(); if (currentTrack) { const loved = newCache[`${currentTrack.title}::${currentTrack.artist}`] ?? false; set({ lastfmLoved: loved }); } }, setLastfmLovedForSong: (title, artist, v) => { const cacheKey = `${title}::${artist}`; const isCurrentTrack = get().currentTrack?.title === title && get().currentTrack?.artist === artist; set(s => ({ lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: v }, ...(isCurrentTrack ? { lastfmLoved: v } : {}), })); }, toggleRepeat: () => set(state => { const modes = ['off', 'all', 'one'] as const; return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] }; }), // ── stop ──────────────────────────────────────────────────────────────── stop: () => { clearAllPlaybackScheduleTimers(); if (get().currentRadio) { stopRadio(); } else { invoke('audio_stop').catch(console.error); } setIsAudioPaused(false); clearSeekFallbackRetry(); clearSeekDebounce(); clearSeekTarget(); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null, waveformBins: null, normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', currentPlaybackSource: null, enginePreloadedTrackId: null, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null, }); }, // ── playRadio ──────────────────────────────────────────────────────────── playRadio: async (station) => { const { volume } = get(); bumpPlayGeneration(); clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); setIsAudioPaused(false); clearRadioReconnectTimer(); clearPreloadingIds(); clearSeekFallbackRetry(); clearSeekDebounce(); clearSeekTarget(); // Stop Rust engine in case a regular track was playing. invoke('audio_stop').catch(() => {}); // Resolve PLS/M3U playlist URLs to the actual stream URL before handing // to HTML5