feat(qbz-slint): real Discover Home view

After sign-in the app loads the real Qobuz discover index and renders
the Home view: a time-based greeting with the user's name, the tab
pill (Home / Editor's Picks / For You), and discover sections — New
Releases, Press Accolades, Ideal Discography, Qobuzissimes, Albums of
the Week — each a horizontally scrolling row of album cards.

- state.slint: AlbumCardItem / DiscoverSection structs, HomeState global.
- discover/: AlbumCard (artwork area, award ribbon, title, artist,
  quality badge), SectionRow, HomeView.
- home.rs: discover-index fetch and mapping (award-ribbon priority,
  quality string), greeting, and event-loop model application. Unit
  tests for ribbon, quality, and greeting logic.
- AppShell content region is now the HomeView.

Album artwork images load in the next increment (artwork pipeline);
cards currently show the elevated placeholder. No fixtures — all data
is the live discover index.

cc
This commit is contained in:
vicrodh committed 2026-05-18 13:01:45 -06:00
1 parent 03b9a1c528
commit 8246ad5dd6
11 files changed
+576 -40

No files matched your search

+1
View File
@@ -5372,6 +5372,7 @@ name = "qbz-slint"
version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"env_logger",
"log",
"open",
+1
View File
@@ -16,6 +16,7 @@ log = { workspace = true }
env_logger = "0.11"
tokio = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
urlencoding = "2"
open = "5"
qbz-app = { path = "../qbz-app" }
+15 -3
View File
@@ -16,9 +16,17 @@ use tokio::net::TcpListener;
const OAUTH_TIMEOUT: Duration = Duration::from_secs(180);
/// Result of a successful login.
pub struct LoginOutcome {
pub user_id: u64,
pub display_name: String,
}
/// Run the full system-browser OAuth login. Returns the authenticated
/// user id on success.
pub async fn login_via_system_browser<A>(runtime: &Arc<AppRuntime<A>>) -> Result<u64, String>
/// user on success.
pub async fn login_via_system_browser<A>(
runtime: &Arc<AppRuntime<A>>,
) -> Result<LoginOutcome, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
@@ -68,6 +76,7 @@ where
.map_err(|e| e.to_string())?
};
let user_id = session.user_id;
let display_name = session.display_name.clone();
// Emit LoggedIn through the core (idempotent set_session).
core.set_session(session).await.map_err(|e| e.to_string())?;
@@ -76,7 +85,10 @@ where
runtime.activate(user_id).await?;
log::info!("[qbz-slint] login complete for user {user_id}");
Ok(user_id)
Ok(LoginOutcome {
user_id,
display_name,
})
}
/// Accept connections until one carries the OAuth code, replying with a
+264
View File
@@ -0,0 +1,264 @@
//! Discover / Home controller.
//!
//! Fetches the Qobuz discover index through `QbzCore`, maps it into plain
//! (Send) section data on the worker thread, and — separately, on the
//! Slint event loop — converts that into Slint models pushed onto the
//! `HomeState` global. Domain types never reach the `.slint` files.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::{AlbumAward, DiscoverAlbum, DiscoverAudioInfo, DiscoverContainer};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AlbumCardItem, AppWindow, DiscoverSection, HomeState};
/// Plain, `Send` section data produced on the worker thread.
pub struct SectionData {
pub title: String,
pub albums: Vec<CardData>,
}
pub struct CardData {
pub id: String,
pub title: String,
pub artist: String,
pub quality: String,
pub ribbon: String,
pub ribbon_kind: String,
pub artwork_url: String,
}
/// Time-of-day greeting, matching the QBZ home greeting strings.
pub fn greeting(display_name: &str) -> String {
use chrono::Timelike;
let hour = chrono::Local::now().hour();
let part = if (5..12).contains(&hour) {
"Good morning"
} else if (12..17).contains(&hour) {
"Good afternoon"
} else if (17..21).contains(&hour) {
"Good evening"
} else {
"Good night"
};
let first = display_name.split_whitespace().next().unwrap_or("");
if first.is_empty() {
part.to_string()
} else {
format!("{part}, {first}")
}
}
/// Fetch the discover index and map it into Home sections.
pub async fn load_home<A>(runtime: &Arc<AppRuntime<A>>) -> Result<Vec<SectionData>, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let response = runtime
.core()
.get_discover_index(None)
.await
.map_err(|e| e.to_string())?;
let containers = response.containers;
let mut sections = Vec::new();
push_section(&mut sections, "New Releases", containers.new_releases);
push_section(&mut sections, "Press Accolades", containers.press_awards);
push_section(
&mut sections,
"Ideal Discography",
containers.ideal_discography,
);
push_section(&mut sections, "Qobuzissimes", containers.qobuzissims);
push_section(
&mut sections,
"Albums of the Week",
containers.album_of_the_week,
);
Ok(sections)
}
fn push_section(
out: &mut Vec<SectionData>,
title: &str,
container: Option<DiscoverContainer<DiscoverAlbum>>,
) {
let Some(container) = container else {
return;
};
if container.data.items.is_empty() {
return;
}
out.push(SectionData {
title: title.to_string(),
albums: container.data.items.into_iter().map(map_album).collect(),
});
}
fn map_album(album: DiscoverAlbum) -> CardData {
let artist = album
.artists
.first()
.map(|a| a.name.clone())
.unwrap_or_default();
let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref());
let artwork_url = album
.image
.large
.or(album.image.thumbnail)
.or(album.image.small)
.unwrap_or_default();
CardData {
id: album.id,
title: album.title,
artist,
quality: quality_string(album.audio_info.as_ref()),
ribbon,
ribbon_kind,
artwork_url,
}
}
/// Pick the single award ribbon, mirroring `pickAlbumRibbon` in data.ts:
/// award id 151 = Album of the Week, 88 = Qobuzissime, otherwise the last
/// award becomes a generic "press" ribbon.
fn pick_ribbon(awards: Option<&[AlbumAward]>) -> (String, String) {
let Some(awards) = awards else {
return (String::new(), String::new());
};
if awards.is_empty() {
return (String::new(), String::new());
}
if let Some(a) = awards.iter().find(|a| a.id.as_deref() == Some("151")) {
return (a.name.clone(), "albumOfTheWeek".to_string());
}
if let Some(a) = awards.iter().find(|a| a.id.as_deref() == Some("88")) {
return (a.name.clone(), "qobuzissime".to_string());
}
let last = awards.last().expect("non-empty checked above");
(last.name.clone(), "press".to_string())
}
/// Format the quality badge as `{depth}-bit / {rate} kHz`.
fn quality_string(audio: Option<&DiscoverAudioInfo>) -> String {
let Some(audio) = audio else {
return String::new();
};
match (audio.maximum_bit_depth, audio.maximum_sampling_rate) {
(Some(depth), Some(rate)) => format!("{depth}-bit / {} kHz", format_rate(rate)),
_ => String::new(),
}
}
fn format_rate(rate: f64) -> String {
if rate.fract().abs() < 0.05 {
format!("{}", rate.round() as i64)
} else {
format!("{rate:.1}")
}
}
/// Convert worker-thread section data into Slint models and push them onto
/// the `HomeState` global. Must run on the Slint event loop.
pub fn apply_sections(window: &AppWindow, data: Vec<SectionData>) {
let sections: Vec<DiscoverSection> = data
.into_iter()
.map(|section| {
let albums: Vec<AlbumCardItem> = section
.albums
.into_iter()
.map(|card| AlbumCardItem {
id: card.id.into(),
title: card.title.into(),
artist: card.artist.into(),
quality: card.quality.into(),
ribbon: card.ribbon.into(),
ribbon_kind: card.ribbon_kind.into(),
artwork_url: card.artwork_url.into(),
})
.collect();
DiscoverSection {
title: section.title.into(),
albums: ModelRc::new(VecModel::from(albums)),
}
})
.collect();
window
.global::<HomeState>()
.set_sections(ModelRc::new(VecModel::from(sections)));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_uses_first_name_only() {
let g = greeting("Victor Rodriguez Hernandez");
assert!(g.ends_with(", Victor"), "got: {g}");
}
#[test]
fn greeting_without_name_omits_comma() {
let g = greeting("");
assert!(!g.contains(','), "got: {g}");
}
#[test]
fn quality_formats_bit_depth_and_rate() {
let audio = DiscoverAudioInfo {
maximum_bit_depth: Some(24),
maximum_sampling_rate: Some(96.0),
maximum_channel_count: Some(2),
};
assert_eq!(quality_string(Some(&audio)), "24-bit / 96 kHz");
}
#[test]
fn quality_keeps_fractional_rate() {
let audio = DiscoverAudioInfo {
maximum_bit_depth: Some(16),
maximum_sampling_rate: Some(44.1),
maximum_channel_count: Some(2),
};
assert_eq!(quality_string(Some(&audio)), "16-bit / 44.1 kHz");
}
#[test]
fn ribbon_prioritizes_album_of_the_week() {
let awards = vec![
AlbumAward {
id: Some("88".into()),
name: "Qobuzissime".into(),
awarded_at: None,
},
AlbumAward {
id: Some("151".into()),
name: "Album of the Week".into(),
awarded_at: None,
},
];
let (label, kind) = pick_ribbon(Some(&awards));
assert_eq!(kind, "albumOfTheWeek");
assert_eq!(label, "Album of the Week");
}
#[test]
fn ribbon_falls_back_to_press() {
let awards = vec![AlbumAward {
id: Some("7".into()),
name: "Gramophone Editor's Choice".into(),
awarded_at: None,
}];
let (label, kind) = pick_ribbon(Some(&awards));
assert_eq!(kind, "press");
assert_eq!(label, "Gramophone Editor's Choice");
}
#[test]
fn ribbon_empty_when_no_awards() {
assert_eq!(pick_ribbon(None), (String::new(), String::new()));
}
}
+34 -9
View File
@@ -8,15 +8,16 @@
//! tree is compiled from `ui/app.slint` by `build.rs`; `include_modules!`
//! pulls in the generated Rust bindings.
//!
//! Status: foundation tokens, login screen, app shell, and functional
//! system-browser OAuth — sign-in runs the real Qobuz OAuth flow and
//! activates a real per-user session.
//! Status: foundation tokens, login screen, app shell, functional
//! system-browser OAuth, and a real Discover / Home view fed by the
//! Qobuz discover index.
slint::include_modules!();
mod adapter;
mod auth;
mod commands;
mod home;
use std::sync::Arc;
@@ -50,7 +51,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Sign in via the system browser → real OAuth → app shell.
// Sign in via the system browser → real OAuth → Discover/Home.
// "Sign in via Browser" and "Use your system browser instead" are the
// same flow in the MVP (the in-app webview path is intentionally absent).
let on_browser_login = {
@@ -61,12 +62,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
match auth::login_via_system_browser(&runtime).await {
Ok(user_id) => {
log::info!("[qbz-slint] authenticated as user {user_id}");
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Shell));
let outcome = match auth::login_via_system_browser(&runtime).await {
Ok(outcome) => outcome,
Err(e) => {
log::error!("[qbz-slint] sign-in failed: {e}");
return;
}
};
log::info!("[qbz-slint] authenticated as user {}", outcome.user_id);
let greeting = home::greeting(&outcome.display_name);
let _ = weak.upgrade_in_event_loop(move |w| {
let state = w.global::<HomeState>();
state.set_greeting(greeting.into());
state.set_loading(true);
w.set_screen(AppScreen::Shell);
});
match home::load_home(&runtime).await {
Ok(sections) => {
let _ = weak.upgrade_in_event_loop(move |w| {
home::apply_sections(&w, sections);
w.global::<HomeState>().set_loading(false);
});
}
Err(e) => {
log::error!("[qbz-slint] discover load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<HomeState>().set_loading(false);
});
}
Err(e) => log::error!("[qbz-slint] sign-in failed: {e}"),
}
});
}
+3
View File
@@ -8,6 +8,9 @@ import { Theme } from "foundation/semantic-colors.slint";
import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the HomeState global so the Rust layer can populate it.
export { HomeState } from "state.slint";
// Which top-level screen is shown. During M1 the screen switches on a
// login-button click without real authentication; M2 ties the switch
// to a real session.
@@ -0,0 +1,107 @@
// Album card for the Discover sections.
//
// Mirrors the Svelte AlbumCardLite: 220px column, square artwork with a
// bottom-left award ribbon, single-line title, artist, and a compact
// quality badge. Artwork image loading lands in the artwork-pipeline
// increment; for now the artwork area is the same elevated placeholder
// the real card shows before its image resolves.
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 { AlbumCardItem } from "../state.slint";
export component AlbumCard inherits Rectangle {
in property <AlbumCardItem> album;
width: 220px;
height: 282px;
VerticalLayout {
spacing: Spacing.xs;
// --- Artwork ------------------------------------------------------
Rectangle {
width: 220px;
height: 220px;
border-radius: Radius.sm;
background: Theme.surface-elevated;
clip: true;
if root.album.ribbon != "": Rectangle {
x: 0px;
y: parent.height - self.height - 8px;
height: 20px;
background: root.album.ribbon-kind == "press"
? #d49511
: #000000e0;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
// Left accent stripe (not on the gold press ribbon).
Rectangle {
x: 0px;
width: root.album.ribbon-kind == "press" ? 0px : 3px;
background: root.album.ribbon-kind == "qobuzissime"
? #8b5cf6
: #eab308;
}
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
Text {
text: root.album.ribbon;
color: root.album.ribbon-kind == "press"
? #1f1407
: #ffffff;
font-size: 10px;
font-weight: Typography.semibold;
vertical-alignment: center;
overflow: elide;
}
}
}
}
// --- Title --------------------------------------------------------
Text {
text: root.album.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
// --- Artist + quality --------------------------------------------
HorizontalLayout {
spacing: Spacing.sm;
Text {
text: root.album.artist;
color: Theme.text-muted;
font-size: Typography.link;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
if root.album.quality != "": Rectangle {
background: Theme.surface-elevated;
border-radius: 3px;
HorizontalLayout {
padding-left: 6px;
padding-right: 6px;
padding-top: 3px;
padding-bottom: 3px;
Text {
text: root.album.quality;
color: Theme.text-secondary;
font-size: 9px;
vertical-alignment: center;
}
}
}
}
}
}
@@ -0,0 +1,86 @@
// Discover — Home view: greeting, tab pill, and the discover sections.
//
// Section data is supplied by the Rust home controller through the
// HomeState global. The Editor's Picks / For You tabs are visual only
// for now; switching their data is a later increment.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { HomeState } from "../state.slint";
import { SectionRow } from "SectionRow.slint";
import { ScrollView } from "std-widgets.slint";
component Tab inherits Rectangle {
in property <string> label;
in property <bool> active: false;
callback clicked();
border-radius: 4px;
background: root.active ? Theme.surface-main
: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 6px;
padding-bottom: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: 13px;
font-weight: Typography.medium;
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component HomeView inherits Rectangle {
background: Theme.surface-main;
ScrollView {
VerticalLayout {
padding: 32px;
spacing: 28px;
alignment: start;
Text {
text: HomeState.greeting;
visible: HomeState.greeting != "";
color: Theme.text-primary;
font-size: 26px;
font-weight: Typography.semibold;
}
HorizontalLayout {
alignment: start;
Rectangle {
background: Theme.surface-elevated;
border-radius: 6px;
HorizontalLayout {
padding: 3px;
spacing: 4px;
Tab { label: @tr("Home"); active: true; }
Tab { label: @tr("Editor's Picks"); }
Tab { label: @tr("For You"); }
}
}
}
if HomeState.loading && HomeState.sections.length == 0: Text {
text: @tr("Loading your music...");
color: Theme.text-muted;
font-size: 14px;
}
for section in HomeState.sections: SectionRow {
section: section;
}
}
}
}
@@ -0,0 +1,34 @@
// One Discover section — a title and a horizontally scrollable row of
// album cards.
//
// The Svelte original paginates by slice; the MVP uses a horizontal
// scroll, which is simpler and behaviorally equivalent for browsing.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { DiscoverSection } from "../state.slint";
import { AlbumCard } from "AlbumCard.slint";
import { ScrollView } from "std-widgets.slint";
export component SectionRow inherits VerticalLayout {
in property <DiscoverSection> section;
spacing: 12px;
Text {
text: root.section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
}
ScrollView {
height: 290px;
HorizontalLayout {
spacing: 32px;
for album in root.section.albums: AlbumCard {
album: album;
}
}
}
}
+5 -28
View File
@@ -6,11 +6,10 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
import { PlayerBar } from "PlayerBar.slint";
import { HomeView } from "../discover/HomeView.slint";
export component AppShell inherits Rectangle {
background: Theme.surface-main;
@@ -29,34 +28,12 @@ export component AppShell inherits Rectangle {
spacing: 0px;
Sidebar { }
// Content region — placeholder until the Discover/Home view.
Rectangle {
width: Layout.hairline;
background: Theme.border-subtle;
}
HomeView {
horizontal-stretch: 1;
background: Theme.surface-main;
VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
VerticalLayout {
spacing: Spacing.sm;
Text {
text: "Discover — Home";
color: Theme.text-primary;
font-size: 22px;
font-weight: Typography.semibold;
horizontal-alignment: center;
}
Text {
text: "Content view lands in the next increment";
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// Shared UI state — structs and globals the Rust controllers populate.
//
// The Rust side builds these models on a worker thread, then sets them
// on the global from the Slint event loop (see src/home.rs, src/main.rs).
export struct AlbumCardItem {
id: string,
title: string,
artist: string,
quality: string,
ribbon: string,
ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | ""
artwork-url: string,
}
export struct DiscoverSection {
title: string,
albums: [AlbumCardItem],
}
// Discover / Home screen state.
export global HomeState {
in property <string> greeting: "";
in property <[DiscoverSection]> sections: [];
in property <bool> loading: false;
}