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:
vicrodh committed 2026-05-27 07:05:17 -06:00
1 parent 66a9f9da22
commit 39b7b6d2f1
4 files changed
+107 -8

No files matched your search

+68 -4
View File
@@ -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).
+12
View File
@@ -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);
+5 -1
View File
@@ -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 */);