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>> = Mutex::new(None); + +/// Lowercase + trim + collapse whitespace. Both sides of every comparison come +/// from the SAME Qobuz field, so this is enough for self-matching (Tauri also +/// strips diacritics, which is moot when the strings share a source). +fn normalize(name: &str) -> String { + name.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// Harvest `(id, name)` pairs into the catalog. Called by `album_qt` for every +/// album document that carries awards, so the ids an album DOES name are known +/// before anyone asks about the ones it does not. +pub fn remember_awards(pairs: &[(String, String)]) { + if pairs.is_empty() { + return; + } + let Ok(mut guard) = CATALOG.lock() else { + return; + }; + let map = guard.get_or_insert_with(HashMap::new); + for (id, name) in pairs { + if id.trim().is_empty() || name.trim().is_empty() { + continue; + } + map.insert(normalize(name), (id.clone(), name.clone())); + } +} + +fn lookup_cached(name: &str) -> Option { + let key = normalize(name); + CATALOG + .lock() + .ok()? + .as_ref()? + .get(&key) + .map(|(id, _)| id.clone()) +} + +/// The catalog as a NAME-sorted list, for the dropdown. +fn catalog_entries() -> Vec<(String, String)> { + let Ok(guard) = CATALOG.lock() else { + return Vec::new(); + }; + let Some(map) = guard.as_ref() else { + return Vec::new(); + }; + // Keyed by NORMALIZED NAME, so one award reached under two spellings (the + // album's and explore's) is two entries with ONE id. Deduped by id here, + // keeping the longest spelling — the fuller name is the more useful row. + // + // This is also the belt for the blank row the owner saw in the dropdown + // (2026-08-16): whatever produced a title-less entry, an untitled row can + // no longer reach the list, and its id keeps whichever sibling HAS a name. + let mut best: HashMap = HashMap::new(); + for (id, name) in map.values() { + let name = name.trim(); + if id.trim().is_empty() || name.is_empty() { + // Loud on purpose. The owner saw a TITLE-LESS row in the dropdown + // once (2026-08-16) and it did not reproduce, so the mechanism is + // still unknown — and the drop above is exactly what would hide it + // next time. If this line ever prints, the id it names is the + // whole answer: cross it against `/award/explore` and the album + // that harvested it. + log::warn!( + "[qbz-qt] award catalog: dropping untitled entry (id={id:?}, name={name:?})" + ); + continue; + } + best.entry(id.clone()) + .and_modify(|cur| { + if name.len() > cur.len() { + *cur = name.to_string(); + } + }) + .or_insert_with(|| name.to_string()); + } + let mut out: Vec<(String, String)> = best.into_iter().collect(); + out.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + out +} + +fn value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +/// One `/award/explore` page: harvest it into the catalog and hand back the +/// raw items so a caller can also read magazine / image off them. +async fn explore_page(limit: u32, offset: u32) -> (Vec, bool) { + let runtime = crate::app(); + let resp = match runtime.core().get_award_explore(limit, offset).await { + Ok(v) => v, + Err(e) => { + log::warn!("[qbz-qt] award explore ({offset}) failed: {e}"); + return (Vec::new(), false); + } + }; + let items = resp + .get("items") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let pairs: Vec<(String, String)> = items + .iter() + .filter_map(|it| { + let id = it.get("id").map(value_to_string)?; + let name = it.get("name").and_then(|v| v.as_str())?.to_string(); + (!id.is_empty() && !name.is_empty()).then_some((id, name)) + }) + .collect(); + remember_awards(&pairs); + let has_more = resp + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + (items, has_more) +} + +/// Resolve an award NAME to its id: the cache first, then an `/award/explore` +/// crawl. `None` means the crawl finished without a match — the caller toasts +/// rather than opening a page that cannot load. +pub async fn resolve_award_id_by_name(name: &str) -> Option { + if name.trim().is_empty() { + return None; + } + if let Some(id) = lookup_cached(name) { + return Some(id); + } + let target = normalize(name); + let mut offset = 0u32; + let mut seen: HashSet = HashSet::new(); + for _ in 0..EXPLORE_MAX_PAGES { + let (items, has_more) = explore_page(EXPLORE_PAGE_SIZE, offset).await; + if items.is_empty() { + break; + } + let mut new_ids = 0usize; + for it in &items { + let Some(id) = it.get("id").map(value_to_string) else { + continue; + }; + let nm = it.get("name").and_then(|v| v.as_str()).unwrap_or_default(); + if seen.insert(id.clone()) { + new_ids += 1; + } + if !nm.is_empty() && normalize(nm) == target { + return Some(id); + } + } + offset += items.len() as u32; + // `new_ids == 0` guards a server that keeps answering with the same + // page: without it the crawl spends all 40 requests re-reading it. + if !has_more || new_ids == 0 { + break; + } + } + log::warn!("[qbz-qt] award id unresolved for name: {name}"); + None +} + +/// Fill the catalog in the background so the view's dropdown can list every +/// award. Runs at most ONCE per session: the crawl is up to 40 requests, and +/// the set it reads does not change while the app is open. +fn ensure_catalog_crawled() { + static CRAWLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if CRAWLED.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + crate::spawn(async move { + let mut offset = 0u32; + let mut seen = 0usize; + for _ in 0..EXPLORE_MAX_PAGES { + let (items, has_more) = explore_page(EXPLORE_PAGE_SIZE, offset).await; + if items.is_empty() { + break; + } + offset += items.len() as u32; + seen += items.len(); + if !has_more { + break; + } + } + log::info!("[qbz-qt] award catalog crawled: {seen} award(s)"); + // Republish so a page already on screen gains its dropdown without + // the user having to leave and come back. + publish_award(); + }); +} + +// =========================================================================== +// Documents +// =========================================================================== + +#[derive(Serialize, Clone, Default)] +struct OtherAward { + id: String, + /// `title` (not `name`): the carousel delegate is the shared card, whose + /// model field is `title` — the same shape the label page's "More labels" + /// rail feeds. + title: String, + artist: String, + #[serde(rename = "imageUrl")] + image_url: String, +} + +#[derive(Serialize, Clone, Default)] +struct CatalogEntry { + id: String, + name: String, +} + +#[derive(Serialize, Clone, Default)] +struct AwardDoc { + id: String, + name: String, + #[serde(rename = "imageUrl")] + image_url: String, + #[serde(rename = "magazineName")] + magazine_name: String, + #[serde(rename = "isFollowing")] + is_following: bool, + #[serde(rename = "followToggling")] + follow_toggling: bool, + albums: Vec, + /// The server's client-side HINT, not a count we can trust for anything + /// except "is there more" (rule 3 in the module header). + total: i64, + #[serde(rename = "hasMore")] + has_more: bool, + #[serde(rename = "loadError")] + load_error: bool, + #[serde(rename = "otherAwards")] + other_awards: Vec, + /// EVERY award, name-sorted — the dropdown's model. Empty until the + /// background crawl lands; the view hides the control while it is. + catalog: Vec, + /// Index of the OPEN award inside `catalog`, or -1 when the crawl has not + /// reached it. The dropdown binds its `currentIndex` to this. + #[serde(rename = "catalogIndex")] + catalog_index: i32, +} + +#[derive(Serialize, Clone, Default)] +struct AwardAlbumsDoc { + id: String, + name: String, + /// The SEARCH-FILTERED list the grid renders. + albums: Vec, + total: i64, + #[serde(rename = "hasMore")] + has_more: bool, + #[serde(rename = "loadError")] + load_error: bool, + #[serde(rename = "loadingMore")] + loading_more: bool, + #[serde(rename = "searchQuery")] + search_query: String, +} + +// =========================================================================== +// State +// =========================================================================== + +#[derive(Default)] +struct State { + /// Bumped by every open; a late reply whose generation is stale is dropped + /// rather than painted over the page the user is now on. + generation: u64, + id: String, + name: String, + image_url: String, + magazine_name: String, + is_following: bool, + follow_toggling: bool, + other_awards: Vec, + /// EVERY album loaded so far, unfiltered — the listing paginates into this + /// and the search filters a view of it. + all_albums: Vec, + total: i64, + loading: bool, + load_error: bool, + /// Listing sub-view only. + albums_loading_more: bool, + search_query: String, +} + +static AWARD: Mutex> = Mutex::new(None); + +fn with_state(f: impl FnOnce(&mut State) -> R) -> R { + let mut guard = AWARD.lock().unwrap_or_else(|e| e.into_inner()); + f(guard.get_or_insert_with(State::default)) +} + +/// Real `has_more`: the server's `total` is a hint built as +/// `offset + len + (has_more ? 1 : 0)`, so the only sound predicate is +/// "the hint is bigger than what we hold" (rule 3). +fn has_more_of(total: i64, loaded: usize) -> bool { + total > loaded as i64 +} + +// =========================================================================== +// Publish +// =========================================================================== + +fn publish_award() { + let (doc, loading) = with_state(|s| { + let catalog = catalog_entries(); + let catalog_index = catalog + .iter() + .position(|(id, _)| *id == s.id) + .map(|i| i as i32) + .unwrap_or(-1); + ( + AwardDoc { + id: s.id.clone(), + name: s.name.clone(), + image_url: s.image_url.clone(), + magazine_name: s.magazine_name.clone(), + is_following: s.is_following, + follow_toggling: s.follow_toggling, + albums: s + .all_albums + .iter() + .take(PREVIEW_PAGE_SIZE as usize) + .cloned() + .collect(), + total: s.total, + has_more: has_more_of(s.total, s.all_albums.len().min(PREVIEW_PAGE_SIZE as usize)), + load_error: s.load_error, + other_awards: s.other_awards.clone(), + catalog: catalog + .into_iter() + .map(|(id, name)| CatalogEntry { id, name }) + .collect(), + catalog_index, + }, + s.loading, + ) + }); + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + home_bridge::ui(move |mut b| { + b.as_mut().set_award_json(QString::from(json.as_str())); + b.as_mut().set_award_loading(loading); + }); +} + +fn publish_albums() { + let (doc, loading) = with_state(|s| { + let q = s.search_query.trim().to_lowercase(); + let visible: Vec = if q.is_empty() { + s.all_albums.clone() + } else { + s.all_albums + .iter() + .filter(|c| { + c.title.to_lowercase().contains(&q) || c.artist.to_lowercase().contains(&q) + }) + .cloned() + .collect() + }; + ( + AwardAlbumsDoc { + id: s.id.clone(), + name: s.name.clone(), + albums: visible, + total: s.total, + has_more: has_more_of(s.total, s.all_albums.len()), + load_error: s.load_error, + loading_more: s.albums_loading_more, + search_query: s.search_query.clone(), + }, + s.loading, + ) + }); + let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into()); + home_bridge::ui(move |mut b| { + b.as_mut() + .set_award_albums_json(QString::from(json.as_str())); + b.as_mut().set_award_albums_loading(loading); + }); +} + +fn publish_both() { + publish_award(); + publish_albums(); +} + +// =========================================================================== +// Landing +// =========================================================================== + +/// Album sidebar laurel / "Other awards" card / the dropdown: open the award +/// page. `fallback_name` paints the hero until `/award/page` answers (and is +/// the whole hero when that endpoint omits the award, which it may). +pub fn open_award(award_id: String, fallback_name: String) { + if crate::offline_fwd::engine().status().is_offline() { + return; + } + let id = award_id.trim().to_string(); + if id.is_empty() { + return; + } + crate::nav_qt::record("award"); + let generation = with_state(|s| { + s.generation = s.generation.wrapping_add(1); + s.id = id.clone(); + s.name = fallback_name.clone(); + s.image_url.clear(); + s.magazine_name.clear(); + s.is_following = false; + s.follow_toggling = false; + s.other_awards.clear(); + s.all_albums.clear(); + s.total = 0; + s.load_error = false; + s.albums_loading_more = false; + s.search_query.clear(); + s.loading = true; + s.generation + }); + publish_both(); + // The dropdown's model. Fire-and-forget and once per session. + ensure_catalog_crawled(); + fetch_page(generation, id, PREVIEW_PAGE_SIZE); +} + +/// The album sidebar's other arm: Qobuz gave a name but no id. Resolve, then +/// open — or toast, because opening a page with no id shows nothing and +/// explains nothing. +pub fn open_award_by_name(name: String) { + if crate::offline_fwd::engine().status().is_offline() { + return; + } + if name.trim().is_empty() { + return; + } + crate::spawn(async move { + match resolve_award_id_by_name(&name).await { + Some(id) => open_award(id, name), + // Untranslated, 1:1 with the reference (`main.rs:13298-13301` + // hands `toast::info_weak` a raw literal). Not a msgid in any of + // the eight catalogs, and inventing one here would be a string + // only this port has. + None => crate::toast_qt::info("Award detail not available for this entry."), + } + }); +} + +/// The error branch's Retry. +pub fn retry() { + let (id, name) = with_state(|s| (s.id.clone(), s.name.clone())); + if !id.is_empty() { + open_award(id, name); + } +} + +fn fetch_page(generation: u64, id: String, limit: u32) { + let runtime = crate::app(); + crate::spawn(async move { + // --- Hero. Best-effort: it must never block or fail the grid, which + // is the half the page is actually about (Tauri loads them + // independently for the same reason). `AwardPageData` is TYPED and + // every field is `Option` — this endpoint is user-scoped and answers + // with holes, which is exactly why it is not allowed near the grid. + if let Ok(page) = runtime.core().get_award_page(&id).await { + let name = page.name.clone().unwrap_or_default(); + let image_url = page.image.clone().unwrap_or_default(); + let magazine = page + .magazine + .as_ref() + .and_then(|m| m.name.clone()) + .unwrap_or_default(); + with_state(|s| { + if s.generation != generation { + return; + } + if !name.is_empty() { + s.name = name; + } + s.image_url = image_url; + s.magazine_name = magazine; + }); + publish_award(); + } + + // --- Follow state (rule 2: read PLURAL). + let following = favorite_award_ids().await.contains(&id); + with_state(|s| { + if s.generation == generation { + s.is_following = following; + } + }); + + // --- The grid. ALWAYS /award/getAlbums (rule 1). + match runtime.core().get_award_albums(&id, limit, 0).await { + Ok(page) => { + let mut cards: Vec = page + .items + .into_iter() + .map(crate::home_qt::map_flat_album) + .collect(); + // A card carries a cover URL, not a cover: `artPath` is filled + // from the on-disk cache here and the misses are downloaded + // below. Without this the grid draws EMPTY TILES forever — + // there is no lazy fetch behind AlbumCollection, the path has + // to be in the document (the `attach_doc_art` rule in + // label_qt.rs:442). + let missing = crate::home_qt::attach_card_art(&mut cards); + let total = page.total as i64; + with_state(|s| { + if s.generation != generation { + return; + } + s.all_albums = cards; + s.total = total; + s.load_error = false; + s.loading = false; + }); + publish_both(); + fill_missing_art(generation, missing).await; + } + Err(e) => { + log::warn!("[qbz-qt] award albums load failed ({id}): {e}"); + with_state(|s| { + if s.generation != generation { + return; + } + s.load_error = true; + s.loading = false; + }); + } + } + publish_both(); + + // --- "Other awards" rail. Its own request, after the page is usable. + let (items, _) = explore_page(OTHER_AWARDS_LIMIT, 0).await; + let others: Vec = items + .iter() + .filter_map(|it| { + let oid = it.get("id").map(value_to_string)?; + let title = it.get("name").and_then(|v| v.as_str())?.to_string(); + if oid.is_empty() || oid == id || title.is_empty() { + return None; + } + Some(OtherAward { + id: oid, + title, + artist: it + .get("magazine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + image_url: it + .get("image") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + }) + }) + .collect(); + with_state(|s| { + if s.generation == generation { + s.other_awards = others; + } + }); + publish_award(); + }); +} + +// =========================================================================== +// Follow (rule 2: read "awards", write "award") +// =========================================================================== + +async fn favorite_award_ids() -> HashSet { + let runtime = crate::app(); + match runtime.core().get_favorites("awards", 500, 0).await { + Ok(value) => value + .get("awards") + .and_then(|v| v.get("items")) + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|it| it.get("id").map(value_to_string)) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(), + Err(e) => { + log::warn!("[qbz-qt] award favorites read failed: {e}"); + HashSet::new() + } + } +} + +pub fn toggle_follow() { + let (id, following, busy) = + with_state(|s| (s.id.clone(), s.is_following, s.follow_toggling)); + if id.is_empty() || busy { + return; + } + with_state(|s| s.follow_toggling = true); + publish_award(); + let runtime = crate::app(); + crate::spawn(async move { + // SINGULAR on the write — the backend derives `award_ids` from it. + let result = if following { + runtime.core().remove_favorite("award", &id).await + } else { + runtime.core().add_favorite("award", &id).await + }; + match result { + Ok(_) => with_state(|s| { + if s.id == id { + s.is_following = !following; + } + s.follow_toggling = false; + }), + Err(e) => { + log::warn!("[qbz-qt] award follow toggle failed ({id}): {e}"); + with_state(|s| s.follow_toggling = false); + } + } + publish_award(); + }); +} + +// =========================================================================== +// Listing sub-view ("See all") +// =========================================================================== + +/// "See All": push the listing route and load its FIRST page at the listing +/// size. The preview already holds 20; the listing wants 50, so it reloads +/// from offset 0 rather than paginating on top of a differently-sized page. +pub fn open_albums() { + let (id, name) = with_state(|s| (s.id.clone(), s.name.clone())); + if id.is_empty() { + return; + } + crate::nav_qt::record("awardalbums"); + let generation = with_state(|s| { + s.generation = s.generation.wrapping_add(1); + s.all_albums.clear(); + s.total = 0; + s.load_error = false; + s.search_query.clear(); + s.loading = true; + s.generation + }); + publish_albums(); + let _ = name; + fetch_albums_page(generation, id, 0, true); +} + +pub fn albums_load_more() { + let (id, offset, busy, loading, more) = with_state(|s| { + ( + s.id.clone(), + s.all_albums.len() as u32, + s.albums_loading_more, + s.loading, + has_more_of(s.total, s.all_albums.len()), + ) + }); + if id.is_empty() || busy || loading || !more { + return; + } + let generation = with_state(|s| { + s.albums_loading_more = true; + s.generation + }); + publish_albums(); + fetch_albums_page(generation, id, offset, false); +} + +fn fetch_albums_page(generation: u64, id: String, offset: u32, replace: bool) { + let runtime = crate::app(); + crate::spawn(async move { + match runtime + .core() + .get_award_albums(&id, LIST_PAGE_SIZE, offset) + .await + { + Ok(page) => { + let mut cards: Vec = page + .items + .into_iter() + .map(crate::home_qt::map_flat_album) + .collect(); + let missing = crate::home_qt::attach_card_art(&mut cards); + let total = page.total as i64; + with_state(|s| { + if s.generation != generation { + return; + } + if replace { + s.all_albums = cards; + } else { + s.all_albums.extend(cards); + } + s.total = total; + s.load_error = false; + s.loading = false; + s.albums_loading_more = false; + }); + publish_albums(); + fill_missing_art(generation, missing).await; + } + Err(e) => { + log::warn!("[qbz-qt] award albums page failed ({id} @{offset}): {e}"); + with_state(|s| { + if s.generation != generation { + return; + } + // A failed LOAD-MORE keeps what is on screen: only the + // first page's failure is the error branch. + s.load_error = replace; + s.loading = false; + s.albums_loading_more = false; + }); + } + } + publish_albums(); + }); +} + +/// Download the covers a page was missing, re-attach every card's `artPath` +/// and republish. Generation-guarded at BOTH ends: the download is the long +/// part, and the user can have navigated to another award while it runs. +async fn fill_missing_art(generation: u64, missing: Vec) { + if missing.is_empty() { + return; + } + crate::artwork_qt::download_missing(missing).await; + let mut cards = with_state(|s| { + if s.generation != generation { + return Vec::new(); + } + std::mem::take(&mut s.all_albums) + }); + if cards.is_empty() { + return; + } + let _ = crate::home_qt::attach_card_art(&mut cards); + with_state(|s| { + if s.generation == generation { + s.all_albums = cards; + } + }); + publish_both(); +} + +/// Client-side filter over the loaded set — 1:1 with the reference, which +/// also never re-queries for the listing's search box. +pub fn albums_search(query: String) { + with_state(|s| s.search_query = query); + publish_albums(); +} + +// No `republish_for_language` here on purpose. `label_qt`'s exists because it +// rebuilds JUMP TO tab labels that live INSIDE its document; these two carry +// no Rust-translated string at all — every label on both views is a `@tr` in +// QML, which `QbzSession.trRev` re-evaluates by itself. A republish hook that +// republishes nothing is the dead-code the rest of this module avoids. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_matches_the_same_string_from_both_sides() { + assert_eq!(normalize(" Gramophone Award "), "gramophone award"); + assert_eq!(normalize("GRAMOPHONE AWARD"), normalize("Gramophone Award")); + } + + /// Rule 3. `total` is a HINT (`offset + len + has_more?1`), so the + /// exactly-one-page case must NOT advertise more — that is the bug the + /// direct comparison fixes. + #[test] + fn has_more_reads_the_hint_against_what_is_loaded() { + assert!(has_more_of(21, 20)); + assert!(!has_more_of(20, 20)); + assert!(!has_more_of(0, 0)); + // A server that under-reports can never make the grid ask for a page + // that does not exist. + assert!(!has_more_of(5, 20)); + } + + #[test] + fn the_catalog_harvests_and_resolves_by_normalized_name() { + remember_awards(&[("42".into(), " Album Of The Year ".into())]); + assert_eq!(lookup_cached("album of the year").as_deref(), Some("42")); + assert_eq!(lookup_cached("ALBUM OF THE YEAR").as_deref(), Some("42")); + assert!(lookup_cached("nothing like it").is_none()); + // The dropdown lists the DISPLAY name, not the normalized key. + assert!(catalog_entries() + .iter() + .any(|(id, name)| id == "42" && name.contains("Album"))); + } + + #[test] + fn empty_pairs_never_enter_the_catalog() { + remember_awards(&[("".into(), "No id".into()), ("7".into(), " ".into())]); + assert!(lookup_cached("no id").is_none()); + assert!(!catalog_entries().iter().any(|(id, _)| id == "7")); + } +} diff --git a/crates/qbz-qt/src/home_bridge.rs b/crates/qbz-qt/src/home_bridge.rs index c5b08c22b..888fa5062 100644 --- a/crates/qbz-qt/src/home_bridge.rs +++ b/crates/qbz-qt/src/home_bridge.rs @@ -90,6 +90,15 @@ pub mod qbz_home { #[qproperty(bool, label_loading)] #[qproperty(QString, label_releases_json)] #[qproperty(bool, label_releases_loading)] + // --- Award landing + "See all" listing (src/award_qt.rs) ---------- + // Awards ride THIS bridge, not one of their own, because they are the + // label pair's sibling in every respect (same two-route shape, same + // controller-in-its-own-file split) and the album sidebar already + // reaches labels through `QbzHome`. One domain, one place to look. + #[qproperty(QString, award_json)] + #[qproperty(bool, award_loading)] + #[qproperty(QString, award_albums_json)] + #[qproperty(bool, award_albums_loading)] type QbzHome = super::QbzHomeRust; @@ -225,6 +234,32 @@ pub mod qbz_home { #[qinvokable] fn label_releases_search(self: Pin<&mut QbzHome>, query: QString); + // --- Award landing ------------------------------------------------- + /// Album sidebar laurel (with an id), an "Other awards" card, or the + /// all-awards dropdown. `fallback_name` paints the hero until + /// `/award/page` answers — and stays as the hero when that endpoint + /// omits the award, which it may. + #[qinvokable] + fn open_award(self: Pin<&mut QbzHome>, award_id: QString, fallback_name: QString); + /// The album sidebar's other arm: Qobuz gave a NAME and no id. + /// Resolves through the catalog / an `/award/explore` crawl, then + /// opens — or toasts, rather than opening a page that cannot load. + #[qinvokable] + fn open_award_by_name(self: Pin<&mut QbzHome>, name: QString); + #[qinvokable] + fn award_retry(self: Pin<&mut QbzHome>); + #[qinvokable] + fn award_follow(self: Pin<&mut QbzHome>); + /// "See All" — push the listing route and load its first page. + #[qinvokable] + fn award_open_albums(self: Pin<&mut QbzHome>); + #[qinvokable] + fn award_albums_load_more(self: Pin<&mut QbzHome>); + /// Client-side filter over what is loaded (the reference never + /// re-queries for this box either). + #[qinvokable] + fn award_albums_search(self: Pin<&mut QbzHome>, query: QString); + /// A batch of For You covers landed: a JSON `{ "": "" }` patch. Per-URL, so the rails patch the TILES instead of /// taking a new `radioStationsJson` / `spotlightJson` document — a @@ -278,6 +313,10 @@ pub struct QbzHomeRust { label_loading: bool, label_releases_json: QString, label_releases_loading: bool, + award_json: QString, + award_loading: bool, + award_albums_json: QString, + award_albums_loading: bool, } impl Default for QbzHomeRust { @@ -309,6 +348,10 @@ impl Default for QbzHomeRust { label_loading: false, label_releases_json: QString::from("{}"), label_releases_loading: false, + award_json: QString::from("{}"), + award_loading: false, + award_albums_json: QString::from("{}"), + award_albums_loading: false, } } } @@ -444,6 +487,28 @@ impl qbz_home::QbzHome { crate::share_qt::share_label(label_id.to_string()); } + pub fn open_award(self: Pin<&mut Self>, award_id: QString, fallback_name: QString) { + crate::award_qt::open_award(award_id.to_string(), fallback_name.to_string()); + } + pub fn open_award_by_name(self: Pin<&mut Self>, name: QString) { + crate::award_qt::open_award_by_name(name.to_string()); + } + pub fn award_retry(self: Pin<&mut Self>) { + crate::award_qt::retry(); + } + pub fn award_follow(self: Pin<&mut Self>) { + crate::award_qt::toggle_follow(); + } + pub fn award_open_albums(self: Pin<&mut Self>) { + crate::award_qt::open_albums(); + } + pub fn award_albums_load_more(self: Pin<&mut Self>) { + crate::award_qt::albums_load_more(); + } + pub fn award_albums_search(self: Pin<&mut Self>, query: QString) { + crate::award_qt::albums_search(query.to_string()); + } + // --- Discover > For You: mixes / radio / spotlight --------------------- pub fn open_mix(self: Pin<&mut Self>, kind: QString) { diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 640f69df1..d49372809 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -227,10 +227,13 @@ mod offline_fwd; // user-managed download cache (see AGENTS.md's caching-model note). mod offline_qt; mod offline_cache_qt; +mod offline_manager_qt; +mod offline_manager_bridge; // Shared multi-select bulk actions for Qobuz track listings (playlist, // artist, label — the album page has its own album-ordered variant). mod bulk_tracks_qt; mod album_qt; +mod award_qt; mod track_info_qt; // Album Info (Credits/Review) modal controller — info_modals.rs port. mod album_info_qt;