feat(locallibrary): Folders tab (flat) — folder-grouped album grid

Fills the Folders tab: a full-loaded grid of albums grouped by directory
(get_albums_with_full_filter), via the shared AlbumCollectionView (grid +
source badge), covers loaded from disk, loaded once on first visit. Clicking
a folder album opens its detail + plays it — navigate_local_album and
play_local_album now resolve tracks via fetch_album_tracks_blocking, which
tries the metadata grouping (Albums tab) then the folder grouping (Folders
tab), so both album views work. New ArtworkTarget::LocalFolderCard applies
covers to the folders model.

Tree mode (two-pane lazy folder tree) + folder search/sort are later slices.

cc
This commit is contained in:
vicrodh committed 2026-05-29 07:37:47 -06:00
1 parent 79551cebb7
commit df351ec6f6
6 files changed
+162 -5

No files matched your search

+9
View File
@@ -83,6 +83,8 @@ pub enum ArtworkTarget {
/// is the albums generation at fetch time; a stale cover (the model was
/// replaced by a search/sort/retry) is dropped on apply.
LocalAlbumCard { index: usize, gen: u64 },
/// A card in LocalLibraryState.folders[index] (Folders-flat grid).
LocalFolderCard { index: usize },
/// A card in FavoritesState.artists[index].
FavoriteArtist { index: usize },
/// A card in FavoritesState.labels[index].
@@ -613,6 +615,13 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::LocalFolderCard { index } => {
let model = window.global::<crate::LocalLibraryState>().get_folders();
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) {
+80
View File
@@ -670,6 +670,86 @@ pub fn apply_local_album(
s.set_loading(false);
}
// ============================ Folders (flat) ==============================
//
// The Folders tab (flat mode) is the album grid grouped by directory rather
// than by metadata. Full-load (folder counts are bounded; the freeze risk is
// the Tracks table). Reuses AlbumCollectionView; covers load per card.
fn fetch_folder_albums() -> Vec<crate::album_map::AlbumCard> {
crate::library_db::with_db(|db| {
db.get_albums_with_full_filter(
/* include_hidden */ false,
/* include_qobuz_downloads */ true,
/* exclude_network_folders */ false,
)
})
.unwrap_or_default()
.into_iter()
.map(map_local_album)
.collect()
}
fn folder_artwork_jobs(cards: &[crate::album_map::AlbumCard]) -> Vec<ArtworkJob> {
cards
.iter()
.enumerate()
.filter(|(_, c)| !c.artwork_url.is_empty())
.map(|(i, c)| ArtworkJob {
target: ArtworkTarget::LocalFolderCard { index: i },
url: c.artwork_url.clone(),
})
.collect()
}
fn apply_folders(window: &AppWindow, cards: Vec<crate::album_map::AlbumCard>) {
let items: Vec<AlbumCardItem> = cards.into_iter().map(crate::album_map::to_item).collect();
let s = window.global::<LocalLibraryState>();
s.set_folders(ModelRc::new(VecModel::from(items)));
s.set_folders_loading(false);
s.set_folders_load_failed(false);
}
/// Load the folder-grouped album grid on first visit (re-entry keeps it).
pub fn ensure_folders_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_folders().row_count() != 0 || s.get_folders_loading() {
return;
}
s.set_folders_loading(true);
s.set_folders_load_failed(false);
let weak2 = w.as_weak();
handle.spawn(async move {
let cards = tokio::task::spawn_blocking(fetch_folder_albums)
.await
.unwrap_or_default();
let _ = weak2.upgrade_in_event_loop(move |w| {
let jobs = folder_artwork_jobs(&cards);
apply_folders(&w, cards);
crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache);
});
});
});
}
/// Fetch an album's tracks by group key, trying the metadata grouping first
/// (Albums tab) then the folder grouping (Folders tab). Blocking.
pub fn fetch_album_tracks_blocking(group_key: &str) -> Vec<qbz_library::LocalTrack> {
crate::library_db::with_db(|db| {
let meta = db.get_album_tracks_metadata(group_key)?;
if !meta.is_empty() {
return Ok(meta);
}
db.get_album_tracks(group_key)
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
+4 -2
View File
@@ -433,8 +433,7 @@ fn navigate_local_album(
});
let gk = group_key.clone();
let tracks = tokio::task::spawn_blocking(move || {
let mut t = crate::library_db::with_db(|db| db.get_album_tracks_metadata(&gk))
.unwrap_or_default();
let mut t = local_library::fetch_album_tracks_blocking(&gk);
playback::fill_missing_covers(&mut t);
t
})
@@ -966,6 +965,9 @@ fn navigate_local_library(
local_library::LibTab::Albums => {
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
}
local_library::LibTab::Folders => {
local_library::ensure_folders_loaded(weak, handle.clone(), image_cache);
}
local_library::LibTab::Tracks => {
local_library::ensure_tracks_loaded(weak, handle.clone());
}
+1 -2
View File
@@ -226,8 +226,7 @@ pub fn play_local_album(
) {
handle.spawn(async move {
let tracks = tokio::task::spawn_blocking(move || {
let mut tracks = crate::library_db::with_db(|db| db.get_album_tracks_metadata(&album_id))
.unwrap_or_default();
let mut tracks = crate::local_library::fetch_album_tracks_blocking(&album_id);
fill_missing_covers(&mut tracks);
tracks
})
@@ -536,9 +536,71 @@ export component LocalLibraryView inherits Rectangle {
}
}
// -------- Folders tab (flat) — folder-grouped album grid -----
if LocalLibraryState.active-tab == "folders": Rectangle {
if LocalLibraryState.folders-loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
}
if !LocalLibraryState.folders-loading
&& LocalLibraryState.folders.length == 0: VerticalLayout {
alignment: center;
Text {
text: @tr("No folders in your local library yet.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
if !LocalLibraryState.folders-loading
&& LocalLibraryState.folders.length > 0: Rectangle {
folders-flick := Flickable {
viewport-width: self.width;
viewport-height: folders-page.preferred-height;
folders-page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
padding-bottom: 100px;
alignment: start;
AlbumCollectionView {
albums: LocalLibraryState.folders;
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: folders-flick.viewport-height;
visible-height: folders-flick.height;
viewport-y <=> folders-flick.viewport-y;
}
}
}
// -------- Other tabs — placeholder until their slices land ----
if LocalLibraryState.active-tab != "albums"
&& LocalLibraryState.active-tab != "tracks": VerticalLayout {
&& LocalLibraryState.active-tab != "tracks"
&& LocalLibraryState.active-tab != "folders": VerticalLayout {
alignment: center;
spacing: 6px;
Text {
+5
View File
@@ -377,6 +377,11 @@ export global LocalLibraryState {
in property <bool> tracks-load-failed: false;
in-out property <int> tracks-next-offset: 0;
in-out property <string> tracks-search: "";
// --- Folders tab (flat) — albums grouped by directory (full load) ---
in property <[AlbumCardItem]> folders: [];
in property <bool> folders-loading: false;
in property <bool> folders-load-failed: false;
}
export global LocalLibraryActions {