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