- 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:
11 files changed
+380
-40
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user