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

+12
View File
@@ -582,6 +582,18 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.await;
}
/// Add a track at the END of the manual block ("Play later", #442):
/// after every play-next / play-later already queued, before the source
/// resumes. "Add to queue" stays untouched at the absolute end.
pub async fn add_track_later(&self, track: QueueTrack) {
let queue = self.queue.write().await;
queue.add_track_later(track);
self.emit(CoreEvent::QueueUpdated {
state: queue.get_state(),
})
.await;
}
/// Set the entire queue (replaces existing)
pub async fn set_queue(&self, tracks: Vec<QueueTrack>, start_index: Option<usize>) {
// Any queue replacement drops the offline-only-playlist stamp; the
+4
View File
@@ -100,6 +100,10 @@ pub struct QueueState {
pub repeat: RepeatMode,
pub total_tracks: usize,
pub stop_after_track_id: Option<u64>,
/// Manual-block size (#442): how many entries right after `current_index`
/// were hand-queued ("Play next" / "Play later") and play before the
/// source resumes.
pub manual_next_count: usize,
}
// ============ Playback State ============
+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,
}
}
@@ -113,6 +113,9 @@ component PmSearch inherits Rectangle {
component PmMenuItem inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
// Sort rows: "up" | "down" draws the direction chevron in place of the
// plain check on the active row (#657 caret).
in property <string> trailing-icon: "";
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
// a hidden item collapses to zero height instead (the sidebar pattern).
in property <bool> shown: true;
@@ -137,7 +140,9 @@ component PmMenuItem inherits Rectangle {
if root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
: @image-url("../assets/icons/check.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
@@ -1193,26 +1198,36 @@ export component PlaylistManagerView inherits Rectangle {
PmMenuItem {
label: @tr("Name A-Z");
selected: PlaylistManagerState.sort == "name";
trailing-icon: PlaylistManagerState.sort == "name"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("name"); }
}
PmMenuItem {
label: @tr("Recent");
selected: PlaylistManagerState.sort == "recent";
trailing-icon: PlaylistManagerState.sort == "recent"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("recent"); }
}
PmMenuItem {
label: @tr("Play Count");
selected: PlaylistManagerState.sort == "playcount";
trailing-icon: PlaylistManagerState.sort == "playcount"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("playcount"); }
}
PmMenuItem {
label: @tr("Track Count");
selected: PlaylistManagerState.sort == "tracks";
trailing-icon: PlaylistManagerState.sort == "tracks"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("tracks"); }
}
PmMenuItem {
label: @tr("Custom");
selected: PlaylistManagerState.sort == "custom";
trailing-icon: PlaylistManagerState.sort == "custom"
? (PlaylistManagerState.sort-asc ? "up" : "down") : "";
activated => { PlaylistManagerActions.set-sort("custom"); }
}
}
@@ -5,6 +5,7 @@
import { ContextMenu } from "ContextMenu.slint";
import { ContextMenuItem } from "ContextMenuItem.slint";
import { QueueState } from "../state.slint";
export component TrackContextMenu inherits PopupWindow {
in property <string> track-id;
@@ -61,14 +62,15 @@ export component TrackContextMenu inherits PopupWindow {
width: 224px;
// Nav-only (blacklisted): only Go to album / Go to artist / Track info.
// Otherwise: 3 transient + optional favorite/mixtape/add-to-playlist/remove
// + optional go-to pair + the Qobuz block (4 fixed: QBZ radio, Qobuz
// radio, 2 shares; + 1-or-2 offline entries + optional track info).
// Otherwise: 4 transient (play/next/later/queue) + optional
// favorite/mixtape/add-to-playlist/remove + optional go-to pair + the
// Qobuz block (4 fixed: QBZ radio, Qobuz radio, 2 shares; + 1-or-2
// offline entries + optional track info).
height: (root.nav-only
? ((root.go-album-action ? 1 : 0)
+ (root.go-artist-action ? 1 : 0)
+ (root.show-track-info ? 1 : 0))
: (3
: (4
+ (root.favorite-action ? 1 : 0)
+ (root.mixtape-action ? 1 : 0)
+ (root.playlist-action ? 1 : 0)
@@ -89,14 +91,31 @@ export component TrackContextMenu inherits PopupWindow {
}
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
// #442 hint: where the track lands (1-based position in the
// queue) — only while a queue exists.
label: QueueState.queue-length > 0
? @tr("Play next") + " · #\{QueueState.queue-current-index + 2}"
: @tr("Play next");
clicked => {
root.media-action("track", root.track-id, "play-next");
}
}
// #442 "Play later" — the end of the manual block (after every
// play-next / play-later already queued, before the source resumes).
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: QueueState.queue-length > 0
? @tr("Play later") + " · #\{QueueState.queue-current-index + 2 + QueueState.manual-count}"
: @tr("Play later");
clicked => {
root.media-action("track", root.track-id, "play-later");
}
}
if !root.nav-only: ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
label: QueueState.queue-length > 0
? @tr("Add to queue") + " · #\{QueueState.queue-length + 1}"
: @tr("Add to queue");
clicked => {
root.media-action("track", root.track-id, "queue");
}
+40 -8
View File
@@ -382,9 +382,21 @@ export component QueueSidebar inherits Rectangle {
// Pointer window-Y -> insertion slot [0..N]. Rows are a fixed 44px stacked
// with 0 spacing; the +0.5 row rounds the boundary to row centers (top half
// of row k -> slot k, bottom half -> slot k+1).
// #442: section headers (22px) above row/slot index i — every drag
// computation below adds them to the uniform 44px strides.
pure function headers-above(i: int) -> int {
return (QueueState.page-manual-header && i >= 1 ? 1 : 0)
+ (QueueState.page-manual-pos >= 0 && i > QueueState.page-manual-pos ? 1 : 0);
}
pure function slot-from-pointer(win-y: length) -> int {
// One header-adjustment pass: with at most 2 headers and 44px rows,
// adjusting by the headers below the RAW slot lands on the right gap
// (boundary cases round to a row gap either way).
return Math.max(0, Math.min(QueueState.upcoming-page.length,
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)));
Math.floor((win-y - root.reorder-list-top
- root.headers-above(
Math.floor((win-y - root.reorder-list-top) / 44px + 0.5)) * 22px)
/ 44px + 0.5)));
}
function commit-reorder() {
root.reorder-active = false;
@@ -597,14 +609,31 @@ export component QueueSidebar inherits Rectangle {
letter-spacing: 0.5px;
}
// Page of upcoming rows + drop-indicator overlay. Explicit
// height (rows are a fixed 44px, spacing 0) so the wrapping
// Rectangle does not collapse — a VerticalLayout does not
// auto-size its parent Rectangle.
// height (rows are a fixed 44px, spacing 0; #442 section
// headers are 22px each) so the wrapping Rectangle does
// not collapse — a VerticalLayout does not auto-size its
// parent Rectangle.
Rectangle {
height: QueueState.upcoming-page.length * 44px;
height: QueueState.upcoming-page.length * 44px
+ QueueState.page-section-count * 22px;
VerticalLayout {
spacing: 0px;
for item[idx] in QueueState.upcoming-page: QueueRow {
for item[idx] in QueueState.upcoming-page: VerticalLayout {
spacing: 0px;
// #442 section header above the row (only on
// marked rows; "" everywhere else).
if item.section != "": Text {
text: item.section == "next-in-queue"
? @tr("Next in queue")
: @tr("Next up");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 0.5px;
height: 22px;
vertical-alignment: center;
}
QueueRow {
item: item;
index: QueueState.page-start - 1 + idx;
page-local-index: idx;
@@ -627,7 +656,8 @@ export component QueueSidebar inherits Rectangle {
root.reorder-active = true;
root.reorder-from = local-idx;
root.reorder-over = local-idx;
root.reorder-list-top = row-top - local-idx * 44px;
root.reorder-list-top = row-top
- (local-idx * 44px + root.headers-above(local-idx) * 22px);
}
drag-update(pointer-y) => {
root.reorder-over = root.slot-from-pointer(pointer-y);
@@ -638,13 +668,15 @@ export component QueueSidebar inherits Rectangle {
}
}
}
}
// Drop indicator: a 2px accent line at the insertion
// slot, hidden for the two no-op slots (from, from+1).
if root.reorder-active
&& root.reorder-over != root.reorder-from
&& root.reorder-over != root.reorder-from + 1: Rectangle {
x: 0;
y: root.reorder-over * 44px - 1px;
y: root.reorder-over * 44px
+ root.headers-above(root.reorder-over) * 22px - 1px;
width: parent.width;
height: 2px;
background: Theme.accent;
+17 -2
View File
@@ -26,6 +26,9 @@ component MenuItem inherits Rectangle {
in property <bool> selected: false;
in property <image> icon;
in property <bool> has-icon: false;
// Sort rows: "up" | "down" draws the direction chevron in place of the
// plain check on the active row (#657 caret).
in property <string> trailing-icon: "";
// When false the row is muted and ignores clicks (Import while
// offline). Same pattern as ContextMenuItem's enabled.
in property <bool> enabled: true;
@@ -56,11 +59,13 @@ component MenuItem inherits Rectangle {
horizontal-stretch: 1;
overflow: elide;
}
// Selected check (sort options).
// Selected check — or the direction chevron on a sort row.
if root.selected: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
source: root.trailing-icon == "up" ? @image-url("../assets/icons/chevron-up.svg")
: root.trailing-icon == "down" ? @image-url("../assets/icons/chevron-down.svg")
: @image-url("../assets/icons/check.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
@@ -946,26 +951,36 @@ export component Sidebar inherits Rectangle {
MenuItem {
label: @tr("Name (A-Z)");
selected: SidebarState.sort-option == "name";
trailing-icon: SidebarState.sort-option == "name"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("name"); }
}
MenuItem {
label: @tr("Recent");
selected: SidebarState.sort-option == "recent";
trailing-icon: SidebarState.sort-option == "recent"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("recent"); }
}
MenuItem {
label: @tr("# of tracks");
selected: SidebarState.sort-option == "tracks";
trailing-icon: SidebarState.sort-option == "tracks"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("tracks"); }
}
MenuItem {
label: @tr("Play Count");
selected: SidebarState.sort-option == "playcount";
trailing-icon: SidebarState.sort-option == "playcount"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("playcount"); }
}
MenuItem {
label: @tr("Custom");
selected: SidebarState.sort-option == "custom";
trailing-icon: SidebarState.sort-option == "custom"
? (SidebarState.sort-asc ? "up" : "down") : "";
activated => { SidebarActions.set-sort("custom"); }
}
Rectangle {
+81
View File
@@ -4065,6 +4065,54 @@ pub fn play_track_next(
});
}
/// Insert a single track at the END of the manual block ("Play later", #442):
/// after every play-next / play-later already queued, before the source
/// resumes. Mirrors `play_track_next`, routing to the peer's play-next as the
/// best effort in QConnect CONTROLLER mode (the QConnect protocol has no
/// "later" — play-next is the closest "soon" semantic a peer understands).
pub fn play_track_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
track_id: u64,
) {
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
if svc
.play_next_on_peer_if_active(track_id, Some("qobuz"))
.await
{
return;
}
}
let track = match runtime.core().get_track(track_id).await {
Ok(track) => track,
Err(e) => {
log::error!("[qbz-slint] playback: play-later get_track {track_id} failed: {e}");
return;
}
};
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
let album_artist = track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default();
let queue_track =
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork, None);
runtime.core().add_track_later(queue_track).await;
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Play a whole playlist (by id) NOW — replace the queue with the playlist's
/// tracks and start at the first one. Fetches the tracks fresh, so it works
/// from any playlist CARD (Discover / Search / Label carousels) without a
@@ -4352,6 +4400,39 @@ pub fn enqueue_queue_tracks(
});
}
/// Insert a batch of source-aware QueueTracks at the END of the manual block
/// (#442 "Play later"): selection order is preserved (each insert appends to
/// the block, no reverse needed). Peer best-effort = play-next batch — the
/// QConnect protocol has no "later".
pub fn enqueue_queue_tracks_later(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
tracks: Vec<QueueTrack>,
) {
if tracks.is_empty() {
return;
}
let tracks = filter_blacklisted_queue(tracks);
if tracks.is_empty() {
return;
}
handle.spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
let routed: Vec<(u64, Option<String>)> =
tracks.iter().map(|qt| (qt.id, qt.source.clone())).collect();
if svc.play_next_batch_on_peer_if_active(&routed).await {
return;
}
}
for track in tracks {
runtime.core().add_track_later(track).await;
}
refresh_sidebar(false);
crate::toast::success_weak(&weak, qbz_i18n::t("Added to play later"));
});
}
/// Append (or insert-next) a batch of already-loaded LocalLibrary rows to the
/// queue. Mirrors `enqueue_tracks` but for `LocalTrack`: `local_queue_track`
/// builds source-aware QueueTracks (is_local=true; "local"/"qobuz_download")
+17 -7
View File
@@ -320,8 +320,10 @@ pub fn reset_session(_window: &AppWindow) {
/// Order playlists by the active sort (mirrors `applySortToList`):
/// name (locale-ish), playcount desc, tracks (remote+local) desc, custom
/// (position asc); `recent` keeps the API order.
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
/// (position asc); `recent` keeps the API order (which IS newest-first —
/// #657). Every option's `asc == true` is its natural direction; `false`
/// reverses it (re-selecting the menu option flips the direction).
fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str, asc: bool) {
match sort {
"name" => list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"playcount" => list.sort_by(|a, b| b.play_count.cmp(&a.play_count)),
@@ -330,6 +332,9 @@ fn sort_playlists(list: &mut Vec<PmPlaylist>, sort: &str) {
// "recent" — keep insertion (API) order.
_ => {}
}
if !asc {
list.reverse();
}
}
/// Display-stage union of a Qobuz playlist and a LOCAL (library.db)
@@ -390,8 +395,9 @@ impl PmEntry<'_> {
}
/// `sort_playlists`, over the merged Qobuz + local display set (same
/// comparators; see `PmEntry` for the missing-stat rules).
fn sort_entries(list: &mut [PmEntry], sort: &str) {
/// comparators; see `PmEntry` for the missing-stat rules). `asc == false`
/// reverses the option's natural direction (#657 caret).
fn sort_entries(list: &mut [PmEntry], sort: &str, asc: bool) {
match sort {
"name" => list.sort_by_key(|e| e.name_lower()),
"playcount" => list.sort_by(|a, b| b.play_count().cmp(&a.play_count())),
@@ -400,6 +406,9 @@ fn sort_entries(list: &mut [PmEntry], sort: &str) {
// "recent" — Qobuz keeps API order, locals stay after it.
_ => {}
}
if !asc {
list.reverse();
}
}
/// The LOCAL playlists that pass the toolbar filters, name-sorted (their
@@ -576,6 +585,7 @@ pub fn rebuild(window: &AppWindow) {
let query = state.get_search_query().trim().to_lowercase();
let filter = state.get_filter().to_string();
let sort = state.get_sort().to_string();
let sort_asc = state.get_sort_asc();
let view_mode = state.get_view_mode().to_string();
let folder_mode = state.get_folder_mode();
@@ -608,7 +618,7 @@ pub fn rebuild(window: &AppWindow) {
// Qobuz set (B4) — see `PmEntry` for the missing-stat sort rules.
let mut entries: Vec<PmEntry> = filtered.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(&data, &query, &filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, &sort);
sort_entries(&mut entries, &sort, sort_asc);
let playlist_items: Vec<PmPlaylistItem> = entries.iter().map(|e| e.item()).collect();
let visible_count = playlist_items.len();
@@ -714,7 +724,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
if (searching || offline) && members.is_empty() {
continue;
}
sort_playlists(&mut members, sort);
sort_playlists(&mut members, sort, sort_asc);
let is_exp = searching || expanded.contains(&f.id);
rows.push(PmTreeRow {
kind: "folder".into(),
@@ -747,7 +757,7 @@ fn build_tree(data: &PmData, query: &str, filter: &str, sort: &str) -> Vec<PmTre
.collect();
let mut entries: Vec<PmEntry> = root.iter().map(PmEntry::Qobuz).collect();
entries.extend(local_entries(data, query, filter).into_iter().map(PmEntry::Local));
sort_entries(&mut entries, sort);
sort_entries(&mut entries, sort, sort_asc);
for e in &entries {
rows.push(PmTreeRow {
kind: "playlist".into(),
+39 -1
View File
@@ -89,6 +89,9 @@ struct RowData {
artwork_url: String,
playing: bool,
is_ephemeral: bool,
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
// manual row) | "next-up" (first row after the manual block).
section: String,
}
/// `M:SS` duration string.
@@ -144,6 +147,7 @@ fn row_from(track: &QueueTrack, playing: bool) -> RowData {
playing,
is_ephemeral: track.source.as_deref() == Some("ephemeral")
|| crate::ephemeral::is_ephemeral_id(track.id as i64),
section: String::new(),
}
}
@@ -258,7 +262,21 @@ impl QueueController {
let page_rows: Vec<RowData> = filtered[start..end]
.iter()
.map(|t| row_from(t, false))
.enumerate()
.map(|(i, t)| {
let mut r = row_from(t, false);
// #442 section markers — only on the unfiltered list (a
// search result has no meaningful sections).
if query.is_empty() {
let g = start + i;
if state.manual_next_count > 0 && g == 0 {
r.section = "next-in-queue".into();
} else if g == state.manual_next_count {
r.section = "next-up".into();
}
}
r
})
.collect();
// page-start / page-end are 1-based for the human-readable counter.
@@ -436,6 +454,25 @@ impl QueueController {
qs.set_page_count(page_count as i32);
qs.set_page_start(page_start as i32);
qs.set_page_end(page_end as i32);
qs.set_page_section_count(
page_rows.iter().filter(|r| !r.section.is_empty()).count() as i32
);
// #442 drag-math aids (see QueueSidebar's headers-above).
qs.set_page_manual_header(
query.is_empty() && state.manual_next_count > 0 && start == 0
);
qs.set_page_manual_pos(if query.is_empty()
&& state.manual_next_count >= start
&& state.manual_next_count < end
{
(state.manual_next_count - start) as i32
} else {
-1
});
// #442 menu-hint numbers (TrackContextMenu suffix).
qs.set_queue_length(state.total_tracks as i32);
qs.set_queue_current_index(state.current_index.map(|i| i as i32).unwrap_or(-1));
qs.set_manual_count(state.manual_next_count as i32);
let history_items: Vec<QueueItem> =
history_rows.iter().map(|r| to_item_reuse(r, &prior_all)).collect();
@@ -982,6 +1019,7 @@ fn to_item_reuse(
duration: row.duration.clone().into(),
explicit: row.explicit,
is_ephemeral: row.is_ephemeral,
section: row.section.clone().into(),
}
}
+60 -15
View File
@@ -93,10 +93,11 @@ pub struct SidebarData {
static EXPANDED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
/// Last loaded data, so expand/move rebuild without a refetch.
static CACHE: LazyLock<Mutex<SidebarData>> = LazyLock::new(|| Mutex::new(SidebarData::default()));
/// Active sort option, mirrored from `SidebarState.sort-option`. Session
/// scope (Tauri persists in localStorage; we have no equivalent store
/// here yet, so this matches the in-session behavior).
static SORT: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new("name".to_string()));
/// Active sort option + direction, mirrored from `SidebarState.sort-option` /
/// `.sort-asc`. Session scope (Tauri persists in localStorage; we have no
/// equivalent store here yet, so this matches the in-session behavior).
static SORT: LazyLock<Mutex<(String, bool)>> =
LazyLock::new(|| Mutex::new(("name".to_string(), true)));
/// Active playlist-name search query (lowercased), mirrored from
/// `SidebarState.search-query`. Filters the rebuilt list recursively.
static SEARCH: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
@@ -111,16 +112,28 @@ pub fn set_loading(window: &AppWindow, loading: bool) {
}
/// Update the active sort option and re-render (matches Tauri's five
/// options). Unknown values fall back to "name".
/// options). Unknown values fall back to "name". PlaylistView-style toggle:
/// re-selecting the ACTIVE option flips its direction (#657); a new option
/// starts at its natural direction (`asc == true` everywhere: name A→Z,
/// recent newest-first, tracks most-first, custom position asc).
pub fn set_sort(window: &AppWindow, option: &str) {
let opt = match option {
"name" | "recent" | "tracks" | "playcount" | "custom" => option,
_ => "name",
};
let mut asc = true;
if let Ok(mut s) = SORT.lock() {
*s = opt.to_string();
if s.0 == opt {
s.1 = !s.1;
} else {
s.0 = opt.to_string();
s.1 = true;
}
asc = s.1;
}
window.global::<SidebarState>().set_sort_option(opt.into());
let st = window.global::<SidebarState>();
st.set_sort_option(opt.into());
st.set_sort_asc(asc);
rebuild(window);
}
@@ -133,20 +146,28 @@ pub fn set_search(window: &AppWindow, query: &str) {
rebuild(window);
}
/// Order playlists by the active sort option, mirroring Tauri's
/// comparators. `recent` keeps reverse insertion order (most-recently
/// added first); `playcount` has no per-playlist count source here, so it
/// stays stable like Tauri does when `play_count` is absent (0).
/// Order playlists by the active sort option + direction, mirroring Tauri's
/// comparators. `recent` keeps the API order, which IS newest-first (#657 —
/// the old unconditional `.reverse()` was written when the list arrived
/// oldest-first and had been flipping it to oldest-first ever since);
/// `playcount` has no per-playlist count source here, so it also keeps the
/// API order like Tauri does when `play_count` is absent. Every option's
/// `asc == true` is its natural direction; `false` reverses it.
fn sort_playlists(playlists: &[SidebarPlaylist]) -> Vec<SidebarPlaylist> {
let sort = SORT.lock().map(|s| s.clone()).unwrap_or_else(|_| "name".into());
let (sort, asc) = SORT
.lock()
.map(|s| s.clone())
.unwrap_or_else(|_| ("name".into(), true));
let mut out: Vec<SidebarPlaylist> = playlists.to_vec();
match sort.as_str() {
"name" => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
"recent" => out.reverse(),
"tracks" => out.sort_by(|a, b| b.tracks_count.cmp(&a.tracks_count)),
"playcount" => { /* no play_count source — stable, like Tauri's absent field */ }
"custom" => out.sort_by(|a, b| a.position.cmp(&b.position)),
_ => out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())),
// recent / playcount — API order (newest first).
_ => {}
}
if !asc {
out.reverse();
}
out
}
@@ -408,6 +429,30 @@ pub fn rename_entry(window: &AppWindow, id: &str, name: &str) {
rebuild(window);
}
/// Optimistically INSERT a freshly-created Qobuz playlist row (import or
/// duplicate-create) so the sidebar shows it instantly — the authoritative
/// `getUserPlaylists` read lags the create by seconds (the import flow
/// reconciles with a bounded retry, mirroring `reconcile_sidebar_after_rename`).
/// Idempotent by id.
pub fn insert_qobuz_entry(window: &AppWindow, id: u64, name: &str, tracks_count: u32) {
if let Ok(mut cache) = CACHE.lock() {
if !cache.playlists.iter().any(|p| p.id == id) {
cache.playlists.insert(
0,
SidebarPlaylist {
id,
name: name.to_string(),
description: String::new(),
tracks_count,
cover_urls: Vec::new(),
position: 0,
},
);
}
}
rebuild(window);
}
/// Build a playlist `SidebarEntry` (with its cover URLs for the
/// micro-collage). The decoded `cover*` images stay default here and are
/// filled asynchronously by the artwork pipeline (see `artwork_jobs`).