diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index b83a5c306..1e859d0f4 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -327,6 +327,19 @@ impl QbzCore { track } + /// Jump to a track by its position in the upcoming list (as shown in the + /// Queue sidebar). Shuffle-aware: resolves through `shuffle_order` when + /// shuffle is active. + pub async fn play_upcoming_at(&self, upcoming_index: usize) -> Option { + let queue = self.queue.write().await; + let track = queue.play_upcoming_at(upcoming_index); + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + track + } + /// Advance to next track in queue pub async fn next_track(&self) -> Option { let queue = self.queue.write().await; diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index b14dd2657..0d25e1fa7 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -580,6 +580,30 @@ impl QueueManager { prev_idx.and_then(|idx| state.tracks.get(idx).cloned()) } + /// Jump to a track by its position in the `upcoming` list as returned by + /// `get_state`. This is the position the user sees in the Queue sidebar; + /// the method resolves it to the correct canonical index even when + /// shuffle is active (where the display order differs from the canonical + /// `tracks` order). + /// + /// Used by the "click a track in the queue panel" path — fixes issue + /// #327 where shuffle mode caused a different track than the one + /// clicked to be played. + pub fn play_upcoming_at(&self, upcoming_index: usize) -> Option { + let canonical_index = { + let state = self.state.lock().unwrap(); + match state.current_index { + Some(_) if state.shuffle => state + .shuffle_order + .get(state.shuffle_position + 1 + upcoming_index) + .copied(), + Some(curr_idx) => Some(curr_idx + 1 + upcoming_index), + None => Some(upcoming_index), + } + }; + canonical_index.and_then(|idx| self.play_index(idx)) + } + /// Jump to a specific track by index pub fn play_index(&self, index: usize) -> Option { let mut state = self.state.lock().unwrap(); @@ -1103,6 +1127,51 @@ mod tests { assert_eq!(state.upcoming.len(), 10); } + #[test] + fn test_play_upcoming_at_without_shuffle_uses_linear_offset() { + let queue = QueueManager::new(); + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + queue.play_index(1); // current = track id 2 + + // upcoming list is [3, 4, 5]; clicking position 1 must play id 4 + let track = queue.play_upcoming_at(1).expect("track"); + assert_eq!(track.id, 4); + } + + #[test] + fn test_play_upcoming_at_with_shuffle_follows_shuffle_order() { + let queue = QueueManager::new(); + for i in 1..=5 { + queue.add_track(create_test_track(i)); + } + + // Authoritative shuffle: playing head is shuffle[0]=2 (id 3), + // upcoming order becomes [5, 2, 4, 1] (track ids). + queue.set_queue_with_order( + (1..=5).map(create_test_track).collect(), + Some(2), + true, + Some(vec![2, 4, 1, 3, 0]), + ); + + let state = queue.get_state(); + assert_eq!( + state + .upcoming + .iter() + .map(|t| t.id) + .collect::>(), + vec![5, 2, 4, 1] + ); + + // Clicking upcoming position 2 must play track id 4, not id 5 + // (which would be the "current_index + 2 + 1" = 5 broken path). + let track = queue.play_upcoming_at(2).expect("track"); + assert_eq!(track.id, 4); + } + #[test] fn test_set_shuffle_with_order_uses_authoritative_order() { let queue = QueueManager::new(); diff --git a/src-tauri/src/commands_v2/queue.rs b/src-tauri/src/commands_v2/queue.rs index 728715592..d676371b0 100644 --- a/src-tauri/src/commands_v2/queue.rs +++ b/src-tauri/src/commands_v2/queue.rs @@ -438,50 +438,6 @@ fn default_streamable() -> bool { true } -impl From for crate::queue::QueueTrack { - fn from(t: V2QueueTrack) -> Self { - Self { - id: t.id, - title: t.title, - artist: t.artist, - album: t.album, - duration_secs: t.duration_secs, - artwork_url: t.artwork_url, - hires: t.hires, - bit_depth: t.bit_depth, - sample_rate: t.sample_rate, - is_local: t.is_local, - album_id: t.album_id, - artist_id: t.artist_id, - streamable: t.streamable, - source: t.source, - parental_warning: t.parental_warning, - } - } -} - -impl From for V2QueueTrack { - fn from(t: crate::queue::QueueTrack) -> Self { - Self { - id: t.id, - title: t.title, - artist: t.artist, - album: t.album, - duration_secs: t.duration_secs, - artwork_url: t.artwork_url, - hires: t.hires, - bit_depth: t.bit_depth, - sample_rate: t.sample_rate, - is_local: t.is_local, - album_id: t.album_id, - artist_id: t.artist_id, - streamable: t.streamable, - source: t.source, - parental_warning: t.parental_warning, - } - } -} - // V2 queue track <-> qbz_models::QueueTrack (CoreQueueTrack) impl From for CoreQueueTrack { fn from(t: V2QueueTrack) -> Self { @@ -749,6 +705,26 @@ pub async fn v2_play_queue_index( Ok(track.map(Into::into)) } +/// Play a track at a given position in the upcoming list (V2 - shuffle-aware). +/// `upcoming_index` matches the position shown in the Queue sidebar; the +/// backend resolves it to the canonical track index, handling shuffle mode +/// correctly. Fixes issue #327. +#[tauri::command] +pub async fn v2_play_queue_upcoming_at( + upcoming_index: usize, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result, RuntimeError> { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresUserSession) + .await?; + log::info!("[V2] play_queue_upcoming_at: {}", upcoming_index); + let bridge = bridge.get().await; + let track = bridge.play_upcoming_at(upcoming_index).await; + Ok(track.map(Into::into)) +} + /// Move a track within the queue (V2 - uses CoreBridge) #[tauri::command] pub async fn v2_move_queue_track( diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index d9291ba36..32c4d8d42 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -202,6 +202,11 @@ impl CoreBridge { self.core.play_index(index).await } + /// Jump to a track by its position in the upcoming list (shuffle-aware) + pub async fn play_upcoming_at(&self, upcoming_index: usize) -> Option { + self.core.play_upcoming_at(upcoming_index).await + } + /// Advance to next track in queue pub async fn next_track(&self) -> Option { self.core.next_track().await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3bb26bf42..e5fa69f27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1591,6 +1591,7 @@ pub fn run() { commands_v2::v2_next_track, commands_v2::v2_previous_track, commands_v2::v2_play_queue_index, + commands_v2::v2_play_queue_upcoming_at, commands_v2::v2_move_queue_track, commands_v2::v2_add_tracks_to_queue, commands_v2::v2_add_tracks_to_queue_next, diff --git a/src/lib/components/QueuePanel.svelte b/src/lib/components/QueuePanel.svelte index b19f7b59c..0b9271b19 100644 --- a/src/lib/components/QueuePanel.svelte +++ b/src/lib/components/QueuePanel.svelte @@ -33,7 +33,7 @@ queueRemainingTracks?: number; // Remaining tracks after current (total - played - 1) historyTracks?: QueueTrack[]; isRadioMode?: boolean; // Is radio/similar tracks mode active - onPlayTrack?: (trackId: string) => void; + onPlayTrack?: (trackId: string, upcomingIndex: number) => void; onPlayHistoryTrack?: (trackId: string) => void; onClearQueue?: () => void; onSaveAsPlaylist?: () => void; @@ -216,9 +216,9 @@ dragOverIndex = null; } - function handleTrackClick(track: QueueTrack) { + function handleTrackClick(track: QueueTrack, upcomingIndex: number) { if (track.available === false) return; - onPlayTrack?.(track.id); + onPlayTrack?.(track.id, upcomingIndex); } function handleHistoryTrackClick(track: QueueTrack) { @@ -354,7 +354,7 @@ class:drag-over={dragOverIndex === originalIndex && draggedIndex !== originalIndex} class:unavailable={isUnavailable} draggable={canDrag && !isUnavailable} - onclick={() => handleTrackClick(queueTrack)} + onclick={() => handleTrackClick(queueTrack, originalIndex)} ondragstart={(e) => handleDragStart(e, originalIndex)} ondragover={(e) => handleDragOver(e, originalIndex)} ondragleave={handleDragLeave} @@ -362,7 +362,7 @@ ondragend={handleDragEnd} role="button" tabindex={isUnavailable ? -1 : 0} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTrackClick(queueTrack); } }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTrackClick(queueTrack, originalIndex); } }} > {originalIndex + 1}
diff --git a/src/lib/stores/queueStore.ts b/src/lib/stores/queueStore.ts index c241b660a..8dfc24eb5 100644 --- a/src/lib/stores/queueStore.ts +++ b/src/lib/stores/queueStore.ts @@ -423,6 +423,20 @@ export async function playQueueIndex(index: number): Promise { + try { + return await invoke('v2_play_queue_upcoming_at', { upcomingIndex }); + } catch (err) { + console.error('Failed to play queue upcoming index:', err); + return null; + } +} + /** * Get next track from queue (V2) */ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6ff2291a1..c80391d8c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -244,6 +244,7 @@ setQueue, clearQueue, playQueueIndex, + playQueueUpcomingAt, nextTrack, nextTrackGuarded, previousTrack, @@ -2608,8 +2609,8 @@ }, { isLocal, source: source as 'qobuz' | 'local' | 'plex', showLoadingToast: false, gaplessTransition }); } - // Play a specific track from the queue panel - async function handleQueueTrackPlay(trackId: string) { + // Play a specific track from the queue panel (shuffle-aware, fixes issue #327) + async function handleQueueTrackPlay(trackId: string, upcomingIndex: number) { try { const handledRemotely = await invoke('v2_qconnect_play_track_if_remote', { trackId: parseInt(trackId, 10) }); if (handledRemotely) { @@ -2622,32 +2623,11 @@ } try { - // Find the index in the queue - const queueState = await getBackendQueueState(); - if (!queueState) { + const track = await playQueueUpcomingAt(upcomingIndex); + if (track) { + await playQueueTrack(track); + } else { showToast($t('toast.failedPlayTrack'), 'error'); - return; - } - - const allTracks = [queueState.current_track, ...queueState.upcoming].filter(Boolean) as BackendQueueTrack[]; - const trackIndex = allTracks.findIndex(trk => String(trk.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 playQueueIndex(actualIndex); - if (track) { - await playQueueTrack(track); - } } } catch (err) { console.error('Failed to play queue track:', err); @@ -6101,7 +6081,7 @@ } else if (index > historyLen) { // Playing from upcoming queue const queueIndex = index - historyLen - 1; - handleQueueTrackPlay(queue[queueIndex]?.id?.toString() ?? ''); + handleQueueTrackPlay(queue[queueIndex]?.id?.toString() ?? '', queueIndex); } // index === historyLen is current track, do nothing }}