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 auth;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
|
pub mod queue;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use playback::*;
|
pub use playback::*;
|
||||||
|
pub use queue::*;
|
||||||
pub use search::*;
|
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 api::QobuzClient;
|
||||||
use media_controls::{MediaControlsManager, TrackInfo};
|
use media_controls::{MediaControlsManager, TrackInfo};
|
||||||
use player::Player;
|
use player::Player;
|
||||||
|
use queue::QueueManager;
|
||||||
|
|
||||||
/// Application state shared across commands
|
/// Application state shared across commands
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub client: Arc<Mutex<QobuzClient>>,
|
pub client: Arc<Mutex<QobuzClient>>,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
|
pub queue: QueueManager,
|
||||||
pub media_controls: MediaControlsManager,
|
pub media_controls: MediaControlsManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ impl AppState {
|
|||||||
Self {
|
Self {
|
||||||
client: Arc::new(Mutex::new(QobuzClient::default())),
|
client: Arc::new(Mutex::new(QobuzClient::default())),
|
||||||
player: Player::new(),
|
player: Player::new(),
|
||||||
|
queue: QueueManager::new(),
|
||||||
media_controls: MediaControlsManager::new(),
|
media_controls: MediaControlsManager::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,22 @@ pub fn run() {
|
|||||||
commands::seek,
|
commands::seek,
|
||||||
commands::get_playback_state,
|
commands::get_playback_state,
|
||||||
commands::set_media_metadata,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+472
-6
@@ -1,10 +1,476 @@
|
|||||||
//! Queue management module
|
//! Queue management module
|
||||||
//!
|
//!
|
||||||
//! Handles playback queue with:
|
//! Handles playback queue with:
|
||||||
//! - Queue manipulation (add, remove, reorder)
|
//! - Queue manipulation (add, remove, reorder, clear)
|
||||||
//! - Shuffle modes
|
//! - Current track tracking
|
||||||
//! - Repeat modes
|
//! - Shuffle mode
|
||||||
//! - Play history
|
//! - Repeat modes (off, all, one)
|
||||||
|
//! - Play history for going back
|
||||||
|
|
||||||
// TODO: Implement queue management
|
use std::collections::VecDeque;
|
||||||
// pub mod manager;
|
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;
|
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 {
|
interface PlayingTrack {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -119,8 +139,9 @@
|
|||||||
let repeatMode = $state<'off' | 'all' | 'one'>('off');
|
let repeatMode = $state<'off' | 'all' | 'one'>('off');
|
||||||
let isFavorite = $state(false);
|
let isFavorite = $state(false);
|
||||||
|
|
||||||
// Queue State
|
// Queue State (synced from backend)
|
||||||
let queue = $state<QueueTrack[]>([]);
|
let queue = $state<QueueTrack[]>([]);
|
||||||
|
let queueTotalTracks = $state(0);
|
||||||
|
|
||||||
// Toast State
|
// Toast State
|
||||||
let toast = $state<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
let toast = $state<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||||
@@ -275,6 +296,32 @@
|
|||||||
duration = track.durationSeconds;
|
duration = track.durationSeconds;
|
||||||
currentTime = 0;
|
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 to play the track
|
||||||
try {
|
try {
|
||||||
console.log('Invoking play_track with trackId:', track.id);
|
console.log('Invoking play_track with trackId:', track.id);
|
||||||
@@ -321,18 +368,27 @@
|
|||||||
invoke('set_volume', { volume: newVolume / 100 }).catch(console.error);
|
invoke('set_volume', { volume: newVolume / 100 }).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShuffle() {
|
async function toggleShuffle() {
|
||||||
isShuffle = !isShuffle;
|
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() {
|
async function toggleRepeat() {
|
||||||
if (repeatMode === 'off') repeatMode = 'all';
|
const nextMode = repeatMode === 'off' ? 'all' : repeatMode === 'all' ? 'one' : 'off';
|
||||||
else if (repeatMode === 'all') repeatMode = 'one';
|
try {
|
||||||
else repeatMode = 'off';
|
await invoke('set_repeat', { mode: nextMode });
|
||||||
|
repeatMode = nextMode;
|
||||||
const messages = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' };
|
const messages = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' };
|
||||||
showToast(messages[repeatMode], 'info');
|
showToast(messages[repeatMode], 'info');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set repeat:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite() {
|
function toggleFavorite() {
|
||||||
@@ -340,23 +396,149 @@
|
|||||||
showToast(isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
|
showToast(isFavorite ? 'Added to favorites' : 'Removed from favorites', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip track handlers (will be wired to queue when implemented)
|
// Skip track handlers - wired to backend queue
|
||||||
function handleSkipBack() {
|
async function handleSkipBack() {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
// If more than 3 seconds in, restart track; otherwise go to previous
|
// If more than 3 seconds in, restart track; otherwise go to previous
|
||||||
if (currentTime > 3) {
|
if (currentTime > 3) {
|
||||||
handleSeek(0);
|
handleSeek(0);
|
||||||
showToast('Restarted track', 'info');
|
return;
|
||||||
} else {
|
}
|
||||||
// TODO: Go to previous track in queue
|
|
||||||
showToast('Previous track (not implemented yet)', 'info');
|
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;
|
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
|
// Toast Functions
|
||||||
@@ -440,6 +622,8 @@
|
|||||||
|
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
let isAdvancingTrack = false; // Prevent multiple advances
|
||||||
|
|
||||||
async function pollPlaybackState() {
|
async function pollPlaybackState() {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
|
|
||||||
@@ -451,10 +635,24 @@
|
|||||||
currentTime = state.position;
|
currentTime = state.position;
|
||||||
isPlaying = state.is_playing;
|
isPlaying = state.is_playing;
|
||||||
|
|
||||||
// Check if track ended
|
// Check if track ended - auto-advance to next
|
||||||
if (state.duration > 0 && state.position >= state.duration && !state.is_playing) {
|
if (state.duration > 0 && state.position >= state.duration - 1 && !state.is_playing && !isAdvancingTrack) {
|
||||||
// Track finished - could trigger next track here
|
console.log('Track finished, advancing to next...');
|
||||||
console.log('Track finished playing');
|
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) {
|
} catch (err) {
|
||||||
@@ -483,6 +681,13 @@
|
|||||||
return () => document.removeEventListener('keydown', handleKeydown);
|
return () => document.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync queue state when opening queue panel
|
||||||
|
$effect(() => {
|
||||||
|
if (isQueueOpen) {
|
||||||
|
syncQueueState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Derived values for NowPlayingBar
|
// Derived values for NowPlayingBar
|
||||||
const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? {
|
const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? {
|
||||||
id: String(currentTrack.id),
|
id: String(currentTrack.id),
|
||||||
@@ -572,11 +777,9 @@
|
|||||||
onClose={() => (isQueueOpen = false)}
|
onClose={() => (isQueueOpen = false)}
|
||||||
currentTrack={currentQueueTrack ?? undefined}
|
currentTrack={currentQueueTrack ?? undefined}
|
||||||
upcomingTracks={queue}
|
upcomingTracks={queue}
|
||||||
onClearQueue={() => {
|
onPlayTrack={handleQueueTrackPlay}
|
||||||
queue = [];
|
onClearQueue={handleClearQueue}
|
||||||
showToast('Queue cleared', 'info');
|
onSaveAsPlaylist={() => showToast('Save as playlist coming soon', 'info')}
|
||||||
}}
|
|
||||||
onSaveAsPlaylist={() => showToast('Saved as playlist', 'success')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Full Screen Now Playing -->
|
<!-- Full Screen Now Playing -->
|
||||||
|
|||||||
Reference in new issue
Block a user