From 81c8d3908e06c2b221bb7f3720e5946ec0a3d887 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:26:11 -0600 Subject: [PATCH] feat(qt): the Releases list shows each release's label, and album menus open it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library feed album items carry the catalog label id next to its name. Library > Releases (list) gains a LABEL column between the item and its quality — a link to the label page when the id is known, muted text otherwise — hidden on the label page itself and on narrow kiosk rows. AlbumListRow and AlbumCard menus gain "View label" (the card only where its host passes the id: the library grid does). The library's release-wide availability helper is shared with the playlist rows for the next change. cc --- crates/qbz-qt/qml/cards/AlbumCard.qml | 6 +++ crates/qbz-qt/qml/views/AlbumListHeader.qml | 10 +++++ crates/qbz-qt/qml/views/AlbumListRow.qml | 38 ++++++++++++++++++- crates/qbz-qt/qml/views/LabelView.qml | 2 +- .../qbz-qt/qml/views/library/FeedGridCell.qml | 1 + crates/qbz-qt/src/library_qt.rs | 11 +++++- 6 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/qbz-qt/qml/cards/AlbumCard.qml b/crates/qbz-qt/qml/cards/AlbumCard.qml index 65480fc4a..4fa255551 100644 --- a/crates/qbz-qt/qml/cards/AlbumCard.qml +++ b/crates/qbz-qt/qml/cards/AlbumCard.qml @@ -40,6 +40,9 @@ Rectangle { property string title: "" property string artist: "" property string artistId: "" + /// The release's label id when the host knows it (library feed rows do): + /// the "View label" menu entry (2026-09-13). "" = no entry. + property string labelId: "" // Hosts with only a display snapshot (Home's persisted Pinned row) can // still make the artist line a link and resolve the destination lazily. // Normal catalog cards keep the direct artistId path and pay no lookup. @@ -391,6 +394,8 @@ Rectangle { } // Host tail (see `extraMenuEntries`). `concat` so the host's array is // never mutated — it is usually a binding's return value. + if (root.labelId !== "") + m.push({ "label": t("View label", r), "icon": "tags", "action": "view-label" }) var extra = root.extraMenuEntries || [] return extra.length > 0 ? m.concat(extra) : m } @@ -415,6 +420,7 @@ Rectangle { // denormalized snapshot and a file:// cache path is dead on any other // machine, the same reason the pin payload uses it. if (a === "favorite") { root.toggleFavorite(); return } + if (a === "view-label") { QbzHome.openLabel(root.labelId); return } if (a === "find-release") { QbzTrackReplace.openRelease(JSON.stringify({ "targetKind": "album", diff --git a/crates/qbz-qt/qml/views/AlbumListHeader.qml b/crates/qbz-qt/qml/views/AlbumListHeader.qml index 243aaf49d..4c3ae53f4 100644 --- a/crates/qbz-qt/qml/views/AlbumListHeader.qml +++ b/crates/qbz-qt/qml/views/AlbumListHeader.qml @@ -27,6 +27,10 @@ Rectangle { // AlbumListCols: art 52, quality 150, year 64, overflow 36, gap 12. readonly property int colArt: 52 + /// The release's label, between the item and its quality (2026-09-13); + /// off on the label page itself, where every row would repeat the same. + property bool showLabel: true + readonly property int colLabel: showLabel ? 150 : 0 readonly property int colQuality: 150 readonly property int colYear: 64 readonly property int colOverflow: 36 @@ -56,10 +60,16 @@ Rectangle { ColLabel { width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.showLabel ? root.colLabel + root.colGap : 0) - (root.selectMode ? 18 + root.colGap : 0) text: QbzSession.tr("ITEM", QbzSession.trRev) elide: Text.ElideRight } + ColLabel { + visible: root.showLabel + width: root.colLabel + text: QbzSession.tr("LABEL", QbzSession.trRev) + } ColLabel { width: root.colQuality text: QbzSession.tr("QUALITY", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/views/AlbumListRow.qml b/crates/qbz-qt/qml/views/AlbumListRow.qml index 08990d53c..a86e72a58 100644 --- a/crates/qbz-qt/qml/views/AlbumListRow.qml +++ b/crates/qbz-qt/qml/views/AlbumListRow.qml @@ -76,6 +76,9 @@ Rectangle { // Same columns (and widths) as AlbumListHeader.qml. readonly property int colArt: 52 + /// See AlbumListHeader.showLabel. + property bool showLabel: true + readonly property int colLabel: (!root.showLabel || (root.kioskHost && width < 500)) ? 0 : 150 readonly property int colQuality: root.kioskHost && width < 500 ? 0 : 150 readonly property int colYear: root.kioskHost && width < 500 ? 0 : 64 readonly property int colOverflow: root.kioskHost ? 44 : 36 @@ -107,7 +110,7 @@ Rectangle { return } if (root.selectMode) root.toggleSelect(mouse.modifiers) - else if (!root.pulledDead) QbzAlbum.openAlbum(root.item.id || "") + else if (!root.pulledDead) QbzAlbum.openAlbumFrom(root.item.id || "", root.item.title || "", root.item.artist || "") } // Touch has no right button. Desktop-inert: `pressAndHold` never // fires for a mouse press that is released normally, and the guard @@ -160,13 +163,17 @@ Rectangle { } if (!root.pulledDead && root.catalogRow) m.push({ "label": t("Block this album", r), "icon": "blind-eye", "action": "block" }) + // The label page outlives a pulled release, so this one stays. + if ((root.item.labelId || "") !== "") + m.push({ "label": t("View label", r), "icon": "tags", "action": "view-label" }) return m } function menuAction(a) { var id = root.item.id || "" if (id === "") return + if (a === "view-label") { QbzHome.openLabel(root.item.labelId || ""); return } if (root.pulledDead) return - if (a === "open") QbzAlbum.openAlbum(id) + if (a === "open") QbzAlbum.openAlbumFrom(id, root.item.title || "", root.item.artist || "") else if (a === "play") QbzPlayer.playAlbum(id) // `artUrl`, never `artPath`: the store keeps a denormalized cover url // and a file:// cache path is dead on any other machine. @@ -274,6 +281,7 @@ Rectangle { // multi-select is switched on. width: parent.width - root.colArt - root.colQuality - root.colYear - root.colOverflow - 4 * root.colGap + - (root.colLabel > 0 ? root.colLabel + root.colGap : 0) - (root.selectMode ? 18 + root.colGap : 0) anchors.verticalCenter: parent.verticalCenter spacing: 2 @@ -305,6 +313,32 @@ Rectangle { } } + // LABEL — the release's label, a link to its page when the feed + // carries the id (2026-09-13). Zero-width (and skipped by the Row) + // where the column is off. + Item { + visible: root.colLabel > 0 + width: root.colLabel + height: parent.height + Text { + id: labelText + width: parent.width + anchors.verticalCenter: parent.verticalCenter + text: root.item.label || "" + color: labelArea.containsMouse && labelArea.enabled ? theme.accent : theme.textMuted + font.pixelSize: 12 + elide: Text.ElideRight + MouseArea { + id: labelArea + anchors.fill: parent + enabled: (root.item.labelId || "") !== "" + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: QbzHome.openLabel(root.item.labelId) + } + } + } + // QUALITY — the SAME badge the album page shows (tier label over the // exact bit-depth / sample-rate line). Item { diff --git a/crates/qbz-qt/qml/views/LabelView.qml b/crates/qbz-qt/qml/views/LabelView.qml index 30bcb3c4a..c4b812833 100644 --- a/crates/qbz-qt/qml/views/LabelView.qml +++ b/crates/qbz-qt/qml/views/LabelView.qml @@ -213,7 +213,7 @@ Rectangle { height: root.kioskHost && (cr.kind === "album" || cr.kind === "playlist") ? 64 : 246 KioskCoverSource { id: cellArt; remote: root.kioskHost ? (crCell.modelData.artUrl || "") : ""; local: crCell.modelData.artPath || ""; edge: 44 } - Component { id: crKioskAlbum; AlbumListRow { kioskHost: true; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } + Component { id: crKioskAlbum; AlbumListRow { kioskHost: true; showLabel: false; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } Component { id: crKioskPlaylist; PlaylistListRow { kioskHost: true; item: crCell.modelData; artSource: root.kioskHost ? cellArt.source : (crCell.modelData.artPath || "") } } Component { id: crAlbum diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index 855e4d36a..c43933838 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -88,6 +88,7 @@ Item { title: cell.item.title artist: cell.item.artist artistId: cell.item.artistId + labelId: cell.item.labelId || "" genre: cell.item.genre year: cell.item.year qualityTier: cell.item.qualityTier diff --git a/crates/qbz-qt/src/library_qt.rs b/crates/qbz-qt/src/library_qt.rs index df987b9ca..22ee44f12 100644 --- a/crates/qbz-qt/src/library_qt.rs +++ b/crates/qbz-qt/src/library_qt.rs @@ -151,6 +151,9 @@ pub struct FeedItem { pub genre: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub label: String, + /// Catalog label id behind `label` ("" when the release carries none) — + /// the Releases list column and the "View label" menu entry open it. + pub label_id: String, #[serde(default, rename = "updatedAt", skip_serializing_if = "Option::is_none")] pub updated_at: Option, #[serde(default, rename = "trackCount", skip_serializing_if = "Option::is_none")] @@ -722,6 +725,11 @@ fn map_album(album: Album, ready_offline_tracks: usize) -> FeedItem { subtitle: artist.clone(), artist, artist_id: album.artist.id.to_string(), + label_id: album + .label + .as_ref() + .map(|label| label.id.to_string()) + .unwrap_or_default(), label: album.label.map(|label| label.name).unwrap_or_default(), duration_secs: album.duration.unwrap_or(0), track_count: Some(track_count as u32), @@ -1009,7 +1017,7 @@ async fn probe_release_availability( /// Resolve release-wide availability only for favourite tracks already known /// to be unavailable. Explicit embedded answers cost no request; unknown album /// ids are de-duplicated and probed with a bounded fan-out. -async fn unavailable_release_ids( +pub(crate) async fn unavailable_release_ids( runtime: &Arc>, tracks: &[Track], ) -> HashSet { @@ -1662,6 +1670,7 @@ fn map_purchased_album(a: qbz_models::PurchaseAlbum) -> FeedItem { ), added_at: a.purchased_at.unwrap_or(0), label: a.label.as_ref().map(|label| label.name.clone()).unwrap_or_default(), + label_id: a.label.as_ref().map(|label| label.id.to_string()).unwrap_or_default(), duration_secs: a.duration.unwrap_or(0), track_count: a.tracks_count, genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),