diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 215bbed1b..7c052ceb6 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -582,6 +582,18 @@ impl QbzCore { .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, start_index: Option) { // Any queue replacement drops the offline-only-playlist stamp; the diff --git a/crates/qbz-models/src/playback.rs b/crates/qbz-models/src/playback.rs index bf85693ce..69619e7f0 100644 --- a/crates/qbz-models/src/playback.rs +++ b/crates/qbz-models/src/playback.rs @@ -100,6 +100,10 @@ pub struct QueueState { pub repeat: RepeatMode, pub total_tracks: usize, pub stop_after_track_id: Option, + /// 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 ============ diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index e6d6c5e4f..8050c06b9 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -36,6 +36,11 @@ struct InternalState { history: VecDeque, /// Track ID to stop after (optional) stop_after_track_id: Option, + /// 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, start_index: Option) { 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, } } diff --git a/crates/qbz-ui/ui/playlist/PlaylistManagerView.slint b/crates/qbz-ui/ui/playlist/PlaylistManagerView.slint index c8970cb2b..535c0c861 100644 --- a/crates/qbz-ui/ui/playlist/PlaylistManagerView.slint +++ b/crates/qbz-ui/ui/playlist/PlaylistManagerView.slint @@ -113,6 +113,9 @@ component PmSearch inherits Rectangle { component PmMenuItem inherits Rectangle { in property label; in property selected: false; + // Sort rows: "up" | "down" draws the direction chevron in place of the + // plain check on the active row (#657 caret). + in property 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 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"); } } } diff --git a/crates/qbz-ui/ui/primitives/TrackContextMenu.slint b/crates/qbz-ui/ui/primitives/TrackContextMenu.slint index 35bd01ba5..7fda892e8 100644 --- a/crates/qbz-ui/ui/primitives/TrackContextMenu.slint +++ b/crates/qbz-ui/ui/primitives/TrackContextMenu.slint @@ -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 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"); } diff --git a/crates/qbz-ui/ui/shell/QueueSidebar.slint b/crates/qbz-ui/ui/shell/QueueSidebar.slint index 495b6bf8d..06de814db 100644 --- a/crates/qbz-ui/ui/shell/QueueSidebar.slint +++ b/crates/qbz-ui/ui/shell/QueueSidebar.slint @@ -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; diff --git a/crates/qbz-ui/ui/shell/Sidebar.slint b/crates/qbz-ui/ui/shell/Sidebar.slint index c6418aebc..ea1d74ba3 100644 --- a/crates/qbz-ui/ui/shell/Sidebar.slint +++ b/crates/qbz-ui/ui/shell/Sidebar.slint @@ -26,6 +26,9 @@ component MenuItem inherits Rectangle { in property selected: false; in property icon; in property 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 trailing-icon: ""; // When false the row is muted and ignores clicks (Import while // offline). Same pattern as ContextMenuItem's enabled. in property 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 { diff --git a/crates/qbz/src/playback.rs b/crates/qbz/src/playback.rs index f72f6204a..afe1088b6 100644 --- a/crates/qbz/src/playback.rs +++ b/crates/qbz/src/playback.rs @@ -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, + 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, + handle: tokio::runtime::Handle, + tracks: Vec, +) { + 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)> = + 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") diff --git a/crates/qbz/src/playlist_manager.rs b/crates/qbz/src/playlist_manager.rs index 9fde47dde..5c248a9ea 100644 --- a/crates/qbz/src/playlist_manager.rs +++ b/crates/qbz/src/playlist_manager.rs @@ -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, 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, 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, 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 = 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 = 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 Vec = 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(), diff --git a/crates/qbz/src/queue.rs b/crates/qbz/src/queue.rs index 5860d980b..5a811f813 100644 --- a/crates/qbz/src/queue.rs +++ b/crates/qbz/src/queue.rs @@ -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 = 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 = 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(), } } diff --git a/crates/qbz/src/sidebar.rs b/crates/qbz/src/sidebar.rs index 2076943ce..9953fc7e6 100644 --- a/crates/qbz/src/sidebar.rs +++ b/crates/qbz/src/sidebar.rs @@ -93,10 +93,11 @@ pub struct SidebarData { static EXPANDED: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); /// Last loaded data, so expand/move rebuild without a refetch. static CACHE: LazyLock> = 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> = 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> = + 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> = 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::().set_sort_option(opt.into()); + let st = window.global::(); + 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 { - 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 = 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`).