feat: implement queue management system

- Add QueueManager in Rust backend with shuffle, repeat modes, and history
- Create queue Tauri commands (add, remove, next, previous, play_index, etc.)
- Wire Next/Previous buttons to backend queue
- Auto-advance to next track when current track ends
- Sync queue state with frontend QueuePanel
- Wire shuffle and repeat toggles to backend
- Add queue population when playing from album detail view
This commit is contained in:
vicrodh committed 2026-01-09 09:46:17 -06:00
1 parent 2399f34103
commit b5c853d945
5 files changed
+846 -34

No files matched your search

+2
View File
@@ -4,8 +4,10 @@
pub mod auth;
pub mod playback;
pub mod queue;
pub mod search;
pub use auth::*;
pub use playback::*;
pub use queue::*;
pub use search::*;
+122
View File
@@ -0,0 +1,122 @@
//! Queue management Tauri commands
use tauri::State;
use crate::queue::{QueueState, QueueTrack, RepeatMode};
use crate::AppState;
/// Add a track to the queue
#[tauri::command]
pub fn add_to_queue(track: QueueTrack, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: add_to_queue - {} by {}", track.title, track.artist);
state.queue.add_track(track);
Ok(())
}
/// Add multiple tracks to the queue
#[tauri::command]
pub fn add_tracks_to_queue(tracks: Vec<QueueTrack>, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: add_tracks_to_queue - {} tracks", tracks.len());
state.queue.add_tracks(tracks);
Ok(())
}
/// Set the entire queue (replaces existing)
#[tauri::command]
pub fn set_queue(tracks: Vec<QueueTrack>, start_index: Option<usize>, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: set_queue - {} tracks, start at {:?}", tracks.len(), start_index);
state.queue.set_queue(tracks, start_index);
Ok(())
}
/// Clear the queue
#[tauri::command]
pub fn clear_queue(state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: clear_queue");
state.queue.clear();
Ok(())
}
/// Remove a track from the queue by index
#[tauri::command]
pub fn remove_from_queue(index: usize, state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
log::info!("Command: remove_from_queue - index {}", index);
Ok(state.queue.remove_track(index))
}
/// Get current track in queue
#[tauri::command]
pub fn get_current_queue_track(state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
Ok(state.queue.current_track())
}
/// Get next track (for pre-loading/display)
#[tauri::command]
pub fn peek_next_track(state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
Ok(state.queue.peek_next())
}
/// Advance to next track and return it
#[tauri::command]
pub fn next_track(state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
log::info!("Command: next_track");
Ok(state.queue.next())
}
/// Go to previous track and return it
#[tauri::command]
pub fn previous_track(state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
log::info!("Command: previous_track");
Ok(state.queue.previous())
}
/// Jump to a specific track by index
#[tauri::command]
pub fn play_queue_index(index: usize, state: State<'_, AppState>) -> Result<Option<QueueTrack>, String> {
log::info!("Command: play_queue_index - {}", index);
Ok(state.queue.play_index(index))
}
/// Set shuffle mode
#[tauri::command]
pub fn set_shuffle(enabled: bool, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: set_shuffle - {}", enabled);
state.queue.set_shuffle(enabled);
Ok(())
}
/// Get shuffle status
#[tauri::command]
pub fn get_shuffle(state: State<'_, AppState>) -> Result<bool, String> {
Ok(state.queue.is_shuffle())
}
/// Set repeat mode
#[tauri::command]
pub fn set_repeat(mode: String, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: set_repeat - {}", mode);
let repeat_mode = match mode.to_lowercase().as_str() {
"all" => RepeatMode::All,
"one" => RepeatMode::One,
_ => RepeatMode::Off,
};
state.queue.set_repeat(repeat_mode);
Ok(())
}
/// Get repeat mode
#[tauri::command]
pub fn get_repeat(state: State<'_, AppState>) -> Result<String, String> {
let mode = state.queue.get_repeat();
Ok(match mode {
RepeatMode::Off => "off".to_string(),
RepeatMode::All => "all".to_string(),
RepeatMode::One => "one".to_string(),
})
}
/// Get full queue state for frontend
#[tauri::command]
pub fn get_queue_state(state: State<'_, AppState>) -> Result<QueueState, String> {
Ok(state.queue.get_state())
}
+19
View File
@@ -16,11 +16,13 @@ use tokio::sync::Mutex;
use api::QobuzClient;
use media_controls::{MediaControlsManager, TrackInfo};
use player::Player;
use queue::QueueManager;
/// Application state shared across commands
pub struct AppState {
pub client: Arc<Mutex<QobuzClient>>,
pub player: Player,
pub queue: QueueManager,
pub media_controls: MediaControlsManager,
}
@@ -29,6 +31,7 @@ impl AppState {
Self {
client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(),
queue: QueueManager::new(),
media_controls: MediaControlsManager::new(),
}
}
@@ -93,6 +96,22 @@ pub fn run() {
commands::seek,
commands::get_playback_state,
commands::set_media_metadata,
// Queue commands
commands::add_to_queue,
commands::add_tracks_to_queue,
commands::set_queue,
commands::clear_queue,
commands::remove_from_queue,
commands::get_current_queue_track,
commands::peek_next_track,
commands::next_track,
commands::previous_track,
commands::play_queue_index,
commands::set_shuffle,
commands::get_shuffle,
commands::set_repeat,
commands::get_repeat,
commands::get_queue_state,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+472 -6
View File
@@ -1,10 +1,476 @@
//! Queue management module
//!
//! Handles playback queue with:
//! - Queue manipulation (add, remove, reorder)
//! - Shuffle modes
//! - Repeat modes
//! - Play history
//! - Queue manipulation (add, remove, reorder, clear)
//! - Current track tracking
//! - Shuffle mode
//! - Repeat modes (off, all, one)
//! - Play history for going back
// TODO: Implement queue management
// pub mod manager;
use std::collections::VecDeque;
use std::sync::Mutex;
/// Track info stored in the queue
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QueueTrack {
pub id: u64,
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: u64,
pub artwork_url: Option<String>,
}
/// Repeat mode options
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RepeatMode {
Off,
All,
One,
}
impl Default for RepeatMode {
fn default() -> Self {
Self::Off
}
}
/// Queue state snapshot for frontend
#[derive(Debug, Clone, serde::Serialize)]
pub struct QueueState {
pub current_track: Option<QueueTrack>,
pub current_index: Option<usize>,
pub upcoming: Vec<QueueTrack>,
pub history: Vec<QueueTrack>,
pub shuffle: bool,
pub repeat: RepeatMode,
pub total_tracks: usize,
}
/// Queue manager for handling playback queue
pub struct QueueManager {
/// All tracks in the queue (original order)
tracks: Mutex<Vec<QueueTrack>>,
/// Current playback index
current_index: Mutex<Option<usize>>,
/// Shuffle mode enabled
shuffle: Mutex<bool>,
/// Shuffled indices (when shuffle is on)
shuffle_order: Mutex<Vec<usize>>,
/// Position in shuffle order
shuffle_position: Mutex<usize>,
/// Repeat mode
repeat: Mutex<RepeatMode>,
/// History of played track indices (for going back)
history: Mutex<VecDeque<usize>>,
}
impl Default for QueueManager {
fn default() -> Self {
Self::new()
}
}
impl QueueManager {
pub fn new() -> Self {
Self {
tracks: Mutex::new(Vec::new()),
current_index: Mutex::new(None),
shuffle: Mutex::new(false),
shuffle_order: Mutex::new(Vec::new()),
shuffle_position: Mutex::new(0),
repeat: Mutex::new(RepeatMode::Off),
history: Mutex::new(VecDeque::with_capacity(50)),
}
}
/// Add a track to the end of the queue
pub fn add_track(&self, track: QueueTrack) {
let mut tracks = self.tracks.lock().unwrap();
tracks.push(track);
// Update shuffle order if needed
if *self.shuffle.lock().unwrap() {
let mut order = self.shuffle_order.lock().unwrap();
order.push(tracks.len() - 1);
}
}
/// Add multiple tracks to the queue
pub fn add_tracks(&self, new_tracks: Vec<QueueTrack>) {
let mut tracks = self.tracks.lock().unwrap();
let start_idx = tracks.len();
tracks.extend(new_tracks);
// Update shuffle order if needed
if *self.shuffle.lock().unwrap() {
let mut order = self.shuffle_order.lock().unwrap();
for i in start_idx..tracks.len() {
order.push(i);
}
}
}
/// Set the entire queue (replaces existing)
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
let mut tracks = self.tracks.lock().unwrap();
*tracks = new_tracks;
let mut current = self.current_index.lock().unwrap();
*current = start_index;
// Reset shuffle order
self.regenerate_shuffle_order();
// Clear history
self.history.lock().unwrap().clear();
}
/// Clear the queue
pub fn clear(&self) {
self.tracks.lock().unwrap().clear();
*self.current_index.lock().unwrap() = None;
self.shuffle_order.lock().unwrap().clear();
*self.shuffle_position.lock().unwrap() = 0;
self.history.lock().unwrap().clear();
}
/// Remove a track by index
pub fn remove_track(&self, index: usize) -> Option<QueueTrack> {
let mut tracks = self.tracks.lock().unwrap();
if index >= tracks.len() {
return None;
}
let removed = tracks.remove(index);
// Adjust current index if needed
let mut current = self.current_index.lock().unwrap();
if let Some(curr_idx) = *current {
if index < curr_idx {
*current = Some(curr_idx - 1);
} else if index == curr_idx {
// Currently playing track was removed
if curr_idx >= tracks.len() {
*current = if tracks.is_empty() { None } else { Some(tracks.len() - 1) };
}
}
}
// Regenerate shuffle order
self.regenerate_shuffle_order();
Some(removed)
}
/// Get current track
pub fn current_track(&self) -> Option<QueueTrack> {
let tracks = self.tracks.lock().unwrap();
let current = self.current_index.lock().unwrap();
current.and_then(|idx| tracks.get(idx).cloned())
}
/// Get next track without advancing
pub fn peek_next(&self) -> Option<QueueTrack> {
let tracks = self.tracks.lock().unwrap();
if tracks.is_empty() {
return None;
}
let current = self.current_index.lock().unwrap();
let repeat = *self.repeat.lock().unwrap();
let shuffle = *self.shuffle.lock().unwrap();
// Handle repeat one - next is the same track
if repeat == RepeatMode::One {
return current.and_then(|idx| tracks.get(idx).cloned());
}
let next_idx = if shuffle {
let order = self.shuffle_order.lock().unwrap();
let pos = *self.shuffle_position.lock().unwrap();
let next_pos = pos + 1;
if next_pos < order.len() {
Some(order[next_pos])
} else if repeat == RepeatMode::All {
order.first().copied()
} else {
None
}
} else {
let curr_idx = current.unwrap_or(0);
let next_idx = curr_idx + 1;
if next_idx < tracks.len() {
Some(next_idx)
} else if repeat == RepeatMode::All {
Some(0)
} else {
None
}
};
next_idx.and_then(|idx| tracks.get(idx).cloned())
}
/// Advance to next track and return it
pub fn next(&self) -> Option<QueueTrack> {
let tracks = self.tracks.lock().unwrap();
if tracks.is_empty() {
return None;
}
let mut current = self.current_index.lock().unwrap();
let repeat = *self.repeat.lock().unwrap();
let shuffle = *self.shuffle.lock().unwrap();
// Save current to history before moving
if let Some(curr_idx) = *current {
let mut history = self.history.lock().unwrap();
history.push_back(curr_idx);
// Keep history limited
while history.len() > 50 {
history.pop_front();
}
}
// Handle repeat one
if repeat == RepeatMode::One {
return current.and_then(|idx| tracks.get(idx).cloned());
}
let next_idx = if shuffle {
let order = self.shuffle_order.lock().unwrap();
let mut pos = self.shuffle_position.lock().unwrap();
*pos += 1;
if *pos < order.len() {
Some(order[*pos])
} else if repeat == RepeatMode::All {
*pos = 0;
order.first().copied()
} else {
None
}
} else {
let curr_idx = current.unwrap_or(0);
let next_idx = curr_idx + 1;
if next_idx < tracks.len() {
Some(next_idx)
} else if repeat == RepeatMode::All {
Some(0)
} else {
None
}
};
*current = next_idx;
drop(current);
drop(tracks);
self.current_track()
}
/// Go to previous track and return it
pub fn previous(&self) -> Option<QueueTrack> {
let tracks = self.tracks.lock().unwrap();
if tracks.is_empty() {
return None;
}
let mut current = self.current_index.lock().unwrap();
let repeat = *self.repeat.lock().unwrap();
let shuffle = *self.shuffle.lock().unwrap();
// Try to get from history first
let mut history = self.history.lock().unwrap();
if let Some(prev_idx) = history.pop_back() {
*current = Some(prev_idx);
// Adjust shuffle position if needed
if shuffle {
let order = self.shuffle_order.lock().unwrap();
if let Some(pos) = order.iter().position(|&x| x == prev_idx) {
*self.shuffle_position.lock().unwrap() = pos;
}
}
return tracks.get(prev_idx).cloned();
}
drop(history);
// No history, go to previous in order
let prev_idx = if shuffle {
let order = self.shuffle_order.lock().unwrap();
let mut pos = self.shuffle_position.lock().unwrap();
if *pos > 0 {
*pos -= 1;
Some(order[*pos])
} else if repeat == RepeatMode::All {
*pos = order.len().saturating_sub(1);
order.last().copied()
} else {
Some(order.first().copied().unwrap_or(0))
}
} else {
let curr_idx = current.unwrap_or(0);
if curr_idx > 0 {
Some(curr_idx - 1)
} else if repeat == RepeatMode::All {
Some(tracks.len().saturating_sub(1))
} else {
Some(0) // Stay at beginning
}
};
*current = prev_idx;
drop(current);
drop(tracks);
self.current_track()
}
/// Jump to a specific track by index
pub fn play_index(&self, index: usize) -> Option<QueueTrack> {
let tracks = self.tracks.lock().unwrap();
if index >= tracks.len() {
return None;
}
// Save current to history
let mut current = self.current_index.lock().unwrap();
if let Some(curr_idx) = *current {
let mut history = self.history.lock().unwrap();
history.push_back(curr_idx);
while history.len() > 50 {
history.pop_front();
}
}
*current = Some(index);
// Update shuffle position if needed
if *self.shuffle.lock().unwrap() {
let order = self.shuffle_order.lock().unwrap();
if let Some(pos) = order.iter().position(|&x| x == index) {
*self.shuffle_position.lock().unwrap() = pos;
}
}
tracks.get(index).cloned()
}
/// Toggle shuffle mode
pub fn set_shuffle(&self, enabled: bool) {
let mut shuffle = self.shuffle.lock().unwrap();
if *shuffle == enabled {
return;
}
*shuffle = enabled;
drop(shuffle);
if enabled {
self.regenerate_shuffle_order();
}
}
/// Get shuffle status
pub fn is_shuffle(&self) -> bool {
*self.shuffle.lock().unwrap()
}
/// Set repeat mode
pub fn set_repeat(&self, mode: RepeatMode) {
*self.repeat.lock().unwrap() = mode;
}
/// Get repeat mode
pub fn get_repeat(&self) -> RepeatMode {
*self.repeat.lock().unwrap()
}
/// Get queue state for frontend
pub fn get_state(&self) -> QueueState {
let tracks = self.tracks.lock().unwrap();
let current_index = *self.current_index.lock().unwrap();
let shuffle = *self.shuffle.lock().unwrap();
let repeat = *self.repeat.lock().unwrap();
let history = self.history.lock().unwrap();
let current_track = current_index.and_then(|idx| tracks.get(idx).cloned());
// Get upcoming tracks (after current)
let upcoming: Vec<QueueTrack> = if let Some(curr_idx) = current_index {
if shuffle {
let order = self.shuffle_order.lock().unwrap();
let pos = *self.shuffle_position.lock().unwrap();
order.iter()
.skip(pos + 1)
.take(20)
.filter_map(|&idx| tracks.get(idx).cloned())
.collect()
} else {
tracks.iter()
.skip(curr_idx + 1)
.take(20)
.cloned()
.collect()
}
} else {
tracks.iter().take(20).cloned().collect()
};
// Get history tracks (recent first)
let history_tracks: Vec<QueueTrack> = history.iter()
.rev()
.take(10)
.filter_map(|&idx| tracks.get(idx).cloned())
.collect();
QueueState {
current_track,
current_index,
upcoming,
history: history_tracks,
shuffle,
repeat,
total_tracks: tracks.len(),
}
}
/// Regenerate shuffle order
fn regenerate_shuffle_order(&self) {
let tracks = self.tracks.lock().unwrap();
let current_index = *self.current_index.lock().unwrap();
let mut order: Vec<usize> = (0..tracks.len()).collect();
// Fisher-Yates shuffle
use std::time::{SystemTime, UNIX_EPOCH};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as usize;
for i in (1..order.len()).rev() {
let j = (seed.wrapping_mul(i + 1)) % (i + 1);
order.swap(i, j);
}
// If there's a current track, move it to the front
if let Some(curr_idx) = current_index {
if let Some(pos) = order.iter().position(|&x| x == curr_idx) {
order.remove(pos);
order.insert(0, curr_idx);
}
}
*self.shuffle_order.lock().unwrap() = order;
*self.shuffle_position.lock().unwrap() = 0;
}
}
+231 -28
View File
@@ -84,6 +84,26 @@
duration: string;
}
// Backend queue track format
interface BackendQueueTrack {
id: number;
title: string;
artist: string;
album: string;
duration_secs: number;
artwork_url: string | null;
}
interface BackendQueueState {
current_track: BackendQueueTrack | null;
current_index: number | null;
upcoming: BackendQueueTrack[];
history: BackendQueueTrack[];
shuffle: boolean;
repeat: 'Off' | 'All' | 'One';
total_tracks: number;
}
interface PlayingTrack {
id: number;
title: string;
@@ -119,8 +139,9 @@
let repeatMode = $state<'off' | 'all' | 'one'>('off');
let isFavorite = $state(false);
// Queue State
// Queue State (synced from backend)
let queue = $state<QueueTrack[]>([]);
let queueTotalTracks = $state(0);
// Toast State
let toast = $state<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
@@ -275,6 +296,32 @@
duration = track.durationSeconds;
currentTime = 0;
// Build queue from album tracks
if (selectedAlbum?.tracks) {
const trackIndex = selectedAlbum.tracks.findIndex(t => t.id === track.id);
const queueTracks: BackendQueueTrack[] = selectedAlbum.tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null
}));
// Set the queue starting at the clicked track
try {
await invoke('set_queue', {
tracks: queueTracks,
startIndex: trackIndex >= 0 ? trackIndex : 0
});
console.log(`Queue set with ${queueTracks.length} tracks, starting at index ${trackIndex}`);
// Sync queue state to update UI
await syncQueueState();
} catch (err) {
console.error('Failed to set queue:', err);
}
}
// Try to play the track
try {
console.log('Invoking play_track with trackId:', track.id);
@@ -321,18 +368,27 @@
invoke('set_volume', { volume: newVolume / 100 }).catch(console.error);
}
function toggleShuffle() {
async function toggleShuffle() {
isShuffle = !isShuffle;
showToast(isShuffle ? 'Shuffle enabled' : 'Shuffle disabled', 'info');
try {
await invoke('set_shuffle', { enabled: isShuffle });
showToast(isShuffle ? 'Shuffle enabled' : 'Shuffle disabled', 'info');
} catch (err) {
console.error('Failed to set shuffle:', err);
isShuffle = !isShuffle; // Revert
}
}
function toggleRepeat() {
if (repeatMode === 'off') repeatMode = 'all';
else if (repeatMode === 'all') repeatMode = 'one';
else repeatMode = 'off';
const messages = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' };
showToast(messages[repeatMode], 'info');
async function toggleRepeat() {
const nextMode = repeatMode === 'off' ? 'all' : repeatMode === 'all' ? 'one' : 'off';
try {
await invoke('set_repeat', { mode: nextMode });
repeatMode = nextMode;
const messages = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' };
showToast(messages[repeatMode], 'info');
} catch (err) {
console.error('Failed to set repeat:', err);
}
}
function toggleFavorite() {
@@ -340,23 +396,149 @@
showToast(isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
}
// Skip track handlers (will be wired to queue when implemented)
function handleSkipBack() {
// Skip track handlers - wired to backend queue
async function handleSkipBack() {
if (!currentTrack) return;
// If more than 3 seconds in, restart track; otherwise go to previous
if (currentTime > 3) {
handleSeek(0);
showToast('Restarted track', 'info');
} else {
// TODO: Go to previous track in queue
showToast('Previous track (not implemented yet)', 'info');
return;
}
try {
const prevTrack = await invoke<BackendQueueTrack | null>('previous_track');
if (prevTrack) {
await playQueueTrack(prevTrack);
} else {
// No previous track, just restart
handleSeek(0);
}
} catch (err) {
console.error('Failed to go to previous track:', err);
showToast('Failed to go to previous track', 'error');
}
}
function handleSkipForward() {
async function handleSkipForward() {
if (!currentTrack) return;
// TODO: Go to next track in queue
showToast('Next track (not implemented yet)', 'info');
try {
const nextTrackResult = await invoke<BackendQueueTrack | null>('next_track');
if (nextTrackResult) {
await playQueueTrack(nextTrackResult);
} else {
// No next track - stop playback
await invoke('stop_playback');
isPlaying = false;
showToast('Queue ended', 'info');
}
} catch (err) {
console.error('Failed to go to next track:', err);
showToast('Failed to go to next track', 'error');
}
}
// Helper to play a track from the queue
async function playQueueTrack(track: BackendQueueTrack) {
currentTrack = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.artwork_url || '',
duration: track.duration_secs,
quality: 'Hi-Res' // Will be updated by actual quality info
};
duration = track.duration_secs;
currentTime = 0;
try {
await invoke('play_track', { trackId: track.id });
isPlaying = true;
// Update MPRIS
await invoke('set_media_metadata', {
title: track.title,
artist: track.artist,
album: track.album,
durationSecs: track.duration_secs,
coverUrl: track.artwork_url
});
// Refresh queue state
await syncQueueState();
} catch (err) {
console.error('Failed to play queue track:', err);
showToast(`Playback error: ${err}`, 'error');
isPlaying = false;
}
}
// Sync queue state from backend
async function syncQueueState() {
try {
const queueState = await invoke<BackendQueueState>('get_queue_state');
// Convert backend queue tracks to frontend format
queue = queueState.upcoming.map(t => ({
id: String(t.id),
artwork: t.artwork_url || '',
title: t.title,
artist: t.artist,
duration: formatDuration(t.duration_secs)
}));
queueTotalTracks = queueState.total_tracks;
isShuffle = queueState.shuffle;
repeatMode = queueState.repeat.toLowerCase() as 'off' | 'all' | 'one';
} catch (err) {
console.error('Failed to sync queue state:', err);
}
}
// Play a specific track from the queue panel
async function handleQueueTrackPlay(trackId: string) {
try {
// Find the index in the queue
const queueState = await invoke<BackendQueueState>('get_queue_state');
const allTracks = [queueState.current_track, ...queueState.upcoming].filter(Boolean) as BackendQueueTrack[];
const trackIndex = allTracks.findIndex(t => String(t.id) === trackId);
if (trackIndex >= 0) {
// If it's the current track (index 0), just ensure it's playing
if (trackIndex === 0 && queueState.current_index !== null) {
// Already current, nothing to do
return;
}
// Play by index (accounting for current track offset)
const actualIndex = queueState.current_index !== null
? queueState.current_index + trackIndex
: trackIndex;
const track = await invoke<BackendQueueTrack | null>('play_queue_index', { index: actualIndex });
if (track) {
await playQueueTrack(track);
}
}
} catch (err) {
console.error('Failed to play queue track:', err);
showToast('Failed to play track', 'error');
}
}
// Clear the queue
async function handleClearQueue() {
try {
await invoke('clear_queue');
queue = [];
queueTotalTracks = 0;
showToast('Queue cleared', 'info');
} catch (err) {
console.error('Failed to clear queue:', err);
showToast('Failed to clear queue', 'error');
}
}
// Toast Functions
@@ -440,6 +622,8 @@
let pollInterval: ReturnType<typeof setInterval> | null = null;
let isAdvancingTrack = false; // Prevent multiple advances
async function pollPlaybackState() {
if (!currentTrack) return;
@@ -451,10 +635,24 @@
currentTime = state.position;
isPlaying = state.is_playing;
// Check if track ended
if (state.duration > 0 && state.position >= state.duration && !state.is_playing) {
// Track finished - could trigger next track here
console.log('Track finished playing');
// Check if track ended - auto-advance to next
if (state.duration > 0 && state.position >= state.duration - 1 && !state.is_playing && !isAdvancingTrack) {
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
console.log('Queue ended');
}
} catch (err) {
console.error('Failed to auto-advance:', err);
} finally {
isAdvancingTrack = false;
}
}
}
} catch (err) {
@@ -483,6 +681,13 @@
return () => document.removeEventListener('keydown', handleKeydown);
});
// Sync queue state when opening queue panel
$effect(() => {
if (isQueueOpen) {
syncQueueState();
}
});
// Derived values for NowPlayingBar
const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? {
id: String(currentTrack.id),
@@ -572,11 +777,9 @@
onClose={() => (isQueueOpen = false)}
currentTrack={currentQueueTrack ?? undefined}
upcomingTracks={queue}
onClearQueue={() => {
queue = [];
showToast('Queue cleared', 'info');
}}
onSaveAsPlaylist={() => showToast('Saved as playlist', 'success')}
onPlayTrack={handleQueueTrackPlay}
onClearQueue={handleClearQueue}
onSaveAsPlaylist={() => showToast('Save as playlist coming soon', 'info')}
/>
<!-- Full Screen Now Playing -->