feat(queue): make history occurrences reorderable
cx
This commit is contained in:
7 files changed
+717
-69
No files matched your search
@@ -693,6 +693,60 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
success
|
||||
}
|
||||
|
||||
/// Reorder one playback-history occurrence inside Queue View. Source
|
||||
/// coordinates are most-recent-first; the destination is an oldest-first
|
||||
/// insertion slot in the chronological projection.
|
||||
pub async fn move_history_entry(
|
||||
&self,
|
||||
history_index: usize,
|
||||
expected_id: u64,
|
||||
to_slot: usize,
|
||||
) -> bool {
|
||||
let queue = self.queue.write().await;
|
||||
let success = queue.move_history_entry(history_index, expected_id, to_slot);
|
||||
if success {
|
||||
self.emit(CoreEvent::QueueUpdated {
|
||||
state: queue.get_state(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
success
|
||||
}
|
||||
|
||||
/// Move one played queue occurrence back into Upcoming without cloning
|
||||
/// it. This is the local Queue View path; remote renderers use
|
||||
/// `remove_history_entry` after their authoritative insert succeeds.
|
||||
pub async fn requeue_history_entry(
|
||||
&self,
|
||||
history_index: usize,
|
||||
expected_id: u64,
|
||||
to_slot: usize,
|
||||
) -> bool {
|
||||
let queue = self.queue.write().await;
|
||||
let success = queue.requeue_history_entry(history_index, expected_id, to_slot);
|
||||
if success {
|
||||
self.emit(CoreEvent::QueueUpdated {
|
||||
state: queue.get_state(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
success
|
||||
}
|
||||
|
||||
/// Forget the local history occurrence that an authoritative remote
|
||||
/// renderer just accepted back into its Upcoming sequence.
|
||||
pub async fn remove_history_entry(&self, history_index: usize, expected_id: u64) -> bool {
|
||||
let queue = self.queue.write().await;
|
||||
let success = queue.remove_history_entry(history_index, expected_id);
|
||||
if success {
|
||||
self.emit(CoreEvent::QueueUpdated {
|
||||
state: queue.get_state(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
success
|
||||
}
|
||||
|
||||
/// Jump to a specific track by index
|
||||
pub async fn play_index(&self, index: usize) -> Option<QueueTrack> {
|
||||
let queue = self.queue.write().await;
|
||||
|
||||
+484
-38
@@ -7,7 +7,7 @@
|
||||
//! - Repeat modes (off, all, one)
|
||||
//! - Play history for going back
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_models::{QueueState, QueueTrack, RepeatMode};
|
||||
@@ -18,6 +18,8 @@ enum QueueMoveDirection {
|
||||
Down,
|
||||
}
|
||||
|
||||
const MAX_HISTORY_LEN: usize = 50;
|
||||
|
||||
/// Internal queue state - all in one struct to avoid deadlocks
|
||||
struct InternalState {
|
||||
/// All tracks in the queue (original order)
|
||||
@@ -271,6 +273,9 @@ impl QueueManager {
|
||||
Self::remap_history_by_track_id_internal(&mut state, &new_tracks);
|
||||
state.tracks = new_tracks;
|
||||
state.current_index = start_index;
|
||||
if let Some(current_index) = state.current_index {
|
||||
state.history.retain(|&index| index != current_index);
|
||||
}
|
||||
|
||||
// Regenerate shuffle order
|
||||
Self::regenerate_shuffle_order_internal(&mut state);
|
||||
@@ -313,6 +318,9 @@ impl QueueManager {
|
||||
Self::remap_history_by_track_id_internal(&mut state, &new_tracks);
|
||||
state.tracks = new_tracks;
|
||||
state.current_index = start_index;
|
||||
if let Some(current_index) = state.current_index {
|
||||
state.history.retain(|&index| index != current_index);
|
||||
}
|
||||
state.shuffle = shuffle_enabled;
|
||||
|
||||
if !shuffle_enabled {
|
||||
@@ -688,6 +696,198 @@ impl QueueManager {
|
||||
true
|
||||
}
|
||||
|
||||
/// Reorder one playback-history occurrence. `history_index` uses the
|
||||
/// public most-recent-first coordinate while `to_slot` is an insertion
|
||||
/// gap in the chronological (oldest-first) Queue View projection.
|
||||
///
|
||||
/// History stores canonical queue indices, not track ids. Consequently two
|
||||
/// independently enqueued copies of the same song remain independently
|
||||
/// movable here.
|
||||
pub fn move_history_entry(
|
||||
&self,
|
||||
history_index: usize,
|
||||
expected_id: u64,
|
||||
to_slot: usize,
|
||||
) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let Some(source_position) = state
|
||||
.history
|
||||
.len()
|
||||
.checked_sub(history_index.saturating_add(1))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(&canonical_index) = state.history.get(source_position) else {
|
||||
return false;
|
||||
};
|
||||
if state.tracks.get(canonical_index).map(|track| track.id) != Some(expected_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let slot = to_slot.min(state.history.len());
|
||||
if slot == source_position || slot == source_position + 1 {
|
||||
return false;
|
||||
}
|
||||
let Some(entry) = state.history.remove(source_position) else {
|
||||
return false;
|
||||
};
|
||||
let insertion = if slot > source_position {
|
||||
slot - 1
|
||||
} else {
|
||||
slot
|
||||
};
|
||||
let insertion = insertion.min(state.history.len());
|
||||
state.history.insert(insertion, entry);
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove one Queue View history occurrence after an authoritative remote
|
||||
/// renderer accepted it as a new upcoming item. The queue row itself stays
|
||||
/// in place until the remote QueueUpdated echo materializes its canonical
|
||||
/// order locally.
|
||||
pub fn remove_history_entry(&self, history_index: usize, expected_id: u64) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let Some(source_position) = state
|
||||
.history
|
||||
.len()
|
||||
.checked_sub(history_index.saturating_add(1))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(&canonical_index) = state.history.get(source_position) else {
|
||||
return false;
|
||||
};
|
||||
if state.tracks.get(canonical_index).map(|track| track.id) != Some(expected_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let before = state.history.len();
|
||||
// Historical sessions could contain the same canonical occurrence
|
||||
// more than once. Removing all of those stale references establishes
|
||||
// the same-entry/latest-play invariant immediately.
|
||||
state.history.retain(|&index| index != canonical_index);
|
||||
state.history.len() != before
|
||||
}
|
||||
|
||||
/// Move a played queue occurrence back into an Upcoming insertion slot.
|
||||
/// This is deliberately a MOVE of the canonical occurrence, never an
|
||||
/// `add_track` clone. Distinct queue occurrences may share a track id and
|
||||
/// remain distinct; replaying one occurrence merely relocates that one.
|
||||
pub fn requeue_history_entry(
|
||||
&self,
|
||||
history_index: usize,
|
||||
expected_id: u64,
|
||||
to_slot: usize,
|
||||
) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let Some(source_history_position) = state
|
||||
.history
|
||||
.len()
|
||||
.checked_sub(history_index.saturating_add(1))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(&source_index) = state.history.get(source_history_position) else {
|
||||
return false;
|
||||
};
|
||||
if state.tracks.get(source_index).map(|track| track.id) != Some(expected_id)
|
||||
|| state.current_index == Some(source_index)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if state.shuffle {
|
||||
let Some(source_order_position) = state
|
||||
.shuffle_order
|
||||
.iter()
|
||||
.position(|&index| index == source_index)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let current_order_position = match state.current_index {
|
||||
Some(current_index) => {
|
||||
let Some(position) = state
|
||||
.shuffle_order
|
||||
.iter()
|
||||
.position(|&index| index == current_index)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Some(position)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let source_upcoming_position = match current_order_position {
|
||||
Some(current_position) if source_order_position > current_position => {
|
||||
Some(source_order_position - current_position - 1)
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some(source_order_position),
|
||||
};
|
||||
let current_after_removal = current_order_position.map(|current_position| {
|
||||
if source_order_position < current_position {
|
||||
current_position - 1
|
||||
} else {
|
||||
current_position
|
||||
}
|
||||
});
|
||||
|
||||
state.history.retain(|&index| index != source_index);
|
||||
state.shuffle_order.remove(source_order_position);
|
||||
let base_position = current_after_removal.map(|position| position + 1).unwrap_or(0);
|
||||
let insertion_slot = match source_upcoming_position {
|
||||
Some(position) if to_slot > position => to_slot - 1,
|
||||
_ => to_slot,
|
||||
}
|
||||
.min(state.shuffle_order.len().saturating_sub(base_position));
|
||||
state
|
||||
.shuffle_order
|
||||
.insert(base_position + insertion_slot, source_index);
|
||||
state.shuffle_position = current_after_removal.unwrap_or(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
let current_before = state.current_index;
|
||||
let source_upcoming_position = match current_before {
|
||||
Some(current_index) if source_index > current_index => {
|
||||
Some(source_index - current_index - 1)
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some(source_index),
|
||||
};
|
||||
let current_after_removal = current_before.map(|current_index| {
|
||||
if source_index < current_index {
|
||||
current_index - 1
|
||||
} else {
|
||||
current_index
|
||||
}
|
||||
});
|
||||
let base_index = current_after_removal.map(|index| index + 1).unwrap_or(0);
|
||||
let insertion_slot = match source_upcoming_position {
|
||||
Some(position) if to_slot > position => to_slot - 1,
|
||||
_ => to_slot,
|
||||
}
|
||||
.min(
|
||||
state
|
||||
.tracks
|
||||
.len()
|
||||
.saturating_sub(1)
|
||||
.saturating_sub(base_index),
|
||||
);
|
||||
let destination_index = base_index + insertion_slot;
|
||||
|
||||
state.history.retain(|&index| index != source_index);
|
||||
let track = state.tracks.remove(source_index);
|
||||
state.tracks.insert(destination_index, track);
|
||||
state.current_index = current_before
|
||||
.map(|index| Self::remap_index_after_move(index, source_index, destination_index));
|
||||
for history_index in state.history.iter_mut() {
|
||||
*history_index =
|
||||
Self::remap_index_after_move(*history_index, source_index, destination_index);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Get current track
|
||||
pub fn current_track(&self) -> Option<QueueTrack> {
|
||||
let state = self.state.lock().unwrap();
|
||||
@@ -817,20 +1017,17 @@ impl QueueManager {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Save current to history before moving
|
||||
if let Some(curr_idx) = state.current_index {
|
||||
state.history.push_back(curr_idx);
|
||||
while state.history.len() > 50 {
|
||||
state.history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
if state.repeat == RepeatMode::One {
|
||||
return state
|
||||
.current_index
|
||||
.and_then(|idx| state.tracks.get(idx).cloned());
|
||||
}
|
||||
|
||||
// Save current to history before moving
|
||||
if let Some(curr_idx) = state.current_index {
|
||||
Self::record_history_internal(&mut state, curr_idx);
|
||||
}
|
||||
|
||||
let next_idx = if state.shuffle {
|
||||
state.shuffle_position += 1;
|
||||
if state.shuffle_position < state.shuffle_order.len() {
|
||||
@@ -853,6 +1050,9 @@ impl QueueManager {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(next_index) = next_idx {
|
||||
state.history.retain(|&index| index != next_index);
|
||||
}
|
||||
state.current_index = next_idx;
|
||||
// Manual-block bookkeeping (#442): advancing past a manual entry
|
||||
// shrinks the block; exhausting or wrapping the queue dissolves it.
|
||||
@@ -874,6 +1074,7 @@ impl QueueManager {
|
||||
|
||||
// Try to get from history first
|
||||
if let Some(prev_idx) = state.history.pop_back() {
|
||||
state.history.retain(|&index| index != prev_idx);
|
||||
state.current_index = Some(prev_idx);
|
||||
|
||||
if state.shuffle {
|
||||
@@ -927,11 +1128,9 @@ impl QueueManager {
|
||||
if moved {
|
||||
// Record the outgoing track so `previous` still walks back.
|
||||
if let Some(curr_idx) = state.current_index {
|
||||
state.history.push_back(curr_idx);
|
||||
while state.history.len() > 50 {
|
||||
state.history.pop_front();
|
||||
}
|
||||
Self::record_history_internal(&mut state, curr_idx);
|
||||
}
|
||||
state.history.retain(|&index| index != target);
|
||||
state.current_index = Some(target);
|
||||
// Keep the shuffle cursor aligned with the new position.
|
||||
if state.shuffle {
|
||||
@@ -1029,10 +1228,10 @@ impl QueueManager {
|
||||
None
|
||||
};
|
||||
|
||||
state.history.extend(crossed);
|
||||
while state.history.len() > 50 {
|
||||
state.history.pop_front();
|
||||
for crossed_index in crossed {
|
||||
Self::record_history_internal(&mut state, crossed_index);
|
||||
}
|
||||
state.history.retain(|&index| index != target_index);
|
||||
state.current_index = Some(target_index);
|
||||
state.manual_next_count = state
|
||||
.manual_next_count
|
||||
@@ -1101,13 +1300,11 @@ impl QueueManager {
|
||||
// Matches `sync_current_to_id`'s `moved` guard.
|
||||
if let Some(curr_idx) = state.current_index {
|
||||
if curr_idx != index {
|
||||
state.history.push_back(curr_idx);
|
||||
while state.history.len() > 50 {
|
||||
state.history.pop_front();
|
||||
}
|
||||
Self::record_history_internal(&mut state, curr_idx);
|
||||
}
|
||||
}
|
||||
|
||||
state.history.retain(|&history_index| history_index != index);
|
||||
state.current_index = Some(index);
|
||||
|
||||
if state.shuffle {
|
||||
@@ -1310,12 +1507,12 @@ impl QueueManager {
|
||||
/// survive, matching the live playback-history bound.
|
||||
pub fn restore_history_indices(&self, history: Vec<usize>) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let valid: Vec<usize> = history
|
||||
.into_iter()
|
||||
.filter(|&idx| idx < state.tracks.len())
|
||||
.collect();
|
||||
let keep_from = valid.len().saturating_sub(50);
|
||||
state.history = valid.into_iter().skip(keep_from).collect();
|
||||
state.history.clear();
|
||||
for index in history {
|
||||
if state.current_index != Some(index) {
|
||||
Self::record_history_internal(&mut state, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the full queue state without the upcoming/history caps applied by
|
||||
@@ -1415,10 +1612,26 @@ impl QueueManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one canonical queue occurrence as the newest played entry.
|
||||
/// Replaying that SAME occurrence moves its existing record to the end;
|
||||
/// independently enqueued copies remain distinct because their canonical
|
||||
/// indices differ even when their track ids are equal.
|
||||
fn record_history_internal(state: &mut InternalState, index: usize) {
|
||||
if index >= state.tracks.len() {
|
||||
return;
|
||||
}
|
||||
state.history.retain(|&existing| existing != index);
|
||||
state.history.push_back(index);
|
||||
while state.history.len() > MAX_HISTORY_LEN {
|
||||
state.history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap history entries from `state.tracks` indices to indices into
|
||||
/// `new_tracks`, looking up by track id. Entries whose track id is no
|
||||
/// longer present in `new_tracks` are dropped. Must be called with the
|
||||
/// lock held and BEFORE `state.tracks` is replaced.
|
||||
/// `new_tracks`. Repeated track ids are matched occurrence-by-occurrence
|
||||
/// in canonical order rather than collapsed onto the last matching id.
|
||||
/// Entries whose occurrence no longer exists are dropped. Must be called
|
||||
/// with the lock held and BEFORE `state.tracks` is replaced.
|
||||
///
|
||||
/// This preserves history across queue version bumps that don't change
|
||||
/// track identity (e.g. pure reorder, shuffle toggle, or an authoritative
|
||||
@@ -1429,23 +1642,32 @@ impl QueueManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build lookup: track_id -> new index. If duplicate ids exist (rare),
|
||||
// last occurrence wins; history will still resolve to a valid track.
|
||||
let mut new_id_to_idx: std::collections::HashMap<u64, usize> =
|
||||
std::collections::HashMap::with_capacity(new_tracks.len());
|
||||
// QueueTrack has no separate persisted occurrence UUID. Pairing the
|
||||
// nth old occurrence with the nth new occurrence is the strongest
|
||||
// stable identity available across an authoritative queue replacement
|
||||
// and, crucially, does not merge duplicate queue entries.
|
||||
let mut new_indices_by_id: HashMap<u64, VecDeque<usize>> =
|
||||
HashMap::with_capacity(new_tracks.len());
|
||||
for (idx, track) in new_tracks.iter().enumerate() {
|
||||
new_id_to_idx.insert(track.id, idx);
|
||||
new_indices_by_id.entry(track.id).or_default().push_back(idx);
|
||||
}
|
||||
let mut old_to_new = vec![None; state.tracks.len()];
|
||||
for (old_index, track) in state.tracks.iter().enumerate() {
|
||||
old_to_new[old_index] = new_indices_by_id
|
||||
.get_mut(&track.id)
|
||||
.and_then(VecDeque::pop_front);
|
||||
}
|
||||
|
||||
let mut remapped: VecDeque<usize> = VecDeque::with_capacity(state.history.len());
|
||||
for &old_idx in state.history.iter() {
|
||||
let Some(old_track) = state.tracks.get(old_idx) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(&new_idx) = new_id_to_idx.get(&old_track.id) {
|
||||
if let Some(new_idx) = old_to_new.get(old_idx).copied().flatten() {
|
||||
remapped.retain(|&existing| existing != new_idx);
|
||||
remapped.push_back(new_idx);
|
||||
}
|
||||
}
|
||||
while remapped.len() > MAX_HISTORY_LEN {
|
||||
remapped.pop_front();
|
||||
}
|
||||
state.history = remapped;
|
||||
}
|
||||
|
||||
@@ -2116,6 +2338,192 @@ mod tests {
|
||||
assert_eq!(state.total_tracks, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaying_the_same_queue_occurrence_keeps_only_its_latest_history_entry() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue((1..=3).map(create_test_track).collect(), Some(0));
|
||||
|
||||
assert_eq!(queue.next().expect("track 2").id, 2);
|
||||
assert_eq!(queue.play_index(0).expect("replay track 1").id, 1);
|
||||
assert_eq!(
|
||||
queue
|
||||
.get_state_full()
|
||||
.history
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2],
|
||||
"the occurrence being replayed must leave History while current"
|
||||
);
|
||||
|
||||
assert_eq!(queue.next().expect("track 2 again").id, 2);
|
||||
assert_eq!(queue.next().expect("track 3").id, 3);
|
||||
let history = queue.get_state_full().history;
|
||||
assert_eq!(
|
||||
history.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||
vec![2, 1]
|
||||
);
|
||||
assert_eq!(history.iter().filter(|track| track.id == 1).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separately_enqueued_copies_keep_distinct_history_occurrences() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue(
|
||||
vec![
|
||||
create_test_track(7),
|
||||
create_test_track(8),
|
||||
create_test_track(7),
|
||||
create_test_track(9),
|
||||
],
|
||||
Some(0),
|
||||
);
|
||||
|
||||
queue.next();
|
||||
queue.next();
|
||||
queue.next();
|
||||
|
||||
let history = queue.get_state_full().history;
|
||||
assert_eq!(
|
||||
history.iter().map(|track| track.id).collect::<Vec<_>>(),
|
||||
vec![7, 8, 7]
|
||||
);
|
||||
assert_eq!(history.iter().filter(|track| track.id == 7).count(), 2);
|
||||
assert_eq!(queue.get_persistable_state().2, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_view_reorders_history_in_chronological_slot_space() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue((1..=4).map(create_test_track).collect(), Some(3));
|
||||
queue.restore_history_indices(vec![0, 1, 2]);
|
||||
|
||||
// Visible History starts [1, 2, 3] (oldest first). Drag 1 to the
|
||||
// trailing history gap, immediately before NOW.
|
||||
assert!(queue.move_history_entry(2, 1, 3));
|
||||
assert_eq!(
|
||||
queue
|
||||
.get_state_full()
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 3, 1]
|
||||
);
|
||||
assert_eq!(queue.get_persistable_state().2, vec![1, 2, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_view_requeues_the_same_history_occurrence_without_cloning() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue((1..=5).map(create_test_track).collect(), Some(3));
|
||||
queue.restore_history_indices(vec![0, 1, 2]);
|
||||
|
||||
// History is publicly [3, 2, 1]. Move occurrence 1 to Upcoming slot
|
||||
// 1: [2, 3, 4 NOW, 5, 1]. No sixth QueueTrack is created.
|
||||
assert!(queue.requeue_history_entry(2, 1, 1));
|
||||
let moved = queue.get_state_full();
|
||||
assert_eq!(moved.total_tracks, 5);
|
||||
assert_eq!(moved.current_track.expect("current").id, 4);
|
||||
assert_eq!(
|
||||
moved
|
||||
.history
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![3, 2]
|
||||
);
|
||||
assert_eq!(
|
||||
moved
|
||||
.upcoming
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![5, 1]
|
||||
);
|
||||
|
||||
queue
|
||||
.play_upcoming_at_preserving_timeline(1, 1)
|
||||
.expect("requeued occurrence");
|
||||
assert_eq!(
|
||||
queue
|
||||
.get_state_full()
|
||||
.history
|
||||
.iter()
|
||||
.filter(|track| track.id == 1)
|
||||
.count(),
|
||||
0,
|
||||
"the replayed occurrence is current, not also historical"
|
||||
);
|
||||
assert!(queue.next().is_none());
|
||||
let finished = queue.get_state_full();
|
||||
assert_eq!(finished.total_tracks, 5);
|
||||
assert_eq!(finished.history.iter().filter(|track| track.id == 1).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_view_requeues_only_the_selected_duplicate_occurrence() {
|
||||
let queue = QueueManager::new();
|
||||
let mut first_lunch = create_test_track(7);
|
||||
first_lunch.title = "Lunch A".into();
|
||||
let mut second_lunch = create_test_track(7);
|
||||
second_lunch.title = "Lunch B".into();
|
||||
queue.set_queue(
|
||||
vec![
|
||||
first_lunch,
|
||||
create_test_track(8),
|
||||
second_lunch,
|
||||
create_test_track(9),
|
||||
create_test_track(10),
|
||||
],
|
||||
Some(3),
|
||||
);
|
||||
queue.restore_history_indices(vec![0, 1, 2]);
|
||||
|
||||
// Most-recent History row is Lunch B. Its id equals Lunch A, so only
|
||||
// the phase coordinate/canonical occurrence can distinguish them.
|
||||
assert!(queue.requeue_history_entry(0, 7, 0));
|
||||
let state = queue.get_state_full();
|
||||
assert_eq!(state.total_tracks, 5);
|
||||
assert_eq!(state.history.iter().filter(|track| track.id == 7).count(), 1);
|
||||
assert_eq!(state.history[1].title, "Lunch A");
|
||||
assert_eq!(state.upcoming[0].title, "Lunch B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_view_requeues_history_inside_the_shuffle_timeline() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue_with_order(
|
||||
(1..=5).map(create_test_track).collect(),
|
||||
Some(2),
|
||||
true,
|
||||
Some(vec![0, 1, 2, 3, 4]),
|
||||
);
|
||||
queue.restore_history_indices(vec![0, 1]);
|
||||
|
||||
assert!(queue.requeue_history_entry(0, 2, 1));
|
||||
let state = queue.get_state_full();
|
||||
assert_eq!(state.total_tracks, 5);
|
||||
assert_eq!(state.current_track.expect("current").id, 3);
|
||||
assert_eq!(
|
||||
state
|
||||
.history
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.upcoming
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![4, 2, 5]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_list_activation_rejects_a_stale_row_atomically() {
|
||||
let queue = QueueManager::new();
|
||||
@@ -2380,6 +2788,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_replacement_does_not_merge_duplicate_track_occurrences() {
|
||||
let queue = QueueManager::new();
|
||||
queue.set_queue(
|
||||
vec![
|
||||
create_test_track(7),
|
||||
create_test_track(8),
|
||||
create_test_track(7),
|
||||
create_test_track(9),
|
||||
],
|
||||
Some(3),
|
||||
);
|
||||
queue.restore_history_indices(vec![0, 2]);
|
||||
|
||||
queue.set_queue_with_order(
|
||||
vec![
|
||||
create_test_track(7),
|
||||
create_test_track(7),
|
||||
create_test_track(8),
|
||||
create_test_track(9),
|
||||
],
|
||||
Some(3),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(queue.get_persistable_state().2, vec![0, 1]);
|
||||
assert_eq!(
|
||||
queue
|
||||
.get_state_full()
|
||||
.history
|
||||
.iter()
|
||||
.map(|track| track.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![7, 7]
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Stop-After Marker — Basic API ============
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -208,20 +208,34 @@ Rectangle {
|
||||
QbzPlaylistPicker.openForTrack(row.id)
|
||||
}
|
||||
|
||||
// The chevrons and the pointer drop share the same queue-wide insertion
|
||||
// contract. Moving down inserts after the following row (`from + 2`),
|
||||
// because `queueExtendedDrop` consumes a SLOT rather than a destination
|
||||
// row index.
|
||||
function historyVisualIndex(row) {
|
||||
return (root.doc.historyCount || 0) - 1 - row.phaseIndex
|
||||
}
|
||||
|
||||
// The chevrons and pointer drops use insertion slots, not destination row
|
||||
// indices. Upcoming stays inside Upcoming. History chevrons reorder the
|
||||
// chronological past; a pointer drag may additionally cross Now Playing
|
||||
// and move that exact occurrence back into Upcoming.
|
||||
function moveQueueRow(row, delta) {
|
||||
if (!row || row.phase !== "upcoming" || root.searchActive
|
||||
|| root.rowBlocked(row))
|
||||
if (!row || root.searchActive || root.rowBlocked(row))
|
||||
return
|
||||
var from = row.phaseIndex
|
||||
var count = root.doc.upcomingCount || 0
|
||||
var from
|
||||
var count
|
||||
var targetPhase = row.phase
|
||||
if (row.phase === "upcoming") {
|
||||
from = row.phaseIndex
|
||||
count = root.doc.upcomingCount || 0
|
||||
} else if (row.phase === "history") {
|
||||
from = root.historyVisualIndex(row)
|
||||
count = root.doc.historyCount || 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if ((delta < 0 && from <= 0) || (delta > 0 && from >= count - 1))
|
||||
return
|
||||
var slot = delta < 0 ? from - 1 : from + 2
|
||||
QbzQueue.queueExtendedDrop("upcoming", from, row.id, slot)
|
||||
QbzQueue.queueExtendedDrop(row.phase, row.phaseIndex, row.id,
|
||||
targetPhase, slot)
|
||||
}
|
||||
|
||||
// -------------------------- local reorder ----------------------------
|
||||
@@ -231,6 +245,7 @@ Rectangle {
|
||||
property string dragTrackId: ""
|
||||
property var dragRow: null
|
||||
property int dragRowNumber: 0
|
||||
property string dropPhase: ""
|
||||
property int dropSlot: -1
|
||||
property real dropLineY: -1
|
||||
property bool dropHot: false
|
||||
@@ -244,12 +259,14 @@ Rectangle {
|
||||
root.dragTrackId = row.id
|
||||
root.dragRow = row
|
||||
root.dragRowNumber = visualIndex + 1
|
||||
root.dropPhase = ""
|
||||
root.dropSlot = -1
|
||||
root.dropHot = false
|
||||
}
|
||||
|
||||
function clearInlineDrop() {
|
||||
root.dropHot = false
|
||||
root.dropPhase = ""
|
||||
root.dropSlot = -1
|
||||
QbzShell.dragInlineVisual = false
|
||||
}
|
||||
@@ -274,13 +291,24 @@ Rectangle {
|
||||
var target = queueList.itemAtIndex(index)
|
||||
insertion = target && cy >= target.y + target.height / 2 ? index + 1 : index
|
||||
}
|
||||
var historyCount = root.doc.historyCount || 0
|
||||
var hasCurrent = root.doc.currentIndex >= 0
|
||||
if (root.dragPhase === "history"
|
||||
&& (insertion < historyCount
|
||||
|| (hasCurrent && insertion === historyCount))) {
|
||||
root.dropPhase = "history"
|
||||
root.dropSlot = Math.max(0, Math.min(historyCount, insertion))
|
||||
} else {
|
||||
insertion = Math.max(root.firstUpcoming, insertion)
|
||||
root.dropPhase = "upcoming"
|
||||
root.dropSlot = Math.max(0, Math.min(root.doc.upcomingCount || 0,
|
||||
insertion - root.firstUpcoming))
|
||||
}
|
||||
root.dropHot = true
|
||||
QbzShell.dragInlineVisual = true
|
||||
|
||||
var fullIndex = root.firstUpcoming + root.dropSlot
|
||||
var fullIndex = root.dropPhase === "history"
|
||||
? root.dropSlot : root.firstUpcoming + root.dropSlot
|
||||
var lineItem = fullIndex < root.rows.length ? queueList.itemAtIndex(fullIndex) : null
|
||||
if (lineItem)
|
||||
root.dropLineY = queueList.y + lineItem.y - queueList.contentY
|
||||
@@ -297,17 +325,20 @@ Rectangle {
|
||||
var phase = root.dragPhase
|
||||
var phaseIndex = root.dragPhaseIndex
|
||||
var trackId = root.dragTrackId
|
||||
var targetPhase = root.dropPhase
|
||||
var slot = root.dropSlot
|
||||
root.dragPhase = ""
|
||||
root.dragPhaseIndex = -1
|
||||
root.dragTrackId = ""
|
||||
root.dragRow = null
|
||||
root.dragRowNumber = 0
|
||||
root.dropPhase = ""
|
||||
root.dropSlot = -1
|
||||
root.dropHot = false
|
||||
QbzShell.dragInlineVisual = false
|
||||
if (shouldCommit)
|
||||
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId, slot)
|
||||
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
|
||||
targetPhase, slot)
|
||||
}
|
||||
|
||||
function dragProxyY() {
|
||||
@@ -617,11 +648,19 @@ Rectangle {
|
||||
showDownload: false
|
||||
showMenu: true
|
||||
showReorder: !root.searchActive
|
||||
&& rowHost.modelData.phase === "upcoming"
|
||||
&& (rowHost.modelData.phase === "upcoming"
|
||||
|| rowHost.modelData.phase === "history")
|
||||
&& !root.rowBlocked(rowHost.modelData)
|
||||
canMoveUp: rowHost.modelData.phaseIndex > 0
|
||||
canMoveDown: rowHost.modelData.phaseIndex
|
||||
canMoveUp: rowHost.modelData.phase === "upcoming"
|
||||
? rowHost.modelData.phaseIndex > 0
|
||||
: (rowHost.modelData.phase === "history"
|
||||
&& root.historyVisualIndex(rowHost.modelData) > 0)
|
||||
canMoveDown: rowHost.modelData.phase === "upcoming"
|
||||
? rowHost.modelData.phaseIndex
|
||||
< (root.doc.upcomingCount || 0) - 1
|
||||
: (rowHost.modelData.phase === "history"
|
||||
&& root.historyVisualIndex(rowHost.modelData)
|
||||
< (root.doc.historyCount || 0) - 1)
|
||||
zebra: true
|
||||
artistLink: true
|
||||
draggable: root.dragAllowed(rowHost.modelData)
|
||||
@@ -682,12 +721,21 @@ Rectangle {
|
||||
showDownload: false
|
||||
showMenu: true
|
||||
showReorder: root.dragRow !== null
|
||||
&& root.dragRow.phase === "upcoming"
|
||||
&& (root.dragRow.phase === "upcoming"
|
||||
|| root.dragRow.phase === "history")
|
||||
&& !root.searchActive
|
||||
&& !root.rowBlocked(root.dragRow)
|
||||
canMoveUp: root.dragRow !== null && root.dragRow.phaseIndex > 0
|
||||
canMoveDown: root.dragRow !== null && root.dragRow.phaseIndex
|
||||
< (root.doc.upcomingCount || 0) - 1
|
||||
canMoveUp: root.dragRow !== null
|
||||
&& (root.dragRow.phase === "upcoming"
|
||||
? root.dragRow.phaseIndex > 0
|
||||
: (root.dragRow.phase === "history"
|
||||
&& root.historyVisualIndex(root.dragRow) > 0))
|
||||
canMoveDown: root.dragRow !== null
|
||||
&& (root.dragRow.phase === "upcoming"
|
||||
? root.dragRow.phaseIndex < (root.doc.upcomingCount || 0) - 1
|
||||
: (root.dragRow.phase === "history"
|
||||
&& root.historyVisualIndex(root.dragRow)
|
||||
< (root.doc.historyCount || 0) - 1))
|
||||
zebra: true
|
||||
artistLink: true
|
||||
draggable: false
|
||||
|
||||
@@ -1447,7 +1447,13 @@ pub(crate) fn queue_extended_play(phase: String, index: i32, expected_id: String
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn queue_extended_drop(phase: String, index: i32, expected_id: String, slot: i32) {
|
||||
pub(crate) fn queue_extended_drop(
|
||||
phase: String,
|
||||
index: i32,
|
||||
expected_id: String,
|
||||
target_phase: String,
|
||||
slot: i32,
|
||||
) {
|
||||
let Ok(track_id) = expected_id.parse::<u64>() else {
|
||||
return;
|
||||
};
|
||||
@@ -1458,6 +1464,7 @@ pub(crate) fn queue_extended_drop(phase: String, index: i32, expected_id: String
|
||||
&phase,
|
||||
index.max(0) as usize,
|
||||
track_id,
|
||||
&target_phase,
|
||||
slot.max(0) as usize,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -455,6 +455,17 @@ pub struct QtQconnectService {
|
||||
controller_manual: Mutex<ControllerManualBlock>,
|
||||
}
|
||||
|
||||
/// Result of routing a Queue View insertion to the active peer. The historical
|
||||
/// bool API intentionally treats both a refusal and a send failure as handled
|
||||
/// (never mutate the local queue while a peer owns playback); History needs the
|
||||
/// finer answer so it only retires its local occurrence after a confirmed send.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PeerInsertAtSlotOutcome {
|
||||
Inactive,
|
||||
Inserted,
|
||||
HandledFailure,
|
||||
}
|
||||
|
||||
/// Controller-mode mirror of the local queue's manual block (#442 "Play later").
|
||||
///
|
||||
/// The QConnect protocol has no "later" concept and the peer's queue carries no
|
||||
@@ -1748,8 +1759,19 @@ impl QtQconnectService {
|
||||
source: Option<&str>,
|
||||
slot: usize,
|
||||
) -> bool {
|
||||
self.insert_at_slot_on_peer_outcome(track_id, source, slot)
|
||||
.await
|
||||
!= PeerInsertAtSlotOutcome::Inactive
|
||||
}
|
||||
|
||||
pub(crate) async fn insert_at_slot_on_peer_outcome(
|
||||
&self,
|
||||
track_id: u64,
|
||||
source: Option<&str>,
|
||||
slot: usize,
|
||||
) -> PeerInsertAtSlotOutcome {
|
||||
if !self.is_peer_renderer_active().await {
|
||||
return false;
|
||||
return PeerInsertAtSlotOutcome::Inactive;
|
||||
}
|
||||
if !self.is_track_castable(track_id, source) {
|
||||
log::info!("[QConnect] insert_at_slot: track {track_id} not Qobuz-castable; refusing");
|
||||
@@ -1757,7 +1779,7 @@ impl QtQconnectService {
|
||||
dev_push_event(format!(
|
||||
"-> insert_at_slot REFUSED (non-Qobuz track {track_id})"
|
||||
));
|
||||
return true;
|
||||
return PeerInsertAtSlotOutcome::HandledFailure;
|
||||
}
|
||||
|
||||
let insert_after = self
|
||||
@@ -1800,13 +1822,14 @@ impl QtQconnectService {
|
||||
"-> insert_at_slot QueueInsertTracks {track_id} slot={slot} after={insert_after:?}"
|
||||
));
|
||||
self.note_controller_insert(insert_after, &[track_id]).await;
|
||||
PeerInsertAtSlotOutcome::Inserted
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("[QConnect] insert_at_slot: insert failed: {err}");
|
||||
// Still handled: a peer owns playback; never fall back to local.
|
||||
PeerInsertAtSlotOutcome::HandledFailure
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Controller play-LATER routing for a MULTI-track batch (#442). Same
|
||||
|
||||
@@ -109,6 +109,7 @@ pub mod qbz_queue {
|
||||
phase: QString,
|
||||
index: i32,
|
||||
expected_id: QString,
|
||||
target_phase: QString,
|
||||
slot: i32,
|
||||
);
|
||||
#[qinvokable]
|
||||
@@ -253,9 +254,16 @@ impl qbz_queue::QbzQueue {
|
||||
phase: QString,
|
||||
index: i32,
|
||||
expected_id: QString,
|
||||
target_phase: QString,
|
||||
slot: i32,
|
||||
) {
|
||||
crate::queue_extended_drop(phase.to_string(), index, expected_id.to_string(), slot);
|
||||
crate::queue_extended_drop(
|
||||
phase.to_string(),
|
||||
index,
|
||||
expected_id.to_string(),
|
||||
target_phase.to_string(),
|
||||
slot,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn queue_remove_upcoming_flat(self: Pin<&mut Self>, index: i32) {
|
||||
|
||||
@@ -888,16 +888,31 @@ pub async fn play_extended(
|
||||
return;
|
||||
}
|
||||
if let Some(service) = crate::qconnect_qt::service() {
|
||||
if service
|
||||
.insert_at_slot_on_peer_if_active(track.id, track.source.as_deref(), 0)
|
||||
match service
|
||||
.insert_at_slot_on_peer_outcome(track.id, track.source.as_deref(), 0)
|
||||
.await
|
||||
{
|
||||
if let Err(error) = service.play_remote_renderer_track_if_active(track.id).await
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::Inserted => {
|
||||
// The peer creates the new upcoming occurrence. Retire
|
||||
// the selected local history reference immediately so
|
||||
// its echo cannot leave this occurrence listed twice.
|
||||
runtime
|
||||
.core()
|
||||
.remove_history_entry(phase_index, expected_id)
|
||||
.await;
|
||||
publish(runtime).await;
|
||||
if let Err(error) =
|
||||
service.play_remote_renderer_track_if_active(track.id).await
|
||||
{
|
||||
log::warn!("[qbz-qt] queue: requeued remote history play failed: {error}");
|
||||
log::warn!(
|
||||
"[qbz-qt] queue: requeued remote history play failed: {error}"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::HandledFailure => return,
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::Inactive => {}
|
||||
}
|
||||
}
|
||||
if crate::local_playback::preflight_queue_track(&track)
|
||||
.await
|
||||
@@ -1122,17 +1137,24 @@ async fn insert_queue_track_at(
|
||||
Ok(InsertOutcome::Local)
|
||||
}
|
||||
|
||||
/// Commit a drag from the chronological view. Upcoming rows move; history rows
|
||||
/// are reinserted (not removed from history) at the dropped upcoming slot.
|
||||
/// Commit a Queue View drag. Upcoming rows reorder only inside Upcoming.
|
||||
/// History rows may reorder inside History or move their canonical queue
|
||||
/// occurrence back into Upcoming. The latter never clones by track id, so two
|
||||
/// separately enqueued copies remain distinct while replaying the same entry
|
||||
/// keeps only its latest history position.
|
||||
pub async fn drop_extended(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
phase: &str,
|
||||
phase_index: usize,
|
||||
expected_id: u64,
|
||||
target_phase: &str,
|
||||
to_slot: usize,
|
||||
) {
|
||||
match phase {
|
||||
"upcoming" => {
|
||||
if target_phase != "upcoming" {
|
||||
return;
|
||||
}
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
if state.upcoming.get(phase_index).map(|track| track.id) != Some(expected_id) {
|
||||
log::warn!("[qbz-qt] queue: upcoming changed during extended drag");
|
||||
@@ -1141,6 +1163,19 @@ pub async fn drop_extended(
|
||||
move_track_flat(runtime, phase_index, to_slot).await;
|
||||
}
|
||||
"history" => {
|
||||
if target_phase == "history" {
|
||||
if runtime
|
||||
.core()
|
||||
.move_history_entry(phase_index, expected_id, to_slot)
|
||||
.await
|
||||
{
|
||||
publish(runtime).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if target_phase != "upcoming" {
|
||||
return;
|
||||
}
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
let Some(track) = state.history.get(phase_index).cloned() else {
|
||||
return;
|
||||
@@ -1158,8 +1193,35 @@ pub async fn drop_extended(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = insert_queue_track_at(runtime, track, to_slot).await {
|
||||
log::warn!("[qbz-qt] queue: extended history drop failed: {error}");
|
||||
if let Some(service) = crate::qconnect_qt::service() {
|
||||
match service
|
||||
.insert_at_slot_on_peer_outcome(
|
||||
track.id,
|
||||
track.source.as_deref(),
|
||||
to_slot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::Inserted => {
|
||||
runtime
|
||||
.core()
|
||||
.remove_history_entry(phase_index, expected_id)
|
||||
.await;
|
||||
publish(runtime).await;
|
||||
return;
|
||||
}
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::HandledFailure => return,
|
||||
crate::qconnect_qt::PeerInsertAtSlotOutcome::Inactive => {}
|
||||
}
|
||||
}
|
||||
if runtime
|
||||
.core()
|
||||
.requeue_history_entry(phase_index, expected_id, to_slot)
|
||||
.await
|
||||
{
|
||||
publish(runtime).await;
|
||||
} else {
|
||||
log::warn!("[qbz-qt] queue: history changed during extended drag");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
Reference in new issue
Block a user