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:
4 files changed
+150
-22
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user