refactor: extract player state into playerStore

Move playback state management into dedicated store:
- PlayingTrack interface
- isPlaying, currentTime, duration, volume, isFavorite
- Polling logic with setOnTrackEnded callback
- Playback controls (togglePlay, seek, setVolume)
This commit is contained in:
vicrodh committed 2026-01-10 04:48:31 -06:00
1 parent cef950b08b
commit 55ec44cdc2
2 files changed
+423 -123

No files matched your search

+338
View File
@@ -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<typeof setInterval> | null = null;
let isAdvancingTrack = false;
let isSkipping = false;
let queueEnded = false;
// Callbacks for track advancement (set by consumer)
let onTrackEnded: (() => Promise<void>) | 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<void> {
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<void> {
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<void> {
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<void> {
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>): void {
onTrackEnded = callback;
}
/**
* Poll playback state from backend
*/
async function pollPlaybackState(): Promise<void> {
if (!currentTrack) return;
try {
const state = await invoke<BackendPlaybackState>('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();
}
+85 -123
View File
@@ -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<UserInfo | null>(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<PlayingTrack | null>(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<QueueTrack[]>([]);
@@ -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<BackendQueueTrack | null>('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<BackendQueueTrack | null>('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<typeof setInterval> | 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<PlaybackState>('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<BackendQueueTrack | null>('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<BackendQueueTrack | null>('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();
};
});