feat(slint): favorites albums search + sort + shuffle

Add an expandable search (title / artist), a sort QbzSelect
(Default / Title A-Z / Title Z-A / Artist A-Z) and a Shuffle header
action (play a random visible album) to the Albums tab. albums-visible
derives from the full albums set via derive_albums (shared model on the
no-filter/default fast path, filtered+sorted clone otherwise — the
label.rs::derive_releases pattern), and the count reflects the filter.

cc
This commit is contained in:
vicrodh committed 2026-05-26 23:09:17 -06:00
1 parent b4b11a59e5
commit 57bace3e1d
4 files changed
+152 -3

No files matched your search

+49 -1
View File
@@ -350,8 +350,15 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
} }
FavData::Albums { items, total } => { FavData::Albums { items, total } => {
let cards: Vec<AlbumCardItem> = items.into_iter().map(to_item).collect(); let cards: Vec<AlbumCardItem> = items.into_iter().map(to_item).collect();
state.set_albums(ModelRc::new(VecModel::from(cards))); // `albums` is the full set (artwork target); `albums-visible`
// (what the grid/list renders) shares it until a search/sort
// forks it, so artwork stays live.
let model = ModelRc::new(VecModel::from(cards));
state.set_albums(model.clone());
state.set_albums_visible(model);
state.set_albums_total(total as i32); state.set_albums_total(total as i32);
state.set_albums_search("".into());
state.set_albums_sort_by("default".into());
} }
FavData::Artists { items, total } => { FavData::Artists { items, total } => {
let cards: Vec<FavoriteArtistItem> = items let cards: Vec<FavoriteArtistItem> = items
@@ -447,6 +454,47 @@ pub fn shuffled_tracks() -> Vec<Track> {
tracks tracks
} }
/// Re-derive the rendered Albums list (`albums-visible`) from the full
/// `albums` set + the search query and sort key. Empty query + default
/// order shares the full model so artwork stays live; otherwise forks a
/// filtered + sorted clone (mirrors label.rs::derive_releases).
pub fn derive_albums(window: &AppWindow) {
let state = window.global::<FavoritesState>();
let query_owned = state.get_albums_search().to_lowercase();
let query = query_owned.trim();
let sort = state.get_albums_sort_by().to_string();
let all = state.get_albums();
if query.is_empty() && sort == "default" {
state.set_albums_visible(all);
return;
}
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|a| {
query.is_empty()
|| a.title.to_lowercase().contains(query)
|| a.artist.to_lowercase().contains(query)
})
.collect();
album_map::sort_album_items(&mut filtered, &sort);
state.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
}
/// A random album id from the currently-visible set (Shuffle / random).
pub fn random_visible_album(window: &AppWindow) -> Option<String> {
let model = window.global::<FavoritesState>().get_albums_visible();
let n = model.row_count();
if n == 0 {
return None;
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1);
let idx = (seed % n as u64) as usize;
model.row_data(idx).map(|a| a.id.to_string())
}
// ---- Tracks multi-select (mirrors playlist.rs) ------------------------- // ---- Tracks multi-select (mirrors playlist.rs) -------------------------
// Selection lives on TrackItem.selected in the rendered `tracks-visible` // Selection lives on TrackItem.selected in the rendered `tracks-visible`
// model (which shares `tracks` when no search filter is active). // model (which shares `tracks` when no search filter is active).
+37
View File
@@ -4276,6 +4276,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
} }
{
// Local search over the loaded favorite albums (title / artist).
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_albums_search(move |q| {
if let Some(w) = weak.upgrade() {
w.global::<FavoritesState>().set_albums_search(q);
favorites::derive_albums(&w);
}
});
}
{
// Sort the favorite albums (default / title / artist).
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_albums_set_sort(move |s| {
if let Some(w) = weak.upgrade() {
w.global::<FavoritesState>().set_albums_sort_by(s);
favorites::derive_albums(&w);
}
});
}
{
// Play a random album from the visible favorites set.
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_albums_shuffle(move || {
if let Some(w) = weak.upgrade() {
if let Some(id) = favorites::random_visible_album(&w) {
w.invoke_media_action("album".into(), id.into(), "play".into());
}
}
});
}
{ {
// Local search over the loaded favorite tracks (title / artist / // Local search over the loaded favorite tracks (title / artist /
// album), re-deriving the rendered list. // album), re-deriving the rendered list.
@@ -24,6 +24,7 @@ import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { CircleAction } from "../primitives/CircleAction.slint"; import { CircleAction } from "../primitives/CircleAction.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint";
@@ -325,6 +326,20 @@ export component FavoritesView inherits Rectangle {
} }
} }
} }
// Albums header action — Shuffle (play a random album).
if FavoritesState.active-tab == "albums"
&& FavoritesState.albums-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
FavoritesActions.albums-shuffle();
}
}
}
}
} }
Rectangle { height: 16px; } Rectangle { height: 16px; }
@@ -468,11 +483,48 @@ export component FavoritesView inherits Rectangle {
alignment: center; alignment: center;
horizontal-stretch: 1; horizontal-stretch: 1;
Text { Text {
text: FavoritesState.albums.length + " " + @tr("albums"); text: FavoritesState.albums-visible.length
+ (FavoritesState.albums-search != ""
? " / " + FavoritesState.albums.length
: "")
+ " " + @tr("albums");
color: Theme.text-muted; color: Theme.text-muted;
font-size: Typography.legal; font-size: Typography.legal;
} }
} }
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
query <=> FavoritesState.albums-search;
edited(q) => {
FavoritesActions.albums-search(q);
}
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 168px;
options: [
@tr("Default"),
@tr("Title A-Z"),
@tr("Title Z-A"),
@tr("Artist A-Z"),
];
current-index: FavoritesState.albums-sort-by == "default" ? 0
: FavoritesState.albums-sort-by == "title-asc" ? 1
: FavoritesState.albums-sort-by == "title-desc" ? 2
: 3;
selected(i) => {
FavoritesActions.albums-set-sort(
i == 0 ? "default"
: i == 1 ? "title-asc"
: i == 2 ? "title-desc"
: "artist-asc");
}
}
}
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
Rectangle { Rectangle {
@@ -507,9 +559,15 @@ export component FavoritesView inherits Rectangle {
color: Theme.text-muted; color: Theme.text-muted;
font-size: Typography.body; font-size: Typography.body;
} }
if FavoritesState.albums.length > 0
&& FavoritesState.albums-visible.length == 0: Text {
text: @tr("No albums match your search.");
color: Theme.text-muted;
font-size: Typography.body;
}
AlbumCollectionView { AlbumCollectionView {
albums: FavoritesState.albums; albums: FavoritesState.albums-visible;
view-mode: FavoritesState.albums-view-mode; view-mode: FavoritesState.albums-view-mode;
is-grouped: false; is-grouped: false;
show-source: false; show-source: false;
+6
View File
@@ -1457,7 +1457,10 @@ 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-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-sort-by: "default"; // default|title-asc|title-desc|artist-asc
in property <[FavoriteArtistItem]> artists: []; in property <[FavoriteArtistItem]> artists: [];
in property <[FavoritePlaylistItem]> playlists: []; in property <[FavoritePlaylistItem]> playlists: [];
in property <[FavoriteLabelItem]> labels: []; in property <[FavoriteLabelItem]> labels: [];
@@ -1485,6 +1488,9 @@ export global FavoritesActions {
callback toggle-multi-select(); callback toggle-multi-select();
callback bulk-action(string /* select-all | remove-selected | clear */); callback bulk-action(string /* select-all | remove-selected | clear */);
callback album-action(string /* album id */, string /* action */); callback album-action(string /* album id */, string /* action */);
callback albums-search(string /* query */);
callback albums-set-sort(string /* sort key */);
callback albums-shuffle();
} }
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened // A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened