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:
5 files changed
+702
-85
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user