Files
qbz/crates/qbz-qt/qml/views/SearchView.qml
T
vicrodh b6cf06ff2a feat(source): wire My QBZ through the seam, and land the owner's container rules
Stage 2. The measured win: opening a 34-item collection now costs **2** library.db
opens instead of 23-34 — the local source owns a pooled handle and hands out no
guard, only `with(|db| ...)`, so a `!Send` `&LibraryDatabase` cannot cross an
await either. Bug 1 from the seam design is dead by construction, not by care.

The owner's rules, decided today, now live in one place instead of eight:
- **Collection** takes albums, singles and EPs from ANY source — qobuz, folders,
  Plex, and whatever is added next. **Mixtape** takes albums, playlists and
  tracks. This was a single boolean (`restrict_to_mixtape`) that could express
  neither release types nor "collections accept local".
- An unsupported combination must never be VISIBLE: the picker stops offering
  the impossible action, and the error stays a log-only safety net. Owner: "el
  bloqueo no debería ser visible nunca, más bien esto hay que ajustarlo en la UI".
- **Ephemeral honours its name** — playable, queueable, and offerable to nothing
  else.
- A row's source comes from the ROW. Eight QML sites built the add-to-mixtape
  payload with `"source": "qobuz"` written as a literal, so a Plex or local
  track was stored as Qobuz and afterwards could not play at all: the app asked
  the Qobuz API for an id that never existed there.

`QobuzSource` reads the client through a lens on every call instead of holding a
clone. That is not style: `qbz-core` REPLACES its client (core.rs:346 and :384,
both on paths that run before `set_session`), so a cached clone goes stale and
fails silently — the hazard the crate's own doc comment described.

The lens is installed in `on_boot`, first of everything, and finding out why is
the lesson of this commit. The gate was green — build, 142 tests, both QML
audits, a clean offscreen smoke — and the feature was visibly broken: every
Qobuz row showed "No results found", an empty Quality cell and a wrong "SINGLE"
type, while the Plex rows resolved fine. The registry is a OnceLock, so whoever
touches it first BUILDS it, and one built without the lens leaves Qobuz
permanently detached. Only driving the real app surfaced it; the crate's error
string named the cause exactly, which is the one thing that made it a ten-minute
fix instead of an afternoon.

cc
2026-07-30 17:14:40 -06:00

994 lines
46 KiB
QML

// Search results view — the QML port of search/SearchResultsView.slint
// (phase 15). One JSON document (QbzBridge.searchJson, search_qt.rs
// SearchPageDoc: query/tab/loading/filterIndex + the four category lists,
// totals, the most-popular hero, the carousel-only artists list).
//
// Layout (1:1): 56px toolbar ("Search" title + the searchType filter
// radios on the right), the five-tab strip (All / Albums / Tracks /
// Artists / Playlists), the scrollable body: All = Most-popular hero +
// Artists carousel + Albums carousel + Tracks preview (6) + Playlists
// carousel; per-type tabs = full grid/list + "Load more (n / total)".
//
// POC-NOTEs:
// - Card click actions: albums/artists navigate; tracks play; playlists
// open (openPlaylist is wired; the stale "inert" note was dropped in
// phase 21, when every card surface moved to the shared qml/ cards).
// - The most-popular track hero stays a DISTINCT component
// (SearchTrackHero below): in Slint the search hero is
// primitives/SearchTrackHero.slint, NOT discover/TrackCard.slint —
// the POC variant is 200x246 (centered play, quality as text) to match
// the ArtistGridCard hero slot it shares the row with.
// - The Slint's windowed grid virtualization is not replicated (page size
// is 20 — the whole set mounts).
//
// LOADING (a deliberate ADDITION over the Slint's bare LoadingSpinner): the
// view mounts QbzSkeleton placeholders in the shape of the tab that is
// coming, plus a per-card cover placeholder that clears when THAT card's
// cover lands. See the "skeleton plumbing" block below for the cost.
//
// SIZE: this file is over the 500-line guideline (it already was at 638
// before the skeleton work). It is NOT split because every section is one
// tab of one document and the split would be arbitrary; the skeleton
// additions are the composite mounts, which are ~8 lines each precisely to
// keep it from growing further.
import QtQuick
import QtQuick.Controls
import QtQuick.Window
import com.blitzfc.qbz
import "../cards"
import "../controls"
import "../rows"
import "../theme"
Rectangle {
id: root
color: ambientOn ? "transparent" : theme.surfaceMain
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
radius: 12
QbzTheme { id: theme }
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzBridge.searchJson)
} catch (e) {
return ({})
}
}
readonly property var albums: doc.albums || []
readonly property var tracks: doc.tracks || []
readonly property var artists: doc.artists || []
readonly property var artistsCarousel: doc.artistsCarousel || []
readonly property var playlists: doc.playlists || []
readonly property var mp: doc.mostPopular || ({})
readonly property bool loading: doc.loading === true
readonly property int tab: doc.tab || 0
readonly property int filterIndex: doc.filterIndex || 0
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property int previewCap: 6
// ======================= skeleton plumbing ===========================
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
// hidden, or the window minimized/hidden. NEVER on lost focus (a tiling
// desktop keeps windows visible and unfocused).
property bool skelPhase: false
readonly property bool windowShowing: root.Window.window
? (root.Window.window.visibility !== Window.Minimized
&& root.Window.window.visibility !== Window.Hidden)
: true
// A card is pending while it HAS a cover url and no local path yet.
// Recomputed only when the document is republished (which is also when
// covers land), never per frame.
function anyPending(rows) {
for (var i = 0; i < rows.length; i++) {
if ((rows[i].artUrl || "") !== "" && (rows[i].artPath || "") === "") return true
}
return false
}
readonly property bool heroPending: {
var k = mp.kind || ""
if (k === "" || k === "artist") return false
var c = k === "album" ? (mp.album || ({})) : (mp.track || ({}))
return (c.artUrl || "") !== "" && (c.artPath || "") === ""
}
readonly property bool artPending: anyPending(albums) || anyPending(tracks)
|| anyPending(playlists) || heroPending
// The pulse must OUTLIVE `artPending`: that flag drops when the last PATH
// lands, but each of those cards still has a decode + canvas raster ahead
// of it and its placeholder is still up (QbzSkeleton's handover). Without
// the grace the tiles freeze mid-shimmer for the rest of the wait.
property bool artHold: false
Timer { id: artHoldOff; interval: 1500; onTriggered: root.artHold = false }
onArtPendingChanged: { root.artHold = true; artHoldOff.restart() }
// "Load more" has NO bridge-side busy flag: search_qt.rs only sets
// doc.loading in submit(), never in load_more(). So the appended-page
// placeholder is tracked locally — armed on the click, disarmed the
// moment that tab's array actually grows. A page that comes back empty
// (end of the result set) never grows it, which is why every appended
// placeholder carries settleMs.
property int moreTab: -1
property int moreFrom: 0
function rowsFor(t) {
return t === 1 ? albums : t === 2 ? tracks
: t === 3 ? artists : t === 4 ? playlists : []
}
readonly property bool morePending: root.moreTab >= 0
&& root.moreTab === root.tab
&& root.rowsFor(root.moreTab).length === root.moreFrom
function armLoadMore(t) {
root.moreTab = t
root.moreFrom = root.rowsFor(t).length
QbzBridge.searchLoadMore(t)
}
Timer {
interval: 900
repeat: true
running: (root.loading || root.artPending || root.artHold || root.morePending)
&& root.visible && root.windowShowing
onTriggered: root.skelPhase = !root.skelPhase
}
// Per-item cover placeholder, mounted by every card delegate over the
// 200x200 artwork square. A bare Rectangle, so it does not eat the
// card's hover/click areas underneath; it clears when THIS card's cover
// lands, so a page resolves progressively instead of as a lump.
// ARTISTS are deliberately excluded everywhere: ArtistCard already draws
// a designed round gradient+glyph portrait for a missing cover, which
// reads as a portrait rather than as a hole (same rule as LibraryView).
// NOTE: an inline `component` does NOT see this file's outer ids, so it
// takes NO `root.` reference — every call site passes `phase:` itself.
component CardArtSkeleton: QbzSkeleton {
property var card: ({})
variant: "art"
width: 200
height: 200
// HANDOVER, not "the path landed": the cards seal their RoundedImage
// away, so this rides QbzSkeleton's probe arm — the same pixmap-cache
// entry the card is loading, so no second decode — and retires when
// that decode finishes. Gating on `artPath !== ""` (what this used to
// do) drops the placeholder while the card's canvas is still blank.
pending: (card.artUrl || "") !== ""
coverSource: card.artPath || ""
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
}
// Track hero (SearchTrackHero: the 200x246 track card + the quality
// label as TEXT under the meta).
component SearchTrackHero: Rectangle {
property var card: ({})
property string qualityLabel: ""
color: "transparent"
readonly property bool overlayOn: heroArtArea.containsMouse
Column {
spacing: 0
Rectangle {
width: 200
height: 200
radius: theme.radiusSm
color: theme.surfaceElevated
clip: true
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
radius: theme.radiusSm
}
Rectangle {
anchors.fill: parent
radius: theme.radiusSm
color: "#000000"
opacity: parent.parent.overlayOn ? 0.6 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Rectangle {
anchors.centerIn: parent
width: 44
height: 44
radius: 22
opacity: parent.parent.overlayOn ? 1.0 : 0.0
color: heroPlayArea.containsMouse ? "#d6ffffff" : "#ffffff"
QbzIcon { name: "play-fill"; width: 18; height: 18; anchors.centerIn: parent; tintName: "black" }
MouseArea {
id: heroPlayArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playTrack(card.id)
}
}
MouseArea {
id: heroArtArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzPlayer.playTrack(card.id)
}
}
Item { width: 1; height: 6 }
Text {
width: 200
height: 20
text: card.title || ""
color: theme.textPrimary
font.pixelSize: theme.fontBody - 2
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
width: 200
height: 18
text: QbzSession.tr("Track", QbzSession.trRev) + " • " + (card.artist || "")
color: theme.textMuted
font.pixelSize: theme.fontLink - 1
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Text {
visible: qualityLabel !== ""
width: 200
text: qualityLabel
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
}
}
// Track row (primitives/TrackRow.slint, 50px — number/play, title +
// explicit, artist, duration, quality, ⋯ menu).
// "Load more (loaded / total)" (LoadMoreButton).
component LoadMoreButton: Item {
property int loaded: 0
property int total: 0
// The click is reported OUT (the call site arms the appended-page
// placeholder and then calls the bridge). An inline component does
// not see this file's outer ids, so it must not reach for `root`.
signal loadMore()
width: parent ? parent.width : 0
visible: loaded < total
height: visible ? 44 : 0
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: 6
width: lmText.implicitWidth + 36
height: 32
radius: theme.radiusSm
color: lmArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
Text {
id: lmText
anchors.centerIn: parent
text: QbzSession.tr("Load more", QbzSession.trRev) + " (" + loaded + " / " + total + ")"
color: theme.textSecondary
font.pixelSize: 13
}
MouseArea {
id: lmArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.parent.loadMore()
}
}
}
// searchType filter radio (FilterRadio: 14px ring + 8px accent dot).
component FilterRadio: Item {
property string label: ""
property bool selected: false
signal picked()
width: frRow.width
height: 22
Row {
id: frRow
spacing: 6
height: parent.height
Rectangle {
width: 14
height: 14
radius: 7
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
border.width: 1.5
border.color: parent.parent.selected ? theme.accent : theme.textMuted
Rectangle {
width: 8
height: 8
radius: 4
anchors.centerIn: parent
color: parent.parent.selected ? theme.accent : "transparent"
}
}
Text {
height: parent.height
text: parent.parent.label
color: (parent.parent.selected || frArea.containsMouse) ? theme.textPrimary : theme.textSecondary
font.pixelSize: 12
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: frArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: parent.picked()
}
}
// ============================ the view ================================
Column {
anchors.fill: parent
spacing: 0
// --- Row 1: title (left) + filter radios (right) -------------------
Item {
width: parent.width
height: 56
Text {
x: 32
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Search", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightBold
}
Row {
visible: root.hasResults
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 16
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzBridge.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzBridge.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzBridge.searchFilterChanged(3) }
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzBridge.searchFilterChanged(4) }
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); selected: root.filterIndex === 5; onPicked: QbzBridge.searchFilterChanged(5) }
Rectangle {
visible: root.filterIndex !== 0
width: 24
height: 24
radius: 12
anchors.verticalCenter: parent.verticalCenter
color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" }
MouseArea {
id: clrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.searchFilterChanged(0)
}
}
}
}
// --- Row 2: the five-tab strip (collapsed during the initial load) --
Item {
visible: !(root.loading && !root.hasResults)
width: parent.width
height: 40
Row {
x: 32
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 22
Repeater {
model: [
{ "t": 0, "label": QbzSession.tr("All", QbzSession.trRev) },
{ "t": 1, "label": QbzSession.tr("Albums", QbzSession.trRev) },
{ "t": 2, "label": QbzSession.tr("Tracks", QbzSession.trRev) },
{ "t": 3, "label": QbzSession.tr("Artists", QbzSession.trRev) },
{ "t": 4, "label": QbzSession.tr("Playlists", QbzSession.trRev) },
]
delegate: Column {
required property var modelData
spacing: 5
Text {
text: modelData.label
color: root.tab === modelData.t ? theme.textPrimary
: (stabArea.containsMouse ? theme.textSecondary : theme.textMuted)
font.pixelSize: 14
font.weight: root.tab === modelData.t ? theme.weightBold : theme.weightMedium
MouseArea {
id: stabArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.searchTabChanged(modelData.t)
}
}
Rectangle {
width: parent.width
height: 2
radius: 1
color: root.tab === modelData.t ? theme.accent : "transparent"
}
}
}
}
}
// --- Scrollable body ------------------------------------------------
Item {
width: parent.width
height: parent.height - 56 - 40
Flickable {
id: bodyFlick
anchors.fill: parent
contentWidth: width
contentHeight: bodyCol.height + 32
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: bodyCol
x: 32
width: bodyFlick.width - 64
spacing: 28
// Fade in once the search completes (SearchResultsView).
opacity: root.loading ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 280; easing.type: Easing.InOutQuad } }
// ---- Most popular + Artists (All tab) ------------------
Row {
visible: root.tab === 0 && (root.mp.kind || "") !== ""
width: parent.width
spacing: 24
// Hero column, fixed 200px (the carousel always starts
// at the same x regardless of the hero kind).
Column {
width: 200
spacing: 12
Row {
spacing: 8
QbzIcon { name: "award"; width: 18; height: 18; tintName: "warning" }
Text {
text: QbzSession.tr("Most popular", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
}
Item {
width: 200
height: 246
ArtistCard {
visible: root.mp.kind === "artist"
anchors.horizontalCenter: parent.horizontalCenter
item: root.mp.artist || ({})
artSource: (root.mp.artist || ({})).artPath || ""
// Same two fields the album hero below
// carries: the pin state (or the glyph
// lies and the first click UN-pins) and
// the REMOTE url the pin payload stores
// (artPath is a local cache path).
isPinned: (root.mp.artist || ({})).isPinned === true
artworkUrl: (root.mp.artist || ({})).artUrl || ""
}
AlbumCard {
visible: root.mp.kind === "album"
anchors.horizontalCenter: parent.horizontalCenter
albumId: (root.mp.album || ({})).id || ""
title: (root.mp.album || ({})).title || ""
artist: (root.mp.album || ({})).artist || ""
artistId: (root.mp.album || ({})).artistId || ""
genre: (root.mp.album || ({})).genre || ""
year: (root.mp.album || ({})).year || ""
qualityTier: (root.mp.album || ({})).qualityTier || ""
artSource: (root.mp.album || ({})).artPath || ""
// search_qt::map_album stamps the heart
// from fav_cache; false inverted the
// first click on an album already in the
// library.
isFavorite: (root.mp.album || ({})).isFavorite === true
// The row carries the pin state
// (search_qt `map_album`); hardcoding
// false made the glyph lie and turned
// the first click into an UN-pin.
isPinned: (root.mp.album || ({})).isPinned === true
artworkUrl: (root.mp.album || ({})).artUrl || ""
}
SearchTrackHero {
visible: root.mp.kind === "track"
anchors.horizontalCenter: parent.horizontalCenter
card: root.mp.track || ({})
qualityLabel: root.mp.qualityLabel || ""
}
// Hero cover placeholder (album/track arms —
// the artist arm keeps ArtistCard's designed
// round portrait).
CardArtSkeleton {
anchors.horizontalCenter: parent.horizontalCenter
card: root.mp.kind === "album" ? (root.mp.album || ({}))
: root.mp.kind === "track" ? (root.mp.track || ({}))
: ({})
phase: root.skelPhase
}
}
}
// Artists carousel — fills the rest of the row.
Column {
visible: root.artistsCarousel.length > 0
width: parent.width - 224
spacing: 12
QbzSectionHeader {
title: QbzSession.tr("Artists", QbzSession.trRev)
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(3)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.artistsCarousel
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
}
// Artists carousel when there is no most-popular hero.
Column {
visible: root.tab === 0 && (root.mp.kind || "") === "" && root.artists.length > 0
width: parent.width
spacing: 12
QbzSectionHeader {
title: QbzSession.tr("Artists", QbzSession.trRev)
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(3)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.artists
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
}
// ---- Albums --------------------------------------------
Column {
visible: root.tab === 0 && root.albums.length > 0
width: parent.width
spacing: 12
QbzSectionHeader {
title: QbzSession.tr("Albums", QbzSession.trRev)
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(1)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.albums
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
// search_qt::map_album stamps the heart
// from fav_cache; false made the glyph lie
// and inverted the first click.
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
// Albums tab: the full grid + Load more.
GridView {
visible: root.tab === 1
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
cellWidth: 224
cellHeight: 270
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.albums
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: modelData.artPath || ""
isFavorite: modelData.isFavorite === true
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
// The page that "Load more" asked for, in the shape it
// will arrive in. One row of cells, one animator; it
// disappears the moment the array grows (or settles out
// if the page comes back empty).
QbzSkeleton {
visible: root.tab === 1 && root.morePending
variant: "cardGrid"
width: parent.width
height: visible ? 270 : 0
cellW: 224
cellH: 270
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 1
loaded: root.albums.length
total: root.doc.albumsTotal || 0
onLoadMore: root.armLoadMore(1)
}
// ---- Tracks --------------------------------------------
Column {
visible: (root.tab === 0 || root.tab === 2) && root.tracks.length > 0
width: parent.width
spacing: 4
QbzSectionHeader {
title: QbzSession.tr("Tracks", QbzSession.trRev)
showViewAll: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(2)
}
Repeater {
model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap)
delegate: TrackRow {
item: modelData
number: index + 1
menuShowFavorite: false
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: the results page has ONE source.
// `search_qt::TrackRow` (:171) carries no
// source word because every row is mapped from
// a Qobuz `Track` (`map_track`, :320-336) and
// the local "on this device" sections are not
// ported (search_qt.rs:24-27) — the cortinilla
// rows, which CAN be local, carry their own
// `source` field and are a different document.
// `item.id` is a Qobuz catalog id by
// construction, not by assumption here.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
}
QbzSkeleton {
visible: root.tab === 2 && root.morePending
variant: "rowList"
width: parent.width
height: visible ? 200 : 0
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 2
loaded: root.tracks.length
total: root.doc.tracksTotal || 0
onLoadMore: root.armLoadMore(2)
}
// ---- Artists grid (per-type tab) ------------------------
GridView {
visible: root.tab === 3
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 16) / 216))) * 262 - 16)
cellWidth: 216
cellHeight: 262
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.artists
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
artworkUrl: modelData.artUrl || ""
}
}
// Artists: ROUND cells (ArtistGridCard's portrait), and
// no per-item overlay anywhere — a missing artist photo
// has a designed placeholder already.
QbzSkeleton {
visible: root.tab === 3 && root.morePending
variant: "cardGrid"
width: parent.width
height: visible ? 262 : 0
cellW: 216
cellH: 262
roundCells: true
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 3
loaded: root.artists.length
total: root.doc.artistsTotal || 0
onLoadMore: root.armLoadMore(3)
}
// ---- Playlists ------------------------------------------
Column {
visible: root.tab === 0 && root.playlists.length > 0
width: parent.width
spacing: 12
QbzSectionHeader {
title: QbzSession.tr("Playlists", QbzSession.trRev)
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(4)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.playlists
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
PlaylistCard {
// artworkUrl is not passed: the card
// defaults it to `item.artUrl`, which is
// this row's remote cover.
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
GridView {
visible: root.tab === 4
width: parent.width
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
cellWidth: 224
cellHeight: 270
clip: true
boundsBehavior: Flickable.StopAtBounds
interactive: false
model: root.playlists
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
QbzSkeleton {
visible: root.tab === 4 && root.morePending
variant: "cardGrid"
width: parent.width
height: visible ? 270 : 0
cellW: 224
cellH: 270
phase: root.skelPhase
settleMs: 8000
}
LoadMoreButton {
visible: root.tab === 4
loaded: root.playlists.length
total: root.doc.playlistsTotal || 0
onLoadMore: root.armLoadMore(4)
}
}
// ---- Loading skeleton ---------------------------------------
// Shape of the tab that is coming, not a spinner. Mounted for
// the WHOLE of `loading`, not only the first search: bodyCol
// fades to 0 while a new query is in flight, so a re-search
// used to show an empty page with no affordance at all.
// COST: at most 8 animators on the All tab (3 title bars, 1
// hero card, 2 card composites, 1 slim-row composite); one
// composite covers a whole viewport row on ONE animator.
Item {
id: searchSkel
visible: root.loading
x: 32
y: 0
width: bodyFlick.width - 64
height: bodyFlick.height
// All tab: hero + artists carousel + albums + tracks.
Column {
visible: root.tab === 0
width: parent.width
spacing: 28
Row {
width: parent.width
spacing: 24
Column {
width: 200
spacing: 12
QbzSkeleton { variant: "block"; width: 160; height: 22; phase: root.skelPhase }
QbzSkeleton { variant: "card"; width: 200; phase: root.skelPhase }
}
Column {
width: parent.width - 224
spacing: 12
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "cardGrid"
width: parent.width
height: 246
cellW: 232
cellH: 246
roundCells: true
phase: root.skelPhase
}
}
}
Column {
width: parent.width
spacing: 12
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "cardGrid"
width: parent.width
height: 246
cellW: 232
cellH: 246
phase: root.skelPhase
}
}
Column {
width: parent.width
spacing: 8
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
QbzSkeleton {
variant: "rowList"
width: parent.width
height: 300
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
}
}
// Albums / Playlists tabs: the 224x270 grid.
QbzSkeleton {
visible: root.tab === 1 || root.tab === 4
variant: "cardGrid"
anchors.fill: parent
cellW: 224
cellH: 270
phase: root.skelPhase
}
// Tracks tab: the 50px TrackRow list.
QbzSkeleton {
visible: root.tab === 2
variant: "rowList"
anchors.fill: parent
rowH: 50
rowGap: 0
rowArtSize: 36
phase: root.skelPhase
}
// Artists tab: round cells on the 216x262 pitch.
QbzSkeleton {
visible: root.tab === 3
variant: "cardGrid"
anchors.fill: parent
cellW: 216
cellH: 262
roundCells: true
phase: root.skelPhase
}
}
Text {
visible: !root.loading && !root.hasResults
x: 32
y: 8
text: QbzSession.tr("No results.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 14
}
}
QbzScrollBar {
target: bodyFlick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}