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