diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index 2acee29bb..2a22da35d 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -192,7 +192,17 @@ impl QueueManager { } } - Self::regenerate_shuffle_order_internal(&mut state); + // Keep history indices aligned with current track list after removal. + state.history.retain(|&hist_idx| hist_idx != index); + for hist_idx in state.history.iter_mut() { + if *hist_idx > index { + *hist_idx -= 1; + } + } + + if state.shuffle { + Self::remove_index_from_shuffle_internal(&mut state, index); + } Some(removed) } @@ -239,7 +249,17 @@ impl QueueManager { } } - Self::regenerate_shuffle_order_internal(&mut state); + // Keep history indices aligned with current track list after removal. + state.history.retain(|&hist_idx| hist_idx != actual_index); + for hist_idx in state.history.iter_mut() { + if *hist_idx > actual_index { + *hist_idx -= 1; + } + } + + if state.shuffle { + Self::remove_index_from_shuffle_internal(&mut state, actual_index); + } Some(removed) } @@ -247,6 +267,36 @@ impl QueueManager { pub fn move_track(&self, from_index: usize, to_index: usize) -> bool { let mut state = self.state.lock().unwrap(); + if state.shuffle { + // In shuffle mode, DnD indices come from the visible upcoming list, + // so they must be applied to shuffle_order positions (not absolute + // track indices in state.tracks). + let base_pos = state.current_index.map(|_| state.shuffle_position + 1).unwrap_or(0); + let from_pos = base_pos + from_index; + let to_pos = base_pos + to_index; + + if from_pos >= state.shuffle_order.len() || to_pos >= state.shuffle_order.len() { + return false; + } + + if from_pos == to_pos { + return true; + } + + let moved = state.shuffle_order.remove(from_pos); + state.shuffle_order.insert(to_pos, moved); + + if let Some(curr_idx) = state.current_index { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == curr_idx) { + state.shuffle_position = pos; + } + } else { + state.shuffle_position = 0; + } + + return true; + } + let direction: QueueMoveDirection = if from_index > to_index { QueueMoveDirection::Up } else { @@ -296,7 +346,11 @@ impl QueueManager { } } - Self::regenerate_shuffle_order_internal(&mut state); + // Keep history aligned after reorder. + for hist_idx in state.history.iter_mut() { + *hist_idx = Self::remap_index_after_move(*hist_idx, from_idx, to_idx); + } + true } @@ -518,6 +572,18 @@ impl QueueManager { if enabled { Self::regenerate_shuffle_order_internal(&mut state); + + // Enabling shuffle during active playback must keep current track + // as the first item in the shuffled timeline. Otherwise, indices + // before current are interpreted as already played. + if let Some(curr_idx) = state.current_index { + if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == curr_idx) { + if pos != 0 { + state.shuffle_order.swap(0, pos); + } + state.shuffle_position = 0; + } + } } } @@ -618,6 +684,58 @@ impl QueueManager { state.shuffle_position = 0; } } + + /// Remap an index after remove+insert move operation. + fn remap_index_after_move(idx: usize, from_idx: usize, to_idx: usize) -> usize { + if idx == from_idx { + return to_idx; + } + + if from_idx < to_idx { + // Moved down: [from+1 ..= to] shift left + if idx > from_idx && idx <= to_idx { + idx - 1 + } else { + idx + } + } else { + // Moved up: [to .. from-1] shift right + if idx >= to_idx && idx < from_idx { + idx + 1 + } else { + idx + } + } + } + + /// Remove one absolute track index from shuffle order and rebase remaining indices. + fn remove_index_from_shuffle_internal(state: &mut InternalState, removed_idx: usize) { + if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == removed_idx) { + state.shuffle_order.remove(pos); + + if pos < state.shuffle_position && state.shuffle_position > 0 { + state.shuffle_position -= 1; + } else if pos == state.shuffle_position && state.shuffle_position >= state.shuffle_order.len() { + state.shuffle_position = state.shuffle_order.len().saturating_sub(1); + } + } + + for idx in state.shuffle_order.iter_mut() { + if *idx > removed_idx { + *idx -= 1; + } + } + + if let Some(curr_idx) = state.current_index { + if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == curr_idx) { + state.shuffle_position = pos; + } else { + state.shuffle_position = 0; + } + } else { + state.shuffle_position = 0; + } + } } #[cfg(test)] @@ -787,4 +905,83 @@ mod tests { vec![5, 2, 3, 4] ); } + + #[test] + fn test_move_track_with_shuffle_reorders_shuffle_timeline() { + let queue = QueueManager::new(); + for i in 1..=8 { + queue.add_track(create_test_track(i)); + } + + queue.play_index(0); + queue.set_shuffle(true); + + let before_shuffle = { + let state = queue.state.lock().unwrap(); + state.shuffle_order.clone() + }; + + // With current_index=0 and shuffle_position=0: + // upcoming move 2 -> 0 maps to shuffle positions 3 -> 1. + assert!(queue.move_track(2, 0)); + + let after_shuffle = { + let state = queue.state.lock().unwrap(); + state.shuffle_order.clone() + }; + + let mut expected = before_shuffle.clone(); + let moved = expected.remove(3); + expected.insert(1, moved); + + assert_eq!(after_shuffle, expected); + assert_eq!(after_shuffle.len(), 8); + } + + #[test] + fn test_remove_track_with_shuffle_preserves_shuffle_order() { + let queue = QueueManager::new(); + for i in 1..=8 { + queue.add_track(create_test_track(i)); + } + + queue.play_index(0); + queue.set_shuffle(true); + + let before_shuffle = { + let state = queue.state.lock().unwrap(); + state.shuffle_order.clone() + }; + + assert!(queue.remove_track(2).is_some()); + + let after_shuffle = { + let state = queue.state.lock().unwrap(); + state.shuffle_order.clone() + }; + + let expected: Vec = before_shuffle + .into_iter() + .filter(|&idx| idx != 2) + .map(|idx| if idx > 2 { idx - 1 } else { idx }) + .collect(); + + assert_eq!(after_shuffle, expected); + assert_eq!(after_shuffle.len(), 7); + } + + #[test] + fn test_enabling_shuffle_keeps_all_remaining_tracks_upcoming() { + let queue = QueueManager::new(); + for i in 1..=11 { + queue.add_track(create_test_track(i)); + } + + queue.play_index(0); + queue.set_shuffle(true); + + let state = queue.get_state(); + assert_eq!(state.total_tracks, 11); + assert_eq!(state.upcoming.len(), 10); + } } diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index c11ba1d00..8e21212e1 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -2746,9 +2746,30 @@ pub async fn v2_plex_play_track( baseUrl: String, token: String, ratingKey: String, - app_state: State<'_, AppState>, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, ) -> Result { - crate::plex::plex_play_track(baseUrl, token, ratingKey, app_state).await + runtime.manager().check_requirements(CommandRequirement::RequiresClientInit).await + .map_err(|e| e.to_string())?; + + let resolved = crate::plex::plex_resolve_track_media(baseUrl, token, ratingKey).await?; + let bridge_guard = bridge.get().await; + let player = bridge_guard.player(); + + player + .play_data(resolved.bytes.clone(), resolved.playback_id) + .map_err(|e| format!("Failed to play Plex track via V2 player: {}", e))?; + + Ok(PlexPlayResult { + rating_key: resolved.rating_key, + part_key: resolved.part_key, + part_url: resolved.part_url, + bytes: resolved.bytes.len(), + direct_play_confirmed: resolved.direct_play_confirmed, + content_type: resolved.content_type, + sampling_rate_hz: resolved.sampling_rate_hz, + bit_depth: resolved.bit_depth, + }) } #[tauri::command] diff --git a/src-tauri/src/plex/mod.rs b/src-tauri/src/plex/mod.rs index 36431e291..81084dc18 100644 --- a/src-tauri/src/plex/mod.rs +++ b/src-tauri/src/plex/mod.rs @@ -61,6 +61,19 @@ pub struct PlexPlayResult { pub bit_depth: Option, } +#[derive(Debug, Clone)] +pub struct PlexResolvedMedia { + pub rating_key: String, + pub playback_id: u64, + pub part_key: String, + pub part_url: String, + pub bytes: Vec, + pub direct_play_confirmed: bool, + pub content_type: Option, + pub sampling_rate_hz: Option, + pub bit_depth: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlexPinStartResult { @@ -1342,6 +1355,30 @@ pub async fn plex_play_track( rating_key: String, app_state: State<'_, AppState>, ) -> Result { + let resolved = plex_resolve_track_media(base_url, token, rating_key).await?; + + app_state + .player + .play_data(resolved.bytes.clone(), resolved.playback_id) + .map_err(|e| format!("Failed to play Plex track: {}", e))?; + + Ok(PlexPlayResult { + rating_key: resolved.rating_key, + part_key: resolved.part_key, + part_url: resolved.part_url, + bytes: resolved.bytes.len(), + direct_play_confirmed: resolved.direct_play_confirmed, + content_type: resolved.content_type, + sampling_rate_hz: resolved.sampling_rate_hz, + bit_depth: resolved.bit_depth, + }) +} + +pub async fn plex_resolve_track_media( + base_url: String, + token: String, + rating_key: String, +) -> Result { let client = build_plex_client()?; let base = normalize_base_url(&base_url); @@ -1388,16 +1425,12 @@ pub async fn plex_play_track( .map_err(|e| format!("Failed to read Plex media bytes: {}", e))?; let playback_id = playback_track_id(&rating_key); - app_state - .player - .play_data(bytes.to_vec(), playback_id) - .map_err(|e| format!("Failed to play Plex track: {}", e))?; - - Ok(PlexPlayResult { + Ok(PlexResolvedMedia { rating_key, + playback_id, part_key: part_key.clone(), part_url, - bytes: bytes.len(), + bytes: bytes.to_vec(), direct_play_confirmed: is_direct_part_key(&part_key), content_type, sampling_rate_hz: track.sampling_rate_hz,