Files
qbz/crates/qbz-qt/qml/kiosk/KioskSearch.qml
T
vicrodh 31da9c57bb fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening:

- Chrome: the back bar is a drag surface and shrank to 44px with one shared
  button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a
  permanent cluster in the bar; the dead Share-logs button left the kiosk
  Settings header.
- Transport bar: the hidden overflow menu is gone; Volume is always visible
  with a vertical sheet, and Desktop mode has its own button.
- Now Playing: one centred transport row (New-bar order); lyrics are centred
  (shared LyricsLinesView gains an opt-in centered, desktop default false).
- Local Library: the media funnel and an Open (ephemeral) entry point reach
  the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the
  desktop LocalArtistsTab is untouched); the Artists drill-down query no
  longer hangs off a stale derived guard.
- Search: the All-tab dashboard shelves render again — they were mounted in a
  Loader Component that read the delegate id under reuse and bound an empty
  model; they are direct children now.
- Album detail: icon actions the height of the quality badge plus play next /
  later / queue, an inline floating quality badge, and zebra rows; both album
  headers show a quality badge.
- Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the
  window controls.

Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the
runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6,
Node gate 5/5, release build 0 warnings.

cc
2026-09-07 12:33:28 -06:00

246 lines
14 KiB
QML

// Search tabs share authoritative history. Only the active viewport is mounted.
import QtQuick
import com.blitzfc.qbz
import "../theme"
import "../controls"
Rectangle {
id: root
color: "transparent"
QbzTheme { id: theme }
readonly property var doc: { try { return JSON.parse(QbzSearch.searchJson) } catch(e) { return ({}) } }
property string restoringQuery: ""
property var shelfPositions: ({})
function saveShelf(key, value) { var next = Object.assign({}, shelfPositions); next[key] = value; shelfPositions = next }
property int restoringTab: 0
readonly property int tab: restoringQuery !== "" ? restoringTab : (doc.tab || 0)
onDocChanged: if (restoringQuery !== "" && doc.query === restoringQuery && !doc.loading) restoringQuery = ""
readonly property string activeTab: String(tab)
readonly property var tabNames: ["All", "Albums", "Tracks", "Artists", "Playlists"]
readonly property var activeRows: restoringQuery !== "" ? [] : tab === 1 ? (doc.albums || []) : tab === 2 ? (doc.tracks || []) : tab === 3 ? (doc.artists || []) : tab === 4 ? (doc.playlists || []) : []
readonly property int columns: Math.max(2, Math.floor((width - 32) / 142))
readonly property bool contentFocus: QbzKioskNav.navActive && QbzKioskNav.zone === "content"
readonly property int focusedItem: QbzKioskNav.index - 5
readonly property var dashboard: {
var result = [], mp = doc.mostPopular || ({})
if (restoringQuery !== "") return result
if (mp.kind && mp[mp.kind]) result.push({ title: "Most popular", kind: mp.kind, rows: [mp[mp.kind]], tab: -1 })
var artists = doc.artistsCarousel || doc.artists || []
if (artists.length) result.push({ title: "Artists", kind: "artist", rows: artists, tab: 3 })
if ((doc.albums || []).length) result.push({ title: "Albums", kind: "album", rows: doc.albums, tab: 1 })
if ((doc.tracks || []).length) result.push({ title: "Tracks", kind: "track", rows: doc.tracks, tab: 2 })
if ((doc.playlists || []).length) result.push({ title: "Playlists", kind: "playlist", rows: doc.playlists, tab: 4 })
return result
}
property var tabNavigationRequest: ({})
onTabNavigationRequestChanged: if (tabNavigationRequest && tabNavigationRequest.tab !== undefined) activateTab(tabNavigationRequest.tab)
function activateTab(value) { selectTab(Number(value)) }
function selectTab(value) {
if (value < 0 || value > 4 || value === tab) return
navigation.recordTab(String(value))
if (restoringQuery !== "") restoringTab = value
QbzSearch.searchTabChanged(value)
}
function open(kind, id) {
if (kind === "artist") QbzArtist.openArtist(id)
else if (kind === "playlist") QbzBridge.openPlaylist(id)
else if (kind === "track") QbzPlayer.playTrack(id)
else QbzAlbum.openAlbum(id)
}
function publishNav() { QbzKioskNav.publishNav(5, tab === 2 ? 1 : columns, 5 + activeRows.length, false) }
onActiveRowsChanged: Qt.callLater(publishNav)
onTabChanged: Qt.callLater(publishNav)
onColumnsChanged: Qt.callLater(publishNav)
Component.onCompleted: publishNav()
KioskNavigation {
id: navigation
route: "search"
snapshot: ({ activeTab: String(root.tab), query: root.restoringQuery || root.doc.query || "", shelfPositions: root.shelfPositions })
onRestore: function(saved) {
root.shelfPositions = saved.shelfPositions || ({})
var value = saved.activeTab !== undefined ? Number(saved.activeTab) : root.tab
if (!isFinite(value) || value < 0 || value > 4) value = 0
if (saved.query && saved.query !== root.doc.query) {
root.restoringTab = value
root.restoringQuery = saved.query
QbzSearch.kioskRestoreSearch(saved.query, value)
} else if (value !== root.tab) QbzSearch.searchTabChanged(value)
}
}
Connections {
target: QbzKioskNav
function onIndexChanged() {
if (root.contentFocus && root.focusedItem >= 0 && body.item && body.item.positionViewAtIndex)
body.item.positionViewAtIndex(root.focusedItem, ListView.Contain)
}
function onActivateSeqChanged() {
if (!root.contentFocus) return
if (QbzKioskNav.index < 5) { root.selectTab(QbzKioskNav.index); return }
var row = root.activeRows[root.focusedItem]
if (row) root.open(root.tab === 2 ? "track" : root.tab === 3 ? "artist" : root.tab === 4 ? "playlist" : "album", row.id || "")
}
}
Row {
id: tabs
x: 16; height: 64; spacing: 4
Repeater {
model: root.tabNames
delegate: Rectangle {
required property string modelData
required property int index
width: Math.max(64, Math.min((root.width - 48) / 5, label.implicitWidth + 22)); height: 64
radius: theme.radiusSm; color: root.tab === index ? theme.surfaceElevated : "transparent"
border.width: root.contentFocus && QbzKioskNav.index === index ? 2 : 0; border.color: theme.accent
Text { id: label; anchors.centerIn: parent; text: QbzSession.tr(modelData, QbzSession.trRev); color: root.tab === index ? theme.textPrimary : theme.textSecondary; font.pixelSize: 16 }
MouseArea { anchors.fill: parent; onClicked: root.selectTab(index) }
}
}
}
Loader {
id: body
anchors.top: tabs.bottom; anchors.bottom: parent.bottom; anchors.left: parent.left; anchors.right: parent.right
sourceComponent: root.tab === 0 ? dashboardView : root.tab === 2 ? tracksView : cardsView
}
ScrollMemory { target: body.item; scope: "search:" + root.tab }
KioskSkeleton {
anchors.fill: body; kind: root.tab === 2 ? "list" : "grid"
loading: (root.restoringQuery !== "" || !!root.doc.loading) && (root.tab === 0 ? root.dashboard.length === 0 : root.activeRows.length === 0)
error: root.doc.error || ""
empty: root.restoringQuery === "" && !root.doc.loading && (root.tab === 0 ? root.dashboard.length === 0 : root.activeRows.length === 0)
}
Component {
id: cardsView
GridView {
id: grid
clip: true; cacheBuffer: 0; reuseItems: true
leftMargin: 16; rightMargin: 16; topMargin: 16; bottomMargin: 16
boundsBehavior: Flickable.StopAtBounds
cellWidth: (width - 32) / root.columns
cellHeight: cellWidth + 48
model: root.activeRows
onAtYEndChanged: if (atYEnd && count > 0 && !root.doc.loading) QbzSearch.searchLoadMore(root.tab)
delegate: Loader {
id: cell
required property var modelData
required property int index
width: grid.cellWidth - 14; height: grid.cellHeight - 14
sourceComponent: root.tab === 3 ? artistCard : albumCard
KioskCoverSource { id: cover; remote: cell.modelData.artUrl || ""; local: cell.modelData.artPath || ""; edge: cell.width }
Component {
id: artistCard
KioskArtistCard {
artSize: cell.width
artist: ({ id: cell.modelData.id || "", title: cell.modelData.title || "", artwork: cover.source })
navFocused: root.contentFocus && root.focusedItem === cell.index
onClicked: function(id) { root.open("artist", id) }
}
}
Component {
id: albumCard
KioskCard {
artSize: cell.width
album: ({ id: cell.modelData.id || "", title: cell.modelData.title || "", artist: cell.modelData.artist || "", artwork: cover.source, qualityTier: cell.modelData.qualityTier || "" })
navFocused: root.contentFocus && root.focusedItem === cell.index
onClicked: function(id) { root.open(root.tab === 4 ? "playlist" : "album", id) }
}
}
}
}
}
Component {
id: tracksView
ListView {
id: tracks
clip: true; cacheBuffer: 0; reuseItems: true
model: root.activeRows
boundsBehavior: Flickable.StopAtBounds
onAtYEndChanged: if (atYEnd && count > 0 && !root.doc.loading) QbzSearch.searchLoadMore(2)
delegate: KioskTrackRow {
id: row
required property var modelData
required property int index
width: tracks.width; height: 64
KioskCoverSource { id: cover; remote: row.modelData.artUrl || ""; local: row.modelData.artPath || ""; edge: 46 }
track: ({ id: modelData.id || "", title: modelData.title || "", artist: modelData.artist || "", duration: modelData.duration || "", artwork: cover.source })
navFocused: root.contentFocus && root.focusedItem === index
onClicked: root.open("track", modelData.id || "")
}
}
}
Component {
id: dashboardView
ListView {
id: dashboardList
clip: true; cacheBuffer: 0; reuseItems: true; spacing: 20
leftMargin: 16; rightMargin: 16; topMargin: 12; bottomMargin: 16
model: root.dashboard
boundsBehavior: Flickable.StopAtBounds
delegate: Column {
id: section
required property var modelData
// GUARDED, STABLE section data. The preview Components below are
// instantiated by a Loader, and at that instant the delegate's
// `modelData` is briefly undefined; a Component that read
// `section.modelData.rows` DIRECTLY threw "Cannot read property
// 'rows' of undefined", which killed the binding for good and
// left every dashboard shelf empty (the 2026-09-07 "search is
// broken" regression from the hardening's dashboard rewrite —
// reproduced in a bare `qml` scene; the guarded read below is
// what stops the throw, NOT disabling reuse, which still threw).
readonly property string sTitle: section.modelData && section.modelData.title ? section.modelData.title : ""
readonly property string sKind: section.modelData && section.modelData.kind ? section.modelData.kind : "album"
readonly property var sRows: section.modelData && section.modelData.rows ? section.modelData.rows : []
readonly property int sTab: section.modelData && section.modelData.tab !== undefined ? section.modelData.tab : -1
width: dashboardList.width - 32; spacing: 8
Item {
width: parent.width; height: 44
Text { anchors.left: parent.left; anchors.right: viewAll.left; height: parent.height; text: QbzSession.tr(section.sTitle, QbzSession.trRev); color: theme.textPrimary; font.pixelSize: 18; font.weight: theme.weightSemibold; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight }
Rectangle {
id: viewAll
anchors.right: parent.right; width: 100; height: 44
visible: section.sTab >= 0; color: "transparent"
Text { anchors.centerIn: parent; text: QbzSession.tr("View all", QbzSession.trRev); color: theme.textSecondary; font.pixelSize: 14 }
MouseArea { anchors.fill: parent; onClicked: root.selectTab(section.sTab) }
}
}
// The preview is a DIRECT child of the delegate, NOT a
// Component behind a Loader. The Loader form (a Component
// defined in the delegate, reading `section.modelData`/`section`)
// is what emptied every shelf on 2026-09-07: the Component's
// bindings to the delegate resolved against an undefined
// `section` at instantiation and never recovered. KioskDiscover
// mounts its shelf as a direct child for exactly this reason;
// this matches it. The two kinds are two direct children gated
// by `visible`; the track preview is a bounded Repeater (<=3),
// never a nested ListView.
KioskShelf {
visible: section.sKind !== "track"
width: parent.width
kind: section.sKind
rows: section.sKind !== "track" ? section.sRows : []
restoreX: root.shelfPositions[(root.doc.query || "") + ":" + section.sTitle] || 0
onScrollSettled: function(x) { root.saveShelf((root.doc.query || "") + ":" + section.sTitle, x) }
onOpen: function(id) { root.open(section.sKind, id) }
}
Column {
visible: section.sKind === "track"
width: parent.width
spacing: 0
Repeater {
model: section.sKind === "track" ? section.sRows.slice(0, 3) : []
delegate: KioskTrackRow {
id: previewRow
required property var modelData
required property int index
width: parent.width; height: 64
KioskCoverSource { id: cover; remote: previewRow.modelData.artUrl || ""; local: previewRow.modelData.artPath || ""; edge: 46 }
track: ({ id: modelData.id || "", title: modelData.title || "", artist: modelData.artist || "", duration: modelData.duration || "", artwork: cover.source })
onClicked: root.open("track", previewRow.modelData.id || "")
}
}
}
}
}
}
}