feat(slint): per-context genre filter + favorites genre filtering

Make genre_filter.rs selection per-context (HashMap keyed by "discover" /
"favorites" + a current context the popup edits), so favorites filters
independently from Discover. Discover keeps its server-side path
unchanged (reads filter_ids("discover") explicitly; its genre buttons set
the "discover" context on open). Favorites get a genre button in both
toolbars that opens the shared popup in the "favorites" context, plus the
popup overlay; derive_tracks / derive_albums filter client-side by the
selected genre NAMES (selected_names expands parents to descendants).
on_toggle / on_clear branch on the current context to re-derive favorites
instead of re-fetching Discover. AlbumSummary gains an optional `genre`
(favorites track album objects carry it; TrackItem can't, so a track
id->genre map backs the tracks filter). Persisted per-context (legacy
flat selection migrates to "discover").

cc
This commit is contained in:
vicrodh committed 2026-05-27 08:03:56 -06:00
1 parent 9c273119a6
commit 4dddd36073
8 files changed
+327 -76

No files matched your search

+2
View File
@@ -248,6 +248,8 @@ pub struct AlbumSummary {
pub image: ImageSet,
/// Label (if returned in track response)
pub label: Option<Label>,
/// Genre (when returned, e.g. on favorites track album objects).
pub genre: Option<Genre>,
}
/// Album model
+55 -8
View File
@@ -8,6 +8,7 @@
//! relevant branch into typed qbz-models items and maps them to the
//! Slint row/card structs.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
@@ -37,6 +38,12 @@ const MAX_ITEMS: usize = 10_000;
/// Shuffle). Set on the UI thread by `apply_favorites`.
static FAV_CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
thread_local! {
/// Track id -> genre name, for the favorites Tracks genre filter
/// (TrackItem carries no genre). Set on the UI thread by apply.
static FAV_TRACK_GENRE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
}
/// Which favorites tab to load.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FavTab {
@@ -105,6 +112,7 @@ pub struct TrackCard {
pub artist_id: String,
pub album: String,
pub album_id: String,
pub genre: String,
pub duration: String,
pub quality_tier: String,
pub explicit: bool,
@@ -248,6 +256,12 @@ fn map_track(track: Track) -> TrackCard {
.map(|a| a.title.clone())
.unwrap_or_default();
let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default();
let genre = track
.album
.as_ref()
.and_then(|a| a.genre.as_ref())
.map(|g| g.name.clone())
.unwrap_or_default();
let (artist, artist_id) = track
.performer
.map(|p| (p.name, p.id.to_string()))
@@ -259,6 +273,7 @@ fn map_track(track: Track) -> TrackCard {
artist_id,
album,
album_id,
genre,
duration: mmss(track.duration),
quality_tier: album_map::tier(track.maximum_bit_depth).to_string(),
explicit: track.parental_warning,
@@ -319,6 +334,9 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
if let Ok(mut current) = FAV_CURRENT.lock() {
*current = play;
}
FAV_TRACK_GENRE.with(|m| {
*m.borrow_mut() = items.iter().map(|t| (t.id.clone(), t.genre.clone())).collect();
});
let rows: Vec<TrackItem> = items
.into_iter()
.map(|t| TrackItem {
@@ -428,19 +446,21 @@ pub fn derive_tracks(window: &AppWindow) {
let query_owned = state.get_tracks_search().to_lowercase();
let query = query_owned.trim();
let group = state.get_tracks_group_mode().to_string();
let genre_names = crate::genre_filter::selected_names("favorites");
let all = state.get_tracks();
// Fast path: no search + no grouping -> share full model (artwork live).
if query.is_empty() && group == "off" {
// Fast path: no search + no grouping + no genre filter -> share model.
if query.is_empty() && group == "off" && genre_names.is_empty() {
state.set_tracks_visible(all);
return;
}
let mut filtered: Vec<TrackItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|t| {
query.is_empty()
(query.is_empty()
|| t.title.to_lowercase().contains(query)
|| t.artist.to_lowercase().contains(query)
|| t.album.to_lowercase().contains(query)
|| t.album.to_lowercase().contains(query))
&& track_genre_matches(t.id.as_str(), &genre_names)
})
.collect();
// Group-by reorders the rows so a group's tracks sit together (Tauri
@@ -497,11 +517,12 @@ pub fn derive_albums(window: &AppWindow) {
let query = query_owned.trim();
let sort = state.get_albums_sort_by().to_string();
let group = state.get_albums_group_mode().to_string();
let genre_names = crate::genre_filter::selected_names("favorites");
let all = state.get_albums();
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" {
// Fast path: no filter, default order, no grouping, no genre -> share.
if query.is_empty() && sort == "default" && group == "off" && genre_names.is_empty() {
let n = all.row_count() as i32;
state.set_albums_visible(all);
state.set_albums_grouped(empty_sections());
@@ -512,9 +533,10 @@ pub fn derive_albums(window: &AppWindow) {
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|a| {
query.is_empty()
(query.is_empty()
|| a.title.to_lowercase().contains(query)
|| a.artist.to_lowercase().contains(query)
|| a.artist.to_lowercase().contains(query))
&& album_genre_matches(a.genre.as_str(), &genre_names)
})
.collect();
album_map::sort_album_items(&mut filtered, &sort);
@@ -573,6 +595,31 @@ fn album_alpha_key(title: &str) -> String {
}
}
/// True if `genre` matches any selected genre name (favorites filter).
fn album_genre_matches(genre: &str, names: &[String]) -> bool {
if names.is_empty() {
return true;
}
let g = genre.to_lowercase();
names.iter().any(|n| g.contains(&n.to_lowercase()))
}
/// Same, looking the track's genre up in the id->genre map.
fn track_genre_matches(id: &str, names: &[String]) -> bool {
if names.is_empty() {
return true;
}
FAV_TRACK_GENRE.with(|m| {
m.borrow()
.get(id)
.map(|g| {
let gl = g.to_lowercase();
names.iter().any(|n| gl.contains(&n.to_lowercase()))
})
.unwrap_or(false)
})
}
/// 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();
+142 -61
View File
@@ -1,11 +1,13 @@
//! Filter-by-genre controller.
//!
//! Loads the parent genres for the Discover popup's simple grid and
//! owns the shared genre selection (one set for all three Discover
//! tabs, matching Tauri's single "home" genre context). The selection
//! Loads the parent genres for the popup's simple grid and owns the genre
//! selection. The selection is **per context** ("discover" for the three
//! Discover tabs, "favorites" for the favorites tabs) so the two surfaces
//! filter independently (Tauri keeps them separate too). The popup edits
//! whatever context is `current` (set when it opens). The selection
//! persists to `<data-dir>/qbz/genre_filter.json` when "Remember
//! selection" is on, and feeds `genre_ids` into the discover-index
//! fetch.
//! selection" is on, and feeds `genre_ids` into the discover-index fetch /
//! the favorites client-side genre filter.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
@@ -26,6 +28,10 @@ struct GenreItem {
#[derive(Default, Serialize, Deserialize)]
struct Persisted {
/// Per-context selections ("discover" / "favorites" / ...).
#[serde(default)]
contexts: HashMap<String, Vec<u64>>,
/// Legacy single-list selection — migrated into the "discover" context.
#[serde(default)]
selected: Vec<u64>,
#[serde(default = "default_true")]
@@ -40,17 +46,41 @@ struct State {
parents: Vec<GenreItem>,
/// Lazily loaded children, keyed by parent id (levels 2 and 3).
children: HashMap<u64, Vec<GenreItem>>,
selected: Vec<u64>,
/// Selected genre ids per context.
selected: HashMap<String, Vec<u64>>,
/// The context the popup is currently editing.
current: String,
expanded: HashSet<u64>,
search: String,
remember: bool,
}
impl State {
/// Mutable handle to the current context's selection (created if absent).
fn cur_mut(&mut self) -> &mut Vec<u64> {
let key = self.current.clone();
self.selected.entry(key).or_default()
}
fn is_selected(&self, id: u64) -> bool {
self.selected
.get(&self.current)
.map(|v| v.contains(&id))
.unwrap_or(false)
}
fn cur_len(&self) -> usize {
self.selected
.get(&self.current)
.map(|v| v.len())
.unwrap_or(0)
}
}
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| {
Mutex::new(State {
parents: Vec::new(),
children: HashMap::new(),
selected: Vec::new(),
selected: HashMap::new(),
current: "discover".to_string(),
expanded: HashSet::new(),
search: String::new(),
remember: true,
@@ -71,7 +101,7 @@ fn load_persisted() -> Persisted {
}
}
fn save_persisted(selected: &[u64], remember: bool) {
fn save_persisted(contexts: &HashMap<String, Vec<u64>>, remember: bool) {
let Some(path) = store_path() else {
return;
};
@@ -84,7 +114,8 @@ fn save_persisted(selected: &[u64], remember: bool) {
let _ = std::fs::create_dir_all(parent);
}
let data = Persisted {
selected: selected.to_vec(),
contexts: contexts.clone(),
selected: Vec::new(),
remember,
};
if let Ok(json) = serde_json::to_vec_pretty(&data) {
@@ -92,29 +123,71 @@ fn save_persisted(selected: &[u64], remember: bool) {
}
}
/// The explicitly-selected genre ids (what the user clicked).
pub fn selected_ids() -> Vec<u64> {
STATE.lock().map(|s| s.selected.clone()).unwrap_or_default()
/// Set the context the popup edits (call when opening it for a surface).
pub fn set_context(ctx: &str) {
if let Ok(mut s) = STATE.lock() {
s.current = ctx.to_string();
s.selected.entry(ctx.to_string()).or_default();
}
}
/// The ids to actually filter by: the selection plus every loaded
/// descendant of each selected genre. Selecting a parent therefore
/// covers its children/grandchildren — restoring the child-genre
/// filtering the discovery-v2 home redesign dropped (the old home
/// expanded to descendant names client-side; here we expand to
/// descendant ids for the server filter).
pub fn filter_ids() -> Vec<u64> {
/// The context the popup is currently editing.
pub fn current_context() -> String {
STATE
.lock()
.map(|s| s.current.clone())
.unwrap_or_else(|_| "discover".to_string())
}
/// The explicitly-selected genre ids in the current popup context.
pub fn selected_ids() -> Vec<u64> {
STATE
.lock()
.map(|s| s.selected.get(&s.current).cloned().unwrap_or_default())
.unwrap_or_default()
}
/// The ids to filter by for `ctx`: its selection plus every loaded
/// descendant of each selected genre (selecting a parent covers its
/// children). Server-side genre filter for Discover.
pub fn filter_ids(ctx: &str) -> Vec<u64> {
let Ok(s) = STATE.lock() else {
return Vec::new();
};
let mut out: HashSet<u64> = HashSet::new();
for id in &s.selected {
out.insert(*id);
collect_descendants(&s.children, *id, &mut out);
if let Some(sel) = s.selected.get(ctx) {
for id in sel {
out.insert(*id);
collect_descendants(&s.children, *id, &mut out);
}
}
out.into_iter().collect()
}
/// Selected genre NAMES (+ descendant names) for `ctx` — for the
/// client-side album / track genre filter used by favorites.
pub fn selected_names(ctx: &str) -> Vec<String> {
let Ok(s) = STATE.lock() else {
return Vec::new();
};
let mut ids: HashSet<u64> = HashSet::new();
if let Some(sel) = s.selected.get(ctx) {
for id in sel {
ids.insert(*id);
collect_descendants(&s.children, *id, &mut ids);
}
}
let mut names: Vec<String> = Vec::new();
for id in ids {
if let Some(g) = s.parents.iter().find(|g| g.id == id) {
names.push(g.name.clone());
} else if let Some(g) = s.children.values().flatten().find(|g| g.id == id) {
names.push(g.name.clone());
}
}
names
}
fn collect_descendants(
children: &HashMap<u64, Vec<GenreItem>>,
id: u64,
@@ -139,9 +212,9 @@ fn store_children(parent_id: u64, kids: Vec<GenreItem>) {
}
}
/// Fetch the parent genres (if not already loaded) and seed the
/// persisted selection. Runs on a worker; call apply_state afterwards
/// on the UI thread.
/// Fetch the parent genres (if not already loaded) and seed the persisted
/// selection. Runs on a worker; call apply_state afterwards on the UI
/// thread.
pub async fn load_parents<A>(runtime: &AppRuntime<A>)
where
A: FrontendAdapter + Send + Sync + 'static,
@@ -168,18 +241,23 @@ where
};
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Keep persisted selections as-is — they may reference child
// genres not yet loaded (advanced view), so validating against
// parents only would wrongly drop them.
// Keep persisted selections as-is — they may reference child genres
// not yet loaded (advanced view), so validating against parents only
// would wrongly drop them.
if let Ok(mut s) = STATE.lock() {
s.parents = parents;
s.selected = persisted.selected;
let mut contexts = persisted.contexts;
// Migrate a legacy flat selection into the discover context.
if contexts.is_empty() && !persisted.selected.is_empty() {
contexts.insert("discover".to_string(), persisted.selected);
}
s.selected = contexts;
s.remember = persisted.remember;
}
}
/// Load one genre level (children of `parent_id`) and store it. No-op
/// if already loaded.
/// Load one genre level (children of `parent_id`) and store it. No-op if
/// already loaded.
pub async fn load_children<A>(runtime: &AppRuntime<A>, parent_id: u64)
where
A: FrontendAdapter + Send + Sync + 'static,
@@ -208,8 +286,8 @@ fn child_ids(parent_id: u64) -> Vec<u64> {
.unwrap_or_default()
}
/// Eager-load every parent's children (level 2) so the advanced tree
/// can show child counts up front. Grandchildren stay lazy.
/// Eager-load every parent's children (level 2) so the advanced tree can
/// show child counts up front. Grandchildren stay lazy.
pub async fn load_all_parent_children<A>(runtime: &AppRuntime<A>)
where
A: FrontendAdapter + Send + Sync + 'static,
@@ -223,8 +301,8 @@ where
}
}
/// Eager-load a genre's full descendant subtree (children +
/// grandchildren), so a selection expands correctly in filter_ids.
/// Eager-load a genre's full descendant subtree (children + grandchildren),
/// so a selection expands correctly in filter_ids / selected_names.
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
where
A: FrontendAdapter + Send + Sync + 'static,
@@ -235,8 +313,8 @@ where
}
}
/// Toggle a tree node's expanded state. Returns true if it is now
/// expanded (so the caller can lazy-load its children).
/// Toggle a tree node's expanded state. Returns true if it is now expanded
/// (so the caller can lazy-load its children).
pub fn toggle_expand(id_str: &str) -> bool {
let Ok(id) = id_str.parse::<u64>() else {
return false;
@@ -259,8 +337,8 @@ pub fn set_search(query: &str) {
}
}
/// Push the current parents + selection + tree into GenreFilterState.
/// UI thread.
/// Push the current parents + selection + tree into GenreFilterState (for
/// the current context). UI thread.
pub fn apply_state(window: &AppWindow) {
let (chips, rows, count, remember) = {
let Ok(s) = STATE.lock() else {
@@ -272,10 +350,10 @@ pub fn apply_state(window: &AppWindow) {
.map(|g| GenreChip {
id: g.id.to_string().into(),
name: g.name.clone().into(),
selected: s.selected.contains(&g.id),
selected: s.is_selected(g.id),
})
.collect();
(chips, build_tree_rows(&s), s.selected.len() as i32, s.remember)
(chips, build_tree_rows(&s), s.cur_len() as i32, s.remember)
};
let state = window.global::<GenreFilterState>();
state.set_genres(ModelRc::new(VecModel::from(chips)));
@@ -287,8 +365,8 @@ pub fn apply_state(window: &AppWindow) {
fn tree_row(item: &GenreItem, level: i32, s: &State) -> GenreTreeRow {
let loaded = s.children.get(&item.id);
let count = loaded.map(|c| c.len()).unwrap_or(0);
// Parents always have children; deeper levels show an expand
// arrow optimistically until a load proves them empty.
// Parents always have children; deeper levels show an expand arrow
// optimistically until a load proves them empty.
let has_children = if level == 0 {
true
} else if level == 1 {
@@ -300,17 +378,17 @@ fn tree_row(item: &GenreItem, level: i32, s: &State) -> GenreTreeRow {
id: item.id.to_string().into(),
name: item.name.clone().into(),
level,
selected: s.selected.contains(&item.id),
selected: s.is_selected(item.id),
expanded: s.expanded.contains(&item.id),
has_children,
count: count as i32,
}
}
/// Flatten the genre tree into the currently-visible rows. With a
/// search query, returns a flat list of all loaded genres matching
/// the query (ignoring expansion); otherwise honors per-node
/// expansion down three levels.
/// Flatten the genre tree into the currently-visible rows. With a search
/// query, returns a flat list of all loaded genres matching the query
/// (ignoring expansion); otherwise honors per-node expansion down three
/// levels.
fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
let query = s.search.trim().to_lowercase();
let mut rows: Vec<GenreTreeRow> = Vec::new();
@@ -355,8 +433,8 @@ fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
rows
}
/// Toggle a genre id in the selection. Returns true if the selection
/// changed (so the caller can re-fetch).
/// Toggle a genre id in the current context's selection. Returns true if
/// the selection changed (so the caller can re-fetch / re-derive).
pub fn toggle(id_str: &str) -> bool {
let Ok(id) = id_str.parse::<u64>() else {
return false;
@@ -364,14 +442,17 @@ pub fn toggle(id_str: &str) -> bool {
let Ok(mut s) = STATE.lock() else {
return false;
};
if let Some(pos) = s.selected.iter().position(|x| *x == id) {
s.selected.remove(pos);
} else {
s.selected.push(id);
{
let sel = s.cur_mut();
if let Some(pos) = sel.iter().position(|x| *x == id) {
sel.remove(pos);
} else {
sel.push(id);
}
}
let (sel, rem) = (s.selected.clone(), s.remember);
let (contexts, rem) = (s.selected.clone(), s.remember);
drop(s);
save_persisted(&sel, rem);
save_persisted(&contexts, rem);
true
}
@@ -379,10 +460,10 @@ pub fn clear() {
let Ok(mut s) = STATE.lock() else {
return;
};
s.selected.clear();
let rem = s.remember;
s.cur_mut().clear();
let (contexts, rem) = (s.selected.clone(), s.remember);
drop(s);
save_persisted(&[], rem);
save_persisted(&contexts, rem);
}
pub fn set_remember(remember: bool) {
@@ -390,7 +471,7 @@ pub fn set_remember(remember: bool) {
return;
};
s.remember = remember;
let sel = s.selected.clone();
let contexts = s.selected.clone();
drop(s);
save_persisted(&sel, remember);
save_persisted(&contexts, remember);
}
+51 -5
View File
@@ -143,7 +143,7 @@ async fn enter_shell(
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
fn current_genre_filter() -> Option<Vec<u64>> {
let ids = genre_filter::filter_ids();
let ids = genre_filter::filter_ids("discover");
(!ids.is_empty()).then_some(ids)
}
@@ -159,7 +159,7 @@ async fn reload_home(
) {
// Expand the selection to descendants so a parent selection
// covers its child genres (the child-genre filtering recovery).
let genre_ids = genre_filter::filter_ids();
let genre_ids = genre_filter::filter_ids("discover");
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
match home::load_home(runtime, genre_ids).await {
@@ -780,6 +780,10 @@ fn navigate_favorites(
state.set_active_tab(tab_id_for_ui.into());
favorites::reset_loading(&w);
w.global::<NavState>().set_view(ContentView::Favorites);
// The genre popup edits the favorites context here, and the
// toolbar genre button shows the favorites selection count.
genre_filter::set_context("favorites");
genre_filter::apply_state(&w);
});
match favorites::load_favorites(&runtime, tab).await {
Ok(data) => {
@@ -3233,9 +3237,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Genre filter — shared selection across the three Discover tabs.
// Toggling / clearing re-fetches the discover index with the new
// genre ids and rebuilds the section sets; the active tab stays.
// Genre filter — selection is per context ("discover" / "favorites").
// Toggling / clearing re-fetches the discover index (discover context)
// or re-derives the favorites tab (favorites context).
{
let weak = window.as_weak();
window
.global::<GenreFilterActions>()
.on_set_context(move |ctx| {
genre_filter::set_context(ctx.as_str());
if let Some(w) = weak.upgrade() {
genre_filter::apply_state(&w);
}
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
@@ -3254,6 +3269,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
// Favorites: client-side genre filter — re-derive the active
// favorites tab instead of re-fetching the discover index.
if genre_filter::current_context() == "favorites" {
let runtime_f = runtime.clone();
let weak_f = weak.clone();
let id_f = id.to_string();
handle.spawn(async move {
if !was_selected {
if let Ok(gid) = id_f.parse::<u64>() {
genre_filter::load_descendants(&runtime_f, gid).await;
}
}
let _ = weak_f.upgrade_in_event_loop(|w| {
genre_filter::apply_state(&w);
if w.global::<FavoritesState>().get_active_tab().as_str() == "albums" {
favorites::derive_albums(&w);
} else {
favorites::derive_tracks(&w);
}
});
});
return;
}
// When the DiscoverBrowse "View all" page is showing, the
// genre change re-fetches THAT page; otherwise it reloads
// the Discover home index.
@@ -3347,6 +3385,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
};
genre_filter::apply_state(&w);
if genre_filter::current_context() == "favorites" {
if w.global::<FavoritesState>().get_active_tab().as_str() == "albums" {
favorites::derive_albums(&w);
} else {
favorites::derive_tracks(&w);
}
return;
}
let browse_target = current_browse_target(&w);
if browse_target.is_none() {
w.global::<HomeState>().set_loading(true);
@@ -10,7 +10,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState } from "../state.slint";
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState, GenreFilterActions } from "../state.slint";
import { AlbumCollectionView } from "AlbumCollectionView.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
@@ -209,6 +209,7 @@ export component DiscoverBrowseView inherits Rectangle {
alignment: center;
GenreButton {
clicked => {
GenreFilterActions.set-context("discover");
GenreFilterState.open = !GenreFilterState.open;
}
}
+2 -1
View File
@@ -8,7 +8,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { HomeState, HomeActions, GenreFilterState, NavState, ContentView } from "../state.slint";
import { HomeState, HomeActions, GenreFilterState, GenreFilterActions, NavState, ContentView } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { SlimCarousel } from "SlimCarousel.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
@@ -225,6 +225,7 @@ export component HomeView inherits Rectangle {
spacing: 6px;
GenreButton {
clicked => {
GenreFilterActions.set-context("discover");
GenreFilterState.open = !GenreFilterState.open;
}
}
@@ -17,8 +17,11 @@ import {
FavoritePlaylistItem,
FavoriteLabelItem,
DragState,
GenreFilterState,
GenreFilterActions,
} from "../state.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
@@ -242,6 +245,50 @@ component FavArtistCard inherits Rectangle {
}
}
// Genre-filter trigger for the favorites toolbars — opens the shared
// popup in the "favorites" context. Accent fill + "N genres" when active.
component FavGenreButton inherits Rectangle {
property <bool> active: GenreFilterState.selected-count > 0;
width: genre-row.preferred-width;
height: 34px;
border-radius: 6px;
background: root.active
? Theme.accent
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
genre-row := HorizontalLayout {
padding-left: 12px;
padding-right: 14px;
spacing: 7px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/list-filter.svg");
width: 14px;
height: 14px;
tint: root.active ? #ffffff : Theme.text-secondary;
}
}
Text {
text: root.active
? (GenreFilterState.selected-count == 1
? @tr("1 genre")
: @tr("{} genres", GenreFilterState.selected-count))
: @tr("Filter by genre");
color: root.active ? #ffffff : Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
GenreFilterActions.set-context("favorites");
GenreFilterState.open = !GenreFilterState.open;
}
}
}
export component FavoritesView inherits Rectangle {
callback open-album(string);
callback open-artist(string);
@@ -464,6 +511,10 @@ export component FavoritesView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
FavGenreButton { }
}
VerticalLayout {
alignment: center;
QbzSelect {
@@ -572,6 +623,10 @@ export component FavoritesView inherits Rectangle {
}
}
}
VerticalLayout {
alignment: center;
FavGenreButton { }
}
VerticalLayout {
alignment: center;
QbzSelect {
@@ -752,4 +807,19 @@ export component FavoritesView inherits Rectangle {
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
// Genre filter overlay — the shared popup, favorites context.
if GenreFilterState.open: Rectangle {
width: 100%;
height: 100%;
TouchArea {
clicked => {
GenreFilterState.open = false;
}
}
}
if GenreFilterState.open: GenreFilterPopup {
x: root.width - self.width - 32px;
y: 56px;
}
}
+3
View File
@@ -203,6 +203,9 @@ export global GenreFilterState {
}
export global GenreFilterActions {
// Set the context the popup edits ("discover" | "favorites"), called
// when a surface opens the shared popup.
callback set-context(string);
callback toggle(string /* genre id */);
callback toggle-expand(string /* genre id */);
callback search(string /* query */);