diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index e30021635..fb78981f1 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -80,7 +80,7 @@ QbzContextMenu { } Text { height: parent.height - width: parent.width - 23 + width: parent.width - 23 - (row.modelData.submenu === true ? 22 : 0) text: row.modelData.label || "" color: row.isDanger ? theme.danger : (row.hot ? theme.textPrimary : theme.textSecondary) @@ -88,6 +88,16 @@ QbzContextMenu { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + // Submenu marker (`submenu: true`): the host answers the pick + // by opening a second menu on the same anchor. + QbzIcon { + visible: row.modelData.submenu === true + name: "chevron-right" + width: 14 + height: 14 + anchors.verticalCenter: parent.verticalCenter + tintName: row.hot ? "textPrimary" : "muted" + } } MouseArea { diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 48fc9357a..36d82da3f 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -258,6 +258,10 @@ Rectangle { /// owns — a worse bug than the one this treatment fixes, and it would bite /// hardest in offline mode. It gets `pulledCached` below instead. readonly property bool pulledDead: root.pulled && root.cacheStatus !== 3 + /// The whole release is gone from the catalog (`releaseUnavailable`, + /// decided by the row's producer): the album link and "Go to album" go + /// dark instead of opening an empty page (2026-09-13). + readonly property bool releaseGone: root.item.releaseUnavailable === true /// Pulled from the catalogue, but playable from the downloaded copy. Keeps /// its play cell, its heart and its click; gains only an honest badge, @@ -1058,15 +1062,18 @@ Rectangle { width: cols.colAlbum anchors.verticalCenter: parent.verticalCenter text: root.item.album || "" - color: albumArea.containsMouse ? theme.accent : theme.textMuted + color: (albumArea.containsMouse && albumArea.enabled) ? theme.accent : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight MouseArea { id: albumArea anchors.fill: parent + enabled: !!root.item.albumId && !root.releaseGone hoverEnabled: true - cursorShape: root.item.albumId ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (root.item.albumId) QbzAlbum.openAlbum(root.item.albumId) + cursorShape: Qt.PointingHandCursor + // The names ride along so a release the catalog lost can + // still be named on its page (album_qt::publish_unavailable). + onClicked: QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } } // Duration. @@ -1268,10 +1275,53 @@ Rectangle { /// Build the popup on first use, then open it. Called by `openRowMenu` /// below, which owns the empty-menu guard. function openRowMenuLazy(anchor, x, y) { + root._menuAnchor = anchor + root._menuX = x + root._menuY = y rowMenuLoader.active = true rowMenuLoader.item.openAtCursor(anchor, x, y) } + // --- Copy submenu + clipboard (2026-09-13) -------------------------------- + // Both lazy, like the row menu: a list row must not carry a Popup tree + // or a TextEdit per delegate. Reopened where the row menu was. + property Item _menuAnchor: null + property real _menuX: 0 + property real _menuY: 0 + Loader { + id: copyMenuLoader + active: false + sourceComponent: CardMenu { + kioskHost: root.kioskHost + menuWidth: 224 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { root.copyRow(a) } + } + } + Loader { + id: clipboardLoader + active: false + sourceComponent: QbzClipboard { } + } + function openCopyMenu() { + if (!root._menuAnchor) + return + copyMenuLoader.active = true + copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY) + } + function copyRow(which) { + var parts = [root.item.title || ""] + if (which === "copy-full") { + if ((root.item.album || "") !== "") parts.push(root.item.album) + if ((root.item.artist || "") !== "") parts.push(root.item.artist) + } + clipboardLoader.active = true + clipboardLoader.item.copy(parts.join(" - ")) + } + // TrackContextMenu.slint, in its order. Every row here reaches a live // seam; the seamless ones are gated off above. /// Open the row menu, unless it would come up EMPTY. @@ -1362,7 +1412,7 @@ Rectangle { m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" }) } } - if (root.menuShowGoTo && root.item.albumId) + if (root.menuShowGoTo && root.item.albumId && !root.releaseGone) m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" }) if (root.menuShowGoTo && root.item.artistId) m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) @@ -1370,6 +1420,13 @@ Rectangle { // opens on nothing. if (root.menuShowTrackInfo && !root.pulled) m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) + // "Buy on Qobuz" (2026-09-13): the store page; store_qt answers with a + // toast when the catalog says the release is not sold. + if (root.catalogRow && !root.localSourceRow && !root.pulledDead) + m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) + // Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist". + if ((root.item.title || "") !== "") + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) return m } @@ -1393,8 +1450,10 @@ Rectangle { } else if (a === "go-album") { if (root.routeGoToExternally) root.goToRequested("album") - else QbzAlbum.openAlbum(root.item.albumId) + else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } + else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "") + else if (a === "copy") root.openCopyMenu() else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() // Add to playlist. The internal arm is the CATALOG one and nothing diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 105bd5afa..01fc58619 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -657,7 +657,7 @@ Rectangle { favorite: root.npFavorite ephemeral: root.npEphemeral tooltip: root.tooltip - onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -680,7 +680,7 @@ Rectangle { playCircle: true favorite: root.npFavorite tooltip: root.tooltip - onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -908,6 +908,26 @@ Rectangle { // "Add to…" flyout behind the transport "+" (TransportControls.slint's // add-menu), on the shared CardMenu surface. Same seven entries, same // order, same icons. + // The "+" button that opened the add-menu: the Copy submenu reopens on it. + property Item addAnchor: null + QbzClipboard { id: npClipboard } + CardMenu { + id: npCopyMenu + menuWidth: 232 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { + var parts = [QbzPlayer.npTitle || ""] + if (a === "copy-full") { + if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) + if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) + } + npClipboard.copy(parts.join(" - ")) + } + } + CardMenu { id: addMenu menuWidth: 232 @@ -963,6 +983,23 @@ Rectangle { "action": "album-favorite" }) } + // "Buy on Qobuz" (2026-09-13): catalog tracks only; store_qt + // resolves whether the release is sold and toasts when not. + if (root.npSource === "qobuz") { + m.push({ + "label": QbzSession.tr("Buy on Qobuz", QbzSession.trRev), + "icon": "shopping-bag", + "action": "buy" + }) + } + // Copy (2026-09-13): a submenu — the track name, or + // "Track - Album - Artist". + m.push({ + "label": QbzSession.tr("Copy", QbzSession.trRev), + "icon": "copy", + "action": "copy", + "submenu": true + }) return m.concat(root.abEntries()) } onPicked: function (a) { @@ -978,6 +1015,10 @@ Rectangle { } else if (a === "album-favorite") { if (QbzPlayer.npAlbumId !== "") QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) + } else if (a === "buy") { + if (id !== "") QbzAlbum.buyTrack(id) + } else if (a === "copy") { + if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) } else if (a === "ab-mark") { QbzPlayer.abMark() } else if (a === "ab-clear") { diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index face85dee..df98929ec 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -106,6 +106,8 @@ Rectangle { } readonly property var albumHeader: album.header || ({}) + /// The "cannot show this release" arm of the document (album_qt::publish_unavailable). + readonly property var unavailable: album.unavailable || null readonly property var tracks: album.tracks || [] readonly property var purchase: album.purchase || ({}) readonly property var localPurchaseVariants: purchase.localVariants || [] @@ -889,12 +891,79 @@ Rectangle { // NavButtons is a 0px placeholder in the Slint source. Item { width: 1; height: 22 } + // The release cannot be shown (album_qt::publish_unavailable, + // 2026-09-13): gone from the catalog, or a load failure. The + // heading comes from the row that opened it, then the reason, and + // for a gone release the best-ranked alternatives Qobuz still + // sells — the same ranking the playlist replacement flow uses. + Column { + visible: root.unavailable !== null + width: parent.width - 64 + spacing: 12 + Item { width: 1; height: 8 } + Text { + width: parent.width + text: root.unavailable && (root.unavailable.title || "") !== "" + ? root.unavailable.title + : QbzSession.tr("This release", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontTitle + font.weight: theme.weightBold + wrapMode: Text.WordWrap + } + Text { + visible: root.unavailable && (root.unavailable.artist || "") !== "" + width: parent.width + text: root.unavailable ? (root.unavailable.artist || "") : "" + color: theme.textSecondary + font.pixelSize: theme.fontSection + elide: Text.ElideRight + } + Row { + width: parent.width + spacing: 8 + QbzIcon { + name: "circle-alert" + width: 18 + height: 18 + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + Text { + width: parent.width - 26 + anchors.verticalCenter: parent.verticalCenter + text: root.unavailable && root.unavailable.gone === true + ? QbzSession.tr("This release is no longer available on Qobuz.", QbzSession.trRev) + : QbzSession.tr("Couldn't load this release.", QbzSession.trRev) + + ((root.unavailable && (root.unavailable.error || "") !== "") + ? " " + root.unavailable.error : "") + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + } + Item { width: 1; height: 8 } + Text { + visible: root.unavailable && root.unavailable.loading === true + text: QbzSession.tr("Looking for other versions…", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + } + SectionRail { + visible: root.unavailable && (root.unavailable.alternatives || []).length > 0 + width: parent.width + title: QbzSession.tr("You might be interested in these instead", QbzSession.trRev) + items: root.unavailable ? (root.unavailable.alternatives || []) : [] + coverMap: root.coverMap + } + } + // --- Album header skeleton ---------------------------------- // Mounted on the primary flag, and the real header is hidden by // the same flag: opening album B never renders a half-empty // header frame while B's document is in flight. Row { - visible: root.primaryLoading + visible: root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx @@ -932,7 +1001,7 @@ Rectangle { // --- Album header ------------------------------------------- Row { - visible: !root.primaryLoading + visible: !root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx @@ -1322,6 +1391,23 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter onClicked: albumInfo.openFor(albumHeader.id) } + // "Buy on Qobuz" (2026-09-13): only when the catalog + // says the release is sold and it is not already + // owned; a link to the store page, not a purchase here. + QbzCircleAction { + id: buyButton + visible: albumHeader.purchasable === true + && (root.purchase.entitlementState || "") !== "purchased" + name: "shopping-bag" + diameterOverride: root.compactHeaderPref ? 28 : 0 + overlay: root.hdrOverlay + anchors.verticalCenter: parent.verticalCenter + onClicked: QbzAlbum.buyAlbum(albumHeader.id) + HoverHandler { id: buyHover } + ToolTip.visible: buyHover.hovered + ToolTip.text: QbzSession.tr("Buy on Qobuz", QbzSession.trRev) + ToolTip.delay: 350 + } QbzCircleAction { id: albumMenuBtn name: "ellipsis" @@ -1429,7 +1515,7 @@ Rectangle { // instead of a floating overlay. QbzMultiSelectBar { id: bulkBar - visible: root.multiSelect && !root.primaryLoading + visible: root.multiSelect && !root.primaryLoading && root.unavailable === null width: parent.width selectedCount: root.selectedCount // The reference's full AlbumView inventory @@ -1449,7 +1535,7 @@ Rectangle { // Toolbar — quality badge + track search (+ inert select). Row { - visible: !QbzAlbum.albumLoading + visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width height: 52 spacing: 16 @@ -1560,7 +1646,7 @@ Rectangle { // the band so `centerIn` centres them, which is the fix // this block used to document at length. TrackListHeader { - visible: !QbzAlbum.albumLoading + visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width bandHeight: 40 labelSpacing: 0.5 diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index fe0f90cb3..c9ef633f0 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -871,6 +871,9 @@ Rectangle { // row). Activated on demand, torn down on close (Qt.callLater so the // Popup is not destroyed mid-signal) — the same shape rows/TrackRow.qml // carries per row. + // One clipboard carrier for every Popular Tracks row's Copy submenu. + QbzClipboard { id: artistClipboard } + Loader { id: artistTrackInfo active: false @@ -1692,7 +1695,7 @@ Rectangle { enabled: !popRow.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } @@ -1774,6 +1777,9 @@ Rectangle { m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" }) if (!popRow.pulled) m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) + if (!popRow.pulledDead) + m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) return m } onPicked: function (a) { @@ -1804,9 +1810,31 @@ Rectangle { else if (a === "cache") QbzPlayer.cacheTrack(id) else if (a === "uncache") QbzPlayer.uncacheTrack(id) else if (a === "recache") QbzPlayer.recacheTrack(id) - else if (a === "go-album") QbzAlbum.openAlbum(popRow.row.albumId) + else if (a === "go-album") QbzAlbum.openAlbumFrom(popRow.row.albumId, popRow.row.album || "", popRow.row.artist || "") else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "track-info") root.openTrackInfo(id) + else if (a === "buy") QbzAlbum.buyTrack(id) + else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) + } + } + // Copy submenu (2026-09-13), reopened where the row menu was. + property Item _menuAnchor: null + property real _menuX: 0 + property real _menuY: 0 + CardMenu { + id: popCopyMenu + menuWidth: 224 + entries: [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] + onPicked: function (a) { + var parts = [popRow.row.title || ""] + if (a === "copy-full") { + if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) + if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + } + artistClipboard.copy(parts.join(" - ")) } } @@ -1832,7 +1860,7 @@ Rectangle { id: popRcArea anchors.fill: parent acceptedButtons: Qt.RightButton - onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } } } diff --git a/crates/qbz-qt/src/album_bridge.rs b/crates/qbz-qt/src/album_bridge.rs index 2e3be06ce..aa9d9e641 100644 --- a/crates/qbz-qt/src/album_bridge.rs +++ b/crates/qbz-qt/src/album_bridge.rs @@ -62,6 +62,16 @@ pub mod qbz_album_bridge { /// Open the album detail view (pushes "album" on the nav stack). #[qinvokable] fn open_album(self: Pin<&mut QbzAlbum>, album_id: QString); + /// `open_album` with the title/artist the caller knows, so a release + /// the catalog no longer has can still be named on its page. + #[qinvokable] + fn open_album_from(self: Pin<&mut QbzAlbum>, album_id: QString, title: QString, artist: QString); + /// "Buy on Qobuz": the store page in the browser when the release is + /// sold, a toast otherwise (store_qt). + #[qinvokable] + fn buy_album(self: Pin<&mut QbzAlbum>, album_id: QString); + #[qinvokable] + fn buy_track(self: Pin<&mut QbzAlbum>, track_id: QString); /// AlbumCard's picture-in-picture action. Fetches the compact album /// document without navigating or touching `album_json`. #[qinvokable] @@ -189,6 +199,20 @@ impl qbz_album_bridge::QbzAlbum { pub fn open_album(self: Pin<&mut Self>, album_id: QString) { crate::open_album(album_id.to_string()); } + pub fn open_album_from( + self: Pin<&mut Self>, + album_id: QString, + title: QString, + artist: QString, + ) { + crate::open_album_from(album_id.to_string(), title.to_string(), artist.to_string()); + } + pub fn buy_album(self: Pin<&mut Self>, album_id: QString) { + crate::store_qt::buy_album(album_id.to_string()); + } + pub fn buy_track(self: Pin<&mut Self>, track_id: QString) { + crate::store_qt::buy_track(track_id.to_string()); + } pub fn open_quick_view(self: Pin<&mut Self>, album_id: QString) { crate::album_quick_view_qt::open(album_id.to_string()); } diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 6f022e4d5..3190eaebb 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -197,6 +197,9 @@ pub struct AlbumHeader { pub label: String, #[serde(rename = "labelId")] pub label_id: String, + /// The catalog's `purchasable` for the release: the header's "Buy on + /// Qobuz" action shows only on `true` (2026-09-13). + pub purchasable: bool, /// (id, name) award pairs for the sidebar. pub awards: Vec<(String, String)>, #[serde(rename = "isFavorite")] @@ -619,11 +622,19 @@ pub async fn load_album( runtime: &Arc>, album_id: &str, ) -> Result { + // A release the catalog no longer has is the one failure the page can + // explain (and offer alternatives for): it travels as `ALBUM_GONE` so + // `open_album` can tell it from a network error. let album = runtime .core() .get_album(album_id) .await - .map_err(|e| e.to_string())?; + .map_err(|e| match e { + qbz_core::CoreError::Api(ref error) if error.is_album_unavailable(album_id) => { + ALBUM_GONE.to_string() + } + other => other.to_string(), + })?; let artist = album.artist.name.clone(); let artist_id = album.artist.id.to_string(); @@ -770,6 +781,7 @@ pub async fn load_album( .as_ref() .map(|l| l.id.to_string()) .unwrap_or_default(), + purchasable: album.purchasable == Some(true), awards, has_booklet: !booklet_url.is_empty(), // album.rs:677-679 — the whole album is cached when every track is. @@ -1077,6 +1089,84 @@ pub(crate) fn format_album_title(title: &str, version: Option<&str>) -> String { /// `main-artist` role, else the first entry). `/album/suggest` returns items /// whose flat `artist` is empty but whose `artists[]` is populated — without /// the fallback the "Listening suggestions" cards render no artist line. +/// `load_album`'s error for a release the catalog no longer has (see there). +pub(crate) const ALBUM_GONE: &str = "album-gone"; + +/// The album page when the release cannot be shown (2026-09-13). `gone` is +/// the catalog saying the release no longer exists — a pulled album that +/// still answers keeps its normal page, rows marked — and anything else is a +/// load failure. Published as the `unavailable` arm of the album document, +/// which AlbumView renders instead of a header; for a gone release whose +/// title is known (the row that opened it passes one, a 404 carries none), +/// the best-ranked alternatives Qobuz still sells follow, through the same +/// ranking the playlist replacement flow uses. +pub(crate) fn publish_unavailable(album_id: String, gone: bool, error: String) { + let (title, artist) = crate::album_hint(&album_id).unwrap_or_default(); + let generation = ALBUM_GEN.fetch_add(1, Ordering::SeqCst) + 1; + let searching = gone && !title.trim().is_empty(); + let doc = json!({ + "unavailable": { + "id": album_id, + "title": title, + "artist": artist, + "gone": gone, + "error": if gone { String::new() } else { error }, + "loading": searching, + "alternatives": [], + } + }); + if let Ok(mut guard) = ALBUM_DOC.lock() { + *guard = Some((generation, doc.clone())); + } + let json = doc.to_string(); + crate::album_bridge::ui(move |mut b| { + b.as_mut().set_album_json(QString::from(json.as_str())); + b.as_mut().set_album_loading(false); + }); + if !searching { + return; + } + crate::spawn(async move { + let found = + crate::track_replace_qt::ranked_alternatives(&album_id, &title, &artist).await; + let cards: Vec = found.iter().take(12).map(album_to_card).collect(); + let value = serde_json::to_value(&cards).unwrap_or_else(|_| json!([])); + publish_patch(generation, move |doc| { + if let Some(arm) = doc.get_mut("unavailable") { + arm["alternatives"] = value; + arm["loading"] = json!(false); + } + }); + }); +} + +/// A catalog album as one card of a rail (the alternatives rail above). +fn album_to_card(album: &Album) -> AlbumCardData { + let (artist, artist_id) = card_artist(album); + let date = album + .release_date_original + .as_deref() + .or_else(|| album.dates.as_ref().and_then(|d| d.original.as_deref())); + AlbumCardData { + id: album.id.clone(), + title: format_album_title(&album.title, album.version.as_deref()), + artist, + artist_id, + genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(), + year: qbz_text_utils::dates::release_label(date), + release_sort_key: qbz_text_utils::dates::release_sort_key(date), + quality_tier: home_qt::quality_tier_from_depth(album.maximum_bit_depth).to_string(), + quality_detail: home_qt::quality_detail_from_parts( + album.maximum_bit_depth, + album.maximum_sampling_rate, + ), + art_url: album.image.best().cloned().unwrap_or_default(), + is_pinned: crate::sidebar_qt::is_pinned("album", &album.id), + is_favorite: crate::library_qt::is_favorite("album", &album.id), + default_order: 0, + } +} + fn card_artist(album: &Album) -> (String, String) { if !album.artist.name.is_empty() { return (album.artist.name.clone(), album.artist.id.to_string()); diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index ebbc99e9c..67219117d 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -299,6 +299,7 @@ mod rip_qt; mod rip_wizard_qt; mod sacd_qt; mod share_qt; +mod store_qt; mod tag_editor_bridge; mod tag_editor_qt; // Offline cache (downloads tier): state activation on login + the action @@ -1448,7 +1449,8 @@ pub(crate) fn open_album(album_id: String) { }), Err(e) => { log::warn!("[qbz-qt] album view load failed: {e}"); - album_bridge::ui(move |mut b| b.as_mut().set_album_loading(false)); + let gone = e == album_qt::ALBUM_GONE; + album_qt::publish_unavailable(album_id, gone, e); } } }); @@ -2754,6 +2756,30 @@ pub(crate) fn navigate_to_tab(view: &str, tab: &str) { /// language switch so its Rust-built section headers re-translate. static LAST_DETAIL: Mutex<(String, String)> = Mutex::new((String::new(), String::new())); +/// The names the row that opened an album knew (id, title, artist): a 404 +/// carries none, and the "no longer available" page needs them for its +/// heading and its alternatives search (album_qt::publish_unavailable). +static ALBUM_HINT: Mutex> = Mutex::new(None); + +pub(crate) fn album_hint(album_id: &str) -> Option<(String, String)> { + ALBUM_HINT + .lock() + .ok() + .and_then(|guard| guard.clone()) + .filter(|(id, _, _)| id == album_id) + .map(|(_, title, artist)| (title, artist)) +} + +/// `open_album` with the title/artist the caller knows — every row that +/// opens an album passes them, so the page can name a release the catalog +/// no longer has. +pub(crate) fn open_album_from(album_id: String, title: String, artist: String) { + if let Ok(mut guard) = ALBUM_HINT.lock() { + *guard = Some((album_id.clone(), title, artist)); + } + open_album(album_id); +} + /// Settings > Appearance > Language: the pref is already persisted by the /// settings arm; this applies it LIVE — "auto" resolves POSIX env — then: /// 1. bumps trRev, so EVERY `QbzBridge.tr(msgid, QbzBridge.trRev)` binding diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index acca6fa6d..e28286a9c 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -159,6 +159,14 @@ pub struct PlaylistTrackRow { skip_serializing_if = "std::ops::Not::not" )] pub upcoming: bool, + /// The whole RELEASE this row belongs to is gone from the catalog: every + /// track withdrawn, or `/album/get` answering unavailable — decided by + /// `library_qt::unavailable_release_ids` once the rows are built, one + /// probe per distinct album of the pulled rows. A pulled track on a live + /// album keeps `false`: its album page still opens. The row's album link + /// and "Go to album" go dark on `true` (2026-09-13). + #[serde(rename = "releaseUnavailable")] + pub release_unavailable: bool, /// The recording identifier, carried so the replacement search's ISRC /// short-circuit can fire (`qbz-playlist-import/src/match_qobuz.rs:156-159` /// scores an ISRC hit 1.0). That is the owner's "a veces cambia el ID del @@ -1199,6 +1207,8 @@ pub async fn load( .await .unwrap_or_default(); let mixed = !sidecar.is_empty(); + // Release-wide withdrawals among the pulled rows (see the field). + let gone_releases = crate::library_qt::unavailable_release_ids(runtime, &tracks).await; let merged = interleave_rows(tracks, sidecar); // Display rows + the playable snapshot + the row positions in ONE pass, so @@ -1217,6 +1227,13 @@ pub async fn load( } rows.push(item); } + if !gone_releases.is_empty() { + for item in rows.iter_mut() { + if item.not_streamable && gone_releases.contains(&item.album_id) { + item.release_unavailable = true; + } + } + } // Seam B: a mixed detail plays through local_playlist's merged queue // snapshot (source-aware QueueTracks — a local file must never be queued as diff --git a/crates/qbz-qt/src/store_qt.rs b/crates/qbz-qt/src/store_qt.rs new file mode 100644 index 000000000..99a48cb36 --- /dev/null +++ b/crates/qbz-qt/src/store_qt.rs @@ -0,0 +1,63 @@ +//! "Buy on Qobuz" (2026-09-13): a link to the store page in the browser, +//! never a purchase inside QBZ. The album page shows the action only when the +//! catalog says the release is `purchasable`; the track menus and the player +//! bar resolve that at click time — not every row document carries the flag — +//! and say so in a toast when a release is not sold. The URL keeps the store +//! locale to Qobuz: `/album/-/{id}` answers 301 to the localised page +//! (verified 2026-09-13). Tracks are sold on their album's page. + +pub(crate) fn album_store_url(album_id: &str) -> String { + format!("https://www.qobuz.com/album/-/{album_id}") +} + +fn open_store(url: String) { + if let Err(error) = open::that(&url) { + log::warn!("[qbz-qt] store link {url} failed to open: {error}"); + crate::toast_qt::error(qbz_i18n::t("Could not open the browser")); + } +} + +fn not_sold() { + crate::toast_qt::info(qbz_i18n::t("This release isn't sold on Qobuz.")); +} + +fn unknown() { + crate::toast_qt::info(qbz_i18n::t("Couldn't check the store for this release.")); +} + +pub(crate) fn buy_album(album_id: String) { + if album_id.is_empty() { + return; + } + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_album(&album_id).await { + Ok(album) if album.purchasable == Some(false) => not_sold(), + Ok(_) => open_store(album_store_url(&album_id)), + Err(error) => { + log::warn!("[qbz-qt] store: album {album_id} lookup failed: {error}"); + unknown(); + } + } + }); +} + +pub(crate) fn buy_track(track_id: String) { + let Ok(id) = track_id.parse::() else { + return; + }; + let runtime = crate::app(); + crate::spawn(async move { + match runtime.core().get_track(id).await { + Ok(track) if track.purchasable == Some(false) => not_sold(), + Ok(track) => match track.album.as_ref().map(|a| a.id.clone()) { + Some(album_id) if !album_id.is_empty() => open_store(album_store_url(&album_id)), + _ => unknown(), + }, + Err(error) => { + log::warn!("[qbz-qt] store: track {id} lookup failed: {error}"); + unknown(); + } + } + }); +} diff --git a/crates/qbz-qt/src/track_replace_qt.rs b/crates/qbz-qt/src/track_replace_qt.rs index 59bcf6f3a..635f68452 100644 --- a/crates/qbz-qt/src/track_replace_qt.rs +++ b/crates/qbz-qt/src/track_replace_qt.rs @@ -656,6 +656,46 @@ async fn run_release_search(release: ReleaseSeed, query: String, generation: u64 } } +/// The ranked release candidates for a title (and artist) the catalog no +/// longer has — the album page's "you might be interested in these" rail +/// (2026-09-13). Same query normalisation, search and ranking as the +/// replacement flow; no modal state is touched. Empty when nothing to ask. +pub(crate) async fn ranked_alternatives(album_id: &str, title: &str, artist: &str) -> Vec { + let seed: ReleaseSeed = match serde_json::from_value(serde_json::json!({ + "targetKind": "album", + "albumId": album_id, + "albumTitle": title, + "artist": artist, + "albumArtist": artist, + })) { + Ok(seed) => seed, + Err(_) => return Vec::new(), + }; + let normalized = qbz_external_reco::normalize_catalog_name(title); + let query = if normalized.is_empty() { + title.trim().to_string() + } else { + normalized + }; + if query.is_empty() { + return Vec::new(); + } + let runtime = crate::app(); + let found = runtime + .core() + .search_albums(&query, SEARCH_LIMIT, 0, None) + .await + .map(|page| page.items) + .unwrap_or_else(|error| { + log::warn!("[qbz-qt] album alternatives: search '{query}' failed: {error}"); + Vec::new() + }); + rank_release_candidates(&seed, &found) + .into_iter() + .map(|(album, _)| album) + .collect() +} + fn release_quality(album: &Album) -> (u32, f64) { let bit_depth = album .audio_info