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
1367 lines
66 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|