Files
qbz/crates/qbz-qt/qml/ArtistView.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

1590 lines
67 KiB
QML

// Artist detail page — QML port of artist/ArtistPageView.slint.
//
// Header (200px circular portrait, name, bio + Read more, CircleAction
// row: Follow / Radio / Network / ⋯, From-catalog/In-library toggle),
// JUMP TO bar (jump-scroll), Popular Tracks (artwork + album column rows,
// Load more 5→all, play/shuffle-all), Latest release, release sections
// (Albums / EPs & Singles / Live / … in the official order, sort menu,
// per-section Load more paged through the core), Appears On, Playlists,
// Other (collapsed), and the 300px Network sidebar (Network/Magazine
// tabs, LABELS, SIMILAR ARTISTS).
//
// POC-NOTEs: MusicBrainz sidebar sections (Origin/Relationships/
// Discovery), the Magazine tab's stories, blacklist banner, artist Scene,
// Share, Create Collection, radio engines (dropdown inert), multi-select,
// the sticky behavior of the JUMP TO bar (it scrolls with the page).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
id: root
color: theme.surfaceMain
QbzTheme { id: theme }
readonly property var artist: JSON.parse(QbzBridge.artistJson)
readonly property var topTracks: artist.topTracks || []
readonly property var appearsOn: artist.appearsOn || []
readonly property var releaseSections: artist.releaseSections || []
readonly property var labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || []
property var coverMap: ({})
property string activeJumpTab: "popular-tracks"
property string artistTab: "catalog"
property bool topTracksExpanded: false
property bool appearsOnExpanded: false
property bool otherExpanded: false
property bool networkOpen: false
property bool showBio: false
property string netTab: "network"
readonly property int preview: 5
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
readonly property var jumpTabs: {
var tabs = []
if ((artist.bio || "") !== "") tabs.push({ "id": "about", "label": QbzBridge.tr("About") })
if (topTracks.length > 0) tabs.push({ "id": "popular-tracks", "label": QbzBridge.tr("Popular Tracks") })
for (var i = 0; i < releaseSections.length; i++) {
if (releaseSections[i].releaseType !== "other")
tabs.push({ "id": releaseSections[i].releaseType, "label": releaseSections[i].title })
}
if (appearsOn.length > 0) tabs.push({ "id": "appears-on", "label": QbzBridge.tr("Appears On") })
return tabs
}
Connections {
target: QbzBridge
function onLibraryArtworkReady(key, path) {
var m = root.coverMap
m[key] = path
root.coverMap = Object.assign({}, m)
}
function onReleaseSectionReady(releaseType, cardsJson, hasMore) {
var cards = JSON.parse(cardsJson)
var sections = root.releaseSections
for (var i = 0; i < sections.length; i++) {
if (sections[i].releaseType === releaseType) {
var seen = {}
for (var j = 0; j < sections[i].cards.length; j++) seen[sections[i].cards[j].id] = true
for (j = 0; j < cards.length; j++) {
if (!seen[cards[j].id]) sections[i].cards.push(cards[j])
}
sections[i].hasMore = hasMore
break
}
}
root.artistChanged()
}
}
Component.onCompleted: dispatchCovers()
onArtistChanged: dispatchCovers()
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
// re-fire when the parsed value actually changes (same stale race).
onTopTracksChanged: dispatchCovers()
onArtistTabChanged: if (artistTab === "library") dispatchLibCovers()
function dispatchLibCovers() {
var urls = []
var items = libraryTab.libItems || []
for (var i = 0; i < items.length; i++) if (items[i].imageUrl) urls.push(items[i].imageUrl)
if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
}
function dispatchCovers() {
var urls = []
if (artist.artUrl) urls.push(artist.artUrl)
var i, j
for (i = 0; i < topTracks.length; i++) if (topTracks[i].artUrl) urls.push(topTracks[i].artUrl)
for (i = 0; i < releaseSections.length; i++)
for (j = 0; j < releaseSections[i].cards.length; j++)
if (releaseSections[i].cards[j].artUrl) urls.push(releaseSections[i].cards[j].artUrl)
if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
}
function scrollToSection(id) {
root.activeJumpTab = id
for (var i = 0; i < sectionAnchors.children.length; i++) {
var c = sectionAnchors.children[i]
if (c.anchorId === id) {
flick.contentY = sectionAnchors.y + c.y - 48
return
}
}
}
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()
}
}
// Popular Tracks row (TrackRow with artwork + album column).
component PopularTrackRow: Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showAlbum: true
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")
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
// Position number (artwork rows carry it separate from the cover).
Text {
visible: showAlbum
width: 32
anchors.verticalCenter: parent.verticalCenter
text: row.number
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
// Cover with hover play overlay.
Rectangle {
width: showAlbum ? 36 : 32
height: showAlbum ? 36 : 28
anchors.verticalCenter: parent.verticalCenter
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
QbzIcon {
anchors.centerIn: parent
name: "music"
width: showAlbum ? 16 : 14
height: showAlbum ? 16 : 14
tintName: "muted"
}
Image {
anchors.fill: parent
source: root.coverMap[row.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: trArea.containsMouse || isActive ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
QbzIcon {
visible: trArea.containsMouse || isActive
anchors.centerIn: parent
name: isActive && QbzBridge.npPlaying ? "pause" : "play-fill"
width: 16
height: 16
tintName: "primary"
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playArtistTrack(row.id)
}
}
// Title + artist.
Column {
width: parent.width - (showAlbum ? 32 + 36 : 32) - albumCell.width - 70 - 92 - 28 - 28 - 32 - 6 * 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: theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
}
}
// Album column.
Text {
id: albumCell
visible: showAlbum
width: showAlbum ? 220 : 0
anchors.verticalCenter: parent.verticalCenter
text: row.album
color: row.albumId !== "" && albumLinkArea.containsMouse ? theme.textPrimary : theme.textMuted
font.pixelSize: 13
elide: Text.ElideRight
MouseArea {
id: albumLinkArea
anchors.fill: parent
enabled: row.albumId !== ""
hoverEnabled: true
cursorShape: row.albumId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzBridge.openAlbum(row.albumId)
}
}
Text {
width: 70
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 13
horizontalAlignment: Text.AlignHCenter
}
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.
Rectangle {
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
QbzIcon { anchors.centerIn: parent; name: "cloud-download"; width: 16; height: 16; tintName: "muted" }
}
// ⋯ (play-only menu for the POC).
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
// POC-NOTE: the unified track context menu is out of scope.
}
}
}
MouseArea {
id: trArea
anchors.fill: parent
hoverEnabled: true
propagateComposedEvents: true
onDoubleClicked: QbzBridge.playArtistTrack(row.id)
onClicked: mouse.accepted = false
}
}
// Sidebar link row (SidebarLink).
component SidebarLink: Rectangle {
property string label: ""
property string iconName: "user"
signal clicked()
width: parent ? parent.width : 0
height: 28
radius: 4
color: slArea.containsMouse ? theme.surfaceElevated : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 8
QbzIcon {
name: iconName
width: 12
height: 12
anchors.verticalCenter: parent.verticalCenter
tintName: slArea.containsMouse ? "primary" : "muted"
}
Text {
width: parent.width - 20
anchors.verticalCenter: parent.verticalCenter
text: label
color: slArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
elide: Text.ElideRight
}
}
MouseArea {
id: slArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
}
}
// Release section (ReleaseGrid).
component ReleaseSection: Column {
property var section: ({})
property string anchorId: ""
width: parent ? parent.width : 0
spacing: 12
Row {
width: parent.width
spacing: 12
Text {
width: parent.width - seeAll.width - sortBtn.width - 24
anchors.verticalCenter: parent.verticalCenter
text: section.title
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
id: seeAll
anchors.verticalCenter: parent.verticalCenter
height: 28
text: QbzBridge.tr("See discography")
color: seeAllArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
MouseArea {
id: seeAllArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: dedicated discography page out of scope.
}
}
Rectangle {
id: sortBtn
width: sortRow.width
height: 28
radius: 5
anchors.verticalCenter: parent.verticalCenter
color: sortArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
Row {
id: sortRow
height: parent.height
leftPadding: 10
rightPadding: 10
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Newest")
color: theme.textSecondary
font.pixelSize: 12
}
QbzIcon { name: "chevron-down"; width: 12; height: 12; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
}
MouseArea {
id: sortArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: per-section sort menu (server re-sort) not wired.
}
}
}
Grid {
width: parent.width
columns: Math.max(1, Math.floor((width + 24) / 224))
columnSpacing: 24
rowSpacing: 24
Repeater {
model: section.cards
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.artUrl] || ""
isFavorite: false
}
}
}
Row {
visible: section.hasMore
width: parent.width
Item { width: (parent.width - loadMoreBtn.width) / 2; height: 1 }
Rectangle {
id: loadMoreBtn
width: loadMoreText.implicitWidth + 24
height: 28
color: "transparent"
Text {
id: loadMoreText
anchors.centerIn: parent
text: QbzBridge.tr("Load more")
color: loadMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: loadMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.loadReleaseSection(artist.id, section.releaseType, section.cards.length)
}
}
Item { width: (parent.width - loadMoreBtn.width) / 2; height: 1 }
}
}
// ============================ the page ================================
Flickable {
id: flick
anchors.fill: parent
anchors.rightMargin: root.networkOpen ? 300 : 0
clip: true
contentWidth: width
contentHeight: page.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Behavior on anchors.rightMargin { NumberAnimation { duration: 160; easing.type: Easing.InOutQuad } }
Column {
id: page
width: parent.width
leftPadding: 32
rightPadding: 32
topPadding: 11
bottomPadding: 100
spacing: 0
Item { width: 1; height: 22 }
// --- Artist header ------------------------------------------
Row {
width: parent.width - 64
spacing: 32
// Circular portrait (rounded Rectangle + clip round-clips
// on this Qt build — verified against the phase-3 circles).
Rectangle {
width: 200
height: 200
radius: 100
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.coverMap[artist.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 200 - 32
anchors.top: parent.top
anchors.topMargin: 8
spacing: 0
Text {
width: parent.width
text: artist.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
elide: Text.ElideRight
}
Item { visible: (artist.bio || "") !== ""; width: 1; height: 12 }
Text {
visible: (artist.bio || "") !== ""
width: parent.width
text: artist.bioShort || ""
color: theme.textSecondary
font.pixelSize: theme.fontLegal
wrapMode: Text.WordWrap
}
Item { visible: artist.bioTruncated === true; width: 1; height: 4 }
Text {
visible: artist.bioTruncated === true
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.showBio = true
}
}
Item { width: 1; height: 18 }
// Action row.
Row {
width: parent.width
spacing: 12
CircleBtn {
name: artist.isFollowing ? "heart-filled" : "heart"
active: artist.isFollowing === true
anchors.verticalCenter: parent.verticalCenter
onClicked: {
artist.isFollowing = !artist.isFollowing
QbzBridge.libraryToggleFavorite("artist", artist.id)
}
}
CircleBtn {
name: "radio"
anchors.verticalCenter: parent.verticalCenter
onClicked: radioPopup.open()
}
CircleBtn {
name: "element-connect"
active: root.networkOpen
anchors.verticalCenter: parent.verticalCenter
onClicked: root.networkOpen = !root.networkOpen
}
CircleBtn {
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: overflowMenu.open()
}
Item { width: parent.width - 4 * 32 - 4 * 12 - segTabs.width; height: 1 }
// From catalog / In library (webplayer parity).
Rectangle {
id: segTabs
visible: (artist.libraryCount || 0) > 0
width: segRow.width
height: segRow.height
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
radius: 6
Row {
id: segRow
padding: 3
spacing: 4
Repeater {
model: [
{ "id": "catalog", "label": QbzBridge.tr("From catalog"), "count": 0 },
{ "id": "library", "label": QbzBridge.tr("In library"), "count": artist.libraryCount || 0 },
]
delegate: Rectangle {
required property var modelData
property bool active: root.artistTab === modelData.id
width: segTabRow.implicitWidth
height: segTabRow.implicitHeight
radius: 4
color: active ? theme.surfaceMain
: segTabArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
id: segTabRow
leftPadding: 12
rightPadding: parent && parent.parent.modelData.count > 0 ? 8 : 12
topPadding: 6
bottomPadding: 6
spacing: 7
Text {
text: modelData.label
color: parent.parent.active ? theme.textPrimary : theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightMedium
}
Rectangle {
visible: modelData.count > 0
width: Math.max(18, segCountText.implicitWidth + 10)
height: 16
radius: 8
color: parent.parent.active ? "#26ffffff" : "#14ffffff"
Text {
id: segCountText
anchors.centerIn: parent
text: modelData.count
color: parent.parent.active ? theme.textPrimary : theme.textSecondary
font.pixelSize: 11
font.weight: theme.weightMedium
}
}
}
MouseArea {
id: segTabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.artistTab = modelData.id
}
}
}
}
}
}
}
}
Item { width: 1; height: 20 }
// --- JUMP TO bar (inline; sticky is POC-NOTE) -----------------
Rectangle {
width: parent.width - 64
height: 44
color: theme.surfaceMain
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Repeater {
model: root.jumpTabs
delegate: Column {
required property var modelData
spacing: 0
Text {
text: modelData.label
color: root.activeJumpTab === modelData.id ? theme.textPrimary
: jumpTabArea.containsMouse ? theme.textSecondary : theme.textMuted
font.pixelSize: 13
font.weight: root.activeJumpTab === modelData.id ? theme.weightSemibold : theme.weightMedium
MouseArea {
id: jumpTabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.scrollToSection(modelData.id)
}
}
Rectangle {
visible: root.activeJumpTab === modelData.id
width: parent.width
height: 2
radius: 1
color: theme.accent
}
}
}
}
}
// --- Loading -------------------------------------------------
Item {
visible: QbzBridge.artistLoading && topTracks.length === 0
width: parent.width - 64
height: 280
Column {
anchors.centerIn: parent
spacing: 18
QbzSpinner { size: 36; anchors.horizontalCenter: parent.horizontalCenter }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: QbzBridge.tr("Loading artist…")
color: theme.textMuted
font.pixelSize: 13
}
}
}
// ================= Catalog tab ================================
Column {
id: sectionAnchors
visible: root.artistTab === "catalog" && !QbzBridge.artistLoading
width: parent.width - 64
spacing: 0
// --- Popular Tracks -------------------------------------
Row {
property string anchorId: "popular-tracks"
visible: topTracks.length > 0
width: parent.width
spacing: 12
Text {
width: parent.width - 44 - 32 - 32 - 3 * 12
anchors.verticalCenter: parent.verticalCenter
text: QbzBridge.tr("Popular Tracks")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Rectangle {
width: 44
height: 44
radius: 22
anchors.verticalCenter: parent.verticalCenter
color: playTopArea.containsMouse ? theme.accentHover : theme.accent
QbzIcon { anchors.centerIn: parent; name: "play-fill"; width: 19; height: 19; tintName: "primary" }
MouseArea {
id: playTopArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playArtistTop(false)
}
}
CircleBtn {
name: "square-check-big"
anchors.verticalCenter: parent.verticalCenter
// POC-NOTE: multi-select out of scope.
}
CircleBtn {
name: "ellipsis"
anchors.verticalCenter: parent.verticalCenter
onClicked: topMenu.open()
}
}
Item { visible: topTracks.length > 0; width: 1; height: 10 }
Repeater {
model: topTracks.length
delegate: PopularTrackRow {
visible: root.topTracksExpanded || index < root.preview
height: visible ? 50 : 0
row: topTracks[index]
rowIndex: index
showAlbum: true
}
}
Item { visible: topTracks.length > root.preview; width: 1; height: 4 }
Rectangle {
visible: topTracks.length > root.preview
width: parent.width
height: 28
color: "transparent"
Text {
anchors.centerIn: parent
text: root.topTracksExpanded ? QbzBridge.tr("View less") : QbzBridge.tr("Load more")
color: loadMoreTopArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
MouseArea {
id: loadMoreTopArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.topTracksExpanded = !root.topTracksExpanded
}
}
}
// --- Latest release --------------------------------------
Column {
property string anchorId: "about"
visible: !!artist.lastRelease
width: parent.width
spacing: 12
Item { width: 1; height: 32 }
Text {
text: QbzBridge.tr("Latest release")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
AlbumCard {
albumId: artist.lastRelease ? artist.lastRelease.id : ""
title: artist.lastRelease ? artist.lastRelease.title : ""
artist: artist.lastRelease ? artist.lastRelease.artist : ""
artistId: artist.lastRelease ? artist.lastRelease.artistId : ""
genre: artist.lastRelease ? artist.lastRelease.genre : ""
year: artist.lastRelease ? artist.lastRelease.year : ""
qualityTier: artist.lastRelease ? artist.lastRelease.qualityTier : ""
artSource: artist.lastRelease ? (root.coverMap[artist.lastRelease.artUrl] || "") : ""
isFavorite: false
}
}
// --- Release sections ------------------------------------
Repeater {
model: releaseSections
delegate: Column {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
visible: modelData.releaseType !== "other"
Item { width: 1; height: 32 }
ReleaseSection { section: modelData; anchorId: modelData.releaseType }
}
}
// --- Appears On -------------------------------------------
Column {
property string anchorId: "appears-on"
visible: appearsOn.length > 0
width: parent.width
spacing: 0
Item { width: 1; height: 32 }
Text {
text: QbzBridge.tr("Appears On")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Item { width: 1; height: 10 }
Repeater {
model: appearsOn.length
delegate: PopularTrackRow {
visible: root.appearsOnExpanded || index < root.preview
height: visible ? 50 : 0
row: appearsOn[index]
rowIndex: index
showAlbum: false
}
}
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 }
Rectangle {
visible: appearsOn.length > root.preview
width: parent.width
height: 28
color: "transparent"
Text {
anchors.centerIn: parent
text: root.appearsOnExpanded ? QbzBridge.tr("View less") : QbzBridge.tr("Load more")
color: loadMoreAppArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
MouseArea {
id: loadMoreAppArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.appearsOnExpanded = !root.appearsOnExpanded
}
}
}
}
// --- Playlists --------------------------------------------
Column {
visible: playlists.length > 0
width: parent.width
spacing: 12
Item { width: 1; height: 32 }
Text {
text: QbzBridge.tr("Playlists")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: playlists
delegate: Rectangle {
width: 200
height: 246
color: "transparent"
Column {
spacing: 0
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: root.coverMap[modelData.artUrl] || ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// POC-NOTE: no playlist view yet.
}
}
Item { width: 1; height: 6 }
Text {
width: 200
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: 200
text: modelData.subtitle
color: theme.textMuted
font.pixelSize: theme.fontLink - 1
elide: Text.ElideRight
}
}
}
}
}
// --- Other (collapsed) ------------------------------------
Repeater {
model: releaseSections
delegate: Column {
required property var modelData
width: parent ? parent.width : 0
spacing: 0
visible: modelData.releaseType === "other"
Item { width: 1; height: 32 }
Row {
width: parent.width
spacing: 8
Text {
width: parent.width - otherToggle.implicitWidth - 8
anchors.verticalCenter: parent.verticalCenter
text: modelData.title
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Text {
id: otherToggle
anchors.verticalCenter: parent.verticalCenter
text: root.otherExpanded ? QbzBridge.tr("Hide") : QbzBridge.tr("Show")
color: otherToggleArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 13
MouseArea {
id: otherToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.otherExpanded = !root.otherExpanded
}
}
}
Item { visible: root.otherExpanded; width: 1; height: 12 }
Grid {
visible: root.otherExpanded
width: parent.width
columns: Math.max(1, Math.floor((width + 24) / 224))
columnSpacing: 24
rowSpacing: 24
Repeater {
model: modelData.cards
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.artUrl] || ""
isFavorite: false
}
}
}
}
}
}
// ================= In library tab =============================
Column {
id: libraryTab
visible: root.artistTab === "library" && !QbzBridge.artistLoading
width: parent.width - 64
spacing: 0
readonly property var libItems: {
var out = []
var feed = libraryFeed()
for (var i = 0; i < feed.length; i++) {
if (feed[i].artistId === artist.id && (feed[i].kind === "track" || feed[i].kind === "album"))
out.push(feed[i])
}
return out
}
readonly property var libAlbums: libItems.filter(function (x) { return x.kind === "album" })
readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" })
Text {
visible: libTracks.length > 0
text: QbzBridge.tr("Tracks")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Item { visible: libTracks.length > 0; width: 1; height: 10 }
Repeater {
model: libraryTab.libTracks
delegate: PopularTrackRow {
row: ({
"id": modelData.id, "number": index + 1, "title": modelData.title,
"artist": modelData.artist, "artistId": modelData.artistId,
"album": modelData.album, "albumId": modelData.albumId,
"duration": modelData.duration, "qualityTier": modelData.qualityTier,
"explicit": modelData.explicit, "artUrl": modelData.imageUrl,
"isFavorite": modelData.isFavorite,
})
rowIndex: index
showAlbum: true
}
}
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 24 }
Text {
visible: libraryTab.libAlbums.length > 0
text: QbzBridge.tr("Albums")
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Item { visible: libraryTab.libAlbums.length > 0; width: 1; height: 10 }
Grid {
visible: libraryTab.libAlbums.length > 0
width: parent.width
columns: Math.max(1, Math.floor((width + 24) / 224))
columnSpacing: 24
rowSpacing: 24
Repeater {
model: libraryTab.libAlbums
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.imageUrl] || ""
isFavorite: modelData.isFavorite
}
}
}
}
}
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: flick
}
}
// Library feed access (the phase-5 document, parsed in LibraryView).
function libraryFeed() {
return JSON.parse(QbzBridge.libraryJson)
}
// --- Network sidebar (300px, surface-card + 1px left border) ---------
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.networkOpen ? 300 : 0
clip: true
color: theme.surfaceCard
Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.InOutQuad } }
Rectangle { anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom; width: 1; color: theme.borderSubtle }
Column {
anchors.fill: parent
spacing: 0
// Header: Network / Magazine tabs + close.
Item {
width: parent.width
height: 44
Row {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 14
Repeater {
model: [
{ "id": "network", "label": QbzBridge.tr("Network") },
{ "id": "magazine", "label": QbzBridge.tr("Magazine") },
]
delegate: Column {
required property var modelData
spacing: 0
Text {
text: modelData.label
color: root.netTab === modelData.id ? theme.textPrimary : theme.textMuted
font.pixelSize: 12
font.weight: theme.weightSemibold
font.letterSpacing: 0.8
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.netTab = modelData.id
}
}
Rectangle {
visible: root.netTab === modelData.id
width: parent.width
height: 2
radius: 1
color: theme.accent
}
}
}
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 28
height: 28
radius: 6
color: netCloseArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "panel-right-close"
width: 18
height: 18
tintName: netCloseArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: netCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.networkOpen = false
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
// Network tab body.
Flickable {
visible: root.netTab === "network"
width: parent.width
height: parent.height - 45
clip: true
contentWidth: width
contentHeight: netBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: netBody
width: parent.width
topPadding: 4
bottomPadding: 12
spacing: 0
// LABELS.
Column {
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 12
bottomPadding: 6
spacing: 4
Text {
text: QbzBridge.tr("LABELS")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Text {
visible: labels.length === 0
text: QbzBridge.tr("No label info")
color: theme.textMuted
font.pixelSize: 12
}
Repeater {
model: labels
delegate: SidebarLink {
label: modelData.name
iconName: "disc"
// POC-NOTE: no label view yet.
}
}
}
// SIMILAR ARTISTS.
Column {
visible: similarArtists.length > 0 || QbzBridge.artistLoading
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 12
bottomPadding: 6
spacing: 4
Text {
text: QbzBridge.tr("SIMILAR ARTISTS")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Text {
visible: similarArtists.length === 0 && QbzBridge.artistLoading
text: QbzBridge.tr("Loading…")
color: theme.textMuted
font.pixelSize: 12
}
Repeater {
model: similarArtists
delegate: SidebarLink {
label: modelData.name
iconName: "user"
onClicked: QbzBridge.openArtist(modelData.id)
}
}
}
}
}
// Magazine tab body — POC-NOTE placeholder (the Stories CMS is
// a separate subsystem).
Item {
visible: root.netTab === "magazine"
width: parent.width
height: parent.height - 45
Text {
anchors.centerIn: parent
width: parent.width - 28
text: "QBZ Qt POC — the Magazine feed is not wired yet"
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
// --- Radio dropdown (INERT items — POC-NOTE: radio engines) ----------
Popup {
id: radioPopup
x: 260
y: 300
width: 180
padding: 5
closePolicy: Popup.CloseOnPressOutside
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Column {
Repeater {
model: [QbzBridge.tr("QBZ Radio"), QbzBridge.tr("Qobuz Radio")]
delegate: Rectangle {
required property string modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: radioOptArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: "radio"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: modelData
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: radioOptArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: radioPopup.close()
}
}
}
}
}
// --- ⋯ overflow menu ---------------------------------------------------
Popup {
id: overflowMenu
x: 260
y: 300
width: 224
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("Create Artist Collection"), "icon": "library-big", "action": "stub" },
{ "label": QbzBridge.tr("Artist Scene"), "icon": "map-pin", "action": "stub" },
{ "label": QbzBridge.tr("Share"), "icon": "link", "action": "stub" },
{ "label": artist.isPinned ? QbzBridge.tr("Unpin") : QbzBridge.tr("Pin"), "icon": artist.isPinned ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: omiArea.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: omiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
overflowMenu.close()
if (modelData.action === "pin") {
artist.isPinned = !artist.isPinned
QbzBridge.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
}
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
Rectangle {
width: parent.width
height: 33
radius: 5
color: blArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
spacing: 8
QbzIcon { name: "blind-eye"; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
Text {
height: parent.height
text: QbzBridge.tr("Blacklist artist")
color: theme.textSecondary
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: blArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: overflowMenu.close()
}
}
}
}
// --- Popular Tracks ⋯ menu ---------------------------------------------
Popup {
id: topMenu
x: 260
y: 340
width: 224
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 all next"), "icon": "list-start", "action": "next-all" },
{ "label": QbzBridge.tr("Add all to queue"), "icon": "list-end", "action": "queue-all" },
{ "label": QbzBridge.tr("Shuffle all"), "icon": "shuffle", "action": "shuffle-all" },
{ "label": QbzBridge.tr("Add all to playlist"), "icon": "list-music", "action": "playlist-all" },
]
delegate: Rectangle {
required property var modelData
width: parent ? parent.width : 0
height: 33
radius: 5
color: tmiArea.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: tmiArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
topMenu.close()
var a = modelData.action
if (a === "shuffle-all") QbzBridge.playArtistTop(true)
else if (a === "next-all") QbzBridge.playArtistTop(false)
else if (a === "queue-all") QbzBridge.enqueueArtistTop()
// playlist-all: inert (no picker) — POC-NOTE.
}
}
}
}
}
}
// --- Full-bio modal ----------------------------------------------------
Rectangle {
visible: root.showBio
anchors.fill: parent
color: "#bf000000"
MouseArea {
anchors.fill: parent
onClicked: root.showBio = 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: artist.name || ""
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
width: 28
height: 28
color: bioCloseArea.containsMouse ? theme.surfaceHover : "transparent"
radius: 6
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 18
height: 18
tintName: bioCloseArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: bioCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.showBio = false
}
}
}
Flickable {
width: parent.width
height: parent.height - 42
clip: true
contentWidth: width
contentHeight: bioText.implicitHeight
Text {
id: bioText
width: parent.width
text: artist.bio || ""
color: theme.textSecondary
font.pixelSize: theme.fontBody
wrapMode: Text.WordWrap
}
}
}
}
}
}