"Play selected" arms a select mode over one album section, and its cards only ever toggled one at a time. Shift now adds the run from the last clicked card in the section's current sort order and Ctrl adds one, through the same SelectionModel every other select-mode list uses. The anchor drops when a section is armed or disarmed. cc
4243 lines
222 KiB
QML
4243 lines
222 KiB
QML
// Artist detail page — QML port of artist/ArtistPageView.slint.
|
||
//
|
||
// Header (200px circular portrait — right-click for the image menu
|
||
// (Add/Change/Remove custom image, View image, Open in browser, Save as…),
|
||
// left-click for the lightbox — 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, ORIGIN, LABELS, SIMILAR ARTISTS, RELATIONSHIPS, YOU MAY ALSO LIKE,
|
||
// and the Magazine story teasers).
|
||
//
|
||
// 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.
|
||
//
|
||
// Blacklist banner, Artist Scene, Create Collection, both radio engines,
|
||
// multi-select and the sticky JUMP TO bar are live. The remaining artist-page
|
||
// deltas are documented beside their data producers in artist_qt.rs.
|
||
//
|
||
// "Share" left this list when the seam landed: the ⋯ entry now calls
|
||
// QbzArtist.share (artist_bridge.rs -> src/share_qt.rs), which copies
|
||
// play.qobuz.com/artist/{id} and raises the same "Link copied" toast as
|
||
// ArtistPageView.slint:530-538 / main.rs:12749.
|
||
//
|
||
// Header atmosphere (ArtistPageView.slint:120-147, 211-247): wired through
|
||
// the shared controls/HeaderGradient.qml — the SAME component AlbumView
|
||
// mounts, because the .slint paints both headers from identical blocks. It
|
||
// carries the .slint's header-colour rules with it (light text + overlay
|
||
// CircleActions while the band is on).
|
||
|
||
import "../assets/release-sort.js" as ReleaseSort
|
||
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Window
|
||
import com.blitzfc.qbz
|
||
import "../cards"
|
||
import "../controls"
|
||
import "../rows"
|
||
import "../shell"
|
||
import "../theme"
|
||
|
||
Rectangle {
|
||
id: root
|
||
// Transparent while the ambient background is active (phase 14 —
|
||
// HomeView.slint:163: the frosted content panel shows through).
|
||
color: ambientOn ? "transparent" : theme.surfaceMain
|
||
readonly property bool ambientOn: theme.ambientOn
|
||
|
||
// Round to the AppShell content-frame bezel (Radius.md): QML clips
|
||
// are rectangular, so the frame's own rounding never reaches the
|
||
// view — the view's own fill must round instead.
|
||
radius: 12
|
||
|
||
QbzTheme { id: theme }
|
||
|
||
readonly property var artist: JSON.parse(QbzArtist.artistJson)
|
||
|
||
// ---- In-page search (JumpNavBar's magnifier) -------------------------
|
||
// artist.rs `filter_artist` (main.rs:14991 wires ArtistActions.search to
|
||
// it) is a PURE client-side filter over Popular Tracks, Appears On and
|
||
// every release-section album — no backend round-trip — so it ports as a
|
||
// filter over the parsed document rather than a bridge call.
|
||
property string searchQuery: ""
|
||
readonly property string needle: searchQuery.trim().toLowerCase()
|
||
|
||
function matchTrack(t) {
|
||
return needle === ""
|
||
|| (t.title || "").toLowerCase().indexOf(needle) >= 0
|
||
|| (t.artist || "").toLowerCase().indexOf(needle) >= 0
|
||
}
|
||
|
||
readonly property var topTracks: {
|
||
var all = artist.topTracks || []
|
||
if (root.needle === "") return all
|
||
var out = []
|
||
for (var i = 0; i < all.length; i++) if (matchTrack(all[i])) out.push(all[i])
|
||
return out
|
||
}
|
||
readonly property var appearsOn: {
|
||
var all = artist.appearsOn || []
|
||
if (root.needle === "") return all
|
||
var out = []
|
||
for (var i = 0; i < all.length; i++) if (matchTrack(all[i])) out.push(all[i])
|
||
return out
|
||
}
|
||
// A section whose albums all filter out DISAPPEARS (artist.rs:1033), and
|
||
// Load more is suppressed while a filter is active (:1042 — appending
|
||
// would bring back unfiltered items).
|
||
//
|
||
// ---- THE PAGED TAIL IS MERGED HERE, NOT PUSHED INTO `artist` ----------
|
||
// Owner, 2026-08-02: "el error esta en que nunca cargan mas albumes."
|
||
// The old handler parsed the page and pushed it into
|
||
// `artist.releaseSections[i].cards` IN PLACE, then fired artistChanged().
|
||
// Both Repeaters (`model: releaseSections` at :1997/:2114 and
|
||
// `model: section.cards` inside ReleaseSection) were then handed the very
|
||
// SAME array object they already held, and the unfiltered arm below used
|
||
// to `return all` — the identical array again. Nothing was dirty, so
|
||
// nothing rebuilt: the page was fetched, parsed and appended to a data
|
||
// structure no view ever re-read. This tree already writes the rule down,
|
||
// in cards/PlaylistCollage.qml: "Rebind requires a NEW object reference."
|
||
// (PRE-EXISTING, not a regression from today's smooth-append work — that
|
||
// pass only added the `releaseMoreLanded` call to the handler. What it
|
||
// changed is that the button now shows an in-flight placeholder, which is
|
||
// what made a silent no-op visible.)
|
||
//
|
||
// So the appended pages live in `root.releaseOverlay` (see its header)
|
||
// and are merged HERE, which builds a NEW section object with a NEW cards
|
||
// array whenever the overlay has anything for that bucket — the one thing
|
||
// that actually invalidates both Repeaters. `artist` is never mutated, so
|
||
// a republish of the document (the progressive Magazine / MusicBrainz
|
||
// passes) cannot lose the tail either.
|
||
//
|
||
// De-dup by album id is MANDATORY on this path and not merely defensive:
|
||
// artist_qt.rs `merge_release_page` (:686) folds every landed page into
|
||
// the STASHED document too, so the moment the next enrichment pass
|
||
// republishes, the document already carries the cards the overlay holds.
|
||
// Without the id filter every appended album would appear twice from that
|
||
// frame on. (Qobuz can also repeat a row across pages by itself.)
|
||
readonly property var releaseSections: {
|
||
var all = artist.releaseSections || []
|
||
var out = []
|
||
for (var i = 0; i < all.length; i++) {
|
||
var src = all[i]
|
||
var extra = root.releaseOverlay[src.releaseType]
|
||
var cards = src.cards || []
|
||
// The bucket's persisted sort, stamped on the row by
|
||
// artist_qt.rs `map_artist` / `resort_section`. It has to be COPIED
|
||
// into every rebuilt literal below: an object literal carries only
|
||
// the keys it names, so a `sortBy` left out here makes the header's
|
||
// QbzSelect snap back to "Default" the moment this arm is taken.
|
||
var sortBy = src.sortBy || "default"
|
||
// `hasMore` is only overridden once a page has actually LANDED
|
||
// (`extra.hasMore` stays undefined otherwise): the document's copy
|
||
// is the truth until then, and blanking it would hide the button —
|
||
// and with it the in-flight placeholder QbzLoadMore draws under it.
|
||
var hasMore = src.hasMore === true
|
||
var k
|
||
if (extra !== undefined) {
|
||
var seen = {}
|
||
var merged = []
|
||
for (k = 0; k < cards.length; k++) {
|
||
merged.push(cards[k])
|
||
seen[cards[k].id] = true
|
||
}
|
||
var tail = extra.cards || []
|
||
for (k = 0; k < tail.length; k++) {
|
||
if (seen[tail[k].id]) continue
|
||
seen[tail[k].id] = true
|
||
merged.push(tail[k])
|
||
}
|
||
// THE TAIL IS APPENDED, SO IT MUST BE RE-SORTED HERE.
|
||
// artist.rs:1245-1258 `append_release_page` re-sorts after
|
||
// folding a page in, "before computing the artwork jobs at
|
||
// their post-sort positions" — under "A–Z" a page-2 album
|
||
// belongs wherever its title puts it, not at the bottom.
|
||
// artist_qt.rs `merge_release_page` does that to the STASHED
|
||
// document; this does it to the copy actually on screen, which
|
||
// is a DIFFERENT array (the overlay never reaches the stash).
|
||
// Fixing only one of the two leaves the grid correct until the
|
||
// next enrichment republish, or wrong until it — see the
|
||
// overlay's own header for why the two copies exist.
|
||
cards = root.sortReleaseCards(merged, sortBy)
|
||
if (extra.hasMore !== undefined) hasMore = extra.hasMore === true
|
||
}
|
||
if (root.needle === "") {
|
||
// No overlay for this bucket -> hand back the document's own
|
||
// object: nothing was appended, so there is nothing to rebind
|
||
// and a fresh wrapper would only churn delegates. (The document
|
||
// arrives already sorted — artist_qt.rs sorts at page build and
|
||
// on every `setSectionSort` — so there is nothing to do to it.)
|
||
out.push(extra === undefined ? src
|
||
: { "releaseType": src.releaseType, "title": src.title,
|
||
"cards": cards, "hasMore": hasMore, "sortBy": sortBy, "sortLoading": src.sortLoading === true })
|
||
continue
|
||
}
|
||
// Filtered arm: the merge happened FIRST, so an appended album
|
||
// that matches the needle is searchable like any other — but
|
||
// `hasMore` is still forced false (artist.rs:1042) and only the
|
||
// KEPT cards travel, so the overlay can never leak a filtered-out
|
||
// card into this arm.
|
||
var kept = []
|
||
for (var j = 0; j < cards.length; j++)
|
||
if ((cards[j].title || "").toLowerCase().indexOf(root.needle) >= 0)
|
||
kept.push(cards[j])
|
||
if (kept.length === 0) continue
|
||
out.push({ "releaseType": src.releaseType, "title": src.title,
|
||
"cards": kept, "hasMore": false, "sortBy": sortBy })
|
||
}
|
||
return out
|
||
}
|
||
/// JS twin of `artist_qt::sort_release_cards`, itself the port of
|
||
/// `album_map::sort_album_items` (crates/qbz/src/album_map.rs:244-264).
|
||
///
|
||
/// Both exist because the bucket exists TWICE: Rust owns the stashed
|
||
/// document (page 1 plus every page `merge_release_page` folded in) and
|
||
/// sorts it there, while `releaseOverlay` holds the pages Load more brought
|
||
/// in on THIS side and is concatenated onto the document's array by the
|
||
/// projection above — an array Rust never sees. The two must agree, so the
|
||
/// arms are kept identical.
|
||
///
|
||
/// Returns a NEW array (`slice()` before `sort()`): the projection binds to
|
||
/// the result, and sorting the caller's array in place would hand the
|
||
/// Repeaters the same reference back — "Rebind requires a NEW object
|
||
/// reference" (cards/PlaylistCollage.qml).
|
||
///
|
||
/// Sort by the original numeric date; `year` is a localized label.
|
||
/// Default preserves the server order, as in the official client.
|
||
function sortReleaseCards(cards, sort) {
|
||
function byTitle(a, b) {
|
||
var x = (a.title || "").toLowerCase(), y = (b.title || "").toLowerCase()
|
||
return x < y ? -1 : (x > y ? 1 : 0)
|
||
}
|
||
switch (sort) {
|
||
case "relevant":
|
||
case "default": return cards.slice().sort(function (a, b) { return (a.defaultOrder || 0) - (b.defaultOrder || 0) })
|
||
case "oldest": return cards.slice().sort(function (a, b) { return ReleaseSort.compareDates(a, b, false) })
|
||
case "newest": return cards.slice().sort(function (a, b) { return ReleaseSort.compareDates(a, b, true) })
|
||
case "title-asc": return cards.slice().sort(byTitle)
|
||
case "title-desc": return cards.slice().sort(function (a, b) { return byTitle(b, a) })
|
||
default: return cards
|
||
}
|
||
}
|
||
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 || []
|
||
|
||
// ---- Artist Scene: the gate BOTH doors share (contract §2.1) ---------
|
||
//
|
||
// Three terms, and dropping any one of them breaks a door:
|
||
//
|
||
// 1. `locationClickable` — computed in Rust (artist_qt.rs `map_origin`)
|
||
// so QML never re-derives the reference's unparenthesised guard. A
|
||
// country-only location has nothing to drill into.
|
||
// 2. `mbid !== ""` — and NOT `network.mbAvailable`, which is the trap.
|
||
// That flag is the user's MusicBrainz OPT-IN and nothing else
|
||
// (artist_qt.rs:982-983, whose doc comment used to claim otherwise);
|
||
// it is seeded SYNCHRONOUSLY with the first artist frame while `mbid`
|
||
// stays "" until the async publish_network lands. Gating on it alone
|
||
// lights both doors up on frame one with no id to hand them, and the
|
||
// click fires with an empty parameter.
|
||
// 3. `mbAvailable` — the MusicBrainz opt-in, which is now trustworthy at
|
||
// THIS point in time: the settings toggle republishes the open artist
|
||
// document (settings_qt.rs, "musicbrainz" arm -> republish_open_artist),
|
||
// so a stale `locationClickable: true` can no longer survive on a page
|
||
// the user navigates back to after switching the integration off.
|
||
readonly property bool sceneAvailable:
|
||
mbOrigin.locationClickable === true
|
||
&& (network.mbid || "") !== ""
|
||
&& network.mbAvailable === true
|
||
|
||
/// Both doors call THIS — never `QbzScene.open` directly — so they can
|
||
/// never drift apart in what they pass.
|
||
function openArtistScene() {
|
||
if (!sceneAvailable)
|
||
return
|
||
QbzScene.open(network.mbid || "",
|
||
mbOrigin.artistName || artist.name || "",
|
||
mbOrigin.locationAreaId || "",
|
||
mbOrigin.locationCity || "",
|
||
mbOrigin.locationCountry || "",
|
||
mbOrigin.locationCountryCode || "",
|
||
mbOrigin.locationPrecision || "",
|
||
(mbOrigin.seedGenres || []).join(","),
|
||
(mbOrigin.seedTags || []).join(","))
|
||
}
|
||
|
||
property var coverMap: ({})
|
||
property string activeJumpTab: "popular-tracks"
|
||
property string artistTab: "catalog"
|
||
|
||
// ---- "In library" tab (#737 round, contract §3/§4) ---------------------
|
||
// The rows ride INSIDE the artist document (`artist.libraryItems`,
|
||
// artist_qt.rs) — favourites ∪ purchases of this artist, one row per
|
||
// entity, the purchase row representative and `isFavorite` meaning "also
|
||
// hearted". The two switches below are the `Library > Albums / Tracks`
|
||
// source switches with THEIR semantics (LibraryView.qml:566-590: neither
|
||
// = union, one = that source, both = intersection) but LOCAL state: the
|
||
// Library view's own switches must not reach into this page.
|
||
property bool libShowPurchases: false
|
||
property bool libShowFavorites: false
|
||
/// Preview 10 (Popular Tracks previews 5), "Load more" opens the whole
|
||
/// list at once — no paging, the rows are already in hand.
|
||
readonly property int libPreview: 10
|
||
property bool libTracksExpanded: false
|
||
readonly property var libItems: {
|
||
var src = artist.libraryItems || []
|
||
var out = []
|
||
for (var i = 0; i < src.length; i++) {
|
||
var it = src[i]
|
||
var purchased = it.group === "purchases"
|
||
var hearted = it.isFavorite === true
|
||
var included
|
||
if (root.libShowPurchases && root.libShowFavorites) included = purchased && hearted
|
||
else if (root.libShowPurchases) included = purchased
|
||
else if (root.libShowFavorites) included = hearted
|
||
else included = true
|
||
if (included) out.push(it)
|
||
}
|
||
return out
|
||
}
|
||
readonly property var libTracks: libItems.filter(function (x) { return x.kind === "track" })
|
||
readonly property var libAlbums: libItems.filter(function (x) { return x.kind === "album" })
|
||
/// The queue a row click / "Play all" builds from: the WHOLE filtered
|
||
/// list in render order, not only the revealed preview — a click on row
|
||
/// 3 must be followed by rows 4..n exactly like Popular Tracks queues its
|
||
/// full list. Handed to `QbzLibrary.libraryPlayVisible` / `libraryPlayAll`
|
||
/// (library_qt::play_from_visible), the source-aware seam LibraryView
|
||
/// uses — never `QbzPlayer.playArtistTrack`, whose queue is the POPULAR
|
||
/// list and which starts it at row 0 for any id it does not hold (#737).
|
||
function libTrackIds() {
|
||
var ids = []
|
||
for (var i = 0; i < root.libTracks.length; i++) ids.push(String(root.libTracks[i].id))
|
||
return ids
|
||
}
|
||
/// The library Albums section's sort — the release buckets' five keys,
|
||
/// seeded from the document (`artist.librarySort`, artist_prefs "library")
|
||
/// and persisted through the same invokable the buckets use. Sorted here,
|
||
/// not in Rust: the rows already live in QML (`sortReleaseCards` reads
|
||
/// `title` / `year`, which a FeedItem carries).
|
||
property string libSort: "default"
|
||
readonly property var libAlbumsSorted: root.sortReleaseCards(root.libAlbums, root.libSort)
|
||
function setLibSort(key) {
|
||
root.libSort = key
|
||
QbzArtist.setSectionSort("library", key)
|
||
}
|
||
|
||
// ---- Album-section play (the split button) -------------------------------
|
||
// Every album section — each catalog bucket and the library Albums grid —
|
||
// carries a QbzSplitButton right of its sort: the body plays the whole
|
||
// section in its SORT order as one queue (`QbzPlayer.playAlbums`), the
|
||
// chevron offers Play random (ONE album of the section, replacing the
|
||
// queue) and Play selected, which arms a per-section select mode: the
|
||
// cards grow a check bottom-right inside the art, the button reads
|
||
// "Play selected (n)" and plays the checked albums in sort order, and the
|
||
// menu swaps that row for Cancel selection. One section is armed at a
|
||
// time (`albumSelectKey` is the bucket's releaseType or "library").
|
||
// No queue options (next / later / queue) on purpose.
|
||
property string albumSelectKey: ""
|
||
property var albumSelected: ({})
|
||
readonly property int albumSelectedCount: Object.keys(root.albumSelected).length
|
||
/// The armed section's cards follow the same rule as every other
|
||
/// select-mode surface: Shift-click ranges over the section's cards in
|
||
/// their CURRENT sort order, Ctrl adds one (controls/SelectionModel.qml).
|
||
SelectionModel { id: albumSectionSel }
|
||
function albumSelectToggle(id, cards, mods) {
|
||
root.albumSelected = albumSectionSel.next(root.albumSelected, id, cards || [],
|
||
mods === undefined ? Qt.NoModifier : mods)
|
||
}
|
||
function albumSelectClear() {
|
||
root.albumSelectKey = ""
|
||
root.albumSelected = ({})
|
||
albumSectionSel.anchorId = ""
|
||
}
|
||
function albumSectionLabel(key) {
|
||
return root.albumSelectKey === key
|
||
? QbzSession.tr("Play selected", QbzSession.trRev) + " (" + root.albumSelectedCount + ")"
|
||
: QbzSession.tr("Play all", QbzSession.trRev)
|
||
}
|
||
function albumSectionMenu(key) {
|
||
var m = [
|
||
{ "label": QbzSession.tr("Play all", QbzSession.trRev), "icon": "play-fill", "action": "play-all" },
|
||
{ "label": QbzSession.tr("Play random", QbzSession.trRev), "icon": "shuffle", "action": "play-random" }
|
||
]
|
||
if (root.albumSelectKey === key)
|
||
m.push({ "label": QbzSession.tr("Cancel selection", QbzSession.trRev), "icon": "x", "action": "cancel-selection" })
|
||
else
|
||
m.push({ "label": QbzSession.tr("Play selected", QbzSession.trRev), "icon": "square-check-big", "action": "play-selected" })
|
||
return m
|
||
}
|
||
function albumSectionIds(cards) {
|
||
var ids = []
|
||
for (var i = 0; i < cards.length; i++) ids.push(String(cards[i].id))
|
||
return ids
|
||
}
|
||
/// `cards` is the section's list in its CURRENT sort order.
|
||
function albumSectionAction(key, cards, action) {
|
||
var ids = root.albumSectionIds(cards)
|
||
if (action === "play-all") {
|
||
root.albumSelectClear()
|
||
if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids))
|
||
} else if (action === "play-random") {
|
||
root.albumSelectClear()
|
||
if (ids.length > 0) QbzPlayer.playAlbum(ids[Math.floor(Math.random() * ids.length)])
|
||
} else if (action === "play-selected") {
|
||
root.albumSelectKey = key
|
||
root.albumSelected = ({})
|
||
albumSectionSel.anchorId = ""
|
||
} else if (action === "cancel-selection") {
|
||
root.albumSelectClear()
|
||
}
|
||
}
|
||
/// The button body: Play all, or — armed — the checked albums in sort
|
||
/// order, then disarm.
|
||
function albumSectionPrimary(key, cards) {
|
||
if (root.albumSelectKey !== key) {
|
||
root.albumSectionAction(key, cards, "play-all")
|
||
return
|
||
}
|
||
var ids = []
|
||
for (var i = 0; i < cards.length; i++)
|
||
if (root.albumSelected[cards[i].id] === true) ids.push(String(cards[i].id))
|
||
root.albumSelectClear()
|
||
if (ids.length > 0) QbzPlayer.playAlbums(JSON.stringify(ids))
|
||
}
|
||
|
||
// ---- Header atmosphere (ArtistPageView.slint:120-147) ----------------
|
||
// Same three-line rule as AlbumView (the .slint says "same rule as
|
||
// AlbumPageView" at :120). The pref is read LIVE off the settings
|
||
// snapshot where one exists, else off the document (artist_qt.rs).
|
||
readonly property bool headerGradientPref: {
|
||
var raw = QbzBridge.settingsJson
|
||
if (raw && raw.length > 2) {
|
||
try {
|
||
var d = JSON.parse(raw)
|
||
if (d.albumHeaderGradient !== undefined)
|
||
return d.albumHeaderGradient === true
|
||
} catch (e) { /* fall through to the document copy */ }
|
||
}
|
||
return artist.headerGradient !== false
|
||
}
|
||
readonly property bool headerAtmoOn: headerGradientPref && !ambientOn
|
||
readonly property bool headerLight: headerGradientPref || ambientOn
|
||
readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary
|
||
readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary
|
||
readonly property bool hdrOverlay: headerLight
|
||
/// Slint's `Theme.text-primary` as an ICON tint, for the hovers that raise
|
||
/// a muted glyph on a THEME surface (every consumer sits on
|
||
/// `surface-elevated`/transparent, never on the artwork header — that one
|
||
/// uses `hdrStrong` above). Runtime-tinted via src/icon_tint_qt.rs, so it
|
||
/// is the live token; it used to be `isDark ? "primary" : "black"`, a
|
||
/// two-value stand-in from when only fixed bakes existed.
|
||
readonly property string tintOnSurface: "textPrimary"
|
||
/// TrackRow.slint:123-125 — the row hover uses the polarity-baked alpha
|
||
/// ramp "so the hover state is visible on light themes too (the old
|
||
/// #ffffff16 was invisible white-on-white there)". The zebra stripe is
|
||
/// deliberately left as the literal, per the same comment.
|
||
readonly property color rowHoverBg: theme.alphaTiers.length > 0
|
||
? theme.alphaTier(8) : (theme.isDark ? "#14ffffff" : "#14000000")
|
||
property bool topTracksExpanded: false
|
||
property bool appearsOnExpanded: false
|
||
property bool otherExpanded: false
|
||
|
||
// ---- Release-section "Load more": in-flight + append-fade state -------
|
||
// controls/QbzLoadMore.qml owns the button and the placeholder row under
|
||
// it; the APPEARANCE of what lands is the HOST's job (that file's "WHAT
|
||
// THIS CONTROL DOES *NOT* DO" note) because only this view knows the index
|
||
// the appended page starts at.
|
||
//
|
||
// Both maps live on `root`, keyed by releaseType, and NOT on the
|
||
// ReleaseSection instances: the Repeater at :1787 re-creates every
|
||
// delegate whenever `artist` changes (a `var` property notifies on every
|
||
// write, and the document is republished once per enrichment pass —
|
||
// stories, then each MusicBrainz section), and one of those republishes is
|
||
// the very frame the new page lands in. A flag parked on the section would
|
||
// be destroyed exactly when it was needed.
|
||
//
|
||
// `releasePending` is COPY-ON-WRITE because the ReleaseSection BINDS
|
||
// `busy` to it, so it has to notify. `releaseFade` is mutated IN PLACE on
|
||
// purpose: it is read once, imperatively, by each card's
|
||
// Component.onCompleted, and a notify there would re-run bindings for
|
||
// nothing.
|
||
property var releasePending: ({})
|
||
/// releaseType -> { from: <index the appended page starts at>,
|
||
/// at: <ms the page LANDED, 0 while in flight> }.
|
||
property var releaseFade: ({})
|
||
|
||
/// ---- The appended pages themselves -----------------------------------
|
||
/// releaseType -> { cards: [<pages 2..n, deduped, NEVER page 1>],
|
||
/// hasMore: <the newest page's has_more; undefined until
|
||
/// a page has landed>,
|
||
/// pages: <index pages loaded so far, 1 = the embedded
|
||
/// bucket — the LOADED_PAGES twin, see
|
||
/// releaseMoreLanded's cap note> }.
|
||
///
|
||
/// The document owns page 1 and stays the source of truth for it; this
|
||
/// overlay owns everything Load more brought in, and `releaseSections`
|
||
/// (:115) merges the two into a NEW array so the Repeaters rebuild. It is
|
||
/// COPY-ON-WRITE for exactly that reason — the projection binds to it, so
|
||
/// a write has to notify.
|
||
///
|
||
/// Why an overlay and not a push into `artist`: `artist` is
|
||
/// `JSON.parse(QbzArtist.artistJson)` (:52), a BINDING. Every progressive
|
||
/// republish (Magazine stories, then each MusicBrainz section — see
|
||
/// artist_qt.rs) re-runs that parse and yields a brand-new object, so
|
||
/// anything written onto the parsed copy is discarded on the next pass.
|
||
/// This is the same reasoning `localToggles` (:437) already carries for
|
||
/// the heart/pin state.
|
||
property var releaseOverlay: ({})
|
||
/// releaseType -> { sent: <offset the page in flight asked for>,
|
||
/// next: <offset the NEXT page must ask for> }.
|
||
///
|
||
/// The paging cursor is the count of rows RECEIVED from the server, which
|
||
/// is NOT the count on screen: both sides drop duplicate ids (here and in
|
||
/// artist_qt.rs `merge_release_page`), so after a page that repeated three
|
||
/// rows the on-screen count trails the server cursor by three. Handing the
|
||
/// on-screen count back as the offset would re-request rows already held,
|
||
/// and the dedup would then swallow them — a Load more that visibly does
|
||
/// nothing, which is the very bug this block exists to kill.
|
||
///
|
||
/// Mutated IN PLACE on purpose, same as `releaseFade`: it is written from
|
||
/// the landing handler and read imperatively from the button's onClicked,
|
||
/// and no binding reads it. A notifying write here would tear down and
|
||
/// rebuild every AlbumCard in every release grid on each click, for
|
||
/// nothing.
|
||
property var releaseCursor: ({})
|
||
/// How long after a landing the threshold stays live. The republish that
|
||
/// carries the page re-creates the delegates in the same tick, so this is
|
||
/// generous; what it buys is that a LATER, unrelated republish (a
|
||
/// MusicBrainz pass) re-creates that tail WITHOUT re-fading it.
|
||
readonly property int releaseFadeWindowMs: 1500
|
||
|
||
/// Called from the section's Load more BEFORE the bridge call, so the
|
||
/// threshold is the count that was on screen at click time. RETURNS the
|
||
/// offset the caller must pass to the bridge (see `releaseCursor`).
|
||
///
|
||
/// `sorted` = this bucket carries a non-default sort. The append fade is
|
||
/// then SUPPRESSED, deliberately: `releaseFadeFrom` hands back an INDEX and
|
||
/// the card delegate fades everything at or past it, which only means "the
|
||
/// page that just landed" while new rows sit at the TAIL. Under "A–Z" the
|
||
/// projection interleaves them (see `sortReleaseCards`), so that band would
|
||
/// dissolve an arbitrary run of cards the user has been looking at all
|
||
/// along. The reference has no fade at all here, so no fade is strictly
|
||
/// closer to it than the wrong one — and tracking the new ids through a
|
||
/// re-sort is not a thing the .slint does either.
|
||
function releaseMoreRequested(releaseType, loadedCount, sorted) {
|
||
if (sorted === true) delete releaseFade[releaseType]
|
||
else releaseFade[releaseType] = { "from": loadedCount, "at": 0 }
|
||
var c = releaseCursor[releaseType]
|
||
// First page of this bucket: nothing has been fetched through the
|
||
// paging endpoint yet, so the document's own row count IS the server
|
||
// cursor. Afterwards the cursor is authoritative.
|
||
var off = (c !== undefined && c.next !== undefined) ? c.next : loadedCount
|
||
releaseCursor[releaseType] = { "sent": off,
|
||
"next": (c !== undefined ? c.next : undefined) }
|
||
var m = Object.assign({}, root.releasePending)
|
||
m[releaseType] = true
|
||
root.releasePending = m
|
||
// artist_bridge.rs emits releaseSectionReady only on SUCCESS
|
||
// (main.rs:872 just logs the error and returns), so a failed page would
|
||
// otherwise leave the skeleton up forever. Bound it with the same 8s
|
||
// QbzLoadMore's own skeleton settles at, after which the button comes
|
||
// back armed.
|
||
releaseSettle.restart()
|
||
return off
|
||
}
|
||
/// The page landed (or came back empty): fold it into the overlay, advance
|
||
/// the cursor, arm the fade window and disarm the placeholder.
|
||
///
|
||
/// `cards` is the RAW page as the bridge sent it; `hasMore` is the
|
||
/// server's own flag for the bucket.
|
||
///
|
||
/// ORDER IS LOAD-BEARING. The overlay write is COPY-ON-WRITE, so it
|
||
/// notifies, so `releaseSections` re-evaluates and the Repeater at :1997
|
||
/// destroys and rebuilds every section delegate — QbzLoadMore and every
|
||
/// AlbumCard included — SYNCHRONOUSLY, inside that assignment. Everything
|
||
/// those rebuilt delegates read has to be true BEFORE it happens:
|
||
/// - `releaseFade[rt].at`, or each appended card's Component.onCompleted
|
||
/// asks `releaseFadeFrom()` while the landing still reads "in flight"
|
||
/// (at === 0 -> MAX_VALUE) and the page snaps in with no fade at all;
|
||
/// - `releasePending`, or the button is re-created with `busy` still
|
||
/// true and the skeleton flashes back for a frame UNDER the page that
|
||
/// just landed (the note the old handler carried, kept verbatim in
|
||
/// spirit).
|
||
/// So: cursor + fade + placeholder first, overlay LAST.
|
||
function releaseMoreLanded(releaseType, cards, hasMore) {
|
||
// ---- 1. the cursor (in place — nothing binds to it) ---------------
|
||
// Advance by the RAW page length, not by what survived the dedup: the
|
||
// server counted every row it sent.
|
||
var c = releaseCursor[releaseType]
|
||
var base = (c !== undefined && c.sent !== undefined) ? c.sent : 0
|
||
releaseCursor[releaseType] = { "sent": base, "next": base + cards.length }
|
||
|
||
// ---- 2. fade window + placeholder ---------------------------------
|
||
if (releaseFade[releaseType] !== undefined)
|
||
releaseFade[releaseType].at = Date.now()
|
||
if (root.releasePending[releaseType] !== undefined) {
|
||
var m = Object.assign({}, root.releasePending)
|
||
delete m[releaseType]
|
||
root.releasePending = m
|
||
}
|
||
|
||
// ---- 3. the overlay (COPY-ON-WRITE — the projection binds to it) ---
|
||
var prev = root.releaseOverlay[releaseType]
|
||
var tail = (prev !== undefined && prev.cards !== undefined)
|
||
? prev.cards.slice() : []
|
||
var seen = {}
|
||
var i
|
||
// Page 1 lives in the document, and once artist_qt.rs
|
||
// `merge_release_page` has folded an earlier page into the STASHED
|
||
// document a republish puts that page there too — so both are checked
|
||
// before anything is kept, and the overlay never grows a row the
|
||
// document already carries.
|
||
var doc = root.artist.releaseSections || []
|
||
for (i = 0; i < doc.length; i++) {
|
||
if (doc[i].releaseType !== releaseType) continue
|
||
var dc = doc[i].cards || []
|
||
for (var j = 0; j < dc.length; j++) seen[dc[j].id] = true
|
||
break
|
||
}
|
||
for (i = 0; i < tail.length; i++) seen[tail[i].id] = true
|
||
var before = tail.length
|
||
for (i = 0; i < cards.length; i++) {
|
||
if (seen[cards[i].id]) continue
|
||
seen[cards[i].id] = true
|
||
tail.push(cards[i])
|
||
}
|
||
// The reference CAPS the index at 4 pages per bucket and hands off to
|
||
// the discography page beyond that: artist.rs:718-720 ("Page 1 is the
|
||
// embedded bucket; 3 more loads reach the cap" — MAX_INDEX_PAGES = 4),
|
||
// enforced at append_release_page:1268. This port has that page now
|
||
// (QbzArtist.openReleases, the section title's own click), so the cap
|
||
// carries over 1:1 — an uncapped index button would page a 300-album
|
||
// Singles bucket into this grid forever, which is exactly what the
|
||
// dedicated page exists to absorb.
|
||
var pages = ((prev !== undefined && prev.pages !== undefined) ? prev.pages : 1) + 1
|
||
// …and `!appended_ids.is_empty()` is the same line's other clause: a
|
||
// page that contributed NOTHING after the dedup (all rows already on
|
||
// screen, or empty outright) kills the button no matter what the flag
|
||
// says — leaving it up would offer a page that can never change the
|
||
// grid, and every further click would burn a request and land here
|
||
// again.
|
||
var more = (hasMore === true) && (tail.length > before) && (pages < 4)
|
||
var o = Object.assign({}, root.releaseOverlay)
|
||
o[releaseType] = { "cards": tail, "hasMore": more, "pages": pages }
|
||
root.releaseOverlay = o
|
||
}
|
||
/// Index the appended page of `releaseType` starts at — +inf when this
|
||
/// section never paged, when its page has not landed yet, or when the
|
||
/// landing is already stale. Fading unconditionally would re-dissolve the
|
||
/// whole grid on every enrichment pass, which reads as a flicker, not as
|
||
/// polish.
|
||
function releaseFadeFrom(releaseType) {
|
||
var e = releaseFade[releaseType]
|
||
if (e === undefined || e.at === 0)
|
||
return Number.MAX_VALUE
|
||
if (Date.now() - e.at > root.releaseFadeWindowMs)
|
||
return Number.MAX_VALUE
|
||
return e.from
|
||
}
|
||
/// The user picked a sort for one bucket (ReleaseGrid.slint:81-89). Hand it
|
||
/// to the bridge — `QbzArtist.setSectionSort` persists it by release_type
|
||
/// and re-sorts the STASHED document, which comes back as a republished
|
||
/// `artistJson` — and reconcile the local paging state first.
|
||
///
|
||
/// The OVERLAY's CARDS for this bucket are dropped. Nothing is lost by
|
||
/// that: an overlay entry is only ever written when a page LANDS, and by
|
||
/// then artist_qt.rs `load_release_page` has already folded that page into
|
||
/// the stashed document (`merge_release_page`, called before the signal is
|
||
/// emitted) — so every card the overlay holds is in the document the
|
||
/// republish is about to carry, now in the new order. Keeping the cards
|
||
/// would leave a second, stale copy of those rows whose only remaining job
|
||
/// is to be deduped away.
|
||
///
|
||
/// `hasMore` and `pages` are KEPT (the entry survives as a card-less
|
||
/// stub). They are paging state, not row data, and neither is in the
|
||
/// document: merge_release_page writes the SERVER's has_more flag into the
|
||
/// stash, not the capped one, so dropping the whole entry after the cap
|
||
/// closed a bucket would let the document's `hasMore: true` resurrect the
|
||
/// button — and the reference's LOADED_PAGES counter survives a sort
|
||
/// change too (nothing in artist.rs `resort_section` touches it).
|
||
///
|
||
/// The CURSOR is deliberately kept. It counts rows the SERVER has sent, and
|
||
/// the server's own order (`release_date`, hardcoded on purpose — the five
|
||
/// picker keys are applied locally and never reach the API) has not changed
|
||
/// just because we re-ordered them here. Resetting it would make the next
|
||
/// Load more re-request pages we already hold, which the dedup would then
|
||
/// swallow — the "button does nothing" bug the cursor exists to prevent.
|
||
///
|
||
/// The FADE threshold is dropped for the same reason `releaseMoreRequested`
|
||
/// suppresses it under a custom sort: it is an index into an order that no
|
||
/// longer exists, and the republish re-creates every delegate.
|
||
function releaseSortChanged(releaseType, sortKey) {
|
||
delete releaseFade[releaseType]
|
||
var sections = root.artist.releaseSections || []
|
||
var oldSort = "default"
|
||
for (var i = 0; i < sections.length; i++) {
|
||
if (sections[i].releaseType === releaseType) oldSort = sections[i].sortBy || "default"
|
||
}
|
||
var serverChanged = (oldSort === "relevant") !== (sortKey === "relevant")
|
||
var overlay = Object.assign({}, root.releaseOverlay)
|
||
if (serverChanged) {
|
||
delete overlay[releaseType]
|
||
delete root.releaseCursor[releaseType]
|
||
var pending = Object.assign({}, root.releasePending)
|
||
delete pending[releaseType]
|
||
root.releasePending = pending
|
||
} else if (overlay[releaseType] !== undefined) {
|
||
var e = Object.assign({}, overlay[releaseType])
|
||
delete e.cards
|
||
overlay[releaseType] = e
|
||
}
|
||
root.releaseOverlay = overlay
|
||
QbzArtist.setSectionSort(releaseType, sortKey)
|
||
}
|
||
Timer {
|
||
id: releaseSettle
|
||
interval: 8000
|
||
repeat: false
|
||
onTriggered: root.releasePending = ({})
|
||
}
|
||
|
||
// ---- Network sidebar open/closed -------------------------------------
|
||
// ShellState.content-constrained (state.slint:4114): window under the NPB
|
||
// breakpoint AND a right panel (Queue / Lyrics) open. NOT a raw
|
||
// `root.width < N` — the .slint calls that exact trigger out as the
|
||
// regression it fixed (ArtistPageView.slint:166-172): at a normal window
|
||
// the content area is already narrow WITHOUT any panel, so the sidebar
|
||
// would never auto-open at all.
|
||
readonly property bool contentConstrained:
|
||
Window.width > 0 && Window.width < 1366
|
||
&& (QbzShell.queueOpen || QbzShell.lyricsOpen)
|
||
// A constrain edge closes the full-width relationship sidebar so the
|
||
// release grid keeps priority. This is deliberately ONE-WAY: after that
|
||
// the existing Network button remains available and may reopen it even in
|
||
// the constrained layout; gaining room must not override that user choice.
|
||
// A newly opened artist still starts from the available-space default in
|
||
// syncArtistState(), matching the page-entry behaviour.
|
||
property bool networkOpen: !contentConstrained
|
||
onContentConstrainedChanged: {
|
||
if (contentConstrained)
|
||
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)
|
||
}
|
||
|
||
// --- Artist blacklist -------------------------------------------------
|
||
// ONE source for the two surfaces the .slint drives off the same
|
||
// ArtistState.is-blacklisted: the overflow-menu row label
|
||
// (ArtistPageView.slint:565-567) and the hidden-artist banner (:595, :600).
|
||
// `artist_qt` seeds the field on entry; the local toggle map provides the
|
||
// optimistic edge while `blacklistChanged` settles or rolls it back.
|
||
readonly property bool artistBlacklisted: toggleState("artistBlacklist", artist.isBlacklisted)
|
||
function toggleBlacklist() {
|
||
var aid = artist.id || ""
|
||
if (aid === "")
|
||
return
|
||
// Optimistic flip first (main.rs:12777 `st.set_is_blacklisted(!was)`),
|
||
// then the mutation; `blacklistChanged` settles it — or rolls it back
|
||
// on a failed write (blacklist_qt.rs `artist_toggle`).
|
||
setToggleState("artistBlacklist", !artistBlacklisted)
|
||
QbzBlacklist.artistToggle(aid, artist.name || "")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ---- Loading staging (artist_qt.rs publishes in passes) --------------
|
||
// The Qobuz page lands first; the Magazine stories and each MusicBrainz
|
||
// sidebar section arrive later on their own flags. Every one of these is
|
||
// ALSO gated on mbAvailable upstream, so with MusicBrainz off in Settings
|
||
// (or no confident match) they are absent — placeholder included.
|
||
readonly property bool primaryLoading: QbzArtist.artistLoading
|
||
&& (artist.topTracks || []).length === 0
|
||
readonly property bool originPending: network.mbAvailable === true
|
||
&& network.originLoading === true
|
||
readonly property bool relationshipsPending: network.mbAvailable === true
|
||
&& network.relationshipsLoading === true
|
||
readonly property bool discoveryPending: network.mbAvailable === true
|
||
&& network.discoveryLoading === true
|
||
&& root.discoveryRows.length === 0
|
||
readonly property bool similarPending: similarArtists.length === 0 && QbzArtist.artistLoading
|
||
readonly property bool storiesPending: artist.storiesLoading === true && stories.length === 0
|
||
|
||
// ONE 900ms phase for every placeholder on the page (QbzSkeleton's COST
|
||
// note: N placeholders, 1 timer). Stops dead when nothing is pending.
|
||
Timer {
|
||
id: skeletonPhase
|
||
property bool on: false
|
||
interval: 900
|
||
repeat: true
|
||
running: root.visible && (root.primaryLoading || root.originPending
|
||
|| root.relationshipsPending || root.discoveryPending
|
||
|| root.similarPending || root.storiesPending)
|
||
onTriggered: on = !on
|
||
}
|
||
|
||
// JUMP TO tabs from the present sections (ArtistState.jump-tabs).
|
||
// Built from the RAW document, never the filtered lists: artist.rs
|
||
// builds jump_tabs ONCE at load (`build_jump_tabs`) and `filter_artist`
|
||
// does not touch them, so the strip must not reshuffle per keystroke.
|
||
readonly property var jumpTabs: {
|
||
var tabs = []
|
||
// The strip is a SCROLL navigator over one vertical page, so it can
|
||
// only list what that page currently draws. In library mode that is
|
||
// the Tracks and Albums sections that have rows — the catalog set
|
||
// stayed up before and "Popular Tracks" scrolled to a section that
|
||
// was not there (owner capture, 2026-09-04).
|
||
if (root.artistTab === "library") {
|
||
// "About" is the strip's back-to-top, and in library mode it is
|
||
// also what keeps the strip MOUNTED when the filter yields nothing
|
||
// (purchases ∧ favourites on an artist whose purchases are not
|
||
// hearted): the bar carries the two switches, so an empty tab set
|
||
// would have hidden the only way to reset them.
|
||
tabs.push({ "id": "about", "label": QbzSession.tr("About", QbzSession.trRev) })
|
||
if (root.libTracks.length > 0) tabs.push({ "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev) })
|
||
if (root.libAlbums.length > 0) tabs.push({ "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev) })
|
||
return tabs
|
||
}
|
||
var rawTop = artist.topTracks || []
|
||
var rawSections = artist.releaseSections || []
|
||
var rawAppears = artist.appearsOn || []
|
||
if ((artist.bio || "") !== "") tabs.push({ "id": "about", "label": QbzSession.tr("About", QbzSession.trRev) })
|
||
if (rawTop.length > 0) tabs.push({ "id": "popular-tracks", "label": QbzSession.tr("Popular Tracks", QbzSession.trRev) })
|
||
for (var i = 0; i < rawSections.length; i++) {
|
||
if (rawSections[i].releaseType !== "other")
|
||
tabs.push({ "id": rawSections[i].releaseType, "label": rawSections[i].title })
|
||
}
|
||
if (rawAppears.length > 0) tabs.push({ "id": "appears-on", "label": QbzSession.tr("Appears On", QbzSession.trRev) })
|
||
return tabs
|
||
}
|
||
|
||
// Two blocks, not one: artwork is QbzLibrary's signal and the releases
|
||
// pager is QbzArtist's. Retargeting a mixed block wholesale would
|
||
// silently orphan the other half — QML resolves handlers lazily, so the
|
||
// discography would just stop loading with nothing in the log.
|
||
// Covers arrive ONE AT A TIME, and on a warm cache `sidebar_artwork_window`
|
||
// emits the whole disk-hit set in a single synchronous loop (main.rs:391).
|
||
// Rebinding `coverMap` per arrival is quadratic in the page: each arrival
|
||
// copied the entire map and re-evaluated the cover binding of EVERY
|
||
// mounted card, so an artist with 200 releases did ~40k binding
|
||
// evaluations and 200 map copies during the frames the page is trying to
|
||
// paint. Arrivals are coalesced into ONE rebind per frame — the same fix
|
||
// LocalLibraryView carries, at O(n) instead of O(n²), with the covers
|
||
// still appearing progressively (16ms granularity is invisible).
|
||
// --- Track info for the Popular Tracks rows (2026-09-13) ---------------
|
||
// ONE lazy modal per artist view (the full Popup tree must not exist per
|
||
// row). Activated on demand, torn down on close (Qt.callLater so the
|
||
// Popup is not destroyed mid-signal) — the same shape rows/TrackRow.qml
|
||
// carries per row.
|
||
// One clipboard carrier for every Popular Tracks row's Copy submenu.
|
||
QbzClipboard { id: artistClipboard }
|
||
|
||
Loader {
|
||
id: artistTrackInfo
|
||
active: false
|
||
sourceComponent: TrackInfoModal { }
|
||
}
|
||
Connections {
|
||
target: artistTrackInfo.item
|
||
ignoreUnknownSignals: true
|
||
function onClosed() { Qt.callLater(function () { artistTrackInfo.active = false }) }
|
||
}
|
||
function openTrackInfo(trackId) {
|
||
if (!trackId || trackId === "")
|
||
return
|
||
artistTrackInfo.active = true
|
||
artistTrackInfo.item.openFor(trackId)
|
||
}
|
||
|
||
property var _coverInbox: ({})
|
||
Timer {
|
||
id: coverFlush
|
||
interval: 16
|
||
repeat: false
|
||
onTriggered: {
|
||
var m = Object.assign({}, root.coverMap, root._coverInbox)
|
||
root._coverInbox = ({})
|
||
// A rebind needs a NEW object reference (same-ref assignment is
|
||
// not a change in QML).
|
||
root.coverMap = m
|
||
}
|
||
}
|
||
Connections {
|
||
target: QbzLibrary
|
||
function onLibraryArtworkReady(key, path) {
|
||
root._coverInbox[key] = path
|
||
if (!coverFlush.running) coverFlush.start()
|
||
}
|
||
// The SETTLED follow/heart state from Rust: the flipped value when the
|
||
// write landed, the UNCHANGED one when it failed. Both the header
|
||
// follow and the Popular Tracks / Appears On hearts write their
|
||
// optimistic flip into localToggles and nothing used to correct it, so
|
||
// a failed write stayed visibly wrong until the user left the page.
|
||
// Key shape is `library_qt::feed_key` (`{kind}:{id}`) — which is
|
||
// already the exact key the track rows use for their override.
|
||
function onLibraryFavoriteChanged(key, value) {
|
||
var aid = (artist && artist.id) ? artist.id : ""
|
||
if (aid !== "" && key === "artist:" + aid)
|
||
root.setToggleState("artist", value)
|
||
else if (key.indexOf("track:") === 0 || key.indexOf("album:") === 0)
|
||
root.setToggleState(key, value)
|
||
}
|
||
// Header pin, same seam: the overflow-menu row flips `artistPin`
|
||
// optimistically, and this settles it from the store (Slint does the
|
||
// same for the open detail view: `st.set_pinned(pinned)` when its id
|
||
// matches). The release CARDS need nothing here — each one listens to
|
||
// this signal itself (cards/AlbumCard.qml).
|
||
function onPinChanged(key, value) {
|
||
var aid = (artist && artist.id) ? artist.id : ""
|
||
if (aid !== "" && key === "artist:" + aid)
|
||
root.setToggleState("artistPin", value)
|
||
}
|
||
}
|
||
// Blacklist settle / rollback. `blacklistChanged` carries the state the
|
||
// write actually produced — flipped on success, UNCHANGED on failure
|
||
// (blacklist_qt.rs `artist_toggle`), which is exactly what main.rs:12799
|
||
// does with its rollback branch. Also the cross-surface walk: unblocking
|
||
// the same artist from the manager view while this page is mounted moves
|
||
// the menu label and drops the banner. Same two-arg `{kind}:{id}` shape as
|
||
// the two signals above; its own Connections block only because the signal
|
||
// lives on a different singleton.
|
||
Connections {
|
||
target: QbzBlacklist
|
||
function onBlacklistChanged(key, value) {
|
||
var aid = (artist && artist.id) ? artist.id : ""
|
||
if (aid !== "" && key === "artist:" + aid)
|
||
root.setToggleState("artistBlacklist", value)
|
||
}
|
||
}
|
||
|
||
Connections {
|
||
target: QbzArtist
|
||
function onReleaseSectionReady(releaseType, cardsJson, hasMore) {
|
||
// Hand the page to the OVERLAY (root.releaseOverlay) and let the
|
||
// `releaseSections` projection merge it. It used to be pushed
|
||
// straight into `root.artist.releaseSections[i].cards` followed by
|
||
// a manual `artistChanged()`, and that is why "nunca cargan mas
|
||
// albumes": the push mutated the array IN PLACE, so both Repeaters
|
||
// were re-handed the identical array reference and QML had nothing
|
||
// to invalidate — the fake `artistChanged()` re-ran every OTHER
|
||
// binding on the page and left the two that mattered untouched,
|
||
// because `releaseSections`' unfiltered arm returned `all`, the
|
||
// document's own array, unchanged.
|
||
//
|
||
// The signal is emitted only on SUCCESS (main.rs:872 logs errors
|
||
// and returns), and an empty page comes through here too — that is
|
||
// what clears the skeleton when a bucket runs out.
|
||
var cards = JSON.parse(cardsJson)
|
||
root.releaseMoreLanded(releaseType, cards, hasMore)
|
||
// The appended covers are not in the document (artist_qt.rs folds
|
||
// them into the STASHED copy, which only reaches us on the next
|
||
// enrichment republish), so the document-driven dispatch would
|
||
// leave the new row as blank tiles until an unrelated pass
|
||
// happened to land. dispatchCovers() reads the overlay too, and
|
||
// `dispatchedCovers` makes the re-scan free.
|
||
root.dispatchCovers()
|
||
}
|
||
}
|
||
Component.onCompleted: {
|
||
syncArtistState()
|
||
dispatchCovers()
|
||
}
|
||
onArtistChanged: {
|
||
syncArtistState()
|
||
invalidatePortraitOverride()
|
||
dispatchCovers()
|
||
// The last owned row just left (a republish after an un-heart): the
|
||
// toggle unmounts, so the page must not stay on an empty tab with no
|
||
// way back.
|
||
if (artistTab === "library" && (artist.libraryCount || 0) === 0)
|
||
artistTab = "catalog"
|
||
}
|
||
// Cover dispatch keys off the raw document (artist.artUrl etc.), so
|
||
// re-fire when the parsed value actually changes (same stale race).
|
||
// (the raw document drives the dispatch; onArtistChanged above covers it)
|
||
onArtistTabChanged: {
|
||
// A selection is a list of THIS mode's rows; it cannot survive the
|
||
// switch. The strip re-anchors on the first section the new mode
|
||
// draws (catalog keeps its Popular Tracks default).
|
||
setMultiSelect(false)
|
||
albumSelectClear()
|
||
// The first SECTION, not "About" (back-to-top) when a section exists.
|
||
var first = ""
|
||
for (var i = 0; i < root.jumpTabs.length; i++) {
|
||
first = root.jumpTabs[i].id
|
||
if (first !== "about") break
|
||
}
|
||
root.activeJumpTab = artistTab === "catalog" ? "popular-tracks" : first
|
||
if (artistTab === "library") {
|
||
// Re-arm the warm, served from cache (main.rs warm_library).
|
||
QbzArtist.warmLibrary()
|
||
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.
|
||
// --- Multi-select (Popular Tracks + Appears On) ------------------------
|
||
// One selection model for the page's Qobuz track lists. Same shape as
|
||
// AlbumView/PlaylistView: state in QML, select-all/clear local, the rest
|
||
// via QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs).
|
||
property bool multiSelect: false
|
||
property var selected: ({})
|
||
readonly property int selectedCount: Object.keys(root.selected).length
|
||
readonly property bool multiSelectOn: root.multiSelect
|
||
function setMultiSelect(on) {
|
||
root.multiSelect = on
|
||
if (!on) { root.selected = ({}); sel.anchorId = "" }
|
||
}
|
||
/// Excel-style selection lives in ONE place — controls/SelectionModel.qml
|
||
/// holds the anchor and the Shift-range rule; this view keeps owning its
|
||
/// map. The two lists are CONCATENATED in the order they are drawn, so a
|
||
/// range can run from the last top track into "Appears on" exactly as it
|
||
/// looks on screen — `selectedIdsInOrder` already walks them that way.
|
||
SelectionModel { id: sel }
|
||
/// The track lists a selection can span, in draw order, for the ACTIVE
|
||
/// mode: the catalog's Popular Tracks + Appears On, or the library tab's
|
||
/// Tracks. `onArtistTabChanged` clears the selection on a switch, so a
|
||
/// range never mixes the two.
|
||
function selectableLists() {
|
||
return root.artistTab === "library" ? [root.libTracks]
|
||
: [root.topTracks, root.appearsOn]
|
||
}
|
||
function toggleSelected(id, mods) {
|
||
var lists = selectableLists()
|
||
var all = lists.length > 1 ? lists[0].concat(lists[1]) : lists[0]
|
||
root.selected = sel.next(root.selected, id, all,
|
||
mods === undefined ? Qt.NoModifier : mods)
|
||
}
|
||
function selectedIdsInOrder() {
|
||
var out = []
|
||
var lists = selectableLists()
|
||
for (var l = 0; l < lists.length; l++) {
|
||
var rows = lists[l]
|
||
for (var i = 0; i < rows.length; i++)
|
||
if (root.selected[rows[i].id] === true) out.push(rows[i].id)
|
||
}
|
||
return out
|
||
}
|
||
function bulkAction(action) {
|
||
if (action === "select-all") {
|
||
var m = {}
|
||
var lists = selectableLists()
|
||
for (var l = 0; l < lists.length; l++)
|
||
for (var i = 0; i < lists[l].length; i++) m[lists[l][i].id] = true
|
||
root.selected = m
|
||
return
|
||
}
|
||
if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return }
|
||
var ids = root.selectedIdsInOrder()
|
||
if (ids.length === 0) return
|
||
QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "artist", String(artist.id || ""))
|
||
if (action !== "add-to-playlist" && action !== "add-to-mixtape")
|
||
root.selected = ({})
|
||
}
|
||
// Ctrl+A / Escape hotkey router seam (AppShell duck-types these).
|
||
function selectAll() {
|
||
if (!root.multiSelect) root.setMultiSelect(true)
|
||
root.bulkAction("select-all")
|
||
}
|
||
function exitMultiSelectMode() {
|
||
if (root.multiSelect) root.setMultiSelect(false)
|
||
}
|
||
|
||
function syncArtistState() {
|
||
var id = artist.id || ""
|
||
if (id === loadedArtistId)
|
||
return
|
||
loadedArtistId = id
|
||
setMultiSelect(false)
|
||
// A fresh artist opens on its catalog with the library tab's
|
||
// preview and switches at rest.
|
||
artistTab = "catalog"
|
||
libTracksExpanded = false
|
||
libShowPurchases = false
|
||
libShowFavorites = false
|
||
libSort = artist.librarySort || "default"
|
||
albumSelectClear()
|
||
// 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"
|
||
// …and re-applies the room rule (ArtistPageView.slint:178, artist.rs
|
||
// `reset_network_sidebar`): a new artist re-opens the panel unless
|
||
// the content area is constrained.
|
||
networkOpen = !contentConstrained
|
||
dismissedDiscovery = ({})
|
||
localToggles = ({})
|
||
// A fresh artist must never inherit the previous one's paging state:
|
||
// a stale threshold would fade a band of the new artist's first page
|
||
// for no reason, a stale pending flag would show a skeleton under a
|
||
// button nobody pressed, a stale OVERLAY would graft the previous
|
||
// artist's albums onto this one's grids (release types are shared
|
||
// keys — every artist has an "album" bucket), and a stale CURSOR would
|
||
// page the new artist from the old one's offset.
|
||
releasePending = ({})
|
||
releaseFade = ({})
|
||
releaseOverlay = ({})
|
||
releaseCursor = ({})
|
||
releaseSettle.stop()
|
||
membersExpanded = false
|
||
groupsExpanded = false
|
||
collabsExpanded = false
|
||
dispatchedCovers = ({})
|
||
}
|
||
|
||
function dispatchLibCovers() {
|
||
var items = root.libItems || []
|
||
var urls = []
|
||
for (var i = 0; i < items.length; i++) if (items[i].imageUrl) urls.push(items[i].imageUrl)
|
||
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))
|
||
}
|
||
|
||
// A custom portrait is consulted by `artwork_qt::cached_path` (it checks
|
||
// `cover_artwork_qt::override_for_url` FIRST), so once one exists
|
||
// `coverMap[artist.artUrl]` holds the OVERRIDE file, not the Qobuz one.
|
||
// Removing the override has to invalidate that entry as well, or the
|
||
// header and the lightbox keep showing the picture the user just deleted:
|
||
// `dispatchedCovers` dedupes by url and is cleared only on an artist
|
||
// change, so the republish alone never re-resolves it. Add/Change need the
|
||
// same invalidation in reverse.
|
||
property string _lastCustomPortrait: ""
|
||
function invalidatePortraitOverride() {
|
||
var cur = (artist && artist.customImagePath) ? artist.customImagePath : ""
|
||
if (cur === root._lastCustomPortrait) return
|
||
root._lastCustomPortrait = cur
|
||
var url = (artist && artist.artUrl) ? artist.artUrl : ""
|
||
if (url === "") return
|
||
var m = Object.assign({}, root.coverMap)
|
||
delete m[url]
|
||
root.coverMap = m
|
||
// Drop the dedupe key too, so the dispatchCovers() that follows in the
|
||
// same handler actually re-requests it.
|
||
delete root.dispatchedCovers[url]
|
||
}
|
||
|
||
// THE RULE FOR THIS FUNCTION: every section of the page that binds
|
||
// `root.coverMap` has to contribute its urls here, or its covers are
|
||
// never requested and the section renders empty tiles forever — nothing
|
||
// downstream reports the omission, because a missing key is
|
||
// indistinguishable from a cover that has not landed yet.
|
||
//
|
||
// Three sections were missing and each one rendered blank:
|
||
// - Latest release (`artist.lastRelease`, the reported bug) — one card,
|
||
// and the only cover between Popular Tracks and the release grids;
|
||
// - Appears On (`artist.appearsOn`) — TrackRow covers, the same
|
||
// PopularTrackRow component the collected topTracks use, which is why
|
||
// the omission was easy to miss;
|
||
// - Playlists (`artist.playlists`) — the 200px rectangle covers of the
|
||
// horizontal strip.
|
||
// Present and collected: the header portrait, Popular Tracks, every
|
||
// release section (including the collapsed "Other"), and the Magazine
|
||
// stories. The Network sidebar carries NO covers (ArtistSimilar and
|
||
// MbDiscoveryJson have no artUrl — artist_qt.rs), so it contributes none.
|
||
// The In-library tab has its own dispatcher, dispatchLibCovers().
|
||
function dispatchCovers() {
|
||
var urls = []
|
||
if (artist.artUrl) urls.push(artist.artUrl)
|
||
var i, j
|
||
// RAW lists: a filtered-out card still needs its cover for when the
|
||
// query is cleared, and this must not re-run per keystroke.
|
||
var rawTop = artist.topTracks || []
|
||
var rawAppears = artist.appearsOn || []
|
||
var rawSections = artist.releaseSections || []
|
||
var rawPlaylists = artist.playlists || []
|
||
for (i = 0; i < rawTop.length; i++) if (rawTop[i].artUrl) urls.push(rawTop[i].artUrl)
|
||
for (i = 0; i < rawAppears.length; i++)
|
||
if (rawAppears[i].artUrl) urls.push(rawAppears[i].artUrl)
|
||
if (artist.lastRelease && artist.lastRelease.artUrl)
|
||
urls.push(artist.lastRelease.artUrl)
|
||
for (i = 0; i < rawSections.length; i++)
|
||
for (j = 0; j < (rawSections[i].cards || []).length; j++)
|
||
if (rawSections[i].cards[j].artUrl) urls.push(rawSections[i].cards[j].artUrl)
|
||
// …and the pages "Load more" appended, which are NOT in the document:
|
||
// they live in root.releaseOverlay until artist_qt.rs's stashed copy
|
||
// reaches us through the next enrichment republish. Off the overlay
|
||
// rather than off `releaseSections` for the same reason the lists
|
||
// above are RAW — the projection is filtered, and a covers dispatch
|
||
// must not depend on the search box.
|
||
for (var rt in root.releaseOverlay) {
|
||
var tail = root.releaseOverlay[rt].cards || []
|
||
for (i = 0; i < tail.length; i++)
|
||
if (tail[i].artUrl) urls.push(tail[i].artUrl)
|
||
}
|
||
for (i = 0; i < rawPlaylists.length; i++)
|
||
if (rawPlaylists[i].artUrl) urls.push(rawPlaylists[i].artUrl)
|
||
// Magazine story thumbnails ride the same pipeline (arc-cdn URLs).
|
||
// Off `artist.stories`, NOT the derived `stories` property (:110): this
|
||
// function is called from `onArtistChanged`, and a derived binding still
|
||
// holds the PREVIOUS document inside its source's change handler on this
|
||
// Qt build (measured — the same race QueuePanel.dispatchCovers and
|
||
// AlbumView's `onHeaderChanged` exist for). The stories land in a LATER
|
||
// publish than the page itself, so a stale read asked for the previous
|
||
// pass's thumbnails and the story cards stayed empty frames whenever the
|
||
// stories pass was the last publish.
|
||
var rawStories = artist.stories || []
|
||
for (i = 0; i < rawStories.length; i++)
|
||
if (rawStories[i].artUrl) urls.push(rawStories[i].artUrl)
|
||
dispatchArtwork(urls)
|
||
}
|
||
|
||
function scrollToSection(id) {
|
||
root.activeJumpTab = id
|
||
// Anchors resolve against the column of the ACTIVE mode: the catalog
|
||
// and library columns are siblings in the page, and a library tab
|
||
// resolved against the hidden catalog column would scroll to that
|
||
// column's coordinates.
|
||
var host = root.artistTab === "library" ? libraryTab : sectionAnchors
|
||
for (var i = 0; i < host.children.length; i++) {
|
||
var c = host.children[i]
|
||
if (c.anchorId === id) {
|
||
flick.contentY = host.y + c.y - 48
|
||
return
|
||
}
|
||
}
|
||
// "About" is back-to-top: the library column carries no such anchor
|
||
// (the bio lives in the header), so it lands on the page top.
|
||
if (id === "about") flick.contentY = 0
|
||
}
|
||
|
||
|
||
|
||
// Popular Tracks row (TrackRow with artwork + album column).
|
||
//
|
||
// COLUMN GEOMETRY: rows/TrackCols.qml, the same object rows/TrackRow.qml
|
||
// and rows/TrackListHeader.qml read. This component is a FORK of the
|
||
// shared row (POC-NOTE, pre-existing) and it had the full column set
|
||
// re-typed as literals; they were numerically right, but a fork with its
|
||
// own copy of the numbers is precisely how a table's columns drift. The
|
||
// artist page draws no column header (neither does
|
||
// artist/ArtistPageView.slint), so nothing is misaligned today — this
|
||
// keeps it that way if the widths ever move.
|
||
component PopularTrackRow: Rectangle {
|
||
id: popRow
|
||
property var row: ({})
|
||
property int rowIndex: 0
|
||
property bool showAlbum: true
|
||
/// Where a play on this row goes — `function (id)`. Unset, the row
|
||
/// plays through `QbzPlayer.playArtistTrack`, whose queue is the
|
||
/// POPULAR list (artist_qt TOP_QUEUE). The "In library" tab passes
|
||
/// its own handler because a library row is NOT in that queue and
|
||
/// `top_queue` starts the popular list at row 0 for any id it does
|
||
/// not hold — issue #737. The row never asks which tab it is in.
|
||
property var playHandler: null
|
||
function play() {
|
||
if (popRow.playHandler) popRow.playHandler(String(popRow.row.id))
|
||
else QbzPlayer.playArtistTrack(popRow.row.id)
|
||
}
|
||
// Multi-select (the view's bulk bar): in select mode the whole row
|
||
// body is the toggle and the leading cell swaps to a round checkbox
|
||
// (TrackRow.slint:173-177, 392-417).
|
||
property bool selectMode: false
|
||
property bool checked: false
|
||
/// `modifiers` off the mouse event — see SelectionModel.
|
||
signal toggleSelect(int modifiers)
|
||
// Live offline status for the row (seeded in the document; updates
|
||
// arrive on QbzShell's trackCacheStatusChanged).
|
||
property int cacheStatus: row.cacheStatus !== undefined ? row.cacheStatus : 0
|
||
Connections {
|
||
target: QbzShell
|
||
function onTrackCacheStatusChanged(trackId, status, progress) {
|
||
if (trackId === (popRow.row.id || "")) popRow.cacheStatus = status
|
||
}
|
||
}
|
||
|
||
TrackCols { id: cols }
|
||
|
||
readonly property bool isActive: QbzPlayer.npTrackId !== "" && QbzPlayer.npTrackId === row.id
|
||
readonly property bool hovered: trArea.containsMouse || favArea.containsMouse || moreArea.containsMouse
|
||
|
||
// --- PULLED FROM THE QOBUZ CATALOGUE (contract §5.2) --------------
|
||
// The artist page is one of the seven surfaces §5.2 names, and this
|
||
// component is a documented FORK of rows/TrackRow.qml (see the header
|
||
// above), so the treatment has to be repeated here rather than
|
||
// inherited. It is repeated EXACTLY — same field, same predicate, same
|
||
// `circle-alert` in the number slot, same 0.5 dim — because two
|
||
// screens that disagree about what a dead row looks like is the
|
||
// fork-drift this file has already paid for once.
|
||
//
|
||
// `row.qobuzUnavailable` is the API's `streamable: false` for THIS
|
||
// track and nothing else: an absent field is not a claim, so a
|
||
// producer that does not carry it yet leaves every row alive.
|
||
// `cacheStatus === 3` (downloaded) is excluded because a pulled track
|
||
// the user already downloaded still plays from disk (§5.3, F5) — this
|
||
// fork has no cached-but-pulled badge of its own, so such a row simply
|
||
// keeps behaving normally, which is the safe half of that split.
|
||
readonly property bool pulled: row.qobuzUnavailable === true
|
||
readonly property bool pulledDead: popRow.pulled && popRow.cacheStatus !== 3
|
||
|
||
width: parent ? parent.width : 0
|
||
height: 50
|
||
radius: 8
|
||
// Hover fill off on a dead row — a row that lights up reads clickable.
|
||
color: (hovered && !popRow.pulledDead)
|
||
? root.rowHoverBg : (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: cols.padH
|
||
anchors.rightMargin: cols.padH
|
||
spacing: cols.gap
|
||
// THE DIM. On the Row, not on `popRow`: the now-playing edge mark
|
||
// and the row menu are siblings of this item and neither fades.
|
||
// 0.5 is views/purchases/PurchaseListRow.qml's value, the same one
|
||
// rows/TrackRow.qml adopted.
|
||
opacity: popRow.pulledDead ? 0.5 : 1.0
|
||
|
||
// Position number (artwork rows carry it separate from the cover).
|
||
// On a dead row the alert glyph takes this slot — same cell, same
|
||
// width, so nothing reflows. `circle-alert`, NOT `triangle-alert`:
|
||
// the download column below already draws the triangle for a
|
||
// FAILED CACHE (`cacheStatus === 4`), and one row must not carry
|
||
// two identical glyphs meaning two different things (§A F11).
|
||
Item {
|
||
visible: showAlbum
|
||
width: cols.colNumber
|
||
height: parent.height
|
||
Text {
|
||
visible: !popRow.pulledDead
|
||
anchors.fill: parent
|
||
verticalAlignment: Text.AlignVCenter
|
||
text: row.number
|
||
color: theme.textMuted
|
||
font.pixelSize: 13
|
||
horizontalAlignment: Text.AlignHCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
Loader {
|
||
anchors.centerIn: parent
|
||
width: 20
|
||
height: 20
|
||
// A Loader, so a live row instantiates neither the glyph
|
||
// nor the attached ToolTip (one QObject per row the moment
|
||
// it is referenced).
|
||
active: popRow.pulledDead
|
||
visible: active
|
||
sourceComponent: Item {
|
||
QbzIcon {
|
||
anchors.centerIn: parent
|
||
name: "circle-alert"
|
||
width: 15
|
||
height: 15
|
||
tintName: "favorite"
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
ToolTip.visible: containsMouse
|
||
ToolTip.delay: 300
|
||
// Same split as rows/TrackRow.qml: a pre-release
|
||
// says "not yet", a pulled track keeps the REUSED
|
||
// msgid the reactive skip path toasts.
|
||
ToolTip.text: row.qobuzUpcoming === true
|
||
? QbzSession.tr("This track is not yet available", QbzSession.trRev)
|
||
: QbzSession.tr("This track is no longer available", QbzSession.trRev)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Cover with hover play overlay — in select mode this cell is
|
||
// the row's round checkbox instead.
|
||
//
|
||
// RADIUS 4, not `theme.radiusSm` (8): rows/TrackRow.qml:553 — the
|
||
// SHARED row this component is a fork of, and the one PlaylistView
|
||
// / the album detail / search all draw — rounds its artwork cell
|
||
// at 4. The fork had drifted to 8 and the owner saw it ("los album
|
||
// arts de Popular tracks esta mas redondo que otros, por ejemplo,
|
||
// los de playlistview"). All THREE stacked elements carry the same
|
||
// number — the cell, the image and the hover scrim — or the scrim's
|
||
// corners stick out past the art. The ROW's own `radius: 8` above
|
||
// is correct and matches TrackRow.qml:288; only the cover moved.
|
||
Rectangle {
|
||
width: showAlbum ? cols.colArt : cols.colNumber
|
||
height: showAlbum ? cols.colArt : 28
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
radius: 4
|
||
color: theme.surfaceElevated
|
||
clip: true
|
||
// The select-mode checkbox (accent fill when checked).
|
||
Rectangle {
|
||
visible: popRow.selectMode
|
||
anchors.centerIn: parent
|
||
width: 14
|
||
height: 14
|
||
radius: 7
|
||
color: popRow.checked ? theme.accent : "transparent"
|
||
border.width: popRow.checked ? 0 : 1.5
|
||
border.color: theme.textMuted
|
||
QbzIcon {
|
||
visible: popRow.checked
|
||
anchors.centerIn: parent
|
||
name: "check"
|
||
width: 10
|
||
height: 10
|
||
tintName: theme.accentGlyphTint
|
||
}
|
||
}
|
||
QbzIcon {
|
||
visible: !popRow.selectMode
|
||
anchors.centerIn: parent
|
||
name: "music"
|
||
width: showAlbum ? 16 : 14
|
||
height: showAlbum ? 16 : 14
|
||
tintName: "muted"
|
||
}
|
||
RoundedImage {
|
||
visible: !popRow.selectMode
|
||
anchors.fill: parent
|
||
source: root.coverMap[row.artUrl] || ""
|
||
radius: 4
|
||
}
|
||
// The hover scrim and its play glyph are the cover cell's play
|
||
// AFFORDANCE, and an affordance is a claim that clicking does
|
||
// something — so a dead row shows neither, and the area under
|
||
// them is disabled rather than left to issue a play that can
|
||
// only fail.
|
||
Rectangle {
|
||
visible: !popRow.selectMode && !popRow.pulledDead
|
||
anchors.fill: parent
|
||
radius: 4
|
||
color: "#000000"
|
||
opacity: trArea.containsMouse || isActive ? 0.6 : 0.0
|
||
Behavior on opacity { NumberAnimation { duration: 150 } }
|
||
}
|
||
QbzIcon {
|
||
visible: !popRow.selectMode && !popRow.pulledDead
|
||
&& (trArea.containsMouse || isActive)
|
||
anchors.centerIn: parent
|
||
name: isActive && QbzPlayer.npPlaying ? "pause" : "play-fill"
|
||
width: 16
|
||
height: 16
|
||
// On the #000000 @ 0.6 artwork scrim above — dark under
|
||
// every theme.
|
||
tintName: "white"
|
||
}
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
enabled: !popRow.selectMode && !popRow.pulledDead
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: popRow.play()
|
||
}
|
||
}
|
||
// Title + artist.
|
||
//
|
||
// This was `- 6 * 14` for the gaps in BOTH arms. With the album
|
||
// column on there are nine visible cells, i.e. EIGHT gaps, so the
|
||
// stretch column ran 28px long and dragged Album / Duration /
|
||
// Quality / heart 28px right of where the shared TrackRow puts
|
||
// them — the same class of defect as the header, inside a forked
|
||
// row. `cols.titleWidth` counts the gaps from the arms.
|
||
//
|
||
// Arms: with the album column the leading cells are the 32px
|
||
// number AND the 36px cover (artwork arm); without it the single
|
||
// 32px cover IS the number cell.
|
||
Column {
|
||
width: cols.titleWidth(popRow.width, popRow.showAlbum, popRow.showAlbum,
|
||
true, true, true)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 2
|
||
Row {
|
||
spacing: 6
|
||
Text {
|
||
text: row.title
|
||
// Muted, not merely dimmed: the Row's 0.5 fades the
|
||
// cell uniformly, and the TITLE is the part that has to
|
||
// stop reading as live content.
|
||
color: popRow.pulledDead ? theme.textMuted : 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 ? cols.colAlbum : 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: QbzAlbum.openAlbum(row.albumId)
|
||
}
|
||
}
|
||
Text {
|
||
width: cols.colDuration
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: row.duration
|
||
color: theme.textMuted
|
||
font.pixelSize: 13
|
||
horizontalAlignment: Text.AlignHCenter
|
||
}
|
||
// The SAME bare badge the shared TrackRow's quality cell mounts
|
||
// (rows/TrackRow.qml:1048): tier label stacked over the exact
|
||
// "24-bit / 96 kHz" line. This fork used to render a lone tier
|
||
// word ("HI-RES") even though the document has carried
|
||
// qualityDetail all along.
|
||
Item {
|
||
width: cols.colQuality
|
||
height: parent.height
|
||
QualityBadgeFull {
|
||
anchors.centerIn: parent
|
||
tier: row.qualityTier || ""
|
||
detail: row.qualityDetail || ""
|
||
showIcon: false
|
||
bare: true
|
||
maxTextWidth: cols.colQuality
|
||
}
|
||
}
|
||
// 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: cols.colFavorite
|
||
height: cols.colFavorite
|
||
radius: theme.radiusSm
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: favArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||
QbzIcon {
|
||
// The SLOT stays and only the glyph goes: this fork lays
|
||
// out from the same TrackCols object the shared row and its
|
||
// header use, so collapsing the cell would shift every
|
||
// column left of it.
|
||
visible: !popRow.pulledDead
|
||
anchors.centerIn: parent
|
||
name: parent.favorite ? "heart-filled" : "heart"
|
||
width: 16
|
||
height: 16
|
||
tintName: parent.favorite
|
||
? "favorite"
|
||
: (favArea.containsMouse ? root.tintOnSurface : "muted")
|
||
}
|
||
MouseArea {
|
||
id: favArea
|
||
anchors.fill: parent
|
||
enabled: !popRow.selectMode && !popRow.pulledDead
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
root.setToggleState("track:" + row.id, !parent.favorite)
|
||
QbzLibrary.libraryToggleFavorite("track", row.id)
|
||
}
|
||
}
|
||
}
|
||
// Offline download — live since the offline port (the fork used
|
||
// to carry an inert stub). Same status vocabulary as the shared
|
||
// TrackRow: 0 none · 1 queued · 2 downloading · 3 ready · 4 failed.
|
||
Rectangle {
|
||
width: cols.colDownload
|
||
height: cols.colDownload
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
color: "transparent"
|
||
QbzIcon {
|
||
// A pulled track has no file url to fetch, so the download
|
||
// affordance could only fail — the slot keeps its width,
|
||
// the glyph goes.
|
||
visible: popRow.cacheStatus === 0 && !popRow.pulledDead
|
||
anchors.centerIn: parent
|
||
name: "cloud-download"
|
||
width: 16
|
||
height: 16
|
||
tintName: popDlArea.containsMouse ? root.tintOnSurface : "muted"
|
||
}
|
||
QbzSpinner {
|
||
visible: popRow.cacheStatus === 1 || popRow.cacheStatus === 2
|
||
anchors.centerIn: parent
|
||
size: 16
|
||
}
|
||
QbzIcon {
|
||
visible: popRow.cacheStatus === 3
|
||
anchors.centerIn: parent
|
||
name: "circle-check-big"
|
||
width: 16
|
||
height: 16
|
||
tintName: "accent"
|
||
}
|
||
QbzIcon {
|
||
// Never alongside the pulled-track alert in the number
|
||
// cell: one row, one alert.
|
||
visible: popRow.cacheStatus === 4 && !popRow.pulledDead
|
||
anchors.centerIn: parent
|
||
name: "triangle-alert"
|
||
width: 16
|
||
height: 16
|
||
tintName: "favorite"
|
||
}
|
||
MouseArea {
|
||
id: popDlArea
|
||
anchors.fill: parent
|
||
enabled: !popRow.selectMode && !popRow.pulledDead
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
if (popRow.cacheStatus === 3) QbzPlayer.uncacheTrack(row.id)
|
||
else QbzPlayer.cacheTrack(row.id)
|
||
}
|
||
}
|
||
}
|
||
// ⋯ row menu. It used to be a hover-lit button with NO handler at
|
||
// all — a control that renders and no-ops, which is the defect
|
||
// class this round is closing.
|
||
Rectangle {
|
||
width: cols.colMenu
|
||
height: cols.colMenu
|
||
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 ? root.tintOnSurface : "muted" }
|
||
MouseArea {
|
||
id: moreArea
|
||
anchors.fill: parent
|
||
enabled: !popRow.selectMode
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// primitives/TrackContextMenu.slint, in ITS order, restricted to the
|
||
// entries whose seam is live at this call site.
|
||
//
|
||
// REUSE, and why it is not `rows/TrackRow.qml`: PopularTrackRow is a
|
||
// pre-existing, documented fork (see the component header) and the
|
||
// fork is load-bearing here — its heart reads the VIEW-level
|
||
// optimistic store (`root.toggleState("track:" + id, …)`, shared with
|
||
// the header and the library tab) while TrackRow owns a private
|
||
// `favorite` property, and its play route is the artist-context
|
||
// `playArtistTrack`. Swapping the component is a view rewrite, not a
|
||
// menu fix. What IS reused is the menu surface itself:
|
||
// `controls/CardMenu.qml`, the same primitive rows/TrackRow.qml opens.
|
||
//
|
||
// ABSENT, not dead (the same discipline TrackRow applies): the radio
|
||
// pair and Share Qobuz link / Song.link, which need bridge seams that
|
||
// do not exist. Track info IS wired (2026-09-13) through ONE lazy
|
||
// TrackInfoModal per artist view (`artistTrackInfo` below), not one
|
||
// per row — the per-row Loader rows/TrackRow.qml carries is what this
|
||
// comment used to refuse to duplicate.
|
||
CardMenu {
|
||
id: popMenu
|
||
menuWidth: 224
|
||
entries: {
|
||
var t = QbzSession.tr
|
||
var r = QbzSession.trRev
|
||
var fav = root.toggleState("track:" + popRow.row.id, popRow.row.isFavorite)
|
||
var m = []
|
||
// A DEAD row gets no transport and no favourite entries: D5
|
||
// keeps an unavailable track out of the queue at the seam, so
|
||
// "Play next" here would render and no-op — the
|
||
// rendered-and-inert defect this menu's own header refuses
|
||
// ("ABSENT, not dead"). The row keeps its menu because the
|
||
// rest of it (go-to) still works and because a right-clickable
|
||
// dead row is what §5.2 asks for.
|
||
if (!popRow.pulledDead) {
|
||
m.push({ "label": t("Play now", r), "icon": "play-fill", "action": "play" })
|
||
m.push({ "label": t("Play next", r), "icon": "list-start", "action": "next" })
|
||
m.push({ "label": t("Play later", r), "icon": "list-plus", "action": "later" })
|
||
m.push({ "label": t("Add to queue", r), "icon": "list-end", "action": "queue" })
|
||
m.push({ "label": fav ? t("Remove from Library", r) : t("Add to Library", r),
|
||
"icon": fav ? "heart-filled" : "heart", "action": "favorite" })
|
||
}
|
||
// A pulled track is refused BOTH containers, and on `pulled`
|
||
// rather than `pulledDead`: a downloaded copy plays for this
|
||
// user on this machine, but what a playlist or a mixtape stores
|
||
// is the Qobuz catalog id, which resolves to nothing everywhere
|
||
// else. Seeding a dead row into a collection is the one thing
|
||
// worth refusing outright.
|
||
if (!popRow.pulled) {
|
||
m.push({ "label": t("Add to mixtape", r), "icon": "cassette-tape", "action": "mixtape" })
|
||
m.push({ "label": t("Add to playlist", r), "icon": "list-music", "action": "add-to-playlist" })
|
||
}
|
||
// The offline block goes with the transport, and for the same
|
||
// reason: a pulled track has no stream url left, so the
|
||
// download arm would fail and a REFRESH would destroy the only
|
||
// copy the user still has.
|
||
if (!popRow.pulledDead) {
|
||
if (popRow.cacheStatus === 3) {
|
||
// `popRow.pulled` and NOT `pulledDead` here: this arm
|
||
// IS the cached-but-pulled row (pulled + cacheStatus 3
|
||
// is exactly what `pulledDead` excludes). A refresh on
|
||
// it would re-fetch a url that no longer exists and end
|
||
// with the copy gone — the one copy still playing.
|
||
// Remove stays: the disk space is the user's call.
|
||
if (!popRow.pulled)
|
||
m.push({ "label": t("Refresh offline copy", r), "icon": "refresh-cw", "action": "recache" })
|
||
m.push({ "label": t("Remove offline copy", r), "icon": "trash-2", "action": "uncache", "danger": true })
|
||
} else {
|
||
m.push({ "label": t("Make available offline", r), "icon": "cloud-download", "action": "cache" })
|
||
}
|
||
}
|
||
if ((popRow.row.albumId || "") !== "")
|
||
m.push({ "label": t("Go to album", r), "icon": "disc-3", "action": "go-album" })
|
||
if ((popRow.row.artistId || "") !== "")
|
||
m.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist" })
|
||
if (!popRow.pulled)
|
||
m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" })
|
||
if (!popRow.pulledDead)
|
||
m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" })
|
||
m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [
|
||
{ "label": t("Track name", r), "icon": "copy", "action": "copy-title" },
|
||
{ "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" }
|
||
] })
|
||
return m
|
||
}
|
||
onPicked: function (a) {
|
||
var id = popRow.row.id
|
||
if (a === "play") popRow.play()
|
||
else if (a === "next") QbzPlayer.enqueueTrack(id, "next")
|
||
else if (a === "later") QbzPlayer.enqueueTrack(id, "later")
|
||
else if (a === "queue") QbzPlayer.enqueueTrack(id, "queue")
|
||
else if (a === "favorite") {
|
||
root.setToggleState("track:" + id,
|
||
!root.toggleState("track:" + id, popRow.row.isFavorite))
|
||
QbzLibrary.libraryToggleFavorite("track", id)
|
||
}
|
||
else if (a === "add-to-playlist") QbzPlaylistPicker.openForTrack(id)
|
||
else if (a === "mixtape") {
|
||
// The HOST builds the AddItem payload. SOURCE: every row
|
||
// this component draws is a Qobuz catalog track — the
|
||
// `/artist/page` `top_tracks` / `appears_on` lists, and
|
||
// the in-library tab's rows, which artist_qt maps from
|
||
// Qobuz `Track` values. There is no local artist page.
|
||
QbzMyQbzAdd.open(JSON.stringify([{
|
||
"itemType": "track", "source": "qobuz",
|
||
"sourceItemId": id, "title": popRow.row.title || "",
|
||
"subtitle": popRow.row.artist || "", "artworkUrl": popRow.row.artUrl || "",
|
||
"year": null, "trackCount": null
|
||
}]))
|
||
}
|
||
else if (a === "cache") QbzPlayer.cacheTrack(id)
|
||
else if (a === "uncache") QbzPlayer.uncacheTrack(id)
|
||
else if (a === "recache") QbzPlayer.recacheTrack(id)
|
||
else if (a === "go-album") QbzAlbum.openAlbumFrom(popRow.row.albumId, popRow.row.album || "", popRow.row.artist || "")
|
||
else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId)
|
||
else if (a === "track-info") root.openTrackInfo(id)
|
||
else if (a === "buy") QbzAlbum.buyTrack(id)
|
||
else if (a === "copy-title" || a === "copy-full") {
|
||
var parts = [popRow.row.title || ""]
|
||
if (a === "copy-full") {
|
||
if ((popRow.row.album || "") !== "") parts.push(popRow.row.album)
|
||
if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist)
|
||
}
|
||
artistClipboard.copy(parts.join(" - "))
|
||
}
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: trArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
propagateComposedEvents: true
|
||
// The dead row is inert on double-click too (D4): no request, no
|
||
// toast, no error. The right-press area below is untouched, so the
|
||
// menu still opens.
|
||
onDoubleClicked: if (!popRow.selectMode && !popRow.pulledDead)
|
||
popRow.play()
|
||
onClicked: {
|
||
if (popRow.selectMode) popRow.toggleSelect(mouse.modifiers)
|
||
else mouse.accepted = false
|
||
}
|
||
}
|
||
// Right press opens the SAME menu at the pointer — the invariant
|
||
// controls/QbzContextMenu.qml:20-22 states. Declared last so it sits
|
||
// on top, RIGHT-only so every left click still falls through.
|
||
MouseArea {
|
||
id: popRcArea
|
||
anchors.fill: parent
|
||
acceptedButtons: Qt.RightButton
|
||
onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) }
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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 ? root.tintOnSurface : "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: 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 empty states inside
|
||
// the sidebar sections. (The "Loading…" lines are now SidebarSkeleton.)
|
||
component SidebarNote: Text {
|
||
color: theme.textMuted
|
||
font.pixelSize: 12
|
||
}
|
||
|
||
// Placeholder rows for a sidebar section still in flight — the shared
|
||
// QbzSkeleton at the 28px pitch of SidebarLink, so the section holds its
|
||
// band and nothing jumps when the real links land.
|
||
//
|
||
// `phase` is a property, not a file-scope id lookup: an inline `component`
|
||
// does not see the enclosing document's ids (QbzSkeleton.qml's gotcha), so
|
||
// the host passes the one shared timer in.
|
||
component SidebarSkeleton: Column {
|
||
id: sbSk
|
||
property bool phase: false
|
||
property int rows: 3
|
||
// -28 = the section Column's left+right padding (see OriginRow).
|
||
width: parent ? parent.width - 28 : 0
|
||
spacing: 9
|
||
Repeater {
|
||
model: sbSk.rows
|
||
delegate: QbzSkeleton {
|
||
required property int index
|
||
variant: "block"
|
||
width: sbSk.width * (index % 2 === 0 ? 0.86 : 0.6)
|
||
height: 13
|
||
cellIndex: index
|
||
phase: sbSk.phase
|
||
}
|
||
}
|
||
}
|
||
|
||
// One "KEY value" row of the MB Origin block.
|
||
component OriginRow: Item {
|
||
id: originRow
|
||
property string key: ""
|
||
property string value: ""
|
||
/// When true the value renders as a link and emits `activated`.
|
||
/// Only the location row ever sets it (door D1 to the Artist Scene);
|
||
/// born/founded/died/disbanded stay plain text, as in the reference.
|
||
property bool clickable: false
|
||
signal activated()
|
||
// 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 {
|
||
id: originValue
|
||
anchors.right: parent.right
|
||
anchors.left: originKey.right
|
||
anchors.leftMargin: 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: value
|
||
// The reference's `.origin-location-link` is
|
||
// `background:none; border:none; padding:0; font-size:13px;
|
||
// color:var(--accent-primary)` with `text-decoration:underline;
|
||
// text-underline-offset:2px` on hover
|
||
// (ArtistDetailView.svelte:3365-3379). Same 13px as the plain
|
||
// rows, so only the colour and the underline change.
|
||
color: originRow.clickable ? theme.accent : theme.textPrimary
|
||
font.pixelSize: 13
|
||
font.underline: originRow.clickable && originLink.containsMouse
|
||
horizontalAlignment: Text.AlignRight
|
||
elide: Text.ElideRight
|
||
}
|
||
MouseArea {
|
||
id: originLink
|
||
// Only the text, not the whole row — the key ("BORN IN") is not
|
||
// part of the link in the reference either.
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.bottom: parent.bottom
|
||
width: Math.min(originValue.implicitWidth, originValue.width)
|
||
enabled: originRow.clickable
|
||
visible: originRow.clickable
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: originRow.activated()
|
||
}
|
||
}
|
||
|
||
// 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"
|
||
/// The MusicBrainz role this group represents ("member", "producer",
|
||
/// …) — passed to the resolver so a same-name match in another role is
|
||
/// not treated as this musician.
|
||
property string roleKey: ""
|
||
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
|
||
// Relationship rows carry a NAME, not a catalog id, so the
|
||
// click resolves through MusicBrainz first. Only a confirmed
|
||
// match navigates (resolve_musician logs and stays put
|
||
// otherwise) — landing the user on a same-name artist is worse
|
||
// than the row doing nothing.
|
||
navigable: true
|
||
// `modelData.role`, NOT `relGroup.roleKey`. Rust already built
|
||
// the right string per ROW: `group_relations` seeds each group
|
||
// with the reference's own default ("Band Member" / "Band" /
|
||
// "Collaborator") and then prefers that person's actual first
|
||
// credited role (artist_qt.rs `map_relationships`), which is
|
||
// exactly what the reference sends
|
||
// (ArtistDetailView.svelte:3015,3033,3046). Sending `roleKey`
|
||
// instead threw all of that away and shipped a per-GROUP
|
||
// lowercase MusicBrainz key shared by every row — and for the
|
||
// middle group it is not even a casing difference, it sent
|
||
// "member of" where the reference sends "Band". The role is
|
||
// echoed back on every appearance card, so it was visible.
|
||
onClicked: QbzArtist.resolveMusician(modelData.name,
|
||
modelData.role || relGroup.roleKey || "")
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Release section (ReleaseGrid).
|
||
component ReleaseSection: Column {
|
||
// Named because the card delegate below reaches for `section` from a
|
||
// nested scope, and an inline component's own root id is the one
|
||
// handle that is unambiguous there.
|
||
id: relSection
|
||
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 - playBtn.width - 36
|
||
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: QbzSession.tr("See discography", QbzSession.trRev)
|
||
color: seeAllArea.containsMouse ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
MouseArea {
|
||
id: seeAllArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
// ReleaseGrid.slint:63-68 -> ArtistPageView.slint:928-929
|
||
// -> ArtistActions.open-releases -> main.rs:15055-15087,
|
||
// which records the nav entry and mounts
|
||
// ArtistReleasesView on this bucket.
|
||
//
|
||
// The artist NAME travels with the call: the second door to
|
||
// the same page (AlbumView's "From the same artist" View
|
||
// all) has no artist document open and supplies its own,
|
||
// exactly as main.rs:11844 does.
|
||
//
|
||
// `relSection.section`, not a bare `section` — the inline
|
||
// component's own root id is the one unambiguous handle
|
||
// from a nested scope, which is why `relSection` is named
|
||
// at all (:1409-1411). `root.` is the same pattern the card
|
||
// delegate below already uses for `coverMap` and
|
||
// `releaseFadeFrom`.
|
||
//
|
||
// (What stood here was an empty MouseArea under a POC-NOTE
|
||
// reading "dedicated discography page out of scope" — a
|
||
// fully lit, pointer-cursor control whose entire effect was
|
||
// nothing. The page now exists; the note is gone with it.)
|
||
onClicked: QbzArtist.openReleases(root.artist.id || "",
|
||
root.artist.name || "",
|
||
relSection.section.releaseType || "")
|
||
}
|
||
}
|
||
// Per-section sort — ReleaseGrid.slint:70-90, the SAME control the
|
||
// reference mounts (primitives/QbzSelect.slint), here through the
|
||
// port's existing replica controls/QbzSelect.qml.
|
||
//
|
||
// `sm: true` + `menuWidth: 150` are the .slint's own values,
|
||
// verbatim: QbzSelect.qml:34 is `width = menuWidth - 40` under
|
||
// `sm`, so 150 lands at the 110 x 30 the reference computes at
|
||
// QbzSelect.slint:88. Passing 190 "to get 150" is the exact mistake
|
||
// that file's :42-50 note documents.
|
||
//
|
||
// `id: sortBtn` is load-bearing beyond this block — the section
|
||
// title above measures `parent.width - seeAll.width - sortBtn.width
|
||
// - 24`. The control grows 28 -> 30 tall with the swap; that IS the
|
||
// reference (QbzSelect.slint `sm` is 30px). Row children are
|
||
// top-aligned, hence the explicit verticalCenter.
|
||
//
|
||
// (What stood here was a dimmed Rectangle with no MouseArea, whose
|
||
// note claimed `set-section-sort` "re-sorts server-side" and that
|
||
// no seam existed. Both halves were wrong: main.rs:14997-15007 sorts
|
||
// the ALREADY-LOADED cards in process and never touches the API —
|
||
// see artist_qt::resort_section — and the label lied twice on top of
|
||
// that, reading "Newest" over data in server order.)
|
||
QbzSelect {
|
||
id: sortBtn
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
sm: true
|
||
menuWidth: 150
|
||
// The .slint's five options in its order (:74-79). "A–Z"/"Z–A"
|
||
// carry a U+2013 EN DASH — the msgid in the catalogue is the
|
||
// en-dash form, and a hyphen would silently go untranslated in
|
||
// all seven non-English locales. (LibraryToolbar's "Title A-Z"
|
||
// is a DIFFERENT, hyphenated msgid — do not copy it here.)
|
||
options: [QbzSession.tr("Default", QbzSession.trRev),
|
||
QbzSession.tr("Newest", QbzSession.trRev),
|
||
QbzSession.tr("Oldest", QbzSession.trRev),
|
||
QbzSession.tr("A–Z", QbzSession.trRev),
|
||
QbzSession.tr("Z–A", QbzSession.trRev),
|
||
QbzSession.tr("Popularity", QbzSession.trRev)]
|
||
// ReleaseGrid.slint:34-39 — the wire key back to an index. This
|
||
// is what makes the picker come back showing the sort the user
|
||
// actually chose: `sortBy` is stamped on the section by
|
||
// artist_qt.rs at page build from the persisted pref, so a
|
||
// revisited artist seats the control without a click.
|
||
//
|
||
// A BINDING, never an assignment: QbzSelect does not
|
||
// self-assign `currentIndex` (it closes the popup and emits,
|
||
// QbzSelect.qml:299-300), and every enrichment republish
|
||
// re-creates this delegate — an assignment would be lost.
|
||
currentIndex: {
|
||
var s = relSection.section.sortBy || "default"
|
||
return s === "newest" ? 1 : s === "oldest" ? 2
|
||
: s === "title-asc" ? 3 : s === "title-desc" ? 4 : s === "relevant" ? 5 : 0
|
||
}
|
||
// ReleaseGrid.slint:81-89 — index to wire key. These five
|
||
// strings are BOTH what gets persisted and what the sort
|
||
// functions switch on, on either side of the bridge.
|
||
//
|
||
// `relSection.section`, not a bare `section`: this header lives
|
||
// inside an inline `component`, which does not see the enclosing
|
||
// document's ids — `relSection` exists for exactly this.
|
||
onSelected: function (i) {
|
||
root.releaseSortChanged(relSection.section.releaseType,
|
||
i === 1 ? "newest" : i === 2 ? "oldest"
|
||
: i === 3 ? "title-asc" : i === 4 ? "title-desc" : i === 5 ? "relevant" : "default")
|
||
}
|
||
}
|
||
// Play all / Play random / Play selected over THIS bucket, in
|
||
// its sort order (`section.cards` is already sorted — Rust sorts
|
||
// the bucket and the overlay merge re-sorts).
|
||
QbzSplitButton {
|
||
id: playBtn
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
label: root.albumSectionLabel(relSection.section.releaseType || "")
|
||
menuItems: root.albumSectionMenu(relSection.section.releaseType || "")
|
||
onClicked: root.albumSectionPrimary(relSection.section.releaseType || "",
|
||
relSection.section.cards || [])
|
||
onPicked: function (a) {
|
||
root.albumSectionAction(relSection.section.releaseType || "",
|
||
relSection.section.cards || [], a)
|
||
}
|
||
}
|
||
}
|
||
|
||
Grid {
|
||
width: parent.width
|
||
columns: Math.max(1, Math.floor((width + 24) / 224))
|
||
columnSpacing: 24
|
||
rowSpacing: 24
|
||
Repeater {
|
||
model: section.cards
|
||
delegate: AlbumCard {
|
||
id: relCard
|
||
albumId: modelData.id
|
||
title: modelData.title
|
||
// The subtitle slot carries the YEAR on the artist page,
|
||
// not the artist: artist.rs `card_to_item` (:670-688)
|
||
// re-routes `year` through the card's `artist` field
|
||
// precisely so the shared card primitive stays unchanged
|
||
// ("the artist is redundant since we're already on their
|
||
// page"), and blanks artist_id so the line is inert.
|
||
artist: modelData.year
|
||
artistId: ""
|
||
genre: modelData.genre
|
||
year: modelData.year
|
||
qualityTier: modelData.qualityTier
|
||
qualityDetail: modelData.qualityDetail || ""
|
||
artSource: root.coverMap[modelData.artUrl] || ""
|
||
// artist_qt `map_release` stamps the pin state on every
|
||
// release row; SectionRail is the only other reader and
|
||
// this page never mounts it, so the flag was published
|
||
// and dropped on the floor — the glyph lied on all four
|
||
// of this page's album grids. `artUrl` is the REMOTE url
|
||
// (coverMap is keyed BY it), which is what the pin
|
||
// payload must store.
|
||
isPinned: modelData.isPinned === true
|
||
artworkUrl: modelData.artUrl || ""
|
||
// artist_qt::map_release stamps this the same way it
|
||
// stamps the pin; false inverted the first click.
|
||
isFavorite: modelData.isFavorite === true
|
||
// "Play selected" over this bucket (the split button).
|
||
selectMode: root.albumSelectKey === (relSection.section.releaseType || "")
|
||
selected: root.albumSelected[modelData.id] === true
|
||
selectMarkBottom: true
|
||
onSelectToggled: function (mods) {
|
||
root.albumSelectToggle(modelData.id, relSection.section.cards || [], mods)
|
||
}
|
||
|
||
// ---- smooth append (owner, 2026-08-02) ---------------
|
||
// "que la aparicion de lo que se cargue, sea smooth" —
|
||
// fade in ONLY the page Load more just brought in. The
|
||
// Repeater re-creates EVERY delegate on any republish (see
|
||
// `releasePending` at the top of this file), so an
|
||
// unconditional fade would re-dissolve the whole grid on
|
||
// an unrelated pass and read as a flicker.
|
||
//
|
||
// `opacity` carries NO binding on purpose: the assignment
|
||
// below would destroy one, and a Behavior would then fire
|
||
// for every card (Behaviors are inert only DURING
|
||
// creation, and Component.onCompleted runs after it). A
|
||
// card below the threshold is never touched at all, so it
|
||
// is created — and stays — fully opaque.
|
||
NumberAnimation {
|
||
id: appendFade
|
||
target: relCard
|
||
property: "opacity"
|
||
from: 0.0
|
||
to: 1.0
|
||
// 220ms / OutCubic is the content duration; the
|
||
// skeleton above it fades at 180ms.
|
||
duration: 220
|
||
easing.type: Easing.OutCubic
|
||
}
|
||
Component.onCompleted: {
|
||
if (index >= root.releaseFadeFrom(relSection.section.releaseType)) {
|
||
relCard.opacity = 0.0
|
||
appendFade.start()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// The shared affordance (controls/QbzLoadMore.qml) — this site is the
|
||
// `a` arm its header cites: plain centred text, 28 tall, and the same
|
||
// `section.hasMore` gate and bridge call as before. What is new is the
|
||
// placeholder row it draws underneath while the page is in flight.
|
||
QbzLoadMore {
|
||
visible: section.hasMore || section.sortLoading === true
|
||
width: parent.width
|
||
// The grid above has a 224 x 270 PITCH: cards/AlbumCard.qml:186-189
|
||
// is 200 x 246 and the Grid adds 24px of column/row spacing — the
|
||
// same pitch SearchView's block was built around, so the
|
||
// placeholder lands exactly where the first appended row will.
|
||
skeleton: "cards"
|
||
cellW: 224
|
||
cellH: 270
|
||
// There is NO per-section loading flag to bind to: the bridge
|
||
// publishes one artist document plus the `releaseSectionReady`
|
||
// signal (artist_bridge.rs:60-66) and nothing in between. So the
|
||
// in-flight state is driven locally, off root's map, cleared by
|
||
// that signal and capped by root's 8s settle timer (the error path
|
||
// emits nothing at all — main.rs:872).
|
||
busy: section.sortLoading === true || root.releasePending[section.releaseType] === true
|
||
onClicked: {
|
||
// BEFORE the call: the threshold is the count on screen now
|
||
// (`section.cards` is the MERGED array — document page 1 plus
|
||
// every page the overlay holds — so the fade threshold lines
|
||
// up with the tail this request will add).
|
||
//
|
||
// The OFFSET is not that count: releaseMoreRequested returns
|
||
// the server cursor (see `releaseCursor`), which is the number
|
||
// of rows the server has actually sent. They differ as soon as
|
||
// a page repeats a row, and passing the on-screen count then
|
||
// re-requests rows we already have — the dedup swallows them
|
||
// and the button appears to do nothing, forever.
|
||
//
|
||
// The third argument suppresses the append fade when this
|
||
// bucket carries a non-default sort: the page will be
|
||
// INTERLEAVED, not appended, so an index threshold would fade
|
||
// the wrong cards (see releaseMoreRequested).
|
||
var off = root.releaseMoreRequested(section.releaseType, section.cards.length,
|
||
(section.sortBy || "default") !== "default")
|
||
QbzArtist.loadReleaseSection(artist.id, section.releaseType, off)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================ the page ================================
|
||
// FULL WIDTH even with the sidebar open. The .slint reserves the 300px
|
||
// inside the BODY ROW only (ArtistPageView.slint:1094) — the header and
|
||
// the JUMP TO bar span the whole window so the gradient covers edge to
|
||
// edge (:184-191). Narrowing the whole Flickable (the port's old
|
||
// `anchors.rightMargin`) squeezed the portrait and the bio too.
|
||
Flickable {
|
||
id: flick
|
||
anchors.fill: parent
|
||
clip: true
|
||
contentWidth: width
|
||
contentHeight: page.implicitHeight
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
|
||
// Artwork-tinted header band — the SAME shared component AlbumView
|
||
// mounts. First child = painted under the page; inside the Flickable
|
||
// = scrolls with it (ArtistPageView.slint:213); full-bleed.
|
||
HeaderGradient {
|
||
x: 0
|
||
y: 0
|
||
width: flick.width
|
||
// .slint:147 `atmo-height: page.y + body-row.y` — the band ends
|
||
// exactly where the body begins, i.e. at the JUMP TO strip, so a
|
||
// long bio pushes the fade down with no manual tuning.
|
||
// `jumpSlot`, not `jumpBar`: the bar is a pinned overlay now and
|
||
// its y is the CLAMPED viewport position, which would freeze this
|
||
// band's height the moment the bar stuck.
|
||
height: page.y + jumpSlot.y
|
||
tint: artist.headerColor || ""
|
||
// Route A: the blurred field. Empty until the cover resolves, and the
|
||
// flat tint stands in meanwhile (HeaderGradient handles the swap).
|
||
atmosphere: artist.headerAtmosphere || ""
|
||
active: root.headerAtmoOn
|
||
}
|
||
|
||
Column {
|
||
id: page
|
||
width: parent.width
|
||
leftPadding: 32
|
||
rightPadding: 32
|
||
topPadding: 11
|
||
bottomPadding: 100
|
||
spacing: 0
|
||
|
||
// Width available to the BODY sections: the page width less the
|
||
// 32+32 padding, less the sidebar reservation while it is open
|
||
// (.slint's empty 300px slot in the body row). The header and the
|
||
// jump strip deliberately do NOT subtract it.
|
||
readonly property real bodyWidth: width - 64 - (root.networkOpen ? 300 : 0)
|
||
|
||
Item { width: 1; height: 22 }
|
||
|
||
// --- Artist header skeleton ----------------------------------
|
||
// Mounted on the primary flag, and the real header is hidden by
|
||
// the same flag: opening artist B never renders a half-empty
|
||
// header frame while B's document is in flight.
|
||
Row {
|
||
visible: root.primaryLoading
|
||
width: parent.width - 64
|
||
spacing: 32
|
||
|
||
QbzSkeleton { variant: "circle"; width: 200; height: 200; phase: skeletonPhase.on }
|
||
Column {
|
||
width: parent.width - 200 - 32
|
||
spacing: 12
|
||
Item { width: 1; height: 10 }
|
||
QbzSkeleton { variant: "block"; width: Math.min(360, parent.width); height: 30; cellIndex: 0; phase: skeletonPhase.on }
|
||
QbzSkeleton { variant: "block"; width: Math.min(520, parent.width); height: 14; cellIndex: 1; phase: skeletonPhase.on }
|
||
QbzSkeleton { variant: "block"; width: Math.min(440, parent.width); height: 14; cellIndex: 2; phase: skeletonPhase.on }
|
||
Item { width: 1; height: 14 }
|
||
Row {
|
||
spacing: 12
|
||
Repeater {
|
||
model: 4
|
||
delegate: QbzSkeleton {
|
||
required property int index
|
||
variant: "circle"
|
||
width: 44
|
||
height: 44
|
||
cellIndex: index
|
||
phase: skeletonPhase.on
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Artist header ------------------------------------------
|
||
Row {
|
||
visible: !root.primaryLoading
|
||
width: parent.width - 64
|
||
spacing: 32
|
||
|
||
// Circular portrait. The circle comes from RoundedImage's
|
||
// MASK, not from a clip: QML's `clip` is rectangular and does
|
||
// NOT follow a Rectangle's radius — theme/RoundedImage.qml:3-6
|
||
// says so and proved it with an isolated scene on this Qt
|
||
// build, and :508 measures this exact radius-100-on-200px
|
||
// case. The `clip: true` that used to sit here (with a comment
|
||
// claiming the opposite) therefore rounded nothing, cost an
|
||
// unconditional batch root, and would have swallowed anything
|
||
// mounted inside the frame — which is why the menu and the
|
||
// lightbox are view-root siblings, exactly as
|
||
// AlbumView.qml:586-588 keeps them.
|
||
Rectangle {
|
||
width: 200
|
||
height: 200
|
||
radius: 100
|
||
color: theme.surfaceElevated
|
||
RoundedImage {
|
||
anchors.fill: parent
|
||
// A custom portrait (the SHARED custom_artwork store,
|
||
// keyed by artist NAME) beats the url-keyed pipeline
|
||
// image — the album header's rule on the artist axis.
|
||
source: (artist.customImageUrl || "") !== ""
|
||
? artist.customImageUrl
|
||
: (root.coverMap[artist.artUrl] || "")
|
||
radius: 100
|
||
}
|
||
// Left-click: the lightbox (NEW in this port — the
|
||
// reference lets left clicks pass through,
|
||
// ArtistPageView.slint:290-293). Right-click: the portrait
|
||
// menu the reference DOES have (ArtistPageView.slint:292-353),
|
||
// which this port simply never paid.
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: function (mouse) {
|
||
if (mouse.button === Qt.RightButton)
|
||
portraitMenu.openAtCursor(portraitMenuAnchor, mouse.x, mouse.y)
|
||
else if (root.bestArtistImage() !== "")
|
||
portraitLightbox.openWith(root.bestArtistImage())
|
||
}
|
||
}
|
||
Item { id: portraitMenuAnchor; anchors.fill: parent }
|
||
}
|
||
|
||
Column {
|
||
width: parent.width - 200 - 32
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 8
|
||
spacing: 0
|
||
|
||
Text {
|
||
width: parent.width
|
||
text: artist.name || ""
|
||
color: root.hdrStrong
|
||
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: root.hdrBody
|
||
font.pixelSize: theme.fontLegal
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
Item { visible: artist.bioTruncated === true; width: 1; height: 4 }
|
||
Text {
|
||
visible: artist.bioTruncated === true
|
||
text: QbzSession.tr("Read more", QbzSession.trRev)
|
||
color: readMoreArea.containsMouse ? theme.accentHover : theme.accent
|
||
font.pixelSize: theme.fontLegal
|
||
MouseArea {
|
||
id: readMoreArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
var shell = root.parent
|
||
while (shell && shell.openTextModal === undefined) shell = shell.parent
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
Item { width: 1; height: 18 }
|
||
// Action row — ArtistPageView.slint:413-591. Four
|
||
// circles (NO Play: Popular Tracks carries its own), then
|
||
// a stretch, then the catalog/library toggle floated
|
||
// right. The palette arm follows the header backdrop
|
||
// (`on-surface: root.hdr-on-surface`, :417).
|
||
Row {
|
||
width: parent.width
|
||
spacing: 12
|
||
QbzCircleAction {
|
||
readonly property bool following: root.toggleState("artist", artist.isFollowing)
|
||
name: following ? "heart-filled" : "heart"
|
||
overlay: root.hdrOverlay
|
||
active: following
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: {
|
||
root.setToggleState("artist", !following)
|
||
QbzLibrary.libraryToggleFavorite("artist", artist.id)
|
||
}
|
||
}
|
||
// Radio — ArtistPageView.slint:426-461: the disc opens
|
||
// a two-row dropdown, QBZ Radio (the local `qbz-radio`
|
||
// pool builder) vs Qobuz Radio (the curated
|
||
// `/radio/artist` endpoint).
|
||
//
|
||
// IT WAS `btnEnabled: false` — dimmed and inert by
|
||
// declaration, because at the time neither engine had a
|
||
// seam on a Qt bridge and the port refuses to render a
|
||
// control that drives nothing. Both exist now:
|
||
// `QbzHome.startArtistRadio` (the smart pool, already
|
||
// shipping behind the Discover spotlight's RADIO card)
|
||
// and `QbzHome.startQobuzArtistRadio`, added with this
|
||
// change. So the disc goes live and the dropdown is the
|
||
// reference's, verbatim.
|
||
QbzCircleAction {
|
||
id: radioBtn
|
||
name: "radio"
|
||
overlay: root.hdrOverlay
|
||
// BOTH dropdown rows arm the same key: they are
|
||
// two ways to fill one queue from one disc, and
|
||
// the disc is what the user is looking at. The
|
||
// control also gates its own clicks while loading,
|
||
// so the dropdown cannot be re-opened over a
|
||
// request that is already running.
|
||
loading: QbzHome.radioPending === "artist:" + artist.id
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: radioMenu.openBelowLeft(radioBtn)
|
||
}
|
||
QbzCircleAction {
|
||
name: "element-connect"
|
||
overlay: root.hdrOverlay
|
||
active: root.networkOpen
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.networkOpen = !root.networkOpen
|
||
}
|
||
QbzCircleAction {
|
||
id: overflowBtn
|
||
name: "ellipsis"
|
||
overlay: root.hdrOverlay
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: function (mouse) { overflowMenu.openAtCursor(overflowBtn, mouse.x, mouse.y) }
|
||
}
|
||
// Stretch (.slint:579 `Rectangle { horizontal-stretch: 1 }`).
|
||
// Clamped: at a narrow window an unclamped negative
|
||
// width silently reflows the whole row.
|
||
Item {
|
||
width: Math.max(0, parent.width - 4 * 32 - 4 * 12
|
||
- (segTabs.visible ? segTabs.width + 12 : 0))
|
||
height: 1
|
||
}
|
||
// From catalog / In library — .slint:582 mounts the
|
||
// SHARED SegmentedTabBar; the port hand-rolled a copy
|
||
// of it here whose delegate walked the wrong parent
|
||
// chain (`parent.parent.modelData` on a Row, and the
|
||
// count chip read `active` off the wrong node), so the
|
||
// count badge never took its active colours. This is
|
||
// the shared control (controls/QbzTabBar.qml), which
|
||
// is that same SegmentedTabBar 1:1 — counts on, and
|
||
// the 2px accent underline the .slint's Segment draws
|
||
// for the active tab (:86-93).
|
||
QbzTabBar {
|
||
id: segTabs
|
||
visible: (artist.libraryCount || 0) > 0
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
counts: true
|
||
underline: true
|
||
activeId: root.artistTab
|
||
tabs: [
|
||
{ "id": "catalog", "label": QbzSession.tr("From catalog", QbzSession.trRev), "count": 0 },
|
||
{ "id": "library", "label": QbzSession.tr("In library", QbzSession.trRev), "count": artist.libraryCount || 0 },
|
||
]
|
||
onSelected: function (id) { root.artistTab = id }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Hidden-artist banner (ArtistPageView.slint:595-660) ------
|
||
// Only when the CURRENTLY displayed artist is blacklisted. The page
|
||
// stays fully navigable — a direct fetch-by-id is never blocked
|
||
// (.slint:595-599) — so this is an unblock affordance, not a lock.
|
||
// Sits between the header and the body row, exactly where the
|
||
// .slint puts it (after the header block at :594, before the body
|
||
// row), with the .slint's own 16px spacer (:600).
|
||
// Built inline rather than through controls/WarningBanner.qml: that
|
||
// control has no action slot, and this banner's whole point is the
|
||
// right-hand "Show artist" button.
|
||
Item { visible: root.artistBlacklisted; width: 1; height: 16 }
|
||
Rectangle {
|
||
id: hiddenBanner
|
||
visible: root.artistBlacklisted
|
||
width: parent.width - 64
|
||
// .slint `height: banner-row.preferred-height` where the row is
|
||
// a HorizontalLayout with padding 12 (:602) — so 24 plus the
|
||
// tallest child: the 16px glyph, the wrapped copy, or the 28px
|
||
// button (:604, :620, :639).
|
||
height: visible ? 24 + Math.max(16, bannerCopy.implicitHeight, 28) : 0
|
||
radius: 8
|
||
// LITERALS, not theme tokens: the .slint hardcodes both
|
||
// (:596-599). theme.warningBg / warningBorder are a different
|
||
// amber (#fbbf24-based) and would not match the reference.
|
||
color: "#eab3081a"
|
||
border.width: 1
|
||
border.color: "#eab3084d"
|
||
|
||
// 16x16 blind-eye (:606-611). The .slint tints it with the
|
||
// literal #eab308; QbzIcon.tintName is a CLOSED vocabulary of
|
||
// names with no #eab308 bake, so "warning" (theme.warning
|
||
// #fbbf24) is the nearest available and the only theme-following
|
||
// amber — spec 03 C6's default decision.
|
||
QbzIcon {
|
||
id: bannerGlyph
|
||
name: "blind-eye"
|
||
width: 16
|
||
height: 16
|
||
tintName: "warning"
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
// "Show artist" button (:634-659): width = label + 20, height
|
||
// 28, radius 6, hover fill surface-elevated else transparent,
|
||
// label accent -> accentHover on hover, 13 / semibold.
|
||
Rectangle {
|
||
id: bannerBtn
|
||
width: bannerBtnLabel.implicitWidth + 20
|
||
height: 28
|
||
radius: 6
|
||
color: bannerBtnArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
Text {
|
||
id: bannerBtnLabel
|
||
anchors.centerIn: parent
|
||
text: QbzSession.tr("Show artist", QbzSession.trRev)
|
||
color: bannerBtnArea.containsMouse ? theme.accentHover : theme.accent
|
||
font.pixelSize: 13
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
MouseArea {
|
||
id: bannerBtnArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
// Same seam as the menu row (:654-657) — one toggle.
|
||
onClicked: root.toggleBlacklist()
|
||
}
|
||
}
|
||
// Copy (:619-627): text-secondary, Typography.legal = 13,
|
||
// word-wrap. The 10px gaps on both sides are the .slint's
|
||
// HorizontalLayout spacing (:603).
|
||
Text {
|
||
id: bannerCopy
|
||
anchors.left: bannerGlyph.right
|
||
anchors.leftMargin: 10
|
||
anchors.right: bannerBtn.left
|
||
anchors.rightMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: QbzSession.tr("This artist is hidden from discovery", QbzSession.trRev)
|
||
color: theme.textSecondary
|
||
font.pixelSize: 13
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
}
|
||
|
||
Item { width: 1; height: 20 }
|
||
|
||
// --- JUMP TO bar ---------------------------------------------
|
||
// The SHARED controls/QbzJumpNavBar (primitives/JumpNavBar.slint
|
||
// 1:1). What the port drew by hand was the tab strip ONLY: no
|
||
// "JUMP TO" caption, no bottom hairline, no search affordance,
|
||
// 13px/medium type instead of the .slint's 15px/regular, and the
|
||
// wrong three tab colours. padH 0 because this Column already
|
||
// pads 32 — the .slint's own 32px padding lands the strip in the
|
||
// same place.
|
||
// The bar itself is an OVERLAY outside this Flickable (see
|
||
// `jumpBar` after it) so it can pin to the top on scroll. What
|
||
// stays in the flow is this slot, which reserves exactly its
|
||
// height: without it the body would jump up by the bar's height
|
||
// the moment the bar was lifted out.
|
||
Item {
|
||
id: jumpSlot
|
||
width: parent.width - 64
|
||
height: jumpBar.height
|
||
}
|
||
|
||
// --- Primary placeholder --------------------------------------
|
||
// Same flag the spinner used, now in the shape of what is coming:
|
||
// the Popular Tracks heading plus 5 rows at the PopularTrackRow
|
||
// 50px pitch (the preview count), so nothing shifts on arrival.
|
||
Column {
|
||
visible: root.primaryLoading
|
||
width: parent.width - 64
|
||
spacing: 0
|
||
|
||
QbzSkeleton { variant: "block"; width: 190; height: 22; phase: skeletonPhase.on }
|
||
Item { width: 1; height: 18 }
|
||
Repeater {
|
||
model: root.preview
|
||
delegate: Item {
|
||
required property int index
|
||
width: parent ? parent.width : 0
|
||
height: 50
|
||
QbzSkeleton {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.leftMargin: 12
|
||
anchors.rightMargin: 24
|
||
height: 40
|
||
variant: "row"
|
||
cellIndex: index
|
||
phase: skeletonPhase.on
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ================= Catalog tab ================================
|
||
Column {
|
||
id: sectionAnchors
|
||
visible: root.artistTab === "catalog" && !QbzArtist.artistLoading
|
||
// Body width: yields the 300px the sidebar overlay occupies
|
||
// (.slint's empty reservation slot, :1094) so the content is
|
||
// never painted under the panel.
|
||
width: page.bodyWidth
|
||
spacing: 0
|
||
|
||
// --- Popular Tracks -------------------------------------
|
||
// Multi-select bulk bar — INLINE above the Popular Tracks
|
||
// header (the Local Library album view's layout: bar in flow,
|
||
// content below), covering this page's Qobuz track lists.
|
||
QbzMultiSelectBar {
|
||
visible: root.multiSelect
|
||
width: parent.width
|
||
selectedCount: root.selectedCount
|
||
actions: [
|
||
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
||
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
||
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
||
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
||
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||
{ "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true },
|
||
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
||
]
|
||
onAction: function (id) { root.bulkAction(id) }
|
||
}
|
||
|
||
// Air between the sticky JUMP TO bar's rule and the row.
|
||
Item { visible: topTracks.length > 0; width: 1; height: 8 }
|
||
Row {
|
||
property string anchorId: "popular-tracks"
|
||
visible: topTracks.length > 0
|
||
width: parent.width
|
||
spacing: 12
|
||
Text {
|
||
width: parent.width - 32 - 32 - 32 - 3 * 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: QbzSession.tr("Popular Tracks", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontHeading
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
// The SHARED CircleAction, in the same plain 32px outlined
|
||
// form as its two siblings (2026-09-13): the filled 44px
|
||
// accent disc the .slint carried here clashed with them
|
||
// and butted the JUMP TO bar's rule.
|
||
QbzCircleAction {
|
||
name: "play-fill"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: QbzPlayer.playArtistTop(false)
|
||
}
|
||
// Multi-select toggle — live since the bulk-bar port.
|
||
QbzCircleAction {
|
||
name: "square-check-big"
|
||
active: root.multiSelect
|
||
btnEnabled: root.topTracks.length > 0
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.setMultiSelect(!root.multiSelect)
|
||
}
|
||
QbzCircleAction {
|
||
id: topMenuBtn
|
||
name: "ellipsis"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: function (mouse) {
|
||
topMenu.forLibrary = false
|
||
topMenu.openAtCursor(topMenuBtn, mouse.x, mouse.y)
|
||
}
|
||
}
|
||
}
|
||
Item { visible: topTracks.length > 0; width: 1; height: 10 }
|
||
|
||
Repeater {
|
||
// Do not build the unrevealed tail. Numeric growth keeps
|
||
// the preview delegates and adds only the requested rows.
|
||
model: root.topTracksExpanded ? topTracks.length
|
||
: Math.min(topTracks.length, root.preview)
|
||
delegate: PopularTrackRow {
|
||
// Smooth reveal (owner, 2026-08-02). This expander is
|
||
// CLIENT-SIDE: numeric model growth now creates only
|
||
// the revealed tail. The former full count built every
|
||
// row up front and only flipped `visible`.
|
||
//
|
||
// OPACITY ONLY, deliberately: the row is 50 tall with
|
||
// a 36px cover and text anchored to its verticalCenter
|
||
// and it does NOT clip, so animating `height` would
|
||
// paint those children outside the row box and over
|
||
// its neighbours for the whole 220ms. Height still
|
||
// snaps (the space opens at once); the content
|
||
// dissolves into it. Same 220ms / OutCubic the
|
||
// appended release cards use.
|
||
//
|
||
// The gate is this explicit local `revealed`, not the
|
||
// item's effective visibility, so showing the catalog
|
||
// tab again does not re-fade surviving delegates.
|
||
property bool revealed: false
|
||
height: 50
|
||
opacity: revealed ? 1.0 : 0.0
|
||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||
Component.onCompleted: revealed = true
|
||
row: topTracks[index]
|
||
rowIndex: index
|
||
showAlbum: true
|
||
selectMode: root.multiSelect
|
||
checked: root.selected[row.id] === true
|
||
onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
|
||
}
|
||
}
|
||
Item { visible: topTracks.length > root.preview; width: 1; height: 4 }
|
||
// The shared affordance — the `b` arm of controls/QbzLoadMore
|
||
// .qml's header: same plain 28-tall shape, same label pair,
|
||
// same client-side toggle. No `busy` and no skeleton: there is
|
||
// no fetch, the reveal is instant (the rows above own the
|
||
// smoothing).
|
||
QbzLoadMore {
|
||
visible: topTracks.length > root.preview
|
||
width: parent.width
|
||
label: root.topTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
|
||
onClicked: root.topTracksExpanded = !root.topTracksExpanded
|
||
}
|
||
|
||
// --- Latest release --------------------------------------
|
||
Column {
|
||
property string anchorId: "about"
|
||
visible: !!root.artist.lastRelease
|
||
width: parent.width
|
||
spacing: 12
|
||
Item { width: 1; height: 32 }
|
||
Text {
|
||
text: QbzSession.tr("Latest release", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontHeading
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
AlbumCard {
|
||
// EVERY read here must be `root.artist.lastRelease`:
|
||
// inside the AlbumCard instantiation the bare name
|
||
// `artist` resolves to the CARD'S OWN string property
|
||
// (a string has no .lastRelease → undefined → every
|
||
// binding fell back to ""), so the card rendered as an
|
||
// empty tile while the grids' modelData-driven cards
|
||
// were fine. Verified live 2026-08-10 (VNC drive).
|
||
albumId: root.artist.lastRelease ? root.artist.lastRelease.id : ""
|
||
title: root.artist.lastRelease ? root.artist.lastRelease.title : ""
|
||
// year in the subtitle slot — card_to_item again
|
||
// (artist.rs:784 maps last_release through it too).
|
||
artist: root.artist.lastRelease ? root.artist.lastRelease.year : ""
|
||
artistId: ""
|
||
genre: root.artist.lastRelease ? root.artist.lastRelease.genre : ""
|
||
year: root.artist.lastRelease ? root.artist.lastRelease.year : ""
|
||
qualityTier: root.artist.lastRelease ? root.artist.lastRelease.qualityTier : ""
|
||
qualityDetail: root.artist.lastRelease ? (root.artist.lastRelease.qualityDetail || "") : ""
|
||
artSource: root.artist.lastRelease ? (root.coverMap[root.artist.lastRelease.artUrl] || "") : ""
|
||
// Same row shape as the release grids (map_release).
|
||
isPinned: root.artist.lastRelease ? root.artist.lastRelease.isPinned === true : false
|
||
artworkUrl: root.artist.lastRelease ? (root.artist.lastRelease.artUrl || "") : ""
|
||
isFavorite: root.artist.lastRelease ? root.artist.lastRelease.isFavorite === true : false
|
||
}
|
||
}
|
||
|
||
// --- Release sections ------------------------------------
|
||
Repeater {
|
||
// `visible: false` does not prevent delegate construction.
|
||
// Feed this arm only regular buckets so the collapsed
|
||
// Other bucket is not built once here and again below.
|
||
model: releaseSections.filter(function (s) {
|
||
return s.releaseType !== "other"
|
||
})
|
||
delegate: Column {
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
spacing: 0
|
||
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: QbzSession.tr("Appears On", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontHeading
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
Item { width: 1; height: 10 }
|
||
Repeater {
|
||
model: root.appearsOnExpanded ? appearsOn.length
|
||
: Math.min(appearsOn.length, root.preview)
|
||
delegate: PopularTrackRow {
|
||
// Same client-side reveal as Popular Tracks above
|
||
// — same component, same 50px pitch, same reason
|
||
// for fading opacity and letting height snap.
|
||
property bool revealed: false
|
||
height: 50
|
||
opacity: revealed ? 1.0 : 0.0
|
||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||
Component.onCompleted: revealed = true
|
||
row: appearsOn[index]
|
||
rowIndex: index
|
||
showAlbum: false
|
||
selectMode: root.multiSelect
|
||
checked: root.selected[row.id] === true
|
||
onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
|
||
}
|
||
}
|
||
Item { visible: appearsOn.length > root.preview; width: 1; height: 4 }
|
||
// Shared affordance, `b` arm again (client-side toggle).
|
||
QbzLoadMore {
|
||
visible: appearsOn.length > root.preview
|
||
width: parent.width
|
||
label: root.appearsOnExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
|
||
onClicked: root.appearsOnExpanded = !root.appearsOnExpanded
|
||
}
|
||
}
|
||
|
||
// --- Playlists --------------------------------------------
|
||
// The reference mounts the SHARED playlist carousel here
|
||
// (ArtistPageView.slint:985-995 -> discover/PlaylistCarousel
|
||
// -> discover/PlaylistCard), so this page gets the same card
|
||
// Home, Search, Browse, Label and the Library feed already
|
||
// mount: body click opens, overlay play/favourite/⋯, pin
|
||
// badge, context menu — and the banner rendered CONTAIN
|
||
// instead of cropped 2.11:1 into a square.
|
||
//
|
||
// What stood here was a hand-rolled Rectangle delegate whose
|
||
// MouseArea had no onClicked at all (only a cursor, which is
|
||
// why it LOOKED clickable) and a RoundedImage with no `fit:`,
|
||
// i.e. the default crop. Its POC-NOTE claimed there was no
|
||
// playlist view yet; views/PlaylistView.qml has existed and
|
||
// been routed for a long time.
|
||
Item { visible: playlists.length > 0; width: 1; height: 32 }
|
||
PlaylistRail {
|
||
visible: playlists.length > 0
|
||
width: parent.width
|
||
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
||
items: playlists
|
||
coverMap: root.coverMap
|
||
}
|
||
|
||
// --- Other (collapsed) ------------------------------------
|
||
Repeater {
|
||
model: releaseSections.filter(function (s) {
|
||
return s.releaseType === "other"
|
||
})
|
||
delegate: Column {
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
spacing: 0
|
||
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 ? QbzSession.tr("Hide", QbzSession.trRev) : QbzSession.tr("Show", QbzSession.trRev)
|
||
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 {
|
||
// A hidden Grid still constructs its Repeater.
|
||
// The collapsed bucket must cost zero cards.
|
||
model: root.otherExpanded ? modelData.cards : []
|
||
delegate: AlbumCard {
|
||
albumId: modelData.id
|
||
title: modelData.title
|
||
// year in the subtitle slot (card_to_item)
|
||
artist: modelData.year
|
||
artistId: ""
|
||
genre: modelData.genre
|
||
year: modelData.year
|
||
qualityTier: modelData.qualityTier
|
||
qualityDetail: modelData.qualityDetail || ""
|
||
artSource: root.coverMap[modelData.artUrl] || ""
|
||
isPinned: modelData.isPinned === true
|
||
artworkUrl: modelData.artUrl || ""
|
||
isFavorite: modelData.isFavorite === true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ================= In library tab =============================
|
||
// The user's OWN rows for this artist (`root.libItems`, off the
|
||
// artist document). Two first-class sections — Tracks modelled on
|
||
// Popular Tracks (header action cluster, 10-row preview, Load
|
||
// more) and the Albums grid — each carrying an `anchorId` so the
|
||
// JUMP TO strip resolves against THIS column in library mode.
|
||
Column {
|
||
id: libraryTab
|
||
visible: root.artistTab === "library" && !QbzArtist.artistLoading
|
||
width: page.bodyWidth
|
||
spacing: 0
|
||
|
||
// Same bulk bar as the catalog column: `bulkAction` routes on
|
||
// the active mode's lists (`selectableLists`).
|
||
QbzMultiSelectBar {
|
||
visible: root.multiSelect
|
||
width: parent.width
|
||
selectedCount: root.selectedCount
|
||
actions: [
|
||
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
||
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
||
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
||
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
||
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
||
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
||
{ "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true },
|
||
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true }
|
||
]
|
||
onAction: function (id) { root.bulkAction(id) }
|
||
}
|
||
|
||
// --- Tracks ---------------------------------------------
|
||
// Header + action cluster: the Popular Tracks row above,
|
||
// with the actions re-pointed at the library list — Play all
|
||
// / Shuffle all through `libraryPlayAll`, the ⋯ menu shared
|
||
// with Popular Tracks (`topMenu.forLibrary`).
|
||
Row {
|
||
property string anchorId: "tracks"
|
||
visible: root.libTracks.length > 0
|
||
width: parent.width
|
||
spacing: 12
|
||
Text {
|
||
width: parent.width - 44 - 32 - 32 - 3 * 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: QbzSession.tr("Tracks", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontHeading
|
||
font.weight: theme.weightSemibold
|
||
}
|
||
QbzCircleAction {
|
||
primary: true
|
||
name: "play-fill"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: QbzLibrary.libraryPlayAll(JSON.stringify(root.libTrackIds()), false)
|
||
}
|
||
QbzCircleAction {
|
||
name: "square-check-big"
|
||
active: root.multiSelect
|
||
btnEnabled: root.libTracks.length > 0
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.setMultiSelect(!root.multiSelect)
|
||
}
|
||
QbzCircleAction {
|
||
id: libMenuBtn
|
||
name: "ellipsis"
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: function (mouse) {
|
||
topMenu.forLibrary = true
|
||
topMenu.openAtCursor(libMenuBtn, mouse.x, mouse.y)
|
||
}
|
||
}
|
||
}
|
||
Item { visible: root.libTracks.length > 0; width: 1; height: 10 }
|
||
Repeater {
|
||
// Numeric model: only the revealed rows are BUILT
|
||
// (declaring is building — the Popular Tracks expander's
|
||
// rule, feedback_qml_hidden_is_not_unbuilt).
|
||
model: root.libTracksExpanded ? root.libTracks.length
|
||
: Math.min(root.libTracks.length, root.libPreview)
|
||
delegate: PopularTrackRow {
|
||
readonly property var item: root.libTracks[index] || ({})
|
||
property bool revealed: false
|
||
// A cleared heart takes the row out — visually now,
|
||
// for real when the document republishes (the heart
|
||
// settles, `artist_qt::apply_favorite_change`
|
||
// recomputes the rows). A PURCHASE that loses its
|
||
// heart stays: it is still library (contract §3).
|
||
readonly property bool leaving:
|
||
!root.toggleState("track:" + item.id, item.isFavorite === true)
|
||
&& item.group !== "purchases"
|
||
height: 50
|
||
opacity: (revealed && !leaving) ? 1.0 : 0.0
|
||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||
Component.onCompleted: revealed = true
|
||
row: ({
|
||
"id": item.id, "number": index + 1, "title": item.title,
|
||
"artist": item.artist, "artistId": item.artistId,
|
||
"album": item.album, "albumId": item.albumId,
|
||
"duration": item.duration, "qualityTier": item.qualityTier,
|
||
"explicit": item.explicit, "artUrl": item.imageUrl,
|
||
"isFavorite": item.isFavorite === true,
|
||
"qobuzUnavailable": item.qobuzUnavailable === true,
|
||
"cacheStatus": item.cacheStatus || 0,
|
||
})
|
||
rowIndex: index
|
||
showAlbum: true
|
||
selectMode: root.multiSelect
|
||
checked: root.selected[row.id] === true
|
||
playHandler: function (id) {
|
||
QbzLibrary.libraryPlayVisible(JSON.stringify(root.libTrackIds()), id)
|
||
}
|
||
onToggleSelect: function (mods) { root.toggleSelected(row.id, mods) }
|
||
}
|
||
}
|
||
Item { visible: root.libTracks.length > root.libPreview; width: 1; height: 4 }
|
||
QbzLoadMore {
|
||
visible: root.libTracks.length > root.libPreview
|
||
width: parent.width
|
||
label: root.libTracksExpanded ? QbzSession.tr("View less", QbzSession.trRev) : QbzSession.tr("Load more", QbzSession.trRev)
|
||
onClicked: root.libTracksExpanded = !root.libTracksExpanded
|
||
}
|
||
|
||
// --- Albums ---------------------------------------------
|
||
Item { visible: root.libAlbums.length > 0 && root.libTracks.length > 0; width: 1; height: 24 }
|
||
// Header: title, the release buckets' sort select, and the
|
||
// split play button — the catalog section header's cluster
|
||
// minus "See discography" (there is nothing to page here).
|
||
Row {
|
||
property string anchorId: "albums"
|
||
visible: root.libAlbums.length > 0
|
||
width: parent.width
|
||
spacing: 12
|
||
Text {
|
||
width: parent.width - libSortBtn.width - libPlayBtn.width - 24
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: QbzSession.tr("Albums", QbzSession.trRev)
|
||
color: theme.textPrimary
|
||
font.pixelSize: theme.fontHeading
|
||
font.weight: theme.weightSemibold
|
||
elide: Text.ElideRight
|
||
}
|
||
QbzSelect {
|
||
id: libSortBtn
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
sm: true
|
||
menuWidth: 150
|
||
options: [QbzSession.tr("Default", QbzSession.trRev),
|
||
QbzSession.tr("Newest", QbzSession.trRev),
|
||
QbzSession.tr("Oldest", QbzSession.trRev),
|
||
QbzSession.tr("A–Z", QbzSession.trRev),
|
||
QbzSession.tr("Z–A", QbzSession.trRev)]
|
||
currentIndex: {
|
||
var s = root.libSort || "default"
|
||
return s === "newest" ? 1 : s === "oldest" ? 2
|
||
: s === "title-asc" ? 3 : s === "title-desc" ? 4 : 0
|
||
}
|
||
onSelected: function (i) {
|
||
root.setLibSort(i === 1 ? "newest" : i === 2 ? "oldest"
|
||
: i === 3 ? "title-asc" : i === 4 ? "title-desc" : "default")
|
||
}
|
||
}
|
||
QbzSplitButton {
|
||
id: libPlayBtn
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
label: root.albumSectionLabel("library")
|
||
menuItems: root.albumSectionMenu("library")
|
||
onClicked: root.albumSectionPrimary("library", root.libAlbumsSorted)
|
||
onPicked: function (a) { root.albumSectionAction("library", root.libAlbumsSorted, a) }
|
||
}
|
||
}
|
||
Item { visible: root.libAlbums.length > 0; width: 1; height: 10 }
|
||
Grid {
|
||
visible: root.libAlbums.length > 0
|
||
width: parent.width
|
||
columns: Math.max(1, Math.floor((width + 24) / 224))
|
||
columnSpacing: 24
|
||
rowSpacing: 24
|
||
Repeater {
|
||
model: root.libAlbumsSorted
|
||
delegate: AlbumCard {
|
||
// "Play selected" over this section (the split
|
||
// button), check bottom-right inside the art.
|
||
selectMode: root.albumSelectKey === "library"
|
||
selected: root.albumSelected[modelData.id] === true
|
||
selectMarkBottom: true
|
||
onSelectToggled: function (mods) {
|
||
root.albumSelectToggle(modelData.id, root.libAlbumsSorted, mods)
|
||
}
|
||
albumId: modelData.id
|
||
title: modelData.title
|
||
artist: modelData.artist
|
||
artistId: modelData.artistId
|
||
genre: modelData.genre
|
||
year: modelData.year
|
||
qualityTier: modelData.qualityTier
|
||
qualityDetail: modelData.qualityDetail || ""
|
||
artSource: root.coverMap[modelData.imageUrl] || ""
|
||
// These rows are LIBRARY feed items (FeedItem),
|
||
// not release cards: the remote url is
|
||
// `imageUrl`, and the pin state rides the same
|
||
// row (library_qt `map_album`).
|
||
isPinned: modelData.isPinned === true
|
||
artworkUrl: modelData.imageUrl || ""
|
||
// The heart goes through the page's optimistic
|
||
// store so the card can fade out on the click,
|
||
// ahead of the settle that removes the row.
|
||
hostFavorite: true
|
||
isFavorite: root.toggleState("album:" + modelData.id, modelData.isFavorite === true)
|
||
onFavoriteRequested: {
|
||
var next = !root.toggleState("album:" + modelData.id, modelData.isFavorite === true)
|
||
root.setToggleState("album:" + modelData.id, next)
|
||
QbzLibrary.libraryToggleFavorite("album", modelData.id)
|
||
}
|
||
readonly property bool leaving: !isFavorite && modelData.group !== "purchases"
|
||
opacity: leaving ? 0.0 : 1.0
|
||
Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// Gutter scrollbar. A SIBLING of the Flickable, not a child: anything
|
||
// declared inside a Flickable lands in its contentItem and scrolls away
|
||
// with the page. Hidden while the network panel is open — that panel pins
|
||
// to the same right edge and carries its own scroll
|
||
// (ArtistPageView.slint:1157).
|
||
// Back/forward scroll memory (controls/ScrollMemory.qml): reports
|
||
// this container's offset while it is the live page, and restores it
|
||
// when a back/forward step arms this route.
|
||
ScrollMemory { target: flick; scope: "artist" }
|
||
QbzScrollBar {
|
||
visible: !root.networkOpen
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 4
|
||
anchors.top: parent.top
|
||
anchors.bottom: parent.bottom
|
||
target: flick
|
||
}
|
||
|
||
// --- Network sidebar (300px, surface-card + 1px left border) ---------
|
||
// BOUNDED, not full-height. ArtistPageView.slint:1178-1195:
|
||
// natural-top = the live viewport y of the body row + the JUMP TO bar
|
||
// height (i.e. the panel starts under the strip, never
|
||
// beside the portrait)
|
||
// y = max(natural-top, sticky-top=44) — it rides the scroll
|
||
// down and then parks 44px from the top
|
||
// height = root.height - y — always flush with the
|
||
// bottom, so no gap appears once it is parked
|
||
// The port ran it top-to-bottom of the whole view, which put the panel
|
||
// alongside the header and made it read as app chrome instead of a page
|
||
// panel.
|
||
// --- STICKY JUMP TO bar (ArtistPageView.slint:1113-1126) ---------------
|
||
//
|
||
// An OVERLAY over the Flickable, not a row inside it, and that is the only
|
||
// way it can pin: an item in the content column scrolls with the content by
|
||
// definition. `jumpSlot` above holds its place in the flow so nothing jumps
|
||
// when it lifts out.
|
||
//
|
||
// The clamp is the reference's, verbatim: `y = max(naturalTop, 0)` where
|
||
// naturalTop is the slot's VIEWPORT-relative y. The reference's own comment
|
||
// is worth keeping in mind — the formula must NOT add the viewport y, or
|
||
// the scroll is double-counted and the bar lifts ahead of the body during
|
||
// the transition (a visible gap plus a bar-over-header artifact mid-scroll).
|
||
//
|
||
// Full width with padH 32, where the in-flow version was `parent.width - 64`
|
||
// with padH 0: the Column supplied that 32px padding, and outside it the bar
|
||
// has to supply its own or the strip would shift left by 32 when it sticks.
|
||
// Net position of the tabs is unchanged. Same shape as the .slint, which
|
||
// also mounts it at x:0 across `root.width`.
|
||
QbzJumpNavBar {
|
||
id: jumpBar
|
||
x: 0
|
||
width: root.width
|
||
padH: 32
|
||
y: Math.max(page.y + jumpSlot.y - flick.contentY, 0)
|
||
// PINNED at the pane's top edge -> round the top corners, or the bar's
|
||
// square ones poke out past the shell's rounded bezel (the Discover
|
||
// full-bleed toolbar, same defect, same fix). Driven off the live y so
|
||
// the rounding exists ONLY while the bar is actually at the top: in
|
||
// mid-page it is surrounded by content and a notch there would be worse
|
||
// than the square corner it fixes.
|
||
topRadius: y <= 0 ? theme.radiusMd : 0
|
||
// surface-main @ 0.6 under the dynamic background, NOT
|
||
// transparent: pinned, it overlays the page content scrolling beneath
|
||
// it, so it needs SOME fill to stay readable — the reference gives thin
|
||
// bars their own lighter tier for exactly that
|
||
// (ArtistPageView.slint:1108). This mattered less when the bar scrolled
|
||
// with the page; now it is load-bearing.
|
||
barBg: root.ambientOn
|
||
? Qt.rgba(theme.surfaceMain.r, theme.surfaceMain.g,
|
||
theme.surfaceMain.b, 0.60)
|
||
: theme.surfaceMain
|
||
tabs: root.jumpTabs
|
||
activeTabId: root.activeJumpTab
|
||
visible: root.jumpTabs.length > 0
|
||
// "In library" source switches — `Library > Albums / Tracks`'s
|
||
// purchases / favourites toggles (LibraryToolbar.qml:376-377), on the
|
||
// shared QbzToggleButton, floating in the strip ONLY while the library
|
||
// tab is active. State is the page's own (`libShowPurchases` /
|
||
// `libShowFavorites`), never `QbzLibrary.sessionShow*`.
|
||
trailing: Row {
|
||
visible: root.artistTab === "library"
|
||
width: visible ? implicitWidth : 0
|
||
height: 44
|
||
spacing: 8
|
||
QbzToggleButton {
|
||
sm: true
|
||
name: "shopping-bag"
|
||
active: root.libShowPurchases
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.libShowPurchases = !root.libShowPurchases
|
||
}
|
||
QbzToggleButton {
|
||
sm: true
|
||
name: "heart"
|
||
active: root.libShowFavorites
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.libShowFavorites = !root.libShowFavorites
|
||
}
|
||
}
|
||
onTabClicked: function (id) { root.scrollToSection(id) }
|
||
onSearchEdited: function (text) { root.searchQuery = text }
|
||
}
|
||
|
||
Rectangle {
|
||
id: netPanel
|
||
// Viewport-relative y of the strip's bottom edge. `jumpBar.y` is
|
||
// content coords, so subtracting the scroll offset gives the live
|
||
// viewport position — the same arithmetic the .slint does with
|
||
// absolute-position.
|
||
readonly property real naturalTop:
|
||
page.y + jumpSlot.y + jumpSlot.height - flick.contentY
|
||
readonly property real stickyTop: 44
|
||
|
||
anchors.right: parent.right
|
||
y: Math.max(naturalTop, stickyTop)
|
||
height: Math.max(0, root.height - y)
|
||
width: root.networkOpen ? 300 : 0
|
||
clip: true
|
||
// Chrome tier: surface-card @ 0.5 under the dynamic background
|
||
// (ArtistPageView.slint:1196). Its 44px header row stays TRANSPARENT
|
||
// there (:1221) — it sits ON this already-translucent body, and a
|
||
// second translucent layer would compound to near-opaque.
|
||
color: root.ambientOn ? theme.surfaceCardA50 : 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": QbzSession.tr("Network", QbzSession.trRev) },
|
||
{ "id": "magazine", "label": QbzSession.tr("Magazine", QbzSession.trRev) },
|
||
]
|
||
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 ? root.tintOnSurface : "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
|
||
|
||
// 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
|
||
SidebarSkeleton {
|
||
visible: root.originPending
|
||
rows: 2
|
||
phase: skeletonPhase.on
|
||
}
|
||
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)
|
||
// DOOR D1 to the Artist Scene, and the reference's
|
||
// ONLY door (ArtistDetailView.svelte:2904-2935).
|
||
// Was plain text under a POC-NOTE until the scene
|
||
// view existed to receive it.
|
||
value: mbOrigin.locationDisplay || ""
|
||
clickable: root.sceneAvailable
|
||
onActivated: root.openArtistScene()
|
||
}
|
||
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
|
||
leftPadding: 14
|
||
rightPadding: 14
|
||
topPadding: 12
|
||
bottomPadding: 6
|
||
spacing: 4
|
||
SidebarSectionHeading { text: QbzSession.tr("LABELS", QbzSession.trRev) }
|
||
SidebarNote {
|
||
visible: labels.length === 0
|
||
text: QbzSession.tr("No label info", QbzSession.trRev)
|
||
}
|
||
Repeater {
|
||
model: labels
|
||
delegate: SidebarLink {
|
||
label: modelData.name
|
||
tooltip: modelData.name
|
||
iconName: "disc"
|
||
onClicked: QbzHome.openLabel(modelData.id)
|
||
}
|
||
}
|
||
}
|
||
// SIMILAR ARTISTS.
|
||
Column {
|
||
visible: similarArtists.length > 0 || QbzArtist.artistLoading
|
||
width: parent.width
|
||
leftPadding: 14
|
||
rightPadding: 14
|
||
topPadding: 12
|
||
bottomPadding: 6
|
||
spacing: 4
|
||
SidebarSectionHeading { text: QbzSession.tr("SIMILAR ARTISTS", QbzSession.trRev) }
|
||
SidebarSkeleton {
|
||
visible: root.similarPending
|
||
rows: 4
|
||
phase: skeletonPhase.on
|
||
}
|
||
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) }
|
||
SidebarSkeleton {
|
||
visible: root.relationshipsPending
|
||
rows: 3
|
||
phase: skeletonPhase.on
|
||
}
|
||
RelationshipGroup {
|
||
visible: network.relationshipsLoading !== true
|
||
&& (mbRelationships.members || []).length > 0
|
||
title: QbzSession.tr("Members & Former", QbzSession.trRev)
|
||
rows: mbRelationships.members || []
|
||
roleKey: "member"
|
||
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 || []
|
||
roleKey: "member of"
|
||
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 || []
|
||
roleKey: "collaborator"
|
||
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) }
|
||
SidebarSkeleton {
|
||
visible: root.discoveryPending
|
||
rows: 4
|
||
phase: skeletonPhase.on
|
||
}
|
||
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 ? root.tintOnSurface : "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 — 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
|
||
clip: true
|
||
contentWidth: width
|
||
contentHeight: magBody.implicitHeight
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
|
||
Column {
|
||
id: magBody
|
||
width: parent.width
|
||
padding: 12
|
||
spacing: 10
|
||
|
||
// Story teasers are fetched after the page (Qobuz
|
||
// editorial): two card placeholders in the teaser shape
|
||
// while they resolve. Resolved-to-nothing keeps the
|
||
// explicit empty line below — this is a TAB body, where a
|
||
// blank panel would read as broken.
|
||
Column {
|
||
visible: root.storiesPending
|
||
width: magBody.width - 24
|
||
spacing: 12
|
||
Repeater {
|
||
model: 2
|
||
delegate: Column {
|
||
required property int index
|
||
width: parent ? parent.width : 0
|
||
spacing: 6
|
||
QbzSkeleton {
|
||
variant: "block"
|
||
width: parent.width
|
||
height: parent.width
|
||
blockRadius: 6
|
||
cellIndex: index
|
||
phase: skeletonPhase.on
|
||
}
|
||
QbzSkeleton { variant: "block"; width: parent.width * 0.82; height: 14; cellIndex: index; phase: skeletonPhase.on }
|
||
QbzSkeleton { variant: "block"; width: parent.width * 0.45; height: 11; cellIndex: index; phase: skeletonPhase.on }
|
||
}
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Radio dropdown (the header disc) ----------------------------------
|
||
// ArtistPageView.slint:440-460 — 180px, two rows, `close-on-click`. Left
|
||
// aligned under the trigger (`x: 0px; y: 38px` there), which is what
|
||
// openBelowLeft does; openBelowRight would hang a 180px panel off to the
|
||
// LEFT of a 32px disc that sits at the start of the row.
|
||
QbzContextMenu {
|
||
id: radioMenu
|
||
menuWidth: 180
|
||
Repeater {
|
||
model: [
|
||
// Both labels already exist in the catalogue (they are the
|
||
// TrackContextMenu's), so this adds no new msgid to the eight
|
||
// locales.
|
||
{ "label": QbzSession.tr("QBZ Radio", QbzSession.trRev), "action": "qbz" },
|
||
{ "label": QbzSession.tr("Qobuz Radio", QbzSession.trRev), "action": "qobuz" },
|
||
]
|
||
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: "radio"
|
||
width: 15
|
||
height: 15
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
tintName: rmiArea.containsMouse ? "textPrimary" : "secondary"
|
||
}
|
||
Text {
|
||
height: parent.height
|
||
width: parent.width - 23
|
||
text: modelData.label
|
||
color: rmiArea.containsMouse ? theme.textPrimary : theme.textSecondary
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: rmiArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
radioMenu.close()
|
||
if (modelData.action === "qbz")
|
||
QbzHome.startArtistRadio(artist.id)
|
||
else
|
||
QbzHome.startQobuzArtistRadio(artist.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- ⋯ overflow menu ---------------------------------------------------
|
||
QbzContextMenu {
|
||
id: overflowMenu
|
||
menuWidth: 224
|
||
Repeater {
|
||
// `live: false` = no seam on this bridge. The .slint greys
|
||
// its own unavailable rows the same way (Artist Scene is
|
||
// `enabled: NetworkSidebarState.mb-available`,
|
||
// ArtistPageView.slint:523) — dimmed, no hover, no click,
|
||
// and the menu keeps its shape.
|
||
model: [
|
||
{ "label": QbzSession.tr("Create Artist Collection", QbzSession.trRev), "icon": "library-big", "action": "disco", "live": true },
|
||
// DOOR D2. This row rendered at opacity 0.4 with
|
||
// `action: "stub"` — the track's named defect class, a
|
||
// control that renders, persists and drives nothing. It is
|
||
// a Slint-era invention with no counterpart in the visual
|
||
// reference (kept on the owner's explicit instruction,
|
||
// contract ruling R4), and it shares D1's gate exactly so
|
||
// the two doors can never disagree about availability.
|
||
{ "label": QbzSession.tr("Artist Scene", QbzSession.trRev), "icon": "map-pin", "action": "scene", "live": root.sceneAvailable },
|
||
{ "label": QbzSession.tr("Share", QbzSession.trRev), "icon": "link", "action": "share", "live": true },
|
||
{ "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", "live": true },
|
||
]
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
height: 33
|
||
radius: 5
|
||
opacity: modelData.live ? 1.0 : 0.4
|
||
color: (modelData.live && 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
|
||
enabled: modelData.live
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
overflowMenu.close()
|
||
if (modelData.action === "pin") {
|
||
root.setToggleState("artistPin", !root.toggleState("artistPin", artist.isPinned))
|
||
QbzLibrary.togglePin("artist", artist.id, artist.name, "", artist.artUrl)
|
||
} else if (modelData.action === "scene") {
|
||
root.openArtistScene()
|
||
} else if (modelData.action === "disco") {
|
||
// Discography Builder — ArtistPageView.slint
|
||
// :505-511 `media-action("artist", id,
|
||
// "build-collection")`. This is the ONLY route
|
||
// to it; the nav flyout has no builder entry.
|
||
QbzDisco.open(artist.id)
|
||
} else if (modelData.action === "share") {
|
||
// ArtistPageView.slint:530-538 -> main.rs
|
||
// :12749 `media-action("artist", id,
|
||
// "share")`. The bridge builds the
|
||
// play.qobuz.com/artist/{id} URL, copies it
|
||
// and raises the "Link copied" toast, exactly
|
||
// as the .slint arm does. Nothing is copied
|
||
// QML-side here (unlike TrackRow.qml's
|
||
// Loader/TextEdit idiom): cxx-qt-lib exposes
|
||
// no QClipboard, and only Rust can publish
|
||
// QbzShell.toastJson, so the copy and its
|
||
// confirmation stay on one side.
|
||
//
|
||
// Link-only — artists have no Song.link path
|
||
// (share.rs's Song.link resolvers serve the
|
||
// track and album menus).
|
||
QbzArtist.share(artist.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||
// ArtistPageView.slint:557-572 — the 1px border-subtle separator
|
||
// above, then the LAST item, whose label flips
|
||
// "Show artist" / "Blacklist artist" on ArtistState.is-blacklisted.
|
||
// LIVE since QbzBlacklist landed (`artistToggle(id, name)`); it was
|
||
// dimmed-and-inert only while the bridge had no invokable.
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 33
|
||
radius: 5
|
||
color: blkArea.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
|
||
width: parent.width - 23
|
||
text: root.artistBlacklisted
|
||
? QbzSession.tr("Show artist", QbzSession.trRev)
|
||
: QbzSession.tr("Blacklist artist", QbzSession.trRev)
|
||
color: theme.textSecondary
|
||
font.pixelSize: 13
|
||
verticalAlignment: Text.AlignVCenter
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
MouseArea {
|
||
id: blkArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
overflowMenu.close()
|
||
root.toggleBlacklist()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Popular Tracks ⋯ menu ---------------------------------------------
|
||
QbzContextMenu {
|
||
id: topMenu
|
||
menuWidth: 224
|
||
/// Opened from the library tab's Tracks header: the five rows act on
|
||
/// `root.libTrackIds()` through the library seams instead of the
|
||
/// TOP_QUEUE ones. Set by whichever ⋯ button opens the menu.
|
||
property bool forLibrary: false
|
||
Repeater {
|
||
model: [
|
||
{ "label": QbzSession.tr("Play all next", QbzSession.trRev), "icon": "list-start", "action": "next-all" },
|
||
{ "label": QbzSession.tr("Play all later", QbzSession.trRev), "icon": "list-plus", "action": "later-all" },
|
||
{ "label": QbzSession.tr("Add all to queue", QbzSession.trRev), "icon": "list-end", "action": "queue-all" },
|
||
{ "label": QbzSession.tr("Shuffle all", QbzSession.trRev), "icon": "shuffle", "action": "shuffle-all" },
|
||
{ "label": QbzSession.tr("Add all to playlist", QbzSession.trRev), "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
|
||
// Library arm: the same five rows over the
|
||
// library tab's list — shuffle through the
|
||
// library play-all seam, the three enqueues
|
||
// through the bulk funnel with the whole list,
|
||
// and the picker over that list (the
|
||
// `playlist-all` branch below reads the mode).
|
||
if (topMenu.forLibrary && a !== "playlist-all") {
|
||
var lib = root.libTrackIds()
|
||
if (lib.length === 0) return
|
||
if (a === "shuffle-all") QbzLibrary.libraryPlayAll(JSON.stringify(lib), true)
|
||
else if (a === "next-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "play-next", "artist", String(artist.id || ""))
|
||
else if (a === "later-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "play-later", "artist", String(artist.id || ""))
|
||
else if (a === "queue-all") QbzPlayer.bulkTracksAction(JSON.stringify(lib), "queue", "artist", String(artist.id || ""))
|
||
return
|
||
}
|
||
if (a === "shuffle-all") QbzPlayer.playArtistTop(true)
|
||
// "Play all next" INSERTS at the cursor; it does
|
||
// not replay the artist. It used to call
|
||
// playArtistTop(false) — "Play all" — so the row
|
||
// labelled "next" wiped the queue and started
|
||
// over (ArtistPageView's twin in Slint enqueues,
|
||
// main.rs:15301).
|
||
else if (a === "next-all") QbzPlayer.enqueueArtistTop("next")
|
||
// #442 block tail — enqueue_track_list_mode's
|
||
// "later" arm, same funnel next-all/queue-all use.
|
||
else if (a === "later-all") QbzPlayer.enqueueArtistTop("later")
|
||
else if (a === "queue-all") QbzPlayer.enqueueArtistTop("queue")
|
||
else if (a === "playlist-all") {
|
||
// ArtistPageView.slint:797-802
|
||
// `top-tracks-menu-action("playlist-all")` —
|
||
// the picker over the section's tracks. `root
|
||
// .topTracks` is the FILTERED list, which is
|
||
// what `ArtistState.top-tracks` holds too
|
||
// (artist.rs filters into the state, the view
|
||
// never sees the raw list). Every row here is
|
||
// a Qobuz catalog track (`/artist/page`
|
||
// top_tracks), so the catalog arm is correct.
|
||
var ids = []
|
||
if (topMenu.forLibrary) ids = root.libTrackIds()
|
||
else for (var i = 0; i < root.topTracks.length; i++)
|
||
ids.push(String(root.topTracks[i].id))
|
||
if (ids.length > 0)
|
||
QbzPlaylistPicker.openForTracks(JSON.stringify(ids))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Artist portrait: menu + lightbox -----------------------------------
|
||
// View-root siblings, never descendants of the portrait frame (the frame
|
||
// is a 200px circle; anything inside it would be masked away).
|
||
|
||
/// Best available portrait source for the lightbox: the custom override
|
||
/// first, then the file the artwork pipeline ALREADY downloaded for this
|
||
/// page, then the remote URL as a last resort.
|
||
///
|
||
/// Deliberate divergence from AlbumView.bestCoverSource(), which goes
|
||
/// straight to the remote URL and re-downloads a file it already has on
|
||
/// disk. Preferring the cache also matters more here: the artist `large`
|
||
/// segment is the ORIGINAL upload (measured on this machine's cache:
|
||
/// n=149, median 1439px, max 5679px), so the fetch it avoids is a big one.
|
||
function bestArtistImage() {
|
||
var custom = artist.customImageUrl || ""
|
||
if (custom !== "") return custom
|
||
var cached = root.coverMap[artist.artUrl] || ""
|
||
if (cached !== "") return cached
|
||
return artist.artUrl || ""
|
||
}
|
||
|
||
/// The portrait menu's rows, rebuilt per open so Add/Change/Remove track
|
||
/// the live flag (ArtistPageView.slint:307-351, plus "View image" — the
|
||
/// lightbox entry, this port's addition and the twin of the album menu's
|
||
/// "View cover").
|
||
///
|
||
/// The two url-only rows are hidden when there is nothing for them to act
|
||
/// on. The reference passes `ArtistState.artwork-url` blindly and shows
|
||
/// them even for an artist Qobuz has no portrait for, where they do
|
||
/// nothing — a small, deliberate divergence rather than a copied hole.
|
||
/// "Save as…" survives an empty url when a custom image exists, because
|
||
/// that path saves the local file.
|
||
function buildPortraitMenuModel() {
|
||
var rows = []
|
||
if (artist.hasCustomImage === true) {
|
||
rows.push({ "label": QbzSession.tr("Change image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
||
rows.push({ "label": QbzSession.tr("Remove image", QbzSession.trRev), "icon": "trash-2", "action": "remove" })
|
||
} else {
|
||
rows.push({ "label": QbzSession.tr("Add image", QbzSession.trRev), "icon": "image-plus", "action": "add" })
|
||
}
|
||
if (root.bestArtistImage() !== "")
|
||
rows.push({ "label": QbzSession.tr("View image", QbzSession.trRev), "icon": "eye", "action": "view" })
|
||
if ((artist.artUrl || "") !== "")
|
||
rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" })
|
||
if ((artist.artUrl || "") !== "" || artist.hasCustomImage === true)
|
||
rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" })
|
||
return rows
|
||
}
|
||
|
||
QbzContextMenu {
|
||
id: portraitMenu
|
||
menuWidth: 196
|
||
onAboutToShow: portraitMenuRepeater.model = root.buildPortraitMenuModel()
|
||
Repeater {
|
||
id: portraitMenuRepeater
|
||
model: []
|
||
delegate: Rectangle {
|
||
required property var modelData
|
||
width: parent ? parent.width : 0
|
||
height: 33
|
||
radius: 5
|
||
color: pmiArea.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: pmiArea
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
portraitMenu.close()
|
||
var a = modelData.action
|
||
// The store is keyed by NAME (ArtistPageView.slint:312),
|
||
// so the name — not the id — is what travels.
|
||
if (a === "add") QbzArtist.imageAddCustom(artist.name || "", artist.artUrl || "")
|
||
else if (a === "remove") QbzArtist.imageRemoveCustom(artist.name || "", artist.artUrl || "")
|
||
else if (a === "view") portraitLightbox.openWith(root.bestArtistImage())
|
||
else if (a === "browser") QbzShell.openExternalUrl(artist.artUrl)
|
||
else if (a === "save") QbzArtist.imageSaveAs(artist.name || "", artist.artUrl || "")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
CoverLightbox { id: portraitLightbox }
|
||
}
|