feat(qbz-qt): fill the Album and Artist pages from the integrations

The two pages were missing most of what Slint shows. With MusicBrainz, Last.fm
and ListenBrainz wired this round, the sections that depend on them can exist:
the artist biography and network, relationship rows, listener figures and the
credit blocks the Slint pages carry.

Nothing is faked. A section whose data an integration cannot supply is absent
rather than an empty frame, and a section fed by an integration the user has
not connected degrades to absent without a network call — integrations stay
strictly opt-in. The relationship rows are non-navigable for now because
resolving a musician name to a catalog artist needs a bridge invokable that is
not there yet; an informational row is better than a dead click.

cc
This commit is contained in:
vicrodh committed 2026-07-28 16:23:36 -06:00
1 parent e0fcf7a089
commit 49ab5f5b9d
7 files changed
+1333 -79

No files matched your search

+482 -54
View File
@@ -7,12 +7,18 @@
// (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).
// tabs, ORIGIN, LABELS, SIMILAR ARTISTS, RELATIONSHIPS, YOU MAY ALSO LIKE,
// and the Magazine story teasers).
//
// 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).
// The document arrives in passes: the Qobuz page first, then the Magazine
// stories, then MusicBrainz Origin -> Relationships -> Discovery (see
// artist_qt.rs). Each MB section renders its own "Loading…" line and, when
// MusicBrainz is off in Settings or the artist has no confident MB match, is
// simply ABSENT — never an error frame, and nothing is requested.
//
// POC-NOTEs: 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
@@ -42,6 +48,13 @@ Rectangle {
readonly property var labels: artist.labels || []
readonly property var similarArtists: artist.similarArtists || []
readonly property var playlists: artist.playlists || []
// MusicBrainz-driven sidebar payload (artist_qt.rs ArtistNetwork). Absent
// on the very first frame of a cold document — every read below is
// defaulted so a missing member can never throw.
readonly property var network: artist.network || ({})
readonly property var mbOrigin: network.origin || ({})
readonly property var mbRelationships: network.relationships || ({})
readonly property var stories: artist.stories || []
property var coverMap: ({})
property string activeJumpTab: "popular-tracks"
@@ -52,6 +65,45 @@ Rectangle {
property bool networkOpen: false
property string netTab: "network"
readonly property int preview: 5
// Sidebar lists are unbounded upstream (an orchestra can list 150
// members): show a slice, expand on demand — the delegates for the rest
// are never instantiated.
readonly property int sidebarPreview: 12
property bool membersExpanded: false
property bool groupsExpanded: false
property bool collabsExpanded: false
// Thumbs-downed discovery rows, by mbid. Session-only: the Slint app
// persists these in its `discovery_dismiss` store, which this POC does
// not open, so the rejection lasts as long as the process — it is NOT
// written anywhere and makes no claim to be.
property var dismissedDiscovery: ({})
// The artist the view state (tab choice, dismissals) belongs to. Compared
// on every republish so a mid-load pass never resets the user's choices.
property string loadedArtistId: ""
// Optimistic heart/pin state. The document is republished several times
// per page now (stories, then each MusicBrainz section), and every
// republish re-parses `artist` — a toggle written straight onto the parsed
// object would silently pop back. Overrides live here instead and win over
// whatever the document says, until the artist changes.
property var localToggles: ({})
function toggleState(key, fallback) {
return localToggles[key] !== undefined ? localToggles[key] : fallback === true
}
function setToggleState(key, value) {
var m = localToggles
m[key] = value
localToggles = Object.assign({}, m)
}
readonly property var discoveryRows: {
var out = []
var rows = network.discovery || []
for (var i = 0; i < rows.length; i++) {
if (!dismissedDiscovery[rows[i].mbid]) out.push(rows[i])
}
return out
}
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
readonly property var jumpTabs: {
@@ -98,17 +150,60 @@ Rectangle {
root.artistChanged()
}
}
Component.onCompleted: dispatchCovers()
onArtistChanged: dispatchCovers()
Component.onCompleted: {
syncArtistState()
dispatchCovers()
}
onArtistChanged: {
syncArtistState()
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()
// The document is republished several times per page (stories, then each
// MusicBrainz section). Reset per-artist view state ONLY when the id
// actually changed, or an enrichment pass would yank the sidebar tab back
// under the user mid-read.
function syncArtistState() {
var id = artist.id || ""
if (id === loadedArtistId)
return
loadedArtistId = id
// Slint opens a fresh artist on Network, or on Magazine when
// MusicBrainz is off (an empty Network tab is worse than none).
netTab = (artist.network && artist.network.mbAvailable) ? "network" : "magazine"
dismissedDiscovery = ({})
localToggles = ({})
membersExpanded = false
groupsExpanded = false
collabsExpanded = false
dispatchedCovers = ({})
}
function dispatchLibCovers() {
var urls = []
var items = libraryTab.libItems || []
var urls = []
for (var i = 0; i < items.length; i++) if (items[i].imageUrl) urls.push(items[i].imageUrl)
if (urls.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
dispatchArtwork(urls)
}
// Already-requested artwork keys. With the progressive republish the
// dispatch runs once per pass, so re-sending the whole (potentially
// several-hundred-entry) URL list every time is pure waste — send only
// what is new.
property var dispatchedCovers: ({})
function dispatchArtwork(urls) {
var fresh = []
for (var i = 0; i < urls.length; i++) {
var u = urls[i]
if (!u || dispatchedCovers[u]) continue
dispatchedCovers[u] = true
fresh.push(u)
}
if (fresh.length > 0) QbzShell.sidebarArtworkWindow(JSON.stringify(fresh))
}
function dispatchCovers() {
@@ -119,7 +214,9 @@ Rectangle {
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) QbzShell.sidebarArtworkWindow(JSON.stringify(urls))
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
for (i = 0; i < stories.length; i++) if (stories[i].artUrl) urls.push(stories[i].artUrl)
dispatchArtwork(urls)
}
function scrollToSection(id) {
@@ -293,8 +390,10 @@ Rectangle {
font.weight: theme.weightBold
horizontalAlignment: Text.AlignHCenter
}
// Favorite (live).
// Favorite (live). Reads through the override map so the state
// survives a document republish (see root.localToggles).
Rectangle {
property bool favorite: root.toggleState("track:" + row.id, row.isFavorite)
width: 28
height: 28
radius: theme.radiusSm
@@ -302,10 +401,10 @@ Rectangle {
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: row.isFavorite ? "heart-filled" : "heart"
name: parent.favorite ? "heart-filled" : "heart"
width: 16
height: 16
tintName: row.isFavorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
tintName: parent.favorite ? "favorite" : (favArea.containsMouse ? "primary" : "muted")
}
MouseArea {
id: favArea
@@ -313,7 +412,7 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
row.isFavorite = !row.isFavorite
root.setToggleState("track:" + row.id, !parent.favorite)
QbzLibrary.libraryToggleFavorite("track", row.id)
}
}
@@ -354,10 +453,14 @@ Rectangle {
}
}
// Sidebar link row (SidebarLink).
// Sidebar link row (SidebarLink). `navigable` false = informational row:
// no pointer cursor, no hover promise it cannot keep (used by the MB
// Relationships rows, which have no destination in this port).
component SidebarLink: Rectangle {
property string label: ""
property string iconName: "user"
property string tooltip: ""
property bool navigable: true
signal clicked()
width: parent ? parent.width : 0
height: 28
@@ -388,8 +491,113 @@ Rectangle {
id: slArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.clicked()
cursorShape: navigable ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (navigable) parent.clicked()
ToolTip.visible: containsMouse && tooltip !== ""
ToolTip.text: tooltip
ToolTip.delay: 400
}
}
// Sidebar section heading (11px muted caps, letter-spaced).
component SidebarSectionHeading: Text {
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// Small 11px muted line — sub-group labels and the "Loading…"/empty
// states inside the sidebar sections.
component SidebarNote: Text {
color: theme.textMuted
font.pixelSize: 12
}
// One "KEY value" row of the MB Origin block.
component OriginRow: Item {
property string key: ""
property string value: ""
// The host section Column carries 14px of left+right padding, and a
// QML Positioner does NOT shrink its children for it — a right-aligned
// value bound to the bare parent.width would run past the sidebar
// edge. Subtract it here (this row only ever lives in that section).
width: parent ? parent.width - 28 : 0
height: 20
Text {
id: originKey
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: key
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Text {
anchors.right: parent.right
anchors.left: originKey.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: value
color: theme.textPrimary
font.pixelSize: 13
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
}
// One MB relationship sub-group (Members & Former / Member Of /
// Collaborators) with a preview cap + expander.
component RelationshipGroup: Column {
id: relGroup
property string title: ""
property var rows: []
property string iconName: "user"
property bool expanded: false
signal toggled()
visible: rows.length > 0
// -28 = the host section Column's left+right padding (see OriginRow).
width: parent ? parent.width - 28 : 0
spacing: 2
topPadding: 2
SidebarNote {
text: relGroup.title
font.pixelSize: 11
}
Repeater {
model: relGroup.rows.length > root.sidebarPreview && !relGroup.expanded
? relGroup.rows.slice(0, root.sidebarPreview)
: relGroup.rows
delegate: SidebarLink {
required property var modelData
label: modelData.name
tooltip: modelData.tooltip
iconName: relGroup.iconName
// POC-NOTE: the Slint row resolves the musician through
// MusicBrainz and lands on the Qobuz artist (or its
// MusicianPageView). Neither exists here, so the row is
// informational — see GLUE in the handoff report.
navigable: false
}
}
Text {
visible: relGroup.rows.length > root.sidebarPreview
leftPadding: 8
// Same msgid pair the page's other expanders use — no new
// catalog entries (all 8 locales already carry these).
text: relGroup.expanded
? QbzSession.tr("View less", QbzSession.trRev)
: QbzSession.tr("Load more", QbzSession.trRev)
color: relMoreArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
MouseArea {
id: relMoreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: relGroup.toggled()
}
}
}
@@ -588,7 +796,15 @@ Rectangle {
onClicked: {
var shell = root.parent
while (shell && shell.openTextModal === undefined) shell = shell.parent
if (shell) shell.openTextModal(artist.name || "", artist.bio || "")
if (!shell) return
// The Slint modal renders the attribution
// ("Source: TiVo") as a small line under the
// body; the shared text modal has one body
// slot, so it rides at the end.
var body = artist.bio || ""
if ((artist.bioSource || "") !== "")
body += "\n\n" + QbzSession.tr("Source", QbzSession.trRev) + ": " + artist.bioSource
shell.openTextModal(artist.name || "", body)
}
}
}
@@ -599,11 +815,12 @@ Rectangle {
width: parent.width
spacing: 12
QbzCircleAction {
name: artist.isFollowing ? "heart-filled" : "heart"
active: artist.isFollowing === true
readonly property bool following: root.toggleState("artist", artist.isFollowing)
name: following ? "heart-filled" : "heart"
active: following
anchors.verticalCenter: parent.verticalCenter
onClicked: {
artist.isFollowing = !artist.isFollowing
root.setToggleState("artist", !following)
QbzLibrary.libraryToggleFavorite("artist", artist.id)
}
}
@@ -1233,6 +1450,47 @@ Rectangle {
bottomPadding: 12
spacing: 0
// ORIGIN (MusicBrainz). Gated exactly like the Slint
// block: MB available AND (still loading OR the artist
// actually carries a life span / location). With MB off
// the whole block is absent — nothing was requested.
Column {
visible: network.mbAvailable === true
&& (network.originLoading === true || mbOrigin.hasData === true)
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 12
bottomPadding: 12
spacing: 6
SidebarNote {
visible: network.originLoading === true
text: QbzSession.tr("Loading origin…", QbzSession.trRev)
}
OriginRow {
visible: network.originLoading !== true && (mbOrigin.beginDate || "") !== ""
key: mbOrigin.isPerson ? QbzSession.tr("BORN", QbzSession.trRev)
: QbzSession.tr("FOUNDED", QbzSession.trRev)
value: mbOrigin.beginDate || ""
}
OriginRow {
visible: network.originLoading !== true && (mbOrigin.locationDisplay || "") !== ""
key: mbOrigin.isPerson ? QbzSession.tr("BORN IN", QbzSession.trRev)
: QbzSession.tr("FOUNDED IN", QbzSession.trRev)
// POC-NOTE: the Slint row is clickable and opens
// ArtistsByLocationView; that view has no port
// here, so the affordance is left out rather than
// rendered dead.
value: mbOrigin.locationDisplay || ""
}
OriginRow {
visible: network.originLoading !== true && (mbOrigin.endDate || "") !== ""
key: mbOrigin.isPerson ? QbzSession.tr("DIED", QbzSession.trRev)
: QbzSession.tr("DISBANDED", QbzSession.trRev)
value: mbOrigin.endDate || ""
}
}
// LABELS.
Column {
width: parent.width
@@ -1241,23 +1499,16 @@ Rectangle {
topPadding: 12
bottomPadding: 6
spacing: 4
Text {
text: QbzSession.tr("LABELS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Text {
SidebarSectionHeading { text: QbzSession.tr("LABELS", QbzSession.trRev) }
SidebarNote {
visible: labels.length === 0
text: QbzSession.tr("No label info", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
}
Repeater {
model: labels
delegate: SidebarLink {
label: modelData.name
tooltip: modelData.name
iconName: "disc"
// POC-NOTE: no label view yet.
}
@@ -1272,45 +1523,222 @@ Rectangle {
topPadding: 12
bottomPadding: 6
spacing: 4
Text {
text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Text {
SidebarSectionHeading { text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev) }
SidebarNote {
visible: similarArtists.length === 0 && QbzArtist.artistLoading
text: QbzSession.tr("Loading…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
}
Repeater {
model: similarArtists
delegate: SidebarLink {
label: modelData.name
tooltip: modelData.name
iconName: "user"
onClicked: QbzArtist.openArtist(modelData.id)
}
}
}
// RELATIONSHIPS (MusicBrainz) — band members, the groups
// this artist belongs to, and studio collaborators.
Column {
visible: network.mbAvailable === true
&& (network.relationshipsLoading === true
|| mbRelationships.hasData === true)
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 12
bottomPadding: 6
spacing: 6
SidebarSectionHeading { text: QbzSession.tr("RELATIONSHIPS", QbzSession.trRev) }
SidebarNote {
visible: network.relationshipsLoading === true
text: QbzSession.tr("Loading…", QbzSession.trRev)
}
RelationshipGroup {
visible: network.relationshipsLoading !== true
&& (mbRelationships.members || []).length > 0
title: QbzSession.tr("Members & Former", QbzSession.trRev)
rows: mbRelationships.members || []
iconName: "user"
expanded: root.membersExpanded
onToggled: root.membersExpanded = !root.membersExpanded
}
RelationshipGroup {
visible: network.relationshipsLoading !== true
&& (mbRelationships.groups || []).length > 0
title: QbzSession.tr("Member Of", QbzSession.trRev)
rows: mbRelationships.groups || []
iconName: "music"
expanded: root.groupsExpanded
onToggled: root.groupsExpanded = !root.groupsExpanded
}
RelationshipGroup {
visible: network.relationshipsLoading !== true
&& (mbRelationships.collaborators || []).length > 0
title: QbzSession.tr("Collaborators", QbzSession.trRev)
rows: mbRelationships.collaborators || []
iconName: "user"
expanded: root.collabsExpanded
onToggled: root.collabsExpanded = !root.collabsExpanded
}
}
// YOU MAY ALSO LIKE (MusicBrainz tag discovery, validated
// against Qobuz by the core). Rows without a resolved
// Qobuz id stay informational instead of dead-clicking.
Column {
visible: network.mbAvailable === true
&& (network.discoveryLoading === true || root.discoveryRows.length > 0)
width: parent.width
leftPadding: 14
rightPadding: 14
topPadding: 12
bottomPadding: 12
spacing: 4
SidebarSectionHeading { text: QbzSession.tr("YOU MAY ALSO LIKE", QbzSession.trRev) }
SidebarNote {
visible: network.discoveryLoading === true && root.discoveryRows.length === 0
text: QbzSession.tr("Loading…", QbzSession.trRev)
}
Repeater {
model: root.discoveryRows
delegate: Item {
required property var modelData
// -28 = the section Column's left+right
// padding (see OriginRow).
width: parent ? parent.width - 28 : 0
height: 28
SidebarLink {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
// Explicit width REPLACES the component's
// own `parent.width` binding (leaving it
// and anchoring both edges fights it).
width: parent.width - 26
label: modelData.name
tooltip: modelData.name
iconName: "user"
navigable: modelData.qobuzId !== ""
onClicked: QbzArtist.openArtist(modelData.qobuzId)
}
Rectangle {
id: dismissBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
radius: 4
color: dismissArea.containsMouse ? theme.surfaceElevated : "transparent"
QbzIcon {
anchors.centerIn: parent
name: "thumbs-down"
width: 12
height: 12
tintName: dismissArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: dismissArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Session-only: drop the row now. The
// Slint app also persists it under the
// discovery tag; that store is not open
// in this port (see the handoff report).
onClicked: {
var d = root.dismissedDiscovery
d[modelData.mbid] = true
root.dismissedDiscovery = Object.assign({}, d)
}
}
}
}
}
}
}
}
// Magazine tab body — POC-NOTE placeholder (the Stories CMS is
// a separate subsystem).
Item {
// Magazine tab body — Qobuz editorial story teasers (limit 2,
// like the official client). A story opens in the system browser.
Flickable {
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
clip: true
contentWidth: width
contentHeight: magBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Column {
id: magBody
width: parent.width
padding: 12
spacing: 10
SidebarNote {
visible: artist.storiesLoading === true && stories.length === 0
text: QbzSession.tr("Loading…", QbzSession.trRev)
}
SidebarNote {
visible: artist.storiesLoading !== true && stories.length === 0
text: QbzSession.tr("No stories for this artist", QbzSession.trRev)
}
Repeater {
model: stories
delegate: Rectangle {
required property var modelData
width: magBody.width - 24
height: storyCol.implicitHeight
radius: 8
color: storyArea.containsMouse ? theme.surfaceHover : "transparent"
Column {
id: storyCol
width: parent.width
padding: 6
spacing: 6
// 1:1 square thumbnail, height tracks width.
Rectangle {
visible: (modelData.artUrl || "") !== ""
width: storyCol.width - 12
height: visible ? width : 0
radius: 6
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: root.coverMap[modelData.artUrl] || ""
radius: 6
}
}
Text {
width: storyCol.width - 12
text: modelData.title
color: theme.textPrimary
font.pixelSize: 13
font.weight: theme.weightSemibold
wrapMode: Text.WordWrap
}
Text {
visible: (modelData.author || "") !== ""
width: storyCol.width - 12
text: modelData.author
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
MouseArea {
id: storyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if ((modelData.url || "") !== "") Qt.openUrlExternally(modelData.url)
}
}
}
}
}
}
@@ -1361,7 +1789,7 @@ Rectangle {
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "stub" },
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "stub" },
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "stub" },
{ "label": artist.isPinned ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": artist.isPinned ? "pin-filled" : "pin", "action": "pin" },
{ "label": root.toggleState("artistPin", artist.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("artistPin", artist.isPinned) ? "pin-filled" : "pin", "action": "pin" },
]
delegate: Rectangle {
required property var modelData
@@ -1392,7 +1820,7 @@ Rectangle {
onClicked: {
overflowMenu.close()
if (modelData.action === "pin") {
artist.isPinned = !artist.isPinned
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
}
}