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 } 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, } 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 { 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, 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 }; import { applyQueueHistorySnapshot } from './applyQueueHistorySnapshot'; // ─── Module-level playback primitives ───────────────────────────────────────── // ─── Store ──────────────────────────────────────────────────────────────────── export const usePlayerStore = create()( persist( (set, get) => { 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