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

@@ -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 {