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
+106
-7
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
//! relevant branch into typed qbz-models items and maps them to the
|
//! relevant branch into typed qbz-models items and maps them to the
|
||||||
//! Slint row/card structs.
|
//! Slint row/card structs.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, LazyLock, Mutex};
|
use std::sync::{Arc, LazyLock, Mutex};
|
||||||
|
|
||||||
use qbz_app::shell::AppRuntime;
|
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::album_map::{self, map_album, to_item, AlbumCard};
|
||||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||||
use crate::{
|
use crate::{
|
||||||
AlbumCardItem, AppWindow, FavoriteArtistItem, FavoriteLabelItem, FavoritePlaylistItem,
|
AlbumCardItem, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
|
||||||
FavoritesState, TrackItem,
|
FavoritePlaylistItem, FavoritesState, TrackItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one
|
/// 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
|
// (what the grid/list renders) shares it until a search/sort
|
||||||
// forks it, so artwork stays live.
|
// forks it, so artwork stays live.
|
||||||
let model = ModelRc::new(VecModel::from(cards));
|
let model = ModelRc::new(VecModel::from(cards));
|
||||||
|
let n = model.row_count() as i32;
|
||||||
state.set_albums(model.clone());
|
state.set_albums(model.clone());
|
||||||
state.set_albums_visible(model);
|
state.set_albums_visible(model);
|
||||||
state.set_albums_total(total as i32);
|
state.set_albums_total(total as i32);
|
||||||
|
state.set_albums_shown(n);
|
||||||
state.set_albums_search("".into());
|
state.set_albums_search("".into());
|
||||||
state.set_albums_sort_by("default".into());
|
state.set_albums_sort_by("default".into());
|
||||||
|
state.set_albums_group_mode("off".into());
|
||||||
}
|
}
|
||||||
FavData::Artists { items, total } => {
|
FavData::Artists { items, total } => {
|
||||||
let cards: Vec<FavoriteArtistItem> = items
|
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_owned = state.get_albums_search().to_lowercase();
|
||||||
let query = query_owned.trim();
|
let query = query_owned.trim();
|
||||||
let sort = state.get_albums_sort_by().to_string();
|
let sort = state.get_albums_sort_by().to_string();
|
||||||
|
let group = state.get_albums_group_mode().to_string();
|
||||||
let all = state.get_albums();
|
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_visible(all);
|
||||||
|
state.set_albums_grouped(empty_sections());
|
||||||
|
state.set_albums_shown(n);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
||||||
.filter_map(|i| all.row_data(i))
|
.filter_map(|i| all.row_data(i))
|
||||||
.filter(|a| {
|
.filter(|a| {
|
||||||
@@ -486,7 +498,59 @@ pub fn derive_albums(window: &AppWindow) {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
album_map::sort_album_items(&mut filtered, &sort);
|
album_map::sort_album_items(&mut filtered, &sort);
|
||||||
|
state.set_albums_shown(filtered.len() as i32);
|
||||||
|
|
||||||
|
if group == "off" {
|
||||||
state.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
|
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).
|
/// 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.
|
// Play a random album from the visible favorites set.
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
alignment: center;
|
alignment: center;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
Text {
|
Text {
|
||||||
text: FavoritesState.albums-visible.length
|
text: FavoritesState.albums-shown
|
||||||
+ (FavoritesState.albums-search != ""
|
+ (FavoritesState.albums-search != ""
|
||||||
? " / " + FavoritesState.albums.length
|
? " / " + 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 {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzSelect {
|
QbzSelect {
|
||||||
@@ -563,7 +581,7 @@ export component FavoritesView inherits Rectangle {
|
|||||||
font-size: Typography.body;
|
font-size: Typography.body;
|
||||||
}
|
}
|
||||||
if FavoritesState.albums.length > 0
|
if FavoritesState.albums.length > 0
|
||||||
&& FavoritesState.albums-visible.length == 0: Text {
|
&& FavoritesState.albums-shown == 0: Text {
|
||||||
text: @tr("No albums match your search.");
|
text: @tr("No albums match your search.");
|
||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
font-size: Typography.body;
|
font-size: Typography.body;
|
||||||
@@ -571,8 +589,9 @@ export component FavoritesView inherits Rectangle {
|
|||||||
|
|
||||||
AlbumCollectionView {
|
AlbumCollectionView {
|
||||||
albums: FavoritesState.albums-visible;
|
albums: FavoritesState.albums-visible;
|
||||||
|
grouped: FavoritesState.albums-grouped;
|
||||||
view-mode: FavoritesState.albums-view-mode;
|
view-mode: FavoritesState.albums-view-mode;
|
||||||
is-grouped: false;
|
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||||
show-source: false;
|
show-source: false;
|
||||||
open-album(id) => {
|
open-album(id) => {
|
||||||
root.open-album(id);
|
root.open-album(id);
|
||||||
|
|||||||
@@ -1461,10 +1461,13 @@ export global FavoritesState {
|
|||||||
in-out property <bool> tracks-multi-select: false;
|
in-out property <bool> tracks-multi-select: false;
|
||||||
in property <int> tracks-selected-count: 0;
|
in property <int> tracks-selected-count: 0;
|
||||||
in property <[AlbumCardItem]> albums: [];
|
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-view-mode: "grid"; // "grid" | "list"
|
||||||
in-out property <string> albums-search: "";
|
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-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 <[FavoriteArtistItem]> artists: [];
|
||||||
in property <[FavoritePlaylistItem]> playlists: [];
|
in property <[FavoritePlaylistItem]> playlists: [];
|
||||||
in property <[FavoriteLabelItem]> labels: [];
|
in property <[FavoriteLabelItem]> labels: [];
|
||||||
@@ -1494,6 +1497,7 @@ export global FavoritesActions {
|
|||||||
callback album-action(string /* album id */, string /* action */);
|
callback album-action(string /* album id */, string /* action */);
|
||||||
callback albums-search(string /* query */);
|
callback albums-search(string /* query */);
|
||||||
callback albums-set-sort(string /* sort key */);
|
callback albums-set-sort(string /* sort key */);
|
||||||
|
callback albums-set-group(string /* off | alpha | artist */);
|
||||||
callback albums-shuffle();
|
callback albums-shuffle();
|
||||||
callback unfavorite-track(string /* track id */);
|
callback unfavorite-track(string /* track id */);
|
||||||
callback unfavorite-album(string /* album id */);
|
callback unfavorite-album(string /* album id */);
|
||||||
|
|||||||
Reference in new issue
Block a user