feat(queue): make history occurrences reorderable

cx
This commit is contained in:
vicrodh committed 2026-08-26 12:31:48 -06:00
1 parent 8d1fb5593d
commit 4630c40222
7 files changed
+717 -69

No files matched your search

+54
View File
@@ -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
View File
@@ -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]
+66 -18
View File
@@ -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
+8 -1
View File
@@ -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
+26 -3
View File
@@ -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
+9 -1
View File
@@ -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) {
+70 -8
View File
@@ -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");
}
}
_ => {}