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:
7 files changed
+630
-68
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user