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, SubsonicSong, getSong, getRandomSongs, 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 { enrichSongsForMixRatingFilter, getMixMinRatingsConfigFromAuth, passesMixMinRatings, } from '../utils/mixRatingFilter'; import { getPerfProbeFlags } from '../utils/perfFlags'; import { bumpPerfCounter } from '../utils/perfTelemetry'; 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 function songToTrack(song: SubsonicSong): Track { return { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, replayGainTrackDb: song.replayGain?.trackGain, replayGainAlbumDb: song.replayGain?.albumGain, replayGainPeak: song.replayGain?.trackPeak, starred: song.starred, genre: song.genre, samplingRate: song.samplingRate, bitDepth: song.bitDepth, size: song.size, }; } /** * Resolve the ReplayGain dB value for a track based on the configured mode. * In 'auto' mode, picks album-gain when an adjacent queue neighbour shares the * same albumId (i.e. the track is being played as part of an album), otherwise * track-gain. Falls back to track-gain when album-gain is missing. */ export function resolveReplayGainDb( track: Track, prevTrack: Track | null | undefined, nextTrack: Track | null | undefined, enabled: boolean, mode: 'track' | 'album' | 'auto', ): number | null { if (!enabled) return null; let useAlbum: boolean; if (mode === 'album') { useAlbum = true; } else if (mode === 'track') { useAlbum = false; } else { const albumId = track.albumId; useAlbum = !!albumId && ( prevTrack?.albumId === albumId || nextTrack?.albumId === albumId ); } const value = useAlbum ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb; return value ?? null; } export function shuffleArray(items: T[]): T[] { const arr = [...items]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * Infinite queue source strategy (Instant Mix-like): * 1) Prefer artist-driven candidates (Top + Similar) around the current track. * 2) Fallback to random songs when artist-driven fetches are empty. */ async function buildInfiniteQueueCandidates( seedTrack: Track | null, existingIds: Set, count = 5, ): Promise { const RANDOM_TOPUP_BATCH_SIZE = Math.max(10, count * 2); const RANDOM_TOPUP_MAX_BATCHES = 8; const artistId = seedTrack?.artistId?.trim() || null; const artistName = seedTrack?.artist?.trim() || null; const [similar, top] = await Promise.all([ artistId ? getSimilarSongs2(artistId).catch(() => []) : Promise.resolve([]), artistName ? getTopSongs(artistName).catch(() => []) : Promise.resolve([]), ]); const seedId = seedTrack?.id ?? null; const mixCfg = getMixMinRatingsConfigFromAuth(); const mixedSources = [...top, ...similar]; const filteredMixedSongs = mixCfg.enabled ? (await enrichSongsForMixRatingFilter(mixedSources, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg)) : mixedSources; const out: Track[] = shuffleArray( filteredMixedSongs .map(songToTrack) .filter(t => t.id !== seedId && !existingIds.has(t.id)), ) .slice(0, count) .map(t => ({ ...t, autoAdded: true as const })); const seenIds = new Set([...existingIds, ...out.map(t => t.id)]); for (let b = 0; out.length < count && b < RANDOM_TOPUP_MAX_BATCHES; b++) { const random = await getRandomSongs(RANDOM_TOPUP_BATCH_SIZE, seedTrack?.genre).catch(() => []); if (!random.length) break; const filteredRandomSongs = mixCfg.enabled ? (await enrichSongsForMixRatingFilter(random, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg)) : random; for (const track of shuffleArray(filteredRandomSongs.map(songToTrack))) { if (track.id === seedId || seenIds.has(track.id)) continue; out.push({ ...track, autoAdded: true as const }); seenIds.add(track.id); if (out.length >= count) break; } } return out.slice(0, count); } 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; }; /** v4: `500` peak + `500` mean-abs = `1000` bytes. Legacy single curve: `500` (treated as mean=max). */ function waveformBlobLenOk(len: number): boolean { return len === 500 || len === 1000; } /** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ function coerceWaveformBins(bins: unknown): number[] | null { if (bins == null) return null; let raw: number[] | null = null; if (Array.isArray(bins)) { if (bins.length === 0) return null; raw = bins.map(x => Number(x) & 255); } else if (bins instanceof Uint8Array) { if (bins.length === 0) return null; raw = Array.from(bins); } else if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') { const len = (bins as { length: number }).length; if (len === 0) return null; try { raw = Array.from(bins as ArrayLike).map(x => Number(x) & 255); } catch { return null; } } else { return null; } if (!waveformBlobLenOk(raw.length)) return null; return raw; } 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; /** True when the user is part of an Orbit session (any role, any phase * short of `idle` / `error` / `ended`). Used by `next()` and its async * fallback callbacks to suppress local queue-extension paths (radio * top-up, infinite-queue, queue-exhausted refill) — those would either * pop the orbitBulkGuard modal or silently inject tracks the host * didn't pick. The helper is also called inside in-flight `.then()` * callbacks so a fetch scheduled just before the user joined Orbit * doesn't fire a `playTrack` after the join. */ function isInOrbitSession(): boolean { const o = useOrbitStore.getState(); if (o.role !== 'host' && o.role !== 'guest') return false; return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting'; } // 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 cachedLoudnessGainByTrackId: Record = {}; let stableLoudnessGainByTrackId: Record = {}; let lastNormalizationUiUpdateAtMs = 0; /** Bounded stack of queue snapshots for Ctrl+Z / Cmd+Z undo. */ const QUEUE_UNDO_MAX = 32; type QueueUndoSnapshot = { queue: Track[]; queueIndex: number; currentTrack: Track | null; /** Seconds — captured with the snapshot (older entries may omit). */ currentTime?: number; progress?: number; isPlaying?: boolean; /** Main queue panel list `scrollTop` when the snapshot was taken. */ queueListScrollTop?: number; }; const queueUndoStack: QueueUndoSnapshot[] = []; const queueRedoStack: QueueUndoSnapshot[] = []; /** QueuePanel registers a reader so undo snapshots capture list scroll position. */ let queueListScrollTopReader: (() => number | undefined) | null = null; export function registerQueueListScrollTopReader(reader: (() => number | undefined) | null): void { queueListScrollTopReader = reader; } function readQueueListScrollTopForUndo(): number | undefined { return queueListScrollTopReader?.() ?? undefined; } /** Set in applyQueueHistorySnapshot; QueuePanel consumes in useLayoutEffect after commit. */ let pendingQueueListScrollTop: number | undefined; export function consumePendingQueueListScrollTop(): number | undefined { const v = pendingQueueListScrollTop; pendingQueueListScrollTop = undefined; return v; } function shallowCloneQueueTracks(queue: Track[]): Track[] { return queue.map(t => ({ ...t })); } function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot { const scrollTop = readQueueListScrollTopForUndo(); return { queue: shallowCloneQueueTracks(s.queue), queueIndex: s.queueIndex, currentTrack: s.currentTrack ? { ...s.currentTrack } : null, currentTime: s.currentTime, progress: s.progress, isPlaying: s.isPlaying, ...(scrollTop !== undefined ? { queueListScrollTop: scrollTop } : {}), }; } function pushQueueUndoFromGetter(get: () => PlayerState) { queueRedoStack.length = 0; queueUndoStack.push(queueUndoSnapshotFromState(get())); while (queueUndoStack.length > QUEUE_UNDO_MAX) queueUndoStack.shift(); } /** 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 ?? ''); } function emitNormalizationDebug(step: string, details?: Record) { if (useAuthStore.getState().loggingMode !== 'debug') return; void invoke('frontend_debug_log', { scope: 'normalization', message: JSON.stringify({ step, details }), }).catch(() => {}); } function normalizeAnalysisTrackId(trackId?: string | null): string | null { if (!trackId) return null; if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length); return trackId; } /** Compare track ids across `stream:` / bare Subsonic forms. */ function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean { if (a == null || b == null) return false; const na = normalizeAnalysisTrackId(a) ?? a; const nb = normalizeAnalysisTrackId(b) ?? b; return na === nb; } function queuesStructuralEqual(a: Track[], b: Track[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false; } return true; } function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean { if (a == null && b == null) return true; if (a == null || b == null) return false; return Math.abs(a - b) <= eps; } function deriveNormalizationSnapshot( track: Track, queue: Track[], queueIndex: number, ): Pick< PlayerState, 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' > { const auth = useAuthStore.getState(); const engine = auth.normalizationEngine; if (engine === 'loudness') { const target = auth.loudnessTargetLufs; return { // Clears stale UI until `audio:normalization-state` / refresh catches up. normalizationNowDb: null, normalizationTargetLufs: target, normalizationEngineLive: 'loudness', }; } if (engine === 'replaygain' && auth.replayGainEnabled) { const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode); const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb; return { normalizationNowDb: nowDb, normalizationTargetLufs: null, normalizationEngineLive: 'replaygain', }; } return { normalizationNowDb: null, normalizationTargetLufs: null, normalizationEngineLive: 'off', }; } // 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; export type PlaybackProgressSnapshot = { currentTime: number; progress: number; buffered: number; }; let playbackProgressSnapshot: PlaybackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0, }; const playbackProgressListeners = new Set<( next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot ) => void>(); function emitPlaybackProgress(next: PlaybackProgressSnapshot): void { const prev = playbackProgressSnapshot; if ( Math.abs(prev.currentTime - next.currentTime) < 0.005 && Math.abs(prev.progress - next.progress) < 0.0002 && Math.abs(prev.buffered - next.buffered) < 0.0002 ) { return; } playbackProgressSnapshot = next; playbackProgressListeners.forEach(cb => cb(next, prev)); } export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot { return playbackProgressSnapshot; } export function subscribePlaybackProgress( cb: (next: PlaybackProgressSnapshot, prev: PlaybackProgressSnapshot) => void, ): () => void { playbackProgressListeners.add(cb); return () => { playbackProgressListeners.delete(cb); }; } /** Deferred pause / resume — cleared on stop, new track, manual pause/resume. */ let scheduledPauseTimer: number | null = null; let scheduledResumeTimer: number | null = null; function clearScheduledPauseTimers() { if (scheduledPauseTimer != null) { window.clearTimeout(scheduledPauseTimer); scheduledPauseTimer = null; } } function clearScheduledResumeTimers() { if (scheduledResumeTimer != null) { window.clearTimeout(scheduledResumeTimer); scheduledResumeTimer = null; } } function clearAllPlaybackScheduleTimers() { clearScheduledPauseTimers(); clearScheduledResumeTimers(); } 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 isRecoverableSeekError(msg: string): boolean { return msg.includes('not seekable') || msg.includes('audio sink not ready') || msg.includes('audio seek busy') || msg.includes('audio seek timeout'); } 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