// 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 label; in property 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 label; in property 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 title; in property 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 loaded; in property 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 card-w: 200px; property card-h: 246px; property gap: 24px; property columns: Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap))); property 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 card-w: 160px; property card-h: 220px; property gap: 16px; property columns: Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap))); property 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 card-w: 200px; property card-h: 246px; property gap: 24px; property columns: Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap))); property 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 preview-cap: 6; property 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: 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; 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 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; } }