From dc2496e5c044689d49e020205c4b66956ca1fb07 Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Sun, 16 Aug 2026 18:55:51 -0600
Subject: [PATCH] feat(qt): award pages, reachable from the album sidebar and
from each other
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine
line, follow heart, "Acclaimed Releases" with its count and See All, the
preview grid and the "Other awards" rail) and the paginated listing with
its client-side search. Routes "award" and "awardalbums", the label pair's
shape — one controller for both, since they share the hero, the follow
state and the identity.
The three rules from the Tauri review are in the module header because they
are the ones a re-implementation gets wrong: the grid is ALWAYS
/award/getAlbums (/award/page is user-scoped and may omit releases, so it
feeds the hero and nothing else); the favorite split is plural-read,
singular-write, because the backend derives `award_ids` from the write
kind; and getAlbums has no has_more, its `total` is a hint, so the only
sound predicate is `total > loaded`.
The album sidebar's AWARDS cards navigate now — with an id directly, and by
NAME through the resolver when Qobuz omits it, which it does. They were
also reading `album.awards` when the field lives on `album.header`, so the
block had rendered EMPTY for every album since it was written: an empty
Repeater draws nothing and reports nothing, and the sidebar's own gate
counted the same empty list. The LABEL card beside it had a "no label view
yet" note and no handler, years after the label view landed.
Beyond the reference: an all-awards dropdown pinned to the page's top-right,
so any award is reachable without first finding an album that won it. It
costs nothing new — the /award/explore crawl already existed to resolve
names to ids.
The award icon was lucide's MICROPHONE under the wrong filename. Search had
routed around it with `crown` and left a note; kiosk was missed and drew a
mic for "Most popular". The asset is the laurel the other two frontends use,
and kiosk now asks for the crown it wanted.
cc
---
crates/qbz-qt/build.rs | 13 +-
crates/qbz-qt/qml/Main.qml | 7 +
.../qbz-qt/qml/assets/icons/accent/award.svg | 6 +-
.../qbz-qt/qml/assets/icons/black/award.svg | 6 +-
.../qml/assets/icons/favorite/award.svg | 6 +-
.../qbz-qt/qml/assets/icons/muted/award.svg | 6 +-
.../qbz-qt/qml/assets/icons/primary/award.svg | 6 +-
.../qml/assets/icons/secondary/award.svg | 6 +-
.../qbz-qt/qml/assets/icons/warning/award.svg | 6 +-
crates/qbz-qt/qml/kiosk/KioskSearch.qml | 9 +-
crates/qbz-qt/qml/shell/ContentRouter.qml | 11 +
crates/qbz-qt/qml/views/AlbumView.qml | 31 +-
crates/qbz-qt/qml/views/AwardAlbumsView.qml | 214 +++++
crates/qbz-qt/qml/views/AwardView.qml | 446 +++++++++
crates/qbz-qt/src/album_qt.rs | 10 +-
crates/qbz-qt/src/award_qt.rs | 881 ++++++++++++++++++
crates/qbz-qt/src/home_bridge.rs | 65 ++
crates/qbz-qt/src/main.rs | 3 +
18 files changed, 1691 insertions(+), 41 deletions(-)
create mode 100644 crates/qbz-qt/qml/views/AwardAlbumsView.qml
create mode 100644 crates/qbz-qt/qml/views/AwardView.qml
create mode 100644 crates/qbz-qt/src/award_qt.rs
diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs
index 98aaffca6..aede2ba57 100644
--- a/crates/qbz-qt/build.rs
+++ b/crates/qbz-qt/build.rs
@@ -359,7 +359,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
- rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs", "src/purchases_bridge.rs"],
+ rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/shader_scene_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/scene_bridge.rs", "src/musician_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs", "src/about_bridge.rs", "src/purchases_bridge.rs", "src/offline_manager_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
@@ -660,6 +660,17 @@ fn main() {
// screen, not one row. Both are invisible to cargo check and to
// both audit scripts, and `show_purchases` defaults OFF, so
// neither would be noticed by anyone who never turns it on.
+ // Offline Cache Manager (route "offlinemanager") — reached
+ // only from Settings > Offline. A route view missing from this
+ // array is absent from the qrc and the router mounts NOTHING:
+ // a blank pane, invisible to cargo check and to both audits.
+ "qml/views/OfflineManagerView.qml",
+ // Awards (routes "award" and "awardalbums"). Both are route
+ // views: absent from the qrc, the router mounts NOTHING and
+ // the pane is blank — invisible to cargo check and to both
+ // audits.
+ "qml/views/AwardView.qml",
+ "qml/views/AwardAlbumsView.qml",
"qml/views/PurchasesView.qml",
"qml/views/PurchaseAlbumView.qml",
"qml/views/purchases/PurchaseAlbumsCollection.qml",
diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml
index 35749c0c3..3290d9e37 100644
--- a/crates/qbz-qt/qml/Main.qml
+++ b/crates/qbz-qt/qml/Main.qml
@@ -348,6 +348,13 @@ ApplicationWindow {
// side. The wizard is the loudest case after QbzBlacklist, because its
// first step ends on "Checking your audio stack…" forever.
QbzDacWizard.boot()
+ // Offline Cache Manager. Nothing to seed — the view is unmounted until
+ // the Settings row opens it — but without this line the manager's very
+ // first publish is dropped and the view sits on its spinner forever:
+ // `managerLoading` DEFAULTS to true precisely so the empty state does
+ // not flash, which turns a missing boot() into a permanent spinner
+ // with nothing logged on either side.
+ QbzOffline.boot()
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted
// like every other domain singleton, after QbzSession: `boot()` is what
// registers the Qt-thread hop, and without it `mini_bridge::ui()` is a
diff --git a/crates/qbz-qt/qml/assets/icons/accent/award.svg b/crates/qbz-qt/qml/assets/icons/accent/award.svg
index b11d2f3e1..66894269a 100644
--- a/crates/qbz-qt/qml/assets/icons/accent/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/accent/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/award.svg b/crates/qbz-qt/qml/assets/icons/black/award.svg
index 9e7c41dcd..ccb55cbd6 100644
--- a/crates/qbz-qt/qml/assets/icons/black/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/black/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/award.svg b/crates/qbz-qt/qml/assets/icons/favorite/award.svg
index dae447718..2c677f108 100644
--- a/crates/qbz-qt/qml/assets/icons/favorite/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/favorite/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/award.svg b/crates/qbz-qt/qml/assets/icons/muted/award.svg
index d106224d8..8ae415431 100644
--- a/crates/qbz-qt/qml/assets/icons/muted/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/muted/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/award.svg b/crates/qbz-qt/qml/assets/icons/primary/award.svg
index 028c3011a..b1b2e6679 100644
--- a/crates/qbz-qt/qml/assets/icons/primary/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/primary/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/award.svg b/crates/qbz-qt/qml/assets/icons/secondary/award.svg
index 4aad5f06b..4d5a10eec 100644
--- a/crates/qbz-qt/qml/assets/icons/secondary/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/secondary/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/award.svg b/crates/qbz-qt/qml/assets/icons/warning/award.svg
index f4aef7bb9..9e243f52b 100644
--- a/crates/qbz-qt/qml/assets/icons/warning/award.svg
+++ b/crates/qbz-qt/qml/assets/icons/warning/award.svg
@@ -1,5 +1 @@
-
+
diff --git a/crates/qbz-qt/qml/kiosk/KioskSearch.qml b/crates/qbz-qt/qml/kiosk/KioskSearch.qml
index 8e04c5660..43f682bbd 100644
--- a/crates/qbz-qt/qml/kiosk/KioskSearch.qml
+++ b/crates/qbz-qt/qml/kiosk/KioskSearch.qml
@@ -395,7 +395,14 @@ Rectangle {
QbzIcon {
id: crownGlyph
- name: "award"
+ // "crown", like its desktop twin
+ // (SearchView.qml:546). This said "award" and drew
+ // a MICROPHONE — the asset under that name was
+ // lucide's mic. SearchView routed around it years
+ // ago and left a note; kiosk was missed. The asset
+ // is a laurel now, which is right for an AWARD and
+ // still wrong for "Most popular".
+ name: "crown"
width: 18
height: 18
tintName: "warning"
diff --git a/crates/qbz-qt/qml/shell/ContentRouter.qml b/crates/qbz-qt/qml/shell/ContentRouter.qml
index 0c05ed8ab..631c73e5b 100644
--- a/crates/qbz-qt/qml/shell/ContentRouter.qml
+++ b/crates/qbz-qt/qml/shell/ContentRouter.qml
@@ -136,6 +136,17 @@ Item {
// Settings > Blacklist > Manage. Not reachable from the
// sidebar — blacklist_qt::open_manager records the route.
: QbzShell.currentView === "blacklist" ? "../views/BlacklistManagerView.qml"
+ // Settings > Offline > Manage offline cache > Open manager. Same
+ // shape as the blacklist arm above: not a sidebar section, and
+ // offline_manager_qt::open() is what records the route.
+ : QbzShell.currentView === "offlinemanager" ? "../views/OfflineManagerView.qml"
+ // Awards — reached from the album sidebar's laurel, an "Other
+ // awards" card or the landing page's all-awards dropdown. Both
+ // routes are recorded by award_qt (open_award / open_albums); the
+ // id lives in the controller, not in the route, exactly like
+ // "label" / "labelreleases" next to them.
+ : QbzShell.currentView === "award" ? "../views/AwardView.qml"
+ : QbzShell.currentView === "awardalbums" ? "../views/AwardAlbumsView.qml"
// Sidebar > Playlists ⋯ > Manage playlists. Not a sidebar
// section — playlist_manager_qt::navigate() records the route.
// The route is a TWO-FILE contract (nav_qt.rs:9-19): the caller
diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml
index 67047f1f0..567aaf0eb 100644
--- a/crates/qbz-qt/qml/views/AlbumView.qml
+++ b/crates/qbz-qt/qml/views/AlbumView.qml
@@ -46,7 +46,14 @@ Rectangle {
readonly property var album: JSON.parse(QbzAlbum.albumJson)
readonly property var header: album.header || ({})
readonly property var tracks: album.tracks || []
- readonly property var awards: album.awards || []
+ // `header.awards`, NOT `album.awards`. It is a field of AlbumHeader
+ // (src/album_qt.rs:171), not of AlbumViewData — read one level too high it
+ // is `undefined`, `|| []` swallows that, and the AWARDS block simply never
+ // renders. Which is what it did, for every album, since the block was
+ // written: an empty Repeater draws nothing and reports nothing, and
+ // `hasSidebar` counted the same empty list, so an album whose ONLY sidebar
+ // content was its awards showed no sidebar at all.
+ readonly property var awards: header.awards || []
property var coverMap: ({})
// Client-side track search (AlbumActions.search equivalent).
property string trackQuery: ""
@@ -1162,7 +1169,13 @@ Rectangle {
iconName: "disc"
gradA: "#6366f1"
gradB: "#8b5cf6"
- // POC-NOTE: no label view yet.
+ // The label page HAS existed for a long time — the
+ // header chip above (:691) has been opening it all
+ // along. This card carried a "no label view yet"
+ // note and no handler, so the sidebar's LABEL entry
+ // looked clickable (it hovers) and did nothing.
+ onClicked: if ((header.labelId || "") !== "")
+ QbzHome.openLabel(header.labelId)
}
}
Column {
@@ -1178,7 +1191,19 @@ Rectangle {
iconName: "award"
gradA: "#b45309"
gradB: "#eab308"
- // POC-NOTE: no award view yet.
+ // `modelData` is the (id, name) pair
+ // album_qt publishes. Qobuz omits the id on
+ // some /album/get entries, so with one we open
+ // directly and without one we hand the NAME to
+ // the resolver — 1:1 with the reference's
+ // "open" / "resolve-open" split
+ // (AlbumPageView.slint:1049-1055).
+ onClicked: {
+ if ((modelData[0] || "") !== "")
+ QbzHome.openAward(modelData[0], modelData[1] || "")
+ else if ((modelData[1] || "") !== "")
+ QbzHome.openAwardByName(modelData[1])
+ }
}
}
}
diff --git a/crates/qbz-qt/qml/views/AwardAlbumsView.qml b/crates/qbz-qt/qml/views/AwardAlbumsView.qml
new file mode 100644
index 000000000..0b4e1c5f3
--- /dev/null
+++ b/crates/qbz-qt/qml/views/AwardAlbumsView.qml
@@ -0,0 +1,214 @@
+// Award "See all" listing — QML port of award/AwardAlbumsView.slint. Route
+// "awardalbums".
+//
+// A search box over the LOADED set, the award name as the page title, and an
+// infinite-scroll grid that asks for the next `/award/getAlbums` page as the
+// tail comes into view. Same four exclusive body branches as the landing
+// page, in the reference's order: loading · error+retry · no-results (a search
+// that matched nothing) · the grid.
+//
+// The search is CLIENT-SIDE over what has been paginated in — 1:1 with the
+// reference, which does not re-query for this box either. That is also why
+// load-more is suppressed while a query is active: appending pages the user
+// cannot see, to filter them out again, is work with no visible result.
+//
+// Every number, filter and page in the document comes from src/award_qt.rs;
+// this file renders and reports scroll, nothing else.
+
+import QtQuick
+import com.blitzfc.qbz
+import "../controls"
+import "../theme"
+
+Rectangle {
+ id: root
+
+ readonly property bool ambientOn: theme.ambientOn
+ color: root.ambientOn ? "transparent" : theme.surfaceMain
+ radius: 12
+
+ QbzTheme { id: theme }
+
+ readonly property var doc: {
+ try {
+ return JSON.parse(QbzHome.awardAlbumsJson)
+ } catch (e) {
+ return ({})
+ }
+ }
+ readonly property var albums: root.doc.albums || []
+ readonly property bool loading: QbzHome.awardAlbumsLoading === true
+ readonly property bool loadError: root.doc.loadError === true
+ readonly property string query: root.doc.searchQuery || ""
+
+ Flickable {
+ id: page
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: col.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+ clip: true
+
+ // Infinite scroll. Gated on the SAME four conditions the reference
+ // gates on — not loading, not already loading more, there IS more, and
+ // no active query — so a filtered view never pages in behind it.
+ onContentYChanged: {
+ if (contentHeight - (contentY + height) < 600
+ && root.doc.hasMore === true
+ && root.doc.loadingMore !== true
+ && !root.loading
+ && root.query === "")
+ QbzHome.awardAlbumsLoadMore()
+ }
+
+ Column {
+ id: col
+ width: page.width - 64
+ x: 32
+ topPadding: 11
+ bottomPadding: 100
+ spacing: 0
+
+ Item { width: 1; height: 22 }
+
+ // ---- Search ------------------------------------------------------
+ QbzLineEdit {
+ width: 320
+ searchMode: true
+ placeholder: QbzSession.tr("Search albums & tracks…", QbzSession.trRev)
+ text: root.query
+ onEdited: function (v) { QbzHome.awardAlbumsSearch(v) }
+ }
+
+ Item { width: 1; height: 20 }
+
+ // ---- Title -------------------------------------------------------
+ Text {
+ width: parent.width
+ text: root.doc.name || ""
+ color: theme.textPrimary
+ font.pixelSize: theme.fontSection
+ font.weight: theme.weightBold
+ elide: Text.ElideRight
+ }
+ Item { width: 1; height: 4 }
+ Row {
+ spacing: 10
+ Text {
+ text: QbzSession.tr("Acclaimed Releases", QbzSession.trRev)
+ color: theme.textSecondary
+ font.pixelSize: theme.fontBody
+ }
+ Text {
+ visible: (root.doc.total || 0) > 0
+ text: root.doc.total || 0
+ color: theme.textMuted
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ }
+ }
+
+ Item { width: 1; height: 16 }
+
+ // ---- Body: loading / error+retry / no-results / grid ------------
+ Item {
+ visible: root.loading
+ width: parent.width
+ height: visible ? 280 : 0
+ Column {
+ anchors.centerIn: parent
+ spacing: 18
+ QbzSpinner {
+ anchors.horizontalCenter: parent.horizontalCenter
+ size: 36
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Loading…", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 13
+ }
+ }
+ }
+
+ Item {
+ visible: !root.loading && root.loadError
+ width: parent.width
+ height: visible ? 240 : 0
+ Column {
+ anchors.centerIn: parent
+ spacing: 16
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Failed to load Library", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 14
+ }
+ SettingsButton {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Retry", QbzSession.trRev)
+ onClicked: QbzHome.awardRetry()
+ }
+ }
+ }
+
+ // A search that matched nothing is NOT the same as an award with
+ // no releases, and the reference keeps them as two strings.
+ Item {
+ visible: !root.loading && !root.loadError
+ && root.albums.length === 0 && root.query !== ""
+ width: parent.width
+ height: visible ? 200 : 0
+ Text {
+ anchors.centerIn: parent
+ text: QbzSession.tr("No results found.", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 14
+ }
+ }
+ Item {
+ visible: !root.loading && !root.loadError
+ && root.albums.length === 0 && root.query === ""
+ width: parent.width
+ height: visible ? 200 : 0
+ Text {
+ anchors.centerIn: parent
+ text: QbzSession.tr("No award-winning releases yet.", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 14
+ }
+ }
+
+ AlbumCollection {
+ visible: !root.loading && !root.loadError && root.albums.length > 0
+ width: parent.width
+ collectionKey: root.doc.id || ""
+ albums: root.albums
+ viewMode: "grid"
+ isGrouped: false
+ cardWidth: 200
+ cardHeight: 266
+ cardGap: 24
+ }
+
+ // Tail spinner for the next page.
+ Item {
+ visible: root.doc.loadingMore === true
+ width: parent.width
+ height: visible ? 72 : 0
+ QbzSpinner {
+ anchors.centerIn: parent
+ size: 28
+ }
+ }
+ }
+
+ QbzScrollBar {
+ target: page
+ anchors.right: parent.right
+ anchors.rightMargin: 4
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ }
+ }
+}
diff --git a/crates/qbz-qt/qml/views/AwardView.qml b/crates/qbz-qt/qml/views/AwardView.qml
new file mode 100644
index 000000000..57f4f2d72
--- /dev/null
+++ b/crates/qbz-qt/qml/views/AwardView.qml
@@ -0,0 +1,446 @@
+// Award landing page — QML port of award/AwardView.slint (itself a 1:1 port
+// of Tauri's AwardView.svelte). Route "award".
+//
+// A circular gold/laurel hero with the award name + magazine + a Follow
+// heart, a section header ("Acclaimed Releases" + count + "See all" when more
+// exist), a bounded PREVIEW grid, and an "Other awards" carousel.
+//
+// SCROLLING-PAGE CONVENTION (AlbumView / ArtistView / LabelView): one
+// Flickable whose Column pads 32 / 32 / top 11 / bottom 100. The .slint's
+// NavButtons row is dropped — back/forward live in the shell header — but its
+// 22px spacer is KEPT so the hero sits where every other detail page's does.
+//
+// Composition: the grid is views/AlbumCollection.qml (the Discover/Label
+// grid) and the heart is controls/QbzCircleAction.qml. The ONE local
+// component is the "Other awards" card — see AwardRailCard below for why the
+// shared ArtistCard could not be reused here.
+//
+// THE ONE ADDITION over the reference: the all-awards dropdown, pinned to the
+// page's top-right. The Slint can only reach an award by finding an album that
+// won it;
+// this lists every award the `/award/explore` crawl found, so you can jump
+// straight to any of them. The crawl already existed to resolve names to ids —
+// the control is new, the data is not.
+
+import QtQuick
+import com.blitzfc.qbz
+import "../controls"
+import "../theme"
+
+Rectangle {
+ id: root
+
+ readonly property bool ambientOn: theme.ambientOn
+ color: root.ambientOn ? "transparent" : theme.surfaceMain
+ radius: 12
+
+ QbzTheme { id: theme }
+
+ // One "Other awards" card. Local, and deliberately NOT cards/ArtistCard:
+ // that one calls `QbzArtist.openArtist(item.id)` INSIDE its own MouseArea
+ // and again from its menu (ArtistCard.qml:197, :294, :331) with no signal
+ // to override — handing it an award id would open an ARTIST page for a
+ // number that means something else entirely. The Slint could reuse its
+ // carousel because the Slint one emits `artist-clicked(id)` outward; this
+ // port's card swallows the click, so reuse here is not the same trade.
+ //
+ // It also drops the deviation the reference had to accept: an award with
+ // no image gets a LAUREL on gold, not the person glyph ArtistCard falls
+ // back to.
+ component AwardRailCard: Item {
+ id: railCard
+ property var entry: ({})
+ width: 160
+ height: 200
+
+ Column {
+ anchors.horizontalCenter: parent.horizontalCenter
+ spacing: 10
+ Rectangle {
+ width: 140
+ height: 140
+ radius: 70
+ clip: true
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: "#b45309" }
+ GradientStop { position: 1.0; color: "#eab308" }
+ }
+ QbzIcon {
+ anchors.centerIn: parent
+ visible: (railCard.entry.imageUrl || "") === ""
+ name: "award"
+ width: 56
+ height: 56
+ tintName: theme.accentGlyphTint
+ }
+ RoundedImage {
+ anchors.fill: parent
+ visible: (railCard.entry.imageUrl || "") !== ""
+ radius: 70
+ source: railCard.entry.imageUrl || ""
+ }
+ }
+ Text {
+ width: 140
+ horizontalAlignment: Text.AlignHCenter
+ text: railCard.entry.title || ""
+ color: railArea.containsMouse ? theme.accent : theme.textPrimary
+ font.pixelSize: theme.fontLegal
+ font.weight: theme.weightMedium
+ elide: Text.ElideRight
+ }
+ Text {
+ width: 140
+ visible: (railCard.entry.artist || "") !== ""
+ horizontalAlignment: Text.AlignHCenter
+ text: railCard.entry.artist || ""
+ color: theme.textMuted
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ }
+ MouseArea {
+ id: railArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzHome.openAward(railCard.entry.id || "",
+ railCard.entry.title || "")
+ }
+ }
+
+ readonly property var doc: {
+ try {
+ return JSON.parse(QbzHome.awardJson)
+ } catch (e) {
+ return ({})
+ }
+ }
+ readonly property var albums: root.doc.albums || []
+ readonly property var otherAwards: root.doc.otherAwards || []
+ readonly property var catalog: root.doc.catalog || []
+ readonly property bool loading: QbzHome.awardLoading === true
+ readonly property bool loadError: root.doc.loadError === true
+
+ Flickable {
+ id: page
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: col.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+ clip: true
+
+ Column {
+ id: col
+ width: page.width - 64
+ x: 32
+ topPadding: 11
+ bottomPadding: 100
+ spacing: 0
+
+ // The .slint's NavButtons row is not ported; its spacer is, so the
+ // hero lands at the same y as every other detail page's header.
+ Item { width: 1; height: 22 }
+
+ // ---- Hero -------------------------------------------------------
+ Row {
+ width: parent.width
+ spacing: 32
+
+ Rectangle {
+ width: 180
+ height: 180
+ radius: 90
+ clip: true
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: "#b45309" }
+ GradientStop { position: 1.0; color: "#eab308" }
+ }
+ QbzIcon {
+ anchors.centerIn: parent
+ visible: (root.doc.imageUrl || "") === ""
+ name: "award"
+ width: 84
+ height: 84
+ // The glyph sits on a saturated gold fill, which is the
+ // on-accent case the tint selector exists for.
+ tintName: theme.accentGlyphTint
+ }
+ RoundedImage {
+ anchors.fill: parent
+ visible: (root.doc.imageUrl || "") !== ""
+ radius: 90
+ source: root.doc.imageUrl || ""
+ }
+ }
+
+ Column {
+ width: parent.width - 180 - 32
+ spacing: 0
+
+ Item { width: 1; height: 8 }
+ Text {
+ text: QbzSession.tr("Award", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 10
+ font.weight: theme.weightSemibold
+ font.letterSpacing: 1.5
+ }
+ Item { width: 1; height: 6 }
+ Text {
+ width: parent.width
+ text: root.doc.name || ""
+ color: theme.textPrimary
+ font.pixelSize: theme.fontSection
+ font.weight: theme.weightBold
+ elide: Text.ElideRight
+ }
+ Item {
+ visible: (root.doc.magazineName || "") !== ""
+ width: 1
+ height: 10
+ }
+ Text {
+ visible: (root.doc.magazineName || "") !== ""
+ width: parent.width
+ // Server text — never translated.
+ text: root.doc.magazineName || ""
+ color: theme.textSecondary
+ font.pixelSize: theme.fontBody
+ elide: Text.ElideRight
+ }
+
+ Item { width: 1; height: 18 }
+
+ QbzCircleAction {
+ name: root.doc.isFollowing === true ? "heart-filled" : "heart"
+ active: root.doc.isFollowing === true
+ btnEnabled: root.doc.followToggling !== true
+ onClicked: QbzHome.awardFollow()
+ }
+ }
+ }
+
+ Item { width: 1; height: 32 }
+
+ // ---- Section header ---------------------------------------------
+ // Anchored, not a Row: the "See all" chip pins right and the
+ // title group pins left, so neither has to know the other's width
+ // (a `parent.children[0].width` subtraction is how a header ends
+ // up mis-measuring itself the first time a child is hidden).
+ Item {
+ width: parent.width
+ height: 26
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 10
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzSession.tr("Acclaimed Releases", QbzSession.trRev)
+ color: theme.textPrimary
+ font.pixelSize: theme.fontHeading
+ font.weight: theme.weightSemibold
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: (root.doc.total || 0) > 0
+ text: root.doc.total || 0
+ color: theme.textMuted
+ font.pixelSize: theme.fontBody
+ font.weight: theme.weightMedium
+ }
+ }
+
+ // "See all" — only when there is more than the preview holds.
+ Rectangle {
+ id: seeAll
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.doc.hasMore === true
+ width: seeAllText.implicitWidth + 16
+ height: 26
+ radius: 4
+ color: seeAllArea.containsMouse ? theme.surfaceHover : "transparent"
+ Text {
+ id: seeAllText
+ anchors.centerIn: parent
+ // "See All" is the msgid; the arrow is decoration
+ // appended OUTSIDE it (the reference is explicit).
+ text: QbzSession.tr("See All", QbzSession.trRev) + " →"
+ color: seeAllArea.containsMouse ? theme.textPrimary : theme.textSecondary
+ font.pixelSize: 14
+ font.weight: theme.weightMedium
+ }
+ MouseArea {
+ id: seeAllArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: QbzHome.awardOpenAlbums()
+ }
+ }
+ }
+
+ Item { width: 1; height: 16 }
+
+ // ---- Body: loading / error+retry / empty / grid (exclusive) -----
+ Item {
+ visible: root.loading
+ width: parent.width
+ height: visible ? 280 : 0
+ Column {
+ anchors.centerIn: parent
+ spacing: 18
+ QbzSpinner {
+ anchors.horizontalCenter: parent.horizontalCenter
+ size: 36
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Loading…", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 13
+ }
+ }
+ }
+
+ Item {
+ visible: !root.loading && root.loadError
+ width: parent.width
+ height: visible ? 240 : 0
+ Column {
+ anchors.centerIn: parent
+ spacing: 16
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Failed to load Library", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 14
+ }
+ SettingsButton {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: QbzSession.tr("Retry", QbzSession.trRev)
+ onClicked: QbzHome.awardRetry()
+ }
+ }
+ }
+
+ Item {
+ visible: !root.loading && !root.loadError && root.albums.length === 0
+ width: parent.width
+ height: visible ? 200 : 0
+ Text {
+ anchors.centerIn: parent
+ text: QbzSession.tr("No award-winning releases yet.", QbzSession.trRev)
+ color: theme.textMuted
+ font.pixelSize: 14
+ }
+ }
+
+ AlbumCollection {
+ visible: !root.loading && !root.loadError && root.albums.length > 0
+ width: parent.width
+ // Identity of the catalog on screen — navigating to ANOTHER
+ // award must not inherit the previous one's tail fade
+ // (AlbumCollection.collectionKey, the LabelReleasesView note).
+ collectionKey: root.doc.id || ""
+ albums: root.albums
+ viewMode: "grid"
+ isGrouped: false
+ cardWidth: 200
+ cardHeight: 266
+ cardGap: 24
+ }
+
+ // ---- Other awards -----------------------------------------------
+ Item {
+ visible: root.otherAwards.length > 0
+ width: 1
+ height: visible ? 32 : 0
+ }
+ Column {
+ visible: root.otherAwards.length > 0
+ width: parent.width
+ spacing: 12
+ Text {
+ text: QbzSession.tr("Other awards", QbzSession.trRev)
+ color: theme.textPrimary
+ font.pixelSize: theme.fontHeading
+ font.weight: theme.weightSemibold
+ }
+ Flickable {
+ width: parent.width
+ height: 210
+ contentWidth: rail.width
+ contentHeight: height
+ flickableDirection: Flickable.HorizontalFlick
+ boundsBehavior: Flickable.StopAtBounds
+ clip: true
+ Row {
+ id: rail
+ height: parent.height
+ spacing: 16
+ Repeater {
+ model: root.otherAwards
+ delegate: AwardRailCard {
+ required property var modelData
+ entry: modelData
+ }
+ }
+ }
+ }
+ }
+ }
+
+ QbzScrollBar {
+ target: page
+ anchors.right: parent.right
+ anchors.rightMargin: 4
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ }
+ }
+
+ // ---- The all-awards dropdown (this port only) ---------------------------
+ // Top-right of the PAGE (owner, 2026-08-16), so it sits outside the
+ // Flickable and stays put while the page scrolls — a jump-to-anywhere
+ // control that scrolls away with the hero is only reachable from the top.
+ //
+ // Hidden until the background crawl has BOTH a list and this award's place
+ // in it: a select whose current row is somebody else's award reads as "you
+ // are here" and is not.
+ //
+ // `searchable` because the catalog runs to hundreds of entries — the
+ // control's own rule is a filter box past ~8 options.
+ QbzSelect {
+ id: awardPicker
+ anchors.right: parent.right
+ anchors.top: parent.top
+ // Clear of the scrollbar's 14px gutter, on the page's 32px rhythm.
+ anchors.rightMargin: 32 + 14
+ anchors.topMargin: 11
+ z: 5
+ visible: root.catalog.length > 1
+ && (root.doc.catalogIndex !== undefined)
+ && root.doc.catalogIndex >= 0
+ menuWidth: 320
+ popupWidth: 320
+ searchable: true
+ options: {
+ var out = []
+ for (var i = 0; i < root.catalog.length; i++)
+ out.push(root.catalog[i].name)
+ return out
+ }
+ currentIndex: root.doc.catalogIndex || 0
+ onSelected: function (i) {
+ var entry = root.catalog[i]
+ if (entry && entry.id !== (root.doc.id || ""))
+ QbzHome.openAward(entry.id, entry.name || "")
+ }
+ }
+}
diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs
index 60c058b07..ec9872711 100644
--- a/crates/qbz-qt/src/album_qt.rs
+++ b/crates/qbz-qt/src/album_qt.rs
@@ -592,7 +592,9 @@ pub async fn load_album(runtime: &Arc>, album_id: &st
.map(qbz_text_utils::strip_html::strip_html)
.unwrap_or_default();
let description_short = truncate_words(&description, 360);
- let awards = album
+ // Annotated: `remember_awards` takes a slice, and without the explicit
+ // Vec here inference picks the unsized slice type off that call.
+ let awards: Vec<(String, String)> = album
.awards
.as_deref()
.unwrap_or_default()
@@ -600,6 +602,12 @@ pub async fn load_album(runtime: &Arc>, album_id: &st
.map(|a| (a.id.clone().unwrap_or_default(), a.name.clone()))
.filter(|(_, n)| !n.is_empty())
.collect();
+ // Feed the award name->id catalog. Qobuz omits the id on some
+ // `/album/get` entries, so the sidebar can only offer a NAME for those —
+ // harvesting the ones it DOES name here is what lets the resolver answer
+ // without an `/award/explore` crawl (award_qt.rs, "the name -> id
+ // resolver").
+ crate::award_qt::remember_awards(&awards);
let title = crate::album_qt::format_album_title(&album.title, album.version.as_deref());
// Pick the booklet goody: prefer the PDF format id (21), else the first
diff --git a/crates/qbz-qt/src/award_qt.rs b/crates/qbz-qt/src/award_qt.rs
new file mode 100644
index 000000000..8fedab802
--- /dev/null
+++ b/crates/qbz-qt/src/award_qt.rs
@@ -0,0 +1,881 @@
+//! Award landing page + "See all" listing — the Slint-free port of
+//! `crates/qbz/src/award.rs`.
+//!
+//! ONE module for BOTH routes, exactly like `label_qt.rs` (which is the
+//! precedent this whole feature follows): they share the hero, the follow
+//! state and the award identity, so splitting them would duplicate all three.
+//!
+//! Two documents on the bridge:
+//! `awardJson` -> `qml/views/AwardView.qml` (route "award")
+//! `awardAlbumsJson` -> `qml/views/AwardAlbumsView.qml` ("awardalbums")
+//!
+//! # The three rules carried from the Tauri review — do NOT re-litigate
+//!
+//! 1. **The grid is ALWAYS `/award/getAlbums`.** `/award/page` is user-scoped
+//! and may omit releases entirely; it is read for the hero (name, image,
+//! magazine) and nothing else. Never map `page.releases`.
+//! 2. **The favorite split is plural-read / singular-write.** Reads go through
+//! `get_favorites("awards")`, writes through `add/remove_favorite("award")`
+//! — the backend builds `format!("{}_ids")` from the write kind, so
+//! `award` + `s` is `award_ids`. Unifying the two silently breaks
+//! favouriting while reads keep working, which is the worst shape of bug.
+//! 3. **`get_award_albums` has no `has_more`.** Its `total` is a client HINT
+//! (`offset + len + has_more?1`), so the real predicate is
+//! `total > loaded` — used directly, which is also what fixes the
+//! exactly-one-page See-All edge.
+//!
+//! # The name -> id resolver
+//!
+//! Qobuz omits the award id on some `/album/get` entries, so the album
+//! sidebar can only offer a NAME. `resolve_award_id_by_name` answers from a
+//! process-local normalized cache — harvested by [`remember_awards`] as album
+//! documents load — and falls back to crawling `/award/explore`.
+//!
+//! # The award CATALOG (this port only)
+//!
+//! The explore crawl also feeds a full id+name catalog, published with the
+//! landing document so the view can offer a dropdown to ANY award without
+//! having to find an album that won it first. The Slint has no such control;
+//! it is the one addition in this port, and it costs nothing extra — the
+//! crawl already existed for the resolver.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Mutex;
+
+use cxx_qt_lib::QString;
+use serde::Serialize;
+use serde_json::Value;
+
+use crate::home_bridge;
+use crate::home_qt::HomeCard;
+
+/// Landing preview grid page size (Tauri AwardView PAGE_SIZE).
+const PREVIEW_PAGE_SIZE: u32 = 20;
+/// Full-listing page size (Tauri AwardAlbumsView PAGE_SIZE).
+const LIST_PAGE_SIZE: u32 = 50;
+/// "Other awards" carousel size (Tauri AwardView loadOtherAwards limit).
+const OTHER_AWARDS_LIMIT: u32 = 30;
+/// `/award/explore` crawl page size + page cap (Tauri awardCatalogStore).
+const EXPLORE_PAGE_SIZE: u32 = 100;
+const EXPLORE_MAX_PAGES: u32 = 40;
+
+// ===========================================================================
+// Name -> id catalog (process-local, harvested + explore-crawled)
+// ===========================================================================
+
+/// Normalized award name -> (id, display name). The display name is kept
+/// because the dropdown lists it; the key exists only to match.
+static CATALOG: Mutex