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:
15 files changed
+1213
-339
No files matched your search
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user