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 { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } 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 { orbitBulkGuard } from '../utils/orbitBulkGuard'; import { useOrbitStore } from './orbitStore'; import { estimateLivePosition } from '../api/orbit'; 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; } 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 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 mixCandidates = shuffleArray( [...top, ...similar] .map(songToTrack) .filter(t => t.id !== seedId && !existingIds.has(t.id)), ) .slice(0, count) .map(t => ({ ...t, autoAdded: true as const })); if (mixCandidates.length > 0) return mixCandidates; const random = await getRandomSongs(count, seedTrack?.genre).catch(() => []); return random .map(songToTrack) .filter(t => t.id !== seedId && !existingIds.has(t.id)) .slice(0, count) .map(t => ({ ...t, autoAdded: true as const })); } interface PlayerState { currentTrack: Track | 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`. */ playTrack: (track: Track, queue?: Track[], manual?: boolean, _orbitConfirmed?: boolean) => 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; setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void; enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => 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; 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; }; 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) => void; closeContextMenu: () => void; songInfoModal: { isOpen: boolean; songId: string | null }; openSongInfo: (songId: string) => void; closeSongInfo: () => void; } // ─── 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; // 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; // 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; /** 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