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:
18 files changed
+1668
-30
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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})"),
|
||||||
|
|||||||
@@ -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}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
@@ -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 "Не удалось загрузить изображение"
|
||||||
Reference in new issue
Block a user