The bar sat inside the tab's 32px content inset AND carried its own 4px margin, leaving it 36px from the window with an empty channel beside it. It now cancels the inset; the rows keep theirs. The "By name" arm is untouched: there the alpha strip owns the right edge and 22px is what clears it. The inset is a named property now because the scrollbar has to cancel it — written twice, moving one silently misaligns the other. cc
357 lines
17 KiB
QML
357 lines
17 KiB
QML
// Tracks tab body (LocalLibraryView.slint:1317) — the SERVER-PAGINATED flat
|
|
// list. This is the surface that froze the Slint build at 16k tracks: the
|
|
// model is never the whole table (500 rows per page, appended within 600px
|
|
// of the tail — the Slint's own trigger distance at :1442) and the ListView
|
|
// only ever instantiates its window.
|
|
//
|
|
// Grouping (off / album / artist / name) inserts 32px header rows, so the
|
|
// model is an entry array — { t: 0, label } | { t: 1, row, n } — exactly as
|
|
// the collection does it, keeping ONE windowing ListView with variable row
|
|
// heights instead of a Repeater per group.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../controls"
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// The tab's own left/right breathing room. Named because the scrollbar
|
|
/// has to CANCEL it (see the QbzScrollBar at the bottom of the list) —
|
|
/// with the number written twice, moving one would silently misalign it.
|
|
readonly property int sideInset: 32
|
|
|
|
property var view: null
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property int rowH: 50
|
|
readonly property int headerH: 32
|
|
|
|
// --------------------------- entry model ------------------------------
|
|
property var entries: []
|
|
property var alphaJumps: []
|
|
/// entry index -> index into `view.tracksVisible`, -1 for a group header.
|
|
/// Group headers inflate the entry indices, so reporting them straight
|
|
/// into the row array asked for the covers of tracks BELOW the ones on
|
|
/// screen, by the number of headers scrolled past.
|
|
property var rowIndex: []
|
|
function rebuild() {
|
|
var rows = view ? view.tracksVisible : []
|
|
var mode = view ? view.tracksGroup : "off"
|
|
var out = []
|
|
var jumps = []
|
|
var idx = []
|
|
var prev = null
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var t = rows[i]
|
|
var key = mode === "album" ? (t.album || "")
|
|
: mode === "artist" ? (t.artist || "")
|
|
: mode === "name" ? (t.title || "").slice(0, 1).toUpperCase()
|
|
: ""
|
|
if (mode !== "off" && key !== "" && key !== prev) {
|
|
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
|
|
out.push({ "t": 0, "label": key })
|
|
idx.push(-1)
|
|
prev = key
|
|
}
|
|
out.push({ "t": 1, "row": t, "n": i + 1 })
|
|
idx.push(i)
|
|
}
|
|
entries = out
|
|
alphaJumps = jumps
|
|
rowIndex = idx
|
|
report()
|
|
}
|
|
Component.onCompleted: { rebuild(); reportSoon() }
|
|
Component.onDestruction: if (view) view.releaseWindow("tracks")
|
|
Connections {
|
|
target: root.view
|
|
function onTracksVisibleChanged() { root.rebuild() }
|
|
function onTracksGroupChanged() { root.rebuild() }
|
|
// The row artwork column is OFF by default; turning it on has to ask
|
|
// for the covers the rows are already bound to.
|
|
function onTrackArtworkChanged() { root.reportSoon() }
|
|
function onArtworkRefresh() { root.reportSoon() }
|
|
}
|
|
|
|
// --------------------------- row play ---------------------------------
|
|
// PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL
|
|
// page order — and find the clicked row in it, so with a group mode on
|
|
// (a CLIENT-side reorder, see LocalLibraryView.tracksVisible) the user
|
|
// heard a different order than the one on screen. The visible order only
|
|
// exists QML-side, so it goes down as a JSON id array and the raw rows
|
|
// play the part of the authoritative cache
|
|
// (local_playback::order_by_visible, the local twin of
|
|
// library_qt::order_by_visible).
|
|
//
|
|
// `view.tracksVisible` is THE array `entries` is built from — never read
|
|
// `list.model` back off the view (see views/LibraryView.qml).
|
|
function visibleTrackIds() {
|
|
var rows = root.view ? root.view.tracksVisible : []
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function playRow(id) {
|
|
QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
|
|
}
|
|
|
|
// First page = the shape of the 50px track rows (the Slint mounts a bare
|
|
// 36px LoadingSpinner). ONE instance for the whole viewport.
|
|
QbzSkeleton {
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.sideInset
|
|
anchors.rightMargin: root.sideInset
|
|
anchors.topMargin: 12
|
|
visible: QbzLocal.localTracksLoading
|
|
rowH: root.rowH
|
|
rowGap: 0
|
|
rowArt: root.view.trackArtwork
|
|
rowArtSize: 36
|
|
phase: root.view.skelPhase
|
|
}
|
|
LocalNote {
|
|
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
|
|
&& root.view.tracksSearch === ""
|
|
text: QbzSession.tr("No tracks in your local library yet.", QbzSession.trRev)
|
|
}
|
|
LocalNote {
|
|
visible: !QbzLocal.localTracksLoading
|
|
&& (root.view.tracks.length === 0 || root.view.tracksVisible.length === 0)
|
|
&& root.view.tracksSearch !== ""
|
|
text: QbzSession.tr("No tracks match your search.", QbzSession.trRev)
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.sideInset
|
|
anchors.rightMargin: root.sideInset
|
|
anchors.topMargin: 12
|
|
spacing: 8
|
|
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
|
|
|
|
QbzMultiSelectBar {
|
|
visible: root.view.tracksMultiSelect
|
|
width: parent.width
|
|
selectedCount: root.view.tracksSelectedCount
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
// NO "Remove from favorites": this tab is the whole local
|
|
// library, not a favourites surface — removing from a
|
|
// collection the user is not looking at is not an action this
|
|
// context can offer. (It was doubly wrong: local hearts are
|
|
// unwired, so `local_bulk.rs`'s arm is a log-only no-op.)
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
|
|
]
|
|
onAction: function (id) { root.view.tracksBulkAction(id) }
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - (root.view.tracksMultiSelect ? 52 : 0)
|
|
|
|
ListView {
|
|
id: list
|
|
anchors.fill: parent
|
|
anchors.rightMargin: root.view.tracksGroup === "name" ? 20 : 0
|
|
clip: true
|
|
cacheBuffer: 50 * 12
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.entries
|
|
|
|
onContentYChanged: {
|
|
root.report()
|
|
// Infinite scroll: 600px of runway (Slint :1442).
|
|
//
|
|
// `contentHeight > height` states the precondition the
|
|
// predicate always had: a list shorter than its viewport
|
|
// has no bottom to run out of, so it cannot be "600px from
|
|
// the end". Nothing is lost by it — a list that short
|
|
// cannot scroll (StopAtBounds), so contentY never moves
|
|
// and this handler never runs for it.
|
|
//
|
|
// It is ALSO the guard for this handler's one re-entrant
|
|
// entry: it runs from INSIDE QQuickItemView::setModel()
|
|
// (the same window that makes a `.model` readback segfault
|
|
// — see views/LibraryView.qml), where contentY has been
|
|
// reset but the layout has not been redone.
|
|
//
|
|
// MEASURED, so nobody re-hunts it: that entry does NOT in
|
|
// fact misfire under qml6 6.11.1. contentHeight inside the
|
|
// swap is the PREVIOUS model's value, not 0 — checked over
|
|
// empty->long, short->long, long->short, long->empty,
|
|
// same-length republishes, both with forceLayout and on
|
|
// the real async polish path. Every one of them entered
|
|
// the handler with (contentY 0, contentHeight stale-LARGE)
|
|
// and evaluated FALSE. So this line changes no observed
|
|
// behaviour today; it is kept because it is true and free,
|
|
// and because the only shape that would misfire is the one
|
|
// it covers.
|
|
if (contentHeight > height
|
|
&& contentY + height >= contentHeight - 600
|
|
&& QbzLocal.localTracksHasMore
|
|
&& !QbzLocal.localTracksLoadingMore
|
|
&& !QbzLocal.localTracksLoading) {
|
|
QbzLocal.tracksLoadMore()
|
|
}
|
|
}
|
|
onModelChanged: root.report()
|
|
onHeightChanged: root.report()
|
|
onVisibleChanged: {
|
|
if (visible) root.reportSoon()
|
|
else if (root.view) root.view.releaseWindow("tracks")
|
|
}
|
|
|
|
delegate: Loader {
|
|
required property var modelData
|
|
width: list.width
|
|
height: modelData.t === 0 ? root.headerH : root.rowH
|
|
sourceComponent: modelData.t === 0 ? headerComp : rowComp
|
|
|
|
Component {
|
|
id: headerComp
|
|
Item {
|
|
Text {
|
|
x: 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - 4
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
Component {
|
|
id: rowComp
|
|
LocalTrackRow {
|
|
width: list.width
|
|
view: root.view
|
|
item: modelData.row
|
|
// The row artwork column was rendering a
|
|
// permanently blank 36px cell: local track rows
|
|
// ship an EMPTY artPath (local_rows.rs:284) and
|
|
// nothing ever fed LocalTrackRow.artSource. It is
|
|
// the same id-keyed artMap every other local
|
|
// surface reads.
|
|
artSource: root.view.artMap[modelData.row.artKey] || ""
|
|
number: modelData.n
|
|
// Album column hides when the list is already
|
|
// grouped by album (Slint :1486).
|
|
showAlbum: root.view.tracksGroup !== "album"
|
|
showArtwork: root.view.trackArtwork
|
|
selectMode: root.view.tracksMultiSelect
|
|
checked: root.view.tracksSelected[modelData.row.id] === true
|
|
onPlayRequested: root.playRow(modelData.row.id)
|
|
onEnqueueRequested: function (m) {
|
|
QbzLocal.enqueue("track", modelData.row.id, m)
|
|
}
|
|
onToggleSelect: root.view.toggleTrackSelected(modelData.row.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infinite scroll: the NEXT page in the shape it will arrive
|
|
// in (3 rows), appended below the last real row. Each
|
|
// placeholder is replaced by a real row as the page lands.
|
|
footer: Item {
|
|
width: list.width
|
|
height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0
|
|
QbzSkeleton {
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
visible: QbzLocal.localTracksLoadingMore
|
|
rowH: root.rowH
|
|
rowGap: 0
|
|
rowArt: root.view.trackArtwork
|
|
rowArtSize: 36
|
|
phase: root.view.skelPhase
|
|
}
|
|
}
|
|
}
|
|
QbzAlphaStrip {
|
|
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
jumps: root.alphaJumps
|
|
onJump: function (ordinal, index) {
|
|
list.positionViewAtIndex(index, ListView.Beginning)
|
|
}
|
|
}
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
// The bar belongs on the VIEW edge, not on the content edge.
|
|
// Its parent sits inside the tab's 32px inset, so a plain 4px
|
|
// margin put the bar 36px from the window — a visibly wide
|
|
// empty channel with nothing in it (owner, 2026-08-16). The
|
|
// negative margin cancels the inset and leaves the same 4px
|
|
// gap the other lists have; the ROWS keep their inset.
|
|
//
|
|
// The "name" arm is untouched on purpose: there the alpha
|
|
// strip owns the right edge at the inset, and 22 is what
|
|
// clears it. Pushing the bar out there would jump it past the
|
|
// strip.
|
|
anchors.rightMargin: root.view.tracksGroup === "name"
|
|
? 22 : 4 - root.sideInset
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: list
|
|
visible: list.contentHeight > list.height
|
|
}
|
|
}
|
|
}
|
|
|
|
// TRIGGERS: `contentY` + the model swap alone never fired for a list that
|
|
// was still hidden behind `localTracksLoading` when its first page landed
|
|
// — the covers only started resolving on the first scroll. Mount, the
|
|
// rebuild, becoming visible and a resize report too.
|
|
function report() {
|
|
if (!view || !list) return
|
|
// The row artwork column is gated on the appearance setting (default
|
|
// OFF, for the 16K-track freeze). With it off nothing on this list
|
|
// renders a cover, so nothing is worth decoding.
|
|
if (!list.visible || !view.trackArtwork || root.entries.length === 0) {
|
|
view.releaseWindow("tracks")
|
|
return
|
|
}
|
|
var first = list.indexAt(4, list.contentY + 1)
|
|
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
|
|
if (first < 0) first = 0
|
|
if (last < 0) last = Math.min(root.entries.length - 1, first + 12)
|
|
// Entry band -> row band (group headers carry no cover).
|
|
var lo = -1
|
|
var hi = -1
|
|
for (var i = first; i <= last; i++) {
|
|
var n = root.rowIndex[i]
|
|
if (n === undefined || n < 0) continue
|
|
if (lo < 0) lo = n
|
|
hi = n
|
|
}
|
|
if (lo < 0) { lo = 0; hi = Math.min(view.tracksVisible.length - 1, 12) }
|
|
view.queueWindowReport(view.tracksVisible, Math.max(0, lo - 4), hi + 4, "tracks")
|
|
}
|
|
|
|
/// Report now, then again once the ListView has laid out (`indexAt`
|
|
/// answers -1 until then — the state a just-shown list is in).
|
|
Timer {
|
|
id: reportSettle
|
|
interval: 50
|
|
repeat: false
|
|
onTriggered: root.report()
|
|
}
|
|
function reportSoon() {
|
|
report()
|
|
reportSettle.restart()
|
|
}
|
|
}
|