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:
1 parent
2399f34103
commit
b5c853d945
5 files changed
+846
-34
No files matched your search
@@ -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::*;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 -->
|
||||
|
||||
Reference in new issue
Block a user