feat(slint): tabbed search results view
SearchResultsView is now a real results page: a five-tab strip (All / Albums / Tracks / Artists / Playlists), an All-tab dashboard (top-result hero plus a capped preview of each category with View more), and per-type tabs showing the full loaded list. Album and playlist result rows are file-local components; tracks reuse SearchTrackRow, artists reuse SlimCard. The load-more button is in place; its callback is wired in the next task. cc
This commit is contained in:
2 files changed
+497
-80
No files matched your search
@@ -1,107 +1,513 @@
|
||||
// Search results view.
|
||||
// Search results view — a tabbed results page.
|
||||
//
|
||||
// Phase-1 scaffold: proves the search pipeline end to end (header input ->
|
||||
// QbzCore::search_all -> SearchState -> rendered results). Phase 2 builds
|
||||
// this out into the tabbed dashboard (tabs, carousels, load-more).
|
||||
// Tabs: All / Albums / Tracks / Artists / Playlists. The All tab is a
|
||||
// dashboard (a most-popular hero plus a capped preview of each category);
|
||||
// the per-type tabs show the full loaded list with a load-more button.
|
||||
// Data comes from the SearchState global; QbzCore::search_all populates
|
||||
// all four categories in one call, so switching tabs needs no re-query.
|
||||
|
||||
import { SearchState } from "../state.slint";
|
||||
import { SearchState, SearchActions, SearchTrackItem, SearchPlaylistItem, AlbumCardItem } from "../state.slint";
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { SlimCard } from "../discover/SlimCard.slint";
|
||||
import { SearchTrackRow } from "SearchTrackRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
||||
import { ContextMenuItem } from "../primitives/ContextMenuItem.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Title (count)" with an optional "View more" affordance.
|
||||
component SectionHeader inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <int> count;
|
||||
in property <bool> show-more;
|
||||
callback more-clicked();
|
||||
|
||||
height: 26px;
|
||||
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: root.title + " (\{root.count})";
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
if root.show-more: Rectangle {
|
||||
width: more-text.preferred-width + 8px;
|
||||
more-text := Text {
|
||||
text: @tr("View more");
|
||||
color: more-ta.has-hover ? Theme.text-primary : Theme.accent;
|
||||
font-size: 13px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
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 ? 40px : 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An album result row — cover thumbnail, title / artist, quality, menu.
|
||||
component SearchAlbumRow inherits Rectangle {
|
||||
in property <AlbumCardItem> album;
|
||||
in property <int> index;
|
||||
callback clicked(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
background: ta.has-hover
|
||||
? #ffffff16
|
||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked(root.album.id);
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 14px;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: Radius.sm;
|
||||
clip: true;
|
||||
background: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/disc.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.album.artwork;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.album.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.album.artist;
|
||||
visible: root.album.artist != "";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.link;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.album.quality-tier == "hires"
|
||||
? "Hi-Res"
|
||||
: (root.album.quality-tier == "cd" ? "CD" : "");
|
||||
width: 60px;
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.legal;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: Radius.sm;
|
||||
background: more-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/ellipsis.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||
}
|
||||
more-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
album-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
album-menu := PopupWindow {
|
||||
x: parent.width - 208px;
|
||||
y: 46px;
|
||||
width: 196px;
|
||||
height: 109px;
|
||||
ContextMenu {
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/play.svg");
|
||||
label: @tr("Play");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "play");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
label: @tr("Play next");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "play-next");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-end.svg");
|
||||
label: @tr("Add to queue");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "queue");
|
||||
}
|
||||
}
|
||||
ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/heart.svg");
|
||||
label: @tr("Add to favorites");
|
||||
clicked => {
|
||||
root.media-action("album", root.album.id, "favorite");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A playlist result row — cover thumbnail, title / subtitle.
|
||||
component SearchPlaylistRow inherits Rectangle {
|
||||
in property <SearchPlaylistItem> playlist;
|
||||
in property <int> index;
|
||||
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
background: Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 14px;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: Radius.sm;
|
||||
clip: true;
|
||||
background: Theme.surface-elevated;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/list-music.svg");
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: root.playlist.artwork;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.playlist.title;
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.playlist.subtitle;
|
||||
visible: root.playlist.subtitle != "";
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.link;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component SearchResultsView inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
callback media-action(string, string, string);
|
||||
|
||||
// How many items each category previews on the All tab.
|
||||
property <int> preview-cap: 5;
|
||||
|
||||
background: Theme.surface-main;
|
||||
|
||||
Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: body.preferred-height;
|
||||
VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 14px;
|
||||
|
||||
body := VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 16px;
|
||||
Text {
|
||||
text: SearchState.loading
|
||||
? @tr("Searching…")
|
||||
: @tr("Results for ") + SearchState.query;
|
||||
color: Theme.text-primary;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: SearchState.loading
|
||||
? @tr("Searching…")
|
||||
: @tr("Results for ") + SearchState.query;
|
||||
color: Theme.text-primary;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
// Tab strip.
|
||||
HorizontalLayout {
|
||||
spacing: 22px;
|
||||
alignment: start;
|
||||
SearchTab {
|
||||
label: @tr("All");
|
||||
active: SearchState.tab == 0;
|
||||
clicked => { SearchActions.tab-changed(0); }
|
||||
}
|
||||
|
||||
// Artists.
|
||||
if SearchState.artists.length > 0: VerticalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: @tr("Artists") + " (\{SearchState.artists-total})";
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
for artist in SearchState.artists: SlimCard {
|
||||
item: artist;
|
||||
clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
}
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
// Albums.
|
||||
if SearchState.albums.length > 0: VerticalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: @tr("Albums") + " (\{SearchState.albums-total})";
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
for album in SearchState.albums: Rectangle {
|
||||
height: 28px;
|
||||
album-ta := TouchArea {
|
||||
clicked => {
|
||||
root.open-album(album.id);
|
||||
}
|
||||
}
|
||||
// Scrollable results region.
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-width: self.width;
|
||||
viewport-height: body.preferred-height;
|
||||
|
||||
body := VerticalLayout {
|
||||
spacing: 18px;
|
||||
|
||||
// ---- Top result (All tab) -----------------------------
|
||||
if SearchState.tab == 0 && SearchState.most-popular-kind != "": VerticalLayout {
|
||||
spacing: 4px;
|
||||
Text {
|
||||
x: 0;
|
||||
text: album.title + " — " + album.artist;
|
||||
color: album-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
|
||||
vertical-alignment: center;
|
||||
height: 100%;
|
||||
text: @tr("Top result");
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
if SearchState.most-popular-kind == "artist": SlimCard {
|
||||
item: SearchState.most-popular-artist;
|
||||
clicked(id) => { root.open-artist(id); }
|
||||
}
|
||||
if SearchState.most-popular-kind == "album": SearchAlbumRow {
|
||||
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": SearchTrackRow {
|
||||
track: SearchState.most-popular-track;
|
||||
media-action(k, i, a) => { root.media-action(k, i, a); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks.
|
||||
if SearchState.tracks.length > 0: VerticalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: @tr("Tracks") + " (\{SearchState.tracks-total})";
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
// ---- Albums -------------------------------------------
|
||||
if (SearchState.tab == 0 || SearchState.tab == 1) && SearchState.albums.length > 0: VerticalLayout {
|
||||
spacing: 4px;
|
||||
SectionHeader {
|
||||
title: @tr("Albums");
|
||||
count: SearchState.albums-total;
|
||||
show-more: SearchState.tab == 0 && SearchState.albums-total > root.preview-cap;
|
||||
more-clicked => { SearchActions.tab-changed(1); }
|
||||
}
|
||||
for album[idx] in SearchState.albums: SearchAlbumRow {
|
||||
visible: SearchState.tab == 1 || idx < root.preview-cap;
|
||||
height: self.visible ? 56px : 0px;
|
||||
album: album;
|
||||
index: idx;
|
||||
clicked(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); }
|
||||
}
|
||||
}
|
||||
for track in SearchState.tracks: Text {
|
||||
text: track.title + " — " + track.artist;
|
||||
color: Theme.text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
// Playlists.
|
||||
if SearchState.playlists.length > 0: VerticalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: @tr("Playlists") + " (\{SearchState.playlists-total})";
|
||||
color: Theme.text-secondary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
// ---- Tracks -------------------------------------------
|
||||
if (SearchState.tab == 0 || SearchState.tab == 2) && SearchState.tracks.length > 0: VerticalLayout {
|
||||
spacing: 4px;
|
||||
SectionHeader {
|
||||
title: @tr("Tracks");
|
||||
count: SearchState.tracks-total;
|
||||
show-more: SearchState.tab == 0 && SearchState.tracks-total > root.preview-cap;
|
||||
more-clicked => { SearchActions.tab-changed(2); }
|
||||
}
|
||||
for track[idx] in SearchState.tracks: SearchTrackRow {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
for playlist in SearchState.playlists: Text {
|
||||
text: playlist.title + " — " + playlist.subtitle;
|
||||
color: Theme.text-secondary;
|
||||
|
||||
// ---- Artists ------------------------------------------
|
||||
if (SearchState.tab == 0 || SearchState.tab == 3) && SearchState.artists.length > 0: VerticalLayout {
|
||||
spacing: 4px;
|
||||
SectionHeader {
|
||||
title: @tr("Artists");
|
||||
count: SearchState.artists-total;
|
||||
show-more: SearchState.tab == 0 && SearchState.artists-total > root.preview-cap;
|
||||
more-clicked => { SearchActions.tab-changed(3); }
|
||||
}
|
||||
for artist[idx] in SearchState.artists: SlimCard {
|
||||
visible: SearchState.tab == 3 || idx < root.preview-cap;
|
||||
height: self.visible ? 60px : 0px;
|
||||
item: artist;
|
||||
clicked(id) => { root.open-artist(id); }
|
||||
}
|
||||
if SearchState.tab == 3: LoadMoreButton {
|
||||
loaded: SearchState.artists.length;
|
||||
total: SearchState.artists-total;
|
||||
clicked => { SearchActions.load-more(3); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Playlists ----------------------------------------
|
||||
if (SearchState.tab == 0 || SearchState.tab == 4) && SearchState.playlists.length > 0: VerticalLayout {
|
||||
spacing: 4px;
|
||||
SectionHeader {
|
||||
title: @tr("Playlists");
|
||||
count: SearchState.playlists-total;
|
||||
show-more: SearchState.tab == 0 && SearchState.playlists-total > root.preview-cap;
|
||||
more-clicked => { SearchActions.tab-changed(4); }
|
||||
}
|
||||
for playlist[idx] in SearchState.playlists: SearchPlaylistRow {
|
||||
visible: SearchState.tab == 4 || idx < root.preview-cap;
|
||||
height: self.visible ? 56px : 0px;
|
||||
playlist: playlist;
|
||||
index: idx;
|
||||
}
|
||||
if SearchState.tab == 4: LoadMoreButton {
|
||||
loaded: SearchState.playlists.length;
|
||||
total: SearchState.playlists-total;
|
||||
clicked => { SearchActions.load-more(4); }
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state.
|
||||
if !SearchState.loading
|
||||
&& SearchState.albums.length == 0
|
||||
&& SearchState.tracks.length == 0
|
||||
&& SearchState.artists.length == 0
|
||||
&& SearchState.playlists.length == 0: Text {
|
||||
text: @tr("No results.");
|
||||
color: Theme.text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user