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:
8 files changed
+327
-76
No files matched your search
@@ -248,6 +248,8 @@ pub struct AlbumSummary {
|
|||||||
pub image: ImageSet,
|
pub image: ImageSet,
|
||||||
/// Label (if returned in track response)
|
/// Label (if returned in track response)
|
||||||
pub label: Option<Label>,
|
pub label: Option<Label>,
|
||||||
|
/// Genre (when returned, e.g. on favorites track album objects).
|
||||||
|
pub genre: Option<Genre>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Album model
|
/// Album model
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! relevant branch into typed qbz-models items and maps them to the
|
//! relevant branch into typed qbz-models items and maps them to the
|
||||||
//! Slint row/card structs.
|
//! Slint row/card structs.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, LazyLock, Mutex};
|
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`.
|
/// Shuffle). Set on the UI thread by `apply_favorites`.
|
||||||
static FAV_CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
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.
|
/// Which favorites tab to load.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FavTab {
|
pub enum FavTab {
|
||||||
@@ -105,6 +112,7 @@ pub struct TrackCard {
|
|||||||
pub artist_id: String,
|
pub artist_id: String,
|
||||||
pub album: String,
|
pub album: String,
|
||||||
pub album_id: String,
|
pub album_id: String,
|
||||||
|
pub genre: String,
|
||||||
pub duration: String,
|
pub duration: String,
|
||||||
pub quality_tier: String,
|
pub quality_tier: String,
|
||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
@@ -248,6 +256,12 @@ fn map_track(track: Track) -> TrackCard {
|
|||||||
.map(|a| a.title.clone())
|
.map(|a| a.title.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let album_id = track.album.as_ref().map(|a| a.id.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
|
let (artist, artist_id) = track
|
||||||
.performer
|
.performer
|
||||||
.map(|p| (p.name, p.id.to_string()))
|
.map(|p| (p.name, p.id.to_string()))
|
||||||
@@ -259,6 +273,7 @@ fn map_track(track: Track) -> TrackCard {
|
|||||||
artist_id,
|
artist_id,
|
||||||
album,
|
album,
|
||||||
album_id,
|
album_id,
|
||||||
|
genre,
|
||||||
duration: mmss(track.duration),
|
duration: mmss(track.duration),
|
||||||
quality_tier: album_map::tier(track.maximum_bit_depth).to_string(),
|
quality_tier: album_map::tier(track.maximum_bit_depth).to_string(),
|
||||||
explicit: track.parental_warning,
|
explicit: track.parental_warning,
|
||||||
@@ -319,6 +334,9 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
|||||||
if let Ok(mut current) = FAV_CURRENT.lock() {
|
if let Ok(mut current) = FAV_CURRENT.lock() {
|
||||||
*current = play;
|
*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
|
let rows: Vec<TrackItem> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| TrackItem {
|
.map(|t| TrackItem {
|
||||||
@@ -428,19 +446,21 @@ pub fn derive_tracks(window: &AppWindow) {
|
|||||||
let query_owned = state.get_tracks_search().to_lowercase();
|
let query_owned = state.get_tracks_search().to_lowercase();
|
||||||
let query = query_owned.trim();
|
let query = query_owned.trim();
|
||||||
let group = state.get_tracks_group_mode().to_string();
|
let group = state.get_tracks_group_mode().to_string();
|
||||||
|
let genre_names = crate::genre_filter::selected_names("favorites");
|
||||||
let all = state.get_tracks();
|
let all = state.get_tracks();
|
||||||
// Fast path: no search + no grouping -> share full model (artwork live).
|
// Fast path: no search + no grouping + no genre filter -> share model.
|
||||||
if query.is_empty() && group == "off" {
|
if query.is_empty() && group == "off" && genre_names.is_empty() {
|
||||||
state.set_tracks_visible(all);
|
state.set_tracks_visible(all);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut filtered: Vec<TrackItem> = (0..all.row_count())
|
let mut filtered: Vec<TrackItem> = (0..all.row_count())
|
||||||
.filter_map(|i| all.row_data(i))
|
.filter_map(|i| all.row_data(i))
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
query.is_empty()
|
(query.is_empty()
|
||||||
|| t.title.to_lowercase().contains(query)
|
|| t.title.to_lowercase().contains(query)
|
||||||
|| t.artist.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();
|
.collect();
|
||||||
// Group-by reorders the rows so a group's tracks sit together (Tauri
|
// 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 query = query_owned.trim();
|
||||||
let sort = state.get_albums_sort_by().to_string();
|
let sort = state.get_albums_sort_by().to_string();
|
||||||
let group = state.get_albums_group_mode().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 all = state.get_albums();
|
||||||
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
|
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
|
||||||
|
|
||||||
// Fast path: no filter, default order, no grouping -> share full model.
|
// Fast path: no filter, default order, no grouping, no genre -> share.
|
||||||
if query.is_empty() && sort == "default" && group == "off" {
|
if query.is_empty() && sort == "default" && group == "off" && genre_names.is_empty() {
|
||||||
let n = all.row_count() as i32;
|
let n = all.row_count() as i32;
|
||||||
state.set_albums_visible(all);
|
state.set_albums_visible(all);
|
||||||
state.set_albums_grouped(empty_sections());
|
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())
|
let mut filtered: Vec<AlbumCardItem> = (0..all.row_count())
|
||||||
.filter_map(|i| all.row_data(i))
|
.filter_map(|i| all.row_data(i))
|
||||||
.filter(|a| {
|
.filter(|a| {
|
||||||
query.is_empty()
|
(query.is_empty()
|
||||||
|| a.title.to_lowercase().contains(query)
|
|| 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();
|
.collect();
|
||||||
album_map::sort_album_items(&mut filtered, &sort);
|
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).
|
/// A random album id from the currently-visible set (Shuffle / random).
|
||||||
pub fn random_visible_album(window: &AppWindow) -> Option<String> {
|
pub fn random_visible_album(window: &AppWindow) -> Option<String> {
|
||||||
let model = window.global::<FavoritesState>().get_albums_visible();
|
let model = window.global::<FavoritesState>().get_albums_visible();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! Filter-by-genre controller.
|
//! Filter-by-genre controller.
|
||||||
//!
|
//!
|
||||||
//! Loads the parent genres for the Discover popup's simple grid and
|
//! Loads the parent genres for the popup's simple grid and owns the genre
|
||||||
//! owns the shared genre selection (one set for all three Discover
|
//! selection. The selection is **per context** ("discover" for the three
|
||||||
//! tabs, matching Tauri's single "home" genre context). The selection
|
//! 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
|
//! persists to `<data-dir>/qbz/genre_filter.json` when "Remember
|
||||||
//! selection" is on, and feeds `genre_ids` into the discover-index
|
//! selection" is on, and feeds `genre_ids` into the discover-index fetch /
|
||||||
//! fetch.
|
//! the favorites client-side genre filter.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -26,6 +28,10 @@ struct GenreItem {
|
|||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize)]
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
struct Persisted {
|
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)]
|
#[serde(default)]
|
||||||
selected: Vec<u64>,
|
selected: Vec<u64>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -40,17 +46,41 @@ struct State {
|
|||||||
parents: Vec<GenreItem>,
|
parents: Vec<GenreItem>,
|
||||||
/// Lazily loaded children, keyed by parent id (levels 2 and 3).
|
/// Lazily loaded children, keyed by parent id (levels 2 and 3).
|
||||||
children: HashMap<u64, Vec<GenreItem>>,
|
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>,
|
expanded: HashSet<u64>,
|
||||||
search: String,
|
search: String,
|
||||||
remember: bool,
|
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(|| {
|
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| {
|
||||||
Mutex::new(State {
|
Mutex::new(State {
|
||||||
parents: Vec::new(),
|
parents: Vec::new(),
|
||||||
children: HashMap::new(),
|
children: HashMap::new(),
|
||||||
selected: Vec::new(),
|
selected: HashMap::new(),
|
||||||
|
current: "discover".to_string(),
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
remember: true,
|
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 {
|
let Some(path) = store_path() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -84,7 +114,8 @@ fn save_persisted(selected: &[u64], remember: bool) {
|
|||||||
let _ = std::fs::create_dir_all(parent);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
let data = Persisted {
|
let data = Persisted {
|
||||||
selected: selected.to_vec(),
|
contexts: contexts.clone(),
|
||||||
|
selected: Vec::new(),
|
||||||
remember,
|
remember,
|
||||||
};
|
};
|
||||||
if let Ok(json) = serde_json::to_vec_pretty(&data) {
|
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).
|
/// Set the context the popup edits (call when opening it for a surface).
|
||||||
pub fn selected_ids() -> Vec<u64> {
|
pub fn set_context(ctx: &str) {
|
||||||
STATE.lock().map(|s| s.selected.clone()).unwrap_or_default()
|
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
|
/// The context the popup is currently editing.
|
||||||
/// descendant of each selected genre. Selecting a parent therefore
|
pub fn current_context() -> String {
|
||||||
/// covers its children/grandchildren — restoring the child-genre
|
STATE
|
||||||
/// filtering the discovery-v2 home redesign dropped (the old home
|
.lock()
|
||||||
/// expanded to descendant names client-side; here we expand to
|
.map(|s| s.current.clone())
|
||||||
/// descendant ids for the server filter).
|
.unwrap_or_else(|_| "discover".to_string())
|
||||||
pub fn filter_ids() -> Vec<u64> {
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
let Ok(s) = STATE.lock() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut out: HashSet<u64> = HashSet::new();
|
let mut out: HashSet<u64> = HashSet::new();
|
||||||
for id in &s.selected {
|
if let Some(sel) = s.selected.get(ctx) {
|
||||||
out.insert(*id);
|
for id in sel {
|
||||||
collect_descendants(&s.children, *id, &mut out);
|
out.insert(*id);
|
||||||
|
collect_descendants(&s.children, *id, &mut out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out.into_iter().collect()
|
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(
|
fn collect_descendants(
|
||||||
children: &HashMap<u64, Vec<GenreItem>>,
|
children: &HashMap<u64, Vec<GenreItem>>,
|
||||||
id: u64,
|
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
|
/// Fetch the parent genres (if not already loaded) and seed the persisted
|
||||||
/// persisted selection. Runs on a worker; call apply_state afterwards
|
/// selection. Runs on a worker; call apply_state afterwards on the UI
|
||||||
/// on the UI thread.
|
/// thread.
|
||||||
pub async fn load_parents<A>(runtime: &AppRuntime<A>)
|
pub async fn load_parents<A>(runtime: &AppRuntime<A>)
|
||||||
where
|
where
|
||||||
A: FrontendAdapter + Send + Sync + 'static,
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
@@ -168,18 +241,23 @@ where
|
|||||||
};
|
};
|
||||||
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
|
||||||
// Keep persisted selections as-is — they may reference child
|
// Keep persisted selections as-is — they may reference child genres
|
||||||
// genres not yet loaded (advanced view), so validating against
|
// not yet loaded (advanced view), so validating against parents only
|
||||||
// parents only would wrongly drop them.
|
// would wrongly drop them.
|
||||||
if let Ok(mut s) = STATE.lock() {
|
if let Ok(mut s) = STATE.lock() {
|
||||||
s.parents = parents;
|
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;
|
s.remember = persisted.remember;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load one genre level (children of `parent_id`) and store it. No-op
|
/// Load one genre level (children of `parent_id`) and store it. No-op if
|
||||||
/// if already loaded.
|
/// already loaded.
|
||||||
pub async fn load_children<A>(runtime: &AppRuntime<A>, parent_id: u64)
|
pub async fn load_children<A>(runtime: &AppRuntime<A>, parent_id: u64)
|
||||||
where
|
where
|
||||||
A: FrontendAdapter + Send + Sync + 'static,
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
@@ -208,8 +286,8 @@ fn child_ids(parent_id: u64) -> Vec<u64> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eager-load every parent's children (level 2) so the advanced tree
|
/// Eager-load every parent's children (level 2) so the advanced tree can
|
||||||
/// can show child counts up front. Grandchildren stay lazy.
|
/// show child counts up front. Grandchildren stay lazy.
|
||||||
pub async fn load_all_parent_children<A>(runtime: &AppRuntime<A>)
|
pub async fn load_all_parent_children<A>(runtime: &AppRuntime<A>)
|
||||||
where
|
where
|
||||||
A: FrontendAdapter + Send + Sync + 'static,
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
@@ -223,8 +301,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eager-load a genre's full descendant subtree (children +
|
/// Eager-load a genre's full descendant subtree (children + grandchildren),
|
||||||
/// grandchildren), so a selection expands correctly in filter_ids.
|
/// so a selection expands correctly in filter_ids / selected_names.
|
||||||
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
|
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
|
||||||
where
|
where
|
||||||
A: FrontendAdapter + Send + Sync + 'static,
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
@@ -235,8 +313,8 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a tree node's expanded state. Returns true if it is now
|
/// Toggle a tree node's expanded state. Returns true if it is now expanded
|
||||||
/// expanded (so the caller can lazy-load its children).
|
/// (so the caller can lazy-load its children).
|
||||||
pub fn toggle_expand(id_str: &str) -> bool {
|
pub fn toggle_expand(id_str: &str) -> bool {
|
||||||
let Ok(id) = id_str.parse::<u64>() else {
|
let Ok(id) = id_str.parse::<u64>() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -259,8 +337,8 @@ pub fn set_search(query: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the current parents + selection + tree into GenreFilterState.
|
/// Push the current parents + selection + tree into GenreFilterState (for
|
||||||
/// UI thread.
|
/// the current context). UI thread.
|
||||||
pub fn apply_state(window: &AppWindow) {
|
pub fn apply_state(window: &AppWindow) {
|
||||||
let (chips, rows, count, remember) = {
|
let (chips, rows, count, remember) = {
|
||||||
let Ok(s) = STATE.lock() else {
|
let Ok(s) = STATE.lock() else {
|
||||||
@@ -272,10 +350,10 @@ pub fn apply_state(window: &AppWindow) {
|
|||||||
.map(|g| GenreChip {
|
.map(|g| GenreChip {
|
||||||
id: g.id.to_string().into(),
|
id: g.id.to_string().into(),
|
||||||
name: g.name.clone().into(),
|
name: g.name.clone().into(),
|
||||||
selected: s.selected.contains(&g.id),
|
selected: s.is_selected(g.id),
|
||||||
})
|
})
|
||||||
.collect();
|
.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>();
|
let state = window.global::<GenreFilterState>();
|
||||||
state.set_genres(ModelRc::new(VecModel::from(chips)));
|
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 {
|
fn tree_row(item: &GenreItem, level: i32, s: &State) -> GenreTreeRow {
|
||||||
let loaded = s.children.get(&item.id);
|
let loaded = s.children.get(&item.id);
|
||||||
let count = loaded.map(|c| c.len()).unwrap_or(0);
|
let count = loaded.map(|c| c.len()).unwrap_or(0);
|
||||||
// Parents always have children; deeper levels show an expand
|
// Parents always have children; deeper levels show an expand arrow
|
||||||
// arrow optimistically until a load proves them empty.
|
// optimistically until a load proves them empty.
|
||||||
let has_children = if level == 0 {
|
let has_children = if level == 0 {
|
||||||
true
|
true
|
||||||
} else if level == 1 {
|
} else if level == 1 {
|
||||||
@@ -300,17 +378,17 @@ fn tree_row(item: &GenreItem, level: i32, s: &State) -> GenreTreeRow {
|
|||||||
id: item.id.to_string().into(),
|
id: item.id.to_string().into(),
|
||||||
name: item.name.clone().into(),
|
name: item.name.clone().into(),
|
||||||
level,
|
level,
|
||||||
selected: s.selected.contains(&item.id),
|
selected: s.is_selected(item.id),
|
||||||
expanded: s.expanded.contains(&item.id),
|
expanded: s.expanded.contains(&item.id),
|
||||||
has_children,
|
has_children,
|
||||||
count: count as i32,
|
count: count as i32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flatten the genre tree into the currently-visible rows. With a
|
/// Flatten the genre tree into the currently-visible rows. With a search
|
||||||
/// search query, returns a flat list of all loaded genres matching
|
/// query, returns a flat list of all loaded genres matching the query
|
||||||
/// the query (ignoring expansion); otherwise honors per-node
|
/// (ignoring expansion); otherwise honors per-node expansion down three
|
||||||
/// expansion down three levels.
|
/// levels.
|
||||||
fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
|
fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
|
||||||
let query = s.search.trim().to_lowercase();
|
let query = s.search.trim().to_lowercase();
|
||||||
let mut rows: Vec<GenreTreeRow> = Vec::new();
|
let mut rows: Vec<GenreTreeRow> = Vec::new();
|
||||||
@@ -355,8 +433,8 @@ fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle a genre id in the selection. Returns true if the selection
|
/// Toggle a genre id in the current context's selection. Returns true if
|
||||||
/// changed (so the caller can re-fetch).
|
/// the selection changed (so the caller can re-fetch / re-derive).
|
||||||
pub fn toggle(id_str: &str) -> bool {
|
pub fn toggle(id_str: &str) -> bool {
|
||||||
let Ok(id) = id_str.parse::<u64>() else {
|
let Ok(id) = id_str.parse::<u64>() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -364,14 +442,17 @@ pub fn toggle(id_str: &str) -> bool {
|
|||||||
let Ok(mut s) = STATE.lock() else {
|
let Ok(mut s) = STATE.lock() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if let Some(pos) = s.selected.iter().position(|x| *x == id) {
|
{
|
||||||
s.selected.remove(pos);
|
let sel = s.cur_mut();
|
||||||
} else {
|
if let Some(pos) = sel.iter().position(|x| *x == id) {
|
||||||
s.selected.push(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);
|
drop(s);
|
||||||
save_persisted(&sel, rem);
|
save_persisted(&contexts, rem);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,10 +460,10 @@ pub fn clear() {
|
|||||||
let Ok(mut s) = STATE.lock() else {
|
let Ok(mut s) = STATE.lock() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
s.selected.clear();
|
s.cur_mut().clear();
|
||||||
let rem = s.remember;
|
let (contexts, rem) = (s.selected.clone(), s.remember);
|
||||||
drop(s);
|
drop(s);
|
||||||
save_persisted(&[], rem);
|
save_persisted(&contexts, rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_remember(remember: bool) {
|
pub fn set_remember(remember: bool) {
|
||||||
@@ -390,7 +471,7 @@ pub fn set_remember(remember: bool) {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
s.remember = remember;
|
s.remember = remember;
|
||||||
let sel = s.selected.clone();
|
let contexts = s.selected.clone();
|
||||||
drop(s);
|
drop(s);
|
||||||
save_persisted(&sel, remember);
|
save_persisted(&contexts, remember);
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ async fn enter_shell(
|
|||||||
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
|
/// `Option<Vec<u64>>` the discover endpoints take (None = no filter).
|
||||||
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
|
/// Shared by the home re-fetch and the DiscoverBrowse "View all" page.
|
||||||
fn current_genre_filter() -> Option<Vec<u64>> {
|
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)
|
(!ids.is_empty()).then_some(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async fn reload_home(
|
|||||||
) {
|
) {
|
||||||
// Expand the selection to descendants so a parent selection
|
// Expand the selection to descendants so a parent selection
|
||||||
// covers its child genres (the child-genre filtering recovery).
|
// 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);
|
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
|
||||||
|
|
||||||
match home::load_home(runtime, genre_ids).await {
|
match home::load_home(runtime, genre_ids).await {
|
||||||
@@ -780,6 +780,10 @@ fn navigate_favorites(
|
|||||||
state.set_active_tab(tab_id_for_ui.into());
|
state.set_active_tab(tab_id_for_ui.into());
|
||||||
favorites::reset_loading(&w);
|
favorites::reset_loading(&w);
|
||||||
w.global::<NavState>().set_view(ContentView::Favorites);
|
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 {
|
match favorites::load_favorites(&runtime, tab).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
@@ -3233,9 +3237,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genre filter — shared selection across the three Discover tabs.
|
// Genre filter — selection is per context ("discover" / "favorites").
|
||||||
// Toggling / clearing re-fetches the discover index with the new
|
// Toggling / clearing re-fetches the discover index (discover context)
|
||||||
// genre ids and rebuilds the section sets; the active tab stays.
|
// 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 runtime = app_runtime.clone();
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
@@ -3254,6 +3269,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
genre_filter::apply_state(&w);
|
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
|
// When the DiscoverBrowse "View all" page is showing, the
|
||||||
// genre change re-fetches THAT page; otherwise it reloads
|
// genre change re-fetches THAT page; otherwise it reloads
|
||||||
// the Discover home index.
|
// the Discover home index.
|
||||||
@@ -3347,6 +3385,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
genre_filter::apply_state(&w);
|
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);
|
let browse_target = current_browse_target(&w);
|
||||||
if browse_target.is_none() {
|
if browse_target.is_none() {
|
||||||
w.global::<HomeState>().set_loading(true);
|
w.global::<HomeState>().set_loading(true);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.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 { AlbumCollectionView } from "AlbumCollectionView.slint";
|
||||||
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
import { GenreFilterPopup } from "GenreFilterPopup.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
@@ -209,6 +209,7 @@ export component DiscoverBrowseView inherits Rectangle {
|
|||||||
alignment: center;
|
alignment: center;
|
||||||
GenreButton {
|
GenreButton {
|
||||||
clicked => {
|
clicked => {
|
||||||
|
GenreFilterActions.set-context("discover");
|
||||||
GenreFilterState.open = !GenreFilterState.open;
|
GenreFilterState.open = !GenreFilterState.open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.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 { Carousel } from "Carousel.slint";
|
||||||
import { SlimCarousel } from "SlimCarousel.slint";
|
import { SlimCarousel } from "SlimCarousel.slint";
|
||||||
import { HomeSkeleton } from "HomeSkeleton.slint";
|
import { HomeSkeleton } from "HomeSkeleton.slint";
|
||||||
@@ -225,6 +225,7 @@ export component HomeView inherits Rectangle {
|
|||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
GenreButton {
|
GenreButton {
|
||||||
clicked => {
|
clicked => {
|
||||||
|
GenreFilterActions.set-context("discover");
|
||||||
GenreFilterState.open = !GenreFilterState.open;
|
GenreFilterState.open = !GenreFilterState.open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ import {
|
|||||||
FavoritePlaylistItem,
|
FavoritePlaylistItem,
|
||||||
FavoriteLabelItem,
|
FavoriteLabelItem,
|
||||||
DragState,
|
DragState,
|
||||||
|
GenreFilterState,
|
||||||
|
GenreFilterActions,
|
||||||
} from "../state.slint";
|
} from "../state.slint";
|
||||||
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
||||||
|
import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint";
|
||||||
import { TrackRow } from "../primitives/TrackRow.slint";
|
import { TrackRow } from "../primitives/TrackRow.slint";
|
||||||
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||||
import { CircleAction } from "../primitives/CircleAction.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 {
|
export component FavoritesView inherits Rectangle {
|
||||||
callback open-album(string);
|
callback open-album(string);
|
||||||
callback open-artist(string);
|
callback open-artist(string);
|
||||||
@@ -464,6 +511,10 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
FavGenreButton { }
|
||||||
|
}
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzSelect {
|
QbzSelect {
|
||||||
@@ -572,6 +623,10 @@ export component FavoritesView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
FavGenreButton { }
|
||||||
|
}
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzSelect {
|
QbzSelect {
|
||||||
@@ -752,4 +807,19 @@ export component FavoritesView inherits Rectangle {
|
|||||||
visible-height: flick.height;
|
visible-height: flick.height;
|
||||||
viewport-y <=> flick.viewport-y;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -203,6 +203,9 @@ export global GenreFilterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export global GenreFilterActions {
|
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(string /* genre id */);
|
||||||
callback toggle-expand(string /* genre id */);
|
callback toggle-expand(string /* genre id */);
|
||||||
callback search(string /* query */);
|
callback search(string /* query */);
|
||||||
|
|||||||
Reference in new issue
Block a user