feat(slint): Discover genre filter (simple view)

Ports Tauri's Filter-by-genre popup (simple grid view). The selection
is shared across the three Discover tabs — matching Tauri's single
"home" genre context — and re-fetches the discover index with the
chosen genre ids.

UI:
  - New GenreFilterPopup: header (icon + title + close), Remember
    selection + Advanced view toggles (QbzToggle), a 3-column grid of
    parent-genre chips with an accent fill + check circle when
    selected, and a Clear filter footer. Anchored top-right under the
    button, with a click-away backdrop.
  - GenreButton turns accent-filled and shows "N genres" / "1 genre"
    when a filter is active (else "Filter by genre"), 1:1 with Tauri.
  - Advanced view (search + 3-level tree + counts) is the next
    increment; its toggle is present and shows a "coming soon" stub.

State / backend:
  - GenreFilterState + GenreFilterActions globals; GenreChip struct.
  - crate::genre_filter loads the parent genres (QbzCore::get_genres)
    and owns the shared selection, persisted to
    <data-dir>/qbz/genre_filter.json when Remember is on (dropped
    when off). Stale ids are pruned against the loaded genres.
  - load_home now takes genre_ids and threads them into
    get_discover_index. The home-load path is extracted into a shared
    `reload_home(active_tab)` used by the initial shell load and by
    genre toggle/clear; it rebuilds all three section sets, restores
    the active tab via select_tab, and re-fires artwork.

cc
This commit is contained in:
vicrodh committed 2026-05-20 22:43:16 -06:00
1 parent 235c61bf80
commit 880493b73d
7 files changed
+630 -68

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
//! 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
//! persists to `<data-dir>/qbz/genre_filter.json` when "Remember
//! selection" is on, and feeds `genre_ids` into the discover-index
//! fetch.
use std::path::PathBuf;
use std::sync::Mutex;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use serde::{Deserialize, Serialize};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AppWindow, GenreChip, GenreFilterState};
#[derive(Clone)]
struct GenreItem {
id: u64,
name: String,
}
#[derive(Default, Serialize, Deserialize)]
struct Persisted {
#[serde(default)]
selected: Vec<u64>,
#[serde(default = "default_true")]
remember: bool,
}
fn default_true() -> bool {
true
}
struct State {
parents: Vec<GenreItem>,
selected: Vec<u64>,
remember: bool,
}
static STATE: Mutex<State> = Mutex::new(State {
parents: Vec::new(),
selected: Vec::new(),
remember: true,
});
fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("genre_filter.json"))
}
fn load_persisted() -> Persisted {
let Some(path) = store_path() else {
return Persisted::default();
};
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Persisted::default(),
}
}
fn save_persisted(selected: &[u64], remember: bool) {
let Some(path) = store_path() else {
return;
};
if !remember {
// Remember off — drop any persisted selection.
let _ = std::fs::remove_file(&path);
return;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let data = Persisted {
selected: selected.to_vec(),
remember,
};
if let Ok(json) = serde_json::to_vec_pretty(&data) {
let _ = std::fs::write(&path, json);
}
}
/// The current shared genre-id selection. Read by navigate_home to
/// pass into the discover-index fetch.
pub fn selected_ids() -> Vec<u64> {
STATE.lock().map(|s| s.selected.clone()).unwrap_or_default()
}
/// 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,
{
{
let already = STATE.lock().map(|s| !s.parents.is_empty()).unwrap_or(false);
if already {
return;
}
}
let persisted = load_persisted();
let mut parents: Vec<GenreItem> = match runtime.core().get_genres(None).await {
Ok(list) => list
.into_iter()
.map(|g| GenreItem {
id: g.id,
name: g.name,
})
.collect(),
Err(e) => {
log::warn!("[qbz-slint] genre filter: get_genres failed: {e}");
Vec::new()
}
};
parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
// Drop any persisted ids that no longer exist.
let valid: Vec<u64> = persisted
.selected
.into_iter()
.filter(|id| parents.iter().any(|p| p.id == *id))
.collect();
if let Ok(mut s) = STATE.lock() {
s.parents = parents;
s.selected = valid;
s.remember = persisted.remember;
}
}
/// Push the current parents + selection into GenreFilterState. UI thread.
pub fn apply_state(window: &AppWindow) {
let (chips, count, remember) = {
let Ok(s) = STATE.lock() else {
return;
};
let chips: Vec<GenreChip> = s
.parents
.iter()
.map(|g| GenreChip {
id: g.id.to_string().into(),
name: g.name.clone().into(),
selected: s.selected.contains(&g.id),
})
.collect();
(chips, s.selected.len() as i32, s.remember)
};
let state = window.global::<GenreFilterState>();
state.set_genres(ModelRc::new(VecModel::from(chips)));
state.set_selected_count(count);
state.set_remember(remember);
}
/// Toggle a genre id in the selection. Returns true if the selection
/// changed (so the caller can re-fetch).
pub fn toggle(id_str: &str) -> bool {
let Ok(id) = id_str.parse::<u64>() else {
return false;
};
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, rem) = (s.selected.clone(), s.remember);
drop(s);
save_persisted(&sel, rem);
true
}
pub fn clear() {
let Ok(mut s) = STATE.lock() else {
return;
};
s.selected.clear();
let rem = s.remember;
drop(s);
save_persisted(&[], rem);
}
pub fn set_remember(remember: bool) {
let Ok(mut s) = STATE.lock() else {
return;
};
s.remember = remember;
let sel = s.selected.clone();
drop(s);
save_persisted(&sel, remember);
}
+7 -3
View File
@@ -75,14 +75,18 @@ pub struct SlimData {
pub artwork_url: String,
}
/// Fetch the discover index and map it into Home sections.
pub async fn load_home<A>(runtime: &Arc<AppRuntime<A>>) -> Result<HomeData, String>
/// Fetch the discover index (optionally genre-filtered) and map it
/// into the Home / Editor's Picks / For You section sets.
pub async fn load_home<A>(
runtime: &Arc<AppRuntime<A>>,
genre_ids: Option<Vec<u64>>,
) -> Result<HomeData, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let response = runtime
.core()
.get_discover_index(None)
.get_discover_index(genre_ids)
.await
.map_err(|e| e.to_string())?;
let containers = response.containers;
+129 -58
View File
@@ -23,6 +23,7 @@ mod commands;
mod custom_artwork;
mod discovery_dismiss;
mod favorites;
mod genre_filter;
mod home;
mod label;
mod location_view;
@@ -92,74 +93,71 @@ async fn enter_shell(
});
}
match home::load_home(&runtime).await {
// Load the genre-filter parents + persisted selection, then seed
// the popup state. Done before the discover load so the first
// fetch honors a remembered genre selection.
genre_filter::load_parents(&runtime).await;
let _ = weak.upgrade_in_event_loop(|w| {
genre_filter::apply_state(&w);
});
reload_home(&runtime, &weak, &image_cache, "home".to_string()).await;
}
/// Fetch the discover index (honoring the shared genre selection),
/// apply all three tab section sets, show the requested tab, and fan
/// out artwork. Shared by the initial shell load and genre-filter /
/// tab re-fetches.
async fn reload_home(
runtime: &Arc<AppRuntime<SlintAdapter>>,
weak: &slint::Weak<AppWindow>,
image_cache: &artwork::ImageCache,
active_tab: String,
) {
let genre_ids = genre_filter::selected_ids();
let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids);
match home::load_home(runtime, genre_ids).await {
Ok(data) => {
// Collect artwork jobs before the data is consumed by apply_home.
let mut jobs: Vec<artwork::ArtworkJob> = data
.sections
.iter()
.enumerate()
.flat_map(|(section_idx, section)| {
section
.albums
.iter()
.enumerate()
.filter_map(move |(album_idx, card)| {
if card.artwork_url.is_empty() {
None
} else {
Some(artwork::ArtworkJob {
target: artwork::ArtworkTarget::Section {
section_idx,
album_idx,
},
url: card.artwork_url.clone(),
})
}
})
})
.collect();
// Artwork for the active tab's section set (Section-targeted,
// so it lands in HomeState.sections once select_tab swaps it
// in below). Built before `data` is moved.
let active_set = match active_tab.as_str() {
"editorPicks" => &data.editor_sections,
"forYou" => &data.foryou_sections,
_ => &data.sections,
};
let mut jobs = home::section_artwork_jobs(active_set);
// Home-only slim grids (their models are populated regardless
// of the visible tab; harmless to prefetch).
jobs.extend(data.popular.iter().enumerate().filter_map(|(idx, slim)| {
if slim.artwork_url.is_empty() {
None
} else {
Some(artwork::ArtworkJob {
target: artwork::ArtworkTarget::Popular { idx },
url: slim.artwork_url.clone(),
})
}
(!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
target: artwork::ArtworkTarget::Popular { idx },
url: slim.artwork_url.clone(),
})
}));
jobs.extend(data.recent.iter().enumerate().filter_map(|(idx, slim)| {
if slim.artwork_url.is_empty() {
None
} else {
Some(artwork::ArtworkJob {
target: artwork::ArtworkTarget::Recent { idx },
url: slim.artwork_url.clone(),
})
}
(!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
target: artwork::ArtworkTarget::Recent { idx },
url: slim.artwork_url.clone(),
})
}));
jobs.extend(
data.recent_albums
.iter()
.enumerate()
.filter_map(|(idx, card)| {
if card.artwork_url.is_empty() {
None
} else {
Some(artwork::ArtworkJob {
target: artwork::ArtworkTarget::RecentAlbum { idx },
url: card.artwork_url.clone(),
})
}
}),
);
jobs.extend(data.recent_albums.iter().enumerate().filter_map(|(idx, card)| {
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
target: artwork::ArtworkTarget::RecentAlbum { idx },
url: card.artwork_url.clone(),
})
}));
let weak_for_artwork = weak.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
home::apply_home(&w, data);
// apply_home shows the home set; swap to the requested
// tab (no-op when it is "home").
home::select_tab(&w, &active_tab);
w.global::<HomeState>().set_loading(false);
});
artwork::spawn_loads(jobs, weak_for_artwork, image_cache);
artwork::spawn_loads(jobs, weak_for_artwork, image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] discover load failed: {e}");
@@ -1777,6 +1775,79 @@ 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.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<GenreFilterActions>()
.on_toggle(move |id| {
if !genre_filter::toggle(id.as_str()) {
return;
}
let Some(w) = weak.upgrade() else {
return;
};
genre_filter::apply_state(&w);
w.global::<HomeState>().set_loading(true);
let active = w.global::<HomeState>().get_active_tab().to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
reload_home(&runtime, &weak, &image_cache, active).await;
});
});
}
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<GenreFilterActions>()
.on_clear(move || {
genre_filter::clear();
let Some(w) = weak.upgrade() else {
return;
};
genre_filter::apply_state(&w);
w.global::<HomeState>().set_loading(true);
let active = w.global::<HomeState>().get_active_tab().to_string();
let runtime = runtime.clone();
let weak = weak.clone();
let image_cache = image_cache.clone();
handle.spawn(async move {
reload_home(&runtime, &weak, &image_cache, active).await;
});
});
}
{
let weak = window.as_weak();
window
.global::<GenreFilterActions>()
.on_set_remember(move |v| {
genre_filter::set_remember(v);
if let Some(w) = weak.upgrade() {
genre_filter::apply_state(&w);
}
});
}
{
let weak = window.as_weak();
window
.global::<GenreFilterActions>()
.on_set_advanced(move |v| {
if let Some(w) = weak.upgrade() {
w.global::<GenreFilterState>().set_advanced(v);
}
});
}
// Header nav-menu navigation — currently routes the Library
// dropdown rows into Library > Favorites tabs.
{
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint";
export { HomeState, HomeActions, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,232 @@
// Filter-by-genre popup — ported from Tauri's GenreFilterPopup.
// Simple grid view (parent genres as selectable chips). The selection
// is shared across the three Discover tabs and re-fetches the discover
// index. The Advanced view (search + 3-level tree + counts) is a
// later increment; its toggle is present but its body is a stub.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { GenreFilterState, GenreFilterActions, GenreChip } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
component GenreCard inherits Rectangle {
in property <GenreChip> chip;
callback clicked(string);
height: 38px;
border-radius: 6px;
background: root.chip.selected
? Theme.accent
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 8px;
Text {
text: root.chip.name;
color: root.chip.selected ? #ffffff : Theme.text-secondary;
font-size: 13px;
font-weight: root.chip.selected ? Typography.medium : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
// Check circle.
VerticalLayout {
alignment: center;
Rectangle {
width: 16px;
height: 16px;
border-radius: 8px;
border-width: 1.5px;
border-color: root.chip.selected ? #ffffff : Theme.border-muted;
background: root.chip.selected ? #ffffff : transparent;
QbzIcon {
visible: root.chip.selected;
source: @image-url("../assets/icons/check.svg");
width: 11px;
height: 11px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.accent;
}
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.chip.id);
}
}
}
export component GenreFilterPopup inherits Rectangle {
property <int> columns: 3;
property <length> grid-gap: 8px;
property <length> chip-height: 38px;
width: 470px;
background: Theme.surface-main;
border-radius: Radius.md;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 28px;
drop-shadow-color: #00000080;
drop-shadow-offset-y: 8px;
// Height hugs the content.
VerticalLayout {
padding: 16px;
spacing: 14px;
// --- Header --------------------------------------------------
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/list-filter.svg");
width: 16px;
height: 16px;
tint: Theme.text-secondary;
}
}
Text {
text: @tr("Filter by genre");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
vertical-alignment: center;
horizontal-stretch: 1;
}
Rectangle {
width: 26px;
height: 26px;
border-radius: 6px;
background: close-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
close-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
GenreFilterState.open = false;
}
}
}
}
// --- Options row: Remember selection + Advanced view ---------
HorizontalLayout {
spacing: 16px;
HorizontalLayout {
spacing: 10px;
horizontal-stretch: 1;
Text {
text: @tr("Remember selection");
color: Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: GenreFilterState.remember;
toggled(v) => {
GenreFilterActions.set-remember(v);
}
}
}
}
HorizontalLayout {
spacing: 10px;
horizontal-stretch: 1;
Text {
text: @tr("Advanced view");
color: Theme.text-secondary;
font-size: 13px;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
QbzToggle {
checked: GenreFilterState.advanced;
toggled(v) => {
GenreFilterActions.set-advanced(v);
}
}
}
}
}
// --- Advanced view stub (tree/search/counts: next increment) -
if GenreFilterState.advanced: Rectangle {
height: 80px;
Text {
text: @tr("Advanced view coming soon.");
color: Theme.text-muted;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
// --- Simple grid: parent genres as chips ---------------------
if !GenreFilterState.advanced: grid := Rectangle {
property <int> count: GenreFilterState.genres.length;
property <int> rows: Math.ceil(self.count / root.columns);
property <length> cell-width:
(self.width - (root.columns - 1) * root.grid-gap) / root.columns;
height: self.rows > 0
? self.rows * root.chip-height + Math.max(0, self.rows - 1) * root.grid-gap
: 0px;
for chip[i] in GenreFilterState.genres: GenreCard {
chip: chip;
width: grid.cell-width;
x: Math.mod(i, root.columns) * (grid.cell-width + root.grid-gap);
y: Math.floor(i / root.columns) * (root.chip-height + root.grid-gap);
clicked(id) => {
GenreFilterActions.toggle(id);
}
}
}
// --- Footer: Clear filter ------------------------------------
Rectangle {
height: 34px;
border-radius: 6px;
background: clear-ta.has-hover && GenreFilterState.selected-count > 0
? Theme.surface-hover
: Theme.surface-elevated;
opacity: GenreFilterState.selected-count > 0 ? 1.0 : 0.5;
Text {
text: @tr("Clear filter");
color: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
clear-ta := TouchArea {
mouse-cursor: GenreFilterState.selected-count > 0 ? pointer : default;
clicked => {
if (GenreFilterState.selected-count > 0) {
GenreFilterActions.clear();
}
}
}
}
}
}
+39 -6
View File
@@ -8,10 +8,11 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { HomeState, HomeActions, NavState, ContentView } from "../state.slint";
import { HomeState, HomeActions, GenreFilterState, NavState, ContentView } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { SlimCarousel } from "SlimCarousel.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NavButtons } from "../shell/NavButtons.slint";
@@ -67,11 +68,17 @@ component Tab inherits Rectangle {
component GenreButton inherits Rectangle {
callback clicked();
// Accent-filled + "N genres" label when a filter is active,
// matching Tauri.
property <bool> active: GenreFilterState.selected-count > 0;
// Hug the content — the button is only as wide as its label.
width: genre-row.preferred-width;
height: 32px;
border-radius: 6px;
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
background: root.active
? Theme.accent
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
genre-row := HorizontalLayout {
padding-left: 12px;
@@ -83,13 +90,18 @@ component GenreButton inherits Rectangle {
source: @image-url("../assets/icons/list-filter.svg");
width: 14px;
height: 14px;
tint: Theme.text-secondary;
tint: root.active ? #ffffff : Theme.text-secondary;
}
}
Text {
text: @tr("Filter by genre");
color: Theme.text-secondary;
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;
font-weight: root.active ? Typography.medium : Typography.regular;
vertical-alignment: center;
}
}
@@ -204,7 +216,11 @@ export component HomeView inherits Rectangle {
width: self.preferred-width;
height: self.preferred-height;
spacing: 6px;
GenreButton { }
GenreButton {
clicked => {
GenreFilterState.open = !GenreFilterState.open;
}
}
// Per-tab sort / show-hide control for the Discover
// sections (not global Settings — that lives in the
// header menu). Its menu is a later increment.
@@ -294,4 +310,21 @@ export component HomeView inherits Rectangle {
}
}
}
// --- Genre filter overlay --------------------------------------------
// Backdrop closes the popup on outside click; the panel anchors to
// the top-right under the Filter-by-genre button.
if GenreFilterState.open: Rectangle {
width: 100%;
height: 100%;
TouchArea {
clicked => {
GenreFilterState.open = false;
}
}
}
if GenreFilterState.open: GenreFilterPopup {
x: root.width - self.width - 32px;
y: 52px;
}
}
+27
View File
@@ -56,6 +56,33 @@ export global HomeActions {
callback select-tab(string);
}
// One genre in the Filter-by-genre popup's simple grid.
export struct GenreChip {
id: string,
name: string,
selected: bool,
}
// Filter-by-genre state. The selection is shared across the three
// Discover tabs (matching Tauri's single "home" genre context) — it
// re-fetches the discover index with the chosen genre ids. Populated
// by crate::genre_filter.
export global GenreFilterState {
in property <[GenreChip]> genres: [];
in property <int> selected-count: 0;
in-out property <bool> open: false;
in-out property <bool> remember: true;
in-out property <bool> advanced: false;
in property <bool> loading: false;
}
export global GenreFilterActions {
callback toggle(string /* genre id */);
callback clear();
callback set-remember(bool);
callback set-advanced(bool);
}
export struct AlbumTrackItem {
id: string,
number: string,