feat(slint): favorites albums group-by (Off / A-Z / Artist)
Add a group QbzSelect to the Albums toolbar. derive_albums buckets the filtered set by artist name or by the title's first letter (# for non-alphabetic), sections ordered alphabetically, rendered through the shared AlbumCollectionView grouped path. albums-shown carries the post-filter count so the count line + no-match state stay correct in grouped mode. cc
This commit is contained in:
4 files changed
+107
-8
No files matched your search
@@ -8,6 +8,7 @@
|
||||
//! relevant branch into typed qbz-models items and maps them to the
|
||||
//! Slint row/card structs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
@@ -19,8 +20,8 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
use crate::album_map::{self, map_album, to_item, AlbumCard};
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
use crate::{
|
||||
AlbumCardItem, AppWindow, FavoriteArtistItem, FavoriteLabelItem, FavoritePlaylistItem,
|
||||
FavoritesState, TrackItem,
|
||||
AlbumCardItem, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
|
||||
FavoritePlaylistItem, FavoritesState, TrackItem,
|
||||
};
|
||||
|
||||
/// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one
|
||||
@@ -363,11 +364,14 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
||||
// (what the grid/list renders) shares it until a search/sort
|
||||
// forks it, so artwork stays live.
|
||||
let model = ModelRc::new(VecModel::from(cards));
|
||||
let n = model.row_count() as i32;
|
||||
state.set_albums(model.clone());
|
||||
state.set_albums_visible(model);
|
||||
state.set_albums_total(total as i32);
|
||||
state.set_albums_shown(n);
|
||||
state.set_albums_search("".into());
|
||||
state.set_albums_sort_by("default".into());
|
||||
state.set_albums_group_mode("off".into());
|
||||
}
|
||||
FavData::Artists { items, total } => {
|
||||
let cards: Vec<FavoriteArtistItem> = items
|
||||
@@ -472,11 +476,19 @@ pub fn derive_albums(window: &AppWindow) {
|
||||
let query_owned = state.get_albums_search().to_lowercase();
|
||||
let query = query_owned.trim();
|
||||
let sort = state.get_albums_sort_by().to_string();
|
||||
let group = state.get_albums_group_mode().to_string();
|
||||
let all = state.get_albums();
|
||||
if query.is_empty() && sort == "default" {
|
||||
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
|
||||
|
||||
// Fast path: no filter, default order, no grouping -> share full model.
|
||||
if query.is_empty() && sort == "default" && group == "off" {
|
||||
let n = all.row_count() as i32;
|
||||
state.set_albums_visible(all);
|
||||
state.set_albums_grouped(empty_sections());
|
||||
state.set_albums_shown(n);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
||||
.filter_map(|i| all.row_data(i))
|
||||
.filter(|a| {
|
||||
@@ -486,7 +498,59 @@ pub fn derive_albums(window: &AppWindow) {
|
||||
})
|
||||
.collect();
|
||||
album_map::sort_album_items(&mut filtered, &sort);
|
||||
state.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
|
||||
state.set_albums_shown(filtered.len() as i32);
|
||||
|
||||
if group == "off" {
|
||||
state.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
|
||||
state.set_albums_grouped(empty_sections());
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped: bucket by artist name, or by the title's first letter
|
||||
// (# bucket for non-alphabetic), sections ordered alphabetically.
|
||||
let mut map: Vec<(String, Vec<AlbumCardItem>)> = Vec::new();
|
||||
let mut index: HashMap<String, usize> = 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 {
|
||||
album_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::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.to_lowercase().cmp(&b.to_lowercase()),
|
||||
});
|
||||
let sections: Vec<DiscoverSection> = map
|
||||
.into_iter()
|
||||
.map(|(key, items)| DiscoverSection {
|
||||
title: key.into(),
|
||||
endpoint: "".into(),
|
||||
albums: ModelRc::new(VecModel::from(items)),
|
||||
})
|
||||
.collect();
|
||||
state.set_albums_grouped(ModelRc::new(VecModel::from(sections)));
|
||||
state.set_albums_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
|
||||
}
|
||||
|
||||
/// First-letter bucket key for alpha grouping (# for non-alphabetic).
|
||||
fn album_alpha_key(title: &str) -> String {
|
||||
match title.trim().chars().next() {
|
||||
Some(c) if c.is_ascii_alphabetic() => c.to_ascii_uppercase().to_string(),
|
||||
Some(c) if c.is_alphabetic() => c.to_uppercase().to_string(),
|
||||
_ => "#".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A random album id from the currently-visible set (Shuffle / random).
|
||||
|
||||
@@ -4300,6 +4300,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Group the favorite albums (off / alpha / artist).
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.on_albums_set_group(move |g| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<FavoritesState>().set_albums_group_mode(g);
|
||||
favorites::derive_albums(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Play a random album from the visible favorites set.
|
||||
let weak = window.as_weak();
|
||||
|
||||
@@ -486,7 +486,7 @@ export component FavoritesView inherits Rectangle {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
Text {
|
||||
text: FavoritesState.albums-visible.length
|
||||
text: FavoritesState.albums-shown
|
||||
+ (FavoritesState.albums-search != ""
|
||||
? " / " + FavoritesState.albums.length
|
||||
: "")
|
||||
@@ -505,6 +505,24 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
menu-width: 150px;
|
||||
options: [
|
||||
@tr("Group: Off"),
|
||||
@tr("Group: A-Z"),
|
||||
@tr("Group: Artist"),
|
||||
];
|
||||
current-index: FavoritesState.albums-group-mode == "off" ? 0
|
||||
: FavoritesState.albums-group-mode == "alpha" ? 1
|
||||
: 2;
|
||||
selected(i) => {
|
||||
FavoritesActions.albums-set-group(
|
||||
i == 0 ? "off" : i == 1 ? "alpha" : "artist");
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
@@ -563,7 +581,7 @@ export component FavoritesView inherits Rectangle {
|
||||
font-size: Typography.body;
|
||||
}
|
||||
if FavoritesState.albums.length > 0
|
||||
&& FavoritesState.albums-visible.length == 0: Text {
|
||||
&& FavoritesState.albums-shown == 0: Text {
|
||||
text: @tr("No albums match your search.");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
@@ -571,8 +589,9 @@ export component FavoritesView inherits Rectangle {
|
||||
|
||||
AlbumCollectionView {
|
||||
albums: FavoritesState.albums-visible;
|
||||
grouped: FavoritesState.albums-grouped;
|
||||
view-mode: FavoritesState.albums-view-mode;
|
||||
is-grouped: false;
|
||||
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||
show-source: false;
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
|
||||
@@ -1461,10 +1461,13 @@ export global FavoritesState {
|
||||
in-out property <bool> tracks-multi-select: false;
|
||||
in property <int> tracks-selected-count: 0;
|
||||
in property <[AlbumCardItem]> albums: [];
|
||||
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (what the grid/list renders)
|
||||
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
|
||||
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
|
||||
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
|
||||
in-out property <string> albums-search: "";
|
||||
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
|
||||
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
|
||||
in property <int> albums-shown: 0; // count after filter (grouped or flat)
|
||||
in property <[FavoriteArtistItem]> artists: [];
|
||||
in property <[FavoritePlaylistItem]> playlists: [];
|
||||
in property <[FavoriteLabelItem]> labels: [];
|
||||
@@ -1494,6 +1497,7 @@ export global FavoritesActions {
|
||||
callback album-action(string /* album id */, string /* action */);
|
||||
callback albums-search(string /* query */);
|
||||
callback albums-set-sort(string /* sort key */);
|
||||
callback albums-set-group(string /* off | alpha | artist */);
|
||||
callback albums-shuffle();
|
||||
callback unfavorite-track(string /* track id */);
|
||||
callback unfavorite-album(string /* album id */);
|
||||
|
||||
Reference in new issue
Block a user