feat(slint): favorites Artists A-Z grouping + alpha strip

Add the optional A-Z group toggle to the Artists grid (Group: Off/A-Z
QbzSelect, default Off, persisted via favorites_prefs artists_group).
derive_artists now buckets the filtered set into FavArtistSection letter
groups (# first, then A-Z) and builds the alpha-jump model; the grouped grid
renders per-letter headers + sub-grids and an AlphaStrip on the right that
jumps the Flickable proportionally (mirrors the Albums alpha grouping).
Also renames the favorites view header title "Favorites" -> "Library" to
match Qobuz.

cc
This commit is contained in:
vicrodh committed 2026-05-27 15:32:15 -06:00
1 parent da683cd5d0
commit 41692012ee
4 files changed
+150 -22

No files matched your search

+58 -8
View File
@@ -22,8 +22,8 @@ use crate::album_map::{self, map_album, to_item, AlbumCard};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::search::{self, PlaylistRow};
use crate::{
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
FavoritesState, SearchPlaylistItem, TrackItem,
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FavArtistSection, FavoriteArtistItem,
FavoriteLabelItem, FavoritesState, SearchPlaylistItem, TrackItem,
};
/// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one
@@ -630,18 +630,68 @@ pub fn derive_artists(window: &AppWindow) {
let state = window.global::<FavoritesState>();
let query_owned = state.get_artists_search().to_lowercase();
let query = query_owned.trim();
let group = state.get_artists_group_enabled();
let all = state.get_artists();
// Flat (search-filtered) model. Share `all` when no query so artwork
// keeps updating in place; a query forks a filtered clone.
let filtered: Vec<FavoriteArtistItem> = if query.is_empty() {
(0..all.row_count()).filter_map(|i| all.row_data(i)).collect()
} else {
(0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|a| a.name.to_lowercase().contains(query))
.collect()
};
state.set_artists_shown(filtered.len() as i32);
if query.is_empty() {
state.set_artists_shown(all.row_count() as i32);
state.set_artists_visible(all);
} else {
state.set_artists_visible(ModelRc::new(VecModel::from(filtered.clone())));
}
// A-Z grouping (grid grouped mode): bucket by first letter, sections
// ordered (# first then A-Z), with an alpha jump per section.
if !group {
state.set_artists_grouped(ModelRc::new(VecModel::from(Vec::<FavArtistSection>::new())));
state.set_artists_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
return;
}
let filtered: Vec<FavoriteArtistItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|a| a.name.to_lowercase().contains(query))
let mut sorted = filtered;
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let mut map: Vec<(String, Vec<FavoriteArtistItem>)> = Vec::new();
let mut index: HashMap<String, usize> = HashMap::new();
for item in sorted {
let key = album_alpha_key(item.name.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.cmp(b),
});
let alpha: Vec<AlphaJump> = map
.iter()
.enumerate()
.map(|(i, (k, _))| AlphaJump {
letter: k.clone().into(),
index: i as i32,
})
.collect();
state.set_artists_shown(filtered.len() as i32);
state.set_artists_visible(ModelRc::new(VecModel::from(filtered)));
let sections: Vec<FavArtistSection> = map
.into_iter()
.map(|(key, artists)| FavArtistSection {
key: key.clone().into(),
title: key.into(),
artists: ModelRc::new(VecModel::from(artists)),
})
.collect();
state.set_artists_alpha(ModelRc::new(VecModel::from(alpha)));
state.set_artists_grouped(ModelRc::new(VecModel::from(sections)));
}
/// The loaded favorite tracks as a play-ready queue (Play all).
+5
View File
@@ -22,6 +22,8 @@ struct Prefs {
tracks_group: String,
#[serde(default = "d_grid")]
playlists_view: String,
#[serde(default)]
artists_group: bool,
}
impl Default for Prefs {
@@ -32,6 +34,7 @@ impl Default for Prefs {
albums_group: d_off(),
tracks_group: d_off(),
playlists_view: d_grid(),
artists_group: false,
}
}
}
@@ -69,6 +72,7 @@ pub fn load(window: &AppWindow) {
st.set_albums_group_mode(p.albums_group.into());
st.set_tracks_group_mode(p.tracks_group.into());
st.set_playlists_view_mode(p.playlists_view.into());
st.set_artists_group_enabled(p.artists_group);
}
/// Persist the current toolbar choices read from `FavoritesState`.
@@ -83,6 +87,7 @@ pub fn save(window: &AppWindow) {
albums_group: st.get_albums_group_mode().into(),
tracks_group: st.get_tracks_group_mode().into(),
playlists_view: st.get_playlists_view_mode().into(),
artists_group: st.get_artists_group_enabled(),
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
+14
View File
@@ -4376,6 +4376,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Group the favorite artists (off / A-Z) — persisted.
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_artists_set_group(move |g| {
if let Some(w) = weak.upgrade() {
w.global::<FavoritesState>()
.set_artists_group_enabled(g == "alpha");
favorites::derive_artists(&w);
favorites_prefs::save(&w);
}
});
}
{
// Playlist card actions: play / play-next / queue / share / favorite.
let runtime = app_runtime.clone();
@@ -567,7 +567,7 @@ export component FavoritesView inherits Rectangle {
VerticalLayout {
alignment: center;
Text {
text: @tr("Favorites");
text: @tr("Library");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
@@ -827,6 +827,21 @@ export component FavoritesView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 150px;
options: [
@tr("Group: Off"),
@tr("Group: A-Z"),
];
sm: true;
current-index: FavoritesState.artists-group-enabled ? 1 : 0;
selected(i) => {
FavoritesActions.artists-set-group(i == 0 ? "off" : "alpha");
}
}
}
}
}
@@ -1067,14 +1082,8 @@ export component FavoritesView inherits Rectangle {
}
// --- Artists tab ---
if FavoritesState.active-tab == "artists": artists-grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
property <int> rows: Math.ceil(FavoritesState.artists-visible.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
if FavoritesState.active-tab == "artists": VerticalLayout {
spacing: 0px;
if FavoritesState.artists.length == 0: Text {
text: @tr("No favorite artists yet.");
color: Theme.text-muted;
@@ -1086,11 +1095,49 @@ export component FavoritesView inherits Rectangle {
color: Theme.text-muted;
font-size: Typography.body;
}
for artist[i] in FavoritesState.artists-visible: FavArtistCard {
x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap);
item: artist;
clicked(id) => { root.open-artist(id); }
// Flat grid (grouping off).
if !FavoritesState.artists-group-enabled: artists-grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
property <int> rows: Math.ceil(FavoritesState.artists-visible.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
for artist[i] in FavoritesState.artists-visible: FavArtistCard {
x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap);
item: artist;
clicked(id) => { root.open-artist(id); }
}
}
// Grouped (A-Z) — sections with letter headers.
if FavoritesState.artists-group-enabled: VerticalLayout {
spacing: 10px;
for section[s] in FavoritesState.artists-grouped: VerticalLayout {
spacing: 6px;
Text {
text: section.title;
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.semibold;
}
section-grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
property <int> rows: Math.ceil(section.artists.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
for artist[i] in section.artists: FavArtistCard {
x: Math.mod(i, section-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / section-grid.columns) * (root.artist-h + root.gap);
item: artist;
clicked(id) => { root.open-artist(id); }
}
}
}
}
}
@@ -1221,6 +1268,18 @@ export component FavoritesView inherits Rectangle {
* Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px;
}
}
// A-Z jump strip (artists grouping) — proportional jump.
if FavoritesState.active-tab == "artists"
&& FavoritesState.artists-group-enabled
&& FavoritesState.artists-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 20px;
height: 100%;
jumps: FavoritesState.artists-alpha;
jump(ord, idx) => {
other-flick.viewport-y = -(ord / Math.max(1, FavoritesState.artists-alpha.length - 1))
* Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px;
}
}
}
}
}