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;