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:
11 files changed
+576
-40
No files matched your search
Generated
+1
@@ -5372,6 +5372,7 @@ name = "qbz-slint"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"env_logger",
|
||||
"log",
|
||||
"open",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user