// 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: , /// at: }. property var releaseFade: ({}) /// ---- The appended pages themselves ----------------------------------- /// releaseType -> { cards: [], /// hasMore: , /// pages: }. /// /// 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: , /// next: }. /// /// 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 } }