feat(locallibrary): Albums tab — server-paginated chunked grid
First Local Library data slice. The Albums tab browses the metadata-grouped local albums via the performant server-paginated path (the same query Tauri's chunked store uses), reusing the shared AlbumCollectionView grid — no new rendering. Scales past the documented ~16K-row Tracks freeze by never materializing the full set. - local_library.rs: map_local_album (LocalAlbum -> the plain album_map AlbumCard, Send-safe; the non-Send slint::Image conversion via to_item happens on the UI thread), local_quality (Hz -> kHz), sort_params; a blocking fetch_albums_page over LibraryDatabase::get_albums_metadata_page (spawn_blocking); apply/append + reload/ensure/load_more driven by an ALBUMS_GEN generation guard so a search/sort/retry can't be clobbered by a stale in-flight page. - LocalLibraryView.slint: Albums body reusing AlbumCollectionView (grid, show-favorite: false), a search + sort toolbar (both server-side), auto load-more on scroll-near-bottom (DiscoverBrowse pattern), and loading / error+retry / empty states. - Local covers: ArtworkTarget::LocalAlbumCard + spawn_local_loads resolve the on-disk cover path through the source-aware ArtworkRef::LocalFile pipeline; the target carries the generation so a cover decoded after a reload is dropped instead of landing on a replaced row. - state.slint: LocalLibraryState album fields + LocalLibraryActions callbacks. - main.rs: navigate_local_library lazily loads page 1 on first visit; the on_albums_* callbacks wire search/sort/load-more/retry (open-album / open-artist / album-action are TODO no-ops until the detail + playback slices). List/group/A-Z/quality filters, album detail, and playback are later slices. include_qobuz_downloads mirrors the show_in_library setting default (true). Built green (cargo check -p qbz-slint); runtime smoke-test pending. cc
This commit is contained in:
5 files changed
+677
-11
No files matched your search
@@ -21,6 +21,11 @@ import { Typography } from "../foundation/typography.slint";
|
||||
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
||||
import { NavButtons } from "../shell/NavButtons.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
||||
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
||||
|
||||
// One tab in the Local Library tab menu — underline style (ADR-008: NO
|
||||
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
|
||||
@@ -184,15 +189,65 @@ export component LocalLibraryView inherits Rectangle {
|
||||
Rectangle { height: 12px; }
|
||||
|
||||
// Row 2 — per-tab action buttons (left) + toolbar (right). Filled as
|
||||
// each tab slice lands (Albums: play/shuffle + search/sort/view).
|
||||
// vertical-stretch: 0 is load-bearing (see FavoritesView): without it
|
||||
// an empty row grabs leftover height and inflates the sub-header.
|
||||
// each tab slice lands. vertical-stretch: 0 is load-bearing (see
|
||||
// FavoritesView): without it an empty row grabs leftover height and
|
||||
// inflates the sub-header.
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 0;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
spacing: 8px;
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
// Albums toolbar — search + sort (both server-side). The
|
||||
// grid/list toggle, group-by and filters land with the next slice.
|
||||
if LocalLibraryState.active-tab == "albums"
|
||||
&& (LocalLibraryState.albums.length > 0
|
||||
|| LocalLibraryState.albums-search != ""): HorizontalLayout {
|
||||
spacing: 8px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ExpandableSearch {
|
||||
placeholder: @tr("Search");
|
||||
sm: true;
|
||||
query <=> LocalLibraryState.albums-search;
|
||||
edited(q) => {
|
||||
LocalLibraryActions.albums-search(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
menu-width: 180px;
|
||||
options: [
|
||||
@tr("Artist A-Z"),
|
||||
@tr("Artist Z-A"),
|
||||
@tr("Title A-Z"),
|
||||
@tr("Title Z-A"),
|
||||
@tr("Year (newest)"),
|
||||
@tr("Year (oldest)"),
|
||||
];
|
||||
sm: true;
|
||||
current-index: LocalLibraryState.albums-sort == "artist-asc" ? 0
|
||||
: LocalLibraryState.albums-sort == "artist-desc" ? 1
|
||||
: LocalLibraryState.albums-sort == "title-asc" ? 2
|
||||
: LocalLibraryState.albums-sort == "title-desc" ? 3
|
||||
: LocalLibraryState.albums-sort == "year-desc" ? 4
|
||||
: 5;
|
||||
selected(i) => {
|
||||
LocalLibraryActions.albums-set-sort(
|
||||
i == 0 ? "artist-asc"
|
||||
: i == 1 ? "artist-desc"
|
||||
: i == 2 ? "title-asc"
|
||||
: i == 3 ? "title-desc"
|
||||
: i == 4 ? "year-desc"
|
||||
: "year-asc");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 14px; }
|
||||
@@ -206,8 +261,139 @@ export component LocalLibraryView inherits Rectangle {
|
||||
vertical-stretch: 1;
|
||||
clip: true;
|
||||
|
||||
// Placeholder until each tab slice lands (Albums is next).
|
||||
VerticalLayout {
|
||||
// -------- Albums tab — chunked grid --------------------------
|
||||
if LocalLibraryState.active-tab == "albums": Rectangle {
|
||||
// Initial page load.
|
||||
if LocalLibraryState.albums-loading: VerticalLayout {
|
||||
alignment: center;
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
LoadingSpinner { size: 36px; }
|
||||
}
|
||||
}
|
||||
// Load error + retry.
|
||||
if !LocalLibraryState.albums-loading
|
||||
&& LocalLibraryState.albums-load-failed: VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 10px;
|
||||
Text {
|
||||
text: @tr("Couldn't load your local albums.");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.semibold;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: retry-label.preferred-width + 28px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
retry-label := Text {
|
||||
text: @tr("Retry");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.legal;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
retry-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
LocalLibraryActions.albums-retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Empty — no albums indexed yet.
|
||||
if !LocalLibraryState.albums-loading
|
||||
&& !LocalLibraryState.albums-load-failed
|
||||
&& LocalLibraryState.albums.length == 0
|
||||
&& LocalLibraryState.albums-search == "": VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No albums in your local library yet.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
// Empty — search matched nothing.
|
||||
if !LocalLibraryState.albums-loading
|
||||
&& !LocalLibraryState.albums-load-failed
|
||||
&& LocalLibraryState.albums.length == 0
|
||||
&& LocalLibraryState.albums-search != "": VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: @tr("No albums match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
// The grid — self-scrolling, paged on scroll-near-bottom.
|
||||
if !LocalLibraryState.albums-loading
|
||||
&& !LocalLibraryState.albums-load-failed
|
||||
&& LocalLibraryState.albums.length > 0: Rectangle {
|
||||
albums-flick := Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: albums-page.preferred-height;
|
||||
changed viewport-y => {
|
||||
if (-self.viewport-y + self.height >= self.viewport-height - 600px
|
||||
&& LocalLibraryState.albums-has-more
|
||||
&& !LocalLibraryState.albums-loading-more
|
||||
&& !LocalLibraryState.albums-loading) {
|
||||
LocalLibraryActions.albums-load-more();
|
||||
}
|
||||
}
|
||||
albums-page := VerticalLayout {
|
||||
width: parent.width;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 100px;
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
AlbumCollectionView {
|
||||
albums: LocalLibraryState.albums;
|
||||
is-grouped: false;
|
||||
view-mode: "grid";
|
||||
show-source: false;
|
||||
// Local albums aren't Qobuz-favoritable — hide the heart.
|
||||
show-favorite: false;
|
||||
open-album(id) => {
|
||||
LocalLibraryActions.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
LocalLibraryActions.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
LocalLibraryActions.album-action(id, action);
|
||||
}
|
||||
}
|
||||
if LocalLibraryState.albums-loading-more: HorizontalLayout {
|
||||
alignment: center;
|
||||
padding-top: 16px;
|
||||
LoadingSpinner { size: 24px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
ListScrollbar {
|
||||
x: parent.width - self.width - 4px;
|
||||
width: 14px;
|
||||
height: 100%;
|
||||
viewport-height: albums-flick.viewport-height;
|
||||
visible-height: albums-flick.height;
|
||||
viewport-y <=> albums-flick.viewport-y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Other tabs — placeholder until their slices land ----
|
||||
if LocalLibraryState.active-tab != "albums": VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 6px;
|
||||
Text {
|
||||
@@ -218,7 +404,7 @@ export component LocalLibraryView inherits Rectangle {
|
||||
font-weight: Typography.semibold;
|
||||
}
|
||||
Text {
|
||||
text: @tr("Building the browse tabs slice by slice.");
|
||||
text: @tr("This tab is coming next.");
|
||||
horizontal-alignment: center;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
|
||||
Reference in new issue
Block a user