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

+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;
}