feat(locallibrary): complete Albums 1:1 + dedicated local album view with versions

Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
  grouping/filtering span the whole set. derive_albums = search + sort + group
  (off / by letter with A-Z jump / by artist, both with section headers) + the
  quality/format/source filter (1:1 with matchesQualityFilters: OR within a
  group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
  Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
  layout, not a layout slot) with an active-count badge. Fixed the year sort
  (sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.

Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
  reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
  actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
  multiple copies of the same album no longer merge into a duplicated track
  list. Best-quality version is the default; a source-icon version picker
  (hard-drive / Qobuz / Plex glyph in both the selected display and the options)
  switches the shown copy in place. The album cover is the best-quality version
  that has one, falling through versions, else a placeholder (covers backfilled
  from disk via fill_missing_covers). Header padding-top 11px matches the app.

Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.

cc
This commit is contained in:
vicrodh committed 2026-05-29 19:16:09 -06:00
1 parent 3c2f46b756
commit 201082d4a1
13 files changed
+1440 -360

No files matched your search

+6 -2
View File
@@ -208,8 +208,12 @@ pub fn to_item(card: AlbumCard) -> AlbumCardItem {
/// case-insensitive; any other key (e.g. `default`) leaves order intact.
pub fn sort_album_items(items: &mut [AlbumCardItem], sort: &str) {
match sort {
"oldest" => items.sort_by(|a, b| a.plain_year.as_str().cmp(b.plain_year.as_str())),
"newest" => items.sort_by(|a, b| b.plain_year.as_str().cmp(a.plain_year.as_str())),
"oldest" | "year-asc" => {
items.sort_by(|a, b| a.plain_year.as_str().cmp(b.plain_year.as_str()))
}
"newest" | "year-desc" => {
items.sort_by(|a, b| b.plain_year.as_str().cmp(a.plain_year.as_str()))
}
"title-asc" => items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
"title-desc" => items.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())),
"artist-asc" => {
+10 -4
View File
@@ -94,6 +94,8 @@ pub enum ArtworkTarget {
/// re-derived, so they must be matched by name). `gen` drops a stale paint
/// after a reload/rescan.
LocalArtistRowImage { index: usize, gen: u64 },
/// The cover of the dedicated Local Library album view (LocalAlbumState).
LocalAlbumViewCover,
/// A card in FavoritesState.artists[index].
FavoriteArtist { index: usize },
/// A card in FavoritesState.labels[index].
@@ -615,14 +617,15 @@ fn apply_artwork(
}
}
ArtworkTarget::LocalAlbumCard { index, gen } => {
// Drop the cover if a reload superseded the page it belongs to.
// Drop the cover if a reload superseded the set it belongs to.
if !crate::local_library::albums_gen_current(gen) {
return;
}
let model = window.global::<crate::LocalLibraryState>().get_albums();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
if let Some(item) = model.row_data(index) {
// Dual-set by id onto the full set + visible + grouped sections.
let id = item.id.to_string();
crate::local_library::set_local_album_artwork(window, &id, image);
}
}
ArtworkTarget::LocalFolderCard { index } => {
@@ -651,6 +654,9 @@ fn apply_artwork(
}
}
}
ArtworkTarget::LocalAlbumViewCover => {
window.global::<crate::LocalAlbumState>().set_cover(image);
}
ArtworkTarget::FavoriteArtist { index } => {
let model = window.global::<crate::FavoritesState>().get_artists();
if let Some(mut item) = model.row_data(index) {
+456 -250
View File
@@ -79,10 +79,7 @@ impl LibTab {
// the result, covers load from the local filesystem via the source-aware
// artwork pipeline.
/// Albums fetched per page (chunk). Mirrors Tauri's chunked store (100).
const ALBUMS_PAGE: u64 = 100;
/// Generation guard, bumped on every page-1 (re)load. A stale in-flight
/// Generation guard, bumped on every (re)load. A stale in-flight
/// fetch (older search/sort) is discarded on apply, and an in-flight
/// load-more is dropped once a reload supersedes it.
static ALBUMS_GEN: AtomicU64 = AtomicU64::new(0);
@@ -165,169 +162,304 @@ fn local_quality(bit_depth: Option<u32>, sample_rate_hz: f64) -> (String, String
)
}
/// Map the toolbar sort key to the DB's validated (sort_by, sort_dir).
fn sort_params(sort: &str) -> (&'static str, &'static str) {
match sort {
"title-asc" => ("title", "asc"),
"title-desc" => ("title", "desc"),
"year-desc" => ("year", "desc"),
"year-asc" => ("year", "asc"),
"artist-desc" => ("artist", "desc"),
// Covers all six toolbar options; unknown input defaults to artist-asc.
_ => ("artist", "asc"),
/// The full metadata-grouped LocalAlbum set — the FILTER SOURCE (the quality/
/// format/source filters need raw bit_depth/format/source, which AlbumCardItem
/// doesn't carry). Loaded once; `derive_albums` filters it by id.
static LOCAL_ALBUMS: LazyLock<Mutex<Vec<qbz_library::LocalAlbum>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
fn local_albums() -> std::sync::MutexGuard<'static, Vec<qbz_library::LocalAlbum>> {
LOCAL_ALBUMS.lock().unwrap_or_else(|e| e.into_inner())
}
/// Active quality/format/source filter (read once per derive from the global).
#[derive(Clone, Copy, Default)]
struct AlbumFilter {
hires: bool,
cd: bool,
lossy: bool,
flac: bool,
alac: bool,
ape: bool,
wav: bool,
mp3: bool,
aac: bool,
other: bool,
local: bool,
offline: bool,
plex: bool,
}
fn read_album_filter(window: &AppWindow) -> AlbumFilter {
let f = window.global::<crate::LibAlbumFilterState>();
AlbumFilter {
hires: f.get_hires(),
cd: f.get_cd(),
lossy: f.get_lossy(),
flac: f.get_flac(),
alac: f.get_alac(),
ape: f.get_ape(),
wav: f.get_wav(),
mp3: f.get_mp3(),
aac: f.get_aac(),
other: f.get_other(),
local: f.get_local(),
offline: f.get_offline(),
plex: f.get_plex(),
}
}
/// Fetch one albums page off the UI thread (rusqlite is blocking). Returns
/// (cards, total, has_more), or None if the DB can't be opened. Returns the
/// plain `AlbumCard` (Send) — conversion to `AlbumCardItem` is UI-thread.
fn fetch_albums_page(
offset: u64,
search: String,
sort: String,
) -> Option<(Vec<crate::album_map::AlbumCard>, u64, bool)> {
let (sort_by, sort_dir) = sort_params(&sort);
let trimmed = search.trim();
let search_opt = if trimmed.is_empty() {
None
} else {
Some(trimmed)
};
crate::library_db::with_db(|db| {
let page = db.get_albums_metadata_page(
offset,
ALBUMS_PAGE,
search_opt,
sort_by,
sort_dir,
// include_qobuz_downloads: mirrors the `show_in_library`
// download-setting default (true). The user toggle lands with
// the Settings > Local Library integration.
true,
// exclude_network_folders / plex_cache_path: offline-network
// filtering and Plex are their own later slices.
false,
None,
)?;
let total = page.total;
let has_more = offset + (page.albums.len() as u64) < total;
let cards: Vec<crate::album_map::AlbumCard> = page
.albums
.into_iter()
.map(|a| {
let mut card = map_local_album(a);
// Offline-cached / folder albums sometimes have no backfilled
// artwork_path though a cover.jpg sits in the track folder —
// resolve it so the cover that exists on disk actually shows.
if card.artwork_url.is_empty() {
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
card.artwork_url = cover;
}
}
card
})
.collect();
Ok((cards, total, has_more))
})
fn album_filter_count(f: &AlbumFilter) -> i32 {
[
f.hires, f.cd, f.lossy, f.flac, f.alac, f.ape, f.wav, f.mp3, f.aac, f.other, f.local,
f.offline, f.plex,
]
.iter()
.filter(|b| **b)
.count() as i32
}
/// Build local-cover artwork jobs for a page of cards. The job url carries
/// the local file path; `spawn_local_loads` decodes via `ArtworkRef::LocalFile`.
/// `gen` is the albums generation at fetch time — a reload bumps it, so a
/// cover decoded after the model was replaced is dropped on apply.
fn album_artwork_jobs(
cards: &[crate::album_map::AlbumCard],
offset: usize,
gen: u64,
) -> Vec<ArtworkJob> {
/// 1:1 with Tauri `matchesQualityFilters`: OR within each group, AND between
/// groups; an empty group passes everything.
fn album_matches_filters(a: &qbz_library::LocalAlbum, f: &AlbumFilter) -> bool {
let format = a.format.to_string().to_lowercase();
let lossless = matches!(
format.as_str(),
"flac" | "wav" | "aiff" | "alac" | "ape" | "dsd" | "dsf" | "dff"
);
let lossy = matches!(format.as_str(), "mp3" | "aac" | "m4a" | "ogg" | "opus" | "wma");
let bit_depth = a.bit_depth.unwrap_or(16);
let q_active = f.hires || f.cd || f.lossy;
let passes_q = !q_active
|| (f.hires && lossless && (bit_depth >= 24 || a.sample_rate > 48000.0))
|| (f.cd && lossless && bit_depth <= 16 && a.sample_rate <= 48000.0)
|| (f.lossy && lossy);
let fmt_active = f.flac || f.alac || f.ape || f.wav || f.mp3 || f.aac || f.other;
let passes_f = !fmt_active
|| (f.flac && format == "flac")
|| (f.alac && (format == "alac" || format == "m4a"))
|| (f.ape && format == "ape")
|| (f.wav && (format == "wav" || format == "wave"))
|| (f.mp3 && format == "mp3")
|| (f.aac && (format == "aac" || format == "m4a"))
|| (f.other
&& !matches!(
format.as_str(),
"flac" | "alac" | "ape" | "wav" | "wave" | "mp3" | "aac" | "m4a"
));
let s_active = f.local || f.offline || f.plex;
let src = a.source.as_str();
let passes_s = !s_active
|| (f.local && (src == "user" || src.is_empty()))
|| (f.offline && src == "qobuz_download")
|| (f.plex && src == "plex");
passes_q && passes_f && passes_s
}
/// Build LocalAlbumCard artwork jobs for the full `albums` set (gen-stamped).
fn album_artwork_jobs(cards: &[crate::album_map::AlbumCard], gen: u64) -> Vec<ArtworkJob> {
cards
.iter()
.enumerate()
.filter(|(_, c)| !c.artwork_url.is_empty())
.map(|(i, c)| ArtworkJob {
target: ArtworkTarget::LocalAlbumCard {
index: offset + i,
gen,
},
target: ArtworkTarget::LocalAlbumCard { index: i, gen },
url: c.artwork_url.clone(),
})
.collect()
}
/// Replace the albums set with page 1 (UI thread). Converts the plain cards
/// to `AlbumCardItem` here, where the non-Send `slint::Image` is allowed.
fn apply_albums(
window: &AppWindow,
cards: Vec<crate::album_map::AlbumCard>,
total: u64,
has_more: bool,
) {
let items: Vec<AlbumCardItem> = cards.into_iter().map(crate::album_map::to_item).collect();
/// Set a freshly-decoded local-album cover (by id) on every rendered model:
/// the full `albums` set + `albums-visible` + each `albums-grouped` section.
pub fn set_local_album_artwork(window: &AppWindow, id: &str, image: slint::Image) {
let s = window.global::<LocalLibraryState>();
let n = items.len() as i32;
s.set_albums(ModelRc::new(VecModel::from(items)));
s.set_albums_total(total as i32);
s.set_album_count(total as i32);
s.set_albums_next_offset(n);
s.set_albums_has_more(has_more);
s.set_albums_loading(false);
s.set_albums_loading_more(false);
s.set_albums_load_failed(false);
let set_in = |m: &ModelRc<AlbumCardItem>| {
for i in 0..m.row_count() {
if let Some(mut it) = m.row_data(i) {
if it.id.as_str() == id {
it.artwork = image.clone();
m.set_row_data(i, it);
break;
}
}
}
};
set_in(&s.get_albums());
set_in(&s.get_albums_visible());
let grouped = s.get_albums_grouped();
for gi in 0..grouped.row_count() {
if let Some(sec) = grouped.row_data(gi) {
set_in(&sec.albums);
}
}
}
/// Append a fetched page onto the existing grid (UI thread).
fn append_albums(
window: &AppWindow,
cards: Vec<crate::album_map::AlbumCard>,
total: u64,
has_more: bool,
) {
let new_items: Vec<AlbumCardItem> =
cards.into_iter().map(crate::album_map::to_item).collect();
/// Re-derive the rendered Albums sets (search + quality/format/source filter +
/// sort + group + A-Z) from the full `albums` card set, filtered by id against
/// the raw LocalAlbum cache. Mirrors `derive_folders` plus the filter.
pub fn derive_albums(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
let model = s.get_albums();
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
let query_owned = s.get_albums_search().to_lowercase();
let query = query_owned.trim();
let sort = s.get_albums_sort().to_string();
let group = s.get_albums_group().to_string();
let filter = read_album_filter(window);
window
.global::<crate::LibAlbumFilterState>()
.set_count(album_filter_count(&filter));
let matching: std::collections::HashSet<String> = {
let cache = local_albums();
cache
.iter()
.filter(|a| {
(query.is_empty()
|| a.title.to_lowercase().contains(query)
|| a.artist.to_lowercase().contains(query)
|| a.all_artists.to_lowercase().contains(query))
&& album_matches_filters(a, &filter)
})
.map(|a| a.id.clone())
.collect()
};
let all = s.get_albums();
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|c| matching.contains(&c.id.to_string()))
.collect();
let added = new_items.len() as i32;
combined.extend(new_items);
s.set_albums(ModelRc::new(VecModel::from(combined)));
s.set_albums_next_offset(s.get_albums_next_offset() + added);
s.set_albums_total(total as i32);
s.set_album_count(total as i32);
s.set_albums_has_more(has_more);
s.set_albums_loading_more(false);
crate::album_map::sort_album_items(&mut filtered, &sort);
s.set_albums_shown(filtered.len() as i32);
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
if group == "off" {
s.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
s.set_albums_grouped(empty_sections());
s.set_albums_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
return;
}
let mut map: Vec<(String, Vec<AlbumCardItem>)> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for item in filtered {
let key = if group == "artist" {
let a = item.artist.to_string();
if a.is_empty() {
"Unknown".to_string()
} else {
a
}
} else {
folder_alpha_key(item.title.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<DiscoverSection> = map
.into_iter()
.map(|(key, items)| DiscoverSection {
title: key.into(),
endpoint: "".into(),
albums: ModelRc::new(VecModel::from(items)),
})
.collect();
s.set_albums_grouped(ModelRc::new(VecModel::from(sections)));
s.set_albums_alpha(ModelRc::new(VecModel::from(jumps)));
s.set_albums_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
}
/// Spawn the page-1 fetch and apply it (gen-guarded). Reads search/sort +
/// spawns from the UI thread; the caller has set `albums-loading` + `gen`.
fn spawn_albums_page_load(
/// Clear all quality/format/source filters, then re-derive.
pub fn clear_album_filter(window: &AppWindow) {
let f = window.global::<crate::LibAlbumFilterState>();
f.set_hires(false);
f.set_cd(false);
f.set_lossy(false);
f.set_flac(false);
f.set_alac(false);
f.set_ape(false);
f.set_wav(false);
f.set_mp3(false);
f.set_aac(false);
f.set_other(false);
f.set_local(false);
f.set_offline(false);
f.set_plex(false);
derive_albums(window);
}
/// Full-load the metadata-grouped albums off the UI thread (mapping + cover
/// fallback resolution all happen on the blocking thread), store the raw cache
/// + the mapped card set, then derive + spawn covers on the UI thread.
fn spawn_albums_load(
window: &AppWindow,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
gen: u64,
) {
let s = window.global::<LocalLibraryState>();
let search = s.get_albums_search().to_string();
let sort = s.get_albums_sort().to_string();
let weak = window.as_weak();
handle.spawn(async move {
let result = tokio::task::spawn_blocking(move || fetch_albums_page(0, search, sort))
let loaded: Option<(Vec<qbz_library::LocalAlbum>, Vec<crate::album_map::AlbumCard>)> =
tokio::task::spawn_blocking(|| {
crate::library_db::with_db(|db| {
let albums = db.get_albums_metadata_grouped(false, true, false)?;
let cards: Vec<crate::album_map::AlbumCard> = albums
.iter()
.map(|a| {
let mut card = map_local_album(a.clone());
if card.artwork_url.is_empty() {
if let Some(cover) = db.resolve_album_cover_fallback(&card.id) {
card.artwork_url = cover;
}
}
card
})
.collect();
Ok((albums, cards))
})
})
.await
.ok()
.flatten();
let _ = weak.upgrade_in_event_loop(move |w| {
if ALBUMS_GEN.load(Ordering::SeqCst) != gen {
return;
}
match result {
Some((cards, total, has_more)) => {
let jobs = album_artwork_jobs(&cards, 0, gen);
apply_albums(&w, cards, total, has_more);
let s = w.global::<LocalLibraryState>();
match loaded {
Some((albums, cards)) => {
*local_albums() = albums;
let jobs = album_artwork_jobs(&cards, gen);
let items: Vec<AlbumCardItem> =
cards.into_iter().map(crate::album_map::to_item).collect();
s.set_albums(ModelRc::new(VecModel::from(items.clone())));
s.set_album_count(items.len() as i32);
s.set_albums_loading(false);
s.set_albums_load_failed(false);
derive_albums(&w);
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
}
None => {
let s = w.global::<LocalLibraryState>();
s.set_albums_loading(false);
s.set_albums_load_failed(true);
}
@@ -336,8 +468,7 @@ fn spawn_albums_page_load(
});
}
/// (Re)load page 1 with the current search + sort. Bumps the generation so
/// any older in-flight fetch is discarded on arrival.
/// (Re)load the full album set, bumping the generation guard.
pub fn reload_albums(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
@@ -348,12 +479,11 @@ pub fn reload_albums(
let s = w.global::<LocalLibraryState>();
s.set_albums_loading(true);
s.set_albums_load_failed(false);
spawn_albums_page_load(&w, handle, image_cache, gen);
spawn_albums_load(&w, handle, image_cache, gen);
});
}
/// Load page 1 only if the albums set is empty and not already loading — the
/// lazy per-tab fetch on first visit (re-entry keeps the loaded set + scroll).
/// Load on first visit only (re-entry keeps the set + derived views).
pub fn ensure_albums_loaded(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
@@ -365,58 +495,11 @@ pub fn ensure_albums_loaded(
let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
s.set_albums_loading(true);
s.set_albums_load_failed(false);
spawn_albums_page_load(&w, handle, image_cache, gen);
spawn_albums_load(&w, handle, image_cache, gen);
}
});
}
/// Fetch + append the next page (driven by scroll-near-bottom). No-ops while
/// a page is in flight or the catalog is exhausted; the in-flight result is
/// dropped if a reload supersedes it.
pub fn load_more_albums(
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_albums_has_more() || s.get_albums_loading_more() || s.get_albums_loading() {
return;
}
let offset = s.get_albums_next_offset().max(0) as u64;
let search = s.get_albums_search().to_string();
let sort = s.get_albums_sort().to_string();
let gen = ALBUMS_GEN.load(Ordering::SeqCst);
s.set_albums_loading_more(true);
let weak2 = w.as_weak();
handle.spawn(async move {
let result =
tokio::task::spawn_blocking(move || fetch_albums_page(offset, search, sort))
.await
.ok()
.flatten();
let _ = weak2.upgrade_in_event_loop(move |w| {
let s = w.global::<LocalLibraryState>();
if ALBUMS_GEN.load(Ordering::SeqCst) != gen {
s.set_albums_loading_more(false);
return;
}
match result {
Some((cards, total, has_more)) => {
let base = s.get_albums().row_count();
let jobs = album_artwork_jobs(&cards, base, gen);
append_albums(&w, cards, total, has_more);
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
}
None => {
s.set_albums_loading_more(false);
}
}
});
});
});
}
// =============================== Tracks tab ===============================
//
// Server-paginated flat list (the perf path that avoids the documented ~16K
@@ -777,22 +860,163 @@ fn fmt_album_duration(secs: u64) -> String {
}
}
/// Populate `AlbumState` from a local album's tracks (UI thread). The cover
/// is loaded separately by the caller; `is-local` is set so playback routes
/// to local. `group_key` is the metadata group key.
pub fn apply_local_album(
window: &AppWindow,
group_key: &str,
tracks: Vec<qbz_library::LocalTrack>,
// The open local album's versions (label, tracks) — a "version" is a distinct
// physical copy (= a distinct source directory). Cached so the version picker
// switches without a DB round-trip. Splitting by directory is what stops two
// copies of the same album from merging into a duplicated track list.
static ALBUM_VERSIONS: LazyLock<Mutex<Vec<(String, Vec<qbz_library::LocalTrack>)>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
fn album_versions() -> std::sync::MutexGuard<'static, Vec<(String, Vec<qbz_library::LocalTrack>)>> {
ALBUM_VERSIONS.lock().unwrap_or_else(|e| e.into_inner())
}
/// Quality rank for ordering versions (hi-res first).
fn version_rank(t: &qbz_library::LocalTrack) -> (u32, u64) {
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
}
/// A version's picker label: "24-bit / 96 kHz · FLAC" (quality + format).
fn version_label(tracks: &[qbz_library::LocalTrack]) -> String {
match tracks.first() {
Some(t) => {
let (detail, _) = local_quality(t.bit_depth, t.sample_rate);
let fmt = t.format.to_string();
if detail.is_empty() {
fmt
} else {
format!("{detail} · {fmt}")
}
}
None => String::new(),
}
}
/// A version's source ("user" | "qobuz_download" | "plex" | "") — drives the
/// picker's source icon.
fn version_source(tracks: &[qbz_library::LocalTrack]) -> String {
tracks
.first()
.and_then(|t| t.source.clone())
.unwrap_or_default()
}
/// Load a local album (dedicated LocalAlbumView), splitting its tracks into
/// VERSIONS by source directory so multiple copies don't merge into a
/// duplicate-track list. Applies the best-quality version first; the picker
/// switches versions in place. Does NOT touch nav — the caller sets the view.
pub fn open_local_album(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
group_key: String,
) {
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<crate::LocalAlbumState>();
s.set_loading(true);
s.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
s.set_versions(ModelRc::new(VecModel::from(Vec::<crate::LocalAlbumVersion>::new())));
s.set_version_index(0);
s.set_cover(slint::Image::default());
});
let gk = group_key.clone();
handle.spawn(async move {
let tracks = tokio::task::spawn_blocking(move || {
let mut t = fetch_album_tracks_blocking(&gk);
// Backfill covers from cover.jpg/folder.jpg on disk (the DB may not
// have an artwork_path even when a cover sits in the folder).
crate::playback::fill_missing_covers(&mut t);
t
})
.await
.unwrap_or_default();
// Group by source directory (LocalTrack.album_group_key = the dir key).
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalTrack>> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for t in tracks {
let key = t.album_group_key.clone();
if !groups.contains_key(&key) {
order.push(key.clone());
}
groups.entry(key).or_default().push(t);
}
let mut versions: Vec<(String, Vec<qbz_library::LocalTrack>)> = order
.into_iter()
.filter_map(|k| {
groups.remove(&k).map(|mut v| {
v.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(0)));
(k, v)
})
})
.collect();
// Best quality first (so the default selection is the highest-res copy).
versions.sort_by(|a, b| {
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
qb.cmp(&qa)
});
// (label, source) per version — best-quality first (already sorted).
let infos: Vec<(String, String)> = versions
.iter()
.map(|(_, v)| (version_label(v), version_source(v)))
.collect();
// Album cover = the FIRST version (best quality first) that has a cover
// on disk; fall through versions; else empty (placeholder). Album-level
// + stable across version switches.
let album_cover = versions
.iter()
.find_map(|(_, v)| {
v.iter()
.find_map(|t| t.artwork_path.clone().filter(|p| !p.is_empty()))
})
.unwrap_or_default();
*album_versions() = versions;
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<crate::LocalAlbumState>();
s.set_id(group_key.into());
let vlist: Vec<crate::LocalAlbumVersion> = infos
.into_iter()
.map(|(label, source)| crate::LocalAlbumVersion {
label: label.into(),
source: source.into(),
})
.collect();
s.set_versions(ModelRc::new(VecModel::from(vlist)));
s.set_loading(false);
s.set_cover_url(album_cover.clone().into());
apply_album_version(&w, 0);
// Decode the album cover once (stable across version switches).
if !album_cover.is_empty() {
crate::artwork::spawn_local_loads(
vec![ArtworkJob {
target: ArtworkTarget::LocalAlbumViewCover,
url: album_cover,
}],
w.as_weak(),
image_cache,
);
}
});
});
}
/// Apply version `index` of the open album to LocalAlbumState (tracks, header,
/// quality). Reads the cached versions; no DB round-trip. The cover is
/// album-level (set once by `open_local_album`), so it is NOT touched here.
pub fn apply_album_version(window: &AppWindow, index: i32) {
let versions = album_versions();
let Some((_, tracks)) = versions.get(index as usize) else {
return;
};
let s = window.global::<crate::LocalAlbumState>();
let group_key = s.get_id().to_string();
let title = tracks
.first()
.map(|t| t.album_group_title.clone())
.unwrap_or_default();
// Album artist: the common album-artist, else "Various Artists".
let artist_of = |t: &qbz_library::LocalTrack| {
t.album_artist.clone().unwrap_or_else(|| t.artist.clone())
};
let artist_of =
|t: &qbz_library::LocalTrack| t.album_artist.clone().unwrap_or_else(|| t.artist.clone());
let artist = match tracks.first() {
Some(first) => {
let name = artist_of(first);
@@ -804,16 +1028,8 @@ pub fn apply_local_album(
}
None => String::new(),
};
let cover = tracks
.iter()
.find_map(|t| t.artwork_path.clone())
.unwrap_or_default();
let cover_url = if cover.is_empty() {
String::new()
} else {
format!("file://{cover}")
};
// Quality from the highest-resolution track in the album.
let total_secs: u64 = tracks.iter().map(|t| t.duration_secs).sum();
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
let (tier, detail) = match tracks.iter().max_by_key(|t| t.bit_depth.unwrap_or(0)) {
Some(t) => {
let tier = match t.bit_depth {
@@ -825,41 +1041,36 @@ pub fn apply_local_album(
}
None => (String::new(), String::new()),
};
let total_secs: u64 = tracks.iter().map(|t| t.duration_secs).sum();
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
// Album source for the tag-editor pencil gate (hidden for Plex). Computed
// before `tracks` is consumed below.
let source = tracks
.first()
.and_then(|t| t.source.clone())
.unwrap_or_default();
let items: Vec<TrackItem> = tracks
.into_iter()
.iter()
.cloned()
.map(|t| {
let mut it = map_local_track(t);
it.album_id = group_key.into();
it.album_id = group_key.clone().into();
it
})
.collect();
let s = window.global::<crate::AlbumState>();
s.set_id(group_key.into());
s.set_is_local(true);
s.set_source(source.into());
s.set_title(title.into());
s.set_artist(artist.into());
s.set_artist_id("".into());
s.set_artwork_url(cover_url.into());
s.set_has_custom_cover(false);
s.set_info_line(info_line.into());
s.set_quality_tier(tier.into());
s.set_quality_detail(detail.into());
s.set_info_line(info_line.into());
s.set_label("".into());
s.set_description("".into());
s.set_description_short("".into());
s.set_awards(ModelRc::new(VecModel::from(Vec::<slint::SharedString>::new())));
s.set_tracks(ModelRc::new(VecModel::from(items)));
s.set_loading(false);
s.set_version_index(index);
}
/// The source directory of version `index` (for the tag editor — a real dir).
pub fn album_version_dir(index: i32) -> Option<String> {
album_versions().get(index as usize).map(|(dir, _)| dir.clone())
}
/// The currently-selected album version's tracks (play / shuffle / add / edit).
pub fn current_album_version_tracks(window: &AppWindow) -> Vec<qbz_library::LocalTrack> {
let idx = window.global::<crate::LocalAlbumState>().get_version_index();
album_versions()
.get(idx as usize)
.map(|(_, v)| v.clone())
.unwrap_or_default()
}
// ============================ Folders (flat) ==============================
@@ -1604,14 +1815,29 @@ pub fn ensure_artists_loaded(
}
crate::artwork::spawn_local_loads(local_jobs, w.as_weak(), image_cache.clone());
crate::artwork::spawn_loads(http_jobs, w.as_weak(), image_cache.clone());
// Kick the capped Qobuz portrait fetch for missing rows.
// Kick the capped Qobuz portrait fetch for missing rows. Snapshot
// the names HERE (UI thread, sync) — fetch_missing_artist_images
// must NOT block the event loop to read the model.
if s.get_artists_fetch_images() {
let mut names = Vec::new();
for i in 0..artists.row_count() {
if let Some(a) = artists.row_data(i) {
if a.image_path.is_empty()
&& normalize_artist(&a.name.to_string()) != "various artists"
{
names.push(a.name.to_string());
}
}
}
s.set_artists_images_fetching(true);
s.set_artists_images_fetched(0);
fetch_missing_artist_images(
runtime,
w.as_weak(),
handle_inner,
image_cache,
gen,
names,
);
}
});
@@ -1630,30 +1856,10 @@ pub fn fetch_missing_artist_images(
handle: tokio::runtime::Handle,
image_cache: ImageCache,
gen: u64,
mut names: Vec<String>,
) {
// Snapshot the names missing a portrait, on the UI thread.
let (tx, rx) = std::sync::mpsc::channel::<Vec<String>>();
let _ = weak.upgrade_in_event_loop(move |w| {
let s = w.global::<LocalLibraryState>();
s.set_artists_images_fetching(true);
s.set_artists_images_fetched(0);
let flat = s.get_artists();
let mut names = Vec::new();
for i in 0..flat.row_count() {
if let Some(a) = flat.row_data(i) {
if !a.image_path.is_empty() {
continue;
}
let name = a.name.to_string();
if normalize_artist(&name) == "various artists" {
continue;
}
names.push(name);
}
}
let _ = tx.send(names);
});
let mut names = rx.recv().unwrap_or_default();
// `names` is snapshotted by the caller on the UI thread (NEVER block the
// event loop here — this can be invoked from inside an event-loop closure).
names.truncate(50);
if names.is_empty() {
let _ = weak.upgrade_in_event_loop(|w| {
+138 -45
View File
@@ -422,45 +422,18 @@ fn navigate_album(
/// the folder/embedded cover from disk. `group_key` is the album's metadata
/// group key.
fn navigate_local_album(
runtime: Arc<AppRuntime<SlintAdapter>>,
_runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
group_key: String,
) {
let _ = runtime;
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
album::reset_album(&w);
w.global::<NavState>().set_view(ContentView::Album);
});
let gk = group_key.clone();
let tracks = tokio::task::spawn_blocking(move || {
let mut t = local_library::fetch_album_tracks_blocking(&gk);
playback::fill_missing_covers(&mut t);
t
})
.await
.unwrap_or_default();
let cover = tracks
.iter()
.find_map(|t| t.artwork_path.clone())
.unwrap_or_default();
let gk2 = group_key.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
local_library::apply_local_album(&w, &gk2, tracks);
});
if !cover.is_empty() {
let art = qbz_models::ArtworkRef::LocalFile(cover);
if let Some((px, ww, hh)) =
artwork::fetch_and_decode_ref(&art, &image_cache, 448).await
{
let _ = weak.upgrade_in_event_loop(move |w| {
album::apply_artwork(&w, &px, ww, hh);
});
}
}
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NavState>().set_view(ContentView::LocalAlbum);
update_nav_flags(&w);
});
// The dedicated local album view owns the load (versions + cover).
local_library::open_local_album(weak, handle.clone(), image_cache, group_key);
}
/// Load an artist page and show the artist view, then fetch the portrait.
@@ -694,6 +667,9 @@ fn apply_entry(
nav::NavEntry::Album(id) => {
navigate_album(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
}
nav::NavEntry::LocalAlbum(gk) => {
navigate_local_album(runtime.clone(), weak.clone(), handle, image_cache.clone(), gk);
}
nav::NavEntry::Artist(id) => {
navigate_artist(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
}
@@ -4167,9 +4143,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<TagEditorActions>()
.on_save(move || tag_editor::save_tags(weak.clone(), handle.clone()));
.on_save(move || tag_editor::save_tags(weak.clone(), handle.clone(), image_cache.clone()));
}
{
let weak = window.as_weak();
@@ -4224,41 +4201,156 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_open_in_browser(move || tag_editor::open_in_browser(weak.clone()));
}
// Dedicated Local album view actions (play / shuffle / edit / add / version).
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_play_all(move || {
if let Some(w) = weak.upgrade() {
let tracks = local_library::current_album_version_tracks(&w);
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, 0, false);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_shuffle(move || {
if let Some(w) = weak.upgrade() {
let tracks = local_library::current_album_version_tracks(&w);
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, 0, true);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_play_track(move |id| {
if let Some(w) = weak.upgrade() {
let tracks = local_library::current_album_version_tracks(&w);
let start = id
.parse::<i64>()
.ok()
.and_then(|tid| tracks.iter().position(|t| t.id == tid))
.unwrap_or(0);
playback::play_local_tracks(runtime.clone(), weak.clone(), handle.clone(), tracks, start, false);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_edit_tags(move || {
if let Some(w) = weak.upgrade() {
let idx = w.global::<LocalAlbumState>().get_version_index();
if let Some(dir) = local_library::album_version_dir(idx) {
tag_editor::open_tag_editor(weak.clone(), handle.clone(), dir.clone(), dir);
}
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<LocalAlbumActions>().on_add_to_playlist(move || {
if let Some(w) = weak.upgrade() {
let tracks = local_library::current_album_version_tracks(&w);
let ids: Vec<String> = tracks
.iter()
.filter(|t| t.source.as_deref() != Some("plex"))
.map(|t| t.id.to_string())
.collect();
if !ids.is_empty() {
playlist_picker::open_multi(&w, &ids, true);
let runtime = runtime.clone();
let weak2 = weak.clone();
handle.spawn(async move {
let pls = playlist_picker::load(&runtime).await;
let _ = weak2.upgrade_in_event_loop(move |w| {
playlist_picker::apply(&w, pls);
});
});
}
}
});
}
{
let weak = window.as_weak();
window.global::<LocalAlbumActions>().on_select_version(move |i| {
if let Some(w) = weak.upgrade() {
local_library::apply_album_version(&w, i);
}
});
}
// Local Library — Albums tab controls (search / sort re-query page 1;
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
// open / per-card actions (album-detail + playback land with later slices).
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocalLibraryActions>()
.on_albums_search(move |_query| {
// The query is two-way bound to albums-search; reload page 1.
local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone());
// Two-way bound to albums-search; re-derive in memory (full-load).
if let Some(w) = weak.upgrade() {
local_library::derive_albums(&w);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocalLibraryActions>()
.on_albums_set_sort(move |sort| {
if let Some(w) = weak.upgrade() {
w.global::<LocalLibraryState>().set_albums_sort(sort);
local_library::derive_albums(&w);
}
local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone());
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<LocalLibraryActions>()
.on_albums_load_more(move || {
local_library::load_more_albums(weak.clone(), handle.clone(), image_cache.clone());
.on_albums_set_group(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<LocalLibraryState>().set_albums_group(mode);
local_library::derive_albums(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_albums_set_view(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<LocalLibraryState>().set_albums_view_mode(mode);
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_albums_filter_changed(move || {
if let Some(w) = weak.upgrade() {
local_library::derive_albums(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_albums_clear_filter(move || {
if let Some(w) = weak.upgrade() {
local_library::clear_album_filter(&w);
}
});
}
{
@@ -4279,6 +4371,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window
.global::<LocalLibraryActions>()
.on_open_album(move |id| {
nav::record(nav::NavEntry::LocalAlbum(id.to_string()));
navigate_local_album(
runtime.clone(),
weak.clone(),
+3
View File
@@ -54,6 +54,9 @@ pub enum NavEntry {
/// OfflineCacheManagerView). Session-scoped; no payload.
OfflineManager,
Album(String),
/// A Local Library album detail page (dedicated view, separate from the
/// Qobuz Album view); the string is the metadata group key.
LocalAlbum(String),
Artist(String),
Settings,
/// A search results page; the string is the query.
+30
View File
@@ -242,6 +242,36 @@ pub fn play_local_album(
});
}
/// Play an explicit list of local tracks (already resolved — e.g. one album
/// VERSION), starting at `start`. `shuffle` enables shuffle mode after the
/// queue is set. Used by the dedicated Local album view so it plays the SHOWN
/// version, never a re-merged metadata group.
pub fn play_local_tracks(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
tracks: Vec<qbz_library::LocalTrack>,
start: usize,
shuffle: bool,
) {
if tracks.is_empty() {
return;
}
handle.spawn(async move {
play_local_tracks_now(&runtime, &weak, tracks, start).await;
if shuffle {
// No set_shuffle on core — toggle until it's on.
let mut on = runtime.core().toggle_shuffle().await;
if !on {
on = runtime.core().toggle_shuffle().await;
}
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<NowPlayingState>().set_shuffle(on);
});
}
});
}
/// Play everything under a folder (recursive), in path order — the whole
/// subtree becomes the queue. Mirrors `play_local_album` but sources the
/// tracks from the folder hierarchy instead of a metadata group.
+24 -24
View File
@@ -158,7 +158,11 @@ pub fn close_tag_editor(weak: Weak<AppWindow>) {
/// Persist the edits. Validates, gates the directory + CUE for direct mode,
/// confirms direct-write once, then writes (sidecar or files) + updates the DB
/// index on a blocking thread, and refreshes the open album.
pub fn save_tags(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
pub fn save_tags(
weak: Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: crate::artwork::ImageCache,
) {
let Some(w) = weak.upgrade() else {
return;
};
@@ -379,7 +383,7 @@ pub fn save_tags(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
if ok {
crate::toast::success_weak(&weak, "Album metadata saved");
// Refresh the open album detail + reset browse models (D7).
refresh_after_save(weak.clone(), handle2.clone(), group_key.clone());
refresh_after_save(weak.clone(), handle2.clone(), image_cache.clone());
} else {
crate::toast::error_weak(&weak, format!("Couldn't save metadata: {err_msg}"));
}
@@ -585,27 +589,23 @@ pub fn open_in_browser(weak: Weak<AppWindow>) {
let _ = open::that(url);
}
/// Re-fetch the album's tracks and re-apply the detail view, and reset the
/// LocalLibrary browse models so the tabs re-fetch on next visit. Avoids a
/// full library reload (the 16K-track freeze).
fn refresh_after_save(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, group_key: String) {
handle.spawn(async move {
let gk = group_key.clone();
let tracks = tokio::task::spawn_blocking(move || {
crate::local_library::fetch_album_tracks_blocking(&gk)
})
.await
.unwrap_or_default();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::local_library::apply_local_album(&w, &group_key, tracks);
// Reset browse models so Albums/Folders/Tracks/Artists re-fetch.
let s = w.global::<crate::LocalLibraryState>();
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
s.set_albums(empty_albums.clone());
s.set_folders(empty_albums);
s.set_tracks(empty_tracks);
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
});
/// Re-open the local album view (re-splits versions with the new tags) and
/// reset the LocalLibrary browse models so the tabs re-fetch. Avoids a full
/// library reload (the 16K-track freeze).
fn refresh_after_save(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, image_cache: crate::artwork::ImageCache) {
let _ = weak.upgrade_in_event_loop(move |w| {
// Refresh the open local album detail (if any) by its metadata key.
let id = w.global::<crate::LocalAlbumState>().get_id().to_string();
if !id.is_empty() {
crate::local_library::open_local_album(w.as_weak(), handle.clone(), image_cache.clone(), id);
}
// Reset browse models so Albums/Folders/Tracks/Artists re-fetch.
let s = w.global::<crate::LocalLibraryState>();
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
s.set_albums(empty_albums.clone());
s.set_folders(empty_albums);
s.set_tracks(empty_tracks);
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
});
}
@@ -0,0 +1,348 @@
// Dedicated Local Library album detail — SEPARATE from the Qobuz AlbumView.
// Reuses the internal components (NavButtons, CircleAction, TrackRow,
// QualityBadgeFull) but exposes ONLY local actions (play all / shuffle / edit
// tags / add to playlist). When the album has multiple physical versions
// (distinct source folders), a version picker switches the shown copy — so two
// copies of the same album never merge into a duplicated track list.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListView } from "std-widgets.slint";
// Source glyph for a version: hard-drive (local), Qobuz logo (offline cache),
// Plex logo. The logos keep their own colors; the local glyph is tinted.
component SourceIcon inherits Image {
in property <string> kind;
width: 14px;
height: 14px;
image-fit: contain;
source: root.kind == "qobuz_download"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: (root.kind == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: @image-url("../assets/icons/hard-drive.svg"));
colorize: (root.kind == "qobuz_download" || root.kind == "plex")
? transparent
: Theme.text-secondary;
}
// Version picker — selected display + dropdown, both with the source icon.
component VersionPicker inherits Rectangle {
in property <[LocalAlbumVersion]> versions;
in property <int> current;
callback select(int);
height: 32px;
width: pick-row.preferred-width + 22px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.border-subtle;
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
pick-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
spacing: 7px;
VerticalLayout {
alignment: center;
SourceIcon { kind: root.versions[root.current].source; }
}
VerticalLayout {
alignment: center;
Text {
text: root.versions[root.current].label;
color: Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
}
}
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/chevron-down.svg");
width: 12px;
height: 12px;
tint: Theme.text-muted;
}
}
}
pick-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
menu.show();
}
}
menu := PopupWindow {
x: 0;
y: parent.height + 4px;
width: 280px;
close-policy: close-on-click;
Rectangle {
background: Theme.surface-card;
border-radius: 8px;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 22px;
drop-shadow-color: #00000080;
VerticalLayout {
padding: 6px;
spacing: 1px;
for v[i] in root.versions: Rectangle {
height: 34px;
border-radius: 6px;
background: i == root.current
? Theme.surface-elevated
: (row-ta.has-hover ? Theme.surface-hover : transparent);
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
VerticalLayout {
alignment: center;
SourceIcon { kind: v.source; }
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: v.label;
color: Theme.text-primary;
font-size: Typography.legal;
vertical-alignment: center;
overflow: elide;
}
}
if i == root.current: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/check.svg");
width: 12px;
height: 12px;
tint: Theme.accent;
}
}
}
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.select(i);
}
}
}
}
}
}
}
export component LocalAlbumView inherits Rectangle {
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- History nav + header ---------------------------------------
// padding-top 11px matches Discover/Album so the [<] [>] pair sits at
// the same top margin across every view. vertical-stretch: 0 is
// load-bearing (FavoritesView lesson): without it this nested layout
// grabs the leftover height and the header balloons.
VerticalLayout {
vertical-stretch: 0;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
spacing: 0px;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
HorizontalLayout {
spacing: 28px;
// Cover.
Rectangle {
width: 200px;
height: 200px;
border-radius: 12px;
background: Theme.surface-elevated;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 8px;
clip: true;
Image {
width: 100%;
height: 100%;
source: LocalAlbumState.cover;
image-fit: cover;
}
}
VerticalLayout {
alignment: start;
padding-top: 6px;
spacing: 6px;
horizontal-stretch: 1;
Text {
text: LocalAlbumState.title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
}
Text {
text: LocalAlbumState.artist;
color: Theme.text-secondary;
font-size: Typography.heading;
font-weight: Typography.bold;
overflow: elide;
}
Text {
text: LocalAlbumState.info-line;
color: Theme.text-muted;
font-size: Typography.body;
}
if LocalAlbumState.quality-tier != "": HorizontalLayout {
alignment: start;
QualityBadgeFull {
tier: LocalAlbumState.quality-tier;
detail: LocalAlbumState.quality-detail;
}
}
Rectangle { height: 6px; }
// Local-only action row.
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
clicked => {
LocalAlbumActions.play-all();
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
LocalAlbumActions.shuffle();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pencil.svg");
clicked => {
LocalAlbumActions.edit-tags();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/list-plus.svg");
clicked => {
LocalAlbumActions.add-to-playlist();
}
}
}
}
// Version picker (only when there are multiple copies).
if LocalAlbumState.versions.length > 1: HorizontalLayout {
alignment: start;
spacing: 8px;
VerticalLayout {
alignment: center;
Text {
text: "\{LocalAlbumState.versions.length} " + @tr("versions") + ":";
color: Theme.text-muted;
font-size: Typography.legal;
}
}
VerticalLayout {
alignment: center;
VersionPicker {
versions: LocalAlbumState.versions;
current: LocalAlbumState.version-index;
select(i) => {
LocalAlbumActions.select-version(i);
}
}
}
}
}
}
Rectangle { height: 16px; }
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// --- Track list -------------------------------------------------
Rectangle {
vertical-stretch: 1;
clip: true;
if LocalAlbumState.loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 32px; }
}
}
if !LocalAlbumState.loading && LocalAlbumState.tracks.length > 0: Rectangle {
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 12px;
tracks-list := ListView {
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
for track[i] in LocalAlbumState.tracks: TrackRow {
track: track;
index: i;
show-artwork: false;
show-album: false;
show-favorite: false;
show-download: false;
show-source: false;
media-action(kind, id, action) => {
// Local-only: ignore album/artist link clicks;
// any other row action plays the track.
if (kind != "album" && kind != "artist") {
LocalAlbumActions.play-track(id);
}
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: tracks-list.viewport-height;
visible-height: tracks-list.visible-height;
viewport-y <=> tracks-list.viewport-y;
}
}
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, LocalArtistItem, AlphaJump } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, LocalArtistItem, AlphaJump, LibAlbumFilterState } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
@@ -400,6 +400,36 @@ component LocalArtistRow inherits Rectangle {
}
}
// One filter chip in the Albums quality/format/source popup. 6px radius (not
// a pill — ADR-008; matches the GenreFilterPopup chip).
component FilterChip inherits Rectangle {
in property <string> label;
in property <bool> active;
callback toggled();
height: 30px;
width: lbl.preferred-width + 22px;
border-radius: 6px;
border-width: 1px;
border-color: root.active ? Theme.accent : Theme.border-subtle;
background: root.active
? Theme.accent
: (chip-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
lbl := Text {
text: root.label;
color: root.active ? Theme.accent-text : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.active ? Typography.semibold : Typography.regular;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
chip-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.toggled();
}
}
}
export component LocalLibraryView inherits Rectangle {
background: Theme.surface-main;
@@ -505,6 +535,78 @@ export component LocalLibraryView inherits Rectangle {
}
}
}
// Group-by.
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 160px;
sm: true;
options: [
@tr("No grouping"),
@tr("Group by letter"),
@tr("Group by artist"),
];
current-index: LocalLibraryState.albums-group == "alpha" ? 1
: LocalLibraryState.albums-group == "artist" ? 2
: 0;
selected(i) => {
LocalLibraryActions.albums-set-group(
i == 1 ? "alpha" : i == 2 ? "artist" : "off");
}
}
}
// Quality/format/source filter (popup) + active-count badge.
VerticalLayout {
alignment: center;
Rectangle {
width: 34px;
height: 30px;
border-radius: 6px;
border-width: 1px;
border-color: LibAlbumFilterState.count > 0 ? Theme.accent : Theme.border-subtle;
background: filt-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/list-filter.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: LibAlbumFilterState.count > 0 ? Theme.accent : Theme.text-secondary;
}
if LibAlbumFilterState.count > 0: Rectangle {
x: parent.width - self.width + 3px;
y: -4px;
width: 15px;
height: 15px;
border-radius: 7.5px;
background: Theme.accent;
Text {
text: LibAlbumFilterState.count;
color: Theme.accent-text;
font-size: 9px;
font-weight: Typography.bold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
filt-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LibAlbumFilterState.open = !LibAlbumFilterState.open;
}
}
}
}
// Grid/list view toggle.
VerticalLayout {
alignment: center;
ViewToggle {
view-mode: LocalLibraryState.albums-view-mode;
set-view(v) => {
LocalLibraryActions.albums-set-view(v);
}
}
}
}
// Tracks toolbar — multi-select toggle + search + group-by.
@@ -735,11 +837,13 @@ export component LocalLibraryView inherits Rectangle {
horizontal-alignment: center;
}
}
// Empty — search matched nothing.
// No-match — search or the quality/format/source filter emptied
// the visible set (the full set still has albums).
if !LocalLibraryState.albums-loading
&& !LocalLibraryState.albums-load-failed
&& LocalLibraryState.albums.length == 0
&& LocalLibraryState.albums-search != "": VerticalLayout {
&& LocalLibraryState.albums.length > 0
&& LocalLibraryState.albums-visible.length == 0
&& LocalLibraryState.albums-grouped.length == 0: VerticalLayout {
alignment: center;
Text {
text: @tr("No albums match your search.");
@@ -748,21 +852,14 @@ export component LocalLibraryView inherits Rectangle {
horizontal-alignment: center;
}
}
// The grid — self-scrolling, paged on scroll-near-bottom.
// Grid / list / grouped — self-scrolling (full-load, derived).
if !LocalLibraryState.albums-loading
&& !LocalLibraryState.albums-load-failed
&& LocalLibraryState.albums.length > 0: Rectangle {
&& (LocalLibraryState.albums-visible.length > 0
|| LocalLibraryState.albums-grouped.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;
@@ -772,10 +869,12 @@ export component LocalLibraryView inherits Rectangle {
spacing: 0px;
alignment: start;
AlbumCollectionView {
albums: LocalLibraryState.albums;
is-grouped: false;
view-mode: "grid";
show-source: false;
albums: LocalLibraryState.albums-visible;
grouped: LocalLibraryState.albums-grouped;
is-grouped: LocalLibraryState.albums-group != "off";
view-mode: LocalLibraryState.albums-view-mode;
// List view shows the SOURCE column (local/offline/plex).
show-source: true;
// Local albums aren't Qobuz-favoritable — hide the heart.
show-favorite: false;
// Always-visible source badge (local | qobuz-offline | plex).
@@ -790,15 +889,23 @@ export component LocalLibraryView inherits Rectangle {
LocalLibraryActions.album-action(id, action);
}
}
if LocalLibraryState.albums-loading-more: HorizontalLayout {
alignment: center;
padding-top: 16px;
LoadingSpinner { size: 24px; }
}
}
}
// A-Z jump strip (alpha grouping; proportional like the rail).
if LocalLibraryState.albums-group == "alpha"
&& LocalLibraryState.albums-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 2px;
height: 100%;
jumps: LocalLibraryState.albums-alpha;
jump(ord, idx) => {
albums-flick.viewport-y = -(ord / Math.max(1, LocalLibraryState.albums-alpha.length - 1))
* Math.max(0, (albums-flick.viewport-height - albums-flick.height) / 1px) * 1px;
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
x: LocalLibraryState.albums-group == "alpha"
? parent.width - self.width - 22px
: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: albums-flick.viewport-height;
@@ -1560,4 +1667,209 @@ export component LocalLibraryView inherits Rectangle {
}
}
}
// ===== Albums quality/format/source filter popup (overlay) =====
// Sibling of the content VerticalLayout (child of the view Rectangle), so
// it FLOATS over the content instead of taking a layout slot.
if LibAlbumFilterState.open: Rectangle {
// Click-out backdrop (covers the whole view).
TouchArea {
mouse-cursor: default;
clicked => {
LibAlbumFilterState.open = false;
}
}
Rectangle {
width: 372px;
height: filt-col.preferred-height;
x: parent.width - self.width - 36px;
y: 92px;
background: Theme.surface-card;
border-radius: 10px;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 28px;
drop-shadow-color: #00000080;
TouchArea { }
filt-col := VerticalLayout {
padding: 16px;
spacing: 10px;
HorizontalLayout {
Text {
text: @tr("Filter");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
if LibAlbumFilterState.count > 0: Rectangle {
width: clr.preferred-width + 18px;
height: 28px;
border-radius: 6px;
background: clr-ta.has-hover ? Theme.surface-hover : transparent;
clr := Text {
text: @tr("Clear");
color: Theme.accent;
font-size: Typography.legal;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
clr-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LocalLibraryActions.albums-clear-filter();
}
}
}
}
// Quality.
Text {
text: @tr("Quality");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
HorizontalLayout {
spacing: 8px;
FilterChip {
label: @tr("Hi-Res");
active: LibAlbumFilterState.hires;
toggled => {
LibAlbumFilterState.hires = !LibAlbumFilterState.hires;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: @tr("CD");
active: LibAlbumFilterState.cd;
toggled => {
LibAlbumFilterState.cd = !LibAlbumFilterState.cd;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: @tr("Lossy");
active: LibAlbumFilterState.lossy;
toggled => {
LibAlbumFilterState.lossy = !LibAlbumFilterState.lossy;
LocalLibraryActions.albums-filter-changed();
}
}
Rectangle { horizontal-stretch: 1; }
}
// Format.
Text {
text: @tr("Format");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
HorizontalLayout {
spacing: 8px;
FilterChip {
label: "FLAC";
active: LibAlbumFilterState.flac;
toggled => {
LibAlbumFilterState.flac = !LibAlbumFilterState.flac;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: "ALAC";
active: LibAlbumFilterState.alac;
toggled => {
LibAlbumFilterState.alac = !LibAlbumFilterState.alac;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: "APE";
active: LibAlbumFilterState.ape;
toggled => {
LibAlbumFilterState.ape = !LibAlbumFilterState.ape;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: "WAV";
active: LibAlbumFilterState.wav;
toggled => {
LibAlbumFilterState.wav = !LibAlbumFilterState.wav;
LocalLibraryActions.albums-filter-changed();
}
}
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: 8px;
FilterChip {
label: "MP3";
active: LibAlbumFilterState.mp3;
toggled => {
LibAlbumFilterState.mp3 = !LibAlbumFilterState.mp3;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: "AAC";
active: LibAlbumFilterState.aac;
toggled => {
LibAlbumFilterState.aac = !LibAlbumFilterState.aac;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: @tr("Other");
active: LibAlbumFilterState.other;
toggled => {
LibAlbumFilterState.other = !LibAlbumFilterState.other;
LocalLibraryActions.albums-filter-changed();
}
}
Rectangle { horizontal-stretch: 1; }
}
// Source.
Text {
text: @tr("Source");
color: Theme.text-muted;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
HorizontalLayout {
spacing: 8px;
FilterChip {
label: @tr("Local");
active: LibAlbumFilterState.local;
toggled => {
LibAlbumFilterState.local = !LibAlbumFilterState.local;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: @tr("Offline cache");
active: LibAlbumFilterState.offline;
toggled => {
LibAlbumFilterState.offline = !LibAlbumFilterState.offline;
LocalLibraryActions.albums-filter-changed();
}
}
FilterChip {
label: "Plex";
active: LibAlbumFilterState.plex;
toggled => {
LibAlbumFilterState.plex = !LibAlbumFilterState.plex;
LocalLibraryActions.albums-filter-changed();
}
}
Rectangle { horizontal-stretch: 1; }
}
}
}
}
}
@@ -269,11 +269,11 @@ export component AlbumListRow inherits Rectangle {
// --- SOURCE (hideable) ----------------------------------------
if root.show-source: Text {
width: AlbumListCols.source;
text: root.album.source == "qobuz"
? @tr("Qobuz")
: (root.album.source == "plex"
? @tr("Plex")
: (root.album.source == "local" ? @tr("Local") : ""));
text: root.album.source == "qobuz" ? @tr("Qobuz")
: root.album.source == "qobuz_download" ? @tr("Offline")
: root.album.source == "plex" ? @tr("Plex")
: root.album.source == "local" ? @tr("Local")
: "";
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
+3
View File
@@ -24,6 +24,7 @@ import { LyricsSidebar } from "LyricsSidebar.slint";
import { HomeView } from "../discover/HomeView.slint";
import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { LocalAlbumView } from "../album/LocalAlbumView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
import { MusicianPageView } from "../musician/MusicianPageView.slint";
import { LabelReleasesView } from "../label/LabelReleasesView.slint";
@@ -124,6 +125,8 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action);
}
}
// Dedicated Local Library album detail (separate from AlbumView).
if NavState.view == ContentView.local-album: LocalAlbumView { }
if NavState.view == ContentView.artist: ArtistPageView {
open-album(id) => {
root.open-album(id);
+80 -5
View File
@@ -332,7 +332,7 @@ export global DragActions {
}
// Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library }
export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, local-library, local-album }
// In-app toast (transient notification). Mirrors Tauri's toastStore:
// a single toast at a time (a new one replaces the current), auto-hidden
@@ -401,8 +401,15 @@ export global LocalLibraryState {
// "artist-asc" | "artist-desc" | "title-asc" | "title-desc"
// | "year-desc" | "year-asc"
in-out property <string> albums-sort: "artist-asc";
// "grid" | "list" — the list view toggle lands with the next slice.
// "grid" | "list" view toggle.
in-out property <string> albums-view-mode: "grid";
// `albums` is the full mapped set (artwork-index target). `albums-visible`
// / `albums-grouped` are the search+filter+sort+group-derived render sets.
in property <[AlbumCardItem]> albums-visible: [];
in property <[DiscoverSection]> albums-grouped: [];
in-out property <string> albums-group: "off"; // off | alpha | artist
in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha group)
in property <int> albums-shown: 0; // count after filter
// --- Tracks tab (server-paginated flat list — the 16K-freeze fix) ---
// Group-by / multi-select / per-row play land with the playback slice.
@@ -480,10 +487,16 @@ export global LocalLibraryActions {
// --- Albums tab ---
// Re-query page 1 with the current search text (server-side LIKE).
callback albums-search(string /* query */);
// Set the sort key + re-query page 1 (server-side ORDER BY).
// Set the sort key + re-derive (in-memory, full-load).
callback albums-set-sort(string /* sort key */);
// Fetch + append the next page (driven by scroll-near-bottom).
callback albums-load-more();
// Set group mode (off|alpha|artist) + re-derive.
callback albums-set-group(string /* mode */);
// Set grid|list view.
callback albums-set-view(string /* mode */);
// A quality/format/source filter toggle changed -> re-derive.
callback albums-filter-changed();
// Clear all quality/format/source filters.
callback albums-clear-filter();
// Retry after a load error.
callback albums-retry();
// Open a local album's detail view (lands with the album-detail slice).
@@ -529,6 +542,68 @@ export global LocalLibraryActions {
callback artists-select(string /* artist name */);
}
// Albums tab quality/format/source filter (1:1 with Tauri matchesQualityFilters:
// OR within each group, AND between groups; an empty group matches all).
export global LibAlbumFilterState {
in-out property <bool> open: false;
// Quality.
in-out property <bool> hires: false;
in-out property <bool> cd: false;
in-out property <bool> lossy: false;
// Format.
in-out property <bool> flac: false;
in-out property <bool> alac: false;
in-out property <bool> ape: false;
in-out property <bool> wav: false;
in-out property <bool> mp3: false;
in-out property <bool> aac: false;
in-out property <bool> other: false;
// Source.
in-out property <bool> local: false;
in-out property <bool> offline: false;
in-out property <bool> plex: false;
// Active-filter count (Rust-computed) for the toolbar badge.
in property <int> count: 0;
}
// Dedicated Local Library album detail (separate from the Qobuz AlbumView).
// Reuses TrackRow / NavButtons / QualityBadge internals, but ONLY local
// actions (play / shuffle / edit tags / add-to-playlist) — no Qobuz chrome.
// When an album has multiple physical versions (distinct source folders),
// `versions` drives a picker and the track list shows ONE version (no dups).
// One physical version of a local album (a distinct source folder). The
// picker shows the source icon + a quality/format label.
export struct LocalAlbumVersion {
label: string, // "24-bit / 96 kHz · FLAC"
source: string, // "user" | "qobuz_download" | "plex" | ""
}
export global LocalAlbumState {
in property <string> id; // metadata group key
in property <string> title;
in property <string> artist;
in property <string> info-line; // "12 tracks · 48 min"
in property <string> quality-tier; // hires | cd | ""
in property <string> quality-detail; // "24-bit / 96 kHz"
in property <string> cover-url;
in property <image> cover;
in property <bool> loading: false;
in property <[TrackItem]> tracks: [];
// Versions (distinct copies). >1 -> show the picker. Sorted best-quality
// first, so version 0 is the highest-quality copy.
in property <[LocalAlbumVersion]> versions: [];
in-out property <int> version-index: 0;
}
export global LocalAlbumActions {
callback play-all();
callback shuffle();
callback edit-tags();
callback add-to-playlist();
callback play-track(string /* track id */);
callback select-version(int);
}
// ============================ Local Library management ====================
// The folder list + scan + maintenance + danger zone live under
// Settings > Local Library (NOT the browse view). One row per registered