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:
vicrodh committed 2026-05-21 01:51:31 -06:00
1 parent 85ecf79a2d
commit c27f6e1b9f
8 files changed
+524

No files matched your search

+16
View File
@@ -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);
}
}
} }
} }
+141
View File
@@ -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
} }
+12
View File
@@ -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);
}
}
}
}
}
}
+10
View File
@@ -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.