feat(locallibrary): Artists tab rebuilt as two-column master/detail

Replaces the thin name+counts grid (which was built without a Tauri
review) with the real structure: a two-column master/detail, 1:1 with
the Svelte Artists tab.

Left rail: a merged/deduped, A-Z grouped master list of compact rows
(round avatar + display name + "N albums · M tracks") with a proportional
alpha jump strip and in-place search. The name-merge collapses
normalized-equal spellings into one canonical row (canonical = most
albums, tie by most tracks) and computes accurate unique album counts
from the album set's all_artists, mirroring Tauri's artistMergeResult.
Diacritics are folded best-effort (no unicode-normalization dep).

Right pane: the selected artist's albums, filtered IN PLACE from the
loaded album set by primary/all_artists/multi-credit match (no new
backend call) and rendered via the shared AlbumCollectionView; covers
load through a new LocalArtistAlbumCard artwork target. Clicking an
album opens the local album detail; the play action queues the album.

Custom DB artist images are resolved into the rail model; rendering them
(plus the capped/sequential Qobuz photo fetch) is the remaining image
follow-up — the rail shows the placeholder avatar until then.

cc
This commit is contained in:
vicrodh committed 2026-05-29 09:40:52 -06:00
1 parent d676a2f5a4
commit 5986779d85
5 files changed
+702 -85

No files matched your search

@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, LocalArtistItem, AlphaJump } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
@@ -299,6 +299,100 @@ component TreeRow inherits Rectangle {
}
}
// Vertical A-Z jump strip on the right edge of the artist rail. Emits
// jump(ordinal, index); the host scrolls its flickable proportionally.
// Ported 1:1 from FavoritesView's AlphaStrip.
component AlphaStrip inherits Rectangle {
in property <[AlphaJump]> jumps;
callback jump(int /* ordinal */, int /* index */);
width: 18px;
VerticalLayout {
alignment: center;
spacing: 0px;
for j[k] in root.jumps: Rectangle {
height: 15px;
width: 18px;
Text {
text: j.letter;
color: la.has-hover ? Theme.accent : Theme.text-muted;
font-size: 9px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
la := TouchArea {
mouse-cursor: pointer;
clicked => {
root.jump(k, j.index);
}
}
}
}
}
// One compact row in the Artists left rail — a 48px round avatar (custom/
// cached photo or a mic-style placeholder) + display name + "N albums ·
// M tracks". 1:1 with Tauri's .artist-card-compact. ADR-008: round avatar is
// the permitted icon-only circle; the row itself is a small-radius rectangle.
component LocalArtistRow inherits Rectangle {
in property <LocalArtistItem> item;
in property <bool> selected;
callback clicked(string /* artist name */);
height: 60px;
border-radius: 8px;
background: root.selected
? Theme.accent
: (ta.has-hover ? Theme.surface-hover : transparent);
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 48px;
height: 48px;
border-radius: 24px;
background: Theme.surface-elevated;
clip: true;
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 22px;
height: 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 3px;
Text {
text: root.item.display-name;
color: root.selected ? Theme.accent-text : Theme.text-primary;
font-size: Typography.body;
font-weight: root.selected ? Typography.semibold : Typography.medium;
overflow: elide;
}
Text {
text: "\{root.item.album-count} " + @tr("albums") + " · \{root.item.track-count} " + @tr("tracks");
color: root.selected ? Theme.accent-text : Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.name);
}
}
}
export component LocalLibraryView inherits Rectangle {
background: Theme.surface-main;
@@ -514,6 +608,23 @@ export component LocalLibraryView inherits Rectangle {
}
}
}
// Artists toolbar — search (filters the master rail in place).
if LocalLibraryState.active-tab == "artists"
&& LocalLibraryState.artists.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search artists");
sm: true;
query <=> LocalLibraryState.artists-search;
edited(q) => {
LocalLibraryActions.artists-search(q);
}
}
}
}
}
Rectangle { height: 14px; }
@@ -1101,7 +1212,7 @@ export component LocalLibraryView inherits Rectangle {
}
}
// -------- Artists tab — name + counts grid -------------------
// -------- Artists tab — two-column master/detail -------------
if LocalLibraryState.active-tab == "artists": Rectangle {
if LocalLibraryState.artists-loading: VerticalLayout {
alignment: center;
@@ -1110,9 +1221,20 @@ export component LocalLibraryView inherits Rectangle {
LoadingSpinner { size: 36px; }
}
}
// Empty library.
if !LocalLibraryState.artists-loading
&& LocalLibraryState.artists.length == 0: VerticalLayout {
alignment: center;
spacing: 10px;
HorizontalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 44px;
height: 44px;
tint: Theme.text-muted;
}
}
Text {
text: @tr("No artists in your local library yet.");
color: Theme.text-muted;
@@ -1120,75 +1242,190 @@ export component LocalLibraryView inherits Rectangle {
horizontal-alignment: center;
}
}
// Two-column layout.
if !LocalLibraryState.artists-loading
&& LocalLibraryState.artists.length > 0: Rectangle {
artists-flick := Flickable {
viewport-width: self.width;
viewport-height: artists-page.preferred-height;
artists-page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
padding-bottom: 100px;
alignment: start;
artists-grid := Rectangle {
property <length> card-w: 160px;
property <length> card-h: 200px;
property <length> gap: 24px;
property <int> columns: Math.max(1,
Math.floor((self.width + self.gap) / (self.card-w + self.gap)));
property <int> rows: Math.ceil(LocalLibraryState.artists.length / self.columns);
height: self.rows > 0
? self.rows * self.card-h + Math.max(0, self.rows - 1) * self.gap
: 0px;
for artist[i] in LocalLibraryState.artists: VerticalLayout {
x: Math.mod(i, artists-grid.columns) * (artists-grid.card-w + artists-grid.gap);
y: Math.floor(i / artists-grid.columns) * (artists-grid.card-h + artists-grid.gap);
width: artists-grid.card-w;
spacing: 10px;
Rectangle {
width: 120px;
height: 120px;
x: (parent.width - self.width) / 2;
border-radius: 60px;
background: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 44px;
height: 44px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
&& LocalLibraryState.artists.length > 0: HorizontalLayout {
spacing: 0px;
// ---- Left rail: merged, A-Z grouped master list ----
Rectangle {
width: 280px;
clip: true;
VerticalLayout {
padding-left: 24px;
padding-top: 12px;
spacing: 8px;
// Rail header — count after filter.
Text {
text: "\{LocalLibraryState.artists-shown} " + @tr("artists");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
// No-match state.
if LocalLibraryState.artists-shown == 0: Text {
text: @tr("No artists match your search.");
color: Theme.text-muted;
font-size: Typography.legal;
}
if LocalLibraryState.artists-shown > 0: Rectangle {
vertical-stretch: 1;
rail-flick := Flickable {
viewport-width: self.width;
viewport-height: rail-col.preferred-height;
rail-col := VerticalLayout {
width: parent.width;
padding-right: 22px;
padding-bottom: 40px;
spacing: 4px;
alignment: start;
for section[s] in LocalLibraryState.artists-grouped: VerticalLayout {
spacing: 2px;
Text {
text: section.letter;
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
for artist[i] in section.artists: LocalArtistRow {
item: artist;
selected: LocalLibraryState.artists-selected-name == artist.name;
clicked(name) => {
LocalLibraryActions.artists-select(name);
}
}
}
}
Text {
text: artist.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
width: artists-grid.card-w;
}
Text {
text: artist.subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
width: artists-grid.card-w;
}
if LocalLibraryState.artists-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 2px;
height: 100%;
jumps: LocalLibraryState.artists-alpha;
jump(ord, idx) => {
rail-flick.viewport-y = -(ord / Math.max(1, LocalLibraryState.artists-alpha.length - 1))
* Math.max(0, (rail-flick.viewport-height - rail-flick.height) / 1px) * 1px;
}
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: artists-flick.viewport-height;
visible-height: artists-flick.height;
viewport-y <=> artists-flick.viewport-y;
// Divider.
Rectangle {
width: 1px;
background: Theme.border-subtle;
}
// ---- Right pane: selected artist's albums ----
Rectangle {
horizontal-stretch: 1;
clip: true;
// Nothing selected.
if LocalLibraryState.artists-selected-name == "": VerticalLayout {
alignment: center;
spacing: 10px;
HorizontalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 44px;
height: 44px;
tint: Theme.text-muted;
}
}
Text {
text: @tr("Select an artist to view their albums");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
if LocalLibraryState.artists-selected-name != "": Rectangle {
VerticalLayout {
padding-left: 28px;
padding-right: 8px;
padding-top: 14px;
spacing: 12px;
// Header — artist name + album count.
VerticalLayout {
spacing: 2px;
Text {
text: LocalLibraryState.artists-selected-display;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
}
Text {
text: "\{LocalLibraryState.artists-selected-albums.length} " + @tr("albums");
color: Theme.text-muted;
font-size: Typography.legal;
}
}
if LocalLibraryState.artists-selected-loading: HorizontalLayout {
alignment: center;
padding-top: 30px;
LoadingSpinner { size: 28px; }
}
// No albums for this artist.
if !LocalLibraryState.artists-selected-loading
&& LocalLibraryState.artists-selected-albums.length == 0: VerticalLayout {
padding-top: 24px;
Text {
text: @tr("No albums found");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
// Album grid — self-scrolling.
if !LocalLibraryState.artists-selected-loading
&& LocalLibraryState.artists-selected-albums.length > 0: Rectangle {
vertical-stretch: 1;
artist-albums-flick := Flickable {
viewport-width: self.width;
viewport-height: artist-albums-page.preferred-height;
artist-albums-page := VerticalLayout {
width: parent.width;
padding-right: 20px;
padding-bottom: 100px;
alignment: start;
AlbumCollectionView {
albums: LocalLibraryState.artists-selected-albums;
is-grouped: false;
view-mode: "grid";
show-source: false;
show-favorite: false;
show-source-badge: true;
open-album(id) => {
LocalLibraryActions.open-album(id);
}
open-artist(id) => {
LocalLibraryActions.open-artist(id);
}
media-action(kind, id, action) => {
LocalLibraryActions.album-action(id, action);
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: artist-albums-flick.viewport-height;
visible-height: artist-albums-flick.height;
viewport-y <=> artist-albums-flick.viewport-y;
}
}
}
}
}
}
}
+34 -3
View File
@@ -343,8 +343,19 @@ export enum ToastKind { success, error, info, warning, buffering }
// One card in the Local Library Artists grid (name + "N albums · M tracks").
// Images + click-to-detail are a later slice.
export struct LocalArtistItem {
name: string,
subtitle: string,
name: string, // raw/canonical key (used for selection + matching)
display-name: string, // canonical display name (after name-merge)
album-count: int,
track-count: int,
image-path: string, // local file path for a custom/cached photo ("" = none)
}
// One A-Z section of the local Artists left rail (master list grouped by the
// display name's first letter, "#" bucket for non-alphabetic). 1:1 with the
// Tauri Artists tab, which groups the compact rows under sticky letters.
export struct LocalArtistSection {
letter: string,
artists: [LocalArtistItem],
}
// One visible row in the Folders tree (flattened-model lazy tree — Slint has
@@ -424,10 +435,24 @@ export global LocalLibraryState {
in property <[TrackItem]> folder-detail-tracks: [];
in property <bool> folder-detail-loading: false;
// --- Artists tab — name + counts grid (full load; images later) -----
// --- Artists tab — two-column master/detail (1:1 with Tauri) --------
// Left rail: the merged/deduped artist master list. `artists` is the full
// merged set (post name-merge); `artists-grouped` / `artists-alpha` are
// the search-and-group-derived render sets; `artists-shown` is the count
// after the search filter (drives the rail header).
in property <[LocalArtistItem]> artists: [];
in property <bool> artists-loading: false;
in property <bool> artists-load-failed: false;
in-out property <string> artists-search: "";
in property <[LocalArtistSection]> artists-grouped: [];
in property <[AlphaJump]> artists-alpha: [];
in property <int> artists-shown: 0;
// Right pane: the selected artist's albums (filtered in place from the
// loaded album set — no new backend call, mirroring Tauri).
in-out property <string> artists-selected-name: ""; // "" = none selected
in property <string> artists-selected-display: "";
in property <[AlbumCardItem]> artists-selected-albums: [];
in property <bool> artists-selected-loading: false;
}
export global LocalLibraryActions {
@@ -475,6 +500,12 @@ export global LocalLibraryActions {
// Tree: play one track from the selected folder's direct tracks (the
// folder's direct list becomes the queue, starting at this track).
callback folders-play-track(string /* track id */);
// --- Artists tab ---
// Filter the master list (two-way bound to artists-search; re-derive).
callback artists-search(string /* query */);
// Select an artist -> filter their albums into the right pane.
callback artists-select(string /* artist name */);
}
// Global toast state, driven by crate::toast. The Toast overlay (mounted