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

+12
View File
@@ -85,6 +85,9 @@ pub enum ArtworkTarget {
LocalAlbumCard { index: usize, gen: u64 },
/// A card in LocalLibraryState.folders[index] (Folders-flat grid).
LocalFolderCard { index: usize },
/// A card in LocalLibraryState.artists-selected-albums[index] (the
/// Artists tab right pane — the selected artist's albums).
LocalArtistAlbumCard { index: usize },
/// A card in FavoritesState.artists[index].
FavoriteArtist { index: usize },
/// A card in FavoritesState.labels[index].
@@ -622,6 +625,15 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::LocalArtistAlbumCard { index } => {
let model = window
.global::<crate::LocalLibraryState>()
.get_artists_selected_albums();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::FavoriteArtist { index } => {
let model = window.global::<crate::FavoritesState>().get_artists();
if let Some(mut item) = model.row_data(index) {
+327 -18
View File
@@ -17,8 +17,8 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::{
AlbumCardItem, AppWindow, DiscoverSection, FolderNode, LocalArtistItem, LocalLibraryState,
TrackItem,
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FolderNode, LocalArtistItem,
LocalArtistSection, LocalLibraryState, TrackItem,
};
/// The four browse tabs. Order mirrors Tauri's default tab order
@@ -1036,27 +1036,265 @@ pub fn select_folder(
// ============================== Artists tab ===============================
//
// A full-loaded grid of artists (name + album/track counts). Images +
// click-to-detail are a later slice; merge/dedup of normalized-equal names
// (the Tauri artistMergeResult) is deferred — v1 shows the DB's distinct
// album-artist rows.
// Two-column master/detail, 1:1 with Tauri's Artists tab (NOT a card grid —
// the Svelte VirtualizedArtistGrid imports there are dead). Left rail: a
// merged/deduped, alpha-grouped master list of compact rows (round avatar +
// name + "N albums · M tracks"). Right pane: the selected artist's albums,
// filtered IN PLACE from the loaded album set (no new backend call). The
// name-merge collapses normalized-equal spellings into one canonical row.
//
// The Qobuz background image fetch (capped/sequential in Tauri, and whose DB
// batch path is broken there) is the remaining follow-up; this pass wires the
// DB custom-image path + the mic placeholder.
fn apply_artists(window: &AppWindow, artists: Vec<qbz_library::LocalArtist>) {
let items: Vec<LocalArtistItem> = artists
.into_iter()
.map(|a| LocalArtistItem {
name: a.name.into(),
subtitle: format!("{} albums · {} tracks", a.album_count, a.track_count).into(),
/// The loaded album set, cached so the right-pane filter (select) doesn't
/// re-hit the DB — mirrors Tauri filtering its in-memory `albums` array.
static ARTIST_ALBUMS: std::sync::Mutex<Vec<qbz_library::LocalAlbum>> =
std::sync::Mutex::new(Vec::new());
/// Fold a common Latin accented char to its ASCII base (best-effort, no
/// `unicode-normalization` dep). Covers Spanish/European music metadata; the
/// uncovered tail just won't merge across diacritics. Mirrors the intent of
/// Tauri's NFKD + combining-mark strip in `normalizeArtistName`.
fn fold_diacritic(c: char) -> char {
match c {
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ā' => 'a',
'é' | 'è' | 'ê' | 'ë' | 'ē' | 'ė' => 'e',
'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i',
'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ō' | 'ø' => 'o',
'ú' | 'ù' | 'û' | 'ü' | 'ū' => 'u',
'ñ' => 'n',
'ç' => 'c',
'ý' | 'ÿ' => 'y',
'ß' => 's',
_ => c,
}
}
/// Normalize an artist name for merge/match: lowercase, fold diacritics,
/// collapse every run of non-alphanumerics to a single space, trim. So
/// "Alice In Chains" and "alice in chains" both -> "alice in chains".
fn normalize_artist(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_space = false;
for ch in name.to_lowercase().chars() {
let c = fold_diacritic(ch);
if c.is_ascii_alphanumeric() {
out.push(c);
prev_space = false;
} else if !prev_space {
out.push(' ');
prev_space = true;
}
}
out.trim().to_string()
}
/// Split a credit string into individual artist names on the usual
/// separators (comma already handled by the caller for `all_artists`).
fn split_credit(s: &str) -> Vec<String> {
s.split([',', '&', '/', ';'])
.flat_map(|p| {
p.split(" feat ")
.flat_map(|q| q.split(" ft "))
.flat_map(|q| q.split(" featuring "))
.flat_map(|q| q.split(" with "))
.map(|q| q.to_string())
.collect::<Vec<_>>()
})
.collect()
}
/// Build the per-normalized-artist set of album ids, so merged rows get an
/// accurate unique album count independent of per-track spelling. Mirrors
/// Tauri's `artistAlbumIds`.
fn build_artist_album_ids(
albums: &[qbz_library::LocalAlbum],
) -> std::collections::HashMap<String, std::collections::HashSet<String>> {
let mut map: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for al in albums {
if !al.all_artists.is_empty() {
for part in al.all_artists.split(',') {
let n = normalize_artist(part);
if n.is_empty() || n == "various artists" {
continue;
}
map.entry(n).or_default().insert(al.id.clone());
}
} else {
let n = normalize_artist(&al.artist);
if !n.is_empty() && n != "various artists" {
map.entry(n).or_default().insert(al.id.clone());
}
}
}
map
}
/// Collapse normalized-equal artist spellings into one canonical row and
/// attach accurate album counts + a custom-image path. Mirrors Tauri's
/// `artistMergeResult`: canonical = the variant with most albums (tie: most
/// tracks); merged track count = sum across variants.
fn merge_artists(
artists: Vec<qbz_library::LocalArtist>,
albums: &[qbz_library::LocalAlbum],
custom_images: &std::collections::HashMap<String, String>,
) -> Vec<LocalArtistItem> {
let album_ids = build_artist_album_ids(albums);
let norm_imgs: std::collections::HashMap<String, String> = custom_images
.iter()
.map(|(k, v)| (normalize_artist(k), v.clone()))
.collect();
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalArtist>> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for a in artists {
let n = normalize_artist(&a.name);
if n.is_empty() {
continue;
}
if !groups.contains_key(&n) {
order.push(n.clone());
}
groups.entry(n).or_default().push(a);
}
let mut out: Vec<LocalArtistItem> = Vec::with_capacity(order.len());
for n in order {
let variants = match groups.remove(&n) {
Some(v) => v,
None => continue,
};
let album_set_len = album_ids.get(&n).map(|s| s.len()).unwrap_or(0) as i32;
let (canonical, album_count, track_count) = if variants.len() == 1 {
let v = &variants[0];
let ac = if album_set_len > 0 {
album_set_len
} else {
v.album_count as i32
};
(v.name.clone(), ac, v.track_count as i32)
} else {
let canon = variants
.iter()
.max_by(|a, b| {
a.album_count
.cmp(&b.album_count)
.then(a.track_count.cmp(&b.track_count))
})
.unwrap();
let total_tracks: u32 = variants.iter().map(|v| v.track_count).sum();
let ac = if album_set_len > 0 {
album_set_len
} else {
canon.album_count as i32
};
(canon.name.clone(), ac, total_tracks as i32)
};
let image_path = norm_imgs.get(&n).cloned().unwrap_or_default();
out.push(LocalArtistItem {
name: canonical.clone().into(),
display_name: canonical.into(),
album_count,
track_count,
image_path: image_path.into(),
});
}
out.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
out
}
/// Does this album credit the (normalized) selected artist — as primary, in
/// `all_artists`, or as one part of a multi-artist credit? Mirrors Tauri's
/// `selectedArtistAlbums` predicate.
fn album_matches_artist(al: &qbz_library::LocalAlbum, nsel: &str) -> bool {
if nsel == "various artists" {
return normalize_artist(&al.artist) == "various artists";
}
if normalize_artist(&al.artist) == nsel {
return true;
}
for part in al.all_artists.split(',') {
if normalize_artist(part) == nsel {
return true;
}
}
for part in split_credit(&al.artist) {
if normalize_artist(&part) == nsel {
return true;
}
}
false
}
/// Re-derive the left-rail render sets (search filter + A-Z grouping + jump
/// strip) from the merged `artists` master list.
pub fn derive_artists(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
let query_owned = s.get_artists_search().to_lowercase();
let query = query_owned.trim();
let all = s.get_artists();
let filtered: Vec<LocalArtistItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|a| query.is_empty() || a.display_name.to_lowercase().contains(query))
.collect();
s.set_artists_shown(filtered.len() as i32);
let mut map: Vec<(String, Vec<LocalArtistItem>)> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for item in filtered {
let key = folder_alpha_key(item.display_name.as_str());
let idx = *index.entry(key.clone()).or_insert_with(|| {
map.push((key.clone(), Vec::new()));
map.len() - 1
});
map[idx].1.push(item);
}
map.sort_by(|(a, _), (b, _)| match (a == "#", b == "#") {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => a.to_lowercase().cmp(&b.to_lowercase()),
});
let jumps: Vec<AlphaJump> = map
.iter()
.enumerate()
.map(|(i, (k, _))| AlphaJump {
letter: k.clone().into(),
index: i as i32,
})
.collect();
let sections: Vec<LocalArtistSection> = map
.into_iter()
.map(|(letter, artists)| LocalArtistSection {
letter: letter.into(),
artists: ModelRc::new(VecModel::from(artists)),
})
.collect();
s.set_artists_grouped(ModelRc::new(VecModel::from(sections)));
s.set_artists_alpha(ModelRc::new(VecModel::from(jumps)));
}
fn apply_artists(window: &AppWindow, items: Vec<LocalArtistItem>) {
let s = window.global::<LocalLibraryState>();
s.set_artists(ModelRc::new(VecModel::from(items)));
s.set_artists_loading(false);
s.set_artists_load_failed(false);
derive_artists(window);
}
/// Load the artists grid on first visit (re-entry keeps it).
pub fn ensure_artists_loaded(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
/// Load + merge the artists master list on first visit (re-entry keeps it).
/// Also caches the album set for the right-pane filter.
pub fn ensure_artists_loaded(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
) {
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<LocalLibraryState>();
if s.get_artists().row_count() != 0 || s.get_artists_loading() {
@@ -1064,15 +1302,86 @@ pub fn ensure_artists_loaded(weak: slint::Weak<AppWindow>, handle: tokio::runtim
}
s.set_artists_loading(true);
s.set_artists_load_failed(false);
let _ = image_cache; // reserved for the Qobuz-image follow-up
let weak2 = w.as_weak();
handle.spawn(async move {
let artists = tokio::task::spawn_blocking(|| {
crate::library_db::with_db(|db| db.get_artists_with_filter(true, false))
.unwrap_or_default()
let items = tokio::task::spawn_blocking(|| {
let artists = crate::library_db::with_db(|db| db.get_artists_with_filter(true, false))
.unwrap_or_default();
let albums = crate::library_db::with_db(|db| {
db.get_albums_with_full_filter(false, true, false)
})
.unwrap_or_default();
let custom = crate::library_db::with_db(|db| db.get_all_custom_artist_images())
.unwrap_or_default();
let merged = merge_artists(artists, &albums, &custom);
if let Ok(mut cache) = ARTIST_ALBUMS.lock() {
*cache = albums;
}
merged
})
.await
.unwrap_or_default();
let _ = weak2.upgrade_in_event_loop(move |w| apply_artists(&w, artists));
let _ = weak2.upgrade_in_event_loop(move |w| apply_artists(&w, items));
});
});
}
/// Select an artist: filter their albums (in place, from the cached album
/// set) into the right pane and kick cover loads.
pub fn select_local_artist(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
name: String,
) {
let _ = weak.upgrade_in_event_loop({
let name = name.clone();
move |w| {
let s = w.global::<LocalLibraryState>();
s.set_artists_selected_name(name.clone().into());
// Display name = the merged row's display, else the raw name.
let all = s.get_artists();
let display = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.find(|a| a.name == name)
.map(|a| a.display_name.to_string())
.unwrap_or_else(|| name.clone());
s.set_artists_selected_display(display.into());
s.set_artists_selected_loading(true);
}
});
handle.spawn(async move {
let cards = tokio::task::spawn_blocking(move || {
let albums = ARTIST_ALBUMS
.lock()
.map(|c| c.clone())
.unwrap_or_default();
let nsel = normalize_artist(&name);
albums
.into_iter()
.filter(|al| album_matches_artist(al, &nsel))
.map(map_local_album)
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let _ = weak.upgrade_in_event_loop(move |w| {
let jobs: Vec<ArtworkJob> = cards
.iter()
.enumerate()
.filter(|(_, c)| !c.artwork_url.is_empty())
.map(|(i, c)| ArtworkJob {
target: ArtworkTarget::LocalArtistAlbumCard { index: i },
url: c.artwork_url.clone(),
})
.collect();
let items: Vec<AlbumCardItem> =
cards.into_iter().map(crate::album_map::to_item).collect();
let s = w.global::<LocalLibraryState>();
s.set_artists_selected_albums(ModelRc::new(VecModel::from(items)));
s.set_artists_selected_loading(false);
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
});
});
}
+29 -1
View File
@@ -972,7 +972,7 @@ fn navigate_local_library(
local_library::ensure_tracks_loaded(weak, handle.clone());
}
local_library::LibTab::Artists => {
local_library::ensure_artists_loaded(weak, handle.clone());
local_library::ensure_artists_loaded(weak, handle.clone(), image_cache);
}
}
}
@@ -4309,6 +4309,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// ---- Artists tab actions ----
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_artists_search(move |_query| {
// Query is two-way bound to artists-search; re-derive in place.
if let Some(w) = weak.upgrade() {
local_library::derive_artists(&w);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocalLibraryActions>()
.on_artists_select(move |name| {
local_library::select_local_artist(
weak.clone(),
handle.clone(),
image_cache.clone(),
name.to_string(),
);
});
}
// Discover "View all" — open the full-list page for a section,
// recording it as a history entry (mirrors the favorites branch
// of on_header_menu_navigate).
@@ -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