feat(slint): For You Spotlight section
Ports Tauri's SpotlightLite — one favorite artist highlighted with a
hero and a content row. Backed by get_artist_page.
- Spotlight component: header (title + "Shine a light…" subtitle),
hero (round portrait + ARTIST label + name + play-top-tracks /
open-artist circle buttons), and a draggable Flickable row
mixing a TOP TRACKS card, a RADIO card, and the artist's albums.
- New RadioCard (port of RadioCardLite): a 180px tile with the seed
artwork centered on an elevated panel, a wordmark label over it,
and a title/subtitle below; hover reveals play. (The Tauri
palette-derived panel colour is approximated with a neutral
elevated panel.)
- foryou::load_spotlight rotates among the top-5 favorites by
wall-clock seconds, fetches get_artist_page, and extracts up to 6
albums (album > live > ep-single > compilation, deduped) plus
whether the artist has top tracks. ForYouState gains the
spotlight-* fields; two new artwork targets feed the portrait +
album covers.
- Wiring: ForYouView renders Spotlight after Artists to Follow.
play-top-tracks routes to the existing artist play-top action;
"start radio" has no artist-radio backend yet so it falls back to
playing top tracks (logged).
Remaining For You: Radio Stations section (old card design), Qobuz
Mixes row, and the reco-DB sections (More from library, Rediscover).
cc
This commit is contained in:
8 files changed
+524
No files matched your search
@@ -76,6 +76,10 @@ pub enum ArtworkTarget {
|
|||||||
ForYouTopArtist { index: usize },
|
ForYouTopArtist { index: usize },
|
||||||
/// A tile in ForYouState.artists-to-follow[index].
|
/// A tile in ForYouState.artists-to-follow[index].
|
||||||
ForYouToFollow { index: usize },
|
ForYouToFollow { index: usize },
|
||||||
|
/// The Spotlight artist portrait.
|
||||||
|
ForYouSpotlightArtist,
|
||||||
|
/// A card in ForYouState.spotlight-albums[index].
|
||||||
|
ForYouSpotlightAlbum { index: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An artwork download job: which card, and the image URL.
|
/// An artwork download job: which card, and the image URL.
|
||||||
@@ -448,5 +452,17 @@ fn apply_artwork(
|
|||||||
model.set_row_data(index, item);
|
model.set_row_data(index, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ArtworkTarget::ForYouSpotlightArtist => {
|
||||||
|
window
|
||||||
|
.global::<crate::ForYouState>()
|
||||||
|
.set_spotlight_image(image);
|
||||||
|
}
|
||||||
|
ArtworkTarget::ForYouSpotlightAlbum { index } => {
|
||||||
|
let model = window.global::<crate::ForYouState>().get_spotlight_albums();
|
||||||
|
if let Some(mut item) = model.row_data(index) {
|
||||||
|
item.artwork = image;
|
||||||
|
model.set_row_data(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,16 @@ pub struct ForYouData {
|
|||||||
pub recent_tracks: Vec<TrackSlim>,
|
pub recent_tracks: Vec<TrackSlim>,
|
||||||
pub top_artists: Vec<ArtistSlim>,
|
pub top_artists: Vec<ArtistSlim>,
|
||||||
pub artists_to_follow: Vec<ArtistSlim>,
|
pub artists_to_follow: Vec<ArtistSlim>,
|
||||||
|
pub spotlight: Option<SpotlightData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SpotlightData {
|
||||||
|
pub artist_id: String,
|
||||||
|
pub artist_name: String,
|
||||||
|
pub category: String,
|
||||||
|
pub image_url: String,
|
||||||
|
pub has_top_tracks: bool,
|
||||||
|
pub albums: Vec<AlbumCard>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -182,15 +192,117 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spotlight — highlight one favorite artist (rotated by time) with
|
||||||
|
// their page (albums + whether they have top tracks).
|
||||||
|
let spotlight = load_spotlight(runtime, &fav_artists).await;
|
||||||
|
|
||||||
ForYouData {
|
ForYouData {
|
||||||
release_watch,
|
release_watch,
|
||||||
recent_albums,
|
recent_albums,
|
||||||
recent_tracks,
|
recent_tracks,
|
||||||
top_artists,
|
top_artists,
|
||||||
artists_to_follow: to_follow,
|
artists_to_follow: to_follow,
|
||||||
|
spotlight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_spotlight<A>(
|
||||||
|
runtime: &Arc<AppRuntime<A>>,
|
||||||
|
favorites: &[Artist],
|
||||||
|
) -> Option<SpotlightData>
|
||||||
|
where
|
||||||
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if favorites.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Rotate among the top 5 favorites by wall-clock seconds.
|
||||||
|
let pool = favorites.len().min(5);
|
||||||
|
let idx = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as usize % pool)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let seed = &favorites[idx];
|
||||||
|
|
||||||
|
let page = runtime.core().get_artist_page(seed.id, None).await.ok()?;
|
||||||
|
let image_url = page
|
||||||
|
.images
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|i| i.portrait.as_ref())
|
||||||
|
.map(|p| {
|
||||||
|
format!(
|
||||||
|
"https://static.qobuz.com/images/artists/covers/medium/{}.{}",
|
||||||
|
p.hash, p.format
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Up to 6 albums, preferring full albums then live/ep/compilation.
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let mut albums: Vec<AlbumCard> = Vec::new();
|
||||||
|
for want in ["album", "live", "ep-single", "compilation"] {
|
||||||
|
if albums.len() >= 6 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(groups) = page.releases.as_ref() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(group) = groups.iter().find(|g| g.release_type == want) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for rel in &group.items {
|
||||||
|
if !seen.insert(rel.id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let year = rel
|
||||||
|
.dates
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|d| d.original.as_deref())
|
||||||
|
.and_then(|s| s.get(..4).map(|y| y.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bd = rel.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
|
||||||
|
let sr = rel.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
|
||||||
|
albums.push(AlbumCard {
|
||||||
|
id: rel.id.clone(),
|
||||||
|
title: rel.title.clone(),
|
||||||
|
artist: rel
|
||||||
|
.artist
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.name.display.clone())
|
||||||
|
.unwrap_or_else(|| page.name.display.clone()),
|
||||||
|
year,
|
||||||
|
quality_tier: match bd {
|
||||||
|
Some(d) if d >= 24 => "hires",
|
||||||
|
Some(_) => "cd",
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
quality_label: match (bd, sr) {
|
||||||
|
(Some(b), Some(r)) => format!("{}-bit / {} kHz", b, r),
|
||||||
|
_ => String::new(),
|
||||||
|
},
|
||||||
|
artwork_url: rel
|
||||||
|
.image
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|img| img.best().cloned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
});
|
||||||
|
if albums.len() >= 6 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(SpotlightData {
|
||||||
|
artist_id: seed.id.to_string(),
|
||||||
|
artist_name: page.name.display.clone(),
|
||||||
|
category: page.artist_category.clone().unwrap_or_default(),
|
||||||
|
image_url,
|
||||||
|
has_top_tracks: page.top_tracks.as_ref().map(|t| !t.is_empty()).unwrap_or(false),
|
||||||
|
albums,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn album_items(cards: &[AlbumCard]) -> Vec<AlbumCardItem> {
|
fn album_items(cards: &[AlbumCard]) -> Vec<AlbumCardItem> {
|
||||||
cards
|
cards
|
||||||
.iter()
|
.iter()
|
||||||
@@ -254,6 +366,19 @@ pub fn apply_for_you(window: &AppWindow, data: &ForYouData) {
|
|||||||
state.set_artists_to_follow(ModelRc::new(VecModel::from(artist_items(
|
state.set_artists_to_follow(ModelRc::new(VecModel::from(artist_items(
|
||||||
&data.artists_to_follow,
|
&data.artists_to_follow,
|
||||||
))));
|
))));
|
||||||
|
|
||||||
|
if let Some(sp) = &data.spotlight {
|
||||||
|
state.set_spotlight_visible(true);
|
||||||
|
state.set_spotlight_artist_id(sp.artist_id.clone().into());
|
||||||
|
state.set_spotlight_name(sp.artist_name.clone().into());
|
||||||
|
state.set_spotlight_category(sp.category.clone().into());
|
||||||
|
state.set_spotlight_image_url(sp.image_url.clone().into());
|
||||||
|
state.set_spotlight_has_top_tracks(sp.has_top_tracks);
|
||||||
|
state.set_spotlight_albums(ModelRc::new(VecModel::from(album_items(&sp.albums))));
|
||||||
|
} else {
|
||||||
|
state.set_spotlight_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
state.set_loading(false);
|
state.set_loading(false);
|
||||||
state.set_loaded(true);
|
state.set_loaded(true);
|
||||||
}
|
}
|
||||||
@@ -304,5 +429,21 @@ pub fn artwork_jobs(data: &ForYouData) -> Vec<ArtworkJob> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(sp) = &data.spotlight {
|
||||||
|
if !sp.image_url.is_empty() {
|
||||||
|
jobs.push(ArtworkJob {
|
||||||
|
url: sp.image_url.clone(),
|
||||||
|
target: ArtworkTarget::ForYouSpotlightArtist,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (i, c) in sp.albums.iter().enumerate() {
|
||||||
|
if !c.artwork_url.is_empty() {
|
||||||
|
jobs.push(ArtworkJob {
|
||||||
|
url: c.artwork_url.clone(),
|
||||||
|
target: ArtworkTarget::ForYouSpotlightAlbum { index: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
jobs
|
jobs
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1227,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
handle.clone(),
|
handle.clone(),
|
||||||
id.clone(),
|
id.clone(),
|
||||||
),
|
),
|
||||||
|
("artist", "radio") => {
|
||||||
|
// Spotlight "start radio" — no artist-radio backend
|
||||||
|
// in the Slint MVP yet; play top tracks as the
|
||||||
|
// closest available behavior.
|
||||||
|
log::info!("[qbz-slint] artist radio not wired; playing top tracks for {id}");
|
||||||
|
playback::play_artist_top_tracks(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
handle.clone(),
|
||||||
|
id.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
("artist", "follow") => {
|
("artist", "follow") => {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
let weak = weak.clone();
|
let weak = weak.clone();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ForYouState } from "../state.slint";
|
|||||||
import { Carousel } from "Carousel.slint";
|
import { Carousel } from "Carousel.slint";
|
||||||
import { SlimCarousel } from "SlimCarousel.slint";
|
import { SlimCarousel } from "SlimCarousel.slint";
|
||||||
import { ArtistCarousel } from "ArtistCarousel.slint";
|
import { ArtistCarousel } from "ArtistCarousel.slint";
|
||||||
|
import { Spotlight } from "Spotlight.slint";
|
||||||
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
|
|
||||||
export component ForYouView inherits VerticalLayout {
|
export component ForYouView inherits VerticalLayout {
|
||||||
@@ -20,6 +21,8 @@ export component ForYouView inherits VerticalLayout {
|
|||||||
callback open-artist(string);
|
callback open-artist(string);
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
callback play-track(string);
|
callback play-track(string);
|
||||||
|
callback play-top-tracks(string);
|
||||||
|
callback start-radio(string);
|
||||||
|
|
||||||
spacing: 40px;
|
spacing: 40px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
@@ -85,4 +88,23 @@ export component ForYouView inherits VerticalLayout {
|
|||||||
root.media-action(kind, id, action);
|
root.media-action(kind, id, action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Spotlight.
|
||||||
|
if ForYouState.spotlight-visible: Spotlight {
|
||||||
|
open-artist(id) => {
|
||||||
|
root.open-artist(id);
|
||||||
|
}
|
||||||
|
open-album(id) => {
|
||||||
|
root.open-album(id);
|
||||||
|
}
|
||||||
|
play-top-tracks(id) => {
|
||||||
|
root.play-top-tracks(id);
|
||||||
|
}
|
||||||
|
start-radio(id) => {
|
||||||
|
root.start-radio(id);
|
||||||
|
}
|
||||||
|
media-action(kind, id, action) => {
|
||||||
|
root.media-action(kind, id, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +277,12 @@ export component HomeView inherits Rectangle {
|
|||||||
play-track(id) => {
|
play-track(id) => {
|
||||||
root.media-action("track", id, "play");
|
root.media-action("track", id, "play");
|
||||||
}
|
}
|
||||||
|
play-top-tracks(id) => {
|
||||||
|
root.media-action("artist", id, "play-top");
|
||||||
|
}
|
||||||
|
start-radio(id) => {
|
||||||
|
root.media-action("artist", id, "radio");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Recently Played + Popular sections below belong
|
// The Recently Played + Popular sections below belong
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Radio / Top-Tracks card — port of Tauri's RadioCardLite. A 180px
|
||||||
|
// tile: a square "display" with the seed artwork centered and a
|
||||||
|
// wordmark label ("RADIO" / "TOP TRACKS") over it, then a title +
|
||||||
|
// subtitle below. Hover reveals a play button.
|
||||||
|
//
|
||||||
|
// The Tauri version derives the panel colour from the artwork's
|
||||||
|
// dominant palette; that palette extraction is omitted here (a
|
||||||
|
// neutral elevated panel stands in), the rest matches.
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { Radius } from "../foundation/radius.slint";
|
||||||
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
|
||||||
|
export component RadioCard inherits Rectangle {
|
||||||
|
in property <string> seed-title;
|
||||||
|
in property <string> seed-subtitle;
|
||||||
|
in property <string> label: "RADIO";
|
||||||
|
in property <image> artwork;
|
||||||
|
callback clicked();
|
||||||
|
callback play();
|
||||||
|
|
||||||
|
width: 180px;
|
||||||
|
height: 234px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
|
||||||
|
// Display panel.
|
||||||
|
panel := Rectangle {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
// Centered artwork "display".
|
||||||
|
Rectangle {
|
||||||
|
width: 114px;
|
||||||
|
height: 114px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2 - 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: Theme.surface-card;
|
||||||
|
clip: true;
|
||||||
|
Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: root.artwork;
|
||||||
|
image-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wordmark over the display, bottom.
|
||||||
|
Text {
|
||||||
|
text: root.label;
|
||||||
|
color: #ffffffe6;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: Typography.bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: parent.height - self.height - 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover overlay + play.
|
||||||
|
Rectangle {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000000;
|
||||||
|
opacity: ta.has-hover || play-ta.has-hover ? 0.45 : 0.0;
|
||||||
|
animate opacity { duration: 150ms; }
|
||||||
|
}
|
||||||
|
play-circle := Rectangle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
opacity: ta.has-hover || play-ta.has-hover ? 1.0 : 0.0;
|
||||||
|
animate opacity { duration: 150ms; }
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/play-fill.svg");
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: #000000;
|
||||||
|
}
|
||||||
|
play-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta.
|
||||||
|
Text {
|
||||||
|
text: root.seed-title;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
overflow: elide;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
if root.seed-subtitle != "": Text {
|
||||||
|
text: root.seed-subtitle;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
overflow: elide;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Discover > For You "Spotlight" — port of Tauri's SpotlightLite.
|
||||||
|
// One favorite artist highlighted: a header, a hero (portrait +
|
||||||
|
// ARTIST + name + play-top-tracks / open-artist circle buttons), and
|
||||||
|
// a draggable row mixing a TOP TRACKS card, a RADIO card, and the
|
||||||
|
// artist's albums.
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { ForYouState, AlbumCardItem } from "../state.slint";
|
||||||
|
import { AlbumCard } from "AlbumCard.slint";
|
||||||
|
import { RadioCard } from "RadioCard.slint";
|
||||||
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
|
||||||
|
component CircleButton inherits Rectangle {
|
||||||
|
in property <image> icon;
|
||||||
|
in property <bool> primary: false;
|
||||||
|
callback clicked();
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: root.primary
|
||||||
|
? (ta.has-hover ? #ffffffd6 : Theme.accent)
|
||||||
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
||||||
|
QbzIcon {
|
||||||
|
source: root.icon;
|
||||||
|
width: root.primary ? 18px : 16px;
|
||||||
|
height: root.primary ? 18px : 16px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: root.primary ? #ffffff : Theme.text-secondary;
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Spotlight inherits VerticalLayout {
|
||||||
|
callback open-artist(string);
|
||||||
|
callback open-album(string);
|
||||||
|
callback play-top-tracks(string);
|
||||||
|
callback start-radio(string);
|
||||||
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
|
property <length> gap: 24px;
|
||||||
|
// Row content width: TopTracks(180) + Radio(180) + albums(220 each)
|
||||||
|
// + gaps. has-top-tracks toggles the first card.
|
||||||
|
property <length> radio-cards-width:
|
||||||
|
(ForYouState.spotlight-has-top-tracks ? 180px + root.gap : 0px) + 180px + root.gap;
|
||||||
|
property <int> album-count: ForYouState.spotlight-albums.length;
|
||||||
|
property <length> content-width:
|
||||||
|
root.radio-cards-width
|
||||||
|
+ root.album-count * (220px + root.gap);
|
||||||
|
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
// Header.
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 2px;
|
||||||
|
Text {
|
||||||
|
text: @tr("Spotlight");
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Shine a light on one of your favourite artists.");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 16px;
|
||||||
|
alignment: start;
|
||||||
|
Rectangle {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 48px;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
clip: true;
|
||||||
|
Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: ForYouState.spotlight-image;
|
||||||
|
image-fit: cover;
|
||||||
|
}
|
||||||
|
if ForYouState.spotlight-image-url == "": QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/user.svg");
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: Theme.text-muted;
|
||||||
|
}
|
||||||
|
portrait-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.open-artist(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 8px;
|
||||||
|
if ForYouState.spotlight-category != "": Text {
|
||||||
|
text: @tr("ARTIST");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: ForYouState.spotlight-name;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.title;
|
||||||
|
font-weight: Typography.bold;
|
||||||
|
}
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
alignment: start;
|
||||||
|
if ForYouState.spotlight-has-top-tracks: CircleButton {
|
||||||
|
icon: @image-url("../assets/icons/play-fill.svg");
|
||||||
|
primary: true;
|
||||||
|
clicked => {
|
||||||
|
root.play-top-tracks(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CircleButton {
|
||||||
|
icon: @image-url("../assets/icons/user.svg");
|
||||||
|
clicked => {
|
||||||
|
root.open-artist(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 4px; }
|
||||||
|
|
||||||
|
// Content row — draggable (Flickable), like the other carousels.
|
||||||
|
Rectangle {
|
||||||
|
height: 270px;
|
||||||
|
clip: true;
|
||||||
|
Flickable {
|
||||||
|
width: 100%;
|
||||||
|
height: parent.height;
|
||||||
|
viewport-width: root.content-width;
|
||||||
|
viewport-height: self.height;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: root.gap;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
if ForYouState.spotlight-has-top-tracks: RadioCard {
|
||||||
|
seed-title: @tr("Top tracks");
|
||||||
|
seed-subtitle: @tr("By {}", ForYouState.spotlight-name);
|
||||||
|
label: "TOP TRACKS";
|
||||||
|
artwork: ForYouState.spotlight-image;
|
||||||
|
clicked => {
|
||||||
|
root.play-top-tracks(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
play => {
|
||||||
|
root.play-top-tracks(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RadioCard {
|
||||||
|
seed-title: ForYouState.spotlight-name;
|
||||||
|
seed-subtitle: @tr("Qobuz Radio Station");
|
||||||
|
label: "RADIO";
|
||||||
|
artwork: ForYouState.spotlight-image;
|
||||||
|
clicked => {
|
||||||
|
root.start-radio(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
play => {
|
||||||
|
root.start-radio(ForYouState.spotlight-artist-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for album in ForYouState.spotlight-albums: AlbumCard {
|
||||||
|
album: album;
|
||||||
|
clicked(id) => {
|
||||||
|
root.open-album(id);
|
||||||
|
}
|
||||||
|
media-action(kind, id, action) => {
|
||||||
|
root.media-action(kind, id, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,16 @@ export global ForYouState {
|
|||||||
in property <[SlimItem]> recent-tracks: [];
|
in property <[SlimItem]> recent-tracks: [];
|
||||||
in property <[SlimItem]> top-artists: [];
|
in property <[SlimItem]> top-artists: [];
|
||||||
in property <[SlimItem]> artists-to-follow: [];
|
in property <[SlimItem]> artists-to-follow: [];
|
||||||
|
// Spotlight — one favorite artist highlighted with a hero + a row
|
||||||
|
// of their content (top tracks card, radio card, albums).
|
||||||
|
in property <bool> spotlight-visible: false;
|
||||||
|
in property <string> spotlight-artist-id;
|
||||||
|
in property <string> spotlight-name;
|
||||||
|
in property <string> spotlight-category;
|
||||||
|
in property <string> spotlight-image-url;
|
||||||
|
in property <image> spotlight-image;
|
||||||
|
in property <bool> spotlight-has-top-tracks: false;
|
||||||
|
in property <[AlbumCardItem]> spotlight-albums: [];
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
// True once a load has completed, so the tab does not refetch on
|
// True once a load has completed, so the tab does not refetch on
|
||||||
// every visit.
|
// every visit.
|
||||||
|
|||||||
Reference in new issue
Block a user