Files
qbz/crates/qbz-qt/qml/AlbumView.qml
T
vicrodh 86de977f3c feat(qbz-qt): phase 8 — AlbumView and ArtistView 1:1
AlbumPageView replica: 224px cover, credits, meta, description modal,
action row, quality badge + track search, exact tracklist (playing-row
pill, disc/work headers, live hearts, row menus), label/awards
sidebar, same-artist + suggestions carousels. ArtistPageView replica:
circular portrait, bio modal, From catalog/In library toggle (both
arms live), JUMP TO tabs with jump-scroll, Popular Tracks with Load
more, server-bucketed release sections paged per section, Appears On,
Network sidebar (labels, similar artists). Card clicks navigate
everywhere via the nav stack; new playback paths (play from track,
shuffled album, track lists). Owner data live: album 654ms, artist
670ms loads.

km
2026-07-27 17:49:08 -06:00

1064 lines
47 KiB
QML

// Album detail page — QML port of album/AlbumPageView.slint.
//
// Header (224px cover, title, credited-artist line, meta with label link,
// description + Read more, CircleAction row), divider, toolbar (quality
// badge + track search), column header, track list (Disc/work headers,
// TrackRow replica with the playing-row pill + number↔play cell + live
// heart), label/awards sidebar, and the two bottom carousels ("From the
// same artist", "Listening suggestions").
//
// POC-NOTEs: multi-select + bulk bar, offline download column, booklet,
// external links, custom-cover menu, album-info modal, the header
// atmosphere (album-header-gradient pref) — out of scope (visible stubs
// are inert). Text keeps the theme colors (header-light = false here).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
QbzTheme { id: theme }
// The view's album + url-keyed cover map (artwork pipeline).
readonly property var album: JSON.parse(QbzBridge.albumJson)
readonly property var header: album.header || ({})
readonly property var tracks: album.tracks || []
readonly property var awards: album.awards || []
property var coverMap: ({})
// Client-side track search (AlbumActions.search equivalent).
property string trackQuery: ""
property bool showDescription: false
readonly property var visibleTracks: {
if (trackQuery === "") return tracks
var q = trackQuery.toLowerCase()
return tracks.filter(function (t) {
return t.title.toLowerCase().indexOf(q) >= 0
})
}
// Disc headers + work headers precede their first row (computed once
// per track list change, mirroring AlbumState's disc-header-number /
// work-header model fields).
function headerFor(i) {
var t = visibleTracks[i]
if (!t) return null
if (i === 0) {
return { disc: t.disc > 1 || (visibleTracks.length > 0 && visibleTracks[visibleTracks.length - 1].disc > 1) ? t.disc : 0,
work: t.workHeader }
}
var prev = visibleTracks[i - 1]
var multi = visibleTracks[visibleTracks.length - 1].disc > 1
return { disc: (multi && t.disc !== prev.disc) ? t.disc : 0,
work: t.workHeader !== prev.workHeader ? t.workHeader : "" }
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
onAlbumChanged: dispatchCovers()
// The derived binding settles AFTER onAlbumChanged fires (stale race) —
// redispatch when the header itself updates.
onHeaderChanged: dispatchCovers()
function dispatchCovers() {
var urls = []
if (header.artUrl) urls.push(header.artUrl)
var more = album.moreFromArtist || []
for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl)
var sug = album.suggestions || []
for (i = 0; i < sug.length; i++) if (sug[i].artUrl) urls.push(sug[i].artUrl)
if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
}
// Ghost CircleAction (secondary, on-surface variant): elevated disc,
// strong ring, text-primary icon (accent when active).
component CircleBtn: Rectangle {
property string name: ""
property bool active: false
signal clicked()
width: 32
height: 32
radius: 16
color: (cbArea.containsMouse || active) ? theme.surfaceHover : theme.surfaceElevated
border.width: 1.5
border.color: theme.borderMuted
QbzIcon {
name: parent.name
width: 15
height: 15
anchors.centerIn: parent
tintName: parent.active ? "accent" : "primary"
}
MouseArea {
id: cbArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Track list row (TrackRow.slint replica: number cell, no artwork,
// favorite + download + menu columns).
component AlbumTrackRow: Rectangle {
property var row: ({})
property int rowIndex: 0
readonly property bool isActive: QbzBridge.npTrackId !== "" && QbzBridge.npTrackId === row.id
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
width: parent ? parent.width : 0
height: 50
radius: 8
color: hovered ? "#14ffffff" : (rowIndex % 2 === 1 ? "#07ffffff" : "transparent")
// Static now-playing mark: 3px accent pill on the left edge.
Rectangle {
visible: isActive
x: 2
y: 7
width: 3
height: parent.height - 14
radius: 1.5
color: theme.accent
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 14
// Number cell — swaps to play on hover (non-artwork variant).
Item {
width: 32
height: 40
anchors.verticalCenter: parent.verticalCenter
Text {
visible: !trArea.containsMouse
anchors.centerIn: parent
text: row.number
color: theme.textMuted
font.pixelSize: 13
}
Rectangle {
visible: trArea.containsMouse
anchors.centerIn: parent
width: 28
height: 28
radius: 14
color: isActive && QbzBridge.npPlaying ? "transparent" : "#3dffffff"
border.width: isActive && QbzBridge.npPlaying ? 1.5 : 0
border.color: theme.accent
QbzIcon {
anchors.centerIn: parent
name: isActive && QbzBridge.npPlaying ? "pause" : "play-fill"
width: 14
height: 14
tintName: isActive && QbzBridge.npPlaying ? "accent" : "primary"
}
}
}
// Title + artist.
Column {
width: parent.width - 32 - 70 - 92 - 28 - 28 - 32 - 5 * 14
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
spacing: 6
Text {
text: row.title
color: theme.textPrimary
font.pixelSize: 14
font.weight: theme.weightMedium
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 22 : 0))
}
Rectangle {
visible: row.explicit
width: 16
height: 16
radius: 3
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
Text {
anchors.centerIn: parent
text: "E"
color: theme.textMuted
font.pixelSize: 9
font.weight: theme.weightSemibold
}
}
}
Text {
width: parent.width
visible: row.artist !== ""
text: row.artist
color: row.artistId !== "" && artistLinkArea.containsMouse ? theme.textPrimary : theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
MouseArea {
id: artistLinkArea
anchors.fill: parent
enabled: row.artistId !== ""
hoverEnabled: true
cursorShape: row.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openArtist(row.artistId)
}
}
}
Text {
width: 70
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
}
// Bare quality badge (tier label + detail).
Text {
width: 92
anchors.verticalCenter: parent.verticalCenter
text: row.qualityTier === "hires" ? "HI-RES" : (row.qualityTier === "cd" ? "CD" : "")
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightBold
horizontalAlignment: Text.AlignHCenter
}
// Favorite (live).
Rectangle {
width: 28
height: 28
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: row.isFavorite ? "heart-filled" : "heart"
width: 16
height: 16
tintName: row.isFavorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: favArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
row.isFavorite = !row.isFavorite
QbzBridge.libraryToggleFavorite("track", row.id)
}
}
}
// Offline download — INERT stub (out of scope).
Rectangle {
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon {
anchors.centerIn: parent
name: "cloud-download"
width: 16
height: 16
tintName: "muted"
}
}
// ⋯ menu.
Rectangle {
width: 32
height: 32
radius: theme.radiusSm
anchors.verticalCenter: parent.verticalCenter
color: moreArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "ellipsis"
width: 16
height: 16
tintName: moreArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: rowMenu.open()
}
Popup {
id: rowMenu
x: 32 - 196
y: 38
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: [
{ "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" },
{ "label": QbzBridge.tr("Play next"), "icon": "list-plus", "action": "next" },
{ "label": QbzBridge.tr("Add to queue"), "icon": "list-end", "action": "queue" },
{ "label": row.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": row.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: rmiArea.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: rmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
rowMenu.close()
var a = modelData.action
if (a === "play") QbzBridge.playAlbumFrom(header.id, row.id)
else if (a === "next") QbzBridge.enqueueAlbumTrack(header.id, row.id, "next")
else if (a === "queue") QbzBridge.enqueueAlbumTrack(header.id, row.id, "later")
else if (a === "favorite") {
row.isFavorite = !row.isFavorite
QbzBridge.libraryToggleFavorite("track", row.id)
}
}
}
}
}
}
}
}
}
MouseArea {
id: trArea
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onDoubleClicked: QbzBridge.playAlbumFrom(header.id, row.id)
onClicked: mouse.accepted = false
}
}
// Sidebar label/award card (SidebarCard).
component SidebarCard: Rectangle {
property string name: ""
property color gradA: "#6366f1"
property color gradB: "#8b5cf6"
property string iconName: "disc"
signal clicked()
width: parent ? parent.width : 0
height: 48
radius: theme.radiusSm
color: scArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.margins: 6
spacing: 10
Rectangle {
width: 28
height: 28
radius: 14
anchors.verticalCenter: parent.verticalCenter
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: gradA }
GradientStop { position: 1.0; color: gradB }
}
QbzIcon {
name: iconName
width: 13
height: 13
anchors.centerIn: parent
tintName: "primary"
}
}
Text {
width: parent.width - 38
anchors.verticalCenter: parent.verticalCenter
text: name
color: theme.textSecondary
font.pixelSize: 12
font.weight: theme.weightMedium
wrapMode: Text.WordWrap
}
}
MouseArea {
id: scArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
component SidebarHeading: Text {
color: theme.textMuted
font.pixelSize: 10
font.weight: theme.weightSemibold
font.letterSpacing: 1
}
// ============================ the page ================================
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
// NavButtons is a 0px placeholder in the Slint source.
Item { width: 1; height: 22 }
// --- Album header -------------------------------------------
Row {
width: parent.width - 64
spacing: 32
Rectangle {
width: 224
height: 224
radius: 12
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.coverMap[header.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 224 - 32
anchors.top: parent.top
anchors.topMargin: 4
spacing: 0
Text {
width: parent.width
text: header.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { width: 1; height: 4 }
// Credited-artist line (links + role suffixes).
Flow {
width: parent.width
spacing: 0
Repeater {
model: header.credits || []
delegate: Row {
required property var modelData
required property int index
spacing: 0
Text {
visible: index > 0
text: " • "
color: theme.textSecondary
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
}
Text {
text: modelData[0]
color: creditArea.containsMouse && modelData[1] !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontHeading
font.weight: theme.weightBold
MouseArea {
id: creditArea
anchors.fill: parent
enabled: modelData[1] !== ""
hoverEnabled: true
cursorShape: modelData[1] !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openArtist(modelData[1])
}
}
Text {
visible: modelData[2] !== ""
text: " (" + modelData[2] + ")"
color: theme.textSecondary
font.pixelSize: theme.fontHeading
}
}
}
}
Item { width: 1; height: 10 }
// Meta line (label as a clickable link when navigable).
Row {
spacing: 0
visible: (header.labelId || "") !== "" && (header.label || "") !== ""
Text {
visible: (header.metaPre || "") !== ""
text: (header.metaPre || "") + " • "
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
Text {
text: header.label || ""
color: labelArea.containsMouse ? theme.accent : theme.textSecondary
font.pixelSize: theme.fontBody
MouseArea {
id: labelArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: no label view yet.
}
}
Text {
visible: (header.metaPost || "") !== ""
text: " • " + (header.metaPost || "")
color: theme.textSecondary
font.pixelSize: theme.fontBody
}
}
Text {
visible: (header.labelId || "") === "" || (header.label || "") === ""
width: parent.width
text: header.infoLine || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
// Editorial description + Read more.
Item { visible: (header.description || "") !== ""; width: 1; height: 12 }
Text {
visible: (header.description || "") !== ""
width: parent.width
text: header.descriptionShort || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Item { visible: (header.description || "") !== (header.descriptionShort || ""); width: 1; height: 4 }
Text {
visible: (header.description || "") !== (header.descriptionShort || "")
text: QbzBridge.tr("Read more")
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
font.pixelSize: theme.fontLegal
MouseArea {
id: readMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.showDescription = true
}
}
Item { width: 1; height: 20 }
// Action row (on-surface CircleActions).
Row {
spacing: 12
// Play — accent disc, white glyph (on-surface primary).
Rectangle {
width: 44
height: 44
radius: 22
color: playHdrArea.containsMouse ? theme.accentHover : theme.accent
QbzIcon {
anchors.centerIn: parent
name: "play-fill"
width: 19
height: 19
tintName: "primary"
}
MouseArea {
id: playHdrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playAlbum(header.id)
}
}
CircleBtn {
name: "shuffle"
anchors.verticalCenter: parent.verticalCenter
onClicked: QbzBridge.playAlbumShuffled(header.id)
}
CircleBtn {
name: header.isFavorite ? "heart-filled" : "heart"
active: header.isFavorite === true
anchors.verticalCenter: parent.verticalCenter
onClicked: {
header.isFavorite = !header.isFavorite
QbzBridge.libraryToggleFavorite("album", header.id)
}
}
// Radio / Mixtape / Info — INERT stubs (POC-NOTE:
// radio engines, mixtape store, album-info modal).
CircleBtn { name: "radio"; anchors.verticalCenter: parent.verticalCenter }
CircleBtn { name: "cassette-tape"; anchors.verticalCenter: parent.verticalCenter }
CircleBtn { name: "info"; anchors.verticalCenter: parent.verticalCenter }
CircleBtn {
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: albumMenu.open()
}
}
}
}
Item { width: 1; height: 20 }
// Header divider (the atmosphere ends here in Slint).
Rectangle { width: parent.width - 64; height: 1; color: theme.borderSubtle }
Item { width: 1; height: 8 }
// --- Track list + label/awards sidebar ----------------------
Row {
width: parent.width - 64
spacing: 32
Column {
width: parent.width - ((header.label || "") !== "" || awards.length > 0 ? 232 : 0)
spacing: 0
// Loading.
Item {
visible: QbzBridge.albumLoading && tracks.length === 0
width: parent.width
height: 280
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Loading album…")
color: theme.textMuted
font.pixelSize: 13
}
}
}
// Toolbar — quality badge + track search (+ inert select).
Row {
visible: !QbzBridge.albumLoading
width: parent.width
height: 52
spacing: 16
Row {
id: qualityRow
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Image {
visible: header.qualityTier === "hires"
source: "assets/hi-res.svg"
width: 42
height: 28
anchors.verticalCenter: parent.verticalCenter
sourceSize: Qt.size(84, 56)
fillMode: Image.PreserveAspectFit
}
Text {
visible: (header.qualityDetail || "") !== ""
text: header.qualityDetail || ""
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
}
Item { width: parent.width - qualityRow.width - 168 - 30 - 3 * 16; height: 1 }
Rectangle {
width: 168
height: 34
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 7
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
TextInput {
width: parent.width - 21
height: parent.height
color: theme.textPrimary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
clip: true
onTextEdited: root.trackQuery = text
Text {
visible: parent.text === ""
anchors.fill: parent
text: QbzBridge.tr("Search tracks...")
color: theme.textMuted
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
}
}
Rectangle {
width: 30
height: 30
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: selectArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
QbzIcon {
name: "square-check-big"
width: 15
height: 15
anchors.centerIn: parent
tintName: selectArea.containsMouse ? "primary" : "secondary"
}
MouseArea {
id: selectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: multi-select out of scope.
}
}
}
// Column header.
Row {
visible: !QbzBridge.albumLoading
width: parent.width
height: 40
leftPadding: 12
rightPadding: 12
spacing: 16
Text { text: "#"; width: 32; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter }
Text { text: QbzBridge.tr("Title"); width: parent.width - 32 - 80 - 80 - 28 - 28 - 32 - 5 * 16 - 24; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; anchors.verticalCenter: parent.verticalCenter; elide: Text.ElideRight }
Text { text: QbzBridge.tr("Duration"); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
Text { text: QbzBridge.tr("Quality"); width: 80; color: theme.textMuted; font.pixelSize: 13; font.letterSpacing: 0.5; horizontalAlignment: Text.AlignHCenter; anchors.verticalCenter: parent.verticalCenter }
Item { width: 28; height: 1; QbzIcon { name: "heart"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
Item { width: 28; height: 1; QbzIcon { name: "cloud-download"; width: 14; height: 14; anchors.centerIn: parent; tintName: "muted" } }
Item { width: 32; height: 1 }
}
// Rows (with Disc / work headers).
Repeater {
model: root.visibleTracks
delegate: Column {
required property var modelData
required property int index
property var hdr: root.headerFor(index)
width: parent ? parent.width : 0
Rectangle {
visible: hdr && hdr.disc > 0
width: parent.width
height: 40
color: "transparent"
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Disc") + " " + hdr.disc
color: theme.textMuted
font.pixelSize: 13
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
Row {
visible: hdr && hdr.work !== ""
width: parent.width
leftPadding: 12
rightPadding: 12
topPadding: 14
bottomPadding: 4
spacing: 0
Text {
text: hdr.work
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: " ("
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: modelData.workComposerName
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
MouseArea {
id: composerArea
anchors.fill: parent
enabled: modelData.workComposerId !== ""
hoverEnabled: true
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openArtist(modelData.workComposerId)
}
}
Text {
visible: modelData.workComposerName !== ""
text: ")"
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
}
AlbumTrackRow { row: modelData; rowIndex: index }
}
}
}
// Label / awards sidebar (200px).
Column {
visible: (header.label || "") !== "" || awards.length > 0
width: 200
spacing: 24
Column {
visible: (header.label || "") !== ""
width: parent.width
spacing: 8
SidebarHeading { text: QbzBridge.tr("LABEL") }
SidebarCard {
name: header.label || ""
iconName: "disc"
gradA: "#6366f1"
gradB: "#8b5cf6"
// POC-NOTE: no label view yet.
}
}
Column {
visible: awards.length > 0
width: parent.width
spacing: 8
SidebarHeading { text: QbzBridge.tr("AWARDS") }
Repeater {
model: awards
delegate: SidebarCard {
required property var modelData
name: modelData[1]
iconName: "award"
gradA: "#b45309"
gradB: "#eab308"
// POC-NOTE: no award view yet.
}
}
}
}
}
// --- Bottom carousels ---------------------------------------
Item { visible: (album.moreFromArtist || []).length > 0; width: 1; height: 40 }
SectionRail {
visible: (album.moreFromArtist || []).length > 0
title: QbzBridge.tr("From the same artist")
items: album.moreFromArtist || []
coverMap: root.coverMap
}
Item { visible: (album.suggestions || []).length > 0; width: 1; height: 40 }
SectionRail {
visible: (album.suggestions || []).length > 0
title: QbzBridge.tr("Listening suggestions")
items: album.suggestions || []
coverMap: root.coverMap
}
}
}
// Thin auto-hiding scrollbar (ListScrollbar).
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: page.parent
}
// --- Full-description modal ------------------------------------------
Rectangle {
visible: root.showDescription
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.showDescription = false
}
Rectangle {
anchors.centerIn: parent
width: Math.min(root.width - 80, 560)
height: Math.min(root.height - 120, 460)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent }
Column {
anchors.fill: parent
anchors.margins: 24
spacing: 14
Row {
width: parent.width
Text {
width: parent.width - 28
text: QbzBridge.tr("About this album")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: 28
height: 28
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
radius: 6
QbzIcon {
name: "x"
width: 18
height: 18
anchors.centerIn: parent
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.showDescription = false
}
}
}
Flickable {
width: parent.width
height: parent.height - 42
clip: true
contentWidth: width
contentHeight: descText.implicitHeight
Text {
id: descText
width: parent.width
text: header.description || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
}
}
}
}
// Album ⋯ menu (AlbumContextMenu subset — card menu + pin).
Popup {
id: albumMenu
x: 260
y: 260
width: 196
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: [
{ "label": QbzBridge.tr("Play"), "icon": "play-fill", "action": "play" },
{ "label": QbzBridge.tr("Play next"), "icon": "list-plus", "action": "next" },
{ "label": QbzBridge.tr("Add to queue"), "icon": "list-end", "action": "queue" },
{ "label": header.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": header.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
{ "label": header.isPinned ? QbzBridge.tr("Unpin") : QbzBridge.tr("Pin"), "icon": header.isPinned ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: amiArea.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: amiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
albumMenu.close()
var a = modelData.action
if (a === "play") QbzBridge.playAlbum(header.id)
else if (a === "next") QbzBridge.enqueueAlbum(header.id, "next")
else if (a === "queue") QbzBridge.enqueueAlbum(header.id, "later")
else if (a === "favorite") {
header.isFavorite = !header.isFavorite
QbzBridge.libraryToggleFavorite("album", header.id)
} else if (a === "pin") {
header.isPinned = !header.isPinned
QbzBridge.togglePin("album", header.id, header.title, header.artist, header.artUrl)
}
}
}
}
}
}
}
}