feat(locallibrary): Tracks tab — group-by + multi-select + bulk actions

Bring the Tracks tab up to the Favorites precedent.

- Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an
  A-Z jump strip for Name (over the loaded set); show-album hidden under album
  grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs
  precedent; default "off"), loaded in navigate_local_library before the tab
  derives.
- Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode)
  + MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue /
  Play next / Add to playlist / Clear (no make-offline, no remove).
- derive_tracks (reduced port: local folder_alpha_key, no genre filter) +
  TRACKS_CURRENT selection-source cache (cloned in apply/append before the move)
  so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip.
- playback::enqueue_local_tracks (source-aware via local_queue_track; reversed
  for play-next); per-row play-next/queue wired in on_track_action.
- Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode
  + on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped
  (decided on the underlying LocalTrack.source, not the remapped TrackItem one).
- No-match empty state added.

cc
This commit is contained in:
vicrodh committed 2026-05-29 15:18:09 -06:00
1 parent 4e79386daf
commit 5a076187e7
7 files changed
+577 -57

No files matched your search

@@ -28,6 +28,7 @@ import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ListView } from "std-widgets.slint";
// One tab in the Local Library tab menu — underline style (ADR-008: NO
@@ -506,11 +507,20 @@ export component LocalLibraryView inherits Rectangle {
}
}
// Tracks toolbar — search.
// Tracks toolbar — multi-select toggle + search + group-by.
if LocalLibraryState.active-tab == "tracks"
&& (LocalLibraryState.tracks.length > 0
|| LocalLibraryState.tracks-search != ""): HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleIconBtn {
icon: @image-url("../assets/icons/square-check-big.svg");
clicked => {
LocalLibraryActions.tracks-toggle-multi-select();
}
}
}
VerticalLayout {
alignment: center;
ExpandableSearch {
@@ -522,6 +532,27 @@ export component LocalLibraryView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 160px;
sm: true;
options: [
@tr("No grouping"),
@tr("By album"),
@tr("By artist"),
@tr("By name"),
];
current-index: LocalLibraryState.tracks-group-mode == "album" ? 1
: LocalLibraryState.tracks-group-mode == "artist" ? 2
: LocalLibraryState.tracks-group-mode == "name" ? 3
: 0;
selected(i) => {
LocalLibraryActions.tracks-set-group(
i == 1 ? "album" : i == 2 ? "artist" : i == 3 ? "name" : "off");
}
}
}
}
// Folders toolbar — search (both modes); flat adds sort + group +
@@ -846,20 +877,43 @@ export component LocalLibraryView inherits Rectangle {
horizontal-alignment: center;
}
}
// No-match (search/filter emptied the visible set).
if !LocalLibraryState.tracks-loading
&& !LocalLibraryState.tracks-load-failed
&& LocalLibraryState.tracks.length > 0: Rectangle {
// Padded content, but the scrollbar floats at the view's
// TRUE right edge — matching the Albums grid, which is the
// chrome-bezel standard. (Previously it was inset by the
// 32px content padding, sitting too far into the content.)
&& LocalLibraryState.tracks.length > 0
&& LocalLibraryState.tracks-visible.length == 0: VerticalLayout {
alignment: center;
Text {
text: @tr("No tracks match your search.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
if !LocalLibraryState.tracks-loading
&& !LocalLibraryState.tracks-load-failed
&& LocalLibraryState.tracks-visible.length > 0: Rectangle {
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
// Bulk action bar (Tauri Tracks subset — no make-offline/remove).
if LocalLibraryState.tracks-multi-select: MultiSelectBar {
selected-count: LocalLibraryState.tracks-selected-count;
actions: [
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), danger: false, needs-selection: true },
];
action(id) => {
LocalLibraryActions.tracks-bulk-action(id);
}
}
if LocalLibraryState.tracks-multi-select: Rectangle { height: 8px; }
tracks-list := ListView {
width: 100%;
height: 100%;
vertical-stretch: 1;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
changed viewport-y => {
if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px
@@ -869,28 +923,75 @@ export component LocalLibraryView inherits Rectangle {
LocalLibraryActions.tracks-load-more();
}
}
for track[i] in LocalLibraryState.tracks: TrackRow {
track: track;
index: i;
show-artwork: false;
show-album: true;
show-favorite: false;
show-download: false;
show-source: true;
media-action(kind, id, action) => {
if (kind == "album" && action == "open") {
LocalLibraryActions.open-album(id);
} else if (kind == "artist" && action == "open") {
LocalLibraryActions.open-artist(id);
} else {
LocalLibraryActions.track-action(id, action);
for track[i] in LocalLibraryState.tracks-visible: VerticalLayout {
spacing: 0px;
// Group header rows for album / artist grouping.
if LocalLibraryState.tracks-group-mode == "album" && track.album != ""
&& (i == 0 || LocalLibraryState.tracks-visible[i - 1].album != track.album): Rectangle {
height: 32px;
Text {
x: 2px;
width: parent.width - 4px;
height: 100%;
text: track.album;
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.semibold;
vertical-alignment: center;
overflow: elide;
}
}
if LocalLibraryState.tracks-group-mode == "artist" && track.artist != ""
&& (i == 0 || LocalLibraryState.tracks-visible[i - 1].artist != track.artist): Rectangle {
height: 32px;
Text {
x: 2px;
width: parent.width - 4px;
height: 100%;
text: track.artist;
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.semibold;
vertical-alignment: center;
overflow: elide;
}
}
TrackRow {
track: track;
index: i;
show-artwork: false;
show-album: LocalLibraryState.tracks-group-mode != "album";
show-favorite: false;
show-download: false;
show-source: true;
multi-select-mode: LocalLibraryState.tracks-multi-select;
media-action(kind, id, action) => {
if (kind == "album" && action == "open") {
LocalLibraryActions.open-album(id);
} else if (kind == "artist" && action == "open") {
LocalLibraryActions.open-artist(id);
} else {
LocalLibraryActions.track-action(id, action);
}
}
}
}
}
}
// A-Z jump strip for name grouping (far right; scrollbar insets).
if LocalLibraryState.tracks-group-mode == "name"
&& LocalLibraryState.tracks-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 2px;
height: 100%;
jumps: LocalLibraryState.tracks-alpha;
jump(ord, idx) => {
tracks-list.viewport-y = -idx * 50px;
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
x: LocalLibraryState.tracks-group-mode == "name"
? parent.width - self.width - 22px
: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: tracks-list.viewport-height;
+17 -1
View File
@@ -414,6 +414,13 @@ export global LocalLibraryState {
in property <bool> tracks-load-failed: false;
in-out property <int> tracks-next-offset: 0;
in-out property <string> tracks-search: "";
// Group-by + multi-select (this slice). `tracks-visible` is the
// group-ordered + search-filtered render model (the list renders THIS).
in property <[TrackItem]> tracks-visible: [];
in-out property <string> tracks-group-mode: "off"; // off | album | artist | name
in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping)
in-out property <bool> tracks-multi-select: false; // checkbox column on
in property <int> tracks-selected-count: 0; // bar count + select-all
// --- Folders tab ---------------------------------------------------
// flat = album grid grouped by directory; tree = two-pane lazy filesystem.
@@ -491,8 +498,14 @@ export global LocalLibraryActions {
callback tracks-search(string /* query */);
callback tracks-load-more();
callback tracks-retry();
// Per-row play/queue — local playback lands with the source-aware slice.
// Per-row play/queue/toggle-select (action: play|play-next|queue|toggle-select).
callback track-action(string /* track id */, string /* action */);
// Group-by mode (off|album|artist|name), persisted.
callback tracks-set-group(string /* mode */);
// Toggle the multi-select checkbox column.
callback tracks-toggle-multi-select();
// Bulk bar action: select-all | clear | queue | play-next | add-to-playlist.
callback tracks-bulk-action(string /* action */);
// --- Folders tab ---
callback folders-search(string /* query */);
@@ -1809,6 +1822,9 @@ export global PlaylistPickerState {
in property <[string]> track-ids: []; // bulk add (favorites multi-select)
in property <[PlaylistPickItem]> playlists: [];
in property <bool> loading: false;
// True when track-ids are LocalLibrary row ids (i64) -> route to
// add_local_track_to_playlist instead of the Qobuz endpoint.
in property <bool> local-mode: false;
}
export global PlaylistPickerActions {