// Album detail page — QML port of album/AlbumPageView.slint. // // Header (224px cover, title, credited-artist line, meta with label link, // description + Read more, CircleAction row), divider, toolbar (quality // badge + track search), column header, track list (Disc/work headers, // TrackRow replica with the playing-row pill + number↔play cell + live // heart), label/awards sidebar, and the two bottom carousels ("From the // same artist", "Listening suggestions"). // // POC-NOTEs: the offline download column (TrackRow status glyphs + ⋯ menu // rows) and multi-select + bulk bar are LIVE. Still out: DiscHeaderMenu // per-disc actions. // // Header atmosphere (AlbumPageView.slint:161-189, 221-257): the // artwork-tinted band IS wired now, through the shared // controls/HeaderGradient.qml (route B — see that file for why the blurred // route is not available on this path). It brings the .slint's header-colour // rules with it: with the band on, the header sits on a DARK backdrop, so // the text goes light regardless of theme (`hdrStrong` / `hdrBody`, // .slint:169-172) and the CircleActions switch to their overlay palette // (`hdrOverlay` = the .slint's inverted `hdr-on-surface`, :179). import QtQuick import QtQuick.Controls import QtQuick.Window import Qt.labs.qmlmodels import com.blitzfc.qbz 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 } /// Header cover edge. One constant because the real header, its skeleton /// twin and both text columns all measure off it — they drifted apart at /// five separate literals before. The Row has no explicit height, so the /// header grows with this on its own. readonly property int coverPx: compactHeaderPref ? 112 : 260 readonly property int headerGapPx: compactHeaderPref ? 20 : 32 /// How far past the header divider the artwork atmosphere reaches: the 8px /// spacer below the divider plus half of the 52px track toolbar band, so /// the gradient dies across the middle of the quality chip and the search /// box instead of stopping at the divider. readonly property int atmoReach: 1 + 8 + 26 // The view's album + url-keyed cover map (artwork pipeline). // // Applied imperatively so the page can tell a SAME-ALBUM deferred rail // publish (Similar albums / Suggestions / More from this artist landing // while the user reads: the viewport stays put) from a NEW album (the // track filter resets and the page starts at the top). The tape itself is // swapped on `cellsModel`, a QbzArrayModel: reassigning the ListView's // `model` would reset contentY on every publish and, on Qt 6.11, focus a // delegate — see that control's header for both. property var album: ({}) property string documentAlbumId: "" function parseAlbumDocument() { try { return JSON.parse(QbzAlbum.albumJson) } catch (e) { return ({}) } } function applyAlbumDocument() { var next = root.parseAlbumDocument() var nextHeader = next.header || ({}) var nextId = nextHeader.id !== undefined && nextHeader.id !== null ? String(nextHeader.id) : "" var sameAlbum = nextId !== "" && nextId === root.documentAlbumId if (!sameAlbum && root.documentAlbumId !== "") { // A different release replacing the one on screen (the empty // document a load publishes first counts): its track list starts // unfiltered — the header's search box mirrors `trackQuery` — and // at the top. The first document of a mount leaves the viewport // alone: ScrollMemory may be restoring a Back navigation. root.trackQuery = "" cellsModel.scrollToTop() } root.album = next if (nextId !== "") root.documentAlbumId = nextId } readonly property var albumHeader: album.header || ({}) /// The "cannot show this release" arm of the document (album_qt::publish_unavailable). readonly property var unavailable: album.unavailable || null readonly property var tracks: album.tracks || [] readonly property var purchase: album.purchase || ({}) readonly property var localPurchaseVariants: purchase.localVariants || [] readonly property bool showPlaybackSource: { for (var i = 0; i < localPurchaseVariants.length; i++) { if (localPurchaseVariants[i].selectable === true) return true } return false } function albumTrackIdsJson() { var ids = [] for (var i = 0; i < root.tracks.length; i++) { var id = String(root.tracks[i].id || "") if (/^\d+$/.test(id)) ids.push(id) } return JSON.stringify(ids) } function playbackSourceOptions() { var out = [{ "label": QbzSession.tr("Qobuz · stream/offline copy", QbzSession.trRev), "mode": "qobuz", "formatId": 0, "enabled": true }] var selected = Number(root.purchase.selectedFormatId || 0) for (var i = 0; i < root.localPurchaseVariants.length; i++) { var variant = root.localPurchaseVariants[i] if (variant.selectable !== true && Number(variant.formatId) !== selected) continue var label = QbzSession.tr("Local purchase", QbzSession.trRev) + " · " + (variant.label || String(variant.formatId)) if (variant.selectable !== true) label += " · " + QbzSession.tr("Unavailable", QbzSession.trRev) out.push({ "label": label, "mode": "purchase", "formatId": Number(variant.formatId), "enabled": variant.selectable === true }) } return out } function playbackSourceIndex(options) { if ((root.purchase.playbackMode || "qobuz") !== "purchase") return 0 var selected = Number(root.purchase.selectedFormatId || 0) for (var i = 1; i < options.length; i++) { if (Number(options[i].formatId) === selected) return i } return 0 } readonly property bool purchaseStatusVisible: { var state = root.purchase.entitlementState || "" if (state === "owned-album" || state === "owned-tracks") return true if (state !== "stale") return false var known = root.purchase.lastKnownEntitlementState || "" return known === "owned-album" || known === "owned-tracks" } readonly property string purchaseStatusText: { var known = root.purchase.entitlementState === "stale" ? root.purchase.lastKnownEntitlementState : root.purchase.entitlementState if (known === "owned-tracks") { var n = Number(root.purchase.purchasedTrackCount || 0) return n === 1 ? QbzSession.tr("1 track purchased", QbzSession.trRev) : QbzSession.tr("{} tracks purchased", QbzSession.trRev) .replace("{}", String(n)) } return QbzSession.tr("Purchased", QbzSession.trRev) } function selectedPlaybackVariant() { if ((root.purchase.playbackMode || "qobuz") !== "purchase") return null var selected = Number(root.purchase.selectedFormatId || 0) for (var i = 0; i < root.localPurchaseVariants.length; i++) { if (Number(root.localPurchaseVariants[i].formatId) === selected) return root.localPurchaseVariants[i] } return null } function playbackQualityTier() { var variant = root.selectedPlaybackVariant() if (!variant) return albumHeader.qualityTier || "" var formatId = Number(variant.formatId || 0) if (formatId === 56 || formatId === 55) return "dsd" if (formatId === 27 || formatId === 7) return "hires" if (formatId === 6) return "cd" if (formatId === 5) return "mp3" return albumHeader.qualityTier || "" } function playbackQualityDetail() { var variant = root.selectedPlaybackVariant() return variant ? (variant.label || "") : (albumHeader.qualityDetail || "") } function selectPlaybackSource(option) { if (!option || option.enabled !== true) return var currentMode = root.purchase.playbackMode || "qobuz" var currentFormat = Number(root.purchase.selectedFormatId || 0) if (option.mode === currentMode && (option.mode !== "purchase" || Number(option.formatId) === currentFormat)) return QbzPurchases.setAlbumPlaybackMode( albumHeader.id, option.mode, Number(option.formatId), root.albumTrackIdsJson()) } // One global opt-in for every album page. `settingsJson` is the live // channel (including clicks made in another open AlbumView); the album // document carries the persisted cold-start fallback until that snapshot // arrives. readonly property bool compactHeaderPref: { var raw = QbzBridge.settingsJson if (raw && raw.length > 2) { try { var d = JSON.parse(raw) if (d.compactAlbumHeader !== undefined) return d.compactAlbumHeader === true } catch (e) { /* fall through to the document copy */ } } return album.compactHeader === true } property bool headerLayoutReady: false property int headerLayoutEpoch: 0 property real headerSavedOffset: 0 property bool headerSnapshotValid: false function toggleCompactHeader() { // Capture before settingsJson changes: dependent geometry bindings and // this property's change handler have no guaranteed observer order. root.headerSavedOffset = pageFlick.contentY - pageFlick.originY root.headerSnapshotValid = true QbzBridge.settingsBool("compact-album-header", !root.compactHeaderPref) } onCompactHeaderPrefChanged: { if (!root.headerLayoutReady) return // An inline ListView header changes originY with its height. Keep the // viewport's offset from that origin so expanding pins the visible top // edge and grows the header downward instead of behind the viewport. var savedOffset = root.headerSnapshotValid ? root.headerSavedOffset : pageFlick.contentY - pageFlick.originY root.headerSnapshotValid = false var epoch = ++root.headerLayoutEpoch Qt.callLater(function () { if (epoch !== root.headerLayoutEpoch) return pageFlick.forceLayout() var minY = pageFlick.originY var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height) pageFlick.contentY = Math.max(minY, Math.min(minY + savedOffset, maxY)) }) } // `header.awards`, NOT `album.awards`. It is a field of AlbumHeader // (src/album_qt.rs:171), not of AlbumViewData — read one level too high it // is `undefined`, `|| []` swallows that, and the AWARDS block simply never // renders. Which is what it did, for every album, since the block was // written: an empty Repeater draws nothing and reports nothing, and // `hasSidebar` counted the same empty list, so an album whose ONLY sidebar // content was its awards showed no sidebar at all. readonly property var awards: albumHeader.awards || [] property var coverMap: ({}) // Client-side track search (AlbumActions.search equivalent). property string trackQuery: "" // ---- Header atmosphere (AlbumPageView.slint:161-189) ----------------- // The pref, LIVE where possible: the settings snapshot is only published // on settings-view open / mutation, so on a cold start it is empty and // the document's own copy (album_qt.rs `headerGradient`) answers instead. 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 album.headerGradient !== false } // .slint:168 — the album's own atmosphere is SUPPRESSED under the // app-wide dynamic background (they clash); the dynamic background then // provides the dark backdrop instead. readonly property bool headerAtmoOn: headerGradientPref && !ambientOn // .slint:167 — dark backdrop from EITHER source means light header text. readonly property bool headerLight: headerGradientPref || ambientOn readonly property color hdrStrong: headerLight ? "#ffffff" : theme.textPrimary readonly property color hdrBody: headerLight ? "#e0ffffff" : theme.textSecondary // (the .slint declares an `hdr-muted` tier too, :173, but never binds it // to anything — not ported rather than ported dead) // .slint:179 — with no dark backdrop the circles use the on-surface arm. readonly property bool hdrOverlay: headerLight readonly property var visibleTracks: { if (trackQuery === "") return tracks var q = trackQuery.toLowerCase() return tracks.filter(function (t) { return t.title.toLowerCase().indexOf(q) >= 0 }) } // ---- Loading staging (album_qt.rs publishes in passes) --------------- // The PRIMARY document (header + tracks) lands as soon as /album/get // answers; each bottom rail arrives later carrying its own flag, so the // page is usable while Qobuz suggestions and the Last.fm row resolve. // // A rail is shown when it HAS cards, its placeholder when its flag is // still up. Both false = the section is ABSENT: `moreLoading` is seeded // false when the album has no artist id and `similarLoading` false when // Last.fm is not connected, so nothing ever spins forever on a row that // will never arrive. readonly property bool primaryLoading: QbzAlbum.albumLoading && tracks.length === 0 readonly property bool moreLoading: album.moreLoading === true && (album.moreFromArtist || []).length === 0 readonly property bool suggestionsLoading: album.suggestionsLoading === true && (album.suggestions || []).length === 0 readonly property bool similarLoading: album.similarLoading === true && (album.similarAlbums || []).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.moreLoading || root.suggestionsLoading || root.similarLoading) onTriggered: on = !on } // Placeholder cards that fill one rail (SectionRail's 232px pitch). readonly property int railSkeletonCount: Math.max(1, Math.min(8, Math.ceil((root.width - 64) / 232))) // The vertical ListView gets ONE uniform 10px layout cell per slice of // content. A visual item lives in the first cell of its run and paints // over the following inert cells (50px row = 5 cells, 40px header = 4). // Uniform delegate height is deliberate: Qt estimates contentHeight for // variable delegates/sections, which makes a scrollbar thumb resize and // skip as new sizes are discovered. Here count * 10 is exact from frame 1. readonly property int listCellPx: 10 readonly property int trackRowPx: 50 readonly property int trackHeaderPx: 40 // SectionRail/RailSkeleton are 286px. Reserve 290 so the tape stays on // the 10px grid; the final 4px is harmless air before the next section. readonly property int railSlotPx: 290 function appendRun(out, head, pixels) { out.push(head) for (var y = root.listCellPx; y < pixels; y += root.listCellPx) out.push({ "kind": "gap" }) } function buildTrackCells() { var out = [] var list = root.visibleTracks var multi = list.length > 0 && (list[list.length - 1].disc || 1) > 1 for (var i = 0; i < list.length; i++) { var t = list[i] var prev = i > 0 ? list[i - 1] : null var disc = (i === 0) ? (multi ? (t.disc || 1) : 0) : (multi && t.disc !== prev.disc ? (t.disc || 1) : 0) var work = (i === 0 || t.workHeader !== prev.workHeader) ? (t.workHeader || "") : "" if (disc > 0) root.appendRun(out, { "kind": "disc", "disc": disc }, root.trackHeaderPx) if (work !== "") { root.appendRun(out, { "kind": "work", "work": work, "composerName": t.workComposerName || "", "composerId": t.workComposerId || "" }, root.trackHeaderPx) } root.appendRun(out, { "kind": "track", "track": t, "trackNumber": i + 1 }, root.trackRowPx) } return out } readonly property var trackCells: buildTrackCells() readonly property int trackTapePx: trackCells.length * listCellPx function appendRailCells(out, kind, loading, items) { var rows = items || [] if (!loading && rows.length === 0) return root.appendRun(out, { "kind": "gap" }, 40) root.appendRun(out, { "kind": loading ? "railSkeleton" : kind, "items": rows }, root.railSlotPx) } readonly property var listCells: { // Copy: the deferred rail passes rebuild this array without mutating // the track-only value used by the header/sidebar overlap calculation. var out = root.trackCells.slice(0) root.appendRailCells(out, "moreRail", root.moreLoading, root.album.moreFromArtist || []) root.appendRailCells(out, "suggestionsRail", root.suggestionsLoading, root.album.suggestions || []) root.appendRailCells(out, "similarRail", root.similarLoading, root.album.similarAlbums || []) return out } Connections { target: QbzLibrary function onLibraryArtworkReady(key, path) { var m = root.coverMap m[key] = path root.coverMap = Object.assign({}, m) } // The SETTLED heart from Rust: the flipped value when the write // landed, the UNCHANGED one when it failed. The header click writes // its optimistic flip into localToggles and nothing used to correct // it, so a 404'd un-favorite stayed visibly un-favorited until the // user navigated away — LibraryView.qml was this signal's only // listener. Key shape is `library_qt::feed_key` (`{kind}:{id}`). function onLibraryFavoriteChanged(key, value) { var id = (albumHeader && albumHeader.id) ? albumHeader.id : "" if (id !== "" && key === "album:" + id) root.setToggleState("album", value) } } // Album-blacklist settle / rollback. `blacklistChanged` carries the state // the write actually produced — flipped on success, UNCHANGED on failure // (blacklist_qt.rs `album_toggle`), so this is both the cross-surface walk // (the manager's row `x`, a card's "Block this album") and the rollback for // the header menu's optimistic flip below. Same two-arg `{kind}:{id}` shape // as pinChanged / libraryFavoriteChanged above; a separate Connections // block only because the signal lives on a different singleton. Connections { target: QbzBlacklist function onBlacklistChanged(key, value) { var id = (albumHeader && albumHeader.id) ? albumHeader.id : "" if (id !== "" && key === "album:" + id) root.setToggleState("blocked", value) } } Component.onCompleted: { root.applyAlbumDocument() syncAlbumState() dispatchCovers() root.headerLayoutReady = true } Connections { target: QbzAlbum function onAlbumJsonChanged() { root.applyAlbumDocument() } } onAlbumChanged: { syncAlbumState(); dispatchCovers() } // The derived binding settles AFTER onAlbumChanged fires (stale race) — // redispatch when the header itself updates. onAlbumHeaderChanged: { syncAlbumState(); dispatchCovers() } // Optimistic heart / pin state. The document is republished once per // deferred rail now, and every republish re-parses `album` — a toggle // written straight onto the parsed object would silently pop back a // second later. Overrides live here and win until the album changes. // (Same pattern, same reason, as ArtistView.localToggles.) 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) } // Per-album view state is reset ONLY when the id actually changes, or a // deferred rail landing would yank the user's toggles back mid-read. property string loadedAlbumId: "" function syncAlbumState() { var id = (albumHeader && albumHeader.id) ? albumHeader.id : "" if (id === loadedAlbumId) return loadedAlbumId = id localToggles = ({}) dispatchedCovers = ({}) // A new album carries no selection over (Slint resets multi-select // on every album load/reset, album.rs:692-694). setMultiSelect(false) // Re-seed the live offline map from the new document (track rows // carry their own seeded cacheStatus; this map tracks the LIVE one). var seed = ({}) var ts = album.tracks || [] for (var i = 0; i < ts.length; i++) seed[ts[i].id] = ts[i].cacheStatus || 0 cacheStates = seed } // --- Multi-select (AlbumPageView.slint:736-807) ------------------------- // The selection lives here in QML (LibraryView precedent): select-all and // clear never reach Rust, everything else goes down as a JSON id array // through QbzAlbum.albumBulkAction. 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. `mods` is the mouse event's modifiers, forwarded by the row; a /// caller with no event (the checkbox, a keyboard path) may omit it. SelectionModel { id: sel } function toggleSelected(id, mods) { root.selected = sel.next(root.selected, id, root.visibleTracks, mods === undefined ? Qt.NoModifier : mods) } /// Selected ids in VISIBLE order — never Object.keys (integer-like keys /// iterate in NUMERIC order, not the user's). function selectedIdsInOrder() { var rows = root.visibleTracks var out = [] 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 rows = root.visibleTracks for (var i = 0; i < rows.length; i++) m[rows[i].id] = true root.selected = m return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return } var ids = root.selectedIdsInOrder() if (ids.length === 0) return QbzAlbum.albumBulkAction(albumHeader.id, JSON.stringify(ids), action) // The Slint keeps the selection while a picker is still open (a // failed write is retried from the same modal) and clears after // everything else. 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) } // --- Offline cache live state ------------------------------------------ // Row glyphs own their live status (TrackRow's Connections). This map // exists for ONE consumer: the ⋯ menu's "Make available offline" / // "Refresh offline copy" swap, which needs the all-rows-ready aggregate // (Slint recomputes album-fully-cached on every row flip, // main.rs:2426-2438). property var cacheStates: ({}) readonly property bool fullyCachedLive: { var ts = album.tracks || [] if (ts.length === 0) return false var m = cacheStates for (var i = 0; i < ts.length; i++) { var st = m[ts[i].id] if (st === undefined) st = ts[i].cacheStatus || 0 if (st !== 3) return false } return true } Connections { target: QbzShell function onTrackCacheStatusChanged(trackId, status, progress) { if (!(trackId in root.cacheStates)) return var m = root.cacheStates m[trackId] = status root.cacheStates = Object.assign({}, m) } } // Already-requested artwork keys. The document is now published in FOUR // passes (primary, then each rail), and every pass re-fires this — resending // the whole list each time is pure waste, so only what is new goes out. property var dispatchedCovers: ({}) function dispatchCovers() { var urls = [] if (albumHeader && albumHeader.artUrl) urls.push(albumHeader.artUrl) var more = album.moreFromArtist || [] for (var i = 0; i < more.length; i++) if (more[i].artUrl) urls.push(more[i].artUrl) var sug = album.suggestions || [] for (i = 0; i < sug.length; i++) if (sug[i].artUrl) urls.push(sug[i].artUrl) // The Last.fm row's covers ride the same dispatch — without this its // cards would render as empty frames. var sim = album.similarAlbums || [] for (i = 0; i < sim.length; i++) if (sim[i].artUrl) urls.push(sim[i].artUrl) var seen = dispatchedCovers var fresh = [] for (i = 0; i < urls.length; i++) { if (!seen[urls[i]]) { seen[urls[i]] = true fresh.push(urls[i]) } } if (fresh.length > 0) { dispatchedCovers = seen QbzShell.sidebarArtworkWindow(JSON.stringify(fresh)) } } // Ghost CircleAction (secondary, on-surface variant): elevated disc, // strong ring, text-primary icon (accent when active). // Track list row (TrackRow.slint replica: number cell, no artwork, // Sidebar label/award card (SidebarCard). component SidebarCard: Rectangle { id: sidebarCard property string name: "" property color gradA: "#6366f1" property color gradB: "#8b5cf6" property string iconName: "disc" property bool compact: false signal clicked() width: parent ? parent.width : 0 height: 48 radius: theme.radiusSm color: scArea.containsMouse ? theme.surfaceHover : "transparent" Row { x: sidebarCard.compact ? Math.round((sidebarCard.width - width) / 2) : 6 anchors.verticalCenter: parent.verticalCenter width: sidebarCard.compact ? 28 : parent.width - 12 height: parent.height - 12 spacing: 10 Rectangle { width: 28 height: 28 radius: 14 anchors.verticalCenter: parent.verticalCenter gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: gradA } GradientStop { position: 1.0; color: gradB } } QbzIcon { name: iconName width: 13 height: 13 anchors.centerIn: parent // On the card's fixed brand gradient disc (indigo/violet // or amber), never on a theme surface. tintName: "white" } } Text { visible: !sidebarCard.compact width: parent.width - 38 anchors.verticalCenter: parent.verticalCenter text: name color: theme.textSecondary font.pixelSize: 12 font.weight: theme.weightMedium wrapMode: Text.WordWrap } } MouseArea { id: scArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: parent.clicked() ToolTip.visible: sidebarCard.compact && containsMouse && sidebarCard.name !== "" ToolTip.text: sidebarCard.name ToolTip.delay: 350 } } component SidebarHeading: Text { property bool compact: false width: parent ? parent.width : implicitWidth color: theme.textMuted font.pixelSize: 10 font.weight: theme.weightSemibold font.letterSpacing: 1 horizontalAlignment: compact ? Text.AlignHCenter : Text.AlignLeft wrapMode: compact ? Text.WordWrap : Text.NoWrap } // Purchase entitlement mark used inside the album metadata flow. Keeping // the separator in the component makes the mark a peer of release date, // label, genre, track count and duration instead of a detached badge row. component PurchaseMetaMark: Row { property string label: "" property color foreground: "#e0b341" spacing: 6 Text { text: " • " color: parent.foreground font.pixelSize: theme.fontBody } QbzIcon { name: "circle-check-big" width: 15 height: 15 anchors.verticalCenter: parent.verticalCenter tintName: "amber" } Text { anchors.verticalCenter: parent.verticalCenter text: parent.label color: parent.foreground font.pixelSize: theme.fontLegal font.weight: Font.DemiBold } } // Placeholder for a bottom rail that has not resolved yet: the SAME 28px // header band, 232px pitch and 246px card band SectionRail uses, so the // page does not jump when the real cards replace it. Built out of the // shared QbzSkeleton — no local skeleton primitive. // // Everything it needs is a property, not a file-scope id: an inline // `component` does not see the enclosing document's ids (the gotcha // QbzSkeleton.qml documents), so `phase` is passed in by the host. component RailSkeleton: Column { id: railSk property bool phase: false property int cardCount: 4 width: parent ? parent.width : 0 spacing: 12 Item { width: parent.width height: 28 QbzSkeleton { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter variant: "block" width: 180 height: 20 phase: railSk.phase } } Item { width: parent.width height: 246 clip: true Row { spacing: 32 Repeater { model: railSk.cardCount // "card" carries its own 200 x (200+42) footprint. delegate: QbzSkeleton { required property int index variant: "card" cellIndex: index phase: railSk.phase } } } } } // One EXTERNAL LINKS brand icon (AlbumPageView.slint BrandLink): the bare // brand SVG in its NATIVE colors — no tint pass, no visible label, the // name lives in the hover tooltip (Feishin-style inline links). component BrandLink: Rectangle { property string iconSource: "" property string name: "" property string url: "" width: 30 height: 30 radius: 6 color: brandArea.containsMouse ? theme.surfaceHover : "transparent" Image { anchors.centerIn: parent source: iconSource width: 18 height: 18 sourceSize: Qt.size(36, 36) fillMode: Image.PreserveAspectFit opacity: brandArea.containsMouse ? 1.0 : 0.85 Behavior on opacity { NumberAnimation { duration: 120 } } } MouseArea { id: brandArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor // Deep link only — the browser does the work, nothing is fetched // here and no integration has to be connected. onClicked: if (url !== "") Qt.openUrlExternally(url) // The Slint BrandLink carries the name in the shared tooltip // bubble; the Qt port rides Qt's own ToolTip (LocalMultiSelectBar // precedent). ToolTip.visible: containsMouse && name !== "" ToolTip.text: name ToolTip.delay: 350 } } // Absolute qrc prefix for the brand SVGs — same rule as QbzIcon: a // relative URL resolves against the CONSUMER's document depth. readonly property string brandDir: "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/brand/" // Whether the right-hand album sidebar has anything to show at all. readonly property bool hasSidebar: (albumHeader.label || "") !== "" || awards.length > 0 || albumHeader.showExternalLinks === true // Same contract as ShellState.content-constrained in the Slint view: the // album sidebar gives the track table priority only when a right panel is // consuming a sub-1366px window. In that state it becomes the 56px icon // rail from AlbumPageView.slint; names remain available as tooltips. readonly property bool contentConstrained: Window.width > 0 && Window.width < 1366 && (QbzShell.queueOpen || QbzShell.lyricsOpen) readonly property bool sidebarCompact: contentConstrained readonly property int sidebarPx: sidebarCompact ? 56 : 200 readonly property int sidebarReservePx: hasSidebar ? sidebarPx + 32 : 0 // ============================ the page ================================ ListView { id: pageFlick anchors.fill: parent clip: true boundsBehavior: Flickable.StopAtBounds // The tape is swapped on this model, never on `model:` itself: a // fresh array assigned to the ListView after completion makes Qt 6.11 // force currentIndex 0 and focus delegate 0, which robbed the header's // track filter of the keyboard on every keystroke (2026-09-13). The // wrapper also keeps the viewport across a same-album rail publish; // applyAlbumDocument() asks for the top when the album changes. model: QbzArrayModel { id: cellsModel view: pageFlick rows: root.listCells } reuseItems: true currentIndex: -1 // The track tape is cheap enough that a fast wheel can consume one // viewport before the first heavy card rail has finished incubating. // Two viewports give those bottom rails roughly one kinetic-scroll // window of lead time. Cache delegates are asynchronous and pooled; // this does not restore the old eager footer mount. cacheBuffer: Math.max(900, 2 * height) header: Item { width: pageFlick.width height: page.implicitHeight // Artwork-tinted header band. FIRST child so it paints under the // page, and inside the ListView header so it scrolls with content. // Full-bleed on purpose: the page's 32px padding must NOT clip it. HeaderGradient { x: 0 y: 0 width: pageFlick.width // .slint:189 `atmo-height: page.y + header-divider.y` — the band // ends EXACTLY on the header/track-list divider, whatever height // a long editorial description gave the header. height: page.y + headerDivider.y + root.atmoReach tint: album.headerColor || "" atmosphere: album.headerAtmosphere || "" active: root.headerAtmoOn } Column { id: page width: parent.width leftPadding: 32 rightPadding: 32 topPadding: 11 spacing: 0 // NavButtons is a 0px placeholder in the Slint source. Item { width: 1; height: 22 } // The release cannot be shown (album_qt::publish_unavailable, // 2026-09-13): gone from the catalog, or a load failure. The // heading comes from the row that opened it, then the reason, and // for a gone release the best-ranked alternatives Qobuz still // sells — the same ranking the playlist replacement flow uses. Column { visible: root.unavailable !== null width: parent.width - 64 spacing: 12 Item { width: 1; height: 8 } Text { width: parent.width text: root.unavailable && (root.unavailable.title || "") !== "" ? root.unavailable.title : QbzSession.tr("This release", QbzSession.trRev) color: theme.textPrimary font.pixelSize: theme.fontTitle font.weight: theme.weightBold wrapMode: Text.WordWrap } Text { visible: root.unavailable && (root.unavailable.artist || "") !== "" width: parent.width text: root.unavailable ? (root.unavailable.artist || "") : "" color: theme.textSecondary font.pixelSize: theme.fontSection elide: Text.ElideRight } Row { width: parent.width spacing: 8 QbzIcon { name: "circle-alert" width: 18 height: 18 anchors.verticalCenter: parent.verticalCenter tintName: "muted" } Text { width: parent.width - 26 anchors.verticalCenter: parent.verticalCenter text: root.unavailable && root.unavailable.gone === true ? QbzSession.tr("This release is no longer available on Qobuz.", QbzSession.trRev) : QbzSession.tr("Couldn't load this release.", QbzSession.trRev) + ((root.unavailable && (root.unavailable.error || "") !== "") ? " " + root.unavailable.error : "") color: theme.textMuted font.pixelSize: theme.fontBody wrapMode: Text.WordWrap } } Item { width: 1; height: 8 } Text { visible: root.unavailable && root.unavailable.loading === true text: QbzSession.tr("Looking for other versions…", QbzSession.trRev) color: theme.textMuted font.pixelSize: theme.fontBody } SectionRail { visible: root.unavailable && (root.unavailable.alternatives || []).length > 0 width: parent.width title: QbzSession.tr("You might be interested in these instead", QbzSession.trRev) items: root.unavailable ? (root.unavailable.alternatives || []) : [] coverMap: root.coverMap } } // --- Album header skeleton ---------------------------------- // Mounted on the primary flag, and the real header is hidden by // the same flag: opening album B never renders a half-empty // header frame while B's document is in flight. Row { visible: root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx QbzSkeleton { variant: "block" width: root.coverPx height: root.coverPx blockRadius: 12 phase: skeletonPhase.on } Column { width: parent.width - root.coverPx - root.headerGapPx spacing: root.compactHeaderPref ? 4 : 12 Item { width: 1; height: root.compactHeaderPref ? 0 : 6 } QbzSkeleton { variant: "block"; width: Math.min(420, parent.width); height: root.compactHeaderPref ? 24 : 30; cellIndex: 0; phase: skeletonPhase.on } QbzSkeleton { visible: !root.compactHeaderPref; variant: "block"; width: Math.min(260, parent.width); height: 18; cellIndex: 1; phase: skeletonPhase.on } QbzSkeleton { variant: "block"; width: Math.min(340, parent.width); height: 14; cellIndex: 2; phase: skeletonPhase.on } Item { width: 1; height: root.compactHeaderPref ? 10 : 14 } Row { spacing: 12 Repeater { model: 4 delegate: QbzSkeleton { required property int index variant: "circle" width: root.compactHeaderPref ? 28 : 44 height: width cellIndex: index phase: skeletonPhase.on } } } } } // --- Album header ------------------------------------------- Row { visible: !root.primaryLoading && root.unavailable === null width: parent.width - 64 spacing: root.headerGapPx Rectangle { width: root.coverPx height: root.coverPx radius: 12 color: theme.surfaceElevated // No clip: RoundedImage confines itself on both arms; a clip is an // unconditional batch root. coverMenu and CoverLightbox are // view-root siblings, never descendants of this frame. RoundedImage { anchors.fill: parent // A custom cover override (shared custom_artwork // store) beats the url-keyed pipeline image. source: (albumHeader.customCoverPath || "") !== "" ? "file://" + albumHeader.customCoverPath : (root.coverMap[albumHeader.artUrl] || "") radius: 12 } // Left-click: the lightbox (NEW — Slint's left-click is // inert). Right-click: the cover menu (Slint // AlbumPageView.slint:300-370). MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: Qt.PointingHandCursor onClicked: function (mouse) { if (mouse.button === Qt.RightButton) { coverMenu.openAtCursor(coverMenuAnchor, mouse.x, mouse.y) } else { coverLightbox.openWith(root.bestCoverSource()) } } } Item { id: coverMenuAnchor; anchors.fill: parent } } Column { width: parent.width - root.coverPx - root.headerGapPx anchors.top: parent.top anchors.topMargin: 4 spacing: 0 Item { width: parent.width height: Math.max(albumTitle.implicitHeight, 32) Text { id: albumTitle visible: !root.compactHeaderPref anchors.left: parent.left anchors.right: headerModeButton.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: albumHeader.title || "" color: root.hdrStrong font.pixelSize: theme.fontSection font.weight: theme.weightBold elide: Text.ElideRight } Row { id: compactHeading visible: root.compactHeaderPref anchors.left: parent.left anchors.right: headerModeButton.left anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter spacing: 0 // The title's natural width, measured OUTSIDE the // Text: an elided Text recomputes its implicit size // after laying out at the bound width, so a `width` // that reads its own `implicitWidth` is a binding // loop (Qt reported it on every album open). TextMetrics { id: compactTitleMetrics font.pixelSize: theme.fontSection font.weight: theme.weightBold text: albumHeader.title || "" } Text { id: compactAlbumTitle width: (albumHeader.artist || "") === "" ? compactHeading.width : Math.min(Math.ceil(compactTitleMetrics.advanceWidth), Math.max(80, compactHeading.width * 0.62)) text: albumHeader.title || "" color: root.hdrStrong font.pixelSize: theme.fontSection font.weight: theme.weightBold elide: Text.ElideRight } Text { id: compactTitleSeparator visible: (albumHeader.artist || "") !== "" text: " • " color: root.hdrBody font.pixelSize: theme.fontSection font.weight: theme.weightBold } Text { width: Math.max(0, compactHeading.width - compactAlbumTitle.width - compactTitleSeparator.width) text: albumHeader.artist || "" color: compactArtistArea.containsMouse && (albumHeader.artistId || "") !== "" ? root.hdrStrong : root.hdrBody font.pixelSize: theme.fontSection font.weight: theme.weightBold elide: Text.ElideRight MouseArea { id: compactArtistArea anchors.fill: parent enabled: (albumHeader.artistId || "") !== "" hoverEnabled: true cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: QbzArtist.openArtist(albumHeader.artistId) } } } QbzIconButton { id: headerModeButton anchors.right: parent.right anchors.top: parent.top anchors.topMargin: -4 btnSize: root.compactHeaderPref ? 22 : 26 iconSize: root.compactHeaderPref ? 11 : 13 name: root.compactHeaderPref ? "maximize-2" : "minimize-2" tintOverride: root.hdrOverlay ? "white" : "" onClicked: root.toggleCompactHeader() HoverHandler { id: headerModeHover } ToolTip.visible: headerModeHover.hovered ToolTip.text: root.compactHeaderPref ? QbzSession.tr("Show full album header", QbzSession.trRev) : QbzSession.tr("Show compact album header", QbzSession.trRev) ToolTip.delay: 350 } } Item { width: 1; height: root.compactHeaderPref ? 2 : 4 } // Credited-artist line (links + role suffixes). Flow { visible: !root.compactHeaderPref width: parent.width spacing: 0 Repeater { model: albumHeader.credits || [] delegate: Row { required property var modelData required property int index spacing: 0 Text { visible: index > 0 text: " • " color: root.hdrBody font.pixelSize: theme.fontHeading font.weight: theme.weightBold } Text { text: modelData[0] color: creditArea.containsMouse && modelData[1] !== "" ? root.hdrStrong : root.hdrBody font.pixelSize: theme.fontHeading font.weight: theme.weightBold MouseArea { id: creditArea anchors.fill: parent enabled: modelData[1] !== "" hoverEnabled: true cursorShape: modelData[1] !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: QbzArtist.openArtist(modelData[1]) } } Text { visible: modelData[2] !== "" text: " (" + modelData[2] + ")" color: root.hdrBody font.pixelSize: theme.fontHeading } } } } Item { width: 1; height: root.compactHeaderPref ? 2 : 10 } // Meta line (label as a clickable link when navigable). Flow { spacing: 0 width: parent.width visible: !root.compactHeaderPref && (albumHeader.labelId || "") !== "" && (albumHeader.label || "") !== "" Text { visible: (albumHeader.metaPre || "") !== "" text: (albumHeader.metaPre || "") + " • " color: root.hdrBody font.pixelSize: theme.fontBody } Text { text: albumHeader.label || "" color: labelArea.containsMouse ? theme.accent : root.hdrBody font.pixelSize: theme.fontBody MouseArea { id: labelArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: QbzHome.openLabel(albumHeader.labelId) } } Text { visible: (albumHeader.metaPost || "") !== "" text: " • " + (albumHeader.metaPost || "") color: root.hdrBody font.pixelSize: theme.fontBody } PurchaseMetaMark { visible: root.purchaseStatusVisible label: root.purchaseStatusText foreground: "#e0b341" } } Flow { visible: !root.compactHeaderPref && ((albumHeader.labelId || "") === "" || (albumHeader.label || "") === "") width: parent.width spacing: 0 Text { text: albumHeader.infoLine || "" color: root.hdrBody font.pixelSize: theme.fontBody } PurchaseMetaMark { visible: root.purchaseStatusVisible label: root.purchaseStatusText foreground: "#e0b341" } } Flow { visible: root.compactHeaderPref width: parent.width spacing: 0 Text { text: albumHeader.compactInfoLine || "" color: root.hdrBody font.pixelSize: theme.fontBody } PurchaseMetaMark { visible: root.purchaseStatusVisible label: root.purchaseStatusText foreground: "#e0b341" } } // Editorial description + Read more. Item { visible: !root.compactHeaderPref && (albumHeader.description || "") !== "" width: 1 height: 12 } Text { visible: !root.compactHeaderPref && (albumHeader.description || "") !== "" width: parent.width text: albumHeader.descriptionShort || "" color: root.hdrBody font.pixelSize: theme.fontLegal wrapMode: Text.WordWrap } Item { visible: !root.compactHeaderPref && (albumHeader.description || "") !== (albumHeader.descriptionShort || "") width: 1 height: 4 } Text { visible: !root.compactHeaderPref && (albumHeader.description || "") !== (albumHeader.descriptionShort || "") 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) shell.openTextModal(QbzSession.tr("About this album", QbzSession.trRev), albumHeader.description || "") } } } Item { width: 1; height: root.compactHeaderPref ? 10 : 20 } // Action row — AlbumPageView.slint:504-640. One shared // CircleAction for every button including Play (the // hand-rolled 44px disc it used to be drifted from the // control on ring, hover and glyph tint); the palette arm // follows the header backdrop, exactly like the .slint's // `on-surface: root.hdr-on-surface`. Row { spacing: 12 QbzCircleAction { primary: true compactPrimary: root.compactHeaderPref diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay name: "play-fill" anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.playAlbum(albumHeader.id) } QbzCircleAction { name: "shuffle" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.playAlbumShuffled(albumHeader.id) } QbzCircleAction { id: playbackSourceButton visible: root.showPlaybackSource name: "cloud-sync" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay active: (root.purchase.playbackMode || "qobuz") === "purchase" || playbackSourceMenu.opened anchors.verticalCenter: parent.verticalCenter onClicked: playbackSourceMenu.openBelowLeft(playbackSourceButton) HoverHandler { id: playbackSourceHover } ToolTip.visible: playbackSourceHover.hovered ToolTip.text: QbzSession.tr("Play with", QbzSession.trRev) ToolTip.delay: 350 } QbzCircleAction { readonly property bool favorite: root.toggleState("album", albumHeader.isFavorite) name: favorite ? "heart-filled" : "heart" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay active: favorite anchors.verticalCenter: parent.verticalCenter onClicked: { root.setToggleState("album", !favorite) QbzLibrary.libraryToggleFavorite("album", albumHeader.id) } } // Radio: the Qobuz /radio/album endpoint, via the // For-You tile seam (foryou_qt::start_album_radio — // minimal track objects are enriched before play). QbzCircleAction { name: "radio" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay // A radio is a fetch + an enrich + a queue write, // and until the first track resolves nothing on // screen moves. The key is per-album so this disc // spins and the Discover rail's stations do not. loading: QbzHome.radioPending === "album:" + albumHeader.id anchors.verticalCenter: parent.verticalCenter onClicked: QbzHome.startAlbumRadio(albumHeader.id) } // Booklet: only when the album carries a PDF goody // (AlbumPageView.slint:575-585); downloads via save-as. QbzCircleAction { visible: albumHeader.hasBooklet === true name: "book-open" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: QbzAlbum.downloadBooklet() } // Mixtape/Collection picker (an album is accepted by // BOTH kinds — myqbz_add_qt's Accepts engine). QbzCircleAction { name: "cassette-tape" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: QbzAlbum.addToMixtape(albumHeader.id) } // Album info: the Credits/Review modal // (AlbumInfoModal.qml, data via album_info_qt.rs). QbzCircleAction { name: "info" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: albumInfo.openFor(albumHeader.id) } // "Buy on Qobuz" (2026-09-13): only when the catalog // says the release is sold and it is not already // owned; a link to the store page, not a purchase here. QbzCircleAction { id: buyButton visible: albumHeader.purchasable === true && (root.purchase.entitlementState || "") !== "purchased" name: "shopping-bag" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: QbzAlbum.buyAlbum(albumHeader.id) HoverHandler { id: buyHover } ToolTip.visible: buyHover.hovered ToolTip.text: QbzSession.tr("Buy on Qobuz", QbzSession.trRev) ToolTip.delay: 350 } QbzCircleAction { id: albumMenuBtn name: "ellipsis" diameterOverride: root.compactHeaderPref ? 28 : 0 overlay: root.hdrOverlay anchors.verticalCenter: parent.verticalCenter onClicked: function (mouse) { albumMenu.openAtCursor(albumMenuBtn, mouse.x, mouse.y) } } } } } Item { width: 1; height: 20 } // Header divider. The gradient band above sizes itself to THIS // item's y (.slint:189 atmo-height), so it keeps its id. // Kept, deliberately invisible. The gradient band above sizes itself // off THIS item's y (.slint:189 atmo-height) and the whole column // below is positioned after it, so removing the item would move the // page; only the paint is unwanted. Rectangle { id: headerDivider; width: parent.width - 64; height: 1; color: "transparent" } Item { width: 1; height: 8 } // --- Track list + label/awards sidebar ---------------------- Row { id: trackChromeRow width: parent.width - 64 // Usually only the toolbar/header chrome contributes to the // ListView header. The sidebar is allowed to paint beside the // recycled rows; for a very short album reserve just enough // extra height to keep the first full-width rail below it. height: Math.max(trackChrome.implicitHeight, albumSidebar.implicitHeight - root.trackTapePx) spacing: 32 Column { id: trackChrome width: parent.width - root.sidebarReservePx spacing: 0 // Track-list placeholder — same flag the spinner used, // now in the shape of the list it is standing in for // (toolbar band + column-header band + 8 rows at the // TrackRow 50px pitch), so nothing shifts on arrival. Column { visible: root.primaryLoading width: parent.width spacing: 0 Item { width: 1; height: 52 } Item { width: 1; height: 40 } Repeater { model: 8 delegate: Item { required property int index width: parent ? parent.width : 0 height: 50 QbzSkeleton { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 12 variant: "block" width: 20 height: 12 cellIndex: index phase: skeletonPhase.on } QbzSkeleton { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 60 variant: "block" width: Math.max(90, parent.width * 0.36) height: 14 cellIndex: index phase: skeletonPhase.on } QbzSkeleton { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right anchors.rightMargin: 128 variant: "block" width: 52 height: 12 cellIndex: index phase: skeletonPhase.on } QbzSkeleton { anchors.verticalCenter: parent.verticalCenter anchors.right: parent.right anchors.rightMargin: 48 variant: "block" width: 52 height: 12 cellIndex: index phase: skeletonPhase.on } } } } // Multi-select bulk bar (controls/QbzMultiSelectBar.qml), // INLINE at the top of the track column — the Local // Library album view's layout (bar in flow, content below) // instead of a floating overlay. QbzMultiSelectBar { id: bulkBar visible: root.multiSelect && !root.primaryLoading && root.unavailable === null width: parent.width selectedCount: root.selectedCount // The reference's full AlbumView inventory // (AlbumPageView.slint:792-807), make-offline included. 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-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) } } // Toolbar — quality badge + track search (+ inert select). Row { visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width height: 52 spacing: 16 // AlbumPageView.slint:692 mounts QualityBadgeFull — // the contained chip (format mark + tier label over // the exact bit-depth/rate line), NOT a loose mark // plus a plain "16-bit / 44.1 kHz" string. The 1:1 // control already exists; this drew its own. QualityBadgeFull { id: qualityRow anchors.verticalCenter: parent.verticalCenter tier: root.playbackQualityTier() detail: root.playbackQualityDetail() } // Clamped, and the badge slot only counts when the // badge is actually there (QualityBadgeFull hides // itself on an empty tier — an unclamped negative // width is a silent layout trap). Item { width: Math.max(0, parent.width - (qualityRow.visible ? qualityRow.width + 16 : 0) - 168 - 30 - 2 * 16) height: 1 } // Track filter (QbzSearchField: Escape clears + blurs, // clear cross). The keyboard survives the tape rebuild // because the tape lives on a QbzArrayModel. QbzSearchField { id: trackSearch width: 168 height: 34 anchors.verticalCenter: parent.verticalCenter placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev) onEdited: function (text) { root.trackQuery = text } // A new release resets `trackQuery` // (applyAlbumDocument); the box follows, so no // stale query outlives the album it was typed on. Connections { target: root function onTrackQueryChanged() { if (trackSearch.text !== root.trackQuery) trackSearch.text = root.trackQuery } } } // Multi-select toggle (AlbumPageView.slint:752-787): // accent border + tint while active; leaving the mode // clears the selection. Rectangle { width: 30 height: 30 radius: 6 anchors.verticalCenter: parent.verticalCenter color: selectToggleArea.containsMouse ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: root.multiSelect ? theme.accent : theme.borderSubtle QbzIcon { name: "square-check-big" width: 15 height: 15 anchors.centerIn: parent tintName: root.multiSelect ? "accent" : "secondary" } MouseArea { id: selectToggleArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.setMultiSelect(!root.multiSelect) } } } // Column header — rows/TrackListHeader.qml, i.e. the SAME // rows/TrackCols.qml geometry the TrackRows below use. // // What was here disagreed with the rows on every number: // spacing 16 vs the row's 14, Duration 80 vs 70, Quality // 80 vs 92, and a title width that counted five gaps // where the layout draws six. Those are Slint's own // header numbers (AlbumPageView.slint:811-880) and they // do NOT match primitives/TrackRow.slint — the reference // has the same defect the owner reported here, so the // port keeps the row's numbers and drops the second // hardcoded copy entirely. // // The heart / cloud glyphs stay (they are the only // labelling those two columns get) — and they still fill // the band so `centerIn` centres them, which is the fix // this block used to document at length. TrackListHeader { visible: !QbzAlbum.albumLoading && root.unavailable === null width: parent.width bandHeight: 40 labelSpacing: 0.5 showDownload: true favoriteGlyph: true downloadGlyph: true downloadActionEnabled: (albumHeader.id || "") !== "" && (root.album.tracks || []).length > 0 && QbzSession.offlineMode === 0 downloadActionLoading: QbzOffline.collectionPreflightLoading && QbzOffline.collectionPreflightKey === "album:" + (albumHeader.id || "") onDownloadClicked: QbzOffline.cacheAlbum(albumHeader.id) } } // Label / awards / external-links sidebar (200px). Column { id: albumSidebar visible: root.hasSidebar width: root.sidebarPx spacing: 24 Column { visible: (albumHeader.label || "") !== "" width: parent.width spacing: 8 SidebarHeading { compact: root.sidebarCompact text: QbzSession.tr("LABEL", QbzSession.trRev) } SidebarCard { name: albumHeader.label || "" iconName: "disc" compact: root.sidebarCompact gradA: "#6366f1" gradB: "#8b5cf6" // The label page HAS existed for a long time — the // header chip above (:691) has been opening it all // along. This card carried a "no label view yet" // note and no handler, so the sidebar's LABEL entry // looked clickable (it hovers) and did nothing. onClicked: if ((albumHeader.labelId || "") !== "") QbzHome.openLabel(albumHeader.labelId) } } Column { visible: awards.length > 0 width: parent.width spacing: 8 SidebarHeading { compact: root.sidebarCompact text: QbzSession.tr("AWARDS", QbzSession.trRev) } Repeater { model: awards delegate: SidebarCard { required property var modelData name: modelData[1] iconName: "award" compact: root.sidebarCompact gradA: "#b45309" gradB: "#eab308" // `modelData` is the (id, name) pair // album_qt publishes. Qobuz omits the id on // some /album/get entries, so with one we open // directly and without one we hand the NAME to // the resolver — 1:1 with the reference's // "open" / "resolve-open" split // (AlbumPageView.slint:1049-1055). onClicked: { if ((modelData[0] || "") !== "") QbzHome.openAward(modelData[0], modelData[1] || "") else if ((modelData[1] || "") !== "") QbzHome.openAwardByName(modelData[1]) } } } } // EXTERNAL LINKS — Last.fm / Discogs / MusicBrainz deep // links for this release. Present whenever the album has // an artist and a title; they are ordinary web URLs, so // they neither require nor touch a connected integration. Column { visible: albumHeader.showExternalLinks === true width: parent.width spacing: 8 SidebarHeading { compact: root.sidebarCompact text: QbzSession.tr("EXTERNAL LINKS", QbzSession.trRev) } Row { visible: !root.sidebarCompact spacing: 8 BrandLink { visible: (albumHeader.lastfmUrl || "") !== "" iconSource: root.brandDir + "brand-lastfm.svg" name: "Last.fm" url: albumHeader.lastfmUrl || "" } BrandLink { visible: (albumHeader.discogsUrl || "") !== "" iconSource: root.brandDir + "brand-discogs.svg" name: "Discogs" url: albumHeader.discogsUrl || "" } BrandLink { visible: (albumHeader.musicbrainzUrl || "") !== "" iconSource: root.brandDir + "brand-musicbrainz.svg" name: "MusicBrainz" url: albumHeader.musicbrainzUrl || "" } } Column { visible: root.sidebarCompact width: parent.width spacing: 8 BrandLink { visible: (albumHeader.lastfmUrl || "") !== "" anchors.horizontalCenter: parent.horizontalCenter iconSource: root.brandDir + "brand-lastfm.svg" name: "Last.fm" url: albumHeader.lastfmUrl || "" } BrandLink { visible: (albumHeader.discogsUrl || "") !== "" anchors.horizontalCenter: parent.horizontalCenter iconSource: root.brandDir + "brand-discogs.svg" name: "Discogs" url: albumHeader.discogsUrl || "" } BrandLink { visible: (albumHeader.musicbrainzUrl || "") !== "" anchors.horizontalCenter: parent.horizontalCenter iconSource: root.brandDir + "brand-musicbrainz.svg" name: "MusicBrainz" url: albumHeader.musicbrainzUrl || "" } } } } } } } // Real viewport recycling: every delegate root has the SAME height. // The child paints over the inert cells reserved by buildTrackCells(). // cacheBuffer keeps the root alive until the overflowing child is well // outside the viewport, and creates the next heavy row asynchronously. delegate: DelegateChooser { role: "kind" DelegateChoice { roleValue: "gap" delegate: Item { width: ListView.view.width height: root.listCellPx } } DelegateChoice { roleValue: "disc" delegate: Item { required property var modelData width: ListView.view.width height: root.listCellPx Rectangle { x: 32 width: parent.width - 64 - root.sidebarReservePx height: root.trackHeaderPx color: "transparent" Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: QbzSession.tr("Disc", QbzSession.trRev) + " " + modelData.disc color: theme.textMuted font.pixelSize: 13 font.weight: theme.weightSemibold font.letterSpacing: 0.5 } } } } DelegateChoice { roleValue: "work" delegate: Item { id: workCell required property var modelData width: ListView.view.width height: root.listCellPx Row { x: 32 width: parent.width - 64 - root.sidebarReservePx height: root.trackHeaderPx leftPadding: 12 rightPadding: 12 topPadding: 14 bottomPadding: 4 spacing: 0 Text { text: workCell.modelData.work color: theme.textPrimary font.pixelSize: theme.fontBody font.weight: theme.weightBold } Text { visible: workCell.modelData.composerName !== "" text: " (" color: theme.textSecondary font.pixelSize: theme.fontBody font.weight: theme.weightBold } Text { visible: workCell.modelData.composerName !== "" text: workCell.modelData.composerName color: composerArea.containsMouse && workCell.modelData.composerId !== "" ? theme.textPrimary : theme.textSecondary font.pixelSize: theme.fontBody font.weight: theme.weightBold MouseArea { id: composerArea anchors.fill: parent enabled: workCell.modelData.composerId !== "" hoverEnabled: true cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: QbzArtist.openArtist( workCell.modelData.composerId) } } Text { visible: workCell.modelData.composerName !== "" text: ")" color: theme.textSecondary font.pixelSize: theme.fontBody font.weight: theme.weightBold } } } } DelegateChoice { roleValue: "track" delegate: Item { id: trackCell required property var modelData width: ListView.view.width height: root.listCellPx TrackRow { id: trackDelegate x: 32 width: parent.width - 64 - root.sidebarReservePx item: trackCell.modelData.track number: trackCell.modelData.trackNumber zebra: true clickPlays: false artistLink: true qualityStyle: "text" showDownload: true downloadGlyph: true selectMode: root.multiSelect checked: root.selected[item.id] === true onToggleSelect: function (mods) { root.toggleSelected(item.id, mods) } // "Play later" is ON now. It was off because // `enqueue_album_track` had no block-tail arm — "later" // and "queue" both plain-appended, so the two entries // would have done the same thing. That arm exists, so // the entry can. menuShowGoTo: false onPlayRequested: QbzPlayer.playAlbumFrom(albumHeader.id, item.id) onEnqueueRequested: function (m) { QbzPlayer.enqueueAlbumTrack(albumHeader.id, item.id, m === "next" ? "next" : "later") } onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{ "itemType": "track", "source": "qobuz", "sourceItemId": item.id, "title": item.title || "", "subtitle": item.artist || "", // The HEADER's url, not `item.artUrl`: album-view // track rows are published with an empty artUrl // (album_qt.rs:90 says so out loud), and every // track on an album shares the album cover anyway. "artworkUrl": (albumHeader && albumHeader.artUrl) || "", "year": null, "trackCount": null }])) } ListView.onPooled: { trackDelegate.recycleActive = false trackDelegate.releaseForReuse() } ListView.onReused: trackDelegate.recycleActive = true } } DelegateChoice { roleValue: "railSkeleton" delegate: Item { id: railSkeletonCell width: ListView.view.width height: root.listCellPx property bool live: true Loader { x: 32 width: parent.width - 64 active: railSkeletonCell.live sourceComponent: RailSkeleton { phase: skeletonPhase.on cardCount: root.railSkeletonCount } } ListView.onPooled: live = false ListView.onReused: live = true } } DelegateChoice { roleValue: "moreRail" delegate: Item { id: moreRailCell required property var modelData width: ListView.view.width height: root.listCellPx property bool live: true Loader { x: 32 width: parent.width - 64 active: moreRailCell.live sourceComponent: SectionRail { title: QbzSession.tr("From the same artist", QbzSession.trRev) items: moreRailCell.modelData.items coverMap: root.coverMap showViewAll: true onViewAllClicked: QbzArtist.openReleases( root.albumHeader.artistId || "", root.albumHeader.artist || "", "album") } } ListView.onPooled: live = false ListView.onReused: live = true } } DelegateChoice { roleValue: "suggestionsRail" delegate: Item { id: suggestionsRailCell required property var modelData width: ListView.view.width height: root.listCellPx property bool live: true Loader { x: 32 width: parent.width - 64 active: suggestionsRailCell.live sourceComponent: SectionRail { title: QbzSession.tr("Listening suggestions", QbzSession.trRev) items: suggestionsRailCell.modelData.items coverMap: root.coverMap } } ListView.onPooled: live = false ListView.onReused: live = true } } DelegateChoice { roleValue: "similarRail" delegate: Item { id: similarRailCell required property var modelData width: ListView.view.width height: root.listCellPx property bool live: true Loader { x: 32 width: parent.width - 64 active: similarRailCell.live sourceComponent: SectionRail { title: QbzSession.tr("Similar albums", QbzSession.trRev) items: similarRailCell.modelData.items coverMap: root.coverMap } } ListView.onPooled: live = false ListView.onReused: live = true } } } // ListView header/footer instances are eager in Qt. The real rails // therefore live in the model above; this footer is intentionally a // cheap fixed spacer matching the page's old bottomPadding. footer: Item { width: ListView.view.width height: 100 } } // Thin auto-hiding scrollbar (ListScrollbar). // 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. // Origin-relative: the tape swaps on cellsModel drift ListView.originY, // so an absolute contentY would restore a different row after Back. ScrollMemory { target: pageFlick; scope: "album"; relativeToOrigin: true } QbzScrollBar { anchors.right: parent.right anchors.rightMargin: 4 anchors.top: parent.top anchors.bottom: parent.bottom target: pageFlick } // Best available cover source for the lightbox: the custom override // file when set, else the remote best() URL (mega → … → large). function bestCoverSource() { var custom = albumHeader.customCoverPath || "" if (custom !== "") return "file://" + custom return albumHeader.artUrl || "" } // The cover menu's rows, rebuilt per open (Add vs Change+Remove flips // on hasCustomCover; "View cover" is this port's addition). function buildCoverMenuModel() { var rows = [] if (albumHeader.hasCustomCover === true) { rows.push({ "label": QbzSession.tr("Change cover", QbzSession.trRev), "icon": "image-plus", "action": "add" }) rows.push({ "label": QbzSession.tr("Remove cover", QbzSession.trRev), "icon": "trash-2", "action": "remove" }) } else { rows.push({ "label": QbzSession.tr("Add cover", QbzSession.trRev), "icon": "image-plus", "action": "add" }) } rows.push({ "label": QbzSession.tr("View cover", QbzSession.trRev), "icon": "eye", "action": "view" }) rows.push({ "label": QbzSession.tr("Open in browser", QbzSession.trRev), "icon": "external-link", "action": "browser" }) rows.push({ "label": QbzSession.tr("Save as…", QbzSession.trRev), "icon": "cloud-download", "action": "save" }) return rows } function buildAlbumMenuModel() { var rows = [ { "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" }, { "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" }, { "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" }, { "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" }, { "label": root.toggleState("album", albumHeader.isFavorite) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.toggleState("album", albumHeader.isFavorite) ? "heart-filled" : "heart", "action": "favorite" }, { "label": root.toggleState("pin", albumHeader.isPinned) ? QbzSession.tr("Unpin", QbzSession.trRev) : QbzSession.tr("Pin", QbzSession.trRev), "icon": root.toggleState("pin", albumHeader.isPinned) ? "pin-filled" : "pin", "action": "pin" }, { "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "action": "playlist" }, { "label": QbzSession.tr("Add to mixtape", QbzSession.trRev), "icon": "cassette-tape", "action": "mixtape" }, { "label": QbzSession.tr("Share Qobuz link", QbzSession.trRev), "icon": "link", "action": "share-qobuz" }, { "label": QbzSession.tr("Share Album.link", QbzSession.trRev), "icon": "link", "action": "share-albumlink" }, { "label": root.fullyCachedLive ? QbzSession.tr("Refresh offline copy", QbzSession.trRev) : QbzSession.tr("Make available offline", QbzSession.trRev), "icon": root.fullyCachedLive ? "refresh-cw" : "cloud-download", "action": root.fullyCachedLive ? "recache-album" : "cache-album" } ] // Separate DRM-free purchase action. Unknown/stale/error/track-only // ownership never reaches this branch; Rust computes the gate. if (root.purchase.canDownloadPurchase === true) rows.push({ "label": QbzSession.tr("Download purchase", QbzSession.trRev), "icon": "cloud-download", "action": "download-purchase" }) return rows } // Cover right-click menu (AlbumPageView.slint:300-370) + "View cover", // the lightbox entry that is NEW in this port. QbzContextMenu { id: coverMenu menuWidth: 196 // Rebuilt on every open so Add/Change/Remove track the live flag. onAboutToShow: coverMenuRepeater.model = root.buildCoverMenuModel() Repeater { id: coverMenuRepeater model: [] delegate: Rectangle { required property var modelData width: parent ? parent.width : 0 height: 33 radius: 5 color: cmiArea.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: cmiArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { coverMenu.close() var a = modelData.action if (a === "add") QbzAlbum.coverAddCustom(albumHeader.id, albumHeader.artUrl) else if (a === "remove") QbzAlbum.coverRemoveCustom(albumHeader.id) else if (a === "view") coverLightbox.openWith(root.bestCoverSource()) else if (a === "browser") QbzShell.openExternalUrl(albumHeader.artUrl) else if (a === "save") QbzAlbum.coverSaveAs(albumHeader.id, albumHeader.title, albumHeader.artUrl) } } } } } CoverLightbox { id: coverLightbox } // Album Info (Credits/Review) modal — mounted by its host view, the // TrackInfoModal pattern (one AlbumView instance exists at a time; the // Popup reparents to the window overlay). AlbumInfoModal { id: albumInfo } // Album-level source picker. It keeps the shared context-menu chrome and // replaces the full-width QbzSelect that used to occupy a second row in // the header. The current source is marked in-place; unavailable persisted // variants remain visible but cannot be selected again. QbzContextMenu { id: playbackSourceMenu menuWidth: 310 Repeater { model: root.playbackSourceOptions() delegate: Rectangle { required property var modelData required property int index readonly property bool selected: index === root.playbackSourceIndex(root.playbackSourceOptions()) width: parent ? parent.width : 0 height: 38 radius: 5 opacity: modelData.enabled === true ? 1.0 : 0.45 color: sourceOptionArea.containsMouse && modelData.enabled === true ? theme.surfaceHover : "transparent" QbzIcon { visible: parent.selected name: "check" width: 14 height: 14 x: 8 anchors.verticalCenter: parent.verticalCenter tintName: "accent" } Text { x: 30 width: parent.width - 38 height: parent.height text: modelData.label || "" color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } MouseArea { id: sourceOptionArea anchors.fill: parent enabled: modelData.enabled === true hoverEnabled: true cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { playbackSourceMenu.close() root.selectPlaybackSource(modelData) } } } } } // Album ⋯ menu (AlbumContextMenu subset — playback_qt::enqueue_album owns // all three insertion modes since the QoL round gave it a real "later" // (#442 block-tail) arm. The offline row swaps on the live all-cached // aggregate). QbzContextMenu { id: albumMenu menuWidth: 196 Repeater { model: root.buildAlbumMenuModel() delegate: Rectangle { required property var modelData width: parent ? parent.width : 0 height: 33 radius: 5 color: amiArea.containsMouse ? theme.surfaceHover : "transparent" Row { anchors.fill: parent anchors.leftMargin: 8 spacing: 8 QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" } Text { height: parent.height width: parent.width - 23 text: modelData.label color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: amiArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { albumMenu.close() var a = modelData.action if (a === "play") QbzPlayer.playAlbum(albumHeader.id) else if (a === "next") QbzPlayer.enqueueAlbum(albumHeader.id, "next") else if (a === "later") QbzPlayer.enqueueAlbum(albumHeader.id, "later") else if (a === "queue") QbzPlayer.enqueueAlbum(albumHeader.id, "queue") else if (a === "favorite") { root.setToggleState("album", !root.toggleState("album", albumHeader.isFavorite)) QbzLibrary.libraryToggleFavorite("album", albumHeader.id) } else if (a === "pin") { root.setToggleState("pin", !root.toggleState("pin", albumHeader.isPinned)) QbzLibrary.togglePin("album", albumHeader.id, albumHeader.title, albumHeader.artist, albumHeader.artUrl) } else if (a === "playlist") { // The loaded view's own track ids (Slint // main.rs:12213 collects AlbumState.tracks). var ids = [] var ts = root.album.tracks || [] for (var i = 0; i < ts.length; i++) { if (/^\d+$/.test(ts[i].id || "")) ids.push(ts[i].id) } if (ids.length > 0) QbzPlaylistPicker.openForTracks(JSON.stringify(ids)) } else if (a === "mixtape") { QbzAlbum.addToMixtape(albumHeader.id) } else if (a === "share-qobuz") { QbzAlbum.shareQobuzLink(albumHeader.id) } else if (a === "share-albumlink") { QbzAlbum.shareAlbumLink(albumHeader.id) } else if (a === "cache-album") { QbzAlbum.albumCacheOffline(albumHeader.id) } else if (a === "recache-album") { QbzAlbum.albumRefreshOffline(albumHeader.id) } else if (a === "download-purchase") { QbzPurchases.openEntitledAlbum(albumHeader.id) } } } } } // AlbumContextMenu.slint:153 — a 1px border-subtle separator, then // the album-blacklist toggle (:157-172). The .slint writes it as two // `if` arms (Block / Unblock) whose row count is constant; one row // with a flipping label is the same single row, and it is the shape // ArtistPageView.slint:561-572 uses for the identical toggle. Own // Rectangle rather than a sixth Repeater entry because the inline // delegate above has no separator arm — `{sep:true}` is CardMenu's // vocabulary, not this hand-rolled menu's (ArtistView.qml's // overflow menu draws its blacklist row exactly this way). Rectangle { width: parent.width; height: 1; color: theme.borderSubtle } Rectangle { id: ablkRow width: parent.width height: 33 radius: 5 // Seeded from the header document, which does NOT carry the // field yet (album_qt.rs still has to port album.rs:683's // `set_is_album_blocked` seed — spec 03 F13). Read defensively: // `undefined` folds to false through toggleState's // `fallback === true`, so the row is correct the moment the // seed lands and never throws before then. readonly property bool blocked: root.toggleState("blocked", albumHeader.isAlbumBlocked) color: ablkArea.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: ablkRow.blocked ? QbzSession.tr("Unblock album", QbzSession.trRev) : QbzSession.tr("Block this album", QbzSession.trRev) color: theme.textSecondary font.pixelSize: 13 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } } MouseArea { id: ablkArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { albumMenu.close() // Optimistic flip (main.rs:12835), then the mutation; // `blacklistChanged` above settles it — or rolls it // back on a write failure (main.rs:12859). root.setToggleState("blocked", !ablkRow.blocked) QbzBlacklist.albumToggle(albumHeader.id, albumHeader.title, albumHeader.artist, albumHeader.artUrl) } } } } }