Files
qbz/crates/qbz-slint/ui/search/SearchResultsView.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

641 lines
25 KiB
Plaintext

// Search results view — a tabbed results page, ported from the Tauri
// SearchView.
//
// Toolbar with the history nav pair; a five-tab strip with the searchType
// filter radios on the right; a scrollable body. The All tab is a
// dashboard — a Most-popular hero inline with the artists carousel, an
// albums carousel, a tracks preview, and a playlists carousel. The
// per-type tabs show the full loaded set as a card grid or row list with
// a load-more button.
import { SearchState, SearchActions, TrackItem, SearchPlaylistItem, AlbumCardItem, SlimItem, DragState } from "../state.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Carousel } from "../discover/Carousel.slint";
import { ArtistCarousel } from "../discover/ArtistCarousel.slint";
import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint";
import { AlbumCard } from "../discover/AlbumCard.slint";
import { ArtistCard } from "../discover/ArtistCard.slint";
import { PlaylistCard } from "../discover/PlaylistCard.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { SearchTrackHero } from "../primitives/SearchTrackHero.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
// One tab in the results tab strip.
component SearchTab inherits Rectangle {
in property <string> label;
in property <bool> active;
callback clicked();
VerticalLayout {
spacing: 5px;
Text {
text: root.label;
color: root.active
? Theme.text-primary
: (ta.has-hover ? Theme.text-secondary : Theme.text-muted);
font-size: 14px;
font-weight: root.active ? 700 : 500;
}
Rectangle {
height: 2px;
border-radius: 1px;
background: root.active ? Theme.accent : transparent;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
// A styled searchType filter radio — a ring with an accent dot when
// selected, and a label. Not a native radio.
component FilterRadio inherits Rectangle {
in property <string> label;
in property <bool> selected;
callback clicked();
height: 22px;
width: radio-row.preferred-width;
radio-row := HorizontalLayout {
spacing: 6px;
VerticalLayout {
alignment: center;
Rectangle {
width: 14px;
height: 14px;
border-radius: 7px;
border-width: 1.5px;
border-color: root.selected ? Theme.accent : Theme.text-muted;
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: root.selected ? Theme.accent : transparent;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
}
Text {
text: root.label;
color: (root.selected || ta.has-hover)
? Theme.text-primary
: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
// "Title" with an optional "View all" affordance (no result count).
component SectionHeader inherits Rectangle {
in property <string> title;
in property <bool> show-more;
callback more-clicked();
height: 30px;
HorizontalLayout {
Text {
text: root.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
vertical-alignment: center;
horizontal-stretch: 1;
}
if root.show-more: Rectangle {
width: more-text.preferred-width + 16px;
height: 26px;
border-radius: 4px;
background: more-ta.has-hover ? Theme.surface-hover : transparent;
more-text := Text {
text: @tr("View all");
color: Theme.accent;
font-size: 14px;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
more-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.more-clicked();
}
}
}
}
}
// A "Load more (loaded / total)" button shown under a per-type list.
component LoadMoreButton inherits Rectangle {
in property <int> loaded;
in property <int> total;
callback clicked();
visible: root.loaded < root.total;
height: self.visible ? 44px : 0px;
horizontal-stretch: 1;
Rectangle {
width: btn-text.preferred-width + 36px;
height: 32px;
border-radius: Radius.sm;
background: btn-ta.has-hover ? Theme.surface-elevated : Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
btn-text := Text {
text: @tr("Load more") + " (\{root.loaded} / \{root.total})";
color: Theme.text-secondary;
font-size: 13px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
btn-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
}
// A wrapping grid of album cards (per-type Albums tab).
component AlbumGrid inherits Rectangle {
in property <[AlbumCardItem]> albums;
callback open(string);
callback media-action(string, string, string);
property <length> card-w: 200px;
property <length> card-h: 246px;
property <length> gap: 24px;
property <int> columns:
Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap)));
property <int> row-count: Math.ceil(root.albums.length / root.columns);
height: root.row-count > 0
? root.row-count * root.card-h + (root.row-count - 1) * root.gap
: 0px;
for album[i] in root.albums: AlbumCard {
x: Math.mod(i, root.columns) * (root.card-w + root.gap);
y: Math.floor(i / root.columns) * (root.card-h + root.gap);
album: album;
clicked(id) => {
root.open(id);
}
media-action(k, ii, a) => {
root.media-action(k, ii, a);
}
}
}
// A wrapping grid of artist cards (per-type Artists tab).
component ArtistGrid inherits Rectangle {
in property <[SlimItem]> artists;
callback open(string);
callback media-action(string, string, string);
property <length> card-w: 160px;
property <length> card-h: 220px;
property <length> gap: 16px;
property <int> columns:
Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap)));
property <int> row-count: Math.ceil(root.artists.length / root.columns);
height: root.row-count > 0
? root.row-count * root.card-h + (root.row-count - 1) * root.gap
: 0px;
for artist[i] in root.artists: ArtistCard {
x: Math.mod(i, root.columns) * (root.card-w + root.gap);
y: Math.floor(i / root.columns) * (root.card-h + root.gap);
artist: artist;
clicked(id) => {
root.open(id);
}
media-action(k, ii, a) => {
root.media-action(k, ii, a);
}
}
}
// A wrapping grid of playlist cards (per-type Playlists tab).
component PlaylistGrid inherits Rectangle {
in property <[SearchPlaylistItem]> playlists;
callback media-action(string, string, string);
property <length> card-w: 200px;
property <length> card-h: 246px;
property <length> gap: 24px;
property <int> columns:
Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap)));
property <int> row-count: Math.ceil(root.playlists.length / root.columns);
height: root.row-count > 0
? root.row-count * root.card-h + (root.row-count - 1) * root.gap
: 0px;
for playlist[i] in root.playlists: PlaylistCard {
x: Math.mod(i, root.columns) * (root.card-w + root.gap);
y: Math.floor(i / root.columns) * (root.card-h + root.gap);
playlist: playlist;
media-action(k, ii, a) => {
root.media-action(k, ii, a);
}
}
}
export component SearchResultsView inherits Rectangle {
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
// How many tracks the All tab previews before "View all".
property <int> preview-cap: 6;
property <bool> has-results:
SearchState.albums.length > 0
|| SearchState.tracks.length > 0
|| SearchState.artists.length > 0
|| SearchState.playlists.length > 0;
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- Row 1: NavButtons + "Search" title (left), searchType filter
// radios floated right. Same toolbar bar as DiscoverBrowseView — the
// back/forward buttons keep the same screen position as every page
// (center at y=25 = 11px top + 14px half-button). The title sits
// inline right after the nav pair.
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: @tr("Search");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
vertical-alignment: center;
}
}
}
// Right cluster — searchType filter radios as one group, anchored
// right. Shown once results exist (mirrors the previous gate).
if root.has-results: HorizontalLayout {
x: parent.width - self.preferred-width - 32px;
y: 25px - self.height / 2;
width: self.preferred-width;
height: self.preferred-height;
spacing: 16px;
VerticalLayout {
alignment: center;
FilterRadio {
label: @tr("Main Artist");
selected: SearchState.filter-index == 1;
clicked => { SearchActions.filter-changed(1); }
}
}
VerticalLayout {
alignment: center;
FilterRadio {
label: @tr("Performer");
selected: SearchState.filter-index == 2;
clicked => { SearchActions.filter-changed(2); }
}
}
VerticalLayout {
alignment: center;
FilterRadio {
label: @tr("Composer");
selected: SearchState.filter-index == 3;
clicked => { SearchActions.filter-changed(3); }
}
}
VerticalLayout {
alignment: center;
FilterRadio {
label: @tr("Label");
selected: SearchState.filter-index == 4;
clicked => { SearchActions.filter-changed(4); }
}
}
VerticalLayout {
alignment: center;
FilterRadio {
label: @tr("Release Name");
selected: SearchState.filter-index == 5;
clicked => { SearchActions.filter-changed(5); }
}
}
// Clear-filter button — visible only when a filter is set.
if SearchState.filter-index != 0: VerticalLayout {
alignment: center;
Rectangle {
width: 24px;
height: 24px;
border-radius: 12px;
background: clear-ta.has-hover
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 13px;
height: 13px;
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 => { SearchActions.filter-changed(0); }
}
}
}
}
}
// --- Row 2: the five-tab strip on its own row -------------------
// Collapsed during the initial load so the empty tabs do not show
// through behind the loading state. Gated only on the initial load
// (loading && no results); once any results exist the strip stays
// visible across tab switches and re-searches.
if !(SearchState.loading && !root.has-results): Rectangle {
height: 40px;
HorizontalLayout {
x: 32px;
padding-bottom: 8px;
spacing: 22px;
alignment: start;
SearchTab {
label: @tr("All");
active: SearchState.tab == 0;
clicked => { SearchActions.tab-changed(0); }
}
SearchTab {
label: @tr("Albums");
active: SearchState.tab == 1;
clicked => { SearchActions.tab-changed(1); }
}
SearchTab {
label: @tr("Tracks");
active: SearchState.tab == 2;
clicked => { SearchActions.tab-changed(2); }
}
SearchTab {
label: @tr("Artists");
active: SearchState.tab == 3;
clicked => { SearchActions.tab-changed(3); }
}
SearchTab {
label: @tr("Playlists");
active: SearchState.tab == 4;
clicked => { SearchActions.tab-changed(4); }
}
}
}
// --- Scrollable results body ------------------------------------
flick := Flickable {
interactive: !DragState.active;
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: body.preferred-height;
body := VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-bottom: 32px;
spacing: 0px;
alignment: start;
// Results sections — fade in once the search completes
// so the empty placeholder colors never show through.
VerticalLayout {
spacing: 28px;
opacity: SearchState.loading ? 0.0 : 1.0;
animate opacity {
duration: 280ms;
easing: ease-in-out;
}
// ---- Most popular + Artists (All tab) ---------------------
if SearchState.tab == 0 && SearchState.most-popular-kind != "": HorizontalLayout {
spacing: 24px;
// Most-popular hero column.
VerticalLayout {
spacing: 12px;
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/crown.svg");
width: 18px;
height: 18px;
tint: #ffd24a;
}
}
Text {
text: @tr("Most popular");
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
if SearchState.most-popular-kind == "artist": ArtistCard {
artist: SearchState.most-popular-artist;
clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "album": AlbumCard {
album: SearchState.most-popular-album;
clicked(id) => { root.open-album(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.most-popular-kind == "track": SearchTrackHero {
track: SearchState.most-popular-track;
quality-label: SearchState.most-popular-quality-label;
media-action(k, i, a) => { root.media-action(k, i, a); }
}
}
// Artists carousel — fills the rest of the row. Uses
// the carousel-only list so the Most-popular hero is
// not duplicated next to it.
if SearchState.artists-carousel.length > 0: ArtistCarousel {
horizontal-stretch: 1;
title: @tr("Artists");
artists: SearchState.artists-carousel;
show-view-all: true;
artist-clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
view-all-clicked => { SearchActions.tab-changed(3); }
}
}
// Artists carousel when there is no most-popular hero.
if SearchState.tab == 0 && SearchState.most-popular-kind == "" && SearchState.artists.length > 0: ArtistCarousel {
title: @tr("Artists");
artists: SearchState.artists;
show-view-all: true;
artist-clicked(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
view-all-clicked => { SearchActions.tab-changed(3); }
}
// ---- Albums -----------------------------------------------
if SearchState.tab == 0 && SearchState.albums.length > 0: Carousel {
section: {
title: @tr("Albums"),
albums: SearchState.albums,
};
show-view-all: true;
album-clicked(id) => { root.open-album(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
view-all-clicked => { SearchActions.tab-changed(1); }
}
if SearchState.tab == 1: AlbumGrid {
albums: SearchState.albums;
open(id) => { root.open-album(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.tab == 1: LoadMoreButton {
loaded: SearchState.albums.length;
total: SearchState.albums-total;
clicked => { SearchActions.load-more(1); }
}
// ---- Tracks -----------------------------------------------
if (SearchState.tab == 0 || SearchState.tab == 2) && SearchState.tracks.length > 0: VerticalLayout {
spacing: 4px;
SectionHeader {
title: @tr("Tracks");
show-more: SearchState.tab == 0 && SearchState.tracks-total > root.preview-cap;
more-clicked => { SearchActions.tab-changed(2); }
}
for track[idx] in SearchState.tracks: TrackRow {
visible: SearchState.tab == 2 || idx < root.preview-cap;
height: self.visible ? 56px : 0px;
track: track;
index: idx;
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.tab == 2: LoadMoreButton {
loaded: SearchState.tracks.length;
total: SearchState.tracks-total;
clicked => { SearchActions.load-more(2); }
}
}
// ---- Artists grid (per-type tab) --------------------------
if SearchState.tab == 3: ArtistGrid {
artists: SearchState.artists;
open(id) => { root.open-artist(id); }
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.tab == 3: LoadMoreButton {
loaded: SearchState.artists.length;
total: SearchState.artists-total;
clicked => { SearchActions.load-more(3); }
}
// ---- Playlists --------------------------------------------
if SearchState.tab == 0 && SearchState.playlists.length > 0: PlaylistCarousel {
title: @tr("Playlists");
playlists: SearchState.playlists;
show-view-all: true;
media-action(k, i, a) => { root.media-action(k, i, a); }
view-all-clicked => { SearchActions.tab-changed(4); }
}
if SearchState.tab == 4: PlaylistGrid {
playlists: SearchState.playlists;
media-action(k, i, a) => { root.media-action(k, i, a); }
}
if SearchState.tab == 4: LoadMoreButton {
loaded: SearchState.playlists.length;
total: SearchState.playlists-total;
clicked => { SearchActions.load-more(4); }
}
}
// ---- Loading + empty states -------------------------------
if SearchState.loading && !root.has-results: Rectangle {
height: 320px;
VerticalLayout {
alignment: center;
spacing: 18px;
HorizontalLayout {
alignment: center;
LoadingSpinner {
size: 36px;
}
}
Text {
text: @tr("Searching…");
color: Theme.text-muted;
font-size: 13px;
horizontal-alignment: center;
}
}
}
if !SearchState.loading && !root.has-results: Text {
text: @tr("No results.");
color: Theme.text-muted;
font-size: 14px;
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view). Sits
// below the fixed header (56px nav row + 40px tab strip when shown).
property <length> search-header-h:
!(SearchState.loading && !root.has-results) ? 96px : 56px;
ListScrollbar {
x: parent.width - self.width - 4px;
y: root.search-header-h;
width: 14px;
height: parent.height - root.search-header-h;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}