The ArtistScene discovery fans out many MusicBrainz requests, so a throttled MB can keep it loading a while. After ~10s the loading screen shows a "this can take a few minutes" note; after 5 minutes it swaps the spinner for a Retry (a late success still swaps in the grid on its own). Both derive from the existing pulse-driven clock, so no new Timer and the repaint law holds. Two new strings across all eight locales. cc
1587 lines
77 KiB
QML
1587 lines
77 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
|
|
property bool kioskHost: false
|
|
|
|
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: ({})
|
|
// 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) {
|
|
if (root._asked[key] !== true || root.coverMap[key] === path) return
|
|
root._coverInbox[key] = path
|
|
if (!coverFlush.running)
|
|
coverFlush.start()
|
|
}
|
|
}
|
|
function requestCovers(urls) {
|
|
var out = []
|
|
for (var i = 0; i < urls.length; 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))
|
|
}
|
|
// The native ListView already mounts two rows around the viewport. Ask
|
|
// for that bounded range, including after scrolling past the first page.
|
|
function requestVisibleCovers(view) {
|
|
if (!root.isReady || !view || !view.visible || !root.visible)
|
|
return
|
|
var rows = root.rowModel
|
|
if (rows.length === 0) return
|
|
var first = view.indexAt(4, view.contentY + 1)
|
|
var last = view.indexAt(4, view.contentY + Math.max(1, view.height) - 1)
|
|
if (first < 0) first = 0
|
|
if (last < 0) last = Math.min(rows.length - 1,
|
|
first + Math.ceil(view.height / (root.cardH + root.rowGap)))
|
|
var start = first
|
|
first = Math.max(0, first - 2)
|
|
last = Math.min(rows.length - 1, last + 2)
|
|
var urls = []
|
|
for (var offset = 0; offset <= last - first; offset++) {
|
|
var i = start + offset
|
|
if (i > last) i = first + i - last - 1
|
|
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)))
|
|
|
|
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
|
|
// Derived from the pulse-driven `ms`, so no extra Timer: a busy-
|
|
// connection heads-up after ~10s, and a 5-minute safety net.
|
|
readonly property bool longWait: loadingLayer.ms > 10000 && !loadingLayer.timedOut
|
|
readonly property bool timedOut: loadingLayer.ms > 300000
|
|
|
|
// 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 || loadingLayer.timedOut)
|
|
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 {
|
|
visible: !loadingLayer.timedOut
|
|
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
|
|
}
|
|
}
|
|
|
|
// Busy-connection heads-up after the first ~10s so a slow
|
|
// MusicBrainz reads as expected, not as a hang.
|
|
Text {
|
|
visible: loadingLayer.longWait
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: root.tr("This can take a few minutes when MusicBrainz is busy.")
|
|
color: theme.textMuted
|
|
font.pixelSize: 12
|
|
width: 320
|
|
wrapMode: Text.WordWrap
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
}
|
|
|
|
// 5-minute safety net: if discovery is still running this long,
|
|
// MusicBrainz is throttling us hard. Offer Retry instead of an endless
|
|
// spinner; a late success still swaps in the grid on its own.
|
|
Column {
|
|
visible: loadingLayer.timedOut
|
|
anchors.centerIn: parent
|
|
spacing: 16
|
|
width: 360
|
|
Text {
|
|
width: parent.width
|
|
text: root.tr("This is taking longer than usual — MusicBrainz may be busy. Try again in a moment.")
|
|
color: theme.textSecondary
|
|
font.pixelSize: 14
|
|
wrapMode: Text.WordWrap
|
|
horizontalAlignment: Text.AlignHCenter
|
|
}
|
|
Rectangle {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: sceneTimeoutLabel.implicitWidth + 40
|
|
height: root.kioskHost ? 64 : sceneTimeoutLabel.implicitHeight + 16
|
|
radius: 8
|
|
color: sceneTimeoutArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
|
|
Text {
|
|
id: sceneTimeoutLabel
|
|
anchors.centerIn: parent
|
|
text: root.tr("Retry")
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
}
|
|
MouseArea {
|
|
id: sceneTimeoutArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
// Reset the pulse clock too: the loading layer never went
|
|
// invisible, so onVisibleChanged would not re-arm `ms`.
|
|
onClicked: {
|
|
QbzScene.retry()
|
|
loadingLayer.ms = 0
|
|
loadingLayer.lastPulse = QbzShell.pulseMs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================= 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: root.kioskHost ? 0 : 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 ? (root.kioskHost ? 64 : 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: root.kioskHost ? 44 : 36 }
|
|
|
|
// --- right: search · genre · view toggle · group (V:456-599) ---
|
|
Row {
|
|
id: navRight
|
|
x: parent.width - width
|
|
spacing: 8 // S:960
|
|
height: root.kioskHost ? 44 : 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
|
|
kioskHost: root.kioskHost
|
|
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: root.kioskHost ? 44 : 36
|
|
height: root.kioskHost ? 44 : 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: root.kioskHost ? 44 : 36
|
|
height: root.kioskHost ? 44 : 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: root.kioskHost ? 44 : 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: root.kioskHost ? 64 : 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)
|
|
function reportCovers() {
|
|
if (visible && !coverWindow.running) coverWindow.start()
|
|
}
|
|
onContentYChanged: reportCovers()
|
|
onModelChanged: reportCovers()
|
|
onHeightChanged: reportCovers()
|
|
onVisibleChanged: reportCovers()
|
|
Timer {
|
|
id: coverWindow
|
|
interval: 180
|
|
onTriggered: root.requestVisibleCovers(list)
|
|
}
|
|
|
|
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 {
|
|
kioskHost: root.kioskHost
|
|
// 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: root.kioskHost ? 64 : 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: root.kioskHost ? 44 : 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.
|
|
}
|