Smoke rounds 3-4 (owner feedback). Checkpoint during iteration. Ambient (the big one): rewrote ambient.wgsl from translating gaussian blobs to a METABALL field (r²/d² summed + smoothstep iso-surface) with 2-octave domain warp — lobes fuse/stretch/split like an amoeba / lava lamp (the Cider motion that was missing), faster clock, more contrast. Surfaces made background-aware (translucent when the dynamic background is on): - Search results view (root + toolbar), artist Network panel + its tab-row header (transparent to avoid double-translucent compounding), JUMP TO bar (JumpNavBar bar-bg passed in — can't import ShellState without a cycle). - Discover toolbars + tab pill + BrowseHeaderTools; SearchResultsView toolbar. - CircleAction (shared: playlist/album/artist/etc. action buttons) on-surface secondary fill goes glass; playlist Sort dropdown; QbzSelect closed control. - New app-background-bar-alpha knob (0.3, env QBZ_BG_BAR_ALPHA): thin bars go MORE transparent than panels (surface-alpha 0.5). - Content well: frosted tint (0.22) + 1px hairline border → distinct panel. - Album/Artist header gradient auto-suppressed under the dynamic background (they clashed); header text stays light on the dynamic backdrop. - Discover carousel edge-fade "shadows" hidden when active (Carousel / PlaylistCarousel / ArtistCarousel / PinnedCarousel). Base:76ea5a82. Prior checkpoint:4e405338. cc
776 lines
32 KiB
Plaintext
776 lines
32 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, NavState, ShellState, AppearanceState } 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 { ArtistGridCard } from "../discover/ArtistGridCard.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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
}
|
|
}
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
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;
|
|
// --- Viewport windowing (opt-in; default OFF = identical to before) ---
|
|
// ELEMENT windowing only: with `windowed` on, cards within ~one viewport
|
|
// of the visible band mount a real AlbumCard; the rest are same-footprint
|
|
// placeholders, so the height formula (the scroll geometry) never
|
|
// changes. No window-changed callback here — search artwork is
|
|
// dispatched per loaded page, index-keyed onto the directly-rendered
|
|
// model, so covers are already present when a card mounts.
|
|
// `visible-top`/`visible-height` arrive in the surrounding Flickable's
|
|
// CONTENT coordinates; `content-offset` is this grid's approximate y
|
|
// inside that content — small errors are absorbed by the margin.
|
|
in property <bool> windowed: false;
|
|
in property <length> visible-top: 0px;
|
|
in property <length> visible-height: 0px;
|
|
in property <length> content-offset: 0px;
|
|
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;
|
|
|
|
// POST-LAYOUT SNAPSHOT SAMPLING — same discipline as the shared
|
|
// AlbumGrid in AlbumCollectionView.slint (read the long comment there;
|
|
// third hit of the "Recursion detected" class). Band state is PLAIN
|
|
// properties written only by the sampler Timer below: `changed`
|
|
// handlers and init-time reads of the windowing inputs are forbidden
|
|
// here because this grid instantiates from `ensure_updated` inside
|
|
// layout/render property evaluation, where reading layout results
|
|
// (visible-top <- -flick.viewport-y, visible-height <- flick.height)
|
|
// can re-enter a mid-flight property and panic. Do not wire these back
|
|
// to reactive bindings.
|
|
property <length> row-stride: root.card-h + root.gap;
|
|
// Pre-seed [0, 3]: roughly one viewport of cards mounted for the <=80ms
|
|
// between mount and the first sampler tick.
|
|
property <int> target-first-row: 0;
|
|
property <int> target-last-row: 3;
|
|
// Mounted band = target band + contract hysteresis. Expansion follows the
|
|
// target immediately (a card must be mounted before it can reach the
|
|
// visible area); contraction lags by `hysteresis-rows`, so a few-px
|
|
// oscillation across a row boundary cannot mount/unmount a whole row of
|
|
// cards on every direction change.
|
|
property <int> hysteresis-rows: 1;
|
|
property <int> first-window-row: 0;
|
|
property <int> last-window-row: 3;
|
|
pure function desired-first-row() -> int {
|
|
if (root.target-first-row < root.first-window-row) {
|
|
return root.target-first-row;
|
|
}
|
|
if (root.target-first-row > root.first-window-row + root.hysteresis-rows) {
|
|
return root.target-first-row - root.hysteresis-rows;
|
|
}
|
|
return root.first-window-row;
|
|
}
|
|
pure function desired-last-row() -> int {
|
|
if (root.target-last-row > root.last-window-row) {
|
|
return root.target-last-row;
|
|
}
|
|
if (root.target-last-row < root.last-window-row - root.hysteresis-rows) {
|
|
return root.target-last-row + root.hysteresis-rows;
|
|
}
|
|
return root.last-window-row;
|
|
}
|
|
function apply-window() {
|
|
if (root.desired-first-row() != root.first-window-row
|
|
|| root.desired-last-row() != root.last-window-row) {
|
|
root.first-window-row = root.desired-first-row();
|
|
root.last-window-row = root.desired-last-row();
|
|
}
|
|
}
|
|
// The sampler: reads the live inputs (safe here — event-loop context),
|
|
// recomputes the target band, applies hysteresis. The one-viewport
|
|
// margin absorbs the 80ms cadence.
|
|
function sample-window() {
|
|
if (!root.windowed) {
|
|
return;
|
|
}
|
|
root.target-first-row = Math.max(0, Math.floor(
|
|
((root.visible-top - root.content-offset) - root.visible-height)
|
|
/ root.row-stride));
|
|
root.target-last-row = Math.max(0, Math.floor(
|
|
((root.visible-top - root.content-offset) + 2 * root.visible-height)
|
|
/ root.row-stride));
|
|
root.apply-window();
|
|
}
|
|
Timer {
|
|
interval: 80ms;
|
|
running: root.windowed;
|
|
triggered => { root.sample-window(); }
|
|
}
|
|
|
|
for album[i] in root.albums: card-slot := Rectangle {
|
|
x: Math.mod(i, root.columns) * (root.card-w + root.gap);
|
|
y: Math.floor(i / root.columns) * (root.card-h + root.gap);
|
|
width: root.card-w;
|
|
height: root.card-h;
|
|
property <bool> in-window: !root.windowed
|
|
|| (Math.floor(i / root.columns) >= root.first-window-row
|
|
&& Math.floor(i / root.columns) <= root.last-window-row);
|
|
// Placeholder tile while the real card is unmounted (fast scroll).
|
|
border-radius: 8px;
|
|
background: card-slot.in-window ? transparent : Theme.surface-elevated;
|
|
if card-slot.in-window: AlbumCard {
|
|
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;
|
|
body-opens: true; // body click opens the playlist; the overlay button plays
|
|
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: ShellState.app-background-active ? transparent : 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: ShellState.app-background-active
|
|
? Theme.surface-main.with-alpha(AppearanceState.app-background-bar-alpha)
|
|
: 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "search" && self.viewport-height > self.height) {
|
|
self.viewport-y = NavState.scroll-restore;
|
|
NavState.restore-scope = "";
|
|
}
|
|
}
|
|
init => { self.sr-restore(); }
|
|
changed sr-armed => { self.sr-restore(); }
|
|
changed viewport-height => { self.sr-restore(); }
|
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
|
|
|
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. Fixed at 200px (= the widest
|
|
// hero, the album card) so the Artists carousel always
|
|
// starts at the same x regardless of the hero kind — the
|
|
// 160px artist/track cards used to pull the carousel ~40px
|
|
// left versus an album hero. The card is centered in the
|
|
// slot.
|
|
VerticalLayout {
|
|
width: 200px;
|
|
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;
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
if SearchState.most-popular-kind == "artist": ArtistGridCard {
|
|
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");
|
|
// Search cards fill the subtitle slot ("N albums") +
|
|
// Follow chip — same stack Recommendations sizes at
|
|
// 256px; the 220px default clips the chip.
|
|
card-height: 256px;
|
|
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");
|
|
card-height: 256px;
|
|
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;
|
|
// Element windowing: on the Albums tab the grid is the
|
|
// first thing in the scroll content (the nav row and tab
|
|
// strip sit ABOVE the Flickable; body has no top
|
|
// padding), so its content offset is ~0. LL-style
|
|
// approximation — the ±one-viewport margin absorbs about
|
|
// a row of drift either way. Applies to tab 1 only; the
|
|
// other tabs' sections are unaffected.
|
|
windowed: true;
|
|
visible-top: -flick.viewport-y;
|
|
visible-height: flick.height;
|
|
content-offset: 0px;
|
|
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;
|
|
body-opens: true; // body click opens the playlist; the overlay button plays
|
|
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;
|
|
}
|
|
}
|