feat(playlists+queue): sort directions (#657), import refresh, Play later (#442)

- Sidebar + playlist manager sorts gain both directions with a caret on
  the active row (re-select flips); 'recent' keeps the API order
  (newest-first — the old reverse was the #657 bug).
- Import now inserts the new playlist optimistically and bounded-retries
  until the API lists it (read-after-write lag forced a manual refresh).
- Play later: tracks land at the end of the manual block (after every
  play-next/later, before the source resumes); Play next and Add to
  queue are untouched. The queue sidebar shows the section divider
  (Next in queue / Next up) with drag-math compensation, and the context
  menu shows the landing position of each queue action.

km
This commit is contained in:
vicrodh committed 2026-07-25 12:05:27 -06:00
1 parent d99419f22d
commit 953ba39e77
11 files changed
+380 -40

No files matched your search

+69
View File
@@ -36,6 +36,11 @@ struct InternalState {
history: VecDeque<usize>,
/// Track ID to stop after (optional)
stop_after_track_id: Option<u64>,
/// Manual-block size (#442): how many entries right after `current_index`
/// were added by hand ("Play next" / "Play later"). The block always
/// plays before the source (album/playlist) resumes; "Add to queue" does
/// NOT extend it (it appends to the absolute end, untouched).
manual_next_count: usize,
}
/// Queue manager for handling playback queue
@@ -61,6 +66,7 @@ impl QueueManager {
repeat: RepeatMode::Off,
history: VecDeque::with_capacity(50),
stop_after_track_id: None,
manual_next_count: 0,
}),
}
}
@@ -147,6 +153,8 @@ impl QueueManager {
} else {
state.tracks.insert(insert_index, track);
}
// The track joins the manual block (#442).
state.manual_next_count += 1;
if state.shuffle {
for idx in state.shuffle_order.iter_mut() {
@@ -170,10 +178,51 @@ impl QueueManager {
}
}
/// Add a track at the END of the manual block (#442 "Play later"): after
/// every play-next / play-later already queued, before the source
/// (album/playlist) resumes. "Add to queue" stays untouched at the
/// absolute end. With shuffle on, degrades to play-next — the block
/// concept is meaningless once the order is reshuffled.
pub fn add_track_later(&self, track: QueueTrack) {
let mut state = self.state.lock().unwrap();
let insert_index = if state.shuffle {
state.current_index.map(|idx| idx + 1).unwrap_or(0)
} else {
state
.current_index
.map(|idx| idx + 1 + state.manual_next_count)
.unwrap_or(state.manual_next_count)
};
let insert_index = insert_index.min(state.tracks.len());
state.tracks.insert(insert_index, track);
state.manual_next_count += 1;
if state.shuffle {
for idx in state.shuffle_order.iter_mut() {
if *idx >= insert_index {
*idx += 1;
}
}
let new_idx = insert_index;
let next_pos = if state.current_index.is_some() {
state.shuffle_position + 1
} else {
state.shuffle_order.len()
};
if next_pos >= state.shuffle_order.len() {
state.shuffle_order.push(new_idx);
} else {
state.shuffle_order.insert(next_pos, new_idx);
}
}
}
/// Set the entire queue (replaces existing)
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
let mut state = self.state.lock().unwrap();
state.stop_after_track_id = None;
// A full replacement is a new source: the manual block dissolves (#442).
state.manual_next_count = 0;
// Remap history by track id BEFORE replacing tracks so that legitimate
// plays survive queue version bumps / reorders. Entries whose track is
// no longer present are dropped. See bug #316.
@@ -259,6 +308,7 @@ impl QueueManager {
pub fn clear(&self, keep_current: bool) {
let mut state = self.state.lock().unwrap();
state.stop_after_track_id = None;
state.manual_next_count = 0;
if keep_current {
// Keep the track at `current_index`, not always `tracks[0]`.
@@ -307,6 +357,13 @@ impl QueueManager {
let removed = state.tracks.remove(index);
// Removing an entry inside the manual block shrinks it (#442).
if let Some(curr_idx) = state.current_index {
if index > curr_idx && index <= curr_idx + state.manual_next_count {
state.manual_next_count = state.manual_next_count.saturating_sub(1);
}
}
// Invalidate marker if the removed track matches
if state.stop_after_track_id == Some(removed.id) {
state.stop_after_track_id = None;
@@ -722,6 +779,14 @@ impl QueueManager {
};
state.current_index = next_idx;
// Manual-block bookkeeping (#442): advancing past a manual entry
// shrinks the block; exhausting or wrapping the queue dissolves it.
match next_idx {
Some(0) | None => state.manual_next_count = 0,
Some(_) => {
state.manual_next_count = state.manual_next_count.saturating_sub(1)
}
}
next_idx.and_then(|idx| state.tracks.get(idx).cloned())
}
@@ -870,6 +935,8 @@ impl QueueManager {
state.shuffle = enabled;
if enabled {
// The manual block is meaningless once the order is reshuffled (#442).
state.manual_next_count = 0;
Self::regenerate_shuffle_order_internal(&mut state);
// Enabling shuffle during active playback must keep current track
@@ -1019,6 +1086,7 @@ impl QueueManager {
repeat: state.repeat,
total_tracks: state.tracks.len(),
stop_after_track_id: state.stop_after_track_id,
manual_next_count: state.manual_next_count,
}
}
@@ -1078,6 +1146,7 @@ impl QueueManager {
repeat: state.repeat,
total_tracks: state.tracks.len(),
stop_after_track_id: state.stop_after_track_id,
manual_next_count: state.manual_next_count,
}
}