diff --git a/src/lib/stores/playerStore.ts b/src/lib/stores/playerStore.ts new file mode 100644 index 000000000..ad36aebb6 --- /dev/null +++ b/src/lib/stores/playerStore.ts @@ -0,0 +1,338 @@ +/** + * Player State Store + * + * Manages playback state including current track, play/pause, position, volume. + * Currently uses polling to sync with backend - designed to be replaced with + * Tauri events in the future. + */ + +import { invoke } from '@tauri-apps/api/core'; + +// ============ Types ============ + +export interface PlayingTrack { + id: number; + title: string; + artist: string; + album: string; + artwork: string; + duration: number; + quality: string; + bitDepth?: number; + samplingRate?: number; + isLocal?: boolean; +} + +interface BackendPlaybackState { + is_playing: boolean; + position: number; + duration: number; + track_id: number; + volume: number; +} + +// ============ State ============ + +let currentTrack: PlayingTrack | null = null; +let isPlaying = false; +let currentTime = 0; +let duration = 0; +let volume = 75; +let isFavorite = false; + +// Polling state +let pollInterval: ReturnType | null = null; +let isAdvancingTrack = false; +let isSkipping = false; +let queueEnded = false; + +// Callbacks for track advancement (set by consumer) +let onTrackEnded: (() => Promise) | null = null; + +// Listeners +const listeners = new Set<() => void>(); + +function notifyListeners(): void { + for (const listener of listeners) { + listener(); + } +} + +/** + * Subscribe to player state changes + */ +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + listener(); // Immediately notify with current state + return () => listeners.delete(listener); +} + +// ============ Getters ============ + +export function getCurrentTrack(): PlayingTrack | null { + return currentTrack; +} + +export function getIsPlaying(): boolean { + return isPlaying; +} + +export function getCurrentTime(): number { + return currentTime; +} + +export function getDuration(): number { + return duration; +} + +export function getVolume(): number { + return volume; +} + +export function getIsFavorite(): boolean { + return isFavorite; +} + +export function getIsSkipping(): boolean { + return isSkipping; +} + +// ============ State Setter ============ + +export interface PlayerState { + currentTrack: PlayingTrack | null; + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + isFavorite: boolean; + isSkipping: boolean; +} + +export function getPlayerState(): PlayerState { + return { + currentTrack, + isPlaying, + currentTime, + duration, + volume, + isFavorite, + isSkipping + }; +} + +// ============ Track Actions ============ + +/** + * Set the current track (called when starting playback) + */ +export function setCurrentTrack(track: PlayingTrack | null): void { + currentTrack = track; + if (track) { + duration = track.duration; + currentTime = 0; + queueEnded = false; + } else { + duration = 0; + currentTime = 0; + } + notifyListeners(); +} + +/** + * Set favorite status + */ +export function setIsFavorite(favorite: boolean): void { + isFavorite = favorite; + notifyListeners(); +} + +/** + * Set skipping state (prevents concurrent skip operations) + */ +export function setIsSkipping(skipping: boolean): void { + isSkipping = skipping; + notifyListeners(); +} + +/** + * Mark queue as ended (prevents spam when no more tracks) + */ +export function setQueueEnded(ended: boolean): void { + queueEnded = ended; +} + +// ============ Playback Controls ============ + +/** + * Toggle play/pause + */ +export async function togglePlay(): Promise { + if (!currentTrack) return; + + const newIsPlaying = !isPlaying; + isPlaying = newIsPlaying; + notifyListeners(); + + try { + if (newIsPlaying) { + await invoke('resume_playback'); + } else { + await invoke('pause_playback'); + } + } catch (err) { + console.error('Failed to toggle playback:', err); + // Revert on error + isPlaying = !newIsPlaying; + notifyListeners(); + } +} + +/** + * Set playing state directly + */ +export function setIsPlaying(playing: boolean): void { + isPlaying = playing; + notifyListeners(); +} + +/** + * Seek to position + */ +export async function seek(position: number): Promise { + const clampedPosition = Math.max(0, Math.min(duration, position)); + currentTime = clampedPosition; + notifyListeners(); + + try { + await invoke('seek', { position: Math.floor(clampedPosition) }); + } catch (err) { + console.error('Failed to seek:', err); + } +} + +/** + * Set volume (0-100) + */ +export async function setVolume(newVolume: number): Promise { + const clampedVolume = Math.max(0, Math.min(100, newVolume)); + volume = clampedVolume; + notifyListeners(); + + try { + await invoke('set_volume', { volume: clampedVolume / 100 }); + } catch (err) { + console.error('Failed to set volume:', err); + } +} + +/** + * Stop playback + */ +export async function stop(): Promise { + try { + await invoke('stop_playback'); + isPlaying = false; + currentTrack = null; + currentTime = 0; + duration = 0; + notifyListeners(); + } catch (err) { + console.error('Failed to stop playback:', err); + } +} + +// ============ Polling ============ + +/** + * Set callback for when track ends (for auto-advance) + */ +export function setOnTrackEnded(callback: () => Promise): void { + onTrackEnded = callback; +} + +/** + * Poll playback state from backend + */ +async function pollPlaybackState(): Promise { + if (!currentTrack) return; + + try { + const state = await invoke('get_playback_state'); + + // Only update if we have a matching track + if (state.track_id === currentTrack.id) { + currentTime = state.position; + isPlaying = state.is_playing; + notifyListeners(); + + // Check if track ended - auto-advance to next + if ( + state.duration > 0 && + state.position >= state.duration - 1 && + !state.is_playing && + !isAdvancingTrack && + !queueEnded && + onTrackEnded + ) { + console.log('Track finished, advancing to next...'); + isAdvancingTrack = true; + + try { + await onTrackEnded(); + } catch (err) { + console.error('Failed to auto-advance:', err); + } finally { + isAdvancingTrack = false; + } + } + } + } catch (err) { + console.error('Failed to poll playback state:', err); + } +} + +/** + * Start polling for playback state + */ +export function startPolling(): void { + if (pollInterval) return; + + pollInterval = setInterval(pollPlaybackState, 500); + pollPlaybackState(); // Poll immediately +} + +/** + * Stop polling + */ +export function stopPolling(): void { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} + +/** + * Check if polling is active + */ +export function isPollingActive(): boolean { + return pollInterval !== null; +} + +// ============ Cleanup ============ + +/** + * Reset all state (for logout) + */ +export function reset(): void { + stopPolling(); + currentTrack = null; + isPlaying = false; + currentTime = 0; + duration = 0; + isFavorite = false; + isAdvancingTrack = false; + isSkipping = false; + queueEnded = false; + notifyListeners(); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fd3148e30..b6d794dd3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -65,6 +65,26 @@ type NavigationState } from '$lib/stores/navigationStore'; + // Player state management + import { + subscribe as subscribePlayer, + setCurrentTrack, + setIsPlaying, + setIsFavorite, + setIsSkipping, + setQueueEnded, + setOnTrackEnded, + togglePlay, + seek as playerSeek, + setVolume as playerSetVolume, + startPolling, + stopPolling, + reset as resetPlayer, + getPlayerState, + type PlayingTrack, + type PlayerState + } from '$lib/stores/playerStore'; + // Components import Sidebar from '$lib/components/Sidebar.svelte'; import NowPlayingBar from '$lib/components/NowPlayingBar.svelte'; @@ -228,18 +248,6 @@ total_tracks: number; } - interface PlayingTrack { - id: number; - title: string; - artist: string; - album: string; - artwork: string; - duration: number; - quality: string; - bitDepth?: number; - samplingRate?: number; - } - // Auth State (from authStore subscription) let isLoggedIn = $state(false); let userInfo = $state(null); @@ -266,15 +274,17 @@ // Sidebar reference for refreshing playlists let sidebarRef: { getPlaylists: () => { id: number; name: string; tracks_count: number }[], refreshPlaylists: () => void } | undefined; - // Playback State + // Playback State (from playerStore subscription) let currentTrack = $state(null); let isPlaying = $state(false); let currentTime = $state(0); let duration = $state(0); let volume = $state(75); + let isFavorite = $state(false); + + // Queue/Shuffle State (will move to queueStore) let isShuffle = $state(false); let repeatMode = $state<'off' | 'all' | 'one'>('off'); - let isFavorite = $state(false); // Queue State (synced from backend) let queue = $state([]); @@ -539,7 +549,7 @@ ? `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz` : 'CD Quality'; - currentTrack = { + const newTrack: PlayingTrack = { id: track.id, title: track.title, artist: track.performer?.name || 'Unknown Artist', @@ -550,9 +560,7 @@ bitDepth: track.maximum_bit_depth, samplingRate: track.maximum_sampling_rate }; - - duration = track.duration; - currentTime = 0; + setCurrentTrack(newTrack); // Try to play the track try { @@ -560,7 +568,7 @@ showToast(`Loading: ${track.title}`, 'info'); await invoke('play_track', { trackId: track.id }); console.log('play_track invoke succeeded'); - isPlaying = true; + setIsPlaying(true); showToast(`Playing: ${track.title}`, 'success'); // Update MPRIS metadata for system media controls @@ -581,11 +589,11 @@ ); // Check if track is favorite - isFavorite = await checkTrackFavorite(track.id); + setIsFavorite(await checkTrackFavorite(track.id)); } catch (err) { console.error('Failed to play track:', err); showToast(`Playback error: ${err}`, 'error'); - isPlaying = false; + setIsPlaying(false); } } @@ -599,7 +607,7 @@ ? `${track.bitDepth}bit/${track.samplingRate}kHz` : 'CD Quality'; - currentTrack = { + const newTrack: PlayingTrack = { id: track.id, title: track.title, artist: track.artist || selectedAlbum?.artist || 'Unknown Artist', @@ -608,9 +616,7 @@ duration: track.durationSeconds, quality }; - - duration = track.durationSeconds; - currentTime = 0; + setCurrentTrack(newTrack); // Build queue from album tracks if (selectedAlbum?.tracks) { @@ -646,7 +652,7 @@ showToast(`Loading: ${track.title}`, 'info'); await invoke('play_track', { trackId: track.id }); console.log('play_track invoke succeeded'); - isPlaying = true; + setIsPlaying(true); showToast(`Playing: ${track.title}`, 'success'); // Update MPRIS metadata for system media controls @@ -677,30 +683,17 @@ } catch (err) { console.error('Failed to play track:', err); showToast(`Playback error: ${err}`, 'error'); - isPlaying = false; - } - } - - function togglePlay() { - if (!currentTrack) return; - isPlaying = !isPlaying; - - // TODO: invoke pause/resume commands - if (isPlaying) { - invoke('resume_playback').catch(console.error); - } else { - invoke('pause_playback').catch(console.error); + setIsPlaying(false); } } + // Playback controls (delegating to playerStore) function handleSeek(time: number) { - currentTime = Math.max(0, Math.min(duration, time)); - invoke('seek', { position: time }).catch(console.error); + playerSeek(time); } function handleVolumeChange(newVolume: number) { - volume = Math.max(0, Math.min(100, newVolume)); - invoke('set_volume', { volume: newVolume / 100 }).catch(console.error); + playerSetVolume(newVolume); } async function toggleShuffle() { @@ -757,7 +750,7 @@ await invoke('remove_favorite', { favType: 'track', itemId: trackId }); showToast('Removed from favorites', 'success'); } - isFavorite = newFavoriteState; + setIsFavorite(newFavoriteState); } catch (err) { console.error('Failed to toggle favorite:', err); showToast('Failed to update favorites', 'error'); @@ -766,14 +759,15 @@ // Skip track handlers - wired to backend queue async function handleSkipBack() { - if (!currentTrack || isSkipping) return; + const playerState = getPlayerState(); + if (!playerState.currentTrack || playerState.isSkipping) return; // If more than 3 seconds in, restart track; otherwise go to previous - if (currentTime > 3) { + if (playerState.currentTime > 3) { handleSeek(0); return; } - isSkipping = true; + setIsSkipping(true); try { const prevTrack = await invoke('previous_track'); if (prevTrack) { @@ -786,14 +780,15 @@ console.error('Failed to go to previous track:', err); showToast('Failed to go to previous track', 'error'); } finally { - isSkipping = false; + setIsSkipping(false); } } async function handleSkipForward() { - if (!currentTrack || isSkipping) return; + const playerState = getPlayerState(); + if (!playerState.currentTrack || playerState.isSkipping) return; - isSkipping = true; + setIsSkipping(true); try { const nextTrackResult = await invoke('next_track'); if (nextTrackResult) { @@ -801,14 +796,14 @@ } else { // No next track - stop playback await invoke('stop_playback'); - isPlaying = false; + setIsPlaying(false); showToast('Queue ended', 'info'); } } catch (err) { console.error('Failed to go to next track:', err); showToast('Failed to go to next track', 'error'); } finally { - isSkipping = false; + setIsSkipping(false); } } @@ -817,20 +812,19 @@ const isLocalTrack = localTrackIds.has(track.id); // Reset queue ended flag when playing a new track - queueEnded = false; + setQueueEnded(false); - currentTrack = { + const newTrack: PlayingTrack = { id: track.id, title: track.title, artist: track.artist, album: track.album, artwork: track.artwork_url || '', duration: track.duration_secs, - quality: isLocalTrack ? 'Local' : 'Hi-Res' + quality: isLocalTrack ? 'Local' : 'Hi-Res', + isLocal: isLocalTrack }; - - duration = track.duration_secs; - currentTime = 0; + setCurrentTrack(newTrack); try { // Use appropriate playback command based on track source @@ -839,7 +833,7 @@ } else { await invoke('play_track', { trackId: track.id }); } - isPlaying = true; + setIsPlaying(true); // Update MPRIS await invoke('set_media_metadata', { @@ -858,9 +852,9 @@ // Check if track is favorite (for Qobuz tracks only) if (!isLocalTrack) { - isFavorite = await checkTrackFavorite(track.id); + setIsFavorite(await checkTrackFavorite(track.id)); } else { - isFavorite = false; + setIsFavorite(false); } // Refresh queue state @@ -868,7 +862,7 @@ } catch (err) { console.error('Failed to play queue track:', err); showToast(`Playback error: ${err}`, 'error'); - isPlaying = false; + setIsPlaying(false); } } @@ -1410,72 +1404,17 @@ } } - // Playback state polling - sync with backend every 500ms - interface PlaybackState { - is_playing: boolean; - position: number; - duration: number; - track_id: number; - volume: number; - } - - let pollInterval: ReturnType | null = null; - - let isAdvancingTrack = false; // Prevent multiple advances - let isSkipping = false; // Prevent concurrent skip operations - let queueEnded = false; // Prevent spam when queue has no more tracks - - async function pollPlaybackState() { - if (!currentTrack) return; - - try { - const state = await invoke('get_playback_state'); - - // Only update if we have a matching track - if (state.track_id === currentTrack.id) { - currentTime = state.position; - isPlaying = state.is_playing; - - // Check if track ended - auto-advance to next - // Don't try if queue already ended or we're already advancing - if (state.duration > 0 && state.position >= state.duration - 1 && !state.is_playing && !isAdvancingTrack && !queueEnded) { - console.log('Track finished, advancing to next...'); - isAdvancingTrack = true; - - try { - const nextTrackResult = await invoke('next_track'); - if (nextTrackResult) { - await playQueueTrack(nextTrackResult); - } else { - // Queue ended - set flag to prevent further attempts - console.log('Queue ended'); - queueEnded = true; - } - } catch (err) { - console.error('Failed to auto-advance:', err); - } finally { - isAdvancingTrack = false; - } - } - } - } catch (err) { - console.error('Failed to poll playback state:', err); - } - } - + // Playback state polling - managed by playerStore + // Start/stop polling based on whether there's a current track $effect(() => { if (currentTrack) { - // Start polling when we have a track - pollInterval = setInterval(pollPlaybackState, 500); - // Also poll immediately - pollPlaybackState(); - } else if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; + startPolling(); + } else { + stopPolling(); } return () => { - if (pollInterval) clearInterval(pollInterval); + stopPolling(); }; }); @@ -1542,6 +1481,27 @@ selectedPlaylistId = navState.selectedPlaylistId; }); + // Subscribe to player state changes + const unsubscribePlayer = subscribePlayer(() => { + const playerState = getPlayerState(); + currentTrack = playerState.currentTrack; + isPlaying = playerState.isPlaying; + currentTime = playerState.currentTime; + duration = playerState.duration; + volume = playerState.volume; + isFavorite = playerState.isFavorite; + }); + + // Set up track ended callback for auto-advance + setOnTrackEnded(async () => { + const nextTrackResult = await invoke('next_track'); + if (nextTrackResult) { + await playQueueTrack(nextTrackResult); + } else { + setQueueEnded(true); + } + }); + // Restore Last.fm session on app startup (async () => { try { @@ -1576,6 +1536,8 @@ unsubscribeUI(); unsubscribeAuth(); unsubscribeNav(); + unsubscribePlayer(); + stopPolling(); }; });