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:
3 files changed
+168
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 !== ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user