From 95df643a25767c1c583bb2acccc3562e3526ee2f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:36:01 -0600 Subject: [PATCH] =?UTF-8?q?feat(qbz-qt):=20phase=203=20=E2=80=94=20Discove?= =?UTF-8?q?r=20>=20Home=20with=20live=20Qobuz=20data=20and=20artwork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure mapping port of home.rs/foryou.rs (quality tiers, award ribbons, slim grids) into Slint-free rows; discover index + favorites + release-watch + top artists fetched concurrently (12 sections, ~170 cards live-verified). Artwork via the shared qbz-cache ImageCacheService (semaphore-16 downloads, file:// rows, native QML decode). HomeView.qml: tab pill, five rail kinds, shimmer skeleton, error+Retry, offline placeholder — exact msgids throughout. Sections travel as one JSON document (cxx-qt-lib cannot nest QVariant containers — documented). km --- crates/Cargo.lock | 5 + crates/qbz-qt/Cargo.toml | 5 + crates/qbz-qt/build.rs | 5 +- crates/qbz-qt/qml/HomeView.qml | 974 +++++++++++++++++- crates/qbz-qt/qml/QbzIcon.qml | 3 +- crates/qbz-qt/qml/assets/hi-res.svg | 293 ++++++ crates/qbz-qt/qml/assets/icons/accent/cd.svg | 15 + .../qbz-qt/qml/assets/icons/accent/heart.svg | 3 + .../qml/assets/icons/accent/home-gear.svg | 7 + .../qml/assets/icons/accent/list-filter.svg | 5 + crates/qbz-qt/qml/assets/icons/black/cast.svg | 6 + crates/qbz-qt/qml/assets/icons/black/cd.svg | 15 + .../qml/assets/icons/black/chevron-down.svg | 3 + .../qml/assets/icons/black/chevron-left.svg | 3 + .../qml/assets/icons/black/chevron-right.svg | 3 + .../qml/assets/icons/black/cloud-off.svg | 5 + .../qbz-qt/qml/assets/icons/black/compass.svg | 4 + .../qml/assets/icons/black/ellipsis.svg | 5 + .../qml/assets/icons/black/hard-drive.svg | 6 + .../qbz-qt/qml/assets/icons/black/heart.svg | 3 + .../qml/assets/icons/black/home-gear.svg | 7 + crates/qbz-qt/qml/assets/icons/black/info.svg | 5 + .../qml/assets/icons/black/layout-grid.svg | 6 + .../qml/assets/icons/black/list-filter.svg | 5 + .../qml/assets/icons/black/list-music.svg | 7 + .../qml/assets/icons/black/list-ordered.svg | 8 + .../qbz-qt/qml/assets/icons/black/log-out.svg | 5 + crates/qbz-qt/qml/assets/icons/black/menu.svg | 5 + .../qml/assets/icons/black/mic-vocal.svg | 17 + .../assets/icons/black/monitor-speaker.svg | 19 + .../assets/icons/black/music-library-2.svg | 12 + .../qbz-qt/qml/assets/icons/black/music.svg | 5 + .../qml/assets/icons/black/panel-left.svg | 4 + .../qbz-qt/qml/assets/icons/black/pause.svg | 4 + .../qml/assets/icons/black/play-fill.svg | 3 + crates/qbz-qt/qml/assets/icons/black/plus.svg | 4 + .../qml/assets/icons/black/qbz-symbolic.svg | 32 + .../qml/assets/icons/black/repeat-1.svg | 7 + .../qbz-qt/qml/assets/icons/black/repeat.svg | 6 + .../qbz-qt/qml/assets/icons/black/search.svg | 4 + .../qml/assets/icons/black/settings-2.svg | 18 + .../qbz-qt/qml/assets/icons/black/shuffle.svg | 7 + .../qml/assets/icons/black/skip-back.svg | 4 + .../qml/assets/icons/black/skip-forward.svg | 4 + crates/qbz-qt/qml/assets/icons/black/user.svg | 4 + .../qml/assets/icons/black/volume-2.svg | 5 + .../qml/assets/icons/black/volume-x.svg | 5 + crates/qbz-qt/qml/assets/icons/black/x.svg | 4 + crates/qbz-qt/qml/assets/icons/muted/cd.svg | 15 + .../qbz-qt/qml/assets/icons/muted/heart.svg | 3 + .../qml/assets/icons/muted/home-gear.svg | 7 + .../qml/assets/icons/muted/list-filter.svg | 5 + crates/qbz-qt/qml/assets/icons/primary/cd.svg | 15 + .../qbz-qt/qml/assets/icons/primary/heart.svg | 3 + .../qml/assets/icons/primary/home-gear.svg | 7 + .../qml/assets/icons/primary/list-filter.svg | 5 + .../qbz-qt/qml/assets/icons/secondary/cd.svg | 15 + .../qml/assets/icons/secondary/heart.svg | 3 + .../qml/assets/icons/secondary/home-gear.svg | 7 + .../assets/icons/secondary/list-filter.svg | 5 + crates/qbz-qt/qml/assets/icons/warning/cd.svg | 15 + .../qbz-qt/qml/assets/icons/warning/heart.svg | 3 + .../qml/assets/icons/warning/home-gear.svg | 7 + .../qml/assets/icons/warning/list-filter.svg | 5 + crates/qbz-qt/src/artwork_qt.rs | 111 ++ crates/qbz-qt/src/bridge.rs | 25 + crates/qbz-qt/src/home_qt.rs | 592 +++++++++++ crates/qbz-qt/src/main.rs | 88 +- 68 files changed, 2506 insertions(+), 14 deletions(-) create mode 100644 crates/qbz-qt/qml/assets/hi-res.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/accent/list-filter.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/cast.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/chevron-down.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/chevron-left.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/chevron-right.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/cloud-off.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/compass.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/ellipsis.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/hard-drive.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/info.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/layout-grid.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/list-filter.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/list-music.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/list-ordered.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/log-out.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/menu.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/music-library-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/music.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/panel-left.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/pause.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/play-fill.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/plus.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/repeat-1.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/repeat.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/search.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/settings-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/shuffle.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/skip-back.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/skip-forward.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/user.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/volume-2.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/volume-x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/black/x.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/muted/list-filter.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/primary/list-filter.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/cd.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/heart.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/home-gear.svg create mode 100644 crates/qbz-qt/qml/assets/icons/warning/list-filter.svg create mode 100644 crates/qbz-qt/src/artwork_qt.rs create mode 100644 crates/qbz-qt/src/home_qt.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 702798d70..12a1607b1 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6514,13 +6514,18 @@ dependencies = [ "log", "open", "qbz-app", + "qbz-cache", "qbz-core", "qbz-credentials", "qbz-i18n", "qbz-log", "qbz-models", "qbz-qobuz", + "qbz-text-utils", "rand 0.10.1", + "reqwest", + "serde", + "serde_json", "tokio", "urlencoding", ] diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index fcb0fa8f7..0172bc54b 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -17,6 +17,9 @@ cxx-qt-lib = { version = "0.7", features = ["qt_full"] } tokio = { workspace = true } log = { workspace = true } rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +reqwest = { workspace = true } urlencoding = "2" open = "5" dirs = "5" @@ -25,6 +28,8 @@ qbz-core = { path = "../qbz-core" } qbz-app = { path = "../qbz-app" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-models = { path = "../qbz-models" } +qbz-cache = { path = "../qbz-cache" } +qbz-text-utils = { path = "../qbz-text-utils" } qbz-credentials = { path = "../qbz-credentials" } qbz-i18n = { path = "../qbz-i18n" } qbz-log = { path = "../qbz-log" } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index fd0d473f0..f8db6158b 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -17,7 +17,10 @@ fn collect_qrc_files(dir: &Path, out: &mut Vec) { } fn main() { - let mut qrc_files: Vec = vec!["qml/assets/qbz-logo.png".to_string()]; + let mut qrc_files: Vec = vec![ + "qml/assets/qbz-logo.png".to_string(), + "qml/assets/hi-res.svg".to_string(), + ]; collect_qrc_files(Path::new("qml/assets/icons"), &mut qrc_files); collect_qrc_files(Path::new("qml/assets/fonts"), &mut qrc_files); // Non-QML resources land at qrc:/qt/qml/com/blitzfc/qbz/ — QML diff --git a/crates/qbz-qt/qml/HomeView.qml b/crates/qbz-qt/qml/HomeView.qml index 82bb708a5..73268dd9c 100644 --- a/crates/qbz-qt/qml/HomeView.qml +++ b/crates/qbz-qt/qml/HomeView.qml @@ -1,31 +1,983 @@ -// Content view stub for the only mounted route ("home" = Discover > Home). -// Phase 3 fills this with the real Discover view; for now it proves the -// content frame, theme and i18n wiring. +// Discover > Home — QML port of crates/qbz-ui/ui/discover/HomeView.slint +// (+ Carousel / AlbumCard / SlimCarousel / SlimCard / ArtistCarousel / +// PlaylistCarousel / HomeSkeleton / OfflinePlaceholder). +// +// Data: QbzBridge.homeSectionsJson (one JSON document — see bridge.rs), +// published by src/home_qt.rs; artwork file:// paths resolve through the +// qbz-cache image cache. Section kinds: "album" | "playlist" | "slim" | +// "artists" | "recentPlaceholder". +// +// POC-NOTEs: +// - The genre filter + section-configurator gear are INERT visual stubs +// (out of scope). +// - Editor's Picks / For You / Recommendations tabs render a placeholder +// page with the exact header copy; only "home" is real. +// - Card clicks / hover actions (play / favorite / more / pin) and +// "View all" / context menus are inert — album/artist pages, playback +// and per-user stores are later phases. +// - The offline mount mirrors AppShell's ADR-010 seam: OfflineState.offline +// -> the OfflinePlaceholder replica (exact msgids). import QtQuick +import QtQuick.Controls import com.blitzfc.qbz Rectangle { + id: root color: theme.surfaceMain QbzTheme { id: theme } - Column { - anchors.left: parent.left - anchors.top: parent.top - anchors.margins: theme.spacingXl - spacing: theme.spacingSm + // Reparsed whenever Rust republishes the JSON document. + readonly property var sections: JSON.parse(QbzBridge.homeSectionsJson) + property string activeTab: "home" + // ============================ shared components ======================= + + // Circular page-control button (Carousel's NavButton). + component NavBtn: Rectangle { + property string name: "" + property bool btnEnabled: true + signal clicked() + + width: 28 + height: 28 + radius: 14 + opacity: btnEnabled ? 1.0 : 0.4 + color: (nbArea.containsMouse && btnEnabled) ? theme.surfaceHover : theme.surfaceElevated + QbzIcon { + name: parent.name + width: 15 + height: 15 + anchors.centerIn: parent + tintName: parent.btnEnabled ? "primary" : "muted" + } + MouseArea { + id: nbArea + anchors.fill: parent + enabled: parent.btnEnabled + hoverEnabled: true + cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: parent.clicked() + } + } + + // Section header: title + page chevrons (Carousel header metrics). + component RailHeader: Item { + property string title: "" + property bool leftEnabled: false + property bool rightEnabled: false + signal pageLeft() + signal pageRight() + + width: parent ? parent.width : 0 + height: 28 Text { - text: QbzBridge.tr("Discover") + " / " + QbzBridge.tr("Home") + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: parent.title color: theme.textPrimary font.pixelSize: theme.fontSection font.weight: theme.weightSemibold } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + NavBtn { + name: "chevron-left" + btnEnabled: parent.parent.leftEnabled + onClicked: parent.parent.pageLeft() + } + NavBtn { + name: "chevron-right" + btnEnabled: parent.parent.rightEnabled + onClicked: parent.parent.pageRight() + } + } + } + + // Album card (AlbumCard.slint): 200x246 — 200px art (radius 8) + 6px + + // 40px title/artist row with the icon-only quality badge. + component AlbumCard: Rectangle { + property var card: ({}) + width: 200 + height: 246 + color: "transparent" + + readonly property bool overlayOn: artArea.containsMouse + + Column { + spacing: 0 + // --- Artwork + hover overlay -------------------------------- + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + + Image { + anchors.fill: parent + source: card.artPath + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + // Hover scrim. + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: overlayOn ? 0.6 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + } + // Hover meta — genre + year, top-left. + Column { + x: 12 + y: 12 + spacing: 2 + opacity: overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + Text { + visible: card.genre !== "" + text: card.genre + height: 20 + color: "#ebffffff" + font.pixelSize: 13 + font.weight: theme.weightBold + verticalAlignment: Text.AlignVCenter + } + Text { + visible: card.year !== "" + text: card.year + height: 17 + color: "#ccffffff" + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + } + // Card-open + hover detector (declared before the action + // buttons so those win the pointer). + MouseArea { + id: artArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // POC-NOTE: the album page is out of scope — inert. + } + // Hover action buttons — favorite / play / more (INERT; + // POC-NOTE: playback + favorites land in later phases). + Row { + x: 0 + y: 120 + width: 200 + height: 44 + anchors.horizontalCenter: parent.horizontalCenter + spacing: 12 + opacity: overlayOn ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + Item { width: (parent.width - 44 - 2 * 36 - 2 * 12) / 2; height: 1 } + Rectangle { + width: 36 + height: 36 + radius: 18 + anchors.verticalCenter: parent.verticalCenter + color: favArea.containsMouse ? "#3dffffff" : "#24ffffff" + border.width: 1.5 + border.color: "#ccffffff" + QbzIcon { + name: "heart" + width: 16 + height: 16 + anchors.centerIn: parent + tintName: "primary" + } + MouseArea { id: favArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor } + } + Rectangle { + width: 44 + height: 44 + radius: 22 + color: playOvArea.containsMouse ? "#d6ffffff" : "#ffffff" + // Black glyph on the white disc (Slint tints #000) + // — the "black" baked variant. + QbzIcon { + name: "play-fill" + width: 18 + height: 18 + anchors.centerIn: parent + tintName: "black" + } + MouseArea { id: playOvArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor } + } + Rectangle { + width: 36 + height: 36 + radius: 18 + anchors.verticalCenter: parent.verticalCenter + color: moreArea.containsMouse ? "#3dffffff" : "#24ffffff" + border.width: 1.5 + border.color: "#ccffffff" + QbzIcon { + name: "ellipsis" + width: 16 + height: 16 + anchors.centerIn: parent + tintName: "primary" + } + MouseArea { id: moreArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor } + } + Item { width: (parent.width - 44 - 2 * 36 - 2 * 12) / 2; height: 1 } + } + // Award ribbon — content-width, capped at the card width. + Rectangle { + visible: card.ribbon !== "" + x: 0 + y: parent.height - height - 8 + height: 20 + width: Math.min(ribbonRow.width, 200) + color: card.ribbonKind === "press" ? "#d49511" : "#e0000000" + topRightRadius: 3 + bottomRightRadius: 3 + clip: true + Rectangle { + width: card.ribbonKind === "press" ? 0 : 3 + height: parent.height + color: card.ribbonKind === "qobuzissime" ? "#8b5cf6" : "#eab308" + } + Row { + id: ribbonRow + height: parent.height + leftPadding: 10 + rightPadding: 10 + width: ribbonText.implicitWidth + 20 + Text { + id: ribbonText + height: parent.height + text: card.ribbon + color: card.ribbonKind === "press" ? "#1f1407" : "#ffffff" + font.pixelSize: 9 + font.weight: theme.weightSemibold + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + Item { width: 1; height: 6 } + // --- Title / artist + quality badge -------------------------- + Row { + width: 200 + height: 40 + spacing: theme.spacingSm + Column { + width: parent.width - (qualityBadge.visible ? qualityBadge.width + theme.spacingSm : 0) + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + height: 20 + text: card.title + color: titleArea.containsMouse ? theme.accent : theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: titleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + } + Text { + width: parent.width + height: 18 + text: card.artist + color: card.artistId !== "" && artistArea.containsMouse + ? theme.textPrimary : theme.textMuted + font.pixelSize: theme.fontLink - 1 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + MouseArea { + id: artistArea + anchors.fill: parent + hoverEnabled: true + cursorShape: card.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + } + // Icon-only quality badge (QualityBadge.slint). + Item { + id: qualityBadge + visible: card.qualityTier !== "" + width: card.qualityTier === "hires" ? 42 : 30 + height: 30 + anchors.verticalCenter: parent.verticalCenter + Image { + visible: card.qualityTier === "hires" + source: "assets/hi-res.svg" + width: 42 + height: 28 + anchors.centerIn: parent + sourceSize: Qt.size(84, 56) + fillMode: Image.PreserveAspectFit + } + Rectangle { + visible: card.qualityTier === "cd" + width: 30 + height: 30 + radius: 3 + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + QbzIcon { + name: "cd" + width: 16 + height: 16 + anchors.centerIn: parent + tintName: "muted" + } + } + } + } + } + } + + // Horizontal album rail (Carousel.slint): header + clipped ListView, + // page chevrons (per-page step like the Slint paging). + component AlbumRail: Column { + property var sectionData: ({}) + width: parent ? parent.width : 0 + spacing: 12 + + readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232)) + readonly property int step: perPage * 232 + readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width) + + RailHeader { + title: sectionData.title + leftEnabled: rail.contentX > 1 + rightEnabled: rail.contentX < maxScroll - 1 + onPageLeft: rail.contentX = Math.min(0, rail.contentX - step) + onPageRight: rail.contentX = Math.min(maxScroll, rail.contentX + step) + } + ListView { + id: rail + width: parent.width + height: 246 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: sectionData.items + delegate: AlbumCard { card: modelData } + } + } + + // Slim ranked card (SlimCard.slint): 60px row — rank / 44px thumb / + // title+subtitle. + component SlimCard: Rectangle { + property var card: ({}) + height: 60 + radius: theme.radiusSm + color: slArea.containsMouse ? theme.surfaceHover : "transparent" + + Row { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 12 + Text { + visible: card.rank !== "" + width: 20 + anchors.verticalCenter: parent.verticalCenter + text: card.rank + color: theme.textMuted + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + } + Rectangle { + width: 44 + height: 44 + radius: 4 + anchors.verticalCenter: parent.verticalCenter + color: theme.surfaceElevated + clip: true + Image { + anchors.fill: parent + source: card.artPath + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + Column { + width: parent.width - (card.rank !== "" ? 20 : 0) - 44 - 2 * 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: card.title + color: theme.textPrimary + font.pixelSize: theme.fontLink + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + width: parent.width + text: card.artist + color: theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + MouseArea { + id: slArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + // POC-NOTE: album page out of scope — inert. + } + } + + // Popular slim grid (SlimCarousel.slint): 4x3 pages of 12, capped 24. + component SlimGrid: Column { + property var sectionData: ({}) + width: parent ? parent.width : 0 + spacing: 12 + + readonly property int perPage: 12 + readonly property int total: Math.min(sectionData.items.length, 2 * perPage) + readonly property int pageCount: Math.max(1, Math.ceil(total / perPage)) + readonly property real maxScroll: (pageCount - 1) * grid.width + + RailHeader { + title: sectionData.title + leftEnabled: grid.contentX > 1 + rightEnabled: grid.contentX < maxScroll - 1 + onPageLeft: grid.contentX = Math.max(0, grid.contentX - grid.width) + onPageRight: grid.contentX = Math.min(maxScroll, grid.contentX + grid.width) + } + Item { + width: parent.width + height: 3 * 60 + 2 * 8 + clip: true + Flickable { + id: grid + width: parent.width + height: parent.height + contentWidth: pageCount * width + contentHeight: height + boundsBehavior: Flickable.StopAtBounds + readonly property real cellWidth: (width - 3 * 8) / 4 + + Repeater { + model: sectionData.items + delegate: SlimCard { + readonly property int slot: index % perPage + readonly property int pageIdx: Math.floor(index / perPage) + visible: index < total + width: grid.cellWidth + height: 60 + x: pageIdx * grid.width + (slot % 4) * (grid.cellWidth + 8) + y: Math.floor(slot / 4) * (60 + 8) + card: modelData + } + } + } + } + } + + // Playlist card (PlaylistCard.slint): 200x246 — 200px cover + category + // subtag + title. + component PlaylistCard: Rectangle { + property var card: ({}) + width: 200 + height: 246 + color: "transparent" + + Column { + spacing: 0 + Rectangle { + width: 200 + height: 200 + radius: theme.radiusSm + color: theme.surfaceElevated + clip: true + Image { + anchors.fill: parent + source: card.artPath + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // POC-NOTE: playlist page out of scope — inert. + } + } + Item { width: 1; height: 8 } + Text { + visible: card.category !== "" + text: card.category + color: theme.accent + font.pixelSize: 10 + font.weight: theme.weightSemibold + font.letterSpacing: 0.5 + elide: Text.ElideRight + width: 200 + } + Item { width: 1; height: 4 } + Text { + width: 200 + text: card.title + color: theme.textPrimary + font.pixelSize: theme.fontBody - 2 + font.weight: theme.weightMedium + elide: Text.ElideRight + } + } + } + + // Artist card (ArtistCard.slint): 160x220 — 120px circular art + name. + component ArtistCard: Rectangle { + property var card: ({}) + width: 160 + height: 220 + radius: 12 + color: "transparent" + + Column { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + Item { width: 1; height: 24 } + Rectangle { + width: 120 + height: 120 + radius: 60 + anchors.horizontalCenter: parent.horizontalCenter + color: theme.surfaceElevated + clip: true + Image { + anchors.fill: parent + source: card.artPath + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + // POC-NOTE: artist page out of scope — inert. + } + } + Text { + width: 128 + anchors.horizontalCenter: parent.horizontalCenter + text: card.title + color: theme.textPrimary + font.pixelSize: 14 + font.weight: theme.weightMedium + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + } + + // Recently-played placeholder (HomeView.slint RecentPlaceholder). + component RecentPlaceholder: Column { + property string title: "" + property string hint: "" + width: parent ? parent.width : 0 + spacing: 10 Text { - text: "QBZ Qt POC — phase 3 fills this view" + text: title + color: theme.textPrimary + font.pixelSize: 18 + font.weight: theme.weightSemibold + } + Text { + text: hint color: theme.textMuted - font.pixelSize: theme.fontLegal + font.pixelSize: 13 + } + } + + // Shimmer block + skeleton rows (HomeSkeleton.slint). + component Shimmer: Rectangle { + property bool phase: false + color: theme.surfaceElevated + radius: theme.radiusSm + opacity: phase ? 0.85 : 0.4 + Behavior on opacity { NumberAnimation { duration: 900; easing.type: Easing.InOutQuad } } + } + component SkeletonRow: Column { + id: skelRow + property bool phase: false + width: parent ? parent.width : 0 + spacing: 12 + Shimmer { phase: skelRow.phase; width: 180; height: 22 } + Row { + spacing: 32 + Repeater { + model: 5 + delegate: Column { + spacing: 8 + Shimmer { phase: skelRow.phase; width: 200; height: 200 } + Shimmer { phase: skelRow.phase; width: 140; height: 14 } + Shimmer { phase: skelRow.phase; width: 90; height: 12 } + } + } + } + } + + // ============================ offline gate ============================ + // (OfflinePlaceholder.slint replica; mounted INSTEAD of the view.) + Column { + visible: QbzBridge.offline + anchors.centerIn: parent + spacing: 0 + QbzIcon { + name: "cloud-off" + width: 56 + height: 56 + anchors.horizontalCenter: parent.horizontalCenter + tintName: "muted" + } + Item { width: 1; height: 18 } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: QbzBridge.tr("You're offline") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + } + Item { width: 1; height: 8 } + Text { + width: 420 + text: QbzBridge.offlineMode === 2 + ? QbzBridge.tr("Offline mode is enabled. Disable it in Settings to use Qobuz.") + : QbzBridge.tr("No internet connection. Your local library and downloads keep working.") + color: theme.textSecondary + font.pixelSize: theme.fontBody + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + // POC-NOTE: induced-offline "Open Settings" button omitted — no + // Settings view exists in the POC yet. + } + + // ============================ the view ================================ + Column { + anchors.fill: parent + spacing: 0 + visible: !QbzBridge.offline + + // --- Toolbar (fixed 56px) --------------------------------------- + Item { + width: parent.width + height: 56 + + Row { + x: 32 + y: 25 - height / 2 + height: tabPill.height + spacing: 16 + + Rectangle { + id: tabPill + width: tabRow.width + height: tabRow.height + color: theme.surfaceElevated + radius: 6 + Row { + id: tabRow + padding: 3 + spacing: 4 + Repeater { + model: [ + { "id": "home", "label": QbzBridge.tr("Home") }, + { "id": "editorPicks", "label": QbzBridge.tr("Editor's Picks") }, + { "id": "forYou", "label": QbzBridge.tr("For You") }, + { "id": "recommendations", "label": QbzBridge.tr("Recommendations") }, + ] + delegate: Rectangle { + required property var modelData + property bool active: root.activeTab === modelData.id + width: tabText.implicitWidth + 28 + height: tabText.implicitHeight + 12 + radius: 4 + color: active ? theme.surfaceMain + : tabArea.containsMouse ? theme.surfaceHover : "transparent" + Text { + id: tabText + anchors.centerIn: parent + text: modelData.label + color: parent.active ? theme.textPrimary : theme.textMuted + font.pixelSize: 13 + font.weight: theme.weightMedium + } + MouseArea { + id: tabArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activeTab = modelData.id + } + } + } + } + } + } + + // Genre filter + configurator gear — INERT stubs (POC-NOTE). + Row { + x: parent.width - width - 32 + y: 25 - height / 2 + height: 32 + spacing: 6 + Rectangle { + width: genreRow.width + height: 32 + radius: 6 + color: genreArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + Row { + id: genreRow + height: parent.height + leftPadding: 12 + rightPadding: 14 + spacing: 7 + QbzIcon { + name: "list-filter" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: "secondary" + } + Text { + text: QbzBridge.tr("Filter by genre") + color: theme.textSecondary + font.pixelSize: 13 + anchors.verticalCenter: parent.verticalCenter + } + } + MouseArea { + id: genreArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + } + Rectangle { + width: 32 + height: 32 + radius: 4 + color: gearArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + name: "home-gear" + width: 20 + height: 20 + anchors.centerIn: parent + tintName: gearArea.containsMouse ? "primary" : "muted" + } + MouseArea { + id: gearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + } + } + } + Rectangle { + width: parent.width + height: 1 + color: theme.borderSubtle + } + + // --- Scrollable sections ----------------------------------------- + Flickable { + id: homeFlick + width: parent.width + height: parent.height - 57 + clip: true + contentWidth: width + contentHeight: homeContent.implicitHeight + boundsBehavior: Flickable.StopAtBounds + Column { + id: homeContent + width: parent.width + padding: 32 + spacing: 40 + + // ===== Home tab ========================================== + Column { + id: homeTab + visible: root.activeTab === "home" + width: parent.width - 64 + spacing: 40 + + // Loading skeleton (HomeSkeleton: two shimmer rows). + Column { + visible: QbzBridge.homeLoading && root.sections.length === 0 + width: parent.width + spacing: 40 + property bool phase: false + Timer { + interval: 900 + running: parent.visible + repeat: true + onTriggered: parent.phase = !parent.phase + } + SkeletonRow { phase: parent.phase } + SkeletonRow { phase: parent.phase } + } + + // Error state with retry (the Slint Home has no error + // arm; the box mirrors the FavoritesView Retry button). + Rectangle { + visible: QbzBridge.homeError !== "" + width: parent.width + height: errorColumn.height + 28 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Column { + id: errorColumn + anchors.centerIn: parent + spacing: 10 + Text { + text: QbzBridge.homeError + color: theme.textSecondary + font.pixelSize: 13 + } + Rectangle { + width: retryText.implicitWidth + 28 + height: 32 + radius: 6 + color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Text { + id: retryText + anchors.centerIn: parent + text: QbzBridge.tr("Retry") + color: theme.textPrimary + font.pixelSize: theme.fontLegal + } + MouseArea { + id: retryArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzBridge.reloadHome() + } + } + } + } + + // Section rails. + Repeater { + model: root.sections + delegate: Loader { + required property var modelData + width: homeTab.width + sourceComponent: modelData.kind === "album" ? albumRailComp + : modelData.kind === "playlist" ? playlistRailComp + : modelData.kind === "slim" ? slimGridComp + : modelData.kind === "artists" ? artistRailComp + : recentComp + property var sectionData: modelData + + Component { + id: albumRailComp + AlbumRail { sectionData: parent.sectionData } + } + Component { + id: playlistRailComp + Column { + property var sectionData: parent.sectionData + width: parent ? parent.width : 0 + spacing: 12 + Text { + text: sectionData.title + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + ListView { + width: parent.width + height: 246 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: sectionData.items + delegate: PlaylistCard { card: modelData } + } + } + } + Component { + id: slimGridComp + SlimGrid { sectionData: parent.sectionData } + } + Component { + id: artistRailComp + Column { + property var sectionData: parent.sectionData + width: parent ? parent.width : 0 + spacing: 12 + Text { + text: sectionData.title + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + ListView { + width: parent.width + height: 220 + orientation: ListView.Horizontal + spacing: 32 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: sectionData.items + delegate: ArtistCard { card: modelData } + } + } + } + Component { + id: recentComp + RecentPlaceholder { + property var sectionData: parent.sectionData + title: sectionData.title + hint: sectionData.hint + } + } + } + } + } + + // ===== Placeholder tabs (POC-NOTE) ======================== + Column { + visible: root.activeTab !== "home" + width: parent.width - 64 + spacing: 10 + Text { + text: root.activeTab === "editorPicks" ? QbzBridge.tr("Editor's Picks") + : root.activeTab === "forYou" ? QbzBridge.tr("For You") + : QbzBridge.tr("Recommendations") + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + } + Text { + text: "QBZ Qt POC — this Discover tab lands in a later phase" + color: theme.textMuted + font.pixelSize: 13 + } + } + } } } } diff --git a/crates/qbz-qt/qml/QbzIcon.qml b/crates/qbz-qt/qml/QbzIcon.qml index b12dcf0d3..44a7203ac 100644 --- a/crates/qbz-qt/qml/QbzIcon.qml +++ b/crates/qbz-qt/qml/QbzIcon.qml @@ -17,7 +17,8 @@ Image { id: root // Icon file name, e.g. "compass" (".svg" appended). property string name: "" - // One of: "primary" | "secondary" | "muted" | "accent" | "warning". + // One of: "primary" | "secondary" | "muted" | "accent" | "warning" | + // "black" (for glyphs on white discs). property string tintName: "secondary" source: "assets/icons/" + tintName + "/" + name + ".svg" diff --git a/crates/qbz-qt/qml/assets/hi-res.svg b/crates/qbz-qt/qml/assets/hi-res.svg new file mode 100644 index 000000000..908574bb8 --- /dev/null +++ b/crates/qbz-qt/qml/assets/hi-res.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/cd.svg b/crates/qbz-qt/qml/assets/icons/accent/cd.svg new file mode 100644 index 000000000..9c0a53843 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/accent/heart.svg b/crates/qbz-qt/qml/assets/icons/accent/heart.svg new file mode 100644 index 000000000..64887d653 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg b/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg b/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg new file mode 100644 index 000000000..c8e0931ac --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cast.svg b/crates/qbz-qt/qml/assets/icons/black/cast.svg new file mode 100644 index 000000000..3cd4aed5a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cast.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cd.svg b/crates/qbz-qt/qml/assets/icons/black/cd.svg new file mode 100644 index 000000000..d34d68005 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg new file mode 100644 index 000000000..a40fd3add --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg new file mode 100644 index 000000000..dc8f533f2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg b/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg new file mode 100644 index 000000000..d7de71e8a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg b/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg new file mode 100644 index 000000000..7577b4682 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/cloud-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/compass.svg b/crates/qbz-qt/qml/assets/icons/black/compass.svg new file mode 100644 index 000000000..71b9c879c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/compass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg b/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg new file mode 100644 index 000000000..156bcca96 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg b/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg new file mode 100644 index 000000000..2e8cfc19e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/heart.svg b/crates/qbz-qt/qml/assets/icons/black/heart.svg new file mode 100644 index 000000000..75887618a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/home-gear.svg b/crates/qbz-qt/qml/assets/icons/black/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/info.svg b/crates/qbz-qt/qml/assets/icons/black/info.svg new file mode 100644 index 000000000..d9d7e6c35 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg b/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg new file mode 100644 index 000000000..574511f69 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/layout-grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-filter.svg b/crates/qbz-qt/qml/assets/icons/black/list-filter.svg new file mode 100644 index 000000000..a47dc734f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-music.svg b/crates/qbz-qt/qml/assets/icons/black/list-music.svg new file mode 100644 index 000000000..8e8023f3e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-music.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg b/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg new file mode 100644 index 000000000..776e11c07 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/list-ordered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/log-out.svg b/crates/qbz-qt/qml/assets/icons/black/log-out.svg new file mode 100644 index 000000000..3598e00c2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/log-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/menu.svg b/crates/qbz-qt/qml/assets/icons/black/menu.svg new file mode 100644 index 000000000..5d9ae7588 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg b/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg new file mode 100644 index 000000000..98442325e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/mic-vocal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg b/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg new file mode 100644 index 000000000..f50878830 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/monitor-speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg b/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg new file mode 100644 index 000000000..9282eb0ab --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/music-library-2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/black/music.svg b/crates/qbz-qt/qml/assets/icons/black/music.svg new file mode 100644 index 000000000..d70ea33cb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/music.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/panel-left.svg b/crates/qbz-qt/qml/assets/icons/black/panel-left.svg new file mode 100644 index 000000000..a0c2e0266 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/panel-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/pause.svg b/crates/qbz-qt/qml/assets/icons/black/pause.svg new file mode 100644 index 000000000..300f7563f --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/play-fill.svg b/crates/qbz-qt/qml/assets/icons/black/play-fill.svg new file mode 100644 index 000000000..1b2effd14 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/play-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/plus.svg b/crates/qbz-qt/qml/assets/icons/black/plus.svg new file mode 100644 index 000000000..585404be2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg b/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg new file mode 100644 index 000000000..88efd84e4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/qbz-symbolic.svg @@ -0,0 +1,32 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg b/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg new file mode 100644 index 000000000..ac7e8282d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/repeat-1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/repeat.svg b/crates/qbz-qt/qml/assets/icons/black/repeat.svg new file mode 100644 index 000000000..5f50e3d6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/search.svg b/crates/qbz-qt/qml/assets/icons/black/search.svg new file mode 100644 index 000000000..7f681bac7 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/settings-2.svg b/crates/qbz-qt/qml/assets/icons/black/settings-2.svg new file mode 100644 index 000000000..e78787e1b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/settings-2.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/shuffle.svg b/crates/qbz-qt/qml/assets/icons/black/shuffle.svg new file mode 100644 index 000000000..04e34e13b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/skip-back.svg b/crates/qbz-qt/qml/assets/icons/black/skip-back.svg new file mode 100644 index 000000000..dce186b54 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/skip-back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg b/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg new file mode 100644 index 000000000..1945b93ca --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/skip-forward.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/user.svg b/crates/qbz-qt/qml/assets/icons/black/user.svg new file mode 100644 index 000000000..98a86d799 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/volume-2.svg b/crates/qbz-qt/qml/assets/icons/black/volume-2.svg new file mode 100644 index 000000000..56014277b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/volume-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/volume-x.svg b/crates/qbz-qt/qml/assets/icons/black/volume-x.svg new file mode 100644 index 000000000..48faaff4d --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/volume-x.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/x.svg b/crates/qbz-qt/qml/assets/icons/black/x.svg new file mode 100644 index 000000000..c9b0cfd95 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/cd.svg b/crates/qbz-qt/qml/assets/icons/muted/cd.svg new file mode 100644 index 000000000..a81e9ee8b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/muted/heart.svg b/crates/qbz-qt/qml/assets/icons/muted/heart.svg new file mode 100644 index 000000000..98bdede6b --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg b/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg b/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg new file mode 100644 index 000000000..2be8e7027 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/cd.svg b/crates/qbz-qt/qml/assets/icons/primary/cd.svg new file mode 100644 index 000000000..e1cf434fb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/primary/heart.svg b/crates/qbz-qt/qml/assets/icons/primary/heart.svg new file mode 100644 index 000000000..ae810a263 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg b/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/list-filter.svg b/crates/qbz-qt/qml/assets/icons/primary/list-filter.svg new file mode 100644 index 000000000..71c7b0f4e --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/cd.svg b/crates/qbz-qt/qml/assets/icons/secondary/cd.svg new file mode 100644 index 000000000..d34d68005 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/secondary/heart.svg b/crates/qbz-qt/qml/assets/icons/secondary/heart.svg new file mode 100644 index 000000000..25dc8627a --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg b/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg b/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg new file mode 100644 index 000000000..f25935a21 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/cd.svg b/crates/qbz-qt/qml/assets/icons/warning/cd.svg new file mode 100644 index 000000000..48a5fb8ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-qt/qml/assets/icons/warning/heart.svg b/crates/qbz-qt/qml/assets/icons/warning/heart.svg new file mode 100644 index 000000000..01cab4ac0 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg b/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg new file mode 100644 index 000000000..60fc53aeb --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/home-gear.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg b/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg new file mode 100644 index 000000000..9927b34f4 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/list-filter.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-qt/src/artwork_qt.rs b/crates/qbz-qt/src/artwork_qt.rs new file mode 100644 index 000000000..644f181f7 --- /dev/null +++ b/crates/qbz-qt/src/artwork_qt.rs @@ -0,0 +1,111 @@ +//! Artwork pipeline for the Home view — a minimal port of the fetch side +//! of `crates/qbz/src/artwork.rs` onto `qbz_cache::ImageCacheService`. +//! +//! Flow: when home sections are built, each card whose `art_url` is +//! already on disk gets `artPath = file://` synchronously; +//! the misses are downloaded on tokio tasks (semaphore cap 16, shared +//! reqwest client, 10s timeout — the artwork.rs constants), stored via +//! `ImageCacheService::store`, and the section list is then republished. +//! +//! POC-NOTE: on download completion the WHOLE section list is republished +//! (a `homeSectionsJson` swap) — per-row QAbstractListModel updates are +//! the documented follow-up. No RGBA decode in Rust: QML `Image` decodes +//! `file://` asynchronously and natively. Cache eviction (`evict`, +//! 200 MB cap in the Slint app) is not wired — POC scale. + +use std::sync::{Arc, Mutex, OnceLock}; + +use qbz_cache::ImageCacheService; +use tokio::sync::Semaphore; + +use crate::home_qt::HomeSection; + +/// Matches artwork.rs `MAX_CONCURRENT`. +const MAX_CONCURRENT: usize = 16; +/// Matches artwork.rs `HTTP_TIMEOUT` (a hung request on a captive portal +/// must not pin a semaphore permit indefinitely). +const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Shared client (artwork.rs: "instead of reqwest::get building a fresh +/// client + TLS state per image"). +static HTTP: std::sync::LazyLock = std::sync::LazyLock::new(|| { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .expect("reqwest client") +}); + +/// The process-wide image cache (`~/.cache/qbz/images`, shared with the +/// Slint/Tauri apps). `Connection` is not Sync, hence the Mutex; `get` / +/// `store` are short blocking calls, fine under it. +static CACHE: OnceLock> = OnceLock::new(); + +fn cache() -> Option<&'static Mutex> { + if CACHE.get().is_none() { + match ImageCacheService::new() { + Ok(service) => { + let _ = CACHE.set(Mutex::new(service)); + } + Err(e) => { + log::error!("[qbz-qt] image cache open failed: {e}"); + return None; + } + } + } + CACHE.get() +} + +/// Fill `art_path` from the disk cache for every card that already has one. +/// Returns the urls still missing (deduped, non-empty). +pub fn attach_cached(sections: &mut [HomeSection]) -> Vec { + let mut missing: Vec = Vec::new(); + for section in sections.iter_mut() { + for card in section.items.iter_mut() { + if card.art_url.is_empty() { + continue; + } + let hit = cache().and_then(|c| c.lock().unwrap().get(&card.art_url)); + if let Some(path) = hit { + card.art_path = format!("file://{}", path.display()); + } else if !missing.contains(&card.art_url) { + missing.push(card.art_url.clone()); + } + } + } + missing +} + +/// Download the missing urls concurrently (semaphore-capped), storing each +/// into the image cache. Returns when all downloads settled (failures are +/// logged and skipped — the cards keep their placeholder). +pub async fn download_missing(urls: Vec) { + let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT)); + let mut handles = Vec::with_capacity(urls.len()); + for url in urls { + let sem = Arc::clone(&semaphore); + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore open"); + let bytes = match HTTP.get(&url).send().await { + Ok(resp) => match resp.bytes().await { + Ok(b) => b, + Err(e) => { + log::warn!("[qbz-qt] artwork read failed ({url}): {e}"); + return; + } + }, + Err(e) => { + log::warn!("[qbz-qt] artwork download failed ({url}): {e}"); + return; + } + }; + if let Some(cache) = cache() { + if let Err(e) = cache.lock().unwrap().store(&url, &bytes) { + log::warn!("[qbz-qt] artwork store failed ({url}): {e}"); + } + } + })); + } + for handle in handles { + let _ = handle.await; + } +} diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index a9818c91e..140a246a7 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -93,6 +93,16 @@ pub mod qbz_bridge { // POC: empty until phase 4 (QML shows the empty states). #[qproperty(QList_QVariant, queue_model)] + // --- Discover > Home --------------------------------------------- + #[qproperty(bool, home_loading)] + #[qproperty(QString, home_error)] + // POC-NOTE: the brief's QVariantList-of-QVariantMap shape is not + // expressible in cxx-qt-lib 0.7.3 (no QVariantValue impls for + // QMap/QList — a QVariant can't hold a nested map/list), so the + // sections travel as ONE JSON document; QML JSON.parse()s it into + // the exact same {id, title, kind, items:[{...}]} object graph. + #[qproperty(QString, home_sections_json)] + type QbzBridge = super::QbzBridgeRust; /// Called once from Main.qml's Component.onCompleted: registers the @@ -163,6 +173,11 @@ pub mod qbz_bridge { fn toggle_shuffle(self: Pin<&mut QbzBridge>); #[qinvokable] fn cycle_repeat(self: Pin<&mut QbzBridge>); + + /// Home view retry button / manual refresh: refetch the discover + /// index + rails and republish `homeSectionsJson`. + #[qinvokable] + fn reload_home(self: Pin<&mut QbzBridge>); } impl cxx_qt::Threading for QbzBridge {} @@ -214,6 +229,9 @@ pub struct QbzBridgeRust { np_quality_tier: QString, np_quality_label: QString, queue_model: QListQVariant, + home_loading: bool, + home_error: QString, + home_sections_json: QString, } impl Default for QbzBridgeRust { @@ -255,6 +273,9 @@ impl Default for QbzBridgeRust { np_repeat_mode: 0, np_quality_label: QString::default(), queue_model: QListQVariant::default(), + home_loading: false, + home_error: QString::default(), + home_sections_json: QString::from("[]"), } } } @@ -349,4 +370,8 @@ impl qbz_bridge::QbzBridge { pub fn cycle_repeat(self: Pin<&mut Self>) { crate::now_playing::cycle_repeat(); } + + pub fn reload_home(self: Pin<&mut Self>) { + crate::reload_home(); + } } diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs new file mode 100644 index 000000000..a8725f982 --- /dev/null +++ b/crates/qbz-qt/src/home_qt.rs @@ -0,0 +1,592 @@ +//! Discover > Home data layer — a Slint-free port of the PURE mapping +//! logic of `crates/qbz/src/home.rs` (+ the personalized rails of +//! `crates/qbz/src/foryou.rs` that need only a live session). +//! +//! Produces plain data rows; serialization to the bridge happens as one +//! JSON document (`homeSectionsJson`) — see the POC-NOTE in +//! `publish_sections` for why not QVariantList-of-QVariantMap. +//! +//! POC-NOTEs (skipped vs the Slint controller): +//! - Genre filter: OUT OF SCOPE — `get_discover_index(None)` (the +//! no-filter default). +//! - Artist/album blacklist filtering (T8): the blacklist store is not +//! opened in this POC (phase-1 skip), so no rows are dropped. +//! - Recently-played rails (local play-history store): OUT OF SCOPE — the +//! view renders the Slint empty-data placeholders instead. +//! - Reco-scored taste ordering of favorite albums (reco store skipped): +//! favorites render in plain favorite order. +//! - Pinned / Most Played Albums / Qobuz Mixes rows: stores/static tiles +//! out of scope. +//! - Discover prefs (per-tab section visibility/reorder): OUT OF SCOPE — +//! a fixed superset order renders (see `SECTION_ORDER`). +//! - Editor's Picks / For You / Recommendations tabs: placeholder pages +//! (the editor section set is not built). + +use std::sync::Arc; + +use qbz_app::shell::AppRuntime; +use qbz_core::FrontendAdapter; +use qbz_models::{ + Album, AlbumAward, Artist, DiscoverAlbum, DiscoverAudioInfo, DiscoverContainer, + DiscoverPlaylist, +}; +use serde::Serialize; + +/// One card row in a section rail (superset of home.rs's CardData / +/// SlimData / PlaylistCardData / foryou ArtistSlim — unused fields stay +/// empty per section kind). +#[derive(Clone, Default, Serialize)] +pub struct HomeCard { + pub id: String, + pub title: String, + pub artist: String, + #[serde(rename = "artistId")] + pub artist_id: String, + pub genre: String, + pub year: String, + #[serde(rename = "qualityTier")] + pub quality_tier: String, + #[serde(rename = "qualityLabel")] + pub quality_label: String, + #[serde(rename = "qualityDetail")] + pub quality_detail: String, + pub ribbon: String, + #[serde(rename = "ribbonKind")] + pub ribbon_kind: String, + /// Slim rows ("Popular albums"): the 1-based rank ("" = none). + pub rank: String, + /// Playlist rows: the UPPERCASE first-tag category subtag. + pub category: String, + #[serde(rename = "artUrl")] + pub art_url: String, + /// `file://` when already on disk ("" = needs download). + #[serde(rename = "artPath")] + pub art_path: String, +} + +#[derive(Clone, Serialize)] +pub struct HomeSection { + pub id: String, + pub title: String, + /// "album" | "slim" | "playlist" | "artists" | "recentPlaceholder". + pub kind: String, + /// Placeholder hint for recentPlaceholder sections. + pub hint: String, + pub items: Vec, +} + +/// Push one album-carousel section from its discover container (drops +/// empty/missing containers, like home.rs `push_section`). +fn push_albums( + out: &mut Vec, + id: &str, + title: String, + container: Option>, +) { + let Some(container) = container else { + return; + }; + if container.data.items.is_empty() { + return; + } + out.push(HomeSection { + id: id.to_string(), + title, + kind: "album".to_string(), + hint: String::new(), + items: container.data.items.into_iter().map(map_album).collect(), + }); +} + +/// The fixed render order (prefs out of scope — see module docs). Mirrors +/// the default-enabled Home order for the shared sections, then the +/// optional rails whose data is fetched anyway. +fn build_sections( + containers: qbz_models::DiscoverContainers, + favorite_albums: Vec, + release_watch: Vec, + top_artists: Vec, +) -> Vec { + let mut out: Vec = Vec::new(); + + push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), containers.new_releases); + push_albums(&mut out, "pressAwards", qbz_i18n::t("Press Accolades"), containers.press_awards); + + // Qobuz Playlists row (single-cover cards, first-tag category subtag). + let playlists: Vec = containers + .playlists + .map(|c| c.data.items) + .unwrap_or_default() + .into_iter() + .take(40) + .map(map_playlist) + .collect(); + if !playlists.is_empty() { + out.push(HomeSection { + id: "qobuzPlaylists".to_string(), + title: qbz_i18n::t("Qobuz Playlists"), + kind: "playlist".to_string(), + hint: String::new(), + items: playlists, + }); + } + + // Recently-played rails are OUT OF SCOPE (local store) — the Slint + // empty-data placeholders render instead (always present on Home). + out.push(HomeSection { + id: "recentlyPlayedAlbums".to_string(), + title: qbz_i18n::t("Recently Played Albums"), + kind: "recentPlaceholder".to_string(), + hint: qbz_i18n::t("Albums you play will appear here."), + items: Vec::new(), + }); + out.push(HomeSection { + id: "continueListening".to_string(), + title: qbz_i18n::t("Recently Played Tracks"), + kind: "recentPlaceholder".to_string(), + hint: qbz_i18n::t("Tracks you play will appear here."), + items: Vec::new(), + }); + + push_albums( + &mut out, + "idealDiscography", + qbz_i18n::t("Ideal Discography"), + containers.ideal_discography, + ); + + // Most Streamed on Home renders as the "Popular albums" slim grid, + // capped at 24 (two carousel pages of 12), 1-based ranked. + let popular: Vec = containers + .most_streamed + .map(|container| container.data.items) + .unwrap_or_default() + .into_iter() + .take(24) + .enumerate() + .map(|(index, album)| map_slim(index, album)) + .collect(); + if !popular.is_empty() { + out.push(HomeSection { + id: "mostStreamed".to_string(), + title: qbz_i18n::t("Popular albums"), + kind: "slim".to_string(), + hint: String::new(), + items: popular, + }); + } + + push_albums( + &mut out, + "editorPicks", + qbz_i18n::t("Albums of the Week"), + containers.album_of_the_week, + ); + push_albums(&mut out, "qobuzissimes", qbz_i18n::t("Qobuzissimes"), containers.qobuzissims); + + // Personalized rails (self-hide while empty, 1:1 Slint). + if !favorite_albums.is_empty() { + out.push(HomeSection { + id: "favoriteAlbums".to_string(), + title: qbz_i18n::t("Library Albums"), + kind: "album".to_string(), + hint: String::new(), + items: favorite_albums, + }); + } + if !release_watch.is_empty() { + out.push(HomeSection { + id: "releaseWatch".to_string(), + title: qbz_i18n::t("Release Watch"), + kind: "album".to_string(), + hint: String::new(), + items: release_watch, + }); + } + if !top_artists.is_empty() { + out.push(HomeSection { + id: "topArtists".to_string(), + title: qbz_i18n::t("Your Top Artists"), + kind: "artists".to_string(), + hint: String::new(), + items: top_artists, + }); + } + + out +} + +/// Fetch the discover index (UNFILTERED — genre filter out of scope) and +/// the personalized rails concurrently (mirrors home.rs's `join!`), then +/// map everything into plain rows. +pub async fn load_home(runtime: &Arc>) -> Result, String> +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let (response, favorite_albums, release_watch, top_artists) = tokio::join!( + runtime.core().get_discover_index(None), + favorite_album_cards(runtime), + fetch_release_watch(runtime), + top_artist_cards(runtime), + ); + let response = response.map_err(|e| e.to_string())?; + log::info!( + "[qbz-qt] discover index fetched; building home sections (fav={}, rw={}, artists={})", + favorite_albums.len(), + release_watch.len(), + top_artists.len(), + ); + Ok(build_sections( + response.containers, + favorite_albums, + release_watch, + top_artists, + )) +} + +// --------------------------------------------------------------------------- +// Pure mapping — ported from home.rs (Discover* inputs). +// --------------------------------------------------------------------------- + +pub(crate) fn map_album(album: DiscoverAlbum) -> HomeCard { + let artist = album + .artists + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(); + let artist_id = album + .artists + .first() + .map(|a| a.id.to_string()) + .unwrap_or_default(); + let genre = album.genre.map(|g| g.name).unwrap_or_default(); + let year = qbz_text_utils::dates::release_label( + album + .dates + .as_ref() + .and_then(|d| d.original.as_ref().or(d.download.as_ref()).or(d.stream.as_ref())) + .map(|s| s.as_str()), + ); + 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(); + HomeCard { + id: album.id, + title: album.title, + artist, + artist_id, + genre, + year, + quality_tier: quality_tier(album.audio_info.as_ref()).to_string(), + quality_label: quality_label(album.audio_info.as_ref()), + quality_detail: quality_detail(album.audio_info.as_ref()), + ribbon, + ribbon_kind, + art_url: artwork_url, + ..HomeCard::default() + } +} + +/// Map a Discover playlist into a single-cover card (1:1 home.rs +/// `map_playlist`): landscape `rectangle` preferred, first square cover +/// fallback; first tag -> UPPERCASE category subtag. +fn map_playlist(p: DiscoverPlaylist) -> HomeCard { + let art_url = p + .image + .rectangle + .or_else(|| p.image.covers.and_then(|c| c.into_iter().next())) + .unwrap_or_default(); + let category = p + .tags + .as_ref() + .and_then(|t| t.first()) + .map(|t| t.name.to_uppercase()) + .unwrap_or_default(); + HomeCard { + id: p.id.to_string(), + title: p.name, + category, + art_url, + ..HomeCard::default() + } +} + +/// A compact ranked slim item (home.rs `map_slim`). +fn map_slim(index: usize, album: DiscoverAlbum) -> HomeCard { + let subtitle = album + .artists + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(); + let art_url = album + .image + .thumbnail + .or(album.image.small) + .or(album.image.large) + .unwrap_or_default(); + HomeCard { + id: album.id, + title: album.title, + artist: subtitle, + rank: (index + 1).to_string(), + art_url, + ..HomeCard::default() + } +} + +/// Pick the single award ribbon, mirroring `pickAlbumRibbon`: award id 151 +/// = Album of the Week, 88 = Qobuzissime, otherwise the last award becomes +/// a generic "press" ribbon. +pub(crate) 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()) +} + +/// Quality tier for the icon-only badge: 24-bit and up is Hi-Res, anything +/// else with audio info is CD-quality, no audio info hides the badge. +pub(crate) fn quality_tier(audio: Option<&DiscoverAudioInfo>) -> &'static str { + let Some(audio) = audio else { + return ""; + }; + match audio.maximum_bit_depth { + Some(depth) if depth >= 24 => "hires", + _ => "cd", + } +} + +/// Exact-quality label for the badge hover tooltip +/// (`{tier}: {depth}-bit / {rate} kHz`). +pub(crate) fn quality_label(audio: Option<&DiscoverAudioInfo>) -> String { + let Some(audio) = audio else { + return String::new(); + }; + let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24); + let tier = if hi_res { "Hi-Res" } else { "CD" }; + let depth = audio + .maximum_bit_depth + .unwrap_or(if hi_res { 24 } else { 16 }); + let rate = audio + .maximum_sampling_rate + .unwrap_or(if hi_res { 96.0 } else { 44.1 }); + format!("{tier}: {depth}-bit / {} kHz", format_rate(rate)) +} + +/// Bare exact-quality detail ("24-bit / 96 kHz", no tier prefix). +pub(crate) fn quality_detail(audio: Option<&DiscoverAudioInfo>) -> String { + let Some(audio) = audio else { + return String::new(); + }; + let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24); + let depth = audio + .maximum_bit_depth + .unwrap_or(if hi_res { 24 } else { 16 }); + let rate = audio + .maximum_sampling_rate + .unwrap_or(if hi_res { 96.0 } else { 44.1 }); + format!("{depth}-bit / {} kHz", format_rate(rate)) +} + +/// Format a kHz sample rate without a trailing `.0` (96.0 -> "96"). +pub(crate) fn format_rate(rate: f64) -> String { + if (rate.fract()).abs() < f64::EPSILON { + format!("{}", rate as i64) + } else { + format!("{rate}") + } +} + +// --------------------------------------------------------------------------- +// Personalized rails — ported from foryou.rs (live-session only; the reco / +// blacklist stores are skipped, see module docs). +// --------------------------------------------------------------------------- + +/// Flat-Album card mapping (foryou.rs `map_album`). +fn map_flat_album(album: Album) -> HomeCard { + let year = album + .release_date_original + .as_deref() + .and_then(|s| s.get(..4).map(|y| y.to_string())) + .unwrap_or_default(); + let quality_tier = match album.maximum_bit_depth { + Some(d) if d >= 24 => "hires", + Some(_) => "cd", + None => "", + } + .to_string(); + let quality_label = match (album.maximum_bit_depth, album.maximum_sampling_rate) { + (Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr), + _ => String::new(), + }; + HomeCard { + id: album.id, + title: album.title, + artist: album.artist.name, + artist_id: album.artist.id.to_string(), + year, + quality_tier, + quality_label, + art_url: album.image.best().cloned().unwrap_or_default(), + ..HomeCard::default() + } +} + +/// "Library Albums" — favorite albums, capped at 18, in favorite order +/// (reco taste-ordering skipped — cold reco store = no reorder upstream). +async fn favorite_album_cards(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match runtime.core().get_favorites("albums", 100, 0).await { + Ok(value) => qbz_models::lenient::parse_items_array::(&value, "albums", "home fav album") + .into_iter() + .take(18) + .map(map_flat_album) + .collect(), + Err(e) => { + log::warn!("[qbz-qt] favorite albums fetch failed: {e}"); + Vec::new() + } + } +} + +/// "Release Watch" — `/release/watch` artists, capped 18 (blacklist filter +/// skipped — the store is not open in this POC). +async fn fetch_release_watch(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match runtime.core().get_release_watch("artists", 18, 0).await { + Ok(page) => page.items.into_iter().map(map_flat_album).collect(), + Err(e) => { + log::warn!("[qbz-qt] release watch fetch failed: {e}"); + Vec::new() + } + } +} + +/// "Your Top Artists" — favorite artists, capped 18. +async fn top_artist_cards(runtime: &Arc>) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match runtime.core().get_favorites("artists", 50, 0).await { + Ok(value) => qbz_models::lenient::parse_items_array::(&value, "artists", "home artist") + .into_iter() + .take(18) + .map(|a| HomeCard { + id: a.id.to_string(), + title: a.name, + art_url: a + .image + .and_then(|img| img.best().cloned()) + .unwrap_or_default(), + ..HomeCard::default() + }) + .collect(), + Err(e) => { + log::warn!("[qbz-qt] favorite artists fetch failed: {e}"); + Vec::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn audio(depth: Option, rate: Option) -> DiscoverAudioInfo { + DiscoverAudioInfo { + maximum_sampling_rate: rate, + maximum_bit_depth: depth, + maximum_channel_count: None, + } + } + + fn award(id: Option<&str>, name: &str) -> AlbumAward { + AlbumAward { + id: id.map(|s| s.to_string()), + name: name.to_string(), + awarded_at: None, + } + } + + #[test] + fn quality_tier_mapping() { + assert_eq!(quality_tier(None), ""); + assert_eq!(quality_tier(Some(&audio(Some(24), Some(96.0)))), "hires"); + assert_eq!(quality_tier(Some(&audio(Some(16), Some(44.1)))), "cd"); + // Bit depth present but < 24 is CD even without a rate. + assert_eq!(quality_tier(Some(&audio(Some(16), None))), "cd"); + // No depth at all still maps to CD (audio info exists). + assert_eq!(quality_tier(Some(&audio(None, None))), "cd"); + } + + #[test] + fn quality_label_and_detail() { + assert_eq!(quality_label(None), ""); + assert_eq!(quality_detail(None), ""); + assert_eq!( + quality_label(Some(&audio(Some(24), Some(96.0)))), + "Hi-Res: 24-bit / 96 kHz" + ); + assert_eq!( + quality_label(Some(&audio(Some(16), Some(44.1)))), + "CD: 16-bit / 44.1 kHz" + ); + assert_eq!( + quality_detail(Some(&audio(Some(24), Some(192.0)))), + "24-bit / 192 kHz" + ); + // Missing fields fall back per tier (hi-res: 24/96, cd: 16/44.1). + assert_eq!(quality_label(Some(&audio(Some(24), None))), "Hi-Res: 24-bit / 96 kHz"); + assert_eq!(quality_detail(Some(&audio(None, Some(44.1)))), "16-bit / 44.1 kHz"); + } + + #[test] + fn format_rate_trailing_zero() { + assert_eq!(format_rate(96.0), "96"); + assert_eq!(format_rate(44.1), "44.1"); + } + + #[test] + fn pick_ribbon_precedence() { + assert_eq!(pick_ribbon(None), (String::new(), String::new())); + assert_eq!(pick_ribbon(Some(&[])), (String::new(), String::new())); + // 151 wins over everything. + assert_eq!( + pick_ribbon(Some(&[award(Some("88"), "Qobuzissime"), award(Some("151"), "AOTW")])), + ("AOTW".to_string(), "albumOfTheWeek".to_string()) + ); + // 88 wins over a generic press award. + assert_eq!( + pick_ribbon(Some(&[award(Some("1"), "Press X"), award(Some("88"), "Qobuzissime")])), + ("Qobuzissime".to_string(), "qobuzissime".to_string()) + ); + // Otherwise the LAST award is the press ribbon. + assert_eq!( + pick_ribbon(Some(&[award(Some("1"), "First"), award(Some("2"), "Last")])), + ("Last".to_string(), "press".to_string()) + ); + // Awards without ids are skipped by the id lookups. + assert_eq!( + pick_ribbon(Some(&[award(None, "NoId")])), + ("NoId".to_string(), "press".to_string()) + ); + } +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index bcc0b5ea2..b356c2f49 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -7,7 +7,9 @@ //! async work; results hop back to Qt through the bridge's `CxxQtThread`. mod auth_qt; +mod artwork_qt; mod bridge; +mod home_qt; mod nav_qt; mod now_playing; mod offline_fwd; @@ -43,6 +45,10 @@ static QT_THREAD: OnceLock> = OnceLock::new(); /// (mirrors `login_task` in crates/qbz/src/main.rs). static LOGIN_TASK: Mutex>> = Mutex::new(None); +/// Whether the Home view has been loaded this session (the auto-load fires +/// once per shell entry; `reloadHome()` bypasses the flag). +static HOME_LOADED: Mutex = Mutex::new(false); + pub(crate) fn register_qt_thread(thread: CxxQtThread) { if QT_THREAD.set(thread).is_err() { log::warn!("[qbz-qt] Qt thread handle already registered"); @@ -108,6 +114,10 @@ fn enter_shell(session: auth_qt::SessionInfo) { // history and push the current now-playing model onto the bar. nav_qt::record("home"); now_playing::publish_current(); + // Phase 3: fetch Discover > Home (online sessions only — the offline + // engine gates Qobuz calls anyway, and the view shows the offline + // placeholder instead). + load_home_once(); ui(move |mut b| { b.as_mut().set_session_user_name(QString::from(session.display_name.as_str())); b.as_mut().set_session_subscription(QString::from(session.subscription.as_str())); @@ -203,17 +213,93 @@ pub(crate) fn do_logout() { if let Err(e) = auth_qt::logout(&runtime).await { log::error!("[qbz-qt] logout failed: {e}"); } + // A later login must re-fetch Home (new user, fresh rails). + *HOME_LOADED.lock().unwrap() = false; ui(|mut b| { - b.as_mut().set_session_user_name(QString::from("")); + b.as_mut().set_session_user_name(QString::from("")); b.as_mut().set_session_subscription(QString::from("")); b.as_mut().set_login_error(QString::from("")); b.as_mut().set_restore_error(QString::from("")); b.as_mut().set_login_phase(0); + b.as_mut().set_home_sections_json(QString::from("[]")); + b.as_mut().set_home_error(QString::from("")); + b.as_mut().set_home_loading(false); b.as_mut().set_screen(QString::from("login")); }); }); } +// ============================ Discover > Home ============================== + +/// Fire the Home auto-load once per shell entry. Gated on the offline +/// engine: offline sessions show the OfflinePlaceholder instead of +/// fetching (the Qobuz gate would refuse the calls anyway). +fn load_home_once() { + if *HOME_LOADED.lock().unwrap() { + return; + } + if offline_fwd::engine().status().is_offline() { + log::info!("[qbz-qt] home load skipped (offline session)"); + return; + } + *HOME_LOADED.lock().unwrap() = true; + reload_home(); +} + +/// `reloadHome()` invokable / auto-load worker: fetch + publish + artwork. +pub(crate) fn reload_home() { + if offline_fwd::engine().status().is_offline() { + return; + } + ui(|mut b| { + b.as_mut().set_home_loading(true); + b.as_mut().set_home_error(QString::from("")); + }); + let runtime = app(); + spawn(async move { + match home_qt::load_home(&runtime).await { + Ok(mut sections) => { + // Artwork: disk hits attach synchronously; misses download + // in the background and trigger ONE republish (POC-NOTE in + // artwork_qt.rs — per-row model updates are the follow-up). + let missing = artwork_qt::attach_cached(&mut sections); + let count: usize = sections.iter().map(|s| s.items.len()).sum(); + publish_home_sections(§ions); + log::info!( + "[qbz-qt] home published: {} sections, {} cards, {} artwork misses", + sections.len(), + count, + missing.len(), + ); + if !missing.is_empty() { + spawn(async move { + artwork_qt::download_missing(missing).await; + let mut sections = sections; + let _ = artwork_qt::attach_cached(&mut sections); + publish_home_sections(§ions); + log::info!("[qbz-qt] home republished after artwork downloads"); + }); + } + ui(|mut b| b.as_mut().set_home_loading(false)); + } + Err(e) => { + log::warn!("[qbz-qt] home load failed: {e}"); + ui(move |mut b| { + b.as_mut().set_home_error(QString::from(e.as_str())); + b.as_mut().set_home_loading(false); + }); + } + } + }); +} + +fn publish_home_sections(sections: &[home_qt::HomeSection]) { + let json = serde_json::to_string(sections).unwrap_or_else(|_| "[]".to_string()); + ui(move |mut b| { + b.as_mut().set_home_sections_json(QString::from(json.as_str())); + }); +} + fn main() { qbz_log::install("info"); qbz_i18n::set_language(qbz_i18n::resolve_auto());