Files
qbz/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml
T
vicrodh dae03e3834 fix(qt): list rebuilds no longer steal the header search box's focus
Typing in the album track filter lost the keyboard on every keystroke. A
qt.quick.focus trace named the thief: a fresh track delegate took focus
"in scope pageFlick" right after each rebuild. In Qt 6.11 assigning a new
array to ListView.model after completion runs
QQuickItemViewPrivate::connectModel, which forces currentIndex to 0, and
updateCurrent() then focuses delegate 0 — inside the view's focus scope,
where the header's TextInput lives. `currentIndex: -1` does not survive
the assignment.

QbzArrayModel (a DelegateModel) now owns such arrays: a swap reaches the
view as remove+insert changes, which honour currentIndex -1 and keep the
rows. Because ListView re-seats inserted rows around the viewport, the
model re-expresses the viewport relative to the content origin and
restores it after layout; a page replacement calls scrollToTop(). The
album page, the kiosk album and the kiosk playlist swap their rows on it,
their ScrollMemory stores origin-relative offsets, and opening another
album resets the track filter (the box mirrors `trackQuery`).

scripts/test-array-model-qml.sh pins the behaviour (focus kept, viewport
kept, top on request) and joins the runtime gate and CI.

cc
2026-09-13 23:32:00 -06:00

188 lines
9.0 KiB
QML

// Local album Kiosk host: one scrolling header and a real delegate window.
// All commands use the same local album document and actions as Full UI.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../rows"
import "../theme"
import "../views/local"
Rectangle {
id: root
color: theme.surfaceMain
QbzTheme { id: theme }
readonly property var doc: {
try { return JSON.parse(QbzLocal.localAlbumJson) || ({}) } catch(e) { return ({}) }
}
readonly property var album: doc.album || null
readonly property var tracks: doc.tracks || []
property string trackQuery: ""
property var selected: ({})
property bool multiSelect: false
readonly property bool multiSelectOn: multiSelect
readonly property int selectedCount: Object.keys(selected).length
SelectionModel { id: selection }
readonly property var visibleTracks: {
var query = trackQuery.trim().toLowerCase()
return query === "" ? tracks : tracks.filter(function (t) {
return (t.title || "").toLowerCase().indexOf(query) >= 0
})
}
readonly property var allArtists: album ? (album.allArtists || album.artist || "").split(",") : []
property string coverPath: ""
onAlbumChanged: {
coverPath = ""
if (album && album.artKey) QbzLocal.artworkWindow(JSON.stringify([album.artKey]))
}
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
if (root.album && key === root.album.artKey) root.coverPath = path
}
}
function selectedIds() { return tracks.filter(function(t) { return selected[t.id] === true }).map(function(t) { return t.id }) }
function bulkAction(action) {
if (action === "clear") { selected = ({}); return }
if (action === "select-all") {
var next = {}; visibleTracks.forEach(function(t) { next[t.id] = true }); selected = next; return
}
QbzLocal.bulkAction("track", JSON.stringify(selectedIds()), action)
}
function selectAll() { multiSelect = true; bulkAction("select-all") }
function exitMultiSelectMode() { multiSelect = false; selected = ({}) }
function toggleSelected(id, mods) { selected = selection.next(selected, id, visibleTracks, mods === undefined ? Qt.NoModifier : mods) }
readonly property string navigationStateJson: JSON.stringify({trackQuery: trackQuery})
function restoreNavigationState(state) { trackQuery = state.trackQuery || "" }
// An in-place album switch starts at the top; the first document of a
// mount does not touch the viewport (ScrollMemory may be restoring it).
property string loadedAlbumId: ""
onDocChanged: {
exitMultiSelectMode()
var id = root.album ? String(root.album.id || "") : ""
if (id === root.loadedAlbumId)
return
if (root.loadedAlbumId !== "")
rowsModel.scrollToTop()
root.loadedAlbumId = id
}
ListView {
id: list
anchors.fill: parent
anchors.margins: 12
clip: true
// Swapped on a QbzArrayModel, never on `model:` — a fresh array there
// makes Qt 6.11 focus delegate 0 and the header's search box loses the
// keyboard on every keystroke (see the control's header).
model: QbzArrayModel { id: rowsModel; view: list; rows: root.visibleTracks }
cacheBuffer: height
reuseItems: true
boundsBehavior: Flickable.StopAtBounds
header: Column {
width: list.width
spacing: 12
LocalAlbumHeader {
width: parent.width
kioskHost: true
visible: !!root.album
album: root.album
allArtists: root.allArtists
versions: root.album ? root.album.versions || [] : []
coverSource: root.coverPath
infoLine: root.album ? [root.album.year || "", root.tracks.length + " " + QbzSession.tr("tracks", QbzSession.trRev), root.album.duration || ""].filter(function(t) { return t !== "" }).join(" • ") : ""
onOpenArtist: function(name) { QbzLocal.openArtistByName(name); QbzShell.navigateTo("local") }
}
Row {
spacing: 8
width: parent.width
QbzLineEdit {
kioskHost: true
width: Math.max(120, parent.width - selectButton.width - 8)
searchMode: true
text: root.trackQuery
onEdited: function(value) { root.trackQuery = value }
}
SettingsButton {
id: selectButton
kioskHost: true
text: QbzSession.tr("Select", QbzSession.trRev)
minWidth: 80
onClicked: root.multiSelect ? root.exitMultiSelectMode() : root.multiSelect = true
}
}
Row {
visible: root.multiSelect
width: parent.width
spacing: 8
SettingsButton { kioskHost: true; text: QbzSession.tr("Select all", QbzSession.trRev); minWidth: 100; onClicked: root.selectAll() }
SettingsButton { kioskHost: true; text: QbzSession.tr("Clear", QbzSession.trRev); minWidth: 80; onClicked: root.bulkAction("clear") }
SettingsButton {
id: selectedMenuButton
kioskHost: true; text: QbzSession.tr("More options", QbzSession.trRev); minWidth: 100
onClicked: selectedMenu.openBelowRight(selectedMenuButton)
}
}
KioskSkeleton {
width:parent.width; height:visible ? 180 : 0
visible:QbzLocal.localAlbumLoading || root.visibleTracks.length===0 || (root.doc.error || "")!==""
kind:"list"; loading:QbzLocal.localAlbumLoading; empty:root.visibleTracks.length===0
error:root.doc.error || ""
}
}
delegate: Column {
id: row
required property var modelData
required property int index
width: list.width
readonly property int disc: modelData.disc || 1
readonly property bool startsDisc: index === 0 || (root.visibleTracks[index-1].disc || 1) !== disc
Item {
width: parent.width
height: row.startsDisc ? 44 : 0
visible: row.startsDisc
Text { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: QbzSession.tr("Disc", QbzSession.trRev) + " " + row.disc; color: theme.textSecondary; font.pixelSize: 16 }
SettingsButton {
id: discButton
kioskHost: true; anchors.right: parent.right
iconName: "ellipsis"; minWidth: 44
onClicked: discMenu.openBelowRight(discButton)
}
CardMenu {
id: discMenu; kioskHost: true
entries: [
{label: QbzSession.tr("Play", QbzSession.trRev), action:"play"},
{label: QbzSession.tr("Play next", QbzSession.trRev), action:"next"},
{label: QbzSession.tr("Play later", QbzSession.trRev), action:"later"},
{label: QbzSession.tr("Add to queue", QbzSession.trRev), action:"queue"}
]
onPicked: function(a) { QbzLocal.albumDiscAction(row.disc, a) }
}
}
LocalTrackRow {
width: parent.width; kioskHost: true
item: row.modelData
number: row.modelData.number > 0 ? row.modelData.number : row.index + 1
showAlbum: false; showArtwork: false; zebra: true
selectMode: root.multiSelect
checked: root.selected[row.modelData.id] === true
onPlayRequested: QbzLocal.albumSelectedAction("play", row.modelData.id)
onEnqueueRequested: function(mode) { QbzLocal.enqueue("track", row.modelData.id, mode) }
onToggleSelect: function(mods) { root.toggleSelected(row.modelData.id, mods) }
}
}
}
CardMenu {
id: selectedMenu; kioskHost: true; menuWidth: 280
entries: [
{label: QbzSession.tr("Play next", QbzSession.trRev), action:"play-next"},
{label: QbzSession.tr("Play later", QbzSession.trRev), action:"play-later"},
{label: QbzSession.tr("Add to queue", QbzSession.trRev), action:"queue"},
{label: QbzSession.tr("Add to playlist", QbzSession.trRev), action:"add-to-playlist"},
{label: QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), action:"add-to-mixtape"}
]
onPicked: function(a) { if (root.selectedCount > 0) root.bulkAction(a) }
}
ScrollMemory { target: list; scope: "localalbum"; relativeToOrigin: true }
QbzScrollBar { target: list; anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom }
}