Files
qbz/crates/qbz-slint/ui/discover/HomeView.slint
T
vicrodh 880493b73d 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
2026-05-20 22:43:16 -06:00

331 lines
12 KiB
Plaintext

// Discover — Home view: a fixed 56px toolbar (tab pill + genre filter +
// settings gear) over a scrollable column of section carousels.
//
// Section data is supplied by the Rust home controller through the
// HomeState global. The Editor's Picks / For You tabs, the genre filter,
// and the settings gear are visual for now; wiring them is a later
// increment.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.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";
// Header + hint shown for a recently-played module while it has no data
// yet (the play history fills once the playback session records plays).
component RecentPlaceholder inherits VerticalLayout {
in property <string> title;
in property <string> hint;
spacing: 10px;
Text {
text: root.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
}
Text {
text: root.hint;
color: Theme.text-muted;
font-size: 13px;
}
}
component Tab inherits Rectangle {
in property <string> label;
in property <bool> active: false;
callback clicked();
border-radius: 4px;
background: root.active ? Theme.surface-main
: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 6px;
padding-bottom: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: 13px;
font-weight: Typography.medium;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
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: 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;
font-weight: root.active ? Typography.medium : Typography.regular;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
component GearButton inherits Rectangle {
callback clicked();
width: 32px;
height: 32px;
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/home-gear.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component HomeView inherits Rectangle {
callback album-clicked(string);
callback media-action(string, string, string);
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- Toolbar (fixed 56px) ----------------------------------------
// Side controls are anchored to the toolbar edges; the three-tab
// pill is absolutely centered on the toolbar so it stays at the
// true horizontal center regardless of the side controls' widths.
Rectangle {
height: 56px;
background: Theme.surface-main;
// History pair + the three-tab nav bar, grouped at the left
// with a gap delimiting the two clusters. Explicit width
// (= content) so the layout does not fill the toolbar and
// overlap the right controls.
left-controls := HorizontalLayout {
x: 32px;
// Align the toolbar content's vertical center to y=25 —
// 11px (the back/forward padding-top used by the album /
// artist / settings views) + 14px (half the 28px nav
// buttons). Keeps the [<] [>] pair at the same screen
// position across every view, with no jump on navigation.
y: 25px - self.height / 2;
width: self.preferred-width;
// Explicit height too — without it the layout fills the
// 56px toolbar, so `y` resolves to 0 and the tab pill
// sticks to the top instead of centering.
height: self.preferred-height;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
tab-pill := Rectangle {
width: tab-row.preferred-width;
height: tab-row.preferred-height;
background: Theme.surface-elevated;
border-radius: 6px;
tab-row := HorizontalLayout {
padding: 3px;
spacing: 4px;
Tab {
label: @tr("Home");
active: HomeState.active-tab == "home";
clicked => { HomeActions.select-tab("home"); }
}
Tab {
label: @tr("Editor's Picks");
active: HomeState.active-tab == "editorPicks";
clicked => { HomeActions.select-tab("editorPicks"); }
}
Tab {
label: @tr("For You");
active: HomeState.active-tab == "forYou";
clicked => { HomeActions.select-tab("forYou"); }
}
}
}
}
// Genre filter + sort gear, anchored right. Explicit width
// (= content) so `x` resolves correctly — without it the
// layout fills the toolbar and the cluster lands far left,
// on top of the history arrows.
right-controls := HorizontalLayout {
x: parent.width - self.width - 32px;
// Same y baseline as the left controls (see above).
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 6px;
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.
GearButton { }
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// --- Scrollable sections -----------------------------------------
// A Flickable (not a ScrollView): wheel scrolling with no scrollbar
// gutters at all. viewport-width is pinned to the Flickable width so
// the carousels' clipped content can never scroll horizontally.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: home-content.preferred-height;
home-content := VerticalLayout {
padding: 32px;
spacing: 40px;
alignment: start;
if HomeState.loading && HomeState.sections.length == 0: HomeSkeleton { }
for section in HomeState.sections: Carousel {
section: section;
album-clicked(id) => {
root.album-clicked(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// The Recently Played + Popular sections below belong
// to the Home tab only; Editor's Picks / For You show
// just their own cached section set above.
// Recently Played Albums — always present; shows a
// placeholder until the play history has data.
if HomeState.active-tab == "home"
&& HomeState.recent-albums.length > 0: Carousel {
section: {
title: @tr("Recently Played Albums"),
albums: HomeState.recent-albums,
};
album-clicked(id) => {
root.album-clicked(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if HomeState.active-tab == "home"
&& HomeState.recent-albums.length == 0: RecentPlaceholder {
title: @tr("Recently Played Albums");
hint: @tr("Albums you play will appear here.");
}
// Recently Played Tracks — paginated 4x3 carousel
// (12 per page, up to two pages).
if HomeState.active-tab == "home"
&& HomeState.recent.length > 0: SlimCarousel {
title: @tr("Recently Played Tracks");
items: HomeState.recent;
item-clicked(id) => {
root.media-action("track", id, "play");
}
}
if HomeState.active-tab == "home"
&& HomeState.recent.length == 0: RecentPlaceholder {
title: @tr("Recently Played Tracks");
hint: @tr("Tracks you play will appear here.");
}
if HomeState.active-tab == "home"
&& HomeState.popular.length > 0: SlimCarousel {
title: @tr("Popular albums");
items: HomeState.popular;
item-clicked(id) => {
root.album-clicked(id);
}
}
}
}
}
// --- 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;
}
}