feat(qt): queue rows slide into place instead of the list being rebuilt

The Listen list and the sidebar queue now reach their views through
QbzKeyedModel. In the Listen list a removed track fades out while the
rows below slide up, "play next" slides the queue open, a history row
trimmed above the window leaves the rows on screen where they are, and a
committed drag lands the row where it was dropped while its neighbours
slide (the chevrons keep the animated swap). Identity is the track
(source + id, repeats by occurrence); phase, section and phaseIndex are
data, so a track ending updates rows in place and moves nothing. The
follow-playing position and the cover window now run after the model
holds the new rows. A search is a different list and is replaced at once.

The sidebar's Up Next and Recently played are Repeaters in Columns, which
have no remove transition: a leaving row disappears and the rows below
slide up, a joining row fades in, and a page or search change is laid out
synchronously without animation. Drop slots count the Repeater's own
items.

tst_keyed_model gains the Repeater-in-a-Column case (silent first page and
page change, sliding rows that keep their items, fading insert).

cc
This commit is contained in:
vicrodh committed 2026-09-14 05:56:58 -06:00
1 parent ce625e7e28
commit 408a7af0c9
3 files changed
+168 -17

No files matched your search

+42 -5
View File
@@ -38,6 +38,24 @@ Rectangle {
readonly property var currentRow: doc.current || null
readonly property var upcoming: doc.upcoming || []
readonly property var historyRows: doc.history || []
// Keyed models for both lists (2026-09-14): a track leaving Up Next (it
// started playing, it was removed) disappears and the rows below slide up;
// a track joining slides the list open; nothing is rebuilt. The rows are
// Repeaters in a Column, so the motion is the Column's own add / move
// transitions — a positioner has no remove transition. Identity is the
// track (source + id, repeats by occurrence); another page or search
// replaces the list at once.
QbzKeyedModel {
id: upcomingModel
rows: root.upcoming
keyOf: function (row) { return (row.source || "") + ":" + row.id }
scope: JSON.stringify([root.doc.searchQuery || "", root.doc.page || 0])
}
QbzKeyedModel {
id: historyModel
rows: root.historyRows
keyOf: function (row) { return (row.source || "") + ":" + row.id }
}
readonly property var settingsDoc: {
try { return JSON.parse(QbzBridge.settingsJson) }
catch (e) { return ({}) }
@@ -138,7 +156,8 @@ Rectangle {
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
function slotFromPointer(winY) {
var n = root.upcoming.length
// The Repeater's own count: its items are what the pointer is over.
var n = upcomingRepeater.count
for (var i = 0; i < n; i++) {
var d = upcomingRepeater.itemAt(i)
if (d && winY < d.rowCenterY())
@@ -223,7 +242,13 @@ Rectangle {
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: dispatchCovers()
Component.onCompleted: {
// The lists' Columns, laid out synchronously around a reset so a new
// page or search appears at once (QbzKeyedModel).
upcomingModel.views = [upNextSection]
historyModel.views = [historyBody]
dispatchCovers()
}
onDocChanged: dispatchCovers()
onQueueTrackArtworkChanged: dispatchCovers()
// Collect the covers of the document CURRENTLY on the property and ask the
@@ -1026,13 +1051,18 @@ Rectangle {
font.letterSpacing: 0.5
}
add: QbzRowAdd { enabled: upcomingModel.animate }
move: QbzRowDisplaced { enabled: upcomingModel.animate }
Repeater {
id: upcomingRepeater
model: root.upcoming
model: upcomingModel
delegate: Column {
id: upDelegate
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: upcomingModel.row(rowKey, rowRev)
width: parent ? parent.width : 0
// Row center in window space — slotFromPointer()
// reads it off the Repeater's items.
@@ -1167,9 +1197,16 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
add: QbzRowAdd { enabled: historyModel.animate }
move: QbzRowDisplaced { enabled: historyModel.animate }
Repeater {
model: root.historyRows
model: historyModel
delegate: QueueRow {
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: historyModel.row(rowKey, rowRev)
width: parent.width
row: modelData
rowIndex: index
+58 -10
View File
@@ -40,6 +40,39 @@ Rectangle {
&& root.doc.currentIndex < root.rows.length
readonly property int actionRailWidth: kioskHost ? 72 : 52
// The list's model (2026-09-14): queue changes reach the ListView as
// removes / inserts / moves instead of a new array, so removing a track
// fades that row out while the rest slide up, "play next" slides the
// queue open, a history row trimmed above the window leaves the rows on
// screen where they are, and nothing is rebuilt from the top.
//
// Identity is the track (source + id); a repeat of the same track is its
// next occurrence. Phase, section and phaseIndex are data, not identity:
// when a track ends, the chronological order does not change, only which
// row is current, so those rows update in place and nothing moves. A
// search is a different list (the scope), replaced at once.
QbzKeyedModel {
id: rowsModel
rows: root.rows
keyOf: function (row) { return (row.source || "") + ":" + row.id }
scope: root.doc.searchQuery || ""
onReconciled: {
// The follow and the covers read view indices: run them once the
// view holds the new rows.
root.scheduleVisibleCovers()
if (root.hasPlayingRow && root.followPlaying)
root.followPlayingRow()
}
}
// A committed drop lands the dragged row where the pointer left it; only
// its neighbours slide. The chevrons keep the move animation (a swap).
property bool dropSettling: false
Timer {
id: dropSettle
interval: 600
onTriggered: root.dropSettling = false
}
// Timing for the chronological projection currently on screen. History is
// fully elapsed, the current row contributes the player's live position,
// and upcoming rows are fully remaining. A filtered listen list therefore
@@ -128,6 +161,7 @@ Rectangle {
}
Component.onCompleted: {
rowsModel.views = [queueList]
QbzQueue.queueExtendedOpened()
root.scheduleVisibleCovers()
}
@@ -139,8 +173,6 @@ Rectangle {
root.scheduleVisibleCovers()
if (!root.hasPlayingRow)
root.cancelPlayingFollow()
else if (root.followPlaying)
Qt.callLater(function () { root.followPlayingRow() })
}
onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow()
@@ -170,20 +202,22 @@ Rectangle {
}
function requestVisibleCovers() {
if (root.rows.length === 0 || queueList.height <= 0)
// View indices index the rows the model holds, not a newer `rows`.
var shown = rowsModel.published
if (shown.length === 0 || queueList.height <= 0)
return
var first = queueList.indexAt(4, queueList.contentY + 2)
if (first < 0)
first = 0
var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2)
if (last < first)
last = Math.min(root.rows.length - 1, first + 16)
last = Math.min(shown.length - 1, first + 16)
first = Math.max(0, first - 3)
last = Math.min(root.rows.length - 1, last + 3)
last = Math.min(shown.length - 1, last + 3)
var urls = []
var wanted = ({})
for (var i = first; i <= last; i++) {
var url = root.artUrl(root.rows[i])
var url = root.artUrl(shown[i])
if (url !== "" && !root.coverMap[url] && wanted[url] !== true) {
wanted[url] = true
urls.push(url)
@@ -493,9 +527,12 @@ Rectangle {
root.dropSlot = -1
root.dropHot = false
QbzShell.dragInlineVisual = false
if (shouldCommit)
if (shouldCommit) {
root.dropSettling = true
dropSettle.restart()
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
targetPhase, slot)
}
}
function dragProxyY() {
@@ -806,7 +843,11 @@ Rectangle {
spacing: 3
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
model: root.rows
model: rowsModel
add: QbzRowAdd { enabled: rowsModel.animate }
remove: QbzRowRemove { enabled: rowsModel.animate }
move: QbzRowDisplaced { enabled: rowsModel.animate && !root.dropSettling }
displaced: QbzRowDisplaced { enabled: rowsModel.animate }
onContentYChanged: root.scheduleVisibleCovers()
onMovementStarted: {
if (!root.programmaticListPosition)
@@ -815,8 +856,10 @@ Rectangle {
delegate: Item {
id: rowHost
required property var modelData
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: rowsModel.row(rowKey, rowRev)
readonly property string heading: root.sectionText(index, modelData)
readonly property var displayItem: Object.assign({}, modelData, {
@@ -832,7 +875,12 @@ Rectangle {
trackRow.recycleActive = false
trackRow.releaseForReuse()
}
ListView.onReused: trackRow.recycleActive = true
ListView.onReused: {
// A transition cut short can pool the row half faded.
rowHost.opacity = 1
rowHost.scale = 1
trackRow.recycleActive = true
}
Text {
visible: rowHost.heading !== ""
+68 -2
View File
@@ -1,8 +1,9 @@
// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates
// instead of a model swap. Guards the order and identity contract under
// random edits, the minimal-move reorder, the silent reset, a removal that
// keeps the viewport and the delegates, the fade that keeps its content, and
// the pooled delegate that must come back opaque (76 blank rows, 2026-09-14).
// keeps the viewport and the delegates, the fade that keeps its content, the
// pooled delegate that must come back opaque (76 blank rows, 2026-09-14), and
// the Repeater-in-a-Column shape the sidebar queue uses.
import QtQuick
import QtTest
import "../../crates/qbz-qt/qml/controls"
@@ -83,6 +84,39 @@ Item {
// --- no view: reconcile arithmetic -------------------------------------
QbzKeyedModel { id: plain; rows: root.plainRows }
// --- a Repeater in a Column (the sidebar queue shape) -------------------
property var columnRows: []
property int columnAdds: 0
property int columnMoves: 0
QbzKeyedModel { id: columnModel; rows: root.columnRows; views: [column] }
Column {
id: column
x: 0; y: 0
width: 200
add: Transition {
enabled: columnModel.animate
ScriptAction { script: root.columnAdds++ }
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 }
}
move: Transition {
enabled: columnModel.animate
ScriptAction { script: root.columnMoves++ }
NumberAnimation { properties: "y"; duration: 120 }
}
Repeater {
id: columnRepeater
model: columnModel
delegate: Rectangle {
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: columnModel.row(rowKey, rowRev)
width: 200; height: 10
color: "transparent"
}
}
}
TestCase {
name: "KeyedModel"
when: windowShown
@@ -326,6 +360,38 @@ Item {
plain.ignoredKeys = []
}
function test_j_column_repeater_resets_silently_and_slides_on_removal() {
columnModel.scope = "page-1"
root.columnRows = root.make(12, "c")
wait(0)
wait(150)
compare(columnRepeater.count, 12)
compare(root.columnAdds, 0, "the first page is not animated in")
var survivor = columnRepeater.itemAt(4)
// One row leaves: the rows below slide up, nothing is re-created.
root.columnRows = root.make(12, "c").filter(function (r) { return r.id !== "c2" })
columnModel.sync()
wait(30)
verify(root.columnMoves > 0, "the rows below slide")
wait(200)
compare(columnRepeater.itemAt(3), survivor, "the same row item, one slot up")
compare(survivor.y, 30)
// A row joins at the top: it fades in.
var adds = root.columnAdds
root.columnRows = [{ "id": "new", "title": "N" }].concat(root.columnRows)
columnModel.sync()
wait(150)
verify(root.columnAdds > adds, "the new row fades in")
// Another page replaces the list without animation.
adds = root.columnAdds
columnModel.scope = "page-2"
root.columnRows = root.make(8, "d")
wait(0)
wait(150)
compare(columnRepeater.count, 8)
compare(root.columnAdds, adds, "a page change is not animated")
}
function test_i_repeated_entities_are_distinct_rows() {
root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }]
plain.scope = "dups"