Ports the Slint/Tauri award pair: the landing (gold laurel hero, magazine line, follow heart, "Acclaimed Releases" with its count and See All, the preview grid and the "Other awards" rail) and the paginated listing with its client-side search. Routes "award" and "awardalbums", the label pair's shape — one controller for both, since they share the hero, the follow state and the identity. The three rules from the Tauri review are in the module header because they are the ones a re-implementation gets wrong: the grid is ALWAYS /award/getAlbums (/award/page is user-scoped and may omit releases, so it feeds the hero and nothing else); the favorite split is plural-read, singular-write, because the backend derives `award_ids` from the write kind; and getAlbums has no has_more, its `total` is a hint, so the only sound predicate is `total > loaded`. The album sidebar's AWARDS cards navigate now — with an id directly, and by NAME through the resolver when Qobuz omits it, which it does. They were also reading `album.awards` when the field lives on `album.header`, so the block had rendered EMPTY for every album since it was written: an empty Repeater draws nothing and reports nothing, and the sidebar's own gate counted the same empty list. The LABEL card beside it had a "no label view yet" note and no handler, years after the label view landed. Beyond the reference: an all-awards dropdown pinned to the page's top-right, so any award is reachable without first finding an album that won it. It costs nothing new — the /award/explore crawl already existed to resolve names to ids. The award icon was lucide's MICROPHONE under the wrong filename. Search had routed around it with `crown` and left a note; kiosk was missed and drew a mic for "Most popular". The asset is the laurel the other two frontends use, and kiosk now asks for the crown it wanted. cc
951 lines
36 KiB
QML
951 lines
36 KiB
QML
// KioskSearch — the kiosk Search results page (shell/KioskSearch.slint, 579
|
|
// lines). Reads the SAME search document the desktop SearchView reads
|
|
// (albums / tracks / artists / artistsCarousel / playlists + the tab int),
|
|
// rendered with the four lightweight kiosk primitives.
|
|
//
|
|
// The QUERY is not here: it is typed in the KioskShell back bar
|
|
// (SearchActions.submit -> QbzSearch.searchSubmit), and this view is only the
|
|
// tabbed results. There is no search field, no filter row, no "Load more" and
|
|
// no skeleton/empty/loading copy — KioskSearch.slint has none of them, so a
|
|
// query with no results renders the 48px tab strip and a blank pane
|
|
// (KioskSearch.slint:181-411: every section is gated on `length > 0` and the
|
|
// hero on `kind != ""`).
|
|
//
|
|
// NAV (KioskSearch.slint:97-144): the index space is the 5 tabs followed by
|
|
// the ACTIVE tab's items as a uniform grid. The All dashboard publishes
|
|
// TABS ONLY — its hero/preview mosaic is not a uniform grid, so keyboard nav
|
|
// on All reaches the 5 tab entries and nothing else. That is deliberate.
|
|
//
|
|
// Slint's `viewport-y` is negative as you scroll down; Qt's `contentY` is its
|
|
// positive twin — every scroll-into-view write below is already translated,
|
|
// and each is made from a function deferred with Qt.callLater, never from a
|
|
// binding that feeds layout.
|
|
//
|
|
// ARTWORK: search rows carry their own Rust-resolved local path (`artPath`,
|
|
// search_qt.rs CardRow/TrackRow/ArtistRow/PlaylistRow), so this view needs no
|
|
// artwork-window plumbing. The kiosk primitives read the resolved path under
|
|
// `artwork`, so the row objects are mapped once per document (below) rather
|
|
// than per delegate.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../theme"
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
// KioskSearch.slint:90 — no `background:` is set on the exported
|
|
// component, i.e. transparent over the shell's own surface.
|
|
color: "transparent"
|
|
|
|
// KioskSearch.slint:95.
|
|
property real pad: 16
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Document (search_qt.rs SearchPageDoc :1774-1796 / MostPopularDoc :1764-1771)
|
|
// ---------------------------------------------------------------------
|
|
readonly property var doc: root.parseDoc()
|
|
function parseDoc() {
|
|
try {
|
|
return JSON.parse(QbzSearch.searchJson)
|
|
} catch (e) {
|
|
return ({})
|
|
}
|
|
}
|
|
readonly property var albums: root.doc.albums || []
|
|
readonly property var tracks: root.doc.tracks || []
|
|
readonly property var artists: root.doc.artists || []
|
|
readonly property var artistsCarousel: root.doc.artistsCarousel || []
|
|
readonly property var playlists: root.doc.playlists || []
|
|
readonly property var mp: root.doc.mostPopular || ({})
|
|
readonly property int tab: root.doc.tab || 0
|
|
readonly property int tracksTotal: root.doc.tracksTotal || 0
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Row -> primitive mapping. Done ONCE per document: the delegates below
|
|
// are handed a finished object, so nothing re-allocates while scrolling.
|
|
// ---------------------------------------------------------------------
|
|
function cardOf(r) {
|
|
return {
|
|
"id": r.id || "",
|
|
"title": r.title || "",
|
|
"artist": r.artist || "",
|
|
"artwork": r.artPath || "",
|
|
"qualityTier": r.qualityTier || ""
|
|
}
|
|
}
|
|
function trackOf(r) {
|
|
return {
|
|
"id": r.id || "",
|
|
"title": r.title || "",
|
|
"artist": r.artist || "",
|
|
"duration": r.duration || "",
|
|
"artwork": r.artPath || ""
|
|
}
|
|
}
|
|
function artistOf(r) {
|
|
// ArtistRow's display name is `title`, NOT `name` (search_qt.rs:214).
|
|
return {
|
|
"id": r.id || "",
|
|
"title": r.title || "",
|
|
"artwork": r.artPath || ""
|
|
}
|
|
}
|
|
function playlistOf(r) {
|
|
return {
|
|
"id": r.id || "",
|
|
"title": r.title || "",
|
|
"artwork": r.artPath || ""
|
|
}
|
|
}
|
|
function mapRows(rows, fn) {
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++)
|
|
out.push(fn(rows[i]))
|
|
return out
|
|
}
|
|
|
|
readonly property var albumCards: root.mapRows(root.albums, root.cardOf)
|
|
readonly property var trackRows: root.mapRows(root.tracks, root.trackOf)
|
|
readonly property var artistCards: root.mapRows(root.artists, root.artistOf)
|
|
readonly property var artistCarouselCards: root.mapRows(root.artistsCarousel, root.artistOf)
|
|
readonly property var playlistCards: root.mapRows(root.playlists, root.playlistOf)
|
|
|
|
readonly property string heroKind: root.mp.kind || ""
|
|
readonly property var heroAlbum: root.mp.album ? root.cardOf(root.mp.album) : ({})
|
|
readonly property var heroArtist: root.mp.artist ? root.artistOf(root.mp.artist) : ({})
|
|
readonly property var heroTrack: root.mp.track ? root.trackOf(root.mp.track) : ({})
|
|
readonly property string heroQualityLabel: root.mp.qualityLabel || ""
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Nav geometry (KioskSearch.slint:103-135). The clamp Slint writes by hand
|
|
// at :122 lives inside the model's own publish (kiosk_nav_qt.rs:266-274).
|
|
// ---------------------------------------------------------------------
|
|
//
|
|
// Re-derives BOTH the tab and the row counts from `root.doc` rather than
|
|
// reading `tab` / `albums` / `tracks` / `artists` / `playlists`, and that
|
|
// is load-bearing. `onTabChanged` below fires on a document commit, and
|
|
// the five list properties are siblings of `tab` under that same document
|
|
// — a handler runs BEFORE the dependent bindings re-evaluate, so read from
|
|
// there they carry the PREVIOUS document (the measured KioskArtist.qml
|
|
// defect, commit ea26d743a, and the same remedy KioskNowPlaying.qml:213-219
|
|
// already applies to its cover dispatch). A pure tab switch republishes the
|
|
// SAME five arrays, so `navLenProbe` does not move and `onTabChanged` is
|
|
// then the ONLY publish — there is no second pass to correct it, and
|
|
// `publish()` CLAMPS `index` into whatever count it is handed
|
|
// (src/kiosk_nav_qt.rs:266-274). Reading the SOURCE document is safe: it is
|
|
// the value that changed.
|
|
function publishNav() {
|
|
var d = root.doc
|
|
var tab = d.tab || 0
|
|
var columns = 1
|
|
var count = 5
|
|
if (tab === 1) {
|
|
columns = 6
|
|
count = 5 + (d.albums || []).length
|
|
} else if (tab === 2) {
|
|
columns = 1
|
|
count = 5 + (d.tracks || []).length
|
|
} else if (tab === 3) {
|
|
columns = 7
|
|
count = 5 + (d.artists || []).length
|
|
} else if (tab === 4) {
|
|
columns = 6
|
|
count = 5 + (d.playlists || []).length
|
|
}
|
|
QbzKioskNav.publishNav(5, columns, count, false)
|
|
}
|
|
|
|
Component.onCompleted: root.publishNav()
|
|
onTabChanged: root.publishNav()
|
|
|
|
readonly property int navLenProbe: root.albums.length + root.tracks.length
|
|
+ root.artists.length + root.playlists.length
|
|
onNavLenProbeChanged: root.publishNav()
|
|
|
|
readonly property bool contentFocus: QbzKioskNav.navActive && QbzKioskNav.zone === "content"
|
|
|
|
// Enter on a tab entry — the same switch the tab taps drive
|
|
// (KioskSearch.slint:137-144). The per-tab containers below answer the
|
|
// same pulse for `index >= tabs`, so the two never overlap.
|
|
Connections {
|
|
target: QbzKioskNav
|
|
|
|
function onActivateSeqChanged() {
|
|
if (root.contentFocus && QbzKioskNav.index < QbzKioskNav.tabs)
|
|
QbzSearch.searchTabChanged(QbzKioskNav.index)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Local components (SearchTab KioskSearch.slint:17-51,
|
|
// SectionHeader :55-88)
|
|
// ---------------------------------------------------------------------
|
|
component SearchTab: Rectangle {
|
|
id: st
|
|
|
|
property string label: ""
|
|
property int tabIndex: 0
|
|
property bool navFocused: false
|
|
readonly property bool active: root.tab === st.tabIndex
|
|
|
|
width: stLabel.implicitWidth + 4
|
|
height: parent ? parent.height : 0
|
|
color: "transparent"
|
|
border.width: st.navFocused ? 2 : 0
|
|
border.color: theme.accent
|
|
radius: theme.radiusSm
|
|
|
|
Text {
|
|
id: stLabel
|
|
anchors.left: st.left
|
|
anchors.leftMargin: 2
|
|
anchors.top: st.top
|
|
anchors.bottom: stRule.top
|
|
text: st.label
|
|
color: st.active ? theme.textPrimary : theme.textMuted
|
|
font.pixelSize: 15
|
|
font.weight: theme.weightSemibold
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
|
|
Rectangle {
|
|
id: stRule
|
|
anchors.left: st.left
|
|
anchors.leftMargin: 2
|
|
anchors.right: st.right
|
|
anchors.rightMargin: 2
|
|
anchors.bottom: st.bottom
|
|
height: 2
|
|
radius: 2
|
|
color: st.active ? theme.accent : "transparent"
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: st
|
|
onClicked: QbzSearch.searchTabChanged(st.tabIndex)
|
|
}
|
|
}
|
|
|
|
component SectionHeader: Item {
|
|
id: sh
|
|
|
|
property string title: ""
|
|
property bool showAll: true
|
|
signal viewAll()
|
|
|
|
height: 34
|
|
|
|
Text {
|
|
anchors.left: sh.left
|
|
anchors.verticalCenter: sh.verticalCenter
|
|
text: sh.title
|
|
color: theme.textPrimary
|
|
font.pixelSize: 18
|
|
font.weight: theme.weightBold
|
|
}
|
|
|
|
Item {
|
|
id: shAll
|
|
visible: sh.showAll
|
|
anchors.right: sh.right
|
|
anchors.top: sh.top
|
|
anchors.bottom: sh.bottom
|
|
width: 74
|
|
|
|
Text {
|
|
anchors.fill: shAll
|
|
text: QbzSession.tr("View all", QbzSession.trRev)
|
|
color: theme.accent
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
horizontalAlignment: Text.AlignRight
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: shAll
|
|
onClicked: sh.viewAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Tab strip (KioskSearch.slint:150-169). The 1px rule sits on the strip's
|
|
// BOTTOM edge (`y: parent.height - 1px`, :165), separating tabs from
|
|
// content — not a top border.
|
|
// ---------------------------------------------------------------------
|
|
Rectangle {
|
|
id: tabStrip
|
|
anchors.top: root.top
|
|
anchors.left: root.left
|
|
anchors.right: root.right
|
|
height: 48
|
|
color: theme.surfaceMain
|
|
|
|
Row {
|
|
id: tabsRow
|
|
x: root.pad
|
|
anchors.top: tabStrip.top
|
|
anchors.bottom: tabStrip.bottom
|
|
spacing: 22
|
|
|
|
SearchTab {
|
|
label: QbzSession.tr("All", QbzSession.trRev)
|
|
tabIndex: 0
|
|
navFocused: root.contentFocus && QbzKioskNav.index === 0
|
|
}
|
|
SearchTab {
|
|
label: QbzSession.tr("Albums", QbzSession.trRev)
|
|
tabIndex: 1
|
|
navFocused: root.contentFocus && QbzKioskNav.index === 1
|
|
}
|
|
SearchTab {
|
|
label: QbzSession.tr("Tracks", QbzSession.trRev)
|
|
tabIndex: 2
|
|
navFocused: root.contentFocus && QbzKioskNav.index === 2
|
|
}
|
|
SearchTab {
|
|
label: QbzSession.tr("Artists", QbzSession.trRev)
|
|
tabIndex: 3
|
|
navFocused: root.contentFocus && QbzKioskNav.index === 3
|
|
}
|
|
SearchTab {
|
|
label: QbzSession.tr("Playlists", QbzSession.trRev)
|
|
tabIndex: 4
|
|
navFocused: root.contentFocus && QbzKioskNav.index === 4
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
y: tabStrip.height - 1
|
|
width: tabStrip.width
|
|
height: 1
|
|
color: theme.borderSubtle
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Content (KioskSearch.slint:172-577). One arm mounted at a time, exactly
|
|
// as Slint's `if` chain does.
|
|
// ---------------------------------------------------------------------
|
|
Item {
|
|
id: content
|
|
anchors.top: tabStrip.bottom
|
|
anchors.left: root.left
|
|
anchors.right: root.right
|
|
anchors.bottom: root.bottom
|
|
clip: true
|
|
|
|
Loader {
|
|
anchors.fill: content
|
|
active: root.tab === 0
|
|
sourceComponent: allDashboard
|
|
}
|
|
Loader {
|
|
anchors.fill: content
|
|
active: root.tab === 1
|
|
sourceComponent: albumsTab
|
|
}
|
|
Loader {
|
|
anchors.fill: content
|
|
active: root.tab === 2
|
|
sourceComponent: tracksTab
|
|
}
|
|
Loader {
|
|
anchors.fill: content
|
|
active: root.tab === 3
|
|
sourceComponent: artistsTab
|
|
}
|
|
Loader {
|
|
anchors.fill: content
|
|
active: root.tab === 4
|
|
sourceComponent: playlistsTab
|
|
}
|
|
}
|
|
|
|
// ---- All — the dashboard (KioskSearch.slint:181-411) -----------------
|
|
Component {
|
|
id: allDashboard
|
|
|
|
Flickable {
|
|
id: allFlick
|
|
contentWidth: allFlick.width
|
|
contentHeight: dash.height + 2 * root.pad
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
Column {
|
|
id: dash
|
|
x: root.pad
|
|
y: root.pad
|
|
width: allFlick.width - 2 * root.pad
|
|
spacing: 26
|
|
|
|
// ---- Most-popular hero (:190-280), polymorphic ----------
|
|
Column {
|
|
visible: root.heroKind !== ""
|
|
width: dash.width
|
|
spacing: 10
|
|
|
|
Row {
|
|
spacing: 8
|
|
|
|
QbzIcon {
|
|
id: crownGlyph
|
|
// "crown", like its desktop twin
|
|
// (SearchView.qml:546). This said "award" and drew
|
|
// a MICROPHONE — the asset under that name was
|
|
// lucide's mic. SearchView routed around it years
|
|
// ago and left a note; kiosk was missed. The asset
|
|
// is a laurel now, which is right for an AWARD and
|
|
// still wrong for "Most popular".
|
|
name: "crown"
|
|
width: 18
|
|
height: 18
|
|
tintName: "warning"
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: crownGlyph.verticalCenter
|
|
text: QbzSession.tr("Most popular", QbzSession.trRev)
|
|
color: theme.textSecondary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightBold
|
|
}
|
|
}
|
|
|
|
// The hero carries no nav ring: the All tab publishes no
|
|
// items (:118-121).
|
|
Loader {
|
|
active: root.heroKind === "artist"
|
|
sourceComponent: KioskArtistCard {
|
|
artSize: 150
|
|
artist: root.heroArtist
|
|
onClicked: function (id) {
|
|
QbzArtist.openArtist(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
active: root.heroKind === "album"
|
|
sourceComponent: KioskCard {
|
|
artSize: 150
|
|
album: root.heroAlbum
|
|
onClicked: function (id) {
|
|
QbzAlbum.openAlbum(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track hero — a bespoke 380x96 card, NOT KioskTrackRow
|
|
// (:227-278).
|
|
Loader {
|
|
active: root.heroKind === "track"
|
|
sourceComponent: Rectangle {
|
|
id: heroTrackCard
|
|
width: 380
|
|
height: 96
|
|
radius: theme.radiusMd
|
|
color: theme.surfaceCard
|
|
|
|
Row {
|
|
x: 8
|
|
y: 8
|
|
width: heroTrackCard.width - 16
|
|
height: heroTrackCard.height - 16
|
|
spacing: 12
|
|
|
|
Rectangle {
|
|
id: heroArt
|
|
width: 80
|
|
height: 80
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
|
|
// Mounted only when there is a resolved
|
|
// cover (:241) — otherwise the bare
|
|
// elevated square shows.
|
|
Loader {
|
|
anchors.fill: heroArt
|
|
active: (root.heroTrack.artwork || "") !== ""
|
|
sourceComponent: RoundedImage {
|
|
source: root.heroTrack.artwork || ""
|
|
radius: theme.radiusSm
|
|
fit: "crop"
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: heroText
|
|
width: heroTrackCard.width - 16 - heroArt.width - 12
|
|
anchors.verticalCenter: heroArt.verticalCenter
|
|
spacing: 3
|
|
|
|
Text {
|
|
width: heroText.width
|
|
text: root.heroTrack.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 16
|
|
font.weight: theme.weightBold
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
|
|
Text {
|
|
width: heroText.width
|
|
text: root.heroTrack.artist || ""
|
|
color: theme.textSecondary
|
|
font.pixelSize: 13
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
|
|
Text {
|
|
visible: root.heroQualityLabel !== ""
|
|
width: heroText.width
|
|
text: root.heroQualityLabel
|
|
color: theme.textMuted
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: heroTrackCard
|
|
onClicked: QbzPlayer.playTrack(root.heroTrack.id || "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Artists preview (:283-309), hero-deduped carousel ----
|
|
Column {
|
|
visible: root.artistCarouselCards.length > 0
|
|
width: dash.width
|
|
spacing: 10
|
|
|
|
SectionHeader {
|
|
width: dash.width
|
|
title: QbzSession.tr("Artists", QbzSession.trRev)
|
|
onViewAll: QbzSearch.searchTabChanged(3)
|
|
}
|
|
|
|
Row {
|
|
spacing: 14
|
|
|
|
Repeater {
|
|
model: root.artistCarouselCards.slice(0, 6)
|
|
|
|
delegate: KioskArtistCard {
|
|
required property var modelData
|
|
|
|
artSize: 120
|
|
artist: modelData
|
|
onClicked: function (id) {
|
|
QbzArtist.openArtist(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Albums preview (:312-336) ---------------------------
|
|
Column {
|
|
visible: root.albumCards.length > 0
|
|
width: dash.width
|
|
spacing: 10
|
|
|
|
SectionHeader {
|
|
width: dash.width
|
|
title: QbzSession.tr("Albums", QbzSession.trRev)
|
|
onViewAll: QbzSearch.searchTabChanged(1)
|
|
}
|
|
|
|
Row {
|
|
spacing: 14
|
|
|
|
Repeater {
|
|
model: root.albumCards.slice(0, 6)
|
|
|
|
delegate: KioskCard {
|
|
required property var modelData
|
|
|
|
artSize: 140
|
|
album: modelData
|
|
onClicked: function (id) {
|
|
QbzAlbum.openAlbum(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Tracks preview (:339-356) ---------------------------
|
|
// The ONLY section with a conditional "View all" (:343).
|
|
Column {
|
|
visible: root.trackRows.length > 0
|
|
width: dash.width
|
|
spacing: 6
|
|
|
|
SectionHeader {
|
|
width: dash.width
|
|
title: QbzSession.tr("Tracks", QbzSession.trRev)
|
|
showAll: root.tracksTotal > 6
|
|
onViewAll: QbzSearch.searchTabChanged(2)
|
|
}
|
|
|
|
Repeater {
|
|
model: root.trackRows.slice(0, 6)
|
|
|
|
delegate: KioskTrackRow {
|
|
required property var modelData
|
|
|
|
width: dash.width
|
|
track: modelData
|
|
onClicked: QbzPlayer.playTrack(modelData.id || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Playlists preview (:359-409) ------------------------
|
|
Column {
|
|
visible: root.playlistCards.length > 0
|
|
width: dash.width
|
|
spacing: 10
|
|
|
|
SectionHeader {
|
|
width: dash.width
|
|
title: QbzSession.tr("Playlists", QbzSession.trRev)
|
|
onViewAll: QbzSearch.searchTabChanged(4)
|
|
}
|
|
|
|
Row {
|
|
spacing: 14
|
|
|
|
Repeater {
|
|
model: root.playlistCards.slice(0, 6)
|
|
|
|
// A bespoke 140px card, not KioskCard (:373-405).
|
|
delegate: Item {
|
|
id: plPrev
|
|
|
|
required property var modelData
|
|
|
|
width: 140
|
|
height: plPrevBody.height
|
|
|
|
Column {
|
|
id: plPrevBody
|
|
width: 140
|
|
spacing: 7
|
|
|
|
Rectangle {
|
|
id: plPrevArt
|
|
width: 140
|
|
height: 140
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
|
|
RoundedImage {
|
|
anchors.fill: plPrevArt
|
|
source: plPrev.modelData.artwork || ""
|
|
radius: theme.radiusSm
|
|
fit: "crop"
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: 140
|
|
text: plPrev.modelData.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 13
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: plPrev
|
|
onClicked: QbzBridge.openPlaylist(plPrev.modelData.id || "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Albums tab (KioskSearch.slint:414-419) --------------------------
|
|
// Only `albums` is passed; columns/gap/pad keep the primitive's defaults,
|
|
// and the grid owns its own ring, Enter pulse and scroll-into-view.
|
|
Component {
|
|
id: albumsTab
|
|
|
|
KioskAlbumGrid {
|
|
albums: root.albumCards
|
|
onOpen: function (id) {
|
|
QbzAlbum.openAlbum(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Tracks tab (KioskSearch.slint:422-453) --------------------------
|
|
Component {
|
|
id: tracksTab
|
|
|
|
ListView {
|
|
id: tracksList
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
spacing: 0
|
|
model: root.trackRows
|
|
|
|
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
|
|
readonly property bool itemFocused: root.contentFocus
|
|
&& tracksList.focusedItem >= 0
|
|
&& tracksList.focusedItem < root.trackRows.length
|
|
|
|
// 62px pitch = the KioskTrackRow height, no spacing.
|
|
function scrollFocusIntoView() {
|
|
if (!tracksList.itemFocused)
|
|
return
|
|
var top = tracksList.focusedItem * 62
|
|
if (top < tracksList.contentY)
|
|
tracksList.contentY = top - 8
|
|
else if (top + 62 > tracksList.contentY + tracksList.height)
|
|
tracksList.contentY = top + 62 - tracksList.height + 8
|
|
}
|
|
|
|
Connections {
|
|
target: QbzKioskNav
|
|
|
|
function onIndexChanged() {
|
|
Qt.callLater(tracksList.scrollFocusIntoView)
|
|
}
|
|
|
|
function onActivateSeqChanged() {
|
|
if (tracksList.itemFocused)
|
|
QbzPlayer.playTrack(root.trackRows[tracksList.focusedItem].id || "")
|
|
}
|
|
}
|
|
|
|
delegate: KioskTrackRow {
|
|
id: trackRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: tracksList.width
|
|
track: trackRow.modelData
|
|
navFocused: tracksList.itemFocused && tracksList.focusedItem === trackRow.index
|
|
onClicked: QbzPlayer.playTrack(trackRow.modelData.id || "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Artists tab (KioskSearch.slint:456-501) -------------------------
|
|
Component {
|
|
id: artistsTab
|
|
|
|
Flickable {
|
|
id: agrid
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
readonly property int cols: 7
|
|
readonly property real gap: 14
|
|
readonly property real cell: (agrid.width - 2 * root.pad - (agrid.cols - 1) * agrid.gap) / agrid.cols
|
|
// +26px is the name-label band under the round avatar.
|
|
readonly property real band: agrid.cell + 26
|
|
|
|
contentWidth: agrid.width
|
|
contentHeight: Math.ceil(root.artistCards.length / agrid.cols) * (agrid.band + agrid.gap) + root.pad
|
|
|
|
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
|
|
readonly property bool itemFocused: root.contentFocus
|
|
&& agrid.focusedItem >= 0
|
|
&& agrid.focusedItem < root.artistCards.length
|
|
readonly property real focusTop: root.pad
|
|
+ Math.floor(agrid.focusedItem / agrid.cols) * (agrid.band + agrid.gap)
|
|
|
|
function scrollFocusIntoView() {
|
|
if (!agrid.itemFocused)
|
|
return
|
|
if (agrid.focusTop < agrid.contentY)
|
|
agrid.contentY = agrid.focusTop - 8
|
|
else if (agrid.focusTop + agrid.band > agrid.contentY + agrid.height)
|
|
agrid.contentY = agrid.focusTop + agrid.band - agrid.height + 8
|
|
}
|
|
|
|
Connections {
|
|
target: QbzKioskNav
|
|
|
|
function onIndexChanged() {
|
|
Qt.callLater(agrid.scrollFocusIntoView)
|
|
}
|
|
|
|
function onActivateSeqChanged() {
|
|
if (agrid.itemFocused)
|
|
QbzArtist.openArtist(root.artistCards[agrid.focusedItem].id || "")
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: agrid.contentWidth
|
|
height: agrid.contentHeight
|
|
|
|
Repeater {
|
|
model: root.artistCards
|
|
|
|
delegate: KioskArtistCard {
|
|
id: artistCell
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
x: root.pad + (artistCell.index % agrid.cols) * (agrid.cell + agrid.gap)
|
|
y: root.pad + Math.floor(artistCell.index / agrid.cols) * (agrid.band + agrid.gap)
|
|
artSize: agrid.cell
|
|
artist: artistCell.modelData
|
|
navFocused: agrid.itemFocused && agrid.focusedItem === artistCell.index
|
|
onClicked: function (id) {
|
|
QbzArtist.openArtist(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Playlists tab (KioskSearch.slint:504-576) -----------------------
|
|
Component {
|
|
id: playlistsTab
|
|
|
|
Flickable {
|
|
id: pgrid
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
|
|
readonly property int cols: 6
|
|
readonly property real gap: 16
|
|
readonly property real cell: (pgrid.width - 2 * root.pad - (pgrid.cols - 1) * pgrid.gap) / pgrid.cols
|
|
// +42px is the title band.
|
|
readonly property real band: pgrid.cell + 42
|
|
|
|
contentWidth: pgrid.width
|
|
contentHeight: Math.ceil(root.playlistCards.length / pgrid.cols) * (pgrid.band + pgrid.gap) + root.pad
|
|
|
|
readonly property int focusedItem: QbzKioskNav.index - QbzKioskNav.tabs
|
|
readonly property bool itemFocused: root.contentFocus
|
|
&& pgrid.focusedItem >= 0
|
|
&& pgrid.focusedItem < root.playlistCards.length
|
|
readonly property real focusTop: root.pad
|
|
+ Math.floor(pgrid.focusedItem / pgrid.cols) * (pgrid.band + pgrid.gap)
|
|
|
|
function scrollFocusIntoView() {
|
|
if (!pgrid.itemFocused)
|
|
return
|
|
if (pgrid.focusTop < pgrid.contentY)
|
|
pgrid.contentY = pgrid.focusTop - 8
|
|
else if (pgrid.focusTop + pgrid.band > pgrid.contentY + pgrid.height)
|
|
pgrid.contentY = pgrid.focusTop + pgrid.band - pgrid.height + 8
|
|
}
|
|
|
|
Connections {
|
|
target: QbzKioskNav
|
|
|
|
function onIndexChanged() {
|
|
Qt.callLater(pgrid.scrollFocusIntoView)
|
|
}
|
|
|
|
function onActivateSeqChanged() {
|
|
if (pgrid.itemFocused)
|
|
QbzBridge.openPlaylist(root.playlistCards[pgrid.focusedItem].id || "")
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: pgrid.contentWidth
|
|
height: pgrid.contentHeight
|
|
|
|
Repeater {
|
|
model: root.playlistCards
|
|
|
|
// The ring is drawn by the cell itself — there is no shared
|
|
// primitive for playlists. Card ring family: 3px + a 0.12
|
|
// accent tint (:540-543).
|
|
delegate: Rectangle {
|
|
id: plCell
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool focused: pgrid.itemFocused
|
|
&& pgrid.focusedItem === plCell.index
|
|
|
|
x: root.pad + (plCell.index % pgrid.cols) * (pgrid.cell + pgrid.gap)
|
|
y: root.pad + Math.floor(plCell.index / pgrid.cols) * (pgrid.band + pgrid.gap)
|
|
width: pgrid.cell
|
|
height: pgrid.band
|
|
border.width: plCell.focused ? 3 : 0
|
|
border.color: theme.accent
|
|
radius: theme.radiusSm
|
|
color: plCell.focused
|
|
? Qt.rgba(theme.accent.r, theme.accent.g, theme.accent.b, 0.12)
|
|
: "transparent"
|
|
|
|
Column {
|
|
width: pgrid.cell
|
|
spacing: 7
|
|
|
|
Rectangle {
|
|
id: plArt
|
|
width: pgrid.cell
|
|
height: pgrid.cell
|
|
radius: theme.radiusSm
|
|
color: theme.surfaceElevated
|
|
clip: true
|
|
|
|
RoundedImage {
|
|
anchors.fill: plArt
|
|
source: plCell.modelData.artwork || ""
|
|
radius: theme.radiusSm
|
|
fit: "crop"
|
|
}
|
|
}
|
|
|
|
Text {
|
|
width: pgrid.cell
|
|
text: plCell.modelData.title || ""
|
|
color: theme.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: plCell
|
|
onClicked: QbzBridge.openPlaylist(plCell.modelData.id || "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|