The Slint reference is 3,885 lines of .slint across ten files plus 5,514 lines
of Rust glue over the shared qbz-mixtape crate. None of it existed in the Qt
port; the nav flyout carried a "GAP:" comment and two disabled entries.
Rust (flat in src/, per QTBUG-93443): three bridges rather than one God-object
— QbzMyQbz (grids + detail + edit + cover + mix), QbzMyQbzAdd (the app-wide
picker, kept apart so TrackRow, PlayerBar and the Local Library bulk bars
cannot couple to detail state) and QbzDisco (artist-scoped, reached from the
artist page, never coexisting with the MyQBZ views). Twelve controllers behind
them, ported function by function from myqbz{,_detail,_play,_edit,_cover,_mix,
_add,_prefs,_view_prefs,_builder}.rs. src/toast_qt.rs finally gives the port a
toast host.
QML in qml/views/myqbz/ plus four global overlays. Reused rather than forked:
TrackRow, TrackListHeader, QualityBadgeFull, QbzContextMenu, CardMenu,
QbzCheckbox, QbzSkeleton, QbzScrollBar, RoundedImage. LocalSegToggle and
LocalMultiSelectBar are PROMOTED to controls/QbzSegToggle and
controls/QbzMultiSelectBar (they were ports of shared Slint primitives that
MyQBZ needs too) — a move plus an import fix, with the Local metrics kept as
the defaults so the four Local Library toggles are byte-unchanged and the MyQBZ
call sites pass ViewToggle's own numbers.
Every list is windowed at write time: GridView/ListView with reuseItems and a
cacheBuffer, and expanded mode is an outer recycled ListView whose delegate is
a Column + Repeater, because a nested ListView inside a recycled delegate
cannot size itself. Single-row updates patch in place; replacing the array
resets contentY and throws the user to the top.
Shared controls gained the arms the new views need, with the numbers taken from
the .slint and not guessed: SettingsButton (btnHeight / minWidth /
trailingIconName), QbzEmptyState's four metrics, QbzSelect's `sm` variant
(30px, r6, no outline, 12px label, 14px chevron, 40px narrower —
QbzSelect.slint:74-148), QbzContextMenu.openBelowLeft, and QbzLineEdit gains a
distinct `accepted` signal fired on ENTER ONLY: a modal that submits on
`committed` submits TWICE, because closing it removes focus and fires
`committed` again — a rename would have run twice and a create made two
collections. clearSearch() no longer assigns root.text either; that JS write
destroyed the call site's binding permanently, so Rust could never re-seed the
field after one click on the X.
SourceIcon gained the plain "qobuz" arm it was missing, which had every Qobuz
row in a collection drawing itself as a local file.
Two icons (turntable, image) are baked from the Slint masters into the seven
full tint dirs and registered in MASTERS; three more (circle-check-big,
circle-alert, triangle-alert) back the toast. A missing tint dir or glyph
renders nothing and logs nothing, so this class never surfaces in a build.
cc
983 lines
46 KiB
QML
983 lines
46 KiB
QML
// Search results view — the QML port of search/SearchResultsView.slint
|
|
// (phase 15). One JSON document (QbzBridge.searchJson, search_qt.rs
|
|
// SearchPageDoc: query/tab/loading/filterIndex + the four category lists,
|
|
// totals, the most-popular hero, the carousel-only artists list).
|
|
//
|
|
// Layout (1:1): 56px toolbar ("Search" title + the searchType filter
|
|
// radios on the right), the five-tab strip (All / Albums / Tracks /
|
|
// Artists / Playlists), the scrollable body: All = Most-popular hero +
|
|
// Artists carousel + Albums carousel + Tracks preview (6) + Playlists
|
|
// carousel; per-type tabs = full grid/list + "Load more (n / total)".
|
|
//
|
|
// POC-NOTEs:
|
|
// - Card click actions: albums/artists navigate; tracks play; playlists
|
|
// open (openPlaylist is wired; the stale "inert" note was dropped in
|
|
// phase 21, when every card surface moved to the shared qml/ cards).
|
|
// - The most-popular track hero stays a DISTINCT component
|
|
// (SearchTrackHero below): in Slint the search hero is
|
|
// primitives/SearchTrackHero.slint, NOT discover/TrackCard.slint —
|
|
// the POC variant is 200x246 (centered play, quality as text) to match
|
|
// the ArtistGridCard hero slot it shares the row with.
|
|
// - The Slint's windowed grid virtualization is not replicated (page size
|
|
// is 20 — the whole set mounts).
|
|
//
|
|
// LOADING (a deliberate ADDITION over the Slint's bare LoadingSpinner): the
|
|
// view mounts QbzSkeleton placeholders in the shape of the tab that is
|
|
// coming, plus a per-card cover placeholder that clears when THAT card's
|
|
// cover lands. See the "skeleton plumbing" block below for the cost.
|
|
//
|
|
// SIZE: this file is over the 500-line guideline (it already was at 638
|
|
// before the skeleton work). It is NOT split because every section is one
|
|
// tab of one document and the split would be arbitrary; the skeleton
|
|
// additions are the composite mounts, which are ~8 lines each precisely to
|
|
// keep it from growing further.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../cards"
|
|
import "../controls"
|
|
import "../rows"
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
color: ambientOn ? "transparent" : theme.surfaceMain
|
|
readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack
|
|
radius: 12
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property var doc: parseDoc()
|
|
function parseDoc() {
|
|
try {
|
|
return JSON.parse(QbzBridge.searchJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var albums: doc.albums || []
|
|
readonly property var tracks: doc.tracks || []
|
|
readonly property var artists: doc.artists || []
|
|
readonly property var artistsCarousel: doc.artistsCarousel || []
|
|
readonly property var playlists: doc.playlists || []
|
|
readonly property var mp: doc.mostPopular || ({})
|
|
readonly property bool loading: doc.loading === true
|
|
readonly property int tab: doc.tab || 0
|
|
readonly property int filterIndex: doc.filterIndex || 0
|
|
readonly property bool hasResults: albums.length + tracks.length + artists.length + playlists.length > 0
|
|
readonly property int previewCap: 6
|
|
|
|
// ======================= skeleton plumbing ===========================
|
|
// ONE 900ms Timer drives EVERY placeholder in this view (QbzSkeleton's
|
|
// preferred drive mode). GATING RULE: freeze on NOT VISIBLE — the view
|
|
// hidden, or the window minimized/hidden. NEVER on lost focus (a tiling
|
|
// desktop keeps windows visible and unfocused).
|
|
property bool skelPhase: false
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
|
|
// A card is pending while it HAS a cover url and no local path yet.
|
|
// Recomputed only when the document is republished (which is also when
|
|
// covers land), never per frame.
|
|
function anyPending(rows) {
|
|
for (var i = 0; i < rows.length; i++) {
|
|
if ((rows[i].artUrl || "") !== "" && (rows[i].artPath || "") === "") return true
|
|
}
|
|
return false
|
|
}
|
|
readonly property bool heroPending: {
|
|
var k = mp.kind || ""
|
|
if (k === "" || k === "artist") return false
|
|
var c = k === "album" ? (mp.album || ({})) : (mp.track || ({}))
|
|
return (c.artUrl || "") !== "" && (c.artPath || "") === ""
|
|
}
|
|
readonly property bool artPending: anyPending(albums) || anyPending(tracks)
|
|
|| anyPending(playlists) || heroPending
|
|
// The pulse must OUTLIVE `artPending`: that flag drops when the last PATH
|
|
// lands, but each of those cards still has a decode + canvas raster ahead
|
|
// of it and its placeholder is still up (QbzSkeleton's handover). Without
|
|
// the grace the tiles freeze mid-shimmer for the rest of the wait.
|
|
property bool artHold: false
|
|
Timer { id: artHoldOff; interval: 1500; onTriggered: root.artHold = false }
|
|
onArtPendingChanged: { root.artHold = true; artHoldOff.restart() }
|
|
|
|
// "Load more" has NO bridge-side busy flag: search_qt.rs only sets
|
|
// doc.loading in submit(), never in load_more(). So the appended-page
|
|
// placeholder is tracked locally — armed on the click, disarmed the
|
|
// moment that tab's array actually grows. A page that comes back empty
|
|
// (end of the result set) never grows it, which is why every appended
|
|
// placeholder carries settleMs.
|
|
property int moreTab: -1
|
|
property int moreFrom: 0
|
|
function rowsFor(t) {
|
|
return t === 1 ? albums : t === 2 ? tracks
|
|
: t === 3 ? artists : t === 4 ? playlists : []
|
|
}
|
|
readonly property bool morePending: root.moreTab >= 0
|
|
&& root.moreTab === root.tab
|
|
&& root.rowsFor(root.moreTab).length === root.moreFrom
|
|
function armLoadMore(t) {
|
|
root.moreTab = t
|
|
root.moreFrom = root.rowsFor(t).length
|
|
QbzBridge.searchLoadMore(t)
|
|
}
|
|
|
|
Timer {
|
|
interval: 900
|
|
repeat: true
|
|
running: (root.loading || root.artPending || root.artHold || root.morePending)
|
|
&& root.visible && root.windowShowing
|
|
onTriggered: root.skelPhase = !root.skelPhase
|
|
}
|
|
|
|
// Per-item cover placeholder, mounted by every card delegate over the
|
|
// 200x200 artwork square. A bare Rectangle, so it does not eat the
|
|
// card's hover/click areas underneath; it clears when THIS card's cover
|
|
// lands, so a page resolves progressively instead of as a lump.
|
|
// ARTISTS are deliberately excluded everywhere: ArtistCard already draws
|
|
// a designed round gradient+glyph portrait for a missing cover, which
|
|
// reads as a portrait rather than as a hole (same rule as LibraryView).
|
|
// NOTE: an inline `component` does NOT see this file's outer ids, so it
|
|
// takes NO `root.` reference — every call site passes `phase:` itself.
|
|
component CardArtSkeleton: QbzSkeleton {
|
|
property var card: ({})
|
|
variant: "art"
|
|
width: 200
|
|
height: 200
|
|
// HANDOVER, not "the path landed": the cards seal their RoundedImage
|
|
// away, so this rides QbzSkeleton's probe arm — the same pixmap-cache
|
|
// entry the card is loading, so no second decode — and retires when
|
|
// that decode finishes. Gating on `artPath !== ""` (what this used to
|
|
// do) drops the placeholder while the card's canvas is still blank.
|
|
pending: (card.artUrl || "") !== ""
|
|
coverSource: card.artPath || ""
|
|
// A cover whose download fails republishes the document with an
|
|
// empty artPath — without this the tile would shimmer forever.
|
|
settleMs: 6000
|
|
}
|
|
|
|
// Track hero (SearchTrackHero: the 200x246 track card + the quality
|
|
// label as TEXT under the meta).
|
|
component SearchTrackHero: Rectangle {
|
|
property var card: ({})
|
|
property string qualityLabel: ""
|
|
color: "transparent"
|
|
readonly property bool overlayOn: heroArtArea.containsMouse
|
|
|
|
Column {
|
|
spacing: 0
|
|
Rectangle {
|
|
width: 200
|
|
height: 200
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
RoundedImage {
|
|
anchors.fill: parent
|
|
source: card.artPath || ""
|
|
radius: theme.radiusSm
|
|
}
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
radius: theme.radiusSm
|
|
color: "#000000"
|
|
opacity: parent.parent.overlayOn ? 0.6 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
}
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: 44
|
|
height: 44
|
|
radius: 22
|
|
opacity: parent.parent.overlayOn ? 1.0 : 0.0
|
|
color: heroPlayArea.containsMouse ? "#d6ffffff" : "#ffffff"
|
|
QbzIcon { name: "play-fill"; width: 18; height: 18; anchors.centerIn: parent; tintName: "black" }
|
|
MouseArea {
|
|
id: heroPlayArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzPlayer.playTrack(card.id)
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: heroArtArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzPlayer.playTrack(card.id)
|
|
}
|
|
}
|
|
Item { width: 1; height: 6 }
|
|
Text {
|
|
width: 200
|
|
height: 20
|
|
text: card.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontBody - 2
|
|
font.weight: theme.weightMedium
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
width: 200
|
|
height: 18
|
|
text: QbzSession.tr("Track", QbzSession.trRev) + " • " + (card.artist || "")
|
|
color: theme.textMuted
|
|
font.pixelSize: theme.fontLink - 1
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
Text {
|
|
visible: qualityLabel !== ""
|
|
width: 200
|
|
text: qualityLabel
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track row (primitives/TrackRow.slint, 50px — number/play, title +
|
|
// explicit, artist, duration, quality, ⋯ menu).
|
|
// "Load more (loaded / total)" (LoadMoreButton).
|
|
component LoadMoreButton: Item {
|
|
property int loaded: 0
|
|
property int total: 0
|
|
// The click is reported OUT (the call site arms the appended-page
|
|
// placeholder and then calls the bridge). An inline component does
|
|
// not see this file's outer ids, so it must not reach for `root`.
|
|
signal loadMore()
|
|
width: parent ? parent.width : 0
|
|
visible: loaded < total
|
|
height: visible ? 44 : 0
|
|
Rectangle {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
y: 6
|
|
width: lmText.implicitWidth + 36
|
|
height: 32
|
|
radius: theme.radiusSm
|
|
color: lmArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard
|
|
border.width: 1
|
|
border.color: theme.borderSubtle
|
|
Text {
|
|
id: lmText
|
|
anchors.centerIn: parent
|
|
text: QbzSession.tr("Load more", QbzSession.trRev) + " (" + loaded + " / " + total + ")"
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
}
|
|
MouseArea {
|
|
id: lmArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: parent.parent.loadMore()
|
|
}
|
|
}
|
|
}
|
|
|
|
// searchType filter radio (FilterRadio: 14px ring + 8px accent dot).
|
|
component FilterRadio: Item {
|
|
property string label: ""
|
|
property bool selected: false
|
|
signal picked()
|
|
width: frRow.width
|
|
height: 22
|
|
Row {
|
|
id: frRow
|
|
spacing: 6
|
|
height: parent.height
|
|
Rectangle {
|
|
width: 14
|
|
height: 14
|
|
radius: 7
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: "transparent"
|
|
border.width: 1.5
|
|
border.color: parent.parent.selected ? theme.accent : theme.textMuted
|
|
Rectangle {
|
|
width: 8
|
|
height: 8
|
|
radius: 4
|
|
anchors.centerIn: parent
|
|
color: parent.parent.selected ? theme.accent : "transparent"
|
|
}
|
|
}
|
|
Text {
|
|
height: parent.height
|
|
text: parent.parent.label
|
|
color: (parent.parent.selected || frArea.containsMouse) ? theme.textPrimary : theme.textSecondary
|
|
font.pixelSize: 12
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
MouseArea {
|
|
id: frArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: parent.picked()
|
|
}
|
|
}
|
|
|
|
// ============================ the view ================================
|
|
Column {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// --- Row 1: title (left) + filter radios (right) -------------------
|
|
Item {
|
|
width: parent.width
|
|
height: 56
|
|
Text {
|
|
x: 32
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: QbzSession.tr("Search", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightBold
|
|
}
|
|
Row {
|
|
visible: root.hasResults
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 32
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 16
|
|
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzBridge.searchFilterChanged(1) }
|
|
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzBridge.searchFilterChanged(2) }
|
|
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzBridge.searchFilterChanged(3) }
|
|
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzBridge.searchFilterChanged(4) }
|
|
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); selected: root.filterIndex === 5; onPicked: QbzBridge.searchFilterChanged(5) }
|
|
Rectangle {
|
|
visible: root.filterIndex !== 0
|
|
width: 24
|
|
height: 24
|
|
radius: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
|
|
QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" }
|
|
MouseArea {
|
|
id: clrArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzBridge.searchFilterChanged(0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Row 2: the five-tab strip (collapsed during the initial load) --
|
|
Item {
|
|
visible: !(root.loading && !root.hasResults)
|
|
width: parent.width
|
|
height: 40
|
|
Row {
|
|
x: 32
|
|
anchors.bottom: parent.bottom
|
|
anchors.bottomMargin: 8
|
|
spacing: 22
|
|
Repeater {
|
|
model: [
|
|
{ "t": 0, "label": QbzSession.tr("All", QbzSession.trRev) },
|
|
{ "t": 1, "label": QbzSession.tr("Albums", QbzSession.trRev) },
|
|
{ "t": 2, "label": QbzSession.tr("Tracks", QbzSession.trRev) },
|
|
{ "t": 3, "label": QbzSession.tr("Artists", QbzSession.trRev) },
|
|
{ "t": 4, "label": QbzSession.tr("Playlists", QbzSession.trRev) },
|
|
]
|
|
delegate: Column {
|
|
required property var modelData
|
|
spacing: 5
|
|
Text {
|
|
text: modelData.label
|
|
color: root.tab === modelData.t ? theme.textPrimary
|
|
: (stabArea.containsMouse ? theme.textSecondary : theme.textMuted)
|
|
font.pixelSize: 14
|
|
font.weight: root.tab === modelData.t ? theme.weightBold : theme.weightMedium
|
|
MouseArea {
|
|
id: stabArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: QbzBridge.searchTabChanged(modelData.t)
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 2
|
|
radius: 1
|
|
color: root.tab === modelData.t ? theme.accent : "transparent"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Scrollable body ------------------------------------------------
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - 56 - 40
|
|
Flickable {
|
|
id: bodyFlick
|
|
anchors.fill: parent
|
|
contentWidth: width
|
|
contentHeight: bodyCol.height + 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: bodyCol
|
|
x: 32
|
|
width: bodyFlick.width - 64
|
|
spacing: 28
|
|
|
|
// Fade in once the search completes (SearchResultsView).
|
|
opacity: root.loading ? 0.0 : 1.0
|
|
Behavior on opacity { NumberAnimation { duration: 280; easing.type: Easing.InOutQuad } }
|
|
|
|
// ---- Most popular + Artists (All tab) ------------------
|
|
Row {
|
|
visible: root.tab === 0 && (root.mp.kind || "") !== ""
|
|
width: parent.width
|
|
spacing: 24
|
|
// Hero column, fixed 200px (the carousel always starts
|
|
// at the same x regardless of the hero kind).
|
|
Column {
|
|
width: 200
|
|
spacing: 12
|
|
Row {
|
|
spacing: 8
|
|
QbzIcon { name: "award"; width: 18; height: 18; tintName: "warning" }
|
|
Text {
|
|
text: QbzSession.tr("Most popular", QbzSession.trRev)
|
|
color: theme.textPrimary
|
|
font.pixelSize: theme.fontSection
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
}
|
|
Item {
|
|
width: 200
|
|
height: 246
|
|
ArtistCard {
|
|
visible: root.mp.kind === "artist"
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
item: root.mp.artist || ({})
|
|
artSource: (root.mp.artist || ({})).artPath || ""
|
|
// Same two fields the album hero below
|
|
// carries: the pin state (or the glyph
|
|
// lies and the first click UN-pins) and
|
|
// the REMOTE url the pin payload stores
|
|
// (artPath is a local cache path).
|
|
isPinned: (root.mp.artist || ({})).isPinned === true
|
|
artworkUrl: (root.mp.artist || ({})).artUrl || ""
|
|
}
|
|
AlbumCard {
|
|
visible: root.mp.kind === "album"
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
albumId: (root.mp.album || ({})).id || ""
|
|
title: (root.mp.album || ({})).title || ""
|
|
artist: (root.mp.album || ({})).artist || ""
|
|
artistId: (root.mp.album || ({})).artistId || ""
|
|
genre: (root.mp.album || ({})).genre || ""
|
|
year: (root.mp.album || ({})).year || ""
|
|
qualityTier: (root.mp.album || ({})).qualityTier || ""
|
|
artSource: (root.mp.album || ({})).artPath || ""
|
|
// search_qt::map_album stamps the heart
|
|
// from fav_cache; false inverted the
|
|
// first click on an album already in the
|
|
// library.
|
|
isFavorite: (root.mp.album || ({})).isFavorite === true
|
|
// The row carries the pin state
|
|
// (search_qt `map_album`); hardcoding
|
|
// false made the glyph lie and turned
|
|
// the first click into an UN-pin.
|
|
isPinned: (root.mp.album || ({})).isPinned === true
|
|
artworkUrl: (root.mp.album || ({})).artUrl || ""
|
|
}
|
|
SearchTrackHero {
|
|
visible: root.mp.kind === "track"
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
card: root.mp.track || ({})
|
|
qualityLabel: root.mp.qualityLabel || ""
|
|
}
|
|
// Hero cover placeholder (album/track arms —
|
|
// the artist arm keeps ArtistCard's designed
|
|
// round portrait).
|
|
CardArtSkeleton {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
card: root.mp.kind === "album" ? (root.mp.album || ({}))
|
|
: root.mp.kind === "track" ? (root.mp.track || ({}))
|
|
: ({})
|
|
phase: root.skelPhase
|
|
}
|
|
}
|
|
}
|
|
// Artists carousel — fills the rest of the row.
|
|
Column {
|
|
visible: root.artistsCarousel.length > 0
|
|
width: parent.width - 224
|
|
spacing: 12
|
|
QbzSectionHeader {
|
|
title: QbzSession.tr("Artists", QbzSession.trRev)
|
|
showViewAll: true
|
|
viewAllAccent: true
|
|
showChevrons: false
|
|
onViewAllClicked: QbzBridge.searchTabChanged(3)
|
|
}
|
|
ListView {
|
|
width: parent.width
|
|
height: 246
|
|
orientation: ListView.Horizontal
|
|
spacing: 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.artistsCarousel
|
|
delegate: ArtistCard {
|
|
item: modelData
|
|
artSource: modelData.artPath || ""
|
|
isPinned: modelData.isPinned === true
|
|
artworkUrl: modelData.artUrl || ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Artists carousel when there is no most-popular hero.
|
|
Column {
|
|
visible: root.tab === 0 && (root.mp.kind || "") === "" && root.artists.length > 0
|
|
width: parent.width
|
|
spacing: 12
|
|
QbzSectionHeader {
|
|
title: QbzSession.tr("Artists", QbzSession.trRev)
|
|
showViewAll: true
|
|
viewAllAccent: true
|
|
showChevrons: false
|
|
onViewAllClicked: QbzBridge.searchTabChanged(3)
|
|
}
|
|
ListView {
|
|
width: parent.width
|
|
height: 246
|
|
orientation: ListView.Horizontal
|
|
spacing: 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.artists
|
|
delegate: ArtistCard {
|
|
item: modelData
|
|
artSource: modelData.artPath || ""
|
|
isPinned: modelData.isPinned === true
|
|
artworkUrl: modelData.artUrl || ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Albums --------------------------------------------
|
|
Column {
|
|
visible: root.tab === 0 && root.albums.length > 0
|
|
width: parent.width
|
|
spacing: 12
|
|
QbzSectionHeader {
|
|
title: QbzSession.tr("Albums", QbzSession.trRev)
|
|
showViewAll: true
|
|
viewAllAccent: true
|
|
showChevrons: false
|
|
onViewAllClicked: QbzBridge.searchTabChanged(1)
|
|
}
|
|
ListView {
|
|
width: parent.width
|
|
height: 246
|
|
orientation: ListView.Horizontal
|
|
spacing: 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.albums
|
|
delegate: Item {
|
|
required property var modelData
|
|
required property int index
|
|
width: 200
|
|
height: 246
|
|
AlbumCard {
|
|
albumId: modelData.id
|
|
title: modelData.title
|
|
artist: modelData.artist
|
|
artistId: modelData.artistId
|
|
genre: modelData.genre
|
|
year: modelData.year
|
|
qualityTier: modelData.qualityTier
|
|
artSource: modelData.artPath || ""
|
|
// search_qt::map_album stamps the heart
|
|
// from fav_cache; false made the glyph lie
|
|
// and inverted the first click.
|
|
isFavorite: modelData.isFavorite === true
|
|
isPinned: modelData.isPinned === true
|
|
artworkUrl: modelData.artUrl || ""
|
|
}
|
|
CardArtSkeleton {
|
|
card: modelData
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Albums tab: the full grid + Load more.
|
|
GridView {
|
|
visible: root.tab === 1
|
|
width: parent.width
|
|
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
|
|
cellWidth: 224
|
|
cellHeight: 270
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
interactive: false
|
|
model: root.albums
|
|
delegate: Item {
|
|
required property var modelData
|
|
required property int index
|
|
width: 200
|
|
height: 246
|
|
AlbumCard {
|
|
albumId: modelData.id
|
|
title: modelData.title
|
|
artist: modelData.artist
|
|
artistId: modelData.artistId
|
|
genre: modelData.genre
|
|
year: modelData.year
|
|
qualityTier: modelData.qualityTier
|
|
artSource: modelData.artPath || ""
|
|
isFavorite: modelData.isFavorite === true
|
|
isPinned: modelData.isPinned === true
|
|
artworkUrl: modelData.artUrl || ""
|
|
}
|
|
CardArtSkeleton {
|
|
card: modelData
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
// The page that "Load more" asked for, in the shape it
|
|
// will arrive in. One row of cells, one animator; it
|
|
// disappears the moment the array grows (or settles out
|
|
// if the page comes back empty).
|
|
QbzSkeleton {
|
|
visible: root.tab === 1 && root.morePending
|
|
variant: "cardGrid"
|
|
width: parent.width
|
|
height: visible ? 270 : 0
|
|
cellW: 224
|
|
cellH: 270
|
|
phase: root.skelPhase
|
|
settleMs: 8000
|
|
}
|
|
LoadMoreButton {
|
|
visible: root.tab === 1
|
|
loaded: root.albums.length
|
|
total: root.doc.albumsTotal || 0
|
|
onLoadMore: root.armLoadMore(1)
|
|
}
|
|
|
|
// ---- Tracks --------------------------------------------
|
|
Column {
|
|
visible: (root.tab === 0 || root.tab === 2) && root.tracks.length > 0
|
|
width: parent.width
|
|
spacing: 4
|
|
QbzSectionHeader {
|
|
title: QbzSession.tr("Tracks", QbzSession.trRev)
|
|
showViewAll: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap
|
|
viewAllAccent: true
|
|
showChevrons: false
|
|
onViewAllClicked: QbzBridge.searchTabChanged(2)
|
|
}
|
|
Repeater {
|
|
model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap)
|
|
delegate: TrackRow {
|
|
item: modelData
|
|
number: index + 1
|
|
menuShowFavorite: false
|
|
onPlayRequested: QbzPlayer.playTrack(item.id)
|
|
onEnqueueRequested: function (m) { QbzPlayer.enqueueTrack(item.id, m) }
|
|
// MyQBZ "Add to mixtape" — the HOST builds the
|
|
// AddItem array (TrackRow does not know
|
|
// itemType/source).
|
|
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
|
|
"itemType": "track", "source": "qobuz",
|
|
"sourceItemId": item.id, "title": item.title || "",
|
|
"subtitle": item.artist || "", "artworkUrl": "",
|
|
"year": null, "trackCount": null
|
|
}]))
|
|
}
|
|
}
|
|
}
|
|
QbzSkeleton {
|
|
visible: root.tab === 2 && root.morePending
|
|
variant: "rowList"
|
|
width: parent.width
|
|
height: visible ? 200 : 0
|
|
rowH: 50
|
|
rowGap: 0
|
|
rowArtSize: 36
|
|
phase: root.skelPhase
|
|
settleMs: 8000
|
|
}
|
|
LoadMoreButton {
|
|
visible: root.tab === 2
|
|
loaded: root.tracks.length
|
|
total: root.doc.tracksTotal || 0
|
|
onLoadMore: root.armLoadMore(2)
|
|
}
|
|
|
|
// ---- Artists grid (per-type tab) ------------------------
|
|
GridView {
|
|
visible: root.tab === 3
|
|
width: parent.width
|
|
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 16) / 216))) * 262 - 16)
|
|
cellWidth: 216
|
|
cellHeight: 262
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
interactive: false
|
|
model: root.artists
|
|
delegate: ArtistCard {
|
|
item: modelData
|
|
artSource: modelData.artPath || ""
|
|
isPinned: modelData.isPinned === true
|
|
artworkUrl: modelData.artUrl || ""
|
|
}
|
|
}
|
|
// Artists: ROUND cells (ArtistGridCard's portrait), and
|
|
// no per-item overlay anywhere — a missing artist photo
|
|
// has a designed placeholder already.
|
|
QbzSkeleton {
|
|
visible: root.tab === 3 && root.morePending
|
|
variant: "cardGrid"
|
|
width: parent.width
|
|
height: visible ? 262 : 0
|
|
cellW: 216
|
|
cellH: 262
|
|
roundCells: true
|
|
phase: root.skelPhase
|
|
settleMs: 8000
|
|
}
|
|
LoadMoreButton {
|
|
visible: root.tab === 3
|
|
loaded: root.artists.length
|
|
total: root.doc.artistsTotal || 0
|
|
onLoadMore: root.armLoadMore(3)
|
|
}
|
|
|
|
// ---- Playlists ------------------------------------------
|
|
Column {
|
|
visible: root.tab === 0 && root.playlists.length > 0
|
|
width: parent.width
|
|
spacing: 12
|
|
QbzSectionHeader {
|
|
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
|
showViewAll: true
|
|
viewAllAccent: true
|
|
showChevrons: false
|
|
onViewAllClicked: QbzBridge.searchTabChanged(4)
|
|
}
|
|
ListView {
|
|
width: parent.width
|
|
height: 246
|
|
orientation: ListView.Horizontal
|
|
spacing: 32
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.playlists
|
|
delegate: Item {
|
|
required property var modelData
|
|
required property int index
|
|
width: 200
|
|
height: 246
|
|
PlaylistCard {
|
|
// artworkUrl is not passed: the card
|
|
// defaults it to `item.artUrl`, which is
|
|
// this row's remote cover.
|
|
item: modelData
|
|
artSource: modelData.artPath || ""
|
|
isPinned: modelData.isPinned === true
|
|
}
|
|
CardArtSkeleton {
|
|
card: modelData
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
GridView {
|
|
visible: root.tab === 4
|
|
width: parent.width
|
|
height: Math.max(0, Math.ceil(count / Math.max(1, Math.floor((width + 24) / 224))) * 270 - 24)
|
|
cellWidth: 224
|
|
cellHeight: 270
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
interactive: false
|
|
model: root.playlists
|
|
delegate: Item {
|
|
required property var modelData
|
|
required property int index
|
|
width: 200
|
|
height: 246
|
|
PlaylistCard {
|
|
item: modelData
|
|
artSource: modelData.artPath || ""
|
|
isPinned: modelData.isPinned === true
|
|
}
|
|
CardArtSkeleton {
|
|
card: modelData
|
|
phase: root.skelPhase
|
|
cellIndex: index
|
|
}
|
|
}
|
|
}
|
|
QbzSkeleton {
|
|
visible: root.tab === 4 && root.morePending
|
|
variant: "cardGrid"
|
|
width: parent.width
|
|
height: visible ? 270 : 0
|
|
cellW: 224
|
|
cellH: 270
|
|
phase: root.skelPhase
|
|
settleMs: 8000
|
|
}
|
|
LoadMoreButton {
|
|
visible: root.tab === 4
|
|
loaded: root.playlists.length
|
|
total: root.doc.playlistsTotal || 0
|
|
onLoadMore: root.armLoadMore(4)
|
|
}
|
|
}
|
|
|
|
// ---- Loading skeleton ---------------------------------------
|
|
// Shape of the tab that is coming, not a spinner. Mounted for
|
|
// the WHOLE of `loading`, not only the first search: bodyCol
|
|
// fades to 0 while a new query is in flight, so a re-search
|
|
// used to show an empty page with no affordance at all.
|
|
// COST: at most 8 animators on the All tab (3 title bars, 1
|
|
// hero card, 2 card composites, 1 slim-row composite); one
|
|
// composite covers a whole viewport row on ONE animator.
|
|
Item {
|
|
id: searchSkel
|
|
visible: root.loading
|
|
x: 32
|
|
y: 0
|
|
width: bodyFlick.width - 64
|
|
height: bodyFlick.height
|
|
|
|
// All tab: hero + artists carousel + albums + tracks.
|
|
Column {
|
|
visible: root.tab === 0
|
|
width: parent.width
|
|
spacing: 28
|
|
Row {
|
|
width: parent.width
|
|
spacing: 24
|
|
Column {
|
|
width: 200
|
|
spacing: 12
|
|
QbzSkeleton { variant: "block"; width: 160; height: 22; phase: root.skelPhase }
|
|
QbzSkeleton { variant: "card"; width: 200; phase: root.skelPhase }
|
|
}
|
|
Column {
|
|
width: parent.width - 224
|
|
spacing: 12
|
|
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
|
|
QbzSkeleton {
|
|
variant: "cardGrid"
|
|
width: parent.width
|
|
height: 246
|
|
cellW: 232
|
|
cellH: 246
|
|
roundCells: true
|
|
phase: root.skelPhase
|
|
}
|
|
}
|
|
}
|
|
Column {
|
|
width: parent.width
|
|
spacing: 12
|
|
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
|
|
QbzSkeleton {
|
|
variant: "cardGrid"
|
|
width: parent.width
|
|
height: 246
|
|
cellW: 232
|
|
cellH: 246
|
|
phase: root.skelPhase
|
|
}
|
|
}
|
|
Column {
|
|
width: parent.width
|
|
spacing: 8
|
|
QbzSkeleton { variant: "block"; width: 180; height: 22; phase: root.skelPhase }
|
|
QbzSkeleton {
|
|
variant: "rowList"
|
|
width: parent.width
|
|
height: 300
|
|
rowH: 50
|
|
rowGap: 0
|
|
rowArtSize: 36
|
|
phase: root.skelPhase
|
|
}
|
|
}
|
|
}
|
|
|
|
// Albums / Playlists tabs: the 224x270 grid.
|
|
QbzSkeleton {
|
|
visible: root.tab === 1 || root.tab === 4
|
|
variant: "cardGrid"
|
|
anchors.fill: parent
|
|
cellW: 224
|
|
cellH: 270
|
|
phase: root.skelPhase
|
|
}
|
|
// Tracks tab: the 50px TrackRow list.
|
|
QbzSkeleton {
|
|
visible: root.tab === 2
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
rowH: 50
|
|
rowGap: 0
|
|
rowArtSize: 36
|
|
phase: root.skelPhase
|
|
}
|
|
// Artists tab: round cells on the 216x262 pitch.
|
|
QbzSkeleton {
|
|
visible: root.tab === 3
|
|
variant: "cardGrid"
|
|
anchors.fill: parent
|
|
cellW: 216
|
|
cellH: 262
|
|
roundCells: true
|
|
phase: root.skelPhase
|
|
}
|
|
}
|
|
Text {
|
|
visible: !root.loading && !root.hasResults
|
|
x: 32
|
|
y: 8
|
|
text: QbzSession.tr("No results.", QbzSession.trRev)
|
|
color: theme.textMuted
|
|
font.pixelSize: 14
|
|
}
|
|
}
|
|
QbzScrollBar {
|
|
target: bodyFlick
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 4
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
}
|
|
}
|
|
}
|
|
}
|