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