import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; import i18n from '../i18n'; import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic'; import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; import { useHotCacheStore } from './hotCacheStore'; import { onAnalysisStorageChanged } from './analysisSync'; 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 { getPerfProbeFlags } from '../utils/perfFlags'; import { bumpPerfCounter } from '../utils/perfTelemetry'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; import { normalizeAnalysisTrackId, queuesStructuralEqual, sameQueueTrackId, shallowCloneQueueTracks, } from '../utils/queueIdentity'; import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse'; import { normalizationAlmostEqual } from '../utils/normalizationCompare'; import { isRecoverableSeekError } from '../utils/seekErrors'; import { emitPlaybackProgress, getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot, } from './playbackProgress'; import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl, shouldRebindPlaybackToHotCache, } from './playbackUrlRouting'; import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { emitNormalizationDebug } from './normalizationDebug'; import { isInOrbitSession } from './orbitSession'; import { clearLoudnessCacheStateForTrackId, forgetLoudnessGain, getCachedLoudnessGain, hasStableLoudness, isReplayGainActive, loudnessCacheStateKeysForTrackId, loudnessGainDbForEngineBind, markLoudnessStable, setCachedLoudnessGain, } from './loudnessGainCache'; import { clearAllPlaybackScheduleTimers, clearScheduledPauseTimers, clearScheduledResumeTimers, schedulePauseTimer, scheduleResumeTimer, } from './scheduleTimers'; // 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 { getWindowKind } from '../app/windowKind'; 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, }; const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible'; function readInitialQueueVisibility(): boolean { if (typeof window === 'undefined') return true; try { const raw = window.localStorage.getItem(QUEUE_VISIBILITY_STORAGE_KEY); if (raw === 'true') return true; if (raw === 'false') return false; } catch { // ignore storage access failures and fall back to default } return true; } function persistQueueVisibility(visible: boolean): void { if (typeof window === 'undefined') return; try { window.localStorage.setItem(QUEUE_VISIBILITY_STORAGE_KEY, String(visible)); } catch { // ignore storage access failures } } export interface Track { id: string; title: string; artist: string; album: string; albumId: string; artistId?: string; duration: number; coverArt?: string; track?: number; year?: number; bitRate?: number; suffix?: string; userRating?: number; replayGainTrackDb?: number; replayGainAlbumDb?: number; replayGainPeak?: number; starred?: string; genre?: string; samplingRate?: number; bitDepth?: number; /** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */ size?: number; autoAdded?: boolean; radioAdded?: boolean; /** Inserted via "Play Next". Used by the preserve-order toggle to find the * end of the current Play-Next streak. Stale flags behind queueIndex are * harmless — the streak scan only looks forward from queueIndex+1. */ playNextAdded?: boolean; } export interface PlayerState { currentTrack: Track | null; waveformBins: number[] | null; normalizationNowDb: number | null; normalizationTargetLufs: number | null; normalizationEngineLive: 'off' | 'replaygain' | 'loudness'; normalizationDbgSource: string | null; normalizationDbgTrackId: string | null; normalizationDbgCacheGainDb: number | null; normalizationDbgCacheTargetLufs: number | null; normalizationDbgCacheUpdatedAt: number | null; normalizationDbgLastEventAt: number | null; currentRadio: InternetRadioStation | null; /** Latches the source used to start the currently playing track. */ currentPlaybackSource: PlaybackSourceKind | null; /** * Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`). * Cleared after a successful `audio_play` consumed that preload, or when starting another track. */ enginePreloadedTrackId: string | null; queue: Track[]; queueIndex: number; isPlaying: boolean; progress: number; // 0–1 buffered: number; // 0–1 (unused in Rust backend, kept for UI compat) currentTime: number; volume: number; scrobbled: boolean; lastfmLoved: boolean; lastfmLovedCache: Record; starredOverrides: Record; setStarredOverride: (id: string, starred: boolean) => void; /** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */ userRatingOverrides: Record; setUserRatingOverride: (id: string, rating: number) => void; playRadio: (station: InternetRadioStation) => void; /** `_orbitConfirmed` is an internal bypass flag — callers outside the * orbit bulk-gate should leave it `undefined`. * `targetQueueIndex` lets callers that already know the exact target * position (next()/previous()/queue-row click) bypass the `findIndex` * by-id fallback, which otherwise resolves to the *first* occurrence * and breaks navigation when the same track appears multiple times in * the queue (issue #500). Ignored if out of range or if the track id * at that position doesn't match. */ playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean, targetQueueIndex?: number) => void; /** Queue becomes `[track]` only; if already on this track, does not restart `audio_play`. */ reseedQueueForInstantMix: (track: Track) => void; pause: () => void; resume: () => void; stop: () => void; togglePlay: () => void; /** Wall-clock ms when auto-pause fires, or null. */ scheduledPauseAtMs: number | null; /** Wall-clock ms when the current auto-pause timer was armed (for progress-ring totals). */ scheduledPauseStartMs: number | null; /** Wall-clock ms when auto-resume fires, or null. */ scheduledResumeAtMs: number | null; /** Wall-clock ms when the current auto-resume timer was armed (for progress-ring totals). */ scheduledResumeStartMs: number | null; schedulePauseIn: (seconds: number) => void; scheduleResumeIn: (seconds: number) => void; clearScheduledPause: () => void; clearScheduledResume: () => void; next: (manual?: boolean) => void; previous: () => void; seek: (progress: number) => void; setVolume: (v: number) => void; updateReplayGainForCurrentTrack: () => void; reanalyzeLoudnessForTrack: (trackId: string) => Promise; setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void; /** "Play Next" — inserts after the current track. When * `preservePlayNextOrder` is on, appends to the existing Play-Next streak * (Spotify-style); otherwise inserts directly after the current track and * pushes any earlier Play-Next items down (default). Falls back to * `playTrack` when nothing is currently playing. */ playNext: (tracks: Track[]) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void; setRadioArtistId: (artistId: string) => void; /** For Lucky Mix: drop upcoming tail; keep the currently playing item only. */ pruneUpcomingToCurrent: () => void; clearQueue: () => void; isQueueVisible: boolean; toggleQueue: () => void; setQueueVisible: (v: boolean) => void; isFullscreenOpen: boolean; toggleFullscreen: () => void; repeatMode: 'off' | 'all' | 'one'; toggleRepeat: () => void; reorderQueue: (startIndex: number, endIndex: number) => void; removeTrack: (index: number) => void; shuffleQueue: () => void; /** Shuffle only the tracks after the current one — leaves played history intact. */ shuffleUpcomingQueue: () => void; /** * Revert the last explicit queue edit (enqueue, reorder, remove, shuffle, manual * `playTrack`, …). Returns true if a snapshot was applied. Snapshots include queue, * current track, playback time, progress, and pause state. If the undone edit did * not change which song is current (reorder, enqueue, remove another row, …), only * the queue is restored and playback continues; otherwise the Rust engine is * resynced to the snapshot track/position. Does not cover `clearQueue` or automatic advances from * `next()` / gapless. * If the snapshot had no `currentTrack` but playback is active, the playing track * is kept: prepended when missing from the restored queue, otherwise re-bound by id. */ undoLastQueueEdit: () => boolean; /** Ctrl+Shift+Z / Cmd+Shift+Z — opposite of `undoLastQueueEdit` while redo stack is non-empty. */ redoLastQueueEdit: () => boolean; toggleLastfmLove: () => void; setLastfmLoved: (v: boolean) => void; setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; syncLastfmLovedTracks: () => Promise; resetAudioPause: () => void; initializeFromServerQueue: () => Promise; contextMenu: { isOpen: boolean; x: number; y: number; item: any; type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null; queueIndex?: number; playlistId?: string; playlistSongIndex?: number; /** Overrides the EntityShareKind for the "Share" action — used by Composers * list/grid to copy a `composer` link from the otherwise artist-typed * context menu, so paste lands on /composer/:id instead of /artist/:id. */ shareKindOverride?: 'track' | 'album' | 'artist' | 'composer'; }; openContextMenu: (x: number, y: number, item: any, type: 'song' | 'favorite-song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number, shareKindOverride?: 'track' | 'album' | 'artist' | 'composer') => void; closeContextMenu: () => void; songInfoModal: { isOpen: boolean; songId: string | null }; openSongInfo: (songId: string) => void; closeSongInfo: () => void; } type WaveformCachePayload = { /** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */ bins: number[] | Uint8Array; binCount: number; isPartial: boolean; knownUntilSec: number; durationSec: number; updatedAt: number; }; type LoudnessCachePayload = { integratedLufs: number; truePeak: number; recommendedGainDb: number; targetLufs: number; updatedAt: number; }; type NormalizationStatePayload = { engine: 'off' | 'replaygain' | 'loudness' | string; currentGainDb: number | null; targetLufs: number; }; // ─── Module-level playback primitives ───────────────────────────────────────── // isAudioPaused — true when the Rust audio engine has a loaded-but-paused track. // Used by resume() to decide between audio_resume (warm) vs audio_play (cold start). let isAudioPaused = false; // JS-side generation counter. Incremented on every playTrack() call. // The invoke().catch() error handler captures its own gen and bails if // playGeneration has moved on, preventing stale errors from skipping wrong tracks. let playGeneration = 0; // Guard against concurrent infinite-queue fetches. let infiniteQueueFetching = false; // Guard against concurrent radio top-up fetches. let radioFetching = false; // Artist ID used to start the current radio session — persists across track // advances so proactive loading works even when songs lack artistId. let currentRadioArtistId: string | null = null; // Track ids the current radio session has already enqueued — *including* // entries that were trimmed off the front of the queue when it grew too long // (`HISTORY_KEEP` in next()'s top-up path). Without this the queue's own // id-set wasn't enough to dedupe: a song played 8 tracks ago is gone from // the queue and the next Last.fm/topSongs response could re-add it. Reset // on `setRadioArtistId(other)` and on `clearQueue()`. Issue #500. let radioSessionSeenIds = new Set(); let lastNormalizationUiUpdateAtMs = 0; /** Reload Rust audio to match a queue-undo snapshot (Zustand alone does not move the engine). */ function queueUndoRestoreAudioEngine(opts: { generation: number; track: Track; queue: Track[]; queueIndex: number; atSeconds: number; wantPlaying: boolean; }): void { const { generation, track, queue, queueIndex, atSeconds, wantPlaying } = opts; const authState = useAuthStore.getState(); const vol = usePlayerStore.getState().volume; const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; const replayGainDb = resolveReplayGainDb( track, coldPrev, coldNext, isReplayGainActive(), authState.replayGainMode, ); 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', { url, volume: vol, durationHint: track.duration, replayGainDb, replayGainPeak, loudnessGainDb: loudnessGainDbForEngineBind(track.id), preGainDb: authState.replayGainPreGainDb, fallbackDb: authState.replayGainFallbackDb, manual: false, hiResEnabled: authState.enableHiRes, analysisTrackId: track.id, streamFormatSuffix: track.suffix ?? null, }) .then(() => { if (playGeneration !== generation) return; if (keepPreloadHint) { usePlayerStore.setState({ enginePreloadedTrackId: null }); } const dur = track.duration && track.duration > 0 ? track.duration : null; const seekTo = Math.max(0, atSeconds); const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05); const afterSeek = () => { if (playGeneration !== generation) return; if (!wantPlaying) { invoke('audio_pause').catch(console.error); isAudioPaused = true; usePlayerStore.setState({ isPlaying: false }); } else { isAudioPaused = false; } }; if (canSeek) { void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek); } else { afterSeek(); } }) .catch((err: unknown) => { if (playGeneration !== generation) return; console.error('[psysonic] queue-undo audio_play failed:', err); usePlayerStore.setState({ isPlaying: false }); }) .finally(() => { setDeferHotCachePrefetch(false); }); touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); } // Debounce timer for seek slider drags. let seekDebounce: ReturnType | null = null; // Target time of the last seek — blocks stale Rust progress ticks until the // engine has actually caught up to the new position. let seekTarget: number | null = null; let seekTargetSetAt = 0; const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000; const analysisBackfillInFlightByTrackId: Record = {}; const analysisBackfillAttemptsByTrackId: Record = {}; const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2; // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. let seekFallbackRetryTimer: ReturnType | null = null; let seekFallbackRetryStartedAt = 0; let seekFallbackRetryTarget: { trackId: string; seconds: number } | null = null; let seekFallbackTrackId: string | null = null; let seekFallbackRestartAt = 0; let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: number } | null = null; const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600; const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180; const SEEK_FALLBACK_RETRY_MAX_MS = 6000; const LIVE_PROGRESS_EMIT_MIN_MS = 1500; const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9; let lastLiveProgressEmitAt = 0; const STORE_PROGRESS_COMMIT_MIN_MS = 20_000; const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0; let lastStoreProgressCommitAt = 0; function setSeekTarget(seconds: number) { seekTarget = seconds; seekTargetSetAt = Date.now(); } function clearSeekTarget() { seekTarget = null; seekTargetSetAt = 0; } function clearSeekFallbackRetry() { if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = null; } seekFallbackRetryStartedAt = 0; seekFallbackRetryTarget = null; } function scheduleSeekFallbackRetry(trackId: string, seconds: number) { const now = Date.now(); if ( !seekFallbackRetryTarget || seekFallbackRetryTarget.trackId !== trackId || Math.abs(seekFallbackRetryTarget.seconds - seconds) > 0.25 ) { clearSeekFallbackRetry(); seekFallbackRetryStartedAt = now; seekFallbackRetryTarget = { trackId, seconds }; } else if (seekFallbackRetryStartedAt === 0) { seekFallbackRetryStartedAt = now; } if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = setTimeout(() => { seekFallbackRetryTimer = null; const target = seekFallbackRetryTarget; const s = usePlayerStore.getState(); if (!target || !s.currentTrack || s.currentTrack.id !== target.trackId) { clearSeekFallbackRetry(); return; } if (Date.now() - seekFallbackRetryStartedAt > SEEK_FALLBACK_RETRY_MAX_MS) { clearSeekFallbackRetry(); seekFallbackVisualTarget = null; return; } invoke('audio_seek', { seconds: target.seconds }).then(() => { setSeekTarget(target.seconds); seekFallbackVisualTarget = null; clearSeekFallbackRetry(); }).catch((err: unknown) => { const msg = String(err ?? ''); if (!isRecoverableSeekError(msg)) { console.error(err); seekFallbackVisualTarget = null; clearSeekFallbackRetry(); return; } scheduleSeekFallbackRetry(target.trackId, target.seconds); }); }, SEEK_FALLBACK_RETRY_INTERVAL_MS); } // Guard against rapid double-click play/pause sending two state transitions // to the Rust backend before it has finished the previous one. let togglePlayLock = false; /** * Skip → 1★: counts in `authStore.skipStarManualSkipCountsByKey` (persisted). * Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count; * threshold reached clears count and sets 1★ if still unrated. */ function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void { if (!manual || !skippedTrack) return; const id = skippedTrack.id; const adv = useAuthStore.getState().recordSkipStarManualAdvance(id); if (!adv?.crossedThreshold) return; const live = usePlayerStore.getState(); const fromQueue = live.queue.find(t => t.id === id); const cur = live.userRatingOverrides[id] ?? fromQueue?.userRating ?? skippedTrack.userRating ?? 0; if (cur >= 1) return; setRating(id, 1) .then(() => { usePlayerStore.setState(s => ({ queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)), currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack, userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 }, })); }) .catch(() => {}); } // ── HTML5 Radio Player ──────────────────────────────────────────────────────── // Internet radio streams are played via a native