Files
qbz/crates/qbz-qt/qml/views/SearchView.qml
T
vicrodh ed7fa588ea fix(qt): elevated controls lift on hover instead of darkening
Buttons that rest on surfaceElevated used surfaceHover for their hover
fill: a 6% white wash meant for rows that rest transparent. Over an
elevated button it lands DARKER than the rest fill — on a modal's dark
ground the Cancel/Save/footer buttons went near-black under the pointer.

QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived
so every palette has it), its ambient half-alpha twin, and the
elevatedFill / elevatedHoverFill pair an ambient-aware control paints.
Every site with the old pairing (65 across modals, toolbars, selects,
circle actions, chips and cards) now hovers to the lift.

cc
2026-09-14 00:49:54 -06:00

1367 lines
66 KiB
QML

// Search results view — the QML port of search/SearchResultsView.slint
// (phase 15). One JSON document (QbzSearch.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.
// - Per-type grids and the track tape are windowed against the outer page
// Flickable; their full footprint remains stable for scrolling, while only
// the viewport runway exists as QML delegates.
//
// 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.
//
// LOAD MORE: the four per-tab buttons are the shared controls/QbzLoadMore.qml
// on its BORDERED arm (that arm IS this file's old `LoadMoreButton`, pixel for
// pixel — see that file's header, item c). Two things moved with it:
// - the appended-page placeholder now hangs BELOW its button instead of
// above it. Each tab had a standalone QbzSkeleton sitting on top of the
// button; that is the wrong side, because the page being fetched is
// appended UNDER the content already on screen and the button is the
// bottom of that content. The knobs (pitch, settleMs) came across.
// - the appended DELEGATES fade in — the other half of what the owner asked
// for ("que la aparicion de lo que se cargue, sea smooth"). Only this file
// can do that half: only the host knows which delegates are new. See the
// `fadeAt` block below.
//
// 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: theme.ambientOn
radius: 12
QbzTheme { id: theme }
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzSearch.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 localSections: doc.localSections || []
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
// The query echo (search_qt::SearchPageDoc.query). Read only as an
// IDENTITY: when it changes, the page on screen is a different result set
// and any pending "fade the tail" threshold is stale (see clearFade).
readonly property string query: doc.query || ""
readonly property bool hasQobuzResults: albums.length + tracks.length + artists.length + playlists.length > 0
readonly property bool hasResults: hasQobuzResults || localSections.length > 0
readonly property int previewCap: 6
// The per-type result tabs live inside the page Flickable, so a GridView
// sized to its whole content is not virtualized: from that GridView's
// perspective every row is in its viewport. Sample one shared vertical
// band and feed only that slice to the manual grids/tape below. Scroll
// events run a constant-time coverage guard synchronously; the old 40ms
// delayed sampler could leave the viewport outside the mounted slice for
// visible blank frames after Load more changed the page footprint.
property real bodyBandTop: 0
property real bodyBandBottom: 0
function sampleBodyBand() {
if (!bodyFlick || bodyFlick.height <= 0)
return
root.bodyBandTop = Math.max(0, bodyFlick.contentY - 2 * bodyFlick.height)
root.bodyBandBottom = bodyFlick.contentY + 3 * bodyFlick.height
}
function ensureBodyBandCoverage() {
if (!root.visible || !bodyFlick || bodyFlick.height <= 0)
return
var top = bodyFlick.contentY
var bottom = top + bodyFlick.height
var runway = bodyFlick.height
if (top < root.bodyBandTop || bottom > root.bodyBandBottom
|| (top > runway && top - root.bodyBandTop < runway)
|| (bottom < bodyFlick.contentHeight - runway
&& root.bodyBandBottom - bottom < runway))
root.sampleBodyBand()
}
// ======================= 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 (QbzLoadMore mounts its own with the same
// 8000ms bound this file used when it drew the block by hand).
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
// ---- the appended TAIL fades in (the "smooth" half) -------------------
// Only the tail, and only once. Both halves of that sentence are load
// bearing:
// - only the TAIL, because every republish of this document hands the
// views a brand-new array and recreates EVERY delegate. search_qt.rs
// republishes on the artwork pass, on tab_changed, on filter_changed
// and on apply_pin_change (its doc comment at :990 spells this out).
// A blanket fade would therefore re-dissolve the whole grid when a
// cover lands or a pin is clicked — a flicker, not a polish. So a
// delegate fades only when its index is at or past the row count that
// was on screen when the button was pressed.
// - only ONCE, because that threshold would otherwise survive into the
// NEXT republish of the same page (load_more publishes the rows, then
// the artwork pass publishes again ~a second later) and blink the new
// cards a second time. `fadeRetire` drops it a beat after the page
// lands.
// `fadeTab` scopes it to the tab that asked: the four tabs share one
// document, so a threshold armed on Albums must not fade Playlists.
property int fadeTab: -1
property int fadeFrom: -1
function fadeAt(t, i) {
return root.fadeTab === t && root.fadeFrom >= 0 && i >= root.fadeFrom
}
function clearFade() {
root.fadeTab = -1
root.fadeFrom = -1
}
Timer {
id: fadeRetire
// > the 220ms delegate fade, and long enough for the views to have
// built the new delegates (a model swap is applied in the polish
// step, not synchronously).
interval: 300
onTriggered: root.clearFade()
}
onMorePendingChanged: if (!root.morePending && root.fadeFrom >= 0) fadeRetire.restart()
// A new query, a filter re-query or a tab switch is a NEW identity — the
// stale threshold must never fade the first page of the next thing.
onQueryChanged: root.clearFade()
onFilterIndexChanged: root.clearFade()
onTabChanged: {
root.clearFade()
root.sampleBodyBand()
}
function armLoadMore(t) {
root.moreTab = t
root.moreFrom = root.rowsFor(t).length
// Armed here, for all four tabs at once: the tail starts exactly
// where the current page ends. BEFORE the bridge call, because the
// reply may republish before this function yields.
root.fadeTab = t
root.fadeFrom = root.moreFrom
moreSettle.restart()
QbzSearch.searchLoadMore(t)
}
// `morePending` clears only when the tab's array GROWS — and search_qt::
// load_more never republishes on a failed fetch (main.rs just logs), nor
// grows the array on a page whose rows were all blacklisted away. Left
// unbounded, a stuck `morePending` now DISARMS the button forever (the
// old LoadMoreButton stayed clickable; QbzLoadMore's busy arm does not)
// and keeps the placeholder block's height reserved after its shimmer
// settles. Same 8s bound ArtistView.releaseSettle uses for the identical
// reason, after which the button comes back armed for a retry.
Timer {
id: moreSettle
interval: 8000
repeat: false
onTriggered: root.moreTab = -1
}
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: ({})
property string resolvedSource: card.artPath || ""
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: artworkItem && artworkItem.artworkImmediate ? "" : resolvedSource
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
}
// A flat, fixed-pitch result grid whose footprint covers the full result
// set while only the sampled rows exist as QML objects. This is the same
// ownership boundary as Home's vertical ListView, adapted to the composite
// Search page where the outer Flickable owns scrolling.
component SearchResultGrid: Item {
id: resultGrid
property var rows: []
property string kind: "album" // album | artist | playlist
property int tabId: 1
property int cellW: 224
property int cellH: 270
property int cardW: 200
property int cardH: 246
property real bandTop: 0
property real bandBottom: 0
property bool phase: false
property int fadeTab: -1
property int fadeFrom: -1
property string collectionKey: ""
// Artwork follows the same bounded runway as delegates. Page-2 rows
// are not bulk-republished after their downloads; each URL lands in
// this local map through the shared one-key-at-a-time artwork signal.
property var artMap: ({})
property var artAsked: ({})
function artOf(card) {
if (!card)
return ""
var url = card.artUrl || ""
return (url !== "" && resultGrid.artMap[url])
? resultGrid.artMap[url] : (card.artPath || "")
}
function reportArtWindow() {
if (!resultGrid.visible)
return
var pending = []
for (var i = resultGrid.mountedFrom; i < resultGrid.mountedTo; i++) {
var card = resultGrid.rows[i] || ({})
var url = card.artUrl || ""
if (url === "" || (card.artPath || "") !== ""
|| resultGrid.artMap[url] || resultGrid.artAsked[url] === true)
continue
resultGrid.artAsked[url] = true
pending.push(url)
}
if (pending.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(pending))
}
onCollectionKeyChanged: {
resultGrid.artMap = ({})
resultGrid.artAsked = ({})
resultGrid.reportArtWindow()
}
onRowsChanged: resultGrid.reportArtWindow()
onMountedFromChanged: resultGrid.reportArtWindow()
onMountedToChanged: resultGrid.reportArtWindow()
onVisibleChanged: resultGrid.reportArtWindow()
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (resultGrid.artAsked[key] !== true || resultGrid.artMap[key] === path)
return
var next = Object.assign({}, resultGrid.artMap)
next[key] = path
resultGrid.artMap = next
}
}
// The final card has no trailing gutter: six 200px cards on a 224px
// pitch consume 6*200 + 5*24, not 6*224.
readonly property int columns: Math.max(1,
Math.floor((width + cellW - cardW) / cellW))
readonly property int rowCount: Math.ceil(rows.length / columns)
readonly property int firstRow: Math.max(0,
Math.floor((bandTop - y) / cellH))
readonly property int lastRow: Math.max(firstRow,
Math.ceil((bandBottom - y) / cellH))
readonly property int mountedFrom: Math.min(rows.length, firstRow * columns)
readonly property int mountedTo: Math.min(rows.length,
Math.max(mountedFrom, (lastRow + 1) * columns))
height: rowCount > 0 ? rowCount * cellH - (cellH - cardH) : 0
Repeater {
model: resultGrid.visible
? Math.max(0, resultGrid.mountedTo - resultGrid.mountedFrom) : 0
delegate: Item {
id: resultCell
required property int index
readonly property int globalIndex: resultGrid.mountedFrom + index
readonly property var cardData: resultGrid.rows[globalIndex] || ({})
readonly property bool fadeIn: resultGrid.fadeTab === resultGrid.tabId
&& resultGrid.fadeFrom >= 0 && globalIndex >= resultGrid.fadeFrom
x: (globalIndex % resultGrid.columns) * resultGrid.cellW
y: Math.floor(globalIndex / resultGrid.columns) * resultGrid.cellH
width: resultGrid.cardW
height: resultGrid.cardH
opacity: resultCell.fadeIn ? 0 : 1
Behavior on opacity {
enabled: resultCell.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
// The sampled slice reuses viewport slots as its first global
// index moves. Card actions intentionally break optimistic
// scalar bindings, so restore them at the identity edge.
function restoreMutableBindings() {
if (!cardLoader.item)
return
cardLoader.item.isPinned = Qt.binding(function () {
return resultCell.cardData.isPinned === true
})
if (resultGrid.kind === "album") {
cardLoader.item.isFavorite = Qt.binding(function () {
return resultCell.cardData.isFavorite === true
})
}
}
onCardDataChanged: resultCell.restoreMutableBindings()
Component {
id: albumResult
AlbumCard {
albumId: resultCell.cardData.id
title: resultCell.cardData.title
artist: resultCell.cardData.artist
artistId: resultCell.cardData.artistId
genre: resultCell.cardData.genre
year: resultCell.cardData.year
qualityTier: resultCell.cardData.qualityTier
qualityDetail: resultCell.cardData.qualityDetail || ""
artSource: resultGrid.artOf(resultCell.cardData)
isFavorite: resultCell.cardData.isFavorite === true
isPinned: resultCell.cardData.isPinned === true
artworkUrl: resultCell.cardData.artUrl || ""
}
}
Component {
id: artistResult
ArtistCard {
item: resultCell.cardData
artSource: resultGrid.artOf(resultCell.cardData)
isPinned: resultCell.cardData.isPinned === true
artworkUrl: resultCell.cardData.artUrl || ""
}
}
Component {
id: playlistResult
PlaylistCard {
item: resultCell.cardData
artSource: resultGrid.artOf(resultCell.cardData)
isPinned: resultCell.cardData.isPinned === true
}
}
Loader {
id: cardLoader
anchors.fill: parent
sourceComponent: resultGrid.kind === "artist" ? artistResult
: resultGrid.kind === "playlist" ? playlistResult
: albumResult
onLoaded: resultCell.restoreMutableBindings()
}
CardArtSkeleton {
artworkItem: cardLoader.item && cardLoader.item.artworkImmediate ? cardLoader.item : null
visible: resultGrid.kind !== "artist"
card: resultCell.cardData
resolvedSource: resultGrid.artOf(resultCell.cardData)
phase: resultGrid.phase
cellIndex: resultCell.globalIndex
}
}
}
}
component SearchTrackResult: TrackRow {
id: searchTrackRow
property int resultIndex: 0
property bool fadeIn: false
number: resultIndex + 1
opacity: searchTrackRow.fadeIn ? 0 : 1
Behavior on opacity {
enabled: searchTrackRow.fadeIn
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Component.onCompleted: opacity = 1
menuShowFavorite: false
showArtwork: true
showAlbum: true
artistLink: true
onPlayRequested: QbzPlayer.playTrack(item.id)
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": item.artUrl || "",
"year": null, "trackCount": null
}]))
}
// 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"
// EXPLICIT size, and load-bearing: a bare Rectangle's implicit size
// is 0x0 (Item does NOT size from children), and the hero slot centers
// it — so the whole card painted from the slot's CENTER, ~100px into
// the Artists carousel, whenever the top result was a track (the
// artist/album arms carry their own 200px and never shifted).
width: 200
height: heroCol.implicitHeight
readonly property bool overlayOn: heroArtArea.containsMouse
Column {
id: heroCol
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)" WAS an inline `LoadMoreButton` here. It is
// now controls/QbzLoadMore.qml with `bordered: true` — the same 32-tall
// radiusSm pill on surfaceCard / surfaceElevated-on-hover, 1px
// borderSubtle, 13px textSecondary label, `implicitWidth + 36` hit box,
// at y:6 inside a 44-tall box. That arm was written FROM this component,
// so the idle pixels are unchanged; what it adds is the placeholder block
// underneath and the busy disarm. The host still owns the whole label
// string (it carries the "n / total" counter) and the whole visibility
// rule — both are passed in at the four mount sites below.
// 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
// Thin-bar tier: surface-main @ bar-alpha (0.3) under the app-wide
// dynamic background, opaque surface-main otherwise (SearchResultsView.slint:386).
// The toolbar had NO fill of its own — with the background off the
// view root's surface-main showed through and it looked right, but
// the view root goes transparent under the background, so the bar
// lost its backing exactly when it needed one.
Rectangle {
anchors.fill: parent
// Rounded at the TOP because this bar is full-bleed at y=0 of
// the content pane, and under the dynamic background the pane's
// own rounding cannot reach it: Qt's `clip` is a rectangular
// scissor that ignores `radius`, and AppShell hides its bezel
// nubs while the field is meant to show through the corners.
// So a full-bleed pane child rounds ITSELF — AppShell.qml says
// exactly this ("there is no mask that can do it for it here")
// and this bar was the counterexample the owner spotted in
// Discover. Invisible with the background off: the view root
// paints the same colour underneath.
topLeftRadius: theme.radiusMd
topRightRadius: theme.radiusMd
color: root.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
}
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.hasQobuzResults || root.filterIndex !== 0
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 16
Text {
text: QbzSession.tr("Qobuz", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzSearch.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzSearch.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzSearch.searchFilterChanged(3) }
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzSearch.searchFilterChanged(4) }
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); selected: root.filterIndex === 5; onPicked: QbzSearch.searchFilterChanged(5) }
Rectangle {
visible: root.filterIndex !== 0
width: 24
height: 24
radius: 12
anchors.verticalCenter: parent.verticalCenter
color: clrArea.containsMouse ? theme.surfaceElevatedHover : 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: QbzSearch.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: QbzSearch.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
onContentYChanged: root.ensureBodyBandCoverage()
onHeightChanged: root.sampleBodyBand()
Component.onCompleted: root.sampleBodyBand()
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
// The crown, 1:1 with the reference
// (SearchResultsView.slint:584) — the POC's
// "award" read as a mic.
QbzIcon { name: "crown"; 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 || ""
qualityDetail: (root.mp.album || ({})).qualityDetail || ""
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: QbzSearch.searchTabChanged(3)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.tab === 0 ? 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: QbzSearch.searchTabChanged(3)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.tab === 0 ? 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: QbzSearch.searchTabChanged(1)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.tab === 0 ? root.albums : []
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
AlbumCard {
id: searchAlbumCard
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
qualityDetail: modelData.qualityDetail || ""
artSource: 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 {
artworkItem: searchAlbumCard.artworkImmediate ? searchAlbumCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
// Albums tab: the full grid + Load more.
SearchResultGrid {
visible: root.tab === 1
width: parent.width
rows: root.albums
kind: "album"
tabId: 1
cellW: 224
cellH: 270
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
collectionKey: root.query + ":albums"
}
// The page that "Load more" asked for, in the shape it
// will arrive in: one row of 224x270 cells on one
// animator, now drawn BELOW the button by QbzLoadMore
// (the standalone block that used to sit here was above
// it). It disappears the moment the array grows, or
// settles out after 8s if the page comes back empty.
//
// `visible` merges the two gates that were split before:
// the mount said `visible: root.tab === 1` and that
// binding OVERRODE the component's own `loaded < total`,
// so the pill survived past the end of the result set
// ("Load more (20 / 20)"). Both live in one expression
// now, which is what the component always meant.
QbzLoadMore {
visible: root.tab === 1
&& root.albums.length < (root.doc.albumsTotal || 0)
width: parent.width
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.albums.length + " / " + (root.doc.albumsTotal || 0) + ")"
// morePending is already scoped to the active tab
// (moreTab === tab), so the other three mounts cannot
// see a busy that is not theirs.
busy: root.morePending
skeleton: "cards"
cellW: 224
cellH: 270
onClicked: root.armLoadMore(1)
}
// ---- Tracks --------------------------------------------
Column {
// The All tab is deliberately bounded to six rows. The
// unbounded type tab has its own windowed tape below.
visible: root.tab === 0 && root.tracks.length > 0
width: parent.width
spacing: 4
QbzSectionHeader {
title: QbzSession.tr("Tracks", QbzSession.trRev)
showViewAll: (root.doc.tracksTotal || 0) > root.previewCap
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzSearch.searchTabChanged(2)
}
Repeater {
model: root.tab === 0
? root.tracks.slice(0, root.previewCap) : []
delegate: SearchTrackResult {
required property var modelData
required property int index
item: modelData
resultIndex: index
}
}
}
Column {
id: trackResults
visible: root.tab === 2 && root.tracks.length > 0
width: parent.width
spacing: 4
QbzSectionHeader {
title: QbzSession.tr("Tracks", QbzSession.trRev)
showViewAll: false
showChevrons: false
}
Item {
id: trackTape
width: parent.width
readonly property int pitch: 54
readonly property real pageY: trackResults.y + y
readonly property int firstRow: Math.max(0,
Math.floor((root.bodyBandTop - pageY) / pitch))
readonly property int lastRow: Math.max(firstRow,
Math.ceil((root.bodyBandBottom - pageY) / pitch))
readonly property int mountedFrom: Math.min(
root.tracks.length, firstRow)
readonly property int mountedTo: Math.min(
root.tracks.length, Math.max(mountedFrom, lastRow + 1))
height: root.tracks.length > 0
? root.tracks.length * 50 + (root.tracks.length - 1) * 4 : 0
Repeater {
model: root.tab === 2
? Math.max(0, trackTape.mountedTo - trackTape.mountedFrom) : 0
delegate: SearchTrackResult {
required property int index
readonly property int globalIndex: trackTape.mountedFrom + index
x: 0
y: globalIndex * trackTape.pitch
width: trackTape.width
item: root.tracks[globalIndex] || ({})
resultIndex: globalIndex
fadeIn: root.fadeAt(2, globalIndex)
}
}
}
}
// Tracks: the appended page arrives as TrackRows, so the
// placeholder is the "rows" arm — 50px, the row height
// TrackRow.qml declares, on the 4px pitch the Column
// above uses (`spacing: 4`). The full-page loading
// skeleton further down passes rowGap: 0 because it
// covers a whole viewport, where the pitch stands next to
// nothing; these two rows stand directly under the last
// real row, so they use the real one. TWO rows — the
// owner's "una o dos filas".
QbzLoadMore {
visible: root.tab === 2
&& root.tracks.length < (root.doc.tracksTotal || 0)
width: parent.width
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.tracks.length + " / " + (root.doc.tracksTotal || 0) + ")"
busy: root.morePending
skeleton: "rows"
rowH: 50
rowGap: 4
rowCount: 2
// TrackRow draws 36px art in its 50px row, and the
// standalone block this replaced said so too.
rowArtSize: 36
onClicked: root.armLoadMore(2)
}
// ---- Artists grid (per-type tab) ------------------------
SearchResultGrid {
visible: root.tab === 3
width: parent.width
rows: root.artists
kind: "artist"
tabId: 3
cellW: 216
cellH: 262
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
collectionKey: root.query + ":artists"
}
// Artists: this grid's own 216x262 pitch, NOT the 224x270
// the album/playlist grids use — the cells here are the
// ArtistCard portrait footprint: 200-wide ROUND cells on
// a 16px gutter (the GridView above says (width+16)/216),
// both of which the standalone block this replaces passed
// and QbzLoadMore now passes through.
QbzLoadMore {
visible: root.tab === 3
&& root.artists.length < (root.doc.artistsTotal || 0)
width: parent.width
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.artists.length + " / " + (root.doc.artistsTotal || 0) + ")"
busy: root.morePending
skeleton: "cards"
cellW: 216
cellH: 262
roundCells: true
cardGutter: 16
onClicked: 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: QbzSearch.searchTabChanged(4)
}
ListView {
width: parent.width
height: 246
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.tab === 0 ? root.playlists : []
delegate: Item {
required property var modelData
required property int index
width: 200
height: 246
PlaylistCard {
id: searchPlaylistCard
// 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 {
artworkItem: searchPlaylistCard.artworkImmediate ? searchPlaylistCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
}
}
}
}
SearchResultGrid {
visible: root.tab === 4
width: parent.width
rows: root.playlists
kind: "playlist"
tabId: 4
cellW: 224
cellH: 270
bandTop: root.bodyBandTop
bandBottom: root.bodyBandBottom
phase: root.skelPhase
fadeTab: root.fadeTab
fadeFrom: root.fadeFrom
collectionKey: root.query + ":playlists"
}
// Playlists share the album grid's 224x270 pitch.
QbzLoadMore {
visible: root.tab === 4
&& root.playlists.length < (root.doc.playlistsTotal || 0)
width: parent.width
bordered: true
buttonHeight: 44
label: QbzSession.tr("Load more", QbzSession.trRev)
+ " (" + root.playlists.length + " / " + (root.doc.playlistsTotal || 0) + ")"
busy: root.morePending
skeleton: "cards"
cellW: 224
cellH: 270
onClicked: root.armLoadMore(4)
}
SearchLocalResults {
id: localSearchResults
width: parent.width
sections: root.localSections
revision: root.doc.localRevision || ""
tab: root.tab
visible: localSearchResults.hasResults
onActivated: function(revision, index, action) {
QbzSearch.searchLocalAction(revision, index, action)
}
onViewMore: function(revision, kind) {
QbzSearch.searchLocalMore(revision, kind)
}
}
}
// ---- 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
}
}
// 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: bodyFlick; scope: "search" }
QbzScrollBar {
target: bodyFlick
anchors.right: parent.right
anchors.rightMargin: 4
anchors.top: parent.top
anchors.bottom: parent.bottom
}
}
}
}