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, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic'; import { resolvePlaybackUrl } 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'; 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; 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, }; } interface PlayerState { currentTrack: Track | null; currentRadio: InternetRadioStation | 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; playTrack: (track: Track, queue?: Track[], manual?: boolean) => void; pause: () => void; resume: () => void; stop: () => void; togglePlay: () => 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[]) => void; enqueueAt: (tracks: Track[], insertIndex: number) => void; enqueueRadio: (tracks: Track[], artistId?: string) => void; setRadioArtistId: (artistId: string) => 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; toggleLastfmLove: () => void; setLastfmLoved: (v: boolean) => void; setLastfmLovedForSong: (title: string, artist: string, v: boolean) => void; syncLastfmLovedTracks: () => Promise; initializeFromServerQueue: () => Promise; contextMenu: { isOpen: boolean; x: number; y: number; item: any; type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null; queueIndex?: number; }; openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: 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; // 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