Files
qbz/crates/qbz-slint/ui/discover/DiscoverBrowseView.slint
T
vicrodh cdb5aa0072 style(qbz-slint): album + playlist cards to 200px, fonts -2/-1
AlbumCard and PlaylistCard are now 200x246 (was 220x266), with the larger
texts -2px and the smaller ones -1px. All album/playlist carousels and grids
follow (card-width/height 200/246) so nothing reflows: Carousel,
PlaylistCarousel, AlbumCollectionView/AlbumGrid, ReleaseGrid, Spotlight,
HomeSkeleton, DiscoverBrowseView, SearchResultsView, FavoritesView. Artist /
radio cards are untouched.

cc
2026-06-04 12:32:47 -06:00

348 lines
13 KiB
Plaintext

// Discover "View all" full-list page — one album module (New Releases,
// Press Accolades, Ideal Discography, Qobuzissimes, Albums of the Week,
// Most Streamed) shown as a paginated full grid or list. Opened from a
// Carousel's "View all" link. Mirrors Tauri's DiscoverBrowseView: the
// back/forward NavButtons + title sit on the LEFT, the header tools
// (search, genre filter, grid/list toggle) float RIGHT on the same row.
// Pagination is infinite-scroll, driven by DiscoverBrowseState.has-more
// off the backend (no total); load-more is disabled while a search is
// active (Tauri does the same).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { DiscoverBrowseState, DiscoverBrowseActions, GenreFilterState, GenreFilterActions } from "../state.slint";
import { AlbumCollectionView } from "AlbumCollectionView.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
// Search field — a leading magnifier, the input, and a trailing X that
// clears (mirrors Tauri's .search-wrapper).
component BrowseSearch inherits Rectangle {
height: 34px;
width: 200px;
border-radius: 6px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
HorizontalLayout {
padding-left: 10px;
padding-right: 6px;
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 15px;
height: 15px;
tint: Theme.text-muted;
}
}
Rectangle {
horizontal-stretch: 1;
clip: true;
input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
single-line: true;
text <=> DiscoverBrowseState.search-query;
edited => {
DiscoverBrowseActions.search-changed();
}
}
Text {
visible: input.text == "";
width: 100%;
height: 100%;
text: @tr("Search…");
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
overflow: elide;
}
}
// Trailing X — clears the query.
VerticalLayout {
alignment: center;
Rectangle {
visible: DiscoverBrowseState.search-query != "";
width: 22px;
height: 22px;
border-radius: 4px;
background: clear-ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 12px;
height: 12px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
clear-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DiscoverBrowseState.search-query = "";
DiscoverBrowseActions.search-changed();
}
}
}
}
}
}
// Genre-filter trigger — same visual as HomeView's GenreButton: accent
// fill + "N genres" when a selection is active.
component GenreButton inherits Rectangle {
callback clicked();
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;
font-weight: root.active ? Typography.medium : Typography.regular;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component DiscoverBrowseView inherits Rectangle {
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
property <length> album-w: 200px;
property <length> album-h: 266px;
property <length> gap: 24px;
property <length> list-row-h: 64px;
property <length> list-row-gap: 4px;
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- Fixed header (always visible) -------------------------------
// NavButtons + section title on the LEFT (title inline, right after
// the nav buttons); search / genre / grid-list tools as one group
// floating RIGHT. Like the Discover toolbar: a fixed bar whose
// back/forward buttons keep the same screen position as every page
// (center at y=25 = 11px top + 14px half-button). Only the list
// below scrolls.
Rectangle {
height: 56px;
background: Theme.surface-main;
// Left cluster — nav buttons then the title, inline.
HorizontalLayout {
x: 32px;
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
VerticalLayout {
alignment: center;
Text {
text: DiscoverBrowseState.title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
}
// Right cluster — the header tools as one group, anchored right.
HorizontalLayout {
x: parent.width - self.preferred-width - 32px;
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 8px;
VerticalLayout {
alignment: center;
BrowseSearch { }
}
VerticalLayout {
alignment: center;
GenreButton {
clicked => {
GenreFilterActions.set-context("discover");
GenreFilterState.open = !GenreFilterState.open;
}
}
}
VerticalLayout {
alignment: center;
Rectangle {
width: 68px;
height: 34px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
HorizontalLayout {
ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg");
active: DiscoverBrowseState.view-mode == "grid";
clicked => {
DiscoverBrowseState.view-mode = "grid";
}
}
ToggleButton {
icon: @image-url("../assets/icons/list.svg");
active: DiscoverBrowseState.view-mode == "list";
clicked => {
DiscoverBrowseState.view-mode = "list";
}
}
}
}
}
}
}
// --- Scrolling list (only the listing scrolls) ------------------
flick := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: page.preferred-height;
// Infinite scroll — when the viewport nears the bottom of the
// content (within 600px), pull the next page. Guarded by
// has-more + the loading flags AND an empty search query, so a
// client-side search filter does not keep paging in the catalog.
changed viewport-y => {
if (-self.viewport-y + self.height >= self.viewport-height - 600px
&& DiscoverBrowseState.has-more
&& !DiscoverBrowseState.loading-more
&& !DiscoverBrowseState.loading
&& DiscoverBrowseState.search-query == "") {
DiscoverBrowseActions.load-more();
}
}
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 8px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
if DiscoverBrowseState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
// Empty-state when a search filters everything out.
if !DiscoverBrowseState.loading
&& DiscoverBrowseState.visible.length == 0
&& DiscoverBrowseState.search-query != "": HorizontalLayout {
alignment: center;
Text {
text: @tr("No results found.");
color: Theme.text-muted;
font-size: 14px;
}
}
// --- Album collection (grid / list) --------------------
if !DiscoverBrowseState.loading: AlbumCollectionView {
albums: DiscoverBrowseState.visible;
view-mode: DiscoverBrowseState.view-mode;
is-grouped: false;
show-source: false;
card-width: root.album-w;
card-height: root.album-h;
card-gap: root.gap;
list-row-gap: root.list-row-gap;
open-album(id) => { root.open-album(id); }
open-artist(id) => { root.open-artist(id); }
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Bottom load-more spinner while the next page is in flight.
if DiscoverBrowseState.loading-more: HorizontalLayout {
alignment: center;
Rectangle { height: 24px; }
LoadingSpinner { size: 28px; }
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view). Sits
// below the fixed 56px header so it tracks only the scrolling list.
ListScrollbar {
x: parent.width - self.width - 4px;
y: 56px;
width: 14px;
height: parent.height - 56px;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
// --- Genre filter overlay --------------------------------------------
// Backdrop closes the popup on outside click; the panel anchors to
// the top-right under the Filter-by-genre button (mirrors HomeView).
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;
}
}