Files
qbz/crates/qbz-qt/qml/views/ArtistSceneView.qml
T
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00

1512 lines
74 KiB
QML

// ArtistScene — the "artists from this scene" browser, reached from the artist
// page's Origin row ("Born in / Founded in") and from the header ⋯ menu.
//
// THE REFERENCE IS TAURI, NOT SLINT. For this module only, the owner suspended
// TRACK-RULES' "Slint is the reference" rule: `legacy-tauri/src/lib/components/
// views/ArtistsByLocationView.svelte` (1685 lines) is the target, and Slint's
// `location/ArtistsByLocationView.slint` (173 lines) is a heavy simplification
// with no nav bar, no sidepanel, no alpha rail and no error state — it is NOT
// consulted for anything visual here. Every number below is cited to the
// Svelte as `V:<line>` (markup) or `S:<line>` (its <style> block); the geometry
// table is 01-tauri-artist-scene.md §17 and the contract is
// qbz-nix-docs/qt-frontend/2026-08-14-artist-scene-musician/00-CONTRACT.md.
//
// ANATOMY (V:422-819), top to bottom:
// root padding 0 8 0 18 (S:823-833) · [top bar — DROPPED, see DV-15] ·
// compact sticky header (V:433-443) · nav bar (V:447-601) · content, five
// mutually exclusive states in Tauri's own branch order (V:606,633,640,645,
// 713): loading -> error -> empty -> grid -> sidepanel.
// The hero (140px flag + 28px title) is NOT a sibling of the nav bar: it is the
// grid's scroll HEADER (V:655-676), so the nav bar stays pinned while the hero
// scrolls away. In sidepanel mode the hero does not render at all.
//
// DELIBERATE DIVERGENCES FROM TAURI (contract §6 — none may be "corrected
// back" without reopening the contract):
// - DV-15 NO per-view back button. Tauri's `.top-bar` holds one and nothing
// else (V:425-430), so the whole region is gone: Qt's back/forward
// live once in the shell HeaderBar (QbzNavButton, HeaderBar.qml:300-
// 311). What replaces it here is the 22px spacer every detail view
// that dropped its Slint NavButtons row keeps (MixView.qml:123,
// LabelReleasesView.qml:92). Tauri's row measured ~40px tall
// (8 margin-top + the 14px line + 12 margin-bottom), so the nav bar
// sits ~18px higher here than in Tauri. Stated because it is the one
// place this file's vertical rhythm departs from the reference.
// - DV-4 the group dropdown closes on an outside click. Tauri's does not
// (only its sibling genre popup does, V:240-246) — which is what
// makes it a defect and not a design.
// - DV-8 `heroScrolledPast` is reset by a grid<->sidepanel switch. In Tauri
// only the grid mounts the sentinel observer, so the flag keeps
// whatever value it had and the compact header can stick on or off.
// Here it is `mode === "grid" && <scrolled>`, i.e. false by
// construction while the sidepanel is up.
// - DV-10 the results count does NOT `toLowerCase()` a translated noun
// (V:451 does, which breaks German "Künstler"). Two msgids carry the
// whole phrase instead.
// - DV-11 the text search matches on the TRIMMED query. Tauri tests
// `searchQuery.trim()` for truthiness but matches with the untrimmed
// string (V:263), so a trailing space filters everything out.
// - DV-12 "Browse view" / "Grid view" / "Group: A-Z" / "Group: Off" / "Off" /
// "Alphabetical (A-Z)" are hardcoded English in Tauri (V:564, :577,
// :587, :594). They go through tr() here. Because a QBZ msgid IS its
// English source string (ADR-012), the English output is
// byte-identical — DV-12 costs nothing visually.
// - DV-13 the flag is a BUNDLED asset (qml/assets/flags/<cc>.svg, 266 files
// already in the qrc), not the hatscripts circle-flags CDN Tauri
// fetches per view (V:161-165). A missing code or a missing file
// renders NO flag element at all — never a broken-image box.
// - DV-17 a failed "Load More" does NOT replace the populated view with a
// full-screen error. Tauri's catch sets `error` unconditionally
// (V:386), `loadMore` never clears it (V:390-399) and the chain
// tests error BEFORE grid (V:633 vs :645), so one failed page
// destroys the grid, the hero and the nav bar and Retry restarts at
// offset 0. Here the grid stays and the failure is a line in the
// load-more footer.
// - DV-18 filtering to zero shows a no-results line. Tauri's empty branch
// tests the UNFILTERED list (V:640), so a search that matches
// nothing leaves the hero over a blank viewport with the count
// reading "0 / N artists".
// - §4.3 Retry and Load More have NO BORDER. Their CSS says `1px solid
// var(--border-primary)` (S:1605, S:1641) and `--border-primary` is
// defined NOWHERE in the Tauri tree, which invalidates the whole
// shorthand at computed-value time and leaves `border-style: none`.
// Tauri paints no border on either button; neither does this.
//
// COLOURS — tokens, never literals (contract §4.3): --bg-primary =
// surfaceMain, --bg-secondary = surfaceCard, --bg-tertiary = surfaceElevated,
// --bg-hover = bgHover, --accent-primary = accent, --border-subtle =
// borderSubtle. The three `rgba()` literals Tauri authors (the flag shadow, the
// dropdown shadow, the alpha rail's backplate) are written `Qt.rgba(r,g,b,a)`
// with 0..1 floats: transcribed as 8-digit hex, Qt reads #AARRGGBB where CSS
// wrote #RRGGBBAA, so `rgba(0,0,0,0.4)` -> `#00000066` is FULLY TRANSPARENT.
// That trap has landed twice in this port.
//
// THE PULSE LAW (contract §4.5). Qt Quick has no partial redraws: any dirty
// item presents the WHOLE window at ~1.2% GPU, flat. The loading screen is the
// only continuous animation on this page and it is BESPOKE and pulse-driven —
// neither QbzSpinner (QbzSpinner.qml:19-25, an infinite RotationAnimation) nor
// QbzSkeleton (QbzSkeleton.qml:229-243, a repeating 900ms Timer plus a
// `Behavior on breathe`) may be used, both break the same law. The progress bar
// is data-fed, so it carries NO Behavior; its width is written on each publish.
// While the view is not loading, the whole subtree writes nothing. Hover
// transitions are gesture-bounded and therefore allowed (ArtistCard.qml:170).
//
// WHAT THIS FILE DOES NOT OWN. Three components are a sibling lane's and are
// mounted here against their frozen APIs: scene/SceneGenreFilter.qml,
// scene/AlphaIndexRail.qml, scene/ScenePanelMode.qml. The document shape is
// frozen in contract §13.1 (QbzScene.sceneJson / sceneRev; open, loadMore,
// retry, openArtist, selectArtist, close).
import QtQuick
import QtQuick.Effects
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../theme"
import "scene"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: theme.ambientOn
radius: 12
QbzTheme { id: theme }
// ONE document, re-parsed on every publish — a fresh string is a fresh
// object, so the Repeaters/ListView below always see a NEW reference
// ("Rebind requires a NEW object reference", ArtistReleasesView.qml:65-68).
// `sceneRev` is read for its dependency alone: the contract has Rust bump
// it on every publish so a document that happens to serialise to the same
// string still forces a re-read.
readonly property var doc: {
QbzScene.sceneRev
try {
return JSON.parse(QbzScene.sceneJson)
} catch (e) {
return {}
}
}
// ---- Artist portraits ------------------------------------------------
// ArtistCard paints `RoundedImage { source: artSource }` — a LOCAL cache
// path and nothing else (ArtistCard.qml:159-161). It does NOT fetch
// `artworkUrl`; that property is only carried so a pin snapshot can store
// the remote url. So the HOST owns the download, and without this block
// every card in the grid drew the person-glyph placeholder forever, with
// no error anywhere — which is exactly what shipped in the first pass.
//
// Same machinery as the sibling panel (scene/ScenePanelMode.qml:104-135),
// deliberately duplicated rather than hoisted: the two have different
// request WINDOWS (a row-band here, an album section there) and sharing a
// map would make one view's scrolling evict the other's covers.
//
// `_asked` is never pruned. A resolved path costs one map entry; dropping
// it would only make the portrait flicker back to the placeholder on the
// next scroll.
property var coverMap: ({})
property var _coverInbox: ({})
property var _asked: ({})
readonly property int coverBatchCap: 48
// Batched on a 16 ms one-shot: `libraryArtworkReady` fires once PER COVER,
// and assigning `coverMap` per signal would republish the whole map to
// every delegate 48 times for one screenful. One dirty frame instead of
// 48 — the pulse law's concern is presents, and this is the same argument.
Timer {
id: coverFlush
interval: 16
repeat: false
onTriggered: {
var m = Object.assign({}, root.coverMap, root._coverInbox)
root._coverInbox = ({})
root.coverMap = m
}
}
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
root._coverInbox[key] = path
if (!coverFlush.running)
coverFlush.start()
}
}
function requestCovers(urls) {
var out = []
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
var u = urls[i]
if (u === "" || root._asked[u] === true || root.coverMap[u])
continue
root._asked[u] = true
out.push(u)
}
if (out.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
}
/// Ask for every portrait the current model wants, capped per call.
/// Driven by the document landing and by scrolling — `requestCovers`
/// self-dedupes, so an over-eager caller costs a loop, not a fetch.
function requestVisibleCovers() {
if (!root.isReady)
return
var urls = []
var rows = root.rowModel
for (var i = 0; i < rows.length; i++) {
if (rows[i].type === "header")
continue
var arts = rows[i].artists || []
for (var j = 0; j < arts.length; j++)
urls.push(String(arts[j].artUrl || ""))
}
root.requestCovers(urls)
}
readonly property string sceneState: doc.state || "loading"
readonly property bool isLoading: root.sceneState === "loading"
readonly property bool isReady: root.sceneState === "ready"
// Refuse entry offline (contract B7): the document's own class first, the
// session flag as the belt-and-braces every other view already wears
// (ArtistReleasesView.qml:100-111).
readonly property bool isOffline: root.sceneState === "offline" || QbzSession.offline
// The rows the bridge published. Candidates with no `qobuzId` are dropped
// in RUST (contract §13.1) — Tauri filters them in candidatesToFavoriteArtists
// (V:170) and the Slint port's failure to do so is defect S3. Nothing here
// re-filters them; a row with an empty id arriving would be a bridge bug.
readonly property var allArtists: doc.artists || []
readonly property var progress: doc.progress || ({})
// Cancel any in-flight discovery when the router unmounts us (contract B8:
// "invalidated on view deactivation"). One page can run up to 100 sequential
// Qobuz searches; a generation guard would drop the publish but not the
// traffic.
Component.onDestruction: QbzScene.close()
// ======================= view state (all client-side) ==================
// Every control in the nav bar filters the ALREADY-FETCHED list, exactly as
// Tauri does (V:250-268) — none of them talks to the bridge.
property string mode: "grid" // V:88 'grid' | 'sidepanel'
property string searchQuery: "" // V:90
property bool groupingEnabled: false // V:92
property bool groupMenuOpen: false // V:93
property var activeGenres: [] // V:95 Tauri's Set<string>
property bool genrePopupOpen: false // V:96
property string selectedArtistId: "" // V:102 (sidepanel only)
// --- Filter chain, in Tauri's order (V:250-268) ------------------------
// 1. genre filter — AND semantics, CASE-SENSITIVE exact equality on the
// strings that came back on the row (V:255-259).
// 2. text search — DV-11, trimmed on both sides of the comparison.
// No fuzzy matching, no diacritic folding, no debounce (V:250 is a plain
// derived value recomputed on every keystroke).
readonly property var filteredArtists: {
var out = root.allArtists
if (root.activeGenres.length > 0) {
var want = root.activeGenres
out = out.filter(function (a) {
var g = a.genres || []
for (var i = 0; i < want.length; ++i)
if (g.indexOf(want[i]) === -1)
return false
return true
})
}
var q = (root.searchQuery || "").trim().toLowerCase()
if (q !== "")
out = out.filter(function (a) {
return root.artistNameOf(a).toLowerCase().indexOf(q) !== -1
})
return out
}
readonly property bool filteringActive:
(root.searchQuery || "").trim() !== "" || root.activeGenres.length > 0
// DV-18 — Tauri has no state for this and shows a blank viewport.
readonly property bool filteredToZero:
root.isReady && root.allArtists.length > 0 && root.filteredArtists.length === 0
// --- The genre inventory the popup filters on (V:207-217) --------------
// All unique genres across the UNFILTERED rows, sorted by frequency
// descending with no secondary tiebreak (Tauri lets Map insertion order
// decide ties; a stable sort over an insertion-ordered array does the same).
readonly property var availableGenres: {
var counts = ({})
var order = []
for (var i = 0; i < root.allArtists.length; ++i) {
var g = root.allArtists[i].genres || []
for (var j = 0; j < g.length; ++j) {
if (counts[g[j]] === undefined) {
counts[g[j]] = 0
order.push(g[j])
}
counts[g[j]] += 1
}
}
var rows = order.map(function (n) { return { "name": n, "count": counts[n] } })
rows.sort(function (a, b) { return b.count - a.count })
// NAMES ONLY. SceneGenreFilter's frozen mount API (contract §13.3)
// takes plain strings — it filters, labels, selection-tests and emits
// with the bare value, and it displays no count. Handing it the
// {name, count} objects the sort needs would break the filter
// silently: nothing in this tree type-checks a QML property, so the
// cards would label themselves "[object Object]" and no selection
// would ever match. The counts exist only to establish Tauri's
// frequency-descending order (01 §7 `availableGenres`).
return rows.map(function (r) { return r.name })
}
// ======================= grouping (V:271-301) ==========================
// The key is the initial upper-cased, kept only when it is A-Z — so
// accented and non-Latin initials all collapse into '#' (V:274-276).
// Deliberately NOT "improved": it is on contract §6's kept-quirk list.
// The 27-letter alphabet itself ('#' first, V:272) belongs to
// scene/AlphaIndexRail.qml; this view only tells it which keys have members.
function alphaKeyOf(name) {
var c = (name || "").charAt(0).toUpperCase()
return (c >= "A" && c <= "Z") ? c : "#"
}
/// The artist's display name, whatever the producer called the field.
///
/// THE SEAM THAT BIT: the row used to carry `name`, and the swap to the
/// standard `cards/ArtistCard.qml` (owner ruling R8) moved it to the house
/// `title`. Grouping, sorting and search all still read `name`, so
/// `(a.name || "")` yielded "", `alphaKeyOf("")` answered "#" for EVERY
/// artist, and the A-Z sort compared empty strings — the rail rendered
/// perfectly over one giant unsorted "#" bucket. Nothing errored, because
/// every step is a legal operation on an empty string.
///
/// Reading BOTH names is the same one-token insurance the sibling panel
/// keeps for its album fields (scene/ScenePanelMode.qml:138-147), and it is
/// what makes this class of rename survivable instead of silent.
function artistNameOf(a) { return String((a && (a.title || a.name)) || "") }
// V:278-296 — sort a COPY by localeCompare, bucket, then order the keys
// with '#' forced first and the rest by localeCompare.
function groupArtists(list) {
var sorted = list.slice().sort(function (a, b) {
return root.artistNameOf(a).localeCompare(root.artistNameOf(b))
})
var buckets = ({})
var keys = []
for (var i = 0; i < sorted.length; ++i) {
var k = root.alphaKeyOf(root.artistNameOf(sorted[i]))
if (buckets[k] === undefined) {
buckets[k] = []
keys.push(k)
}
buckets[k].push(sorted[i])
}
keys.sort(function (a, b) {
if (a === "#") return -1
if (b === "#") return 1
return a.localeCompare(b)
})
return keys.map(function (k) { return { "key": k, "artists": buckets[k] } })
}
// V:298-301 — grouping OFF is a single anonymous group that keeps the
// BACKEND's score-descending order. That is the default view, and the one
// an "obvious" alphabetical sort would silently destroy.
readonly property var groups: root.groupingEnabled
? root.groupArtists(root.filteredArtists)
: [{ "key": "", "artists": root.filteredArtists }]
readonly property var alphaActiveKeys: {
var out = []
for (var i = 0; i < root.groups.length; ++i)
if (root.groups[i].key !== "")
out.push(root.groups[i].key)
return out
}
// ======================= the virtual row model =========================
// Tauri virtualises a FLAT list of header|row items (Grid:68-100) with
// CARD_WIDTH 160, CARD_HEIGHT 250, GAP 24, ROW_GAP 11, HEADER_HEIGHT 44
// (Grid:48-53). A ListView over the same flat model gives Qt the identical
// windowing for free — a GridView could not carry the group headers.
// OWNER RULING R8 (2026-08-14): the grid draws the STANDARD
// `cards/ArtistCard.qml` — hover overlay, follow chip, pin and context
// menu — not the bare tile Tauri has. So the card DICTATES these two, and
// they are its `implicitWidth`/`implicitHeight` (ArtistCard.qml:84-85),
// not Tauri's 160x250 (Grid:48-49). The gap and row-gap stay Tauri's,
// which is what keeps the grid's rhythm recognisably the reference's.
readonly property int cardW: 200
readonly property int cardH: 246
readonly property int cardGap: 24
readonly property int rowGap: 11
readonly property int headerH: 44
// Grid:62-65 — from a ResizeObserver in Tauri, from the item width here.
// No media queries anywhere in the reference.
readonly property int columns: Math.max(1,
Math.floor((gridPane.width + root.cardGap) / (root.cardW + root.cardGap)))
// Ask for the portraits whenever the row model settles. `rowModel` is
// rebuilt by a new document, a page appended, a filter, a search keystroke
// and a resize — every one of which can bring artists into view that were
// not there before, and `requestCovers` self-dedupes, so a redundant call
// costs a loop over the model and no fetch.
//
// NOT a `Component.onCompleted`: the view mounts BEFORE the first document
// lands (the route is recorded by `QbzScene.open` and discovery is async),
// so at completion the model is empty and the one shot would be wasted.
onRowModelChanged: root.requestVisibleCovers()
readonly property var rowModel: {
var out = []
var showHeaders = root.groupingEnabled
for (var g = 0; g < root.groups.length; ++g) {
var grp = root.groups[g]
if (showHeaders && grp.key !== "")
out.push({ "type": "header", "key": grp.key, "count": grp.artists.length })
for (var i = 0; i < grp.artists.length; i += root.columns)
out.push({ "type": "row", "key": grp.key,
"artists": grp.artists.slice(i, i + root.columns) })
}
return out
}
/// Top of the content in the ListView's own content coordinates. Read off
/// the header ITEM rather than `originY`, which means different things with
/// and without a header and is the kind of detail a silent 40px offset
/// hides in.
function contentTop() {
return list.headerItem ? list.headerItem.y : 0
}
/// Content-Y of row `idx`, accumulated exactly the way Tauri accumulates
/// its virtual tops (Grid:68-100): headers 44, rows 250 + 11.
function rowTop(idx) {
var y = root.contentTop() + (list.headerItem ? list.headerItem.height : 0)
for (var i = 0; i < idx; ++i)
y += root.rowModel[i].type === "header" ? root.headerH : (root.cardH + root.rowGap)
return y
}
function jumpToLetter(letter) {
for (var i = 0; i < root.rowModel.length; ++i) {
if (root.rowModel[i].type === "header" && root.rowModel[i].key === letter) {
var top = root.contentTop()
var maxY = Math.max(top, top + list.contentHeight - list.height)
// Tauri smooth-scrolls (Grid:224-231, `behavior: 'smooth'`).
// ONE-SHOT and gesture-bounded, like the tail fades this tree
// already runs (SearchView.qml:905-911) — not a continuous
// animator and not a Behavior on a data-fed property, so the
// pulse law is untouched.
jumpAnim.stop()
jumpAnim.from = list.contentY
jumpAnim.to = Math.max(top, Math.min(maxY, root.rowTop(i)))
jumpAnim.start()
return
}
}
}
// ======================= scene identity ================================
// `sceneLabel` is already `country.unwrap_or(display_name)` on the Rust
// side (core.rs:2655-2657), so the view has ONE title source and does not
// re-derive Tauri's three-way `sceneLabel || country || displayName`.
readonly property string sceneTitle: doc.sceneLabel || ""
readonly property string genreSummary: doc.genreSummary || ""
readonly property var seedGenres: doc.seedGenres || []
// V:664-673 — the subtitle renders when EITHER source has something.
readonly property string subtitleGenres:
root.genreSummary !== "" ? root.genreSummary : root.seedGenres.slice(0, 3).join(" / ")
readonly property bool hasSubtitle: root.subtitleGenres !== ""
// DV-13 — the bundled asset, lowercased ISO code (qml/assets/flags/, 266
// files, already swept into the qrc by build.rs:177). Both mounts gate the
// ELEMENT on `Image.Ready`, not on "the code is non-empty": an ISO code
// MusicBrainz can emit and circle-flags lacks must render NOTHING — never a
// broken-image box — and an Item that is `visible: false` is skipped by
// Row/Column layout, so the 20px hero gap and the 10px compact gap close
// with it.
readonly property string flagCode: (doc.countryCode || "").toLowerCase()
readonly property string flagSource: root.flagCode === ""
? "" : "qrc:/qt/qml/com/blitzfc/qbz/qml/assets/flags/" + root.flagCode + ".svg"
function tr(s) { return QbzSession.tr(s, QbzSession.trRev) }
// ============================ offline gate =============================
QbzOfflinePlaceholder {
visible: root.isOffline
anchors.centerIn: parent
showSettingsAction: true
onSettingsClicked: QbzShell.navigateTo("settings")
}
// ===================== STATE 1 — loading (V:606-632) ===================
// The loading screen OWNS the viewport: top bar, compact header and nav bar
// are all suppressed while it is up (V:423, V:447).
//
// BESPOKE AND PULSE-DRIVEN (DV-7). The reference is a 96px breathing circle
// + an icon carousel + a 280x4 bar + phase text + a percentage, which no
// stock Qt component resembles; and the two that come closest both break
// the pulse law (see the header). One `Connections` on QbzShell.pulseMs
// drives all of it, and it writes NOTHING unless `isLoading`.
Item {
id: loadingLayer
anchors.fill: parent
visible: root.isLoading && !root.isOffline
// Local accumulated milliseconds. `pulseMs` is a WRAPPING counter whose
// absolute value means nothing (shell_bridge.rs:295-302) — consumers
// accumulate their own tick off its notify edge, which is also what
// lets this one reset to 0 without touching the shared clock.
property int ms: 0
property int lastPulse: 0
// S:1551-1554 — `@keyframes pulse`, 2s ease-in-out, 0%/100% scale(1)
// opacity .8, 50% scale(1.06) opacity 1. A raised cosine is that curve.
readonly property real breathe: (1 - Math.cos(ms / 2000 * 2 * Math.PI)) / 2
// V:116-123 — Music -> Globe -> Share2 every 1500ms.
readonly property int iconIndex: Math.floor(ms / 1500) % 3
// S:1546-1549 — `iconFadeIn` 400ms ease-out, opacity 0->1,
// scale .8->1, replayed on EVERY icon change by Tauri's `{#key}` block.
readonly property real fadeT: Math.min(1, (ms % 1500) / 400)
Connections {
target: QbzShell
function onPulseMsChanged() {
if (!root.isLoading)
return // a handler that writes nothing costs no frame
var d = QbzShell.pulseMs - loadingLayer.lastPulse
if (d < 0 || d > 500)
d = 33 // wrap, or a stall: one nominal period
loadingLayer.lastPulse = QbzShell.pulseMs
loadingLayer.ms += d
}
}
onVisibleChanged: {
if (visible) {
ms = 0
lastPulse = QbzShell.pulseMs
}
}
Column {
anchors.centerIn: parent
spacing: 24 // S:1511-1517
// S:1527-1537 — 96px disc, bg-secondary, accent glyph 42px.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 96
height: 96
radius: 48
color: theme.surfaceCard
scale: 1 + 0.06 * loadingLayer.breathe
opacity: 0.8 + 0.2 * loadingLayer.breathe
QbzIcon {
anchors.centerIn: parent
// Tauri's LOADING_ICONS is ['music','globe','network']
// (V:113) rendered as Music / Globe / Share2; `network` is
// this tree's name for that third glyph.
name: loadingLayer.iconIndex === 0 ? "music"
: (loadingLayer.iconIndex === 1 ? "globe" : "network")
width: 42
height: 42
tintName: "accent"
opacity: loadingLayer.fadeT
scale: 0.8 + 0.2 * loadingLayer.fadeT
}
}
// S:1556-1569 — 280x4 track, radius 2, accent fill. The width is
// DATA-FED, so no Behavior: progress events arrive at human rate
// and a Behavior would animate every step at display rate, which is
// one full-window present per tween frame.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 280
height: 4
radius: 2
color: theme.surfaceElevated
Rectangle {
width: parent.width * Math.max(0, Math.min(100, root.progress.pct || 0)) / 100
height: parent.height
radius: 2
color: theme.accent
}
}
// S:1571-1589 — phase label 14 / text-secondary, percent 12 muted.
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 4
Text {
anchors.horizontalCenter: parent.horizontalCenter
// V:147-158 — three phases; anything else falls back to
// the 'searching' label with the literal "music".
// `sceneStep2` exists in Tauri's catalogue and is never
// used by this view (contract §7): do not port it.
text: {
var ph = root.progress.phase || "searching"
if (ph === "validating")
return root.tr("Verifying availability on Qobuz...")
if (ph === "done")
return root.tr("Preparing results...")
var g = root.progress.detail || "music"
return root.tr("Searching MusicBrainz for {genre} artists...")
.replace("{genre}", g)
}
color: theme.textSecondary
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: Math.round(root.progress.pct || 0) + "%"
color: theme.textMuted
font.pixelSize: 12
}
}
}
}
// ======================= everything that is not loading ================
Item {
id: page
anchors.fill: parent
visible: !root.isLoading && !root.isOffline
// S:823-833 — `.scene-view { padding: 0 8px 0 18px }`.
readonly property int padL: 18
readonly property int padR: 8
// DV-15's replacement for the dropped `.top-bar`.
Item { id: topSpacer; y: 0; width: 1; height: 22 }
// ---- Compact sticky header (V:433-443, S:862-895) -----------------
// It lives OUTSIDE the scroll container, so when it appears it pushes
// the nav bar and the grid down by its own height — sticky in intent,
// static in layout, exactly as the reference is.
Item {
id: compact
x: page.padL
y: topSpacer.height
width: page.width - page.padL - page.padR
// 8 padding-top + content + 8 padding-bottom + 1 border + 8 margin
height: visible ? 45 : 0
visible: root.heroScrolledPast && root.isReady
Row {
id: compactRow
y: 8
width: parent.width
spacing: 10 // S:864
Image {
id: compactFlag
// 28x28 (S:872-878). circle-flags ship round, so no mask.
width: 28
height: 28
sourceSize.width: 56
sourceSize.height: 56
fillMode: Image.PreserveAspectCrop
source: root.flagSource
visible: root.flagSource !== "" && status === Image.Ready
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: root.sceneTitle
color: theme.textPrimary
font.pixelSize: 16
font.weight: theme.weightBold
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, Math.min(implicitWidth,
parent.width
- (compactFlag.visible ? compactFlag.width + 10 : 0)
- (compactSub.visible ? compactSub.width + 10 : 0)))
}
Text {
id: compactSub
text: root.genreSummary
visible: root.genreSummary !== ""
color: theme.textMuted
font.pixelSize: 12
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, parent.width * 0.4)
}
}
// S:866 — 1px bottom border, then an 8px margin.
Rectangle {
y: 37
width: parent.width
height: 1
color: theme.borderSubtle
}
}
// ---- Nav bar (V:447-601, S:943-950) -------------------------------
// Gated exactly as Tauri gates it: not while loading, not on an error,
// and not before there is anything to count.
Item {
id: navBar
x: page.padL
y: compact.y + compact.height
width: page.width - page.padL - page.padR
height: visible ? 48 : 0 // 36px controls + S:947 margin 12
visible: root.isReady && root.allArtists.length > 0
// --- left: the results count (V:449-453, S:964-968) ------------
// DV-10: Tauri prints `{n}{ / {total}}? ` + t('search.artists')
// .toLowerCase(). Lower-casing a translated noun is locale-naive,
// so the whole phrase is one msgid here and the English output is
// identical.
Text {
anchors.verticalCenter: countAnchor.verticalCenter
text: root.filteringActive
? root.tr("{} / {} artists")
.replace("{}", root.filteredArtists.length)
.replace("{}", root.allArtists.length)
: root.tr("{} artists").replace("{}", root.allArtists.length)
color: theme.textMuted
font.pixelSize: 12
}
Item { id: countAnchor; width: 1; height: 36 }
// --- right: search · genre · view toggle · group (V:456-599) ---
Row {
id: navRight
x: parent.width - width
spacing: 8 // S:960
height: 36
// (a) Search — QbzLineEdit's expandable arm IS this control:
// a magnifier slot that opens a right-anchored field growing
// LEFT, live `edited`, and an X that clears AND closes, which
// is V:472 verbatim. DELIBERATE 2px DEVIATION: the shared
// control's collapsed slot is 34px (ExpandableSearch.slint's
// own number) where Tauri's `.search-icon-btn` is 36 — worth it
// to not grow a second search box in this tree.
QbzLineEdit {
id: searchBox
anchors.verticalCenter: parent.verticalCenter
expandable: true
openWidth: 240 // S:997
placeholder: root.tr("Search…")
onEdited: function (v) { root.searchQuery = v }
}
// (b) Genre filter trigger — only when there is more than one
// genre to choose between (V:480).
Rectangle {
id: genreBtn
visible: root.availableGenres.length > 1
width: 36
height: 36 // S:1056-1062
radius: 8
color: genreArea.containsMouse ? theme.bgHover : theme.surfaceElevated
border.width: 1
border.color: root.activeGenres.length > 0
? theme.accent : theme.borderSubtle // S:1107-1110
Behavior on color { ColorAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
// Tauri's lucide Funnel; `list-filter` is this tree's
// funnel glyph (BrowseGenreButton.qml uses the same).
name: "list-filter"
width: 16
height: 16
tintName: root.activeGenres.length > 0 ? "accent"
: (genreArea.containsMouse ? "textPrimary" : "secondary")
}
// S:1112-1126 — 16px accent disc at -4/-4, 10/700, the
// count of active filters, in the page background colour.
Rectangle {
visible: root.activeGenres.length > 0
x: parent.width - width + 4
y: -4
width: 16
height: 16
radius: 8
color: theme.accent
Text {
anchors.centerIn: parent
text: root.activeGenres.length
color: theme.surfaceMain
font.pixelSize: 10
font.weight: theme.weightBold
}
}
MouseArea {
id: genreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.groupMenuOpen = false
root.genrePopupOpen = !root.genrePopupOpen
}
// With genres ON, say WHICH — the badge only ever
// showed a number. This view already owns a QbzTooltip
// instance (`tips`), so it calls the summary mode
// directly instead of going through the shell channel
// that toolbars without one have to use.
onContainsMouseChanged: {
if (!containsMouse) {
tips.hide("scene-genre")
} else if (root.activeGenres.length > 0) {
tips.showSummary(genreBtn, "scene-genre", [{
group: root.tr("Genre"),
values: root.activeGenres
}])
} else {
tips.showAbove(genreBtn, "scene-genre",
root.tr("Filter by genre"))
}
}
}
}
// (c) View toggle (V:554-571) ---------------------------------
Rectangle {
id: modeBtn
width: 36
height: 36
radius: 8
color: modeArea.containsMouse ? theme.bgHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Behavior on color { ColorAnimation { duration: 150 } }
QbzIcon {
anchors.centerIn: parent
// lucide PanelLeftClose while in grid mode, LayoutGrid
// while in sidepanel mode (V:565-569).
name: root.mode === "grid" ? "panel-left" : "layout-grid"
width: 16
height: 16
tintName: modeArea.containsMouse ? "textPrimary" : "secondary"
}
MouseArea {
id: modeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.mode === "grid") {
root.mode = "sidepanel"
} else {
// V:559-562 — leaving the sidepanel clears the
// selection.
root.mode = "grid"
root.selectedArtistId = ""
}
root.groupMenuOpen = false
root.genrePopupOpen = false
}
// DV-12 — hardcoded English in Tauri (V:564).
onContainsMouseChanged: containsMouse
? tips.showAbove(modeBtn, "scene-mode",
root.mode === "grid" ? root.tr("Browse view") : root.tr("Grid view"))
: tips.hide("scene-mode")
}
}
// (d) Group dropdown — grid mode only (V:574-599) -------------
// Written out rather than reusing QbzSelect: this control is a
// labelled button ("Group: A-Z") with a 2-row menu on Tauri's
// own metrics, where QbzSelect carries its own chrome, its own
// 30/34px sizing and a value-shaped label.
Rectangle {
id: groupBtn
visible: root.mode === "grid"
width: groupRow.width + 24 // S:1043 padding 8px 12px
height: 36
radius: 8
color: groupArea.containsMouse ? theme.bgHover : theme.surfaceElevated
border.width: 1
border.color: theme.borderSubtle
Behavior on color { ColorAnimation { duration: 150 } }
Row {
id: groupRow
anchors.centerIn: parent
spacing: 8 // S:1039
Text {
// DV-12 — both labels are hardcoded English at
// V:577.
text: root.groupingEnabled
? root.tr("Group: A-Z") : root.tr("Group: Off")
color: groupArea.containsMouse ? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
QbzIcon {
name: "chevron-down"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: groupArea.containsMouse ? "textPrimary" : "secondary"
}
}
MouseArea {
id: groupArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.genrePopupOpen = false
root.groupMenuOpen = !root.groupMenuOpen
}
}
}
}
}
// ---- Body (V:605-819) ---------------------------------------------
Item {
id: body
x: page.padL
y: navBar.y + navBar.height
width: page.width - page.padL - page.padR
height: Math.max(0, page.height - y)
// === STATE 2 — error (V:633-639, S:1592-1615) ==================
// Only a FIRST-page failure reaches here; a failed pagination is
// DV-17 and stays in the footer.
Column {
visible: root.sceneState === "error"
anchors.horizontalCenter: parent.horizontalCenter
y: 80 // S:1596 padding
spacing: 16
Text {
anchors.horizontalCenter: parent.horizontalCenter
// The raw error string, untranslated — Tauri renders
// `String(err)` verbatim (V:635).
text: root.doc.error || ""
color: theme.textMuted
font.pixelSize: 14
width: Math.min(implicitWidth, body.width - 64)
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
// S:1602-1615 — padding 8px 20px, radius 8, bg-secondary,
// text-primary 13. NO BORDER (see the header, §4.3).
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: retryLabel.implicitWidth + 40
height: retryLabel.implicitHeight + 16
radius: 8
color: retryArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
Behavior on color { ColorAnimation { duration: 150 } }
Text {
id: retryLabel
anchors.centerIn: parent
text: root.tr("Retry")
color: theme.textPrimary
font.pixelSize: 13
}
MouseArea {
id: retryArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// DV-5 lives in Rust: `retry()` re-arms the progress
// stream AND the cancellation token before restarting
// at offset 0 (contract §13.1). Tauri's retry re-runs
// discovery without re-attaching its listener, so its
// bar stays frozen at whatever value it held.
onClicked: QbzScene.retry()
}
}
}
// === STATE 3 — empty (V:640-644, S:1617-1626) ==================
// Reached when the query SUCCEEDED and returned nothing — i.e.
// MusicBrainz found artists but none of them exist on Qobuz.
Column {
visible: root.sceneState === "empty"
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
spacing: 16
QbzIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "music"
width: 48
height: 48
tintName: "muted"
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.tr("No matching artists from this scene are currently available on Qobuz.")
color: theme.textMuted
font.pixelSize: 14
width: Math.min(implicitWidth, body.width - 64)
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
// === STATE 4 — grid mode (V:645-712) ===========================
Item {
id: gridMode
anchors.fill: parent
visible: root.isReady && root.mode === "grid"
// S:1359-1370 — `.grid-with-alpha` is a flex ROW: the grid
// takes the remaining width and the rail sits beside it. The
// rail is NOT sticky, whatever its CSS says: its scroller is a
// descendant of its SIBLING, so `position:sticky` never
// engages and it is simply top-aligned (08-critique-findings).
Item {
id: gridPane
width: parent.width - (rail.visible ? rail.width + 4 : 0)
height: parent.height
ListView {
id: list
anchors.fill: parent
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.rowModel
spacing: 0
cacheBuffer: 2 * (root.cardH + root.rowGap) // Grid:150 ±5 items
NumberAnimation {
id: jumpAnim
target: list
property: "contentY"
duration: 250
easing.type: Easing.OutCubic
}
// --- the hero, as the scroll header (V:655-676) -----
header: Item {
width: list.width
height: heroRow.height + 20 // S:900 padding-bottom
Row {
id: heroRow
width: parent.width
spacing: 20 // S:899
// S:905-918 — 140px round flag with a
// `0 8px 32px rgba(0,0,0,0.4)` shadow. Written
// Qt.rgba(): #00000066 would be TRANSPARENT
// black under Qt's #AARRGGBB parse.
Item {
id: flagWrap
width: 140
height: 140
visible: heroFlag.status === Image.Ready
anchors.verticalCenter: parent.verticalCenter
Rectangle {
// The Cortinilla.qml:188-201 shadow
// recipe: a blurred offset disc, gated
// off on the software renderer where
// there are no shaders.
visible: !root.noShaders
x: 0
y: 8
width: 140
height: 140
radius: 70
color: Qt.rgba(0, 0, 0, 0.4)
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 32
blur: 1.0
}
}
Image {
id: heroFlag
anchors.fill: parent
source: root.flagSource
sourceSize.width: 280
sourceSize.height: 280
fillMode: Image.PreserveAspectCrop
}
}
Column {
spacing: 8 // S:920-925
anchors.verticalCenter: parent.verticalCenter
width: parent.width
- (flagWrap.visible ? flagWrap.width + 20 : 0)
Text {
width: parent.width
text: root.sceneTitle
color: theme.textPrimary
font.pixelSize: 28 // S:927-933
font.weight: theme.weightBold
lineHeight: 1.2
lineHeightMode: Text.ProportionalHeight
elide: Text.ElideRight
}
Text {
visible: root.hasSubtitle
width: parent.width
// V:666 — "Based on {artist} · {genres}",
// where {artist} is the MUSICBRAINZ name
// (ArtistDetailView.svelte:2915), not the
// Qobuz one, and the separator is a
// U+00B7 MIDDLE DOT.
text: root.tr("Based on {artist} · {genres}")
.replace("{artist}", root.doc.artistName || "")
.replace("{genres}", root.subtitleGenres)
color: theme.textMuted
font.pixelSize: 14 // S:935-940
lineHeight: 1.4
lineHeightMode: Text.ProportionalHeight
wrapMode: Text.WordWrap
}
}
}
}
// --- rows and group headers ------------------------
// The model is FLAT (header | row of up to `columns`
// cards), which is what lets one ListView give the
// grouped grid the same windowing Tauri hand-rolls.
// Every reference below goes through `del`, never a
// `parent.parent` chain: parent-chain bindings are
// invisible to qmllint and are a documented trap in
// this port (Cortinilla.qml:441-448).
delegate: Item {
id: del
required property var modelData
readonly property bool isHeader: modelData.type === "header"
width: list.width
height: del.isHeader
? root.headerH : (root.cardH + root.rowGap)
// Grid:333-350 — 12px uppercase muted, letter-
// spacing 0.08em (= 0.96px at 12px), padding
// 12px 0 4px, count on the right.
Item {
visible: del.isHeader
width: del.width
height: root.headerH
Text {
y: 12
text: (del.modelData.key || "").toUpperCase()
color: theme.textMuted
font.pixelSize: 12
font.weight: theme.weightSemibold
font.letterSpacing: 0.96
}
Text {
y: 12
x: parent.width - width
text: del.modelData.count || ""
color: theme.textMuted
font.pixelSize: 12
}
}
// Grid:352-356 — `.artist-row { display:flex;
// gap:24 }`.
Row {
visible: !del.isHeader
spacing: root.cardGap
Repeater {
model: del.isHeader ? [] : (del.modelData.artists || [])
delegate: ArtistCard {
// The row is published in the house
// card shape by artist_scene_qt.rs
// (id/title/subtitle/artUrl +
// following/isPinned), so there is no
// translation layer here — same mount
// as LabelView.qml:247 and
// HomeView.qml:1176.
item: modelData
artSource: root.coverMap[String(modelData.artUrl || "")] || ""
artworkUrl: modelData.artUrl || ""
isPinned: modelData.isPinned === true
}
}
}
}
// --- footer: Load More / the DV-17 inline failure ---
footer: Item {
width: list.width
// Grid:432-434 `.virtual-footer` padding 16px 0 8px
// plus S:1629-1633 `.load-more-inline` 16px 0 24px,
// then the tail room every Qt view leaves for the
// now-playing bar (ArtistReleasesView.qml:230).
height: footerCol.height + 100
Column {
id: footerCol
width: parent.width
topPadding: 16
spacing: 12
// DV-17 — a failed pagination keeps the grid and
// says so here. The seam: `state` stays "ready"
// and `error` is non-empty.
Text {
visible: root.isReady && (root.doc.error || "") !== ""
anchors.horizontalCenter: parent.horizontalCenter
text: root.doc.error || ""
color: theme.textMuted
font.pixelSize: 12
width: Math.min(implicitWidth, parent.width - 64)
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
}
// Idle: S:1635-1651 — padding 10px 24px,
// radius 8, bg-secondary, 13px. NO BORDER
// (§4.3). The msgid is the catalogue's existing
// "Load more"; Tauri's "Load More" would cost
// eight translations for one capital letter
// (contract §7).
Rectangle {
visible: root.doc.hasMore === true
&& root.doc.loadingMore !== true
anchors.horizontalCenter: parent.horizontalCenter
width: moreLabel.implicitWidth + 48
height: moreLabel.implicitHeight + 20
radius: 8
color: moreArea.containsMouse
? theme.surfaceElevated : theme.surfaceCard
Behavior on color { ColorAnimation { duration: 150 } }
Text {
id: moreLabel
anchors.centerIn: parent
text: root.tr("Load more")
color: theme.textPrimary
font.pixelSize: 13
}
MouseArea {
id: moreArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzScene.loadMore()
}
}
// Busy: S:1653-1679 — a 200x4 bar and a
// tabular percentage, replacing the button.
// Data-fed width, so again no Behavior.
Row {
visible: root.doc.loadingMore === true
anchors.horizontalCenter: parent.horizontalCenter
spacing: 12
Rectangle {
width: 200
height: 4
radius: 2
color: theme.surfaceElevated
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: parent.width
* Math.max(0, Math.min(100, root.progress.pct || 0)) / 100
height: parent.height
radius: 2
color: theme.accent
}
}
Text {
text: Math.round(root.progress.pct || 0) + "%"
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
// 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: list; scope: "scene" }
QbzScrollBar {
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
target: list
}
// DV-18 — Tauri leaves the hero over a blank viewport here.
// The list is unscrollable in this state, so a static line
// under the hero is enough.
Text {
visible: root.filteredToZero
anchors.horizontalCenter: parent.horizontalCenter
y: (list.headerItem ? list.headerItem.height : 0) + 40
text: root.tr("No results found.")
color: theme.textMuted
font.pixelSize: 14
}
}
// The 27-letter jump rail — a SIBLING lane's component
// (scene/AlphaIndexRail.qml). Rendered only while grouping is
// on (V:699), top-aligned, 4px off the grid (S:1381).
AlphaIndexRail {
id: rail
visible: root.groupingEnabled
x: gridPane.width + 4
y: 0
activeKeys: root.alphaActiveKeys
onJumped: function (letter) { root.jumpToLetter(letter) }
}
}
// === STATE 5 — sidepanel mode (V:713-817) ======================
// `.artist-two-column-layout` carries `margin: 0 -8px 0 -18px`
// (S:1419), which exactly cancels `.scene-view`'s padding so the
// two columns go edge to edge. Here that is the negative x plus the
// full root width.
ScenePanelMode {
id: panel
visible: root.isReady && root.mode === "sidepanel"
x: -page.padL
y: 0
width: root.width
height: parent.height
doc: root.doc.panel || ({})
// ALWAYS grouped A-Z in this mode, regardless of the nav bar's
// grouping toggle (V:718) — the rail's own contract.
artists: root.filteredArtists
selectedId: root.selectedArtistId
onArtistSelected: function (qobuzId) {
root.selectedArtistId = qobuzId
QbzScene.selectArtist(qobuzId)
}
// V:762-765 — the sidepanel's album cards forward to the host's
// `onAlbumClick` / `onAlbumPlay`, which are the shell's normal
// album open and album play (+page.svelte:6989-6991).
onAlbumActivated: function (albumId) { QbzAlbum.openAlbum(albumId) }
onAlbumPlayed: function (albumId) { QbzPlayer.playAlbum(albumId) }
}
}
// ---- Popover layer -------------------------------------------------
// DV-4: ONE scrim closes both menus on an outside click. Tauri gives
// the genre popup a document-level handler (V:240-246) and the group
// menu none at all, which is a plain defect and not a design.
MouseArea {
id: outsideScrim
anchors.fill: parent
visible: root.genrePopupOpen || root.groupMenuOpen
z: 90
onPressed: {
root.genrePopupOpen = false
root.groupMenuOpen = false
}
}
// Group menu (V:578-598, S:1069-1104) — absolute, right-aligned under
// its trigger, 4px gap, min-width 160, z-index 100. ADR-009's "modals
// >= 3000" does not apply: this is a popover inside a view.
Rectangle {
id: groupMenu
visible: root.groupMenuOpen && groupBtn.visible
z: 100
width: Math.max(160, offItem.implicitTextWidth + 24)
height: 4 + offItem.height + alphaItem.height + 4
radius: 8
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// Position: the trigger's right edge, 4px below it.
x: navBar.x + navRight.x + groupBtn.x + groupBtn.width - width
y: navBar.y + groupBtn.y + groupBtn.height + 4
// S:1079 — `box-shadow: 0 4px 16px rgba(0,0,0,0.3)`.
Rectangle {
visible: !root.noShaders
z: -1
x: 0
y: 4
width: parent.width
height: parent.height
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.3)
layer.enabled: true
layer.effect: MultiEffect {
blurEnabled: true
blurMax: 16
blur: 1.0
}
}
Column {
y: 4
width: parent.width
DropdownItem {
id: offItem
// DV-12 — "Off" is hardcoded English at V:587, and the
// msgid already exists in all eight catalogues.
label: root.tr("Off")
selected: !root.groupingEnabled
onPicked: {
root.groupingEnabled = false
root.groupMenuOpen = false
}
}
DropdownItem {
id: alphaItem
label: root.tr("Alphabetical (A-Z)") // DV-12, V:594
selected: root.groupingEnabled
onPicked: {
root.groupingEnabled = true
root.groupMenuOpen = false
}
}
}
}
// Genre popup — a SIBLING lane's component (scene/SceneGenreFilter.qml).
// S:1133-1147 anchors it to the 36x36 funnel button:
// `top: calc(100% + 6px); right: 0` against `.genre-filter-container`
// (S:1129-1131), NOT against the nav bar.
SceneGenreFilter {
id: genrePopup
z: 200
open: root.genrePopupOpen && genreBtn.visible
visible: open
x: Math.max(0, navBar.x + navRight.x + genreBtn.x + genreBtn.width - width)
y: navBar.y + genreBtn.y + genreBtn.height + 6
genres: root.availableGenres
selected: root.activeGenres
onToggled: function (genre) {
var next = root.activeGenres.slice()
var i = next.indexOf(genre)
if (i === -1)
next.push(genre)
else
next.splice(i, 1)
root.activeGenres = next
}
onCleared: {
root.activeGenres = []
root.genrePopupOpen = false // V:542 closes with it
}
onCloseRequested: root.genrePopupOpen = false
}
}
// Hover tooltips for the three icon-only controls — Tauri's `title=`
// attributes (V:458, :487, :564), which the browser renders for free and
// QML does not. QbzTooltip is the shared overlay; it takes no pointer and
// owns no animator, so an idle one costs nothing.
QbzTooltip {
id: tips
anchors.fill: parent
z: 900
}
// MultiEffect needs shaders. Where there are none (offscreen smoke runs,
// and any platform on the software path) the two shadows are dropped
// rather than faked — the same detection RoundedImage.qml and
// Cortinilla.qml:186-187 do.
readonly property bool noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// Scroll sentinel — Tauri watches a 1px element pinned to the bottom of the
// hero with an IntersectionObserver (Grid:200-216) and hands the result up
// as `heroScrolledPast` (V:653). The header item's own bottom edge in
// content coordinates is the same measurement, and unlike `originY` it
// means one thing whether or not a header exists.
// DV-8: the `mode === "grid"` term is what resets it on a mode switch — in
// Tauri the sentinel simply stops existing and the flag sticks at whatever
// it held, so the compact header can be stuck on or off.
readonly property bool heroScrolledPast: root.mode === "grid"
&& list.headerItem !== null
&& list.contentY >= list.headerItem.y + list.headerItem.height
// ---- The two-row group menu's item (S:1082-1104) ----------------------
component DropdownItem: Item {
id: ddi
property string label: ""
property bool selected: false
readonly property real implicitTextWidth: ddText.implicitWidth
signal picked()
width: parent ? parent.width : 0
height: 32 // padding 8px + 12px text
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
radius: 6
color: ddArea.containsMouse ? theme.surfaceElevated : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
}
Text {
id: ddText
x: 12
anchors.verticalCenter: parent.verticalCenter
text: ddi.label
color: ddi.selected ? theme.accent
: (ddArea.containsMouse ? theme.textPrimary : theme.textSecondary)
font.pixelSize: 12
font.weight: ddi.selected ? theme.weightSemibold : theme.weightRegular
}
MouseArea {
id: ddArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ddi.picked()
}
}
// ---- The scene's grid card (Grid:267-286, S:358-418) ------------------
// 160x250 slot, NOT the shared cards/ArtistCard.qml (200x246). The contract
// (§4.4) settles this: reuse the primitives, write the scene's own
// delegate. Tauri's scene card is a tile with a 120px round portrait and a
// NAME — no follow, no play, no pin, no context menu, and NO SUBTITLE.
//
// The absent subtitle is deliberate and was verified against the reference
// rather than inherited: `albums_count` is set on the object (V:179) and
// rendered NOWHERE (Grid:270-285 is image + name), and the sibling list row
// carries a comment saying the count was removed on purpose — "albums_count
// from Qobuz API includes compilations, tributes, etc. — misleadingly high.
// Removed per #169" (List:269-270). Contract §13.1's note that albumsCount
// is the card subtitle describes Tauri's DATA, not its UI; putting the count
// on screen would re-introduce a line Tauri's authors deleted.
}