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 { 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 { 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, getCachedLoudnessGain, hasStableLoudness, isReplayGainActive, loudnessCacheStateKeysForTrackId, loudnessGainDbForEngineBind, setCachedLoudnessGain, } from './loudnessGainCache'; import { clearAllPlaybackScheduleTimers, clearScheduledPauseTimers, clearScheduledResumeTimers, schedulePauseTimer, scheduleResumeTimer, } from './scheduleTimers'; import { invokeAudioSetNormalizationDeduped, invokeAudioUpdateReplayGainDeduped, } from './normalizationIpcDedupe'; import { bumpWaveformRefreshGen } from './waveformRefreshGen'; import { touchHotCacheOnPlayback } from './hotCacheTouch'; import { applySkipStarOnManualNext } from './skipStarRating'; import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow'; import { flushPlayQueuePosition, flushQueueSyncToServer, getLastQueueHeartbeatAt, syncQueueToServer, } from './queueSync'; import { clearPreloadingIds, getBytePreloadingId, getGaplessPreloadingId, getLastGaplessSwitchTime, markGaplessSwitch, setBytePreloadingId, setGaplessPreloadingId, } from './gaplessPreloadState'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { SEEK_TARGET_GUARD_TIMEOUT_MS, clearSeekTarget, getSeekTarget, getSeekTargetSetAt, 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 { LIVE_PROGRESS_EMIT_MIN_DELTA_SEC, LIVE_PROGRESS_EMIT_MIN_MS, NORMALIZATION_UI_THROTTLE_MS, STORE_PROGRESS_COMMIT_MIN_DELTA_SEC, STORE_PROGRESS_COMMIT_MIN_MS, getLastLiveProgressEmitAt, getLastNormalizationUiUpdateAtMs, getLastStoreProgressCommitAt, markLiveProgressEmit, markNormalizationUiUpdate, markStoreProgressCommit, resetProgressEmitThrottles, } from './playbackThrottles'; import { SEEK_FALLBACK_VISUAL_GUARD_MS, clearSeekFallbackRetry, getSeekFallbackRestartAt, getSeekFallbackTrackId, getSeekFallbackVisualTarget, scheduleSeekFallbackRetry, setSeekFallbackRestartAt, setSeekFallbackTrackId, setSeekFallbackVisualTarget, } from './seekFallbackState'; import { armSeekDebounce, clearSeekDebounce, isSeekDebouncePending, } 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'; // 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 { 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 NormalizationStatePayload = { engine: 'off' | 'replaygain' | 'loudness' | string; currentGainDb: number | null; targetLufs: number; }; // ─── Module-level playback primitives ───────────────────────────────────────── /** 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 (getPlayGeneration() !== 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 (getPlayGeneration() !== generation) return; if (!wantPlaying) { invoke('audio_pause').catch(console.error); setIsAudioPaused(true); usePlayerStore.setState({ isPlaying: false }); } else { setIsAudioPaused(false); } }; if (canSeek) { void invoke('audio_seek', { seconds: seekTo }).then(afterSeek).catch(afterSeek); } else { afterSeek(); } }) .catch((err: unknown) => { if (getPlayGeneration() !== generation) return; console.error('[psysonic] queue-undo audio_play failed:', err); usePlayerStore.setState({ isPlaying: false }); }) .finally(() => { setDeferHotCachePrefetch(false); }); touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); } // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. function prefetchLoudnessForEnqueuedTracks( mergedQueue: Track[], queueIndex: number, ) { if (useAuthStore.getState().normalizationEngine !== 'loudness') return; const currentTrack = usePlayerStore.getState().currentTrack; const ids = collectLoudnessBackfillWindowTrackIds(mergedQueue, queueIndex, currentTrack); for (const id of ids) { void refreshLoudnessForTrack(id, { syncPlayingEngine: false }); } } // ─── Audio event handlers (called from initAudioListeners) ─────────────────── function handleAudioPlaying(_duration: number) { setDeferHotCachePrefetch(false); resetProgressEmitThrottles(); usePlayerStore.setState({ isPlaying: true }); } function handleAudioProgress(current_time: number, duration: number) { 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; // 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 = 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; 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, buffered: 0, }); 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.queue, 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()); 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 based on preload mode and crossfade. const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled, crossfadeEnabled, crossfadeSecs, } = useAuthStore.getState(); const remaining = dur - current_time; // Gapless chain: always triggers at 30s regardless of preloadMode. const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0; // Byte pre-download: skip when Hot Cache is active (it already handles buffering). // Even with preload mode OFF, crossfade needs the next track bytes ready before // we enter the fade window to avoid a hard gap after track boundary. const shouldBytePreloadFromMode = preloadMode !== 'off' && ( preloadMode === 'early' ? current_time >= 5 : preloadMode === 'custom' ? remaining < preloadCustomSeconds && remaining > 0 : remaining < 30 && remaining > 0 // balanced (default) ); const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6)); const shouldBytePreloadForCrossfade = !gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0; const shouldBytePreload = !hotCacheEnabled && ( shouldBytePreloadFromMode || shouldBytePreloadForCrossfade ); if (shouldChainGapless || shouldBytePreload || gaplessEnabled) { const { queue, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; const nextTrack = repeatMode === 'one' ? track : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : 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 = useAuthStore.getState().activeServerId ?? ''; const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); // Byte pre-download — runs early so bytes are cached by chain time. if ((shouldBytePreload || 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, shouldBytePreload, shouldBytePreloadForGaplessBackup, remaining, gaplessEnabled, }); } invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration, analysisTrackId: nextTrack.id, }).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, // queue[nextIdx+1] on its right. const nextNeighbour = nextIdx + 1 < queue.length ? queue[nextIdx + 1] : (repeatMode === 'all' && queue.length > 0 ? queue[0] : 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, }).catch(() => {}); } } } function handleAudioEnded() { // If a gapless switch happened recently, this ended event is stale — the // progress task fired it for the OLD source before seeing the chained one. if (Date.now() - getLastGaplessSwitchTime() < 600) { return; } // 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, queue, queueIndex } = usePlayerStore.getState(); setIsAudioPaused(false); usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0, }); setTimeout(() => { 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, ); } // Pin to the current slot — the track may appear elsewhere in the queue. usePlayerStore.getState().playTrack(currentTrack, queue, 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!). */ function handleAudioTrackSwitched(duration: number) { 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 { queue, 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 < queue.length) { nextTrack = queue[nextIdx]; newIndex = nextIdx; } else if (repeatMode === 'all' && queue.length > 0) { nextTrack = queue[0]; newIndex = 0; } 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, ...deriveNormalizationSnapshot(nextTrack, queue, newIndex), normalizationDbgSource: 'track-switched', normalizationDbgTrackId: nextTrack.id, queueIndex: newIndex, isPlaying: true, 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); 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(queue, nextTrack, 0); touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? ''); } function handleAudioError(message: string) { 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 }); setTimeout(() => { if (getPlayGeneration() !== gen) return; usePlayerStore.getState().next(false); }, 1500); } /** * Set up Tauri event listeners for the Rust audio engine. * Returns a cleanup function — pass it to useEffect's return value so that * React StrictMode (which double-invokes effects in dev) tears down the first * set of listeners before creating the second, avoiding duplicate handlers. */ export function initAudioListeners(): () => void { // Dev-only: warn when audio:progress events arrive faster than 10/s. // This would indicate the Rust emit interval was accidentally lowered. let _devEventCount = 0; let _devWindowStart = 0; const pending = [ listen('audio:playing', ({ payload }) => handleAudioPlaying(payload)), listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => { if (import.meta.env.DEV) { _devEventCount++; const now = Date.now(); if (_devWindowStart === 0) _devWindowStart = now; if (now - _devWindowStart >= 1000) { if (_devEventCount > 10) { console.warn(`[psysonic] audio:progress: ${_devEventCount} events/s (threshold: 10) — check Rust emit interval`); } _devEventCount = 0; _devWindowStart = now; } } handleAudioProgress(payload.current_time, payload.duration); }), listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { const current = usePlayerStore.getState().currentTrack; if (!current || !payload) return; const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); if (payloadTrackId && payloadTrackId !== current.id) return; if (!Number.isFinite(payload.gainDb)) return; if (hasStableLoudness(current.id)) return; // Skip when the cached gain is already within ~0.05 dB of the new payload — // float jitter from the partial-loudness heuristic would otherwise re-trigger // updateReplayGainForCurrentTrack → audio_update_replay_gain → backend echo // every PARTIAL_LOUDNESS_EMIT_INTERVAL_MS even when nothing audibly changed. const existing = getCachedLoudnessGain(current.id); if (Number.isFinite(existing) && Math.abs(existing! - payload.gainDb) < 0.05) return; setCachedLoudnessGain(current.id, payload.gainDb); emitNormalizationDebug('partial-loudness:apply', { trackId: current.id, gainDb: payload.gainDb, targetLufs: payload.targetLufs, }); usePlayerStore.getState().updateReplayGainForCurrentTrack(); }), listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => { if (!payload?.trackId) return; const payloadTrackId = normalizeAnalysisTrackId(payload.trackId); if (!payloadTrackId) return; const currentRaw = usePlayerStore.getState().currentTrack?.id; const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null; if (currentId && payloadTrackId === currentId) { bumpWaveformRefreshGen(currentRaw!); void refreshWaveformForTrack(currentRaw!); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId }); return; } // Backfill finished for another id (e.g. next in queue): refresh loudness cache only // so the cached gain is ready before `audio_play` / gapless chain. void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false }); emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId }); }), listen('audio:normalization-state', ({ payload }) => { if (!payload) return; const engine = payload.engine === 'loudness' || payload.engine === 'replaygain' ? payload.engine : 'off'; const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null; const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null; const prev = usePlayerStore.getState(); // Avoid UI flicker from noisy duplicate emits and transient nulls. if ( engine === prev.normalizationEngineLive && normalizationAlmostEqual(nowDb, prev.normalizationNowDb) && normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02) ) { return; } if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) { return; } const nowMs = Date.now(); const isFirstNumericGain = engine === 'loudness' && nowDb != null && prev.normalizationNowDb == null; if ( !isFirstNumericGain && nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS && engine === prev.normalizationEngineLive ) { return; } markNormalizationUiUpdate(nowMs); emitNormalizationDebug('event:audio:normalization-state', { trackId: usePlayerStore.getState().currentTrack?.id ?? null, payload, }); usePlayerStore.setState({ normalizationEngineLive: engine, normalizationNowDb: nowDb, normalizationTargetLufs: targetLufs, normalizationDbgSource: 'event:audio:normalization-state', normalizationDbgLastEventAt: Date.now(), }); }), listen('audio:preload-ready', ({ payload }) => { const tid = streamUrlTrackId(payload); if (import.meta.env.DEV) { console.info('[psysonic][preload-ready]', { payload, parsedTrackId: tid, prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId, }); } if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid }); else if (import.meta.env.DEV) { console.warn('[psysonic][preload-ready] could not parse track id from payload URL'); } }), ]; // Sync Last.fm loved tracks cache on startup. usePlayerStore.getState().syncLastfmLovedTracks(); // Initial sync of audio settings to Rust engine on startup. const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState(); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {}); const normCfg = useAuthStore.getState(); usePlayerStore.setState({ normalizationEngineLive: normCfg.normalizationEngine, normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null, normalizationNowDb: null, normalizationDbgSource: 'init:set-normalization', }); emitNormalizationDebug('init:set-normalization', { engine: normCfg.normalizationEngine, targetLufs: normCfg.loudnessTargetLufs, currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null, }); invokeAudioSetNormalizationDeduped({ engine: normCfg.normalizationEngine, targetLufs: normCfg.loudnessTargetLufs, preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb( normCfg.loudnessPreAnalysisAttenuationDb, normCfg.loudnessTargetLufs, ), }); const bootTrackId = usePlayerStore.getState().currentTrack?.id; if (bootTrackId) { void refreshWaveformForTrack(bootTrackId); } if (normCfg.normalizationEngine === 'loudness') { const currentId = usePlayerStore.getState().currentTrack?.id; if (currentId) { void refreshLoudnessForTrack(currentId).finally(() => { usePlayerStore.getState().updateReplayGainForCurrentTrack(); }); } } if (audioOutputDevice) { invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {}); } // Keep audio settings in sync whenever auth store changes. let prevNormEngine = normCfg.normalizationEngine; let prevNormTarget = normCfg.loudnessTargetLufs; let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb; const unsubAuth = useAuthStore.subscribe((state) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, secs: state.crossfadeSecs, }).catch(() => {}); invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); const normChanged = state.normalizationEngine !== prevNormEngine || state.loudnessTargetLufs !== prevNormTarget || state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; if (!normChanged) return; const onlyPreAnalysisChanged = state.normalizationEngine === prevNormEngine && state.loudnessTargetLufs === prevNormTarget && state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis; const targetLufsChanged = state.normalizationEngine === 'loudness' && state.loudnessTargetLufs !== prevNormTarget; prevNormEngine = state.normalizationEngine; prevNormTarget = state.loudnessTargetLufs; prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb; usePlayerStore.setState({ normalizationEngineLive: state.normalizationEngine, normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null, normalizationNowDb: state.normalizationEngine === 'loudness' ? usePlayerStore.getState().normalizationNowDb : null, normalizationDbgSource: 'auth:normalization-changed', }); emitNormalizationDebug('auth:normalization-changed', { engine: state.normalizationEngine, targetLufs: state.loudnessTargetLufs, currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null, }); invokeAudioSetNormalizationDeduped({ engine: state.normalizationEngine, targetLufs: state.loudnessTargetLufs, preAnalysisAttenuationDb: effectiveLoudnessPreAnalysisAttenuationDb( state.loudnessPreAnalysisAttenuationDb, state.loudnessTargetLufs, ), }); if (state.normalizationEngine === 'loudness') { const currentId = usePlayerStore.getState().currentTrack?.id; if (onlyPreAnalysisChanged) { usePlayerStore.getState().updateReplayGainForCurrentTrack(); } else if (currentId) { if (targetLufsChanged) { clearLoudnessCacheStateForTrackId(currentId); } void refreshLoudnessForTrack(currentId).finally(() => { usePlayerStore.getState().updateReplayGainForCurrentTrack(); }); } } else { usePlayerStore.getState().updateReplayGainForCurrentTrack(); } }); const unsubAnalysisSync = onAnalysisStorageChanged(detail => { const currentId = usePlayerStore.getState().currentTrack?.id; if (!currentId) return; if (detail.trackId && detail.trackId !== currentId) return; bumpWaveformRefreshGen(currentId); void refreshWaveformForTrack(currentId); void refreshLoudnessForTrack(currentId); }); // ── MPRIS / OS media controls sync ─────────────────────────────────────── // Whenever the current track or playback state changes, push updates to the // Rust souvlaki MediaControls so the OS media overlay stays accurate. let prevTrackId: string | null = null; let prevRadioId: string | null = null; let prevIsPlaying: boolean | null = null; let lastMprisPositionUpdate = 0; const unsubMpris = usePlayerStore.subscribe((state) => { const { currentTrack, currentRadio, isPlaying } = state; // Update metadata when track changes if (currentTrack && currentTrack.id !== prevTrackId) { prevTrackId = currentTrack.id; prevRadioId = null; const coverUrl = currentTrack.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 512) : undefined; invoke('mpris_set_metadata', { title: currentTrack.title, artist: currentTrack.artist, album: currentTrack.album, coverUrl, durationSecs: currentTrack.duration, }).catch(() => {}); } // Update metadata when a radio station starts (initial push — station name as title). // ICY StreamTitle updates are forwarded by the radio:metadata listener below. if (currentRadio && currentRadio.id !== prevRadioId) { prevRadioId = currentRadio.id; prevTrackId = null; invoke('mpris_set_metadata', { title: currentRadio.name, artist: null, album: null, coverUrl: null, durationSecs: null, }).catch(() => {}); } // Update playback state on play/pause change (use live snapshot — persisted // store currentTime is intentionally coarse between commits). const playbackChanged = isPlaying !== prevIsPlaying; if (playbackChanged) { prevIsPlaying = isPlaying; lastMprisPositionUpdate = Date.now(); const pos = getPlaybackProgressSnapshot().currentTime; invoke('mpris_set_playback', { playing: isPlaying, positionSecs: pos > 0 ? pos : null, }).catch(() => {}); invoke('update_taskbar_icon', { isPlaying }).catch(() => {}); return; } }); const unsubMprisProgress = subscribePlaybackProgress(({ currentTime }) => { const { currentRadio, isPlaying } = usePlayerStore.getState(); if (currentRadio || !isPlaying) return; if (Date.now() - lastMprisPositionUpdate < 1500) return; lastMprisPositionUpdate = Date.now(); invoke('mpris_set_playback', { playing: true, positionSecs: currentTime, }).catch(() => {}); }); // ── Radio ICY StreamTitle → MPRIS ───────────────────────────────────────── // The Rust download task emits "radio:metadata" with { title, is_ad } every // time an ICY metadata block changes (typically every 8–32 KB of audio). // Forward each update to mpris_set_metadata so the OS now-playing overlay // stays in sync while the stream is live. const radioMetaUnlisten = listen<{ title: string; is_ad: boolean }>('radio:metadata', ({ payload }) => { const { currentRadio } = usePlayerStore.getState(); if (!currentRadio) return; // guard: only forward during active radio session if (payload.is_ad) return; // skip CDN-injected ad metadata // Parse "Artist - Title" convention used by most ICY streams. const sep = payload.title.indexOf(' - '); const artist = sep !== -1 ? payload.title.slice(0, sep).trim() : null; const title = sep !== -1 ? payload.title.slice(sep + 3).trim() : payload.title; invoke('mpris_set_metadata', { title: title || currentRadio.name, artist: artist || currentRadio.name, album: null, coverUrl: null, durationSecs: null, }).catch(() => {}); }); // ── Discord Rich Presence sync ──────────────────────────────────────────── // Updates on track change or play/pause toggle. No per-tick updates needed — // Discord auto-counts up the elapsed timer from the start_timestamp we set. let discordPrevTrackId: string | null = null; let discordPrevIsPlaying: boolean | null = null; let discordPrevFetchCovers: boolean | null = null; let discordPrevTemplateDetails: string | null = null; let discordPrevTemplateState: string | null = null; let discordPrevTemplateLargeText: string | null = null; let discordPrevCoverSource: string | null = null; const discordServerCoverCache = new Map(); function syncDiscord() { const { currentTrack, isPlaying } = usePlayerStore.getState(); const currentTime = getPlaybackProgressSnapshot().currentTime; const { discordRichPresence, discordCoverSource, discordTemplateDetails, discordTemplateState, discordTemplateLargeText, } = useAuthStore.getState(); if (!discordRichPresence || !currentTrack) { if (discordPrevTrackId !== null) { discordPrevTrackId = null; discordPrevIsPlaying = null; discordPrevFetchCovers = null; discordPrevCoverSource = null; discordPrevTemplateDetails = null; discordPrevTemplateState = null; discordPrevTemplateLargeText = null; invoke('discord_clear_presence').catch(() => {}); } return; } const trackChanged = currentTrack.id !== discordPrevTrackId; const playingChanged = isPlaying !== discordPrevIsPlaying; const coverSourceChanged = discordCoverSource !== discordPrevCoverSource; const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails; const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState; const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText; if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return; discordPrevTrackId = currentTrack.id; discordPrevIsPlaying = isPlaying; discordPrevFetchCovers = discordCoverSource === 'apple'; discordPrevCoverSource = discordCoverSource; discordPrevTemplateDetails = discordTemplateDetails; discordPrevTemplateState = discordTemplateState; discordPrevTemplateLargeText = discordTemplateLargeText; const sendPresence = (coverArtUrl: string | null) => { invoke('discord_update_presence', { title: currentTrack.title, artist: currentTrack.artist ?? 'Unknown Artist', album: currentTrack.album ?? null, isPlaying, elapsedSecs: isPlaying ? currentTime : null, coverArtUrl, fetchItunesCovers: discordCoverSource === 'apple', detailsTemplate: discordTemplateDetails, stateTemplate: discordTemplateState, largeTextTemplate: discordTemplateLargeText, }).catch(() => {}); }; if (discordCoverSource === 'server' && currentTrack.albumId) { const cached = discordServerCoverCache.get(currentTrack.albumId); if (cached !== undefined) { sendPresence(cached); } else { getAlbumInfo2(currentTrack.albumId).then(info => { const url = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl || null; discordServerCoverCache.set(currentTrack.albumId, url); sendPresence(url); }); } } else { sendPresence(null); } } const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord); const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord); return () => { unsubAuth(); unsubAnalysisSync(); unsubMpris(); unsubMprisProgress(); unsubDiscordPlayer(); unsubDiscordAuth(); pending.forEach(p => p.then(unlisten => unlisten())); radioMetaUnlisten.then(unlisten => unlisten()); }; } // ─── 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