fix(slint): search results 1:1 with Tauri

- Most popular: a crowned hero inline with the artists carousel; its
  cover now resolves (a dedicated artwork target was missing).
- Artists: a real carousel on the All tab, ArtistCard restyled to the
  Tauri For-You tile with a Follow chip (favorites the artist) shown
  when the user does not follow it.
- Playlists: a PlaylistCarousel of PlaylistCard, with a count-based
  PlaylistCollage (1/2/3/4-up) instead of a flat row list.
- Carousels gained a View all link; result counts removed from titles.
- searchType filter is a styled radio row (ring + accent dot) at the
  top right with a clear button, not a dropdown.
- Title fixed to Search Results.
- QbzCore::favorite_artist_ids backs the follow state.

cc
This commit is contained in:
vicrodh committed 2026-05-19 13:59:56 -06:00
1 parent 61232c05ce
commit 5707601174
15 files changed
+1213 -339

No files matched your search

+33 -4
View File
@@ -45,8 +45,10 @@ pub enum ArtworkTarget {
SearchTrack { idx: usize },
/// A row in `SearchState.artists[idx]`.
SearchArtist { idx: usize },
/// A row in `SearchState.playlists[idx]`.
SearchPlaylist { idx: usize },
/// One collage cover slot (0-3) of `SearchState.playlists[idx]`.
SearchPlaylistCover { idx: usize, slot: usize },
/// The most-popular search hero (its kind is read from SearchState).
SearchMostPopular,
}
/// An artwork download job: which card, and the image URL.
@@ -287,12 +289,39 @@ fn apply_artwork(
model.set_row_data(idx, item);
}
}
ArtworkTarget::SearchPlaylist { idx } => {
ArtworkTarget::SearchPlaylistCover { idx, slot } => {
let model = window.global::<SearchState>().get_playlists();
if let Some(mut item) = model.row_data(idx) {
item.artwork = image;
match slot {
0 => item.cover1 = image,
1 => item.cover2 = image,
2 => item.cover3 = image,
3 => item.cover4 = image,
_ => return,
}
model.set_row_data(idx, item);
}
}
ArtworkTarget::SearchMostPopular => {
let state = window.global::<SearchState>();
match state.get_most_popular_kind().as_str() {
"album" => {
let mut it = state.get_most_popular_album();
it.artwork = image;
state.set_most_popular_album(it);
}
"artist" => {
let mut it = state.get_most_popular_artist();
it.artwork = image;
state.set_most_popular_artist(it);
}
"track" => {
let mut it = state.get_most_popular_track();
it.artwork = image;
state.set_most_popular_track(it);
}
_ => {}
}
}
}
}
+1
View File
@@ -299,6 +299,7 @@ pub fn apply_home(window: &AppWindow, data: HomeData) {
rank: slim.rank.into(),
artwork_url: slim.artwork_url.into(),
artwork: slint::Image::default(),
following: false,
})
.collect()
};
+17
View File
@@ -863,6 +863,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
}
}
("artist", "follow") => {
let runtime = runtime.clone();
let weak = weak.clone();
let artist_id = id.clone();
handle.spawn(async move {
match runtime.core().add_favorite("artist", &artist_id).await {
Ok(()) => {
let _ = weak.upgrade_in_event_loop(move |w| {
search::mark_artist_followed(&w, &artist_id, true);
});
}
Err(e) => {
log::error!("[qbz-slint] follow artist failed: {e}");
}
}
});
}
_ => {}
}
});
+165 -84
View File
@@ -5,6 +5,7 @@
//! types into plain `Send` rows (the unit-tested layer), and
//! `apply_search` writes the `SearchState` global on the Slint event loop.
use std::collections::HashSet;
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
@@ -70,6 +71,8 @@ pub struct ArtistRow {
pub name: String,
pub subtitle: String,
pub artwork_url: String,
/// Whether the user already follows (favorites) this artist.
pub following: bool,
}
/// A playlist result row, before it becomes a Slint `SearchPlaylistItem`.
@@ -78,7 +81,8 @@ pub struct PlaylistRow {
pub id: String,
pub title: String,
pub subtitle: String,
pub artwork_url: String,
/// Up to four distinct cover URLs for the collage.
pub cover_urls: Vec<String>,
}
/// The most-popular hero entry.
@@ -143,6 +147,33 @@ fn year_of(date: Option<&str>) -> String {
date.and_then(|d| d.get(0..4)).unwrap_or("").to_string()
}
/// Up to four distinct cover URLs for a playlist collage. Qobuz returns
/// pre-built cover lists in `images300` / `images150` / `images`; the
/// highest-resolution non-empty list wins.
fn playlist_cover_urls(playlist: &Playlist) -> Vec<String> {
let source = [
&playlist.images300,
&playlist.images150,
&playlist.images,
]
.into_iter()
.flatten()
.find(|v| !v.is_empty());
let mut out: Vec<String> = Vec::new();
if let Some(list) = source {
for url in list {
if !url.is_empty() && !out.contains(url) {
out.push(url.clone());
}
if out.len() == 4 {
break;
}
}
}
out
}
// ==================== Mappers (unit-tested) ====================
pub fn map_album(album: Album) -> AlbumRow {
@@ -183,7 +214,7 @@ pub fn map_track(track: Track) -> TrackRow {
}
}
pub fn map_artist(artist: &Artist) -> ArtistRow {
pub fn map_artist(artist: &Artist, following: bool) -> ArtistRow {
ArtistRow {
id: artist.id.to_string(),
name: artist.name.clone(),
@@ -196,17 +227,12 @@ pub fn map_artist(artist: &Artist) -> ArtistRow {
.as_ref()
.and_then(|i| i.best().cloned())
.unwrap_or_default(),
following,
}
}
pub fn map_playlist(playlist: Playlist) -> PlaylistRow {
let artwork_url = playlist
.images150
.as_ref()
.or(playlist.images.as_ref())
.or(playlist.images300.as_ref())
.and_then(|imgs| imgs.first().cloned())
.unwrap_or_default();
let cover_urls = playlist_cover_urls(&playlist);
let mut subtitle = playlist.owner.name.clone();
if playlist.tracks_count > 0 {
if subtitle.is_empty() {
@@ -219,21 +245,29 @@ pub fn map_playlist(playlist: Playlist) -> PlaylistRow {
id: playlist.id.to_string(),
title: playlist.name,
subtitle,
artwork_url,
cover_urls,
}
}
fn map_most_popular(item: Option<MostPopularItem>) -> MostPopularRow {
fn map_most_popular(item: Option<MostPopularItem>, favorite_artists: &HashSet<u64>) -> MostPopularRow {
match item {
Some(MostPopularItem::Albums(a)) => MostPopularRow::Album(map_album(a)),
Some(MostPopularItem::Artists(a)) => MostPopularRow::Artist(map_artist(&a)),
Some(MostPopularItem::Artists(a)) => {
let following = favorite_artists.contains(&a.id);
MostPopularRow::Artist(map_artist(&a, following))
}
Some(MostPopularItem::Tracks(t)) => MostPopularRow::Track(map_track(t)),
None => MostPopularRow::None,
}
}
/// Map a combined-search result into plain `Send` data.
pub fn map_search_all(query: &str, results: SearchAllResults) -> SearchData {
/// Map a combined-search result into plain `Send` data. `favorite_artists`
/// is the set of artist ids the user already follows.
pub fn map_search_all(
query: &str,
results: SearchAllResults,
favorite_artists: &HashSet<u64>,
) -> SearchData {
SearchData {
query: query.to_string(),
albums_total: results.albums.total,
@@ -242,15 +276,21 @@ pub fn map_search_all(query: &str, results: SearchAllResults) -> SearchData {
playlists_total: results.playlists.total,
albums: results.albums.items.into_iter().map(map_album).collect(),
tracks: results.tracks.items.into_iter().map(map_track).collect(),
artists: results.artists.items.iter().map(map_artist).collect(),
artists: results
.artists
.items
.iter()
.map(|a| map_artist(a, favorite_artists.contains(&a.id)))
.collect(),
playlists: results.playlists.items.into_iter().map(map_playlist).collect(),
most_popular: map_most_popular(results.most_popular),
most_popular: map_most_popular(results.most_popular, favorite_artists),
}
}
// ==================== Load (async, worker thread) ====================
/// Run a combined search and map it to plain `Send` data.
/// Run a combined search and map it to plain `Send` data. The search and
/// the user's followed-artist set are fetched concurrently.
pub async fn load_search<A>(
runtime: &Arc<AppRuntime<A>>,
query: &str,
@@ -260,13 +300,15 @@ where
{
// Plan A passes an empty blacklist; the blacklist module is migrated
// separately (roadmap task #9).
let blacklist: std::collections::HashSet<u64> = std::collections::HashSet::new();
let results = runtime
.core()
.search_all(query, &blacklist)
.await
.map_err(|e| e.to_string())?;
Ok(map_search_all(query, results))
let blacklist: HashSet<u64> = HashSet::new();
let core = runtime.core();
let (results, favs) = tokio::join!(
core.search_all(query, &blacklist),
core.favorite_artist_ids(),
);
let results = results.map_err(|e| e.to_string())?;
let favs = favs.unwrap_or_default();
Ok(map_search_all(query, results, &favs))
}
// ==================== Apply (Slint event loop) ====================
@@ -308,16 +350,27 @@ fn artist_item(row: ArtistRow) -> SlimItem {
rank: Default::default(),
artwork_url: row.artwork_url.into(),
artwork: slint::Image::default(),
following: row.following,
}
}
fn playlist_item(row: PlaylistRow) -> SearchPlaylistItem {
let url = |i: usize| -> slint::SharedString {
row.cover_urls.get(i).cloned().unwrap_or_default().into()
};
SearchPlaylistItem {
id: row.id.into(),
title: row.title.into(),
subtitle: row.subtitle.into(),
artwork_url: row.artwork_url.into(),
artwork: slint::Image::default(),
cover_count: row.cover_urls.len().min(4) as i32,
url1: url(0),
url2: url(1),
url3: url(2),
url4: url(3),
cover1: slint::Image::default(),
cover2: slint::Image::default(),
cover3: slint::Image::default(),
cover4: slint::Image::default(),
}
}
@@ -379,6 +432,33 @@ pub fn reset_search(window: &AppWindow) {
state.set_loading(true);
}
/// Mark an artist as followed in every `SearchState` list it appears in
/// (results list + most-popular hero). Runs on the Slint event loop.
pub fn mark_artist_followed(window: &AppWindow, artist_id: &str, following: bool) {
let state = window.global::<SearchState>();
if let Some(vm) = state
.get_artists()
.as_any()
.downcast_ref::<VecModel<SlimItem>>()
{
for i in 0..vm.row_count() {
if let Some(mut item) = vm.row_data(i) {
if item.id == artist_id {
item.following = following;
vm.set_row_data(i, item);
}
}
}
}
if state.get_most_popular_kind() == "artist" {
let mut mp = state.get_most_popular_artist();
if mp.id == artist_id {
mp.following = following;
state.set_most_popular_artist(mp);
}
}
}
// ==================== Load-more (pagination) ====================
/// Which category a load-more request targets.
@@ -402,8 +482,8 @@ pub fn category_for_tab(tab: i32) -> Option<SearchCategory> {
}
}
/// Map a filter-dropdown index to the Qobuz `search_type` value.
/// Index 0 (No filter) maps to `None`.
/// Map a filter index to the Qobuz `search_type` value. Index 0 maps to
/// `None` (no filter).
pub fn search_type_for_filter(index: i32) -> Option<String> {
match index {
1 => Some("MainArtist".into()),
@@ -459,12 +539,17 @@ where
))
}
SearchCategory::Artists => {
let page = core
.search_artists(query, PAGE_SIZE, offset, search_type)
.await
.map_err(|e| e.to_string())?;
let (page, favs) = tokio::join!(
core.search_artists(query, PAGE_SIZE, offset, search_type),
core.favorite_artist_ids(),
);
let page = page.map_err(|e| e.to_string())?;
let favs = favs.unwrap_or_default();
Ok(MoreRows::Artists(
page.items.iter().map(map_artist).collect(),
page.items
.iter()
.map(|a| map_artist(a, favs.contains(&a.id)))
.collect(),
))
}
SearchCategory::Playlists => {
@@ -558,42 +643,48 @@ pub fn replace_category(window: &AppWindow, more: MoreRows) {
// ==================== Artwork jobs ====================
/// Build artwork download jobs for a freshly applied `SearchData` — one
/// per result row that carries a cover URL.
/// Cover-download jobs for an album/track/artist row at `idx`.
fn simple_job(target: ArtworkTarget, url: &str) -> Option<ArtworkJob> {
(!url.is_empty()).then(|| ArtworkJob {
target,
url: url.to_string(),
})
}
/// Playlist collage jobs — one per cover URL the row carries.
fn playlist_jobs(idx: usize, urls: &[String], jobs: &mut Vec<ArtworkJob>) {
for (slot, url) in urls.iter().enumerate().take(4) {
if !url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchPlaylistCover { idx, slot },
url: url.clone(),
});
}
}
}
/// Build artwork download jobs for a freshly applied `SearchData`.
pub fn artwork_jobs(data: &SearchData) -> Vec<ArtworkJob> {
let mut jobs = Vec::new();
for (idx, row) in data.albums.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchAlbum { idx },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(ArtworkTarget::SearchAlbum { idx }, &row.artwork_url));
}
for (idx, row) in data.tracks.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchTrack { idx },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(ArtworkTarget::SearchTrack { idx }, &row.artwork_url));
}
for (idx, row) in data.artists.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchArtist { idx },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(ArtworkTarget::SearchArtist { idx }, &row.artwork_url));
}
for (idx, row) in data.playlists.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchPlaylist { idx },
url: row.artwork_url.clone(),
});
}
playlist_jobs(idx, &row.cover_urls, &mut jobs);
}
let mp_url = match &data.most_popular {
MostPopularRow::Album(r) => r.artwork_url.as_str(),
MostPopularRow::Artist(r) => r.artwork_url.as_str(),
MostPopularRow::Track(r) => r.artwork_url.as_str(),
MostPopularRow::None => "",
};
jobs.extend(simple_job(ArtworkTarget::SearchMostPopular, mp_url));
jobs
}
@@ -604,42 +695,31 @@ pub fn artwork_jobs_for_more(more: &MoreRows, start: usize) -> Vec<ArtworkJob> {
match more {
MoreRows::Albums(rows) => {
for (i, row) in rows.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchAlbum { idx: start + i },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(
ArtworkTarget::SearchAlbum { idx: start + i },
&row.artwork_url,
));
}
}
MoreRows::Tracks(rows) => {
for (i, row) in rows.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchTrack { idx: start + i },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(
ArtworkTarget::SearchTrack { idx: start + i },
&row.artwork_url,
));
}
}
MoreRows::Artists(rows) => {
for (i, row) in rows.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchArtist { idx: start + i },
url: row.artwork_url.clone(),
});
}
jobs.extend(simple_job(
ArtworkTarget::SearchArtist { idx: start + i },
&row.artwork_url,
));
}
}
MoreRows::Playlists(rows) => {
for (i, row) in rows.iter().enumerate() {
if !row.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::SearchPlaylist { idx: start + i },
url: row.artwork_url.clone(),
});
}
playlist_jobs(start + i, &row.cover_urls, &mut jobs);
}
}
}
@@ -702,9 +782,10 @@ mod tests {
tracks_appears_on: None,
playlists: None,
};
let row = map_artist(&artist);
let row = map_artist(&artist, true);
assert_eq!(row.id, "7");
assert_eq!(row.name, "Metallica");
assert_eq!(row.subtitle, "12 albums");
assert!(row.following);
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/></svg>

After

Width:  |  Height:  |  Size: 431 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 21a8 8 0 0 1 13.292-6"/><circle cx="10" cy="8" r="5"/><path d="M19 16v6"/><path d="M22 19h-6"/></svg>

After

Width:  |  Height:  |  Size: 290 B

+72 -34
View File
@@ -1,5 +1,6 @@
// Artist card — a circular portrait with the artist name and a subtitle
// (album count). Mirrors the Tauri search artist card.
// Artist card — a circular portrait with the artist name and, when the
// user does not already follow the artist, a Follow chip. Style ported
// from the Tauri Discover "For You" artist tile.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
@@ -9,27 +10,41 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
export component ArtistCard inherits Rectangle {
in property <SlimItem> artist;
callback clicked(string);
// kind ("artist"), artist id, action ("follow").
callback media-action(string, string, string);
width: 160px;
height: 200px;
width: 170px;
height: 248px;
border-radius: 8px;
background: ta.has-hover ? Theme.surface-elevated : transparent;
// Whole-card click — declared before the content so the Follow chip
// keeps its own clicks.
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.artist.id);
}
}
VerticalLayout {
padding: 12px;
spacing: 8px;
alignment: start;
spacing: 12px;
// Circular portrait, centered in the card column.
// Circular portrait.
HorizontalLayout {
alignment: center;
Rectangle {
width: 148px;
height: 148px;
border-radius: 74px;
width: 140px;
height: 140px;
border-radius: 70px;
clip: true;
background: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 46px;
height: 46px;
width: 44px;
height: 44px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
@@ -43,31 +58,54 @@ export component ArtistCard inherits Rectangle {
}
}
VerticalLayout {
spacing: 2px;
Text {
text: root.artist.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
}
Text {
text: root.artist.subtitle;
visible: root.artist.subtitle != "";
color: Theme.text-muted;
font-size: Typography.link;
horizontal-alignment: center;
overflow: elide;
}
Text {
text: root.artist.title;
width: 146px;
color: Theme.text-primary;
font-size: 14px;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.artist.id);
// Follow chip — only while the user does not follow the artist.
if !root.artist.following: HorizontalLayout {
alignment: center;
Rectangle {
width: chip-row.preferred-width;
height: 30px;
border-radius: 15px;
background: follow-ta.has-hover
? Theme.surface-hover
: Theme.surface-elevated;
chip-row := HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
spacing: 6px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/user-plus.svg");
width: 14px;
height: 14px;
tint: Theme.text-primary;
}
}
Text {
text: @tr("Follow");
color: Theme.text-primary;
font-size: 12px;
font-weight: Typography.medium;
vertical-alignment: center;
}
}
follow-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("artist", root.artist.id, "follow");
}
}
}
}
}
}
@@ -1,6 +1,6 @@
// Artist carousel — a title, prev/next page controls, and a clipped,
// sliding track of artist cards. Same paging mechanism as Carousel, but
// for artists (circular cards). Used by the search results All tab.
// Artist carousel — title, "View all" link, prev/next page controls, and
// a clipped sliding track of artist cards. Same paging mechanism as
// Carousel. Used by the search results All tab.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
@@ -39,14 +39,39 @@ component NavButton inherits Rectangle {
}
}
component ViewAllLink inherits Rectangle {
callback clicked();
width: link-text.preferred-width + 16px;
height: 26px;
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
link-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;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component ArtistCarousel inherits VerticalLayout {
in property <string> title;
in property <[SlimItem]> artists;
in property <bool> show-view-all: false;
callback artist-clicked(string);
callback media-action(string, string, string);
callback view-all-clicked();
property <length> card-width: 160px;
property <length> card-height: 200px;
property <length> gap: 24px;
property <length> card-width: 170px;
property <length> card-height: 248px;
property <length> gap: 16px;
property <int> count: root.artists.length;
property <int> per-page: Math.max(
@@ -62,6 +87,7 @@ export component ArtistCarousel inherits VerticalLayout {
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: root.title;
color: Theme.text-primary;
@@ -70,26 +96,31 @@ export component ArtistCarousel inherits VerticalLayout {
vertical-alignment: center;
horizontal-stretch: 1;
}
HorizontalLayout {
spacing: 4px;
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: root.current-page > 0;
clicked => {
root.page = root.current-page - 1;
}
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: root.current-page > 0;
clicked => {
root.page = root.current-page - 1;
}
}
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: root.current-page < root.page-count - 1;
clicked => {
root.page = root.current-page + 1;
}
}
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: root.current-page < root.page-count - 1;
clicked => {
root.page = root.current-page + 1;
}
}
}
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
@@ -113,6 +144,9 @@ export component ArtistCarousel inherits VerticalLayout {
clicked(id) => {
root.artist-clicked(id);
}
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
@@ -42,10 +42,34 @@ component NavButton inherits Rectangle {
}
}
component ViewAllLink inherits Rectangle {
callback clicked();
width: link-text.preferred-width + 16px;
height: 26px;
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
link-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;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component Carousel inherits VerticalLayout {
in property <DiscoverSection> section;
in property <bool> show-view-all: false;
callback album-clicked(string);
callback media-action(string, string, string);
callback view-all-clicked();
property <length> card-width: 220px;
property <length> card-height: 266px;
@@ -97,6 +121,14 @@ export component Carousel inherits VerticalLayout {
}
}
}
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
}
}
@@ -0,0 +1,197 @@
// Playlist card — the album card's twin for playlists: a 162px cover (a
// collage of up to four covers), a hover overlay with favorite / play /
// more, and a title / subtitle stack. No quality badge, no award ribbon.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { SearchPlaylistItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { PlaylistCollage } from "PlaylistCollage.slint";
component OverlayButton inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> diameter: 36px;
in property <length> icon-size: 16px;
out property <bool> hovered: ta.has-hover;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
background: root.primary
? (ta.has-hover ? #ffffffd6 : #ffffff)
: (ta.has-hover ? #ffffff3d : #ffffff24);
border-width: root.primary ? 0px : 1.5px;
border-color: #ffffffcc;
QbzIcon {
source: root.icon;
width: root.icon-size;
height: root.icon-size;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.primary ? #000000 : #ffffff;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component PlaylistCard inherits Rectangle {
in property <SearchPlaylistItem> playlist;
// kind ("playlist"), playlist id, action ("play" | "play-next" |
// "queue" | "favorite").
callback media-action(string, string, string);
width: 162px;
height: 210px;
property <bool> overlay-on: hover.has-hover
|| fav-btn.hovered || play-btn.hovered || more-btn.hovered;
VerticalLayout {
alignment: start;
spacing: 0px;
// --- Cover collage + hover overlay -------------------------------
Rectangle {
width: 162px;
height: 162px;
border-radius: Radius.sm;
clip: true;
PlaylistCollage {
playlist: root.playlist;
size: 162px;
}
Rectangle {
width: 100%;
height: 100%;
background: #000000;
opacity: root.overlay-on ? 0.6 : 0.0;
animate opacity { duration: 150ms; }
}
hover := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
HorizontalLayout {
x: 0px;
y: 95px;
width: 162px;
height: 44px;
alignment: center;
spacing: 12px;
opacity: root.overlay-on ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
VerticalLayout {
alignment: center;
fav-btn := OverlayButton {
icon: @image-url("../assets/icons/heart.svg");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
play-btn := OverlayButton {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
icon-size: 18px;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
VerticalLayout {
alignment: center;
more-btn := OverlayButton {
icon: @image-url("../assets/icons/ellipsis.svg");
clicked => {
playlist-menu.show();
}
}
}
}
playlist-menu := PopupWindow {
x: 26px;
y: 56px;
width: 196px;
height: 109px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("playlist", root.playlist.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to favorites");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
}
}
Rectangle { height: 6px; }
// --- Title / subtitle --------------------------------------------
VerticalLayout {
height: 42px;
spacing: 2px;
padding-left: Spacing.xs;
Text {
text: root.playlist.title;
height: 20px;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
vertical-alignment: center;
overflow: elide;
}
Text {
text: root.playlist.subtitle;
height: 16px;
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
}
}
}
}
@@ -0,0 +1,165 @@
// Playlist carousel — title, "View all" link, prev/next page controls,
// and a clipped sliding track of playlist cards. Same paging mechanism as
// Carousel. Used by the search results All tab.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { SearchPlaylistItem } from "../state.slint";
import { PlaylistCard } from "PlaylistCard.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavButton inherits Rectangle {
in property <image> icon;
in property <bool> enabled: true;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
opacity: root.enabled ? 1.0 : 0.4;
background: ta.has-hover && root.enabled
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
ta := TouchArea {
enabled: root.enabled;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
root.clicked();
}
}
}
component ViewAllLink inherits Rectangle {
callback clicked();
width: link-text.preferred-width + 16px;
height: 26px;
border-radius: 4px;
background: ta.has-hover ? Theme.surface-hover : transparent;
link-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;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component PlaylistCarousel inherits VerticalLayout {
in property <string> title;
in property <[SearchPlaylistItem]> playlists;
in property <bool> show-view-all: false;
callback view-all-clicked();
callback media-action(string, string, string);
property <length> card-width: 162px;
property <length> card-height: 210px;
property <length> gap: 24px;
property <int> count: root.playlists.length;
property <int> per-page: Math.max(
1,
Math.floor((viewport.width + root.gap) / (root.card-width + root.gap)));
property <int> page-count: Math.max(1, Math.ceil(root.count / root.per-page));
property <int> page: 0;
property <int> current-page: Math.min(Math.max(0, root.page), root.page-count - 1);
property <length> step: root.per-page * (root.card-width + root.gap);
property <length> content-width:
root.count * root.card-width + Math.max(0, root.count - 1) * root.gap;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: root.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: root.current-page > 0;
clicked => {
root.page = root.current-page - 1;
}
}
}
VerticalLayout {
alignment: center;
NavButton {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: root.current-page < root.page-count - 1;
clicked => {
root.page = root.current-page + 1;
}
}
}
if root.show-view-all: VerticalLayout {
alignment: center;
ViewAllLink {
clicked => {
root.view-all-clicked();
}
}
}
}
viewport := Rectangle {
height: root.card-height;
clip: true;
Rectangle {
x: -root.current-page * root.step;
y: 0px;
width: root.content-width;
height: root.card-height;
animate x { duration: 220ms; easing: ease-in-out; }
HorizontalLayout {
spacing: root.gap;
for playlist in root.playlists: PlaylistCard {
playlist: playlist;
media-action(k, i, a) => {
root.media-action(k, i, a);
}
}
}
}
Rectangle {
x: 0px;
width: 56px;
background: @linear-gradient(90deg, Theme.surface-main 0%, transparent 100%);
opacity: root.current-page > 0 ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
}
Rectangle {
x: parent.width - self.width;
width: 56px;
background: @linear-gradient(90deg, transparent 0%, Theme.surface-main 100%);
opacity: root.current-page < root.page-count - 1 ? 1.0 : 0.0;
animate opacity { duration: 150ms; }
}
}
}
@@ -0,0 +1,114 @@
// Playlist cover collage. Qobuz returns up to four cover images per
// playlist; the layout is chosen by how many are available — single,
// dual (side by side), triple (one large + two stacked), or quad (three
// stacked left + one large right). Ported from the Tauri PlaylistCollage.
import { Theme } from "../foundation/semantic-colors.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { SearchPlaylistItem } from "../state.slint";
export component PlaylistCollage inherits Rectangle {
in property <SearchPlaylistItem> playlist;
in property <length> size: 162px;
width: root.size;
height: root.size;
clip: true;
background: Theme.surface-elevated;
property <length> gap: 2px;
property <int> n: root.playlist.cover-count;
// 0 covers — placeholder glyph.
if root.n <= 0: QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: root.size * 0.32;
height: root.size * 0.32;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
// 1 — full bleed.
if root.n == 1: Image {
width: 100%;
height: 100%;
source: root.playlist.cover1;
image-fit: cover;
}
// 2 — side by side halves.
if root.n == 2: HorizontalLayout {
spacing: root.gap;
Image {
source: root.playlist.cover1;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: root.size;
}
Image {
source: root.playlist.cover2;
image-fit: cover;
width: (root.size - root.gap) / 2;
height: root.size;
}
}
// 3 — one large left, two stacked right.
if root.n == 3: HorizontalLayout {
spacing: root.gap;
Image {
source: root.playlist.cover1;
image-fit: cover;
width: (root.size - root.gap) * 0.62;
height: root.size;
}
VerticalLayout {
spacing: root.gap;
Image {
source: root.playlist.cover2;
image-fit: cover;
width: (root.size - root.gap) * 0.38;
height: (root.size - root.gap) / 2;
}
Image {
source: root.playlist.cover3;
image-fit: cover;
width: (root.size - root.gap) * 0.38;
height: (root.size - root.gap) / 2;
}
}
}
// 4+ — three stacked left, one large right.
if root.n >= 4: HorizontalLayout {
spacing: root.gap;
VerticalLayout {
spacing: root.gap;
Image {
source: root.playlist.cover1;
image-fit: cover;
width: (root.size - root.gap) * 0.34;
height: (root.size - 2 * root.gap) / 3;
}
Image {
source: root.playlist.cover2;
image-fit: cover;
width: (root.size - root.gap) * 0.34;
height: (root.size - 2 * root.gap) / 3;
}
Image {
source: root.playlist.cover3;
image-fit: cover;
width: (root.size - root.gap) * 0.34;
height: (root.size - 2 * root.gap) / 3;
}
}
Image {
source: root.playlist.cover4;
image-fit: cover;
width: (root.size - root.gap) * 0.66;
height: root.size;
}
}
}
+309 -190
View File
@@ -1,11 +1,12 @@
// Search results view — a tabbed results page, ported faithfully from
// the Tauri SearchView.
// Search results view — a tabbed results page, ported from the Tauri
// SearchView.
//
// Toolbar with the history nav pair, a five-tab strip, and a scrollable
// body. The All tab is a dashboard (a top-result hero, an albums
// carousel, an artists carousel, and capped track/playlist previews);
// the per-type tabs show the full loaded set as a card grid or row list
// with a load-more button. Data comes from the SearchState global.
// 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, SearchTrackItem, SearchPlaylistItem, AlbumCardItem, SlimItem } from "../state.slint";
import { Theme } from "../foundation/semantic-colors.slint";
@@ -13,12 +14,13 @@ 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 { SearchTrackRow } from "SearchTrackRow.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
// One tab in the results tab strip.
component SearchTab inherits Rectangle {
@@ -51,18 +53,65 @@ component SearchTab inherits Rectangle {
}
}
// "Title (count)" with an optional "View more" affordance.
// 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 <int> count;
in property <bool> show-more;
callback more-clicked();
height: 28px;
height: 30px;
HorizontalLayout {
Text {
text: root.title + " (\{root.count})";
text: root.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
@@ -70,12 +119,17 @@ component SectionHeader inherits Rectangle {
horizontal-stretch: 1;
}
if root.show-more: Rectangle {
width: more-text.preferred-width + 8px;
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 more");
color: more-ta.has-hover ? Theme.text-primary : Theme.accent;
font-size: 13px;
vertical-alignment: center;
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;
@@ -120,67 +174,6 @@ component LoadMoreButton inherits Rectangle {
}
}
// 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;
}
}
}
}
// A wrapping grid of album cards (per-type Albums tab).
component AlbumGrid inherits Rectangle {
in property <[AlbumCardItem]> albums;
@@ -215,10 +208,11 @@ component AlbumGrid inherits Rectangle {
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: 200px;
property <length> gap: 24px;
property <length> card-w: 170px;
property <length> card-h: 248px;
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);
@@ -234,6 +228,35 @@ component ArtistGrid inherits Rectangle {
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: 162px;
property <length> card-h: 210px;
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);
}
}
}
@@ -242,8 +265,13 @@ export component SearchResultsView inherits Rectangle {
callback open-artist(string);
callback media-action(string, string, string);
// How many tracks/playlists each category previews on the All tab.
property <int> preview-cap: 5;
// 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;
@@ -266,67 +294,122 @@ export component SearchResultsView inherits Rectangle {
}
}
// --- Fixed header: title, tabs, filter --------------------------
// --- Fixed header: title, tabs + filter radios ------------------
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-bottom: 10px;
spacing: 14px;
padding-bottom: 12px;
spacing: 16px;
Text {
text: SearchState.loading
? @tr("Searching…")
: @tr("Results for ") + SearchState.query;
text: @tr("Search Results");
color: Theme.text-primary;
font-size: 24px;
font-weight: 700;
}
HorizontalLayout {
spacing: 22px;
alignment: start;
SearchTab {
label: @tr("All");
active: SearchState.tab == 0;
clicked => { SearchActions.tab-changed(0); }
// Tabs, left.
HorizontalLayout {
horizontal-stretch: 1;
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); }
}
}
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); }
}
}
if SearchState.tab >= 1 && SearchState.tab <= 3: HorizontalLayout {
alignment: start;
QbzSelect {
menu-width: 180px;
options: [
@tr("No filter"),
@tr("Main artist"),
@tr("Performer"),
@tr("Composer"),
@tr("Label"),
@tr("Release name"),
];
current-index: SearchState.filter-index;
selected(i) => {
SearchActions.filter-changed(i);
// searchType filter radios, right.
if root.has-results: HorizontalLayout {
spacing: 16px;
alignment: end;
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); }
}
}
}
}
}
@@ -342,45 +425,89 @@ export component SearchResultsView inherits Rectangle {
padding-left: 32px;
padding-right: 32px;
padding-bottom: 32px;
spacing: 24px;
spacing: 28px;
alignment: start;
// ---- Top result (All tab) ---------------------------------
if SearchState.tab == 0 && SearchState.most-popular-kind != "": VerticalLayout {
spacing: 12px;
Text {
text: @tr("Top result");
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
}
HorizontalLayout {
alignment: start;
// ---- Most popular + Artists (All tab) ---------------------
if SearchState.tab == 0 && SearchState.most-popular-kind != "": HorizontalLayout {
spacing: 24px;
alignment: start;
// 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": Rectangle {
width: 320px;
height: 56px;
SearchTrackRow {
width: 320px;
track: SearchState.most-popular-track;
index: 0;
media-action(k, i, a) => { root.media-action(k, i, a); }
}
}
}
if SearchState.most-popular-kind == "track": SearchTrackRow {
track: SearchState.most-popular-track;
index: 0;
// Artists carousel — fills the rest of the row.
if SearchState.artists.length > 0: ArtistCarousel {
horizontal-stretch: 1;
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); }
}
}
// 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") + " (\{SearchState.albums-total})",
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;
@@ -393,28 +520,11 @@ export component SearchResultsView inherits Rectangle {
clicked => { SearchActions.load-more(1); }
}
// ---- Artists ----------------------------------------------
if SearchState.tab == 0 && SearchState.artists.length > 0: ArtistCarousel {
title: @tr("Artists") + " (\{SearchState.artists-total})";
artists: SearchState.artists;
artist-clicked(id) => { root.open-artist(id); }
}
if SearchState.tab == 3: ArtistGrid {
artists: SearchState.artists;
open(id) => { root.open-artist(id); }
}
if SearchState.tab == 3: LoadMoreButton {
loaded: SearchState.artists.length;
total: SearchState.artists-total;
clicked => { SearchActions.load-more(3); }
}
// ---- 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); }
}
@@ -432,34 +542,43 @@ export component SearchResultsView inherits Rectangle {
}
}
// ---- 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); }
}
// ---- 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); }
}
// ---- Empty state ------------------------------------------
if !SearchState.loading
&& SearchState.albums.length == 0
&& SearchState.tracks.length == 0
&& SearchState.artists.length == 0
&& SearchState.playlists.length == 0: Text {
// ---- 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); }
}
// ---- Empty / loading state --------------------------------
if SearchState.loading && !root.has-results: Text {
text: @tr("Searching…");
color: Theme.text-muted;
font-size: 14px;
}
if !SearchState.loading && !root.has-results: Text {
text: @tr("No results.");
color: Theme.text-muted;
font-size: 14px;
+13 -3
View File
@@ -31,6 +31,8 @@ export struct SlimItem {
rank: string, // "" = no rank
artwork-url: string,
artwork: image,
// Artist follow state — drives the ArtistCard Follow button.
following: bool,
}
// Discover / Home screen state.
@@ -343,13 +345,21 @@ export struct SearchTrackItem {
artwork: image,
}
// A playlist result row in search.
// A playlist result in search. Qobuz returns up to four cover URLs per
// playlist; the card renders them as a count-based collage.
export struct SearchPlaylistItem {
id: string,
title: string,
subtitle: string, // owner name + track count
artwork-url: string,
artwork: image,
cover-count: int, // number of cover images, 0-4
url1: string,
url2: string,
url3: string,
url4: string,
cover1: image,
cover2: image,
cover3: image,
cover4: image,
}
// Search results screen state. The Rust search controller populates the