feat(qt): album page parity — cover menu + lightbox, header actions, album info modal

Four wired surfaces on AlbumView, all previously inert stubs:

- Cover menu (right-click, AlbumPageView.slint:300-370): Add/Change/Remove
  custom cover over the SAME custom_artwork.json store the Slint app uses
  (a cover set in one app shows in both), Open in browser, Save as…. Plus
  the NEW lightbox (menu entry + left-click): the art at its best available
  quality fitted to the window, custom override preferred.
- Header action row: radio (the existing foryou_qt album-radio seam),
  booklet (visible when a PDF goody exists; downloads via save-as — no
  in-app reader, like the reference), and add-to-mixtape/collection — the
  MyQBZ picker's Accepts engine already lets albums target Collections
  (the Tauri rule), so the button just feeds it.
- Album Info modal (the extensive one): fresh /album/get → credits grouped
  through the shared qbz_qobuz::performers parser, per-track copyright,
  review tab when the album has editorial text, label link, musician
  navigation, per-track play in album context. Tauri layout (left fixed
  meta column, right scrollable tabs) per the project ruling for this
  feature; the header's label link is wired along the way (LabelView has
  existed for a while — the POC-NOTE was stale).
- … menu: Add to playlist (the loaded view's track ids into the picker),
  Add to mixtape, Share Qobuz link, Share Album.link (UPC -> Deezer ->
  album.link, #514 path — the resolver half of share.rs is now ported for
  the album arm). The offline rows stay out: no Qt album-cache seam.

i18n: "View cover" + "Could not load the image" catalogued in all seven
locales. Gate: both QML audits + 305 tests + debug/release builds green.

km
This commit is contained in:
vicrodh committed 2026-08-10 20:24:25 -06:00
1 parent 2ca7b672e0
commit d9b1c26ff3
18 files changed
+1668 -30

No files matched your search

+2
View File
@@ -197,6 +197,7 @@ fn main() {
"qml/theme/QbzTheme.qml", "qml/theme/QbzTheme.qml",
"qml/theme/RoundedImage.qml", "qml/theme/RoundedImage.qml",
"qml/views/AlbumView.qml", "qml/views/AlbumView.qml",
"qml/views/CoverLightbox.qml",
"qml/views/ArtistReleasesView.qml", "qml/views/ArtistReleasesView.qml",
"qml/views/ArtistView.qml", "qml/views/ArtistView.qml",
"qml/views/BlacklistManagerView.qml", "qml/views/BlacklistManagerView.qml",
@@ -259,6 +260,7 @@ fn main() {
"qml/shell/SongCard.qml", "qml/shell/SongCard.qml",
"qml/shell/TrackInfoBody.qml", "qml/shell/TrackInfoBody.qml",
"qml/shell/TrackInfoModal.qml", "qml/shell/TrackInfoModal.qml",
"qml/shell/AlbumInfoModal.qml",
"qml/shell/TransportControls.qml", "qml/shell/TransportControls.qml",
"qml/views/local/LocalIconSelect.qml", "qml/views/local/LocalIconSelect.qml",
"qml/views/local/LocalTip.qml", "qml/views/local/LocalTip.qml",
+530
View File
@@ -0,0 +1,530 @@
// Album Info (Credits / Review) modal — Qt port of AlbumCreditsModal.slint
// (itself 1:1 with Tauri's AlbumCreditsModal.svelte: left fixed meta column,
// right scrollable tabs). Opened from the AlbumView header info button.
//
// DATA: QbzAlbum.openAlbumInfo(albumId) publishes QbzAlbum.albumInfoJson
// while albumInfoLoading is true (album_info_qt.rs). Document shape:
// { "error": "", "albumId": "", "title": "", "artist": "",
// "artUrl": "", "customCoverPath": "", "label": "", "labelId": "",
// "releaseDate": "September 2, 2021",
// "metaLine": "Hard Rock · 10 tracks · 1h 21m",
// "quality": "24-Bit / 96 kHz", "review": "", "hasReview": true,
// "tracks": [ { "id", "number", "title", "artist", "hasCredits",
// "copyright", "performers":
// [ { "name", "roles", "primaryRole" } ] } ] }
// Tab switching and close are QML-local; the bridge carries data only.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Popup {
id: root
parent: Overlay.overlay
x: 0
y: 0
width: parent ? parent.width : 0
height: parent ? parent.height : 0
padding: 0
z: 3000
modal: true
// Our own scrim (the reference's #000000bf) — the default modal dimmer
// would darken it twice.
dim: false
closePolicy: Popup.CloseOnEscape
QbzTheme { id: theme }
// --- Data ------------------------------------------------------------
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzAlbum.albumInfoJson || "{}")
} catch (e) {
return ({})
}
}
readonly property bool loading: QbzAlbum.albumInfoLoading === true
readonly property string errorText: QbzAlbum.albumInfoError || ""
property string activeTab: "credits"
// --- Actions ---------------------------------------------------------
/// Header info button. The view gates local/Plex albums (no button);
/// the Rust side re-checks catalog ids.
function openFor(albumId) {
if (!albumId || albumId === "")
return
root.activeTab = "credits"
QbzAlbum.openAlbumInfo(albumId)
open()
}
function playTrack(trackId) {
if (!trackId || trackId === "")
return
// Album context: the queue keeps the album, the cursor starts at the
// tapped row (same seam as the view's own track rows).
QbzPlayer.playAlbumFrom(doc.albumId || "", trackId)
}
function openLabel(labelId) {
if (!labelId || labelId === "")
return
close()
QbzHome.openLabel(labelId)
}
function openMusician(name, primaryRole) {
if (!name || name === "")
return
close()
QbzArtist.resolveMusician(name, primaryRole || "")
}
// Cover source: the custom override beats the remote best() URL, same
// rule as the AlbumView header.
readonly property string coverSource: (doc.customCoverPath || "") !== ""
? "file://" + doc.customCoverPath
: (doc.artUrl || "")
background: Rectangle { color: "#bf000000" }
// One Credits row: number/play-on-hover, title + artist, expand chevron;
// the expanded panel lists performers (name links) + copyright.
component CreditTrackRow: Column {
id: row
property var track: ({})
property bool isLast: false
property bool expanded: false
width: parent ? parent.width : 0
Rectangle {
width: parent.width
height: 46
color: "transparent"
opacity: (rowArea.containsMouse && row.track.hasCredits) ? 0.8 : 1.0
MouseArea {
id: rowArea
anchors.fill: parent
hoverEnabled: true
cursorShape: row.track.hasCredits ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (row.track.hasCredits) row.expanded = !row.expanded
}
Row {
anchors.fill: parent
spacing: 12
// Number / play-on-hover cell.
Item {
width: 28
height: parent.height
Text {
anchors.centerIn: parent
visible: !rowArea.containsMouse && !playCell.containsMouse
text: row.track.number || ""
color: theme.textMuted
font.pixelSize: 14
}
Rectangle {
anchors.centerIn: parent
visible: rowArea.containsMouse || playCell.containsMouse
width: 28
height: 28
radius: 14
color: theme.accent
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 14
height: 14
tintName: theme.accentGlyphTint
}
}
MouseArea {
id: playCell
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.playTrack(row.track.id)
}
}
Column {
width: parent.width - 28 - 18 - (credIcon.visible ? 18 : 0) - 36
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.track.title || ""
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.track.artist || ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
}
}
QbzIcon {
id: credIcon
visible: row.track.hasCredits === true
anchors.verticalCenter: parent.verticalCenter
name: row.expanded ? "chevron-up" : "chevron-down"
width: 18
height: 18
tintName: row.expanded ? "primary" : "muted"
}
}
}
// Expanded credits.
Column {
visible: row.expanded && row.track.hasCredits
width: parent.width
leftPadding: 40
bottomPadding: 12
spacing: 0
Repeater {
model: row.track.performers || []
delegate: Row {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
topPadding: 4
bottomPadding: 4
Text {
id: perfName
text: modelData.name
color: perfArea.containsMouse ? theme.accent : theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
id: perfArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openMusician(modelData.name, modelData.primaryRole)
}
}
Text {
width: parent.width - perfName.implicitWidth
text: modelData.roles
color: theme.textMuted
font.pixelSize: 13
wrapMode: Text.WordWrap
}
}
}
Item { visible: (row.track.copyright || "") !== ""; width: 1; height: 8 }
Rectangle {
visible: (row.track.copyright || "") !== ""
width: parent.width
height: 1
color: theme.borderSubtle
}
Item { visible: (row.track.copyright || "") !== ""; width: 1; height: 8 }
Text {
visible: (row.track.copyright || "") !== ""
width: parent.width
text: row.track.copyright || ""
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
Rectangle {
visible: !row.isLast
width: parent.width
height: 1
color: theme.borderSubtle
}
}
contentItem: Item {
// Scrim — click outside dismisses.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: card
width: Math.min(root.width - 40, 850)
height: Math.min(root.height - 80, bodyCol.implicitHeight + 24)
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
clip: true
// Swallow clicks so they don't reach the scrim.
MouseArea { anchors.fill: parent }
Column {
id: bodyCol
width: parent.width
spacing: 0
// ---- Header ----------------------------------------------
Row {
width: parent.width
height: 62
spacing: 16
Item { width: 8; height: 1 }
Column {
width: parent.width - 82
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
width: parent.width
text: root.doc.title || ""
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.doc.artist || ""
color: theme.textMuted
font.pixelSize: theme.fontLegal
elide: Text.ElideRight
}
}
Rectangle {
width: 32
height: 32
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 18
height: 18
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.close()
}
}
Item { width: 8; height: 1 }
}
// ---- Body (two columns) ----------------------------------
Row {
width: parent.width - 48
x: 24
spacing: 24
// LEFT — album meta (fixed 260px).
Column {
width: 260
spacing: 0
Rectangle {
width: 200
height: 200
radius: 8
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverSource
radius: 8
}
}
Item { width: 1; height: 16 }
Column {
visible: (root.doc.label || "") !== ""
spacing: 0
Text {
text: QbzSession.tr("Released by", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 13
}
Text {
text: root.doc.label || ""
color: (labelArea.containsMouse && (root.doc.labelId || "") !== "")
? theme.accent : theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
MouseArea {
id: labelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: (root.doc.labelId || "") !== ""
? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.openLabel(root.doc.labelId || "")
}
}
Text {
visible: (root.doc.releaseDate || "") !== ""
text: QbzSession.tr("on", QbzSession.trRev) + " " + (root.doc.releaseDate || "")
color: theme.textMuted
font.pixelSize: 13
}
}
Item { visible: (root.doc.label || "") !== ""; width: 1; height: 8 }
Text {
visible: (root.doc.metaLine || "") !== ""
width: parent.width
text: root.doc.metaLine || ""
color: theme.textMuted
font.pixelSize: 13
wrapMode: Text.WordWrap
}
Item { visible: (root.doc.quality || "") !== ""; width: 1; height: 12 }
Text {
visible: (root.doc.quality || "") !== ""
text: root.doc.quality || ""
color: theme.textSecondary
font.pixelSize: 13
}
}
// RIGHT — tabs + scrollable content.
Column {
width: parent.width - 260 - 24
spacing: 0
// Tab switcher (only when a review exists).
Column {
visible: root.doc.hasReview === true
width: parent.width
spacing: 0
Row {
spacing: 16
Text {
text: QbzSession.tr("Credits", QbzSession.trRev)
color: root.activeTab === "credits" ? theme.accent : theme.textMuted
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = "credits"
}
}
Text {
text: QbzSession.tr("Review", QbzSession.trRev)
color: root.activeTab === "review" ? theme.accent : theme.textMuted
font.pixelSize: 13
font.weight: theme.weightMedium
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = "review"
}
}
}
Item { width: 1; height: 12 }
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 12 }
}
// Content. Fixed height so the card's height binding
// stays acyclic: the Flickable scrolls what overflows.
Rectangle {
width: parent.width
height: 320
color: "transparent"
clip: true
Flickable {
id: flick
anchors.fill: parent
contentWidth: width
contentHeight: contentCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: contentCol
width: flick.width - 18
spacing: 0
Column {
visible: root.activeTab === "credits"
width: parent.width
spacing: 0
Repeater {
model: root.doc.tracks || []
delegate: CreditTrackRow {
required property var modelData
required property int index
track: modelData
isLast: index === (root.doc.tracks || []).length - 1
}
}
}
Text {
visible: root.activeTab === "review"
width: parent.width
text: root.doc.review || ""
color: theme.textSecondary
font.pixelSize: 14
wrapMode: Text.WordWrap
}
}
}
QbzScrollBar {
target: flick
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}
Item { width: 1; height: 24 }
}
// ---- Loading / error overlay ----------------------------------
Rectangle {
anchors.fill: parent
visible: root.loading || root.errorText !== ""
color: theme.surfaceCard
radius: theme.radiusMd
MouseArea { anchors.fill: parent }
Column {
anchors.centerIn: parent
spacing: 8
QbzSpinner {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.loading && root.errorText === ""
}
Text {
visible: root.errorText !== ""
text: QbzSession.tr("Failed to load album credits", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
Text {
visible: root.errorText !== ""
width: Math.min(card.width - 96, implicitWidth)
text: root.errorText
color: theme.textMuted
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
}
+148 -17
View File
@@ -7,9 +7,8 @@
// heart), label/awards sidebar, and the two bottom carousels ("From the // heart), label/awards sidebar, and the two bottom carousels ("From the
// same artist", "Listening suggestions"). // same artist", "Listening suggestions").
// //
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet, // POC-NOTEs: multi-select + bulk bar and the offline download column —
// custom-cover menu, album-info modal — out of scope (visible stubs are // out of scope (visible stubs are inert).
// inert).
// //
// Header atmosphere (AlbumPageView.slint:161-189, 221-257): the // Header atmosphere (AlbumPageView.slint:161-189, 221-257): the
// artwork-tinted band IS wired now, through the shared // artwork-tinted band IS wired now, through the shared
@@ -25,6 +24,7 @@ import QtQuick.Controls
import com.blitzfc.qbz import com.blitzfc.qbz
import "../controls" import "../controls"
import "../rows" import "../rows"
import "../shell"
import "../theme" import "../theme"
Rectangle { Rectangle {
@@ -488,9 +488,29 @@ Rectangle {
clip: true clip: true
RoundedImage { RoundedImage {
anchors.fill: parent anchors.fill: parent
source: root.coverMap[header.artUrl] || "" // A custom cover override (shared custom_artwork
// store) beats the url-keyed pipeline image.
source: (header.customCoverPath || "") !== ""
? "file://" + header.customCoverPath
: (root.coverMap[header.artUrl] || "")
radius: 12 radius: 12
} }
// Left-click: the lightbox (NEW — Slint's left-click is
// inert). Right-click: the cover menu (Slint
// AlbumPageView.slint:300-370).
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
coverMenu.openAtCursor(coverMenuAnchor, mouse.x, mouse.y)
} else {
coverLightbox.openWith(root.bestCoverSource())
}
}
}
Item { id: coverMenuAnchor; anchors.fill: parent }
} }
Column { Column {
@@ -568,7 +588,7 @@ Rectangle {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
// POC-NOTE: no label view yet. onClicked: QbzHome.openLabel(header.labelId)
} }
} }
Text { Text {
@@ -649,29 +669,39 @@ Rectangle {
QbzLibrary.libraryToggleFavorite("album", header.id) QbzLibrary.libraryToggleFavorite("album", header.id)
} }
} }
// Radio / Mixtape / Album info: no seam on this // Radio: the Qobuz /radio/album endpoint, via the
// bridge (radio engines, mixtape store, credits // For-You tile seam (foryou_qt::start_album_radio —
// modal). They stay VISIBLE but DISABLED — the // minimal track objects are enriched before play).
// .slint's own `enabled: false` treatment (dimmed to
// 0.4, click gated) — rather than rendering as live
// buttons that do nothing on click.
QbzCircleAction { QbzCircleAction {
name: "radio" name: "radio"
overlay: root.hdrOverlay overlay: root.hdrOverlay
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: QbzHome.startAlbumRadio(header.id)
} }
// Booklet: only when the album carries a PDF goody
// (AlbumPageView.slint:575-585); downloads via save-as.
QbzCircleAction {
visible: header.hasBooklet === true
name: "book-open"
overlay: root.hdrOverlay
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzAlbum.downloadBooklet()
}
// Mixtape/Collection picker (an album is accepted by
// BOTH kinds — myqbz_add_qt's Accepts engine).
QbzCircleAction { QbzCircleAction {
name: "cassette-tape" name: "cassette-tape"
overlay: root.hdrOverlay overlay: root.hdrOverlay
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: QbzAlbum.addToMixtape(header.id)
} }
// Album info: the Credits/Review modal
// (AlbumInfoModal.qml, data via album_info_qt.rs).
QbzCircleAction { QbzCircleAction {
name: "info" name: "info"
overlay: root.hdrOverlay overlay: root.hdrOverlay
btnEnabled: false
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: albumInfo.openFor(header.id)
} }
QbzCircleAction { QbzCircleAction {
id: albumMenuBtn id: albumMenuBtn
@@ -1148,9 +1178,91 @@ Rectangle {
target: pageFlick target: pageFlick
} }
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin + the block // Best available cover source for the lightbox: the custom override
// toggle; the .slint's playlist/mixtape/share/offline rows are the parts // file when set, else the remote best() URL (mega → … → large).
// still out of scope). function bestCoverSource() {
var custom = header.customCoverPath || ""
if (custom !== "") return "file://" + custom
return header.artUrl || ""
}
// The cover menu's rows, rebuilt per open (Add vs Change+Remove flips
// on hasCustomCover; "View cover" is this port's addition).
function buildCoverMenuModel() {
var rows = []
if (header.hasCustomCover === true) {
rows.push({ "label": QbzSession.tr("Change cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
rows.push({ "label": QbzSession.tr("Remove cover", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
} else {
rows.push({ "label": QbzSession.tr("Add cover", QbzSession.trRev), "icon": "image-plus", "action": "add" })
}
rows.push({ "label": QbzSession.tr("View cover", QbzSession.trRev), "icon": "eye", "action": "view" })
rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" })
rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" })
return rows
}
// Cover right-click menu (AlbumPageView.slint:300-370) + "View cover",
// the lightbox entry that is NEW in this port.
QbzContextMenu {
id: coverMenu
menuWidth: 196
// Rebuilt on every open so Add/Change/Remove track the live flag.
onAboutToShow: coverMenuRepeater.model = root.buildCoverMenuModel()
Repeater {
id: coverMenuRepeater
model: []
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: cmiArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
width: parent.width - 23
text: modelData.label
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
MouseArea {
id: cmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
coverMenu.close()
var a = modelData.action
if (a === "add") QbzAlbum.coverAddCustom(header.id)
else if (a === "remove") QbzAlbum.coverRemoveCustom(header.id)
else if (a === "view") coverLightbox.openWith(root.bestCoverSource())
else if (a === "browser") QbzShell.openExternalUrl(header.artUrl)
else if (a === "save") QbzAlbum.coverSaveAs(header.id, header.title, header.artUrl)
}
}
}
}
}
CoverLightbox { id: coverLightbox }
// Album Info (Credits/Review) modal — mounted by its host view, the
// TrackInfoModal pattern (one AlbumView instance exists at a time; the
// Popup reparents to the window overlay).
AlbumInfoModal { id: albumInfo }
// Album ⋯ menu (AlbumContextMenu subset — the offline rows stay out:
// Qt has no album offline-cache seam. "Play later" as a distinct
// block-tail insert has no mode in playback_qt::enqueue_album either
// (next / append only), so "Add to queue" covers the append arm).
QbzContextMenu { QbzContextMenu {
id: albumMenu id: albumMenu
menuWidth: 196 menuWidth: 196
@@ -1161,6 +1273,10 @@ Rectangle {
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
{ "label": root.toggleState("album", header.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", header.isFavorite) ? "heart-filled" : "heart", "action": "favorite" }, { "label": root.toggleState("album", header.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", header.isFavorite) ? "heart-filled" : "heart", "action": "favorite" },
{ "label": root.toggleState("pin", header.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", header.isPinned) ? "pin-filled" : "pin", "action": "pin" }, { "label": root.toggleState("pin", header.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", header.isPinned) ? "pin-filled" : "pin", "action": "pin" },
{ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" },
{ "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" },
{ "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" },
{ "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" },
] ]
delegate: Rectangle { delegate: Rectangle {
required property var modelData required property var modelData
@@ -1200,6 +1316,21 @@ Rectangle {
} else if (a === "pin") { } else if (a === "pin") {
root.setToggleState("pin", !root.toggleState("pin", header.isPinned)) root.setToggleState("pin", !root.toggleState("pin", header.isPinned))
QbzLibrary.togglePin("album", header.id, header.title, header.artist, header.artUrl) QbzLibrary.togglePin("album", header.id, header.title, header.artist, header.artUrl)
} else if (a === "playlist") {
// The loaded view's own track ids (Slint
// main.rs:12213 collects AlbumState.tracks).
var ids = []
var ts = root.album.tracks || []
for (var i = 0; i < ts.length; i++) {
if (/^\d+$/.test(ts[i].id || "")) ids.push(ts[i].id)
}
if (ids.length > 0) QbzPlaylistPicker.openForTracks(JSON.stringify(ids))
} else if (a === "mixtape") {
QbzAlbum.addToMixtape(header.id)
} else if (a === "share-qobuz") {
QbzAlbum.shareQobuzLink(header.id)
} else if (a === "share-albumlink") {
QbzAlbum.shareAlbumLink(header.id)
} }
} }
} }
+83
View File
@@ -0,0 +1,83 @@
// Cover lightbox — NEW in the Qt port (no Slint counterpart): the album art
// at its best available quality, fitted to the window. Opened by left-click
// on the AlbumView cover or by the cover menu's "View cover" entry.
//
// Shape follows TrackInfoModal: a full-window Popup parented to
// Overlay.overlay, a dim scrim whose click closes, Escape closes. The image
// asks for a decode capped at the window size (sourceSize), so a mega
// variant does not cost a full-res decode to display at ~90% of the window.
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
import "../theme"
Popup {
id: root
parent: Overlay.overlay
x: 0
y: 0
width: parent ? parent.width : 0
height: parent ? parent.height : 0
padding: 0
closePolicy: Popup.CloseOnEscape
// file:// (custom cover) or the remote best() URL. Set through openWith.
property string artSource: ""
function openWith(source) {
root.artSource = source
root.open()
}
QbzTheme { id: theme }
background: Rectangle {
color: "#bf000000"
}
contentItem: Item {
// Scrim click closes; clicks on the image itself are swallowed so
// the lightbox stays up while the user inspects it.
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
// Fit the LONGER window axis at 90%, so the cover never touches the
// rim. Covers are square; PreserveAspectFit keeps any odd one honest.
readonly property real side: Math.min(root.width, root.height) * 0.9
Image {
id: art
anchors.centerIn: parent
width: parent.side
height: parent.side
source: root.artSource
asynchronous: true
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(parent.side, parent.side)
smooth: true
visible: status === Image.Ready
MouseArea {
anchors.fill: parent
onClicked: {} // swallow — the scrim's close must not fire here
}
}
QbzSpinner {
anchors.centerIn: parent
visible: root.artSource !== "" && art.status === Image.Loading
}
Text {
anchors.centerIn: parent
visible: root.artSource !== "" && art.status === Image.Error
text: QbzSession.tr("Could not load the image", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
}
+66 -1
View File
@@ -42,6 +42,12 @@ pub mod qbz_album_bridge {
// --- Track Info modal (qml/shell/TrackInfoModal.qml) -------------- // --- Track Info modal (qml/shell/TrackInfoModal.qml) --------------
#[qproperty(bool, track_info_loading)] #[qproperty(bool, track_info_loading)]
#[qproperty(QString, track_info_json)] #[qproperty(QString, track_info_json)]
// --- Album Info modal (qml/shell/AlbumInfoModal.qml) --------------
// One JSON document (album_info_qt.rs); loading + error ride their
// own properties so the modal can show its skeleton without a parse.
#[qproperty(bool, album_info_loading)]
#[qproperty(QString, album_info_json)]
#[qproperty(QString, album_info_error)]
type QbzAlbum = super::QbzAlbumRust; type QbzAlbum = super::QbzAlbumRust;
@@ -57,6 +63,32 @@ pub mod qbz_album_bridge {
/// Info document (track_info_qt.rs). /// Info document (track_info_qt.rs).
#[qinvokable] #[qinvokable]
fn open_track_info(self: Pin<&mut QbzAlbum>, track_id: QString); fn open_track_info(self: Pin<&mut QbzAlbum>, track_id: QString);
/// Header cassette button: open the MyQBZ picker with this album as
/// the payload (album_qt::add_to_mixtape).
#[qinvokable]
fn add_to_mixtape(self: Pin<&mut QbzAlbum>, album_id: QString);
/// Header booklet button: download the open album's booklet PDF to a
/// user-chosen path (album_qt::download_booklet).
#[qinvokable]
fn download_booklet(self: Pin<&mut QbzAlbum>);
/// Cover right-click menu (cover_artwork_qt.rs): pick / clear the
/// custom cover override, save the artwork to disk.
#[qinvokable]
fn cover_add_custom(self: Pin<&mut QbzAlbum>, album_id: QString);
#[qinvokable]
fn cover_remove_custom(self: Pin<&mut QbzAlbum>, album_id: QString);
#[qinvokable]
fn cover_save_as(self: Pin<&mut QbzAlbum>, album_id: QString, title: QString, artwork_url: QString);
/// ⋯ menu Share rows (share_qt.rs): copy the Qobuz link, or resolve
/// UPC -> Deezer -> Album.link and copy that (async, toasts).
#[qinvokable]
fn share_qobuz_link(self: Pin<&mut QbzAlbum>, album_id: QString);
#[qinvokable]
fn share_album_link(self: Pin<&mut QbzAlbum>, album_id: QString);
/// Header info button: fetch + publish the Album Info (credits /
/// review) document (album_info_qt.rs).
#[qinvokable]
fn open_album_info(self: Pin<&mut QbzAlbum>, album_id: QString);
} }
impl cxx_qt::Threading for QbzAlbum {} impl cxx_qt::Threading for QbzAlbum {}
@@ -70,6 +102,9 @@ pub struct QbzAlbumRust {
album_json: QString, album_json: QString,
track_info_loading: bool, track_info_loading: bool,
track_info_json: QString, track_info_json: QString,
album_info_loading: bool,
album_info_json: QString,
album_info_error: QString,
} }
impl Default for QbzAlbumRust { impl Default for QbzAlbumRust {
@@ -79,6 +114,9 @@ impl Default for QbzAlbumRust {
album_json: QString::from("{}"), album_json: QString::from("{}"),
track_info_loading: false, track_info_loading: false,
track_info_json: QString::from("{}"), track_info_json: QString::from("{}"),
album_info_loading: false,
album_info_json: QString::from("{}"),
album_info_error: QString::default(),
} }
} }
} }
@@ -110,5 +148,32 @@ impl qbz_album_bridge::QbzAlbum {
pub fn open_track_info(self: Pin<&mut Self>, track_id: QString) { pub fn open_track_info(self: Pin<&mut Self>, track_id: QString) {
crate::track_info_qt::open(track_id.to_string()); crate::track_info_qt::open(track_id.to_string());
} }
pub fn add_to_mixtape(self: Pin<&mut Self>, album_id: QString) {
crate::album_qt::add_to_mixtape(album_id.to_string());
}
pub fn download_booklet(self: Pin<&mut Self>) {
crate::album_qt::download_booklet();
}
pub fn cover_add_custom(self: Pin<&mut Self>, album_id: QString) {
crate::cover_artwork_qt::add_custom_cover(album_id.to_string());
}
pub fn cover_remove_custom(self: Pin<&mut Self>, album_id: QString) {
crate::cover_artwork_qt::remove_custom_cover(album_id.to_string());
}
pub fn cover_save_as(self: Pin<&mut Self>, album_id: QString, title: QString, artwork_url: QString) {
crate::cover_artwork_qt::save_cover_as(
album_id.to_string(),
title.to_string(),
artwork_url.to_string(),
);
}
pub fn share_qobuz_link(self: Pin<&mut Self>, album_id: QString) {
crate::share_qt::share_album_qobuz(album_id.to_string());
}
pub fn share_album_link(self: Pin<&mut Self>, album_id: QString) {
crate::share_qt::share_album_link(album_id.to_string());
}
pub fn open_album_info(self: Pin<&mut Self>, album_id: QString) {
crate::album_info_qt::open(album_id.to_string());
}
} }
+260
View File
@@ -0,0 +1,260 @@
//! Album Info (Credits / Review) modal document — the Qt port of
//! `qbz/src/info_modals.rs::open_album_credits` + `map_album_credits`,
//! opened by the AlbumView header's info button. Layout reference per the
//! project ruling for this feature: Tauri's `AlbumCreditsModal.svelte`
//! (left fixed meta column, right scrollable Credits/Review tabs).
//!
//! Performer parsing stays in the shared crate
//! (`qbz_qobuz::performers`, ADR-006); this module fetches the album and
//! shapes the JSON document `qml/shell/AlbumInfoModal.qml` parses. Tab
//! switching and close are QML-local state — the doc carries data only.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::{Album, Track};
use qbz_qobuz::performers::parse_performers;
use serde::Serialize;
use crate::album_bridge;
use cxx_qt_lib::QString;
#[derive(Serialize)]
struct PerformerRow {
name: String,
/// ", Role1, Role2" suffix (empty when role-less) — RAW roles, not
/// localized, 1:1 with Tauri (Slint `info_modals.rs::roles_suffix`).
roles: String,
/// First role or "Performer" — the musician-nav hint
/// (Tauri `handlePerformerClick`: roles[0] || 'Performer').
#[serde(rename = "primaryRole")]
primary_role: String,
}
#[derive(Serialize)]
struct InfoTrack {
id: String,
number: String,
title: String,
artist: String,
#[serde(rename = "hasCredits")]
has_credits: bool,
performers: Vec<PerformerRow>,
copyright: String,
}
#[derive(Serialize, Default)]
struct AlbumInfoDoc {
error: String,
/// The album id, so the modal's per-track play can rebuild the queue
/// context (QbzPlayer.playAlbumFrom).
#[serde(rename = "albumId")]
album_id: String,
title: String,
artist: String,
/// Best-quality artwork URL; the modal falls back to the custom-cover
/// override path when one is set (same rule as the header).
#[serde(rename = "artUrl")]
art_url: String,
#[serde(rename = "customCoverPath")]
custom_cover_path: String,
label: String,
#[serde(rename = "labelId")]
label_id: String,
/// Full localized date ("September 2, 2021").
#[serde(rename = "releaseDate")]
release_date: String,
/// "Hard Rock · 10 tracks · 1h 21m" — gated on a genre, 1:1 with Tauri.
#[serde(rename = "metaLine")]
meta_line: String,
/// "24-Bit / 96 kHz" (Tauri `formatQuality`; empty when unknown).
quality: String,
review: String,
#[serde(rename = "hasReview")]
has_review: bool,
tracks: Vec<InfoTrack>,
}
/// "1h 21m" / "45m" (Slint `album_duration`; Tauri `formatAlbumDuration`).
fn album_duration(secs: u32) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
if hours > 0 {
qbz_i18n::t_args("{} h {} min", &[&hours.to_string(), &minutes.to_string()])
} else {
qbz_i18n::t_args("{} min", &[&minutes.to_string()])
}
}
/// Sample rate without a trailing ".0" (96.0 -> "96", 44.1 -> "44.1") — JS
/// number interpolation parity, NOT normalized to kHz from Hz.
fn fmt_rate(rate: f64) -> String {
if rate.fract().abs() < f64::EPSILON {
format!("{}", rate as i64)
} else {
format!("{rate}")
}
}
/// "24-Bit / 96 kHz" (capital Bit, space before kHz) — Tauri
/// `formatQuality(bitDepth, samplingRate)`; empty when both are absent.
fn album_quality(bit: Option<u32>, rate: Option<f64>) -> String {
match (bit, rate) {
(Some(b), Some(r)) => format!("{b}-Bit / {} kHz", fmt_rate(r)),
(Some(b), None) => format!("{b}-Bit"),
(None, Some(r)) => format!("{} kHz", fmt_rate(r)),
(None, None) => String::new(),
}
}
/// ", Role1, Role2" or "" (raw roles, not localized — Tauri parity).
fn roles_suffix(roles: &[String]) -> String {
if roles.is_empty() {
String::new()
} else {
format!(", {}", roles.join(", "))
}
}
fn map_track(index: usize, t: &Track, album_artist: &str) -> InfoTrack {
let performers: Vec<PerformerRow> = parse_performers(t.performers.as_deref().unwrap_or_default())
.into_iter()
.map(|p| PerformerRow {
roles: roles_suffix(&p.roles),
primary_role: p
.roles
.first()
.cloned()
.unwrap_or_else(|| qbz_i18n::t("Performer")),
name: p.name,
})
.collect();
let copyright = t
.copyright
.as_deref()
.map(qbz_text_utils::strip_html::decode_html_entities)
.unwrap_or_default();
let number = if t.track_number > 0 {
t.track_number.to_string()
} else {
(index + 1).to_string()
};
let artist = t
.performer
.as_ref()
.map(|a| a.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| {
if album_artist.is_empty() {
qbz_i18n::t("Unknown Artist")
} else {
album_artist.to_string()
}
});
InfoTrack {
id: t.id.to_string(),
number,
title: crate::album_qt::format_album_title(&t.title, t.version.as_deref()),
artist,
has_credits: !performers.is_empty() || !copyright.is_empty(),
performers,
copyright,
}
}
fn map(album: Album) -> AlbumInfoDoc {
let album_artist = album.artist.name.clone();
let (label, label_id) = match album.label.as_ref() {
Some(l) => (l.name.clone(), l.id.to_string()),
None => (String::new(), String::new()),
};
let raw_tracks = album
.tracks
.as_ref()
.map(|c| c.items.clone())
.unwrap_or_default();
let track_count = album
.tracks_count
.or(album.track_count)
.unwrap_or(raw_tracks.len() as u32);
// Tauri always appends formatAlbumDuration(album.duration || 0), so an
// absent duration shows "· 0m" rather than dropping it.
let meta_line = match album.genre.as_ref().filter(|g| !g.name.is_empty()) {
Some(g) => [
g.name.clone(),
qbz_i18n::tf("{} track", "{} tracks", track_count as i64, &[&track_count.to_string()]),
album_duration(album.duration.unwrap_or(0)),
]
.join(" · "),
None => String::new(),
};
let review = album
.description
.as_deref()
.map(qbz_text_utils::strip_html::strip_html)
.unwrap_or_default();
let has_review = !review.trim().is_empty();
AlbumInfoDoc {
error: String::new(),
album_id: album.id.clone(),
title: crate::album_qt::format_album_title(&album.title, album.version.as_deref()),
artist: album_artist.clone(),
art_url: album.image.best().cloned().unwrap_or_default(),
custom_cover_path: crate::cover_artwork_qt::album_cover(&album.id).unwrap_or_default(),
label,
label_id,
release_date: qbz_text_utils::dates::full_release_label(
album.release_date_original.as_deref(),
),
meta_line,
quality: album_quality(album.maximum_bit_depth, album.maximum_sampling_rate),
review,
has_review,
tracks: raw_tracks
.iter()
.enumerate()
.map(|(i, t)| map_track(i, t, &album_artist))
.collect(),
}
}
fn publish(doc: &AlbumInfoDoc, loading: bool) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
album_bridge::ui(move |mut b| {
b.as_mut().set_album_info_json(QString::from(json.as_str()));
b.as_mut().set_album_info_loading(loading);
});
}
/// Header info button: open the modal in its loading state, fetch the album
/// fresh (the credits/copyright fields are not in the view's document), then
/// publish. Non-catalog ids (local/Plex keys) are rejected — the button is
/// hidden for those, this is the backstop.
pub fn open(album_id: String) {
if album_id.parse::<u64>().is_err() {
log::debug!("[qbz-qt] album info skipped for non-catalog id '{album_id}'");
return;
}
album_bridge::ui(|mut b| {
b.as_mut().set_album_info_loading(true);
b.as_mut().set_album_info_error(QString::default());
});
let runtime: Arc<AppRuntime<LoggingAdapter>> = crate::app();
crate::spawn(async move {
match runtime.core().get_album(&album_id).await {
Ok(album) => publish(&map(album), false),
Err(e) => {
log::warn!("[qbz-qt] album info load failed for {album_id}: {e}");
let msg = e.to_string();
album_bridge::ui(move |mut b| {
b.as_mut().set_album_info_loading(false);
b.as_mut().set_album_info_error(QString::from(msg.as_str()));
});
}
}
});
}
+173 -2
View File
@@ -20,8 +20,8 @@
//! Last.fm answer for album A can never paint onto album B. //! Last.fm answer for album A can never paint onto album B.
//! //!
//! POC-NOTEs: //! POC-NOTEs:
//! - Booklet download/rasterize, multi-select + bulk bar, DiscHeaderMenu, //! - Multi-select + bulk bar and the offline download column: out of scope
//! album-info modal, custom covers: out of scope (inert stubs where visible). //! (inert stubs where visible).
//! - Blacklist filtering on carousels: skipped (store not open). //! - Blacklist filtering on carousels: skipped (store not open).
//! - The album-header-gradient atmosphere: not wired (appearance pref). //! - The album-header-gradient atmosphere: not wired (appearance pref).
//! - The Last.fm "similar albums" second suggestions row needs the //! - The Last.fm "similar albums" second suggestions row needs the
@@ -176,6 +176,20 @@ pub struct AlbumHeader {
/// `isPinned` existed. /// `isPinned` existed.
#[serde(rename = "isAlbumBlocked")] #[serde(rename = "isAlbumBlocked")]
pub is_album_blocked: bool, pub is_album_blocked: bool,
/// The header's booklet button: a PDF goody with a usable URL exists
/// (Slint `album.rs` `has_booklet`). The URL itself is stashed Rust-side
/// by `load_album` for `download_booklet`.
#[serde(rename = "hasBooklet")]
pub has_booklet: bool,
/// A user-picked cover override exists (Slint `custom_artwork` store,
/// shared file). The view swaps the header image to `customCoverPath`
/// and flips the cover menu's Add/Change/Remove rows.
#[serde(rename = "hasCustomCover")]
pub has_custom_cover: bool,
/// Absolute path of the override ("" when none) — the header image reads
/// it as file://, bypassing the url-keyed pipeline.
#[serde(rename = "customCoverPath")]
pub custom_cover_path: String,
/// EXTERNAL LINKS sidebar block — deep links into the three music /// EXTERNAL LINKS sidebar block — deep links into the three music
/// databases, built from artist + title. These are plain URLs handed to /// databases, built from artist + title. These are plain URLs handed to
/// the system browser on click: nothing is fetched, no account is needed /// the system browser on click: nothing is fetched, no account is needed
@@ -571,6 +585,14 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
.filter(|(_, n)| !n.is_empty()) .filter(|(_, n)| !n.is_empty())
.collect(); .collect();
let title = crate::album_qt::format_album_title(&album.title, album.version.as_deref()); 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
// goody whose url/original_url ends in ".pdf"; `original_url` (full-size)
// wins over the thumbnail `url` (Slint `album.rs:307-333`). The URL is
// stashed for `download_booklet` — the header only carries the flag.
let booklet_url = pick_booklet_url(&album);
set_booklet_stash(&title, &booklet_url);
let tracks: Vec<TrackRow> = album let tracks: Vec<TrackRow> = album
.tracks .tracks
.as_ref() .as_ref()
@@ -581,6 +603,11 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
let links = external_links(&artist, &title); let links = external_links(&artist, &title);
let (lastfm_url, discogs_url, musicbrainz_url) = links.clone().unwrap_or_default(); let (lastfm_url, discogs_url, musicbrainz_url) = links.clone().unwrap_or_default();
// Custom cover override (shared custom_artwork.json store) — seeded on
// every build so the header swaps image source and the cover menu flips
// its Add/Change/Remove rows.
let custom_cover = crate::cover_artwork_qt::album_cover(&album.id).unwrap_or_default();
let header = AlbumHeader { let header = AlbumHeader {
show_external_links: links.is_some(), show_external_links: links.is_some(),
lastfm_url, lastfm_url,
@@ -620,6 +647,9 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
.map(|l| l.id.to_string()) .map(|l| l.id.to_string())
.unwrap_or_default(), .unwrap_or_default(),
awards, awards,
has_booklet: !booklet_url.is_empty(),
has_custom_cover: !custom_cover.is_empty(),
custom_cover_path: custom_cover,
}; };
Ok(AlbumViewData { Ok(AlbumViewData {
header, header,
@@ -628,6 +658,147 @@ pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &st
}) })
} }
// ==================== Booklet (Slint booklet.rs port) ======================
/// Booklet download timeout (matches the Slint client, `booklet.rs:19`).
const BOOKLET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// (title, url) of the open album's booklet goody — stashed by `load_album`.
/// The header doc carries only the `hasBooklet` flag; the URL never crosses
/// the bridge until the user actually asks for the file.
static BOOKLET_STASH: std::sync::Mutex<(String, String)> =
std::sync::Mutex::new((String::new(), String::new()));
fn set_booklet_stash(title: &str, url: &str) {
if let Ok(mut s) = BOOKLET_STASH.lock() {
*s = (title.to_string(), url.to_string());
}
}
/// album.rs's booklet pick: the PDF format id (21) first, else the first
/// goody whose url ends in ".pdf"; `original_url` wins over `url`.
fn pick_booklet_url(album: &Album) -> String {
album
.goodies
.as_deref()
.and_then(|goodies| {
goodies
.iter()
.find(|g| g.file_format_id == Some(21))
.or_else(|| {
goodies.iter().find(|g| {
let ends_pdf = |s: &str| s.to_lowercase().ends_with(".pdf");
ends_pdf(&g.original_url) || ends_pdf(&g.url)
})
})
})
.map(|g| {
if !g.original_url.is_empty() {
g.original_url.clone()
} else {
g.url.clone()
}
})
.unwrap_or_default()
}
/// Download the open album's booklet PDF to a user-chosen location (Slint
/// `booklet::download_booklet`): fetch with a 30s client, then a native
/// save-as dialog seeded `{title}.pdf`. No-op when the open album has none.
pub fn download_booklet() {
let (title, url) = BOOKLET_STASH
.lock()
.map(|s| s.clone())
.unwrap_or_default();
if url.is_empty() {
return;
}
let default_name = if title.is_empty() {
"booklet.pdf".to_string()
} else {
format!("{title}.pdf")
};
crate::spawn(async move {
let client = match reqwest::Client::builder().timeout(BOOKLET_TIMEOUT).build() {
Ok(c) => c,
Err(e) => {
log::warn!("[qbz-qt] booklet HTTP client error: {e}");
return;
}
};
let bytes = match client
.get(&url)
.send()
.await
.and_then(|r| r.error_for_status())
{
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
log::warn!("[qbz-qt] booklet read failed: {e}");
return;
}
},
Err(e) => {
log::warn!("[qbz-qt] booklet fetch failed: {e}");
return;
}
};
let Some(dest) = rfd::AsyncFileDialog::new()
.set_file_name(&default_name)
.add_filter("PDF", &["pdf"])
.save_file()
.await
else {
return;
};
if let Err(e) = tokio::fs::write(dest.path(), &bytes).await {
log::warn!("[qbz-qt] booklet save failed: {e}");
}
});
}
// ==================== Add to Mixtape/Collection (header button) ============
/// The cassette button: open the MyQBZ picker with THIS album as the
/// payload. Slint builds the item from a fresh `/album/get`
/// (`main.rs:12001-12040`); the picker itself decides which container kinds
/// accept an album (Mixtapes AND Collections — `myqbz_add_qt::Accepts`).
pub fn add_to_mixtape(album_id: String) {
if album_id.is_empty() {
return;
}
let runtime = crate::app();
crate::spawn(async move {
let album = match runtime.core().get_album(&album_id).await {
Ok(a) => a,
Err(e) => {
log::warn!("[qbz-qt] album add-to-mixtape fetch failed: {e}");
return;
}
};
let item = crate::myqbz_add_qt::AddItem {
item_type: "album".into(),
source: "qobuz".into(),
source_item_id: album.id.clone(),
title: format_album_title(&album.title, album.version.as_deref()),
subtitle: Some(album.artist.name.clone()).filter(|s| !s.is_empty()),
artwork_url: album
.image
.thumbnail
.clone()
.or_else(|| album.image.small.clone()),
year: album
.release_date_original
.as_deref()
.and_then(|s| s.get(..4))
.and_then(|y| y.parse::<i32>().ok()),
track_count: album.tracks_count.map(|n| n as i32),
};
crate::myqbz_add_qt::open_items(vec![item]);
});
}
pub(crate) fn format_album_title(title: &str, version: Option<&str>) -> String { pub(crate) fn format_album_title(title: &str, version: Option<&str>) -> String {
match version.map(str::trim).filter(|v| !v.is_empty()) { match version.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => format!("{title} ({v})"), Some(v) => format!("{title} ({v})"),
+183
View File
@@ -0,0 +1,183 @@
//! Album custom covers + cover file actions — the Qt port of the album half
//! of `crates/qbz/src/custom_artwork.rs` and the cover-menu Rust arms
//! (`main.rs:23167-23239` add/remove, `:23096-23159` open-in-browser/save-as).
//!
//! The store is the SAME file and format the Slint app uses
//! (`<data-dir>/qbz/custom_artwork.json`, `albums: album_id → absolute
//! path`), so a cover set in either app shows in both. The store records the
//! picked file's own path (Slint does not copy/resize into the cache); if
//! the user later moves or deletes that file the override silently stops
//! applying — the reference's accepted behaviour, kept on purpose.
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize)]
struct Store {
// Artist images share this file in the Slint store; the key must round-
// trip even though this port only writes album covers.
#[serde(default)]
artists: HashMap<String, String>,
#[serde(default)]
albums: HashMap<String, String>,
}
fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("custom_artwork.json"))
}
fn load_store() -> Store {
let Some(path) = store_path() else {
return Store::default();
};
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => Store::default(),
}
}
fn write_store(store: &Store) {
let Some(path) = store_path() else {
return;
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("[qbz-qt] custom-artwork dir failed: {e}");
return;
}
}
match serde_json::to_vec_pretty(store) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("[qbz-qt] custom-artwork write failed: {e}");
}
}
Err(e) => log::warn!("[qbz-qt] custom-artwork serialize failed: {e}"),
}
}
/// The absolute path of the user-picked cover for `album_id`, if registered.
/// `album_qt::load_album` calls this on every build to seed the header's
/// `hasCustomCover` / `customCoverPath`.
pub fn album_cover(album_id: &str) -> Option<String> {
load_store().albums.get(album_id).cloned()
}
fn set_album_cover(album_id: &str, path: &str) {
let mut store = load_store();
store.albums.insert(album_id.to_string(), path.to_string());
write_store(&store);
}
fn remove_album_cover(album_id: &str) {
let mut store = load_store();
if store.albums.remove(album_id).is_some() {
write_store(&store);
}
}
/// "Add cover" / "Change cover": native picker, persist the picked path,
/// then re-open the album so the header repaints from the override.
/// Cancel = no-op, no toast (the picker's own affordance).
pub fn add_custom_cover(album_id: String) {
if album_id.is_empty() {
return;
}
crate::spawn(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp"])
.pick_file()
.await
else {
return;
};
let path = file.path().to_string_lossy().to_string();
// Blocking IO, but tiny (read + write of a small JSON map); the spawn
// keeps it off the Qt thread, same as the reference's handler.
let id = album_id.clone();
if tokio::task::spawn_blocking(move || {
set_album_cover(&id, &path);
})
.await
.is_ok()
{
crate::open_album(album_id);
}
});
}
/// "Remove cover": drop the override and re-open so the header reverts to
/// the Qobuz artwork.
pub fn remove_custom_cover(album_id: String) {
if album_id.is_empty() {
return;
}
let id = album_id.clone();
crate::spawn(async move {
let _ = tokio::task::spawn_blocking(move || remove_album_cover(&id)).await;
crate::open_album(album_id);
});
}
/// "Save as…": native save dialog seeded `{title} - Cover.jpg`. The source
/// is the custom override when one is set, else the pipeline's cached file
/// for the header URL, else one GET of the URL (Slint `main.rs:23108-23159`).
pub fn save_cover_as(album_id: String, title: String, artwork_url: String) {
crate::spawn(async move {
let default_name = format!(
"{} - Cover.jpg",
if title.is_empty() { "album" } else { &title }
);
let Some(dest) = rfd::AsyncFileDialog::new()
.set_file_name(&default_name)
.add_filter("JPEG", &["jpg", "jpeg"])
.save_file()
.await
else {
return;
};
let dest_path = dest.path().to_path_buf();
// Local sources first: the override file, then the artwork cache.
let local = album_cover(&album_id).filter(|p| !p.is_empty()).or_else(|| {
let cached = crate::artwork_qt::cached_path(&artwork_url);
if cached.is_empty() {
None
} else {
Some(cached)
}
});
if let Some(src) = local {
if let Err(e) = tokio::fs::copy(&src, &dest_path).await {
log::warn!("[qbz-qt] cover save-as copy failed: {e}");
}
return;
}
if artwork_url.is_empty() {
return;
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
log::warn!("[qbz-qt] cover save-as client error: {e}");
return;
}
};
match client.get(&artwork_url).send().await.and_then(|r| r.error_for_status()) {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => {
if let Err(e) = tokio::fs::write(&dest_path, &bytes).await {
log::warn!("[qbz-qt] cover save-as write failed: {e}");
}
}
Err(e) => log::warn!("[qbz-qt] cover save-as read failed: {e}"),
},
Err(e) => log::warn!("[qbz-qt] cover save-as fetch failed: {e}"),
}
});
}
+5
View File
@@ -184,6 +184,11 @@ mod quality_state;
mod offline_fwd; mod offline_fwd;
mod album_qt; mod album_qt;
mod track_info_qt; mod track_info_qt;
// Album Info (Credits/Review) modal controller — info_modals.rs port.
mod album_info_qt;
// Album custom covers + cover file actions — the album half of the Slint
// `custom_artwork.rs` store (SAME json file, shared between both apps).
mod cover_artwork_qt;
mod external_reco_qt; mod external_reco_qt;
mod ambient_qt; mod ambient_qt;
mod artist_qt; mod artist_qt;
+139 -10
View File
@@ -1,16 +1,12 @@
//! Share links + system clipboard — the Qt port of `crates/qbz/src/share.rs` //! Share links + system clipboard — the Qt port of `crates/qbz/src/share.rs`
//! (lines 1-70: the URL builders and the clipboard). Driven by the artist //! (lines 1-70: the URL builders and the clipboard). Driven by the artist
//! header's ⋯ → Share (`qml/views/ArtistView.qml`, the overflow menu). //! header's ⋯ → Share (`qml/views/ArtistView.qml`, the overflow menu) and,
//! since 2026-08-10, the album page's ⋯ → Share rows.
//! //!
//! **Only the first half of the reference is ported.** `share.rs:72-184` //! The Album.link resolver half (`share.rs:72-184`) is ported for the ALBUM
//! (`share_http_client`, `deezer_lookup`, `songlink_for_track`, //! arm only (`albumlink_for_album` + `deezer_lookup` + the Odesli fallback);
//! `albumlink_for_album`, `songlink_url`) resolves Song.link / Album.link for //! `songlink_for_track` still has no Qt call site — port it with the track
//! the TRACK and ALBUM context menus. Artists have no such path anywhere in //! menu that needs it.
//! the Slint tree — `main.rs:12749` ("artist","share") is two statements, copy
//! + toast — and this port has no Song.link call site at all, so porting those
//! resolvers now would be dead async HTTP. The module header of the reference
//! naming "Song.link" is about track/album only; do not read it as an artist
//! feature.
//! //!
//! **The `open.` vs `play.` split is deliberate, not a typo.** Track and album //! **The `open.` vs `play.` split is deliberate, not a typo.** Track and album
//! moved to `open.qobuz.com` for #514 (`share.rs:4`, `:14-16`, which records //! moved to `open.qobuz.com` for #514 (`share.rs:4`, `:14-16`, which records
@@ -170,3 +166,136 @@ pub(crate) fn share_label(label_id: String) {
copy_to_clipboard(qobuz_label_url(&label_id)); copy_to_clipboard(qobuz_label_url(&label_id));
crate::toast_qt::success(qbz_i18n::t("Link copied")); crate::toast_qt::success(qbz_i18n::t("Link copied"));
} }
// ---------------------------------------------------------------------------
// Album ⋯ menu Share rows (AlbumContextMenu.slint:120-135)
// ---------------------------------------------------------------------------
/// Album ⋯ → "Share Qobuz link": `main.rs:12245-12248` — copy + toast.
pub(crate) fn share_album_qobuz(album_id: String) {
if album_id.is_empty() {
return;
}
copy_to_clipboard(qobuz_album_url(&album_id));
crate::toast_qt::success(qbz_i18n::t("Link copied"));
}
/// Album ⋯ → "Share Album.link" (`main.rs:12249-12280`): fetch the album for
/// its UPC, resolve UPC -> Deezer -> album.link, copy + toast. The Odesli
/// API cannot resolve Qobuz URLs (#514: 400 could_not_resolve_entity), so
/// the UPC path is the working one; songlink_url is the last-resort fallback.
pub(crate) fn share_album_link(album_id: String) {
if album_id.is_empty() {
return;
}
crate::toast_qt::info(qbz_i18n::t("Fetching Album.link..."));
crate::spawn(async move {
let upc = crate::app()
.core()
.get_album(&album_id)
.await
.ok()
.and_then(|a| a.upc);
match albumlink_for_album(&album_id, upc.as_deref()).await {
Some(url) => {
copy_to_clipboard(url);
crate::toast_qt::success(qbz_i18n::t("Link copied"));
}
None => {
log::warn!("[qbz-qt] Album.link resolution failed for {album_id}");
crate::toast_qt::error(qbz_i18n::t("Failed to copy link"));
}
}
});
}
/// Shared HTTP client settings for the share resolvers (Tauri parity:
/// 10 s request / 5 s connect timeouts).
fn share_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_default()
}
/// Resolve an ISRC/UPC code to a Deezer catalog id. `path` is
/// `track/isrc:{code}` or `album/upc:{code}`. Deezer answers misses with
/// HTTP 200 + an `{"error": ...}` body, so both shapes are checked.
async fn deezer_lookup(path: &str) -> Option<u64> {
let url = format!("https://api.deezer.com/2.0/{path}");
let resp = match share_http_client().get(&url).send().await {
Ok(r) => r,
Err(e) => {
log::warn!("[qbz-qt] deezer lookup {path}: request failed: {e}");
return None;
}
};
if !resp.status().is_success() {
log::warn!("[qbz-qt] deezer lookup {path}: HTTP {}", resp.status());
return None;
}
let body: serde_json::Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
log::warn!("[qbz-qt] deezer lookup {path}: bad JSON: {e}");
return None;
}
};
if let Some(err) = body.get("error") {
log::info!("[qbz-qt] deezer lookup {path}: no match ({err})");
return None;
}
body.get("id").and_then(|v| v.as_u64())
}
/// Album.link page URL for an album — UPC-first via Deezer (#514). Qobuz
/// UPCs often carry a leading zero (13-digit EAN) while Deezer stores the
/// 12-digit form and does NOT match zero-padded input (verified), so a
/// trimmed retry is attempted.
pub async fn albumlink_for_album(album_id: &str, upc: Option<&str>) -> Option<String> {
if let Some(code) = upc.map(str::trim).filter(|c| !c.is_empty()) {
if let Some(deezer_id) = deezer_lookup(&format!("album/upc:{code}")).await {
log::info!("[qbz-qt] album.link via UPC {code} -> deezer album {deezer_id}");
return Some(format!("https://album.link/d/{deezer_id}"));
}
let trimmed = code.trim_start_matches('0');
if trimmed != code && !trimmed.is_empty() {
if let Some(deezer_id) = deezer_lookup(&format!("album/upc:{trimmed}")).await {
log::info!(
"[qbz-qt] album.link via UPC {trimmed} (leading zeros trimmed) -> deezer album {deezer_id}"
);
return Some(format!("https://album.link/d/{deezer_id}"));
}
}
} else {
log::info!("[qbz-qt] album {album_id} has no UPC; trying Odesli URL fallback");
}
songlink_url(&qobuz_album_url(album_id)).await
}
/// Resolve a source URL to its universal Song.link (Odesli) page URL.
/// One GET to the Odesli API; returns the `pageUrl` field. NOTE: Odesli
/// cannot resolve Qobuz URLs (400 `could_not_resolve_entity`) — for Qobuz
/// content use the ISRC/UPC resolvers above; this remains as their last
/// resort and for any non-Qobuz source URL.
pub async fn songlink_url(source_url: &str) -> Option<String> {
let resp = share_http_client()
.get("https://api.song.link/v1-alpha.1/links")
.query(&[("url", source_url), ("userCountry", "US")])
.send()
.await
.ok()?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let snippet: String = body.chars().take(200).collect();
log::warn!("[qbz-qt] song.link status {status} for {source_url}: {snippet}");
return None;
}
let value: serde_json::Value = resp.json().await.ok()?;
value
.get("pageUrl")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
}
+23
View File
@@ -48,3 +48,26 @@ pub fn release_label(date: Option<&str>) -> String {
// Year-only fallback (e.g. the source only had "2025"). // Year-only fallback (e.g. the source only had "2025").
raw.get(0..4).unwrap_or_default().to_string() raw.get(0..4).unwrap_or_default().to_string()
} }
/// Format a Qobuz release date as "MMMM D, YYYY" (FULL localized month) —
/// the Album Info modal's "Released by … on <date>" line (Slint
/// `info_modals.rs::full_release_date`, Tauri `formatReleaseDate`). Empty
/// when the value is missing or unparseable (no year fallback here: the
/// header already shows the short form elsewhere).
pub fn full_release_label(date: Option<&str>) -> String {
let Some(raw) = date else {
return String::new();
};
let raw = raw.trim();
if raw.is_empty() {
return String::new();
}
let head = raw.get(0..10).unwrap_or(raw);
if let Ok(parsed) = NaiveDate::parse_from_str(head, "%Y-%m-%d") {
// %B = localized full month, %-d = day without leading zero.
return parsed
.format_localized("%B %-d, %Y", current_locale())
.to_string();
}
String::new()
}
@@ -5088,3 +5088,11 @@ msgstr "Kioskmodus"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Desktopmodus" msgstr "Desktopmodus"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Cover ansehen"
msgid "Could not load the image"
msgstr "Das Bild konnte nicht geladen werden"
@@ -5096,3 +5096,11 @@ msgstr "Modo kiosco"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Modo escritorio" msgstr "Modo escritorio"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Ver carátula"
msgid "Could not load the image"
msgstr "No se pudo cargar la imagen"
@@ -5088,3 +5088,11 @@ msgstr "Mode kiosque"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Mode bureau" msgstr "Mode bureau"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Voir la pochette"
msgid "Could not load the image"
msgstr "Impossible de charger l'image"
@@ -5073,3 +5073,11 @@ msgstr "キオスクモード"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "デスクトップモード" msgstr "デスクトップモード"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "カバーを表示"
msgid "Could not load the image"
msgstr "画像を読み込めませんでした"
@@ -5078,3 +5078,11 @@ msgstr "Kioskmodus"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Bureaubladmodus" msgstr "Bureaubladmodus"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Hoes bekijken"
msgid "Could not load the image"
msgstr "De afbeelding kon niet worden geladen"
@@ -5088,3 +5088,11 @@ msgstr "Modo quiosque"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Modo desktop" msgstr "Modo desktop"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Ver capa"
msgid "Could not load the image"
msgstr "Não foi possível carregar a imagem"
@@ -5105,3 +5105,11 @@ msgstr "Режим киоска"
msgid "Desktop mode" msgid "Desktop mode"
msgstr "Режим рабочего стола" msgstr "Режим рабочего стола"
# AlbumView cover menu / lightbox (Qt port, 2026-08-10)
msgid "View cover"
msgstr "Показать обложку"
msgid "Could not load the image"
msgstr "Не удалось загрузить изображение"