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
|
/// is the albums generation at fetch time; a stale cover (the model was
|
||||||
/// replaced by a search/sort/retry) is dropped on apply.
|
/// replaced by a search/sort/retry) is dropped on apply.
|
||||||
LocalAlbumCard { index: usize, gen: u64 },
|
LocalAlbumCard { index: usize, gen: u64 },
|
||||||
|
/// A card in LocalLibraryState.folders[index] (Folders-flat grid).
|
||||||
|
LocalFolderCard { index: usize },
|
||||||
/// A card in FavoritesState.artists[index].
|
/// A card in FavoritesState.artists[index].
|
||||||
FavoriteArtist { index: usize },
|
FavoriteArtist { index: usize },
|
||||||
/// A card in FavoritesState.labels[index].
|
/// A card in FavoritesState.labels[index].
|
||||||
@@ -613,6 +615,13 @@ fn apply_artwork(
|
|||||||
model.set_row_data(index, item);
|
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 } => {
|
ArtworkTarget::FavoriteArtist { index } => {
|
||||||
let model = window.global::<crate::FavoritesState>().get_artists();
|
let model = window.global::<crate::FavoritesState>().get_artists();
|
||||||
if let Some(mut item) = model.row_data(index) {
|
if let Some(mut item) = model.row_data(index) {
|
||||||
|
|||||||
@@ -670,6 +670,86 @@ pub fn apply_local_album(
|
|||||||
s.set_loading(false);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -433,8 +433,7 @@ fn navigate_local_album(
|
|||||||
});
|
});
|
||||||
let gk = group_key.clone();
|
let gk = group_key.clone();
|
||||||
let tracks = tokio::task::spawn_blocking(move || {
|
let tracks = tokio::task::spawn_blocking(move || {
|
||||||
let mut t = crate::library_db::with_db(|db| db.get_album_tracks_metadata(&gk))
|
let mut t = local_library::fetch_album_tracks_blocking(&gk);
|
||||||
.unwrap_or_default();
|
|
||||||
playback::fill_missing_covers(&mut t);
|
playback::fill_missing_covers(&mut t);
|
||||||
t
|
t
|
||||||
})
|
})
|
||||||
@@ -966,6 +965,9 @@ fn navigate_local_library(
|
|||||||
local_library::LibTab::Albums => {
|
local_library::LibTab::Albums => {
|
||||||
local_library::ensure_albums_loaded(weak, handle.clone(), image_cache);
|
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::LibTab::Tracks => {
|
||||||
local_library::ensure_tracks_loaded(weak, handle.clone());
|
local_library::ensure_tracks_loaded(weak, handle.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,8 +226,7 @@ pub fn play_local_album(
|
|||||||
) {
|
) {
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let tracks = tokio::task::spawn_blocking(move || {
|
let tracks = tokio::task::spawn_blocking(move || {
|
||||||
let mut tracks = crate::library_db::with_db(|db| db.get_album_tracks_metadata(&album_id))
|
let mut tracks = crate::local_library::fetch_album_tracks_blocking(&album_id);
|
||||||
.unwrap_or_default();
|
|
||||||
fill_missing_covers(&mut tracks);
|
fill_missing_covers(&mut tracks);
|
||||||
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 ----
|
// -------- Other tabs — placeholder until their slices land ----
|
||||||
if LocalLibraryState.active-tab != "albums"
|
if LocalLibraryState.active-tab != "albums"
|
||||||
&& LocalLibraryState.active-tab != "tracks": VerticalLayout {
|
&& LocalLibraryState.active-tab != "tracks"
|
||||||
|
&& LocalLibraryState.active-tab != "folders": VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
Text {
|
Text {
|
||||||
|
|||||||
@@ -377,6 +377,11 @@ export global LocalLibraryState {
|
|||||||
in property <bool> tracks-load-failed: false;
|
in property <bool> tracks-load-failed: false;
|
||||||
in-out property <int> tracks-next-offset: 0;
|
in-out property <int> tracks-next-offset: 0;
|
||||||
in-out property <string> tracks-search: "";
|
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 {
|
export global LocalLibraryActions {
|
||||||
|
|||||||
Reference in new issue
Block a user