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 currentRow: doc.current || null
|
||||||
readonly property var upcoming: doc.upcoming || []
|
readonly property var upcoming: doc.upcoming || []
|
||||||
readonly property var historyRows: doc.history || []
|
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: {
|
readonly property var settingsDoc: {
|
||||||
try { return JSON.parse(QbzBridge.settingsJson) }
|
try { return JSON.parse(QbzBridge.settingsJson) }
|
||||||
catch (e) { return ({}) }
|
catch (e) { return ({}) }
|
||||||
@@ -138,7 +156,8 @@ Rectangle {
|
|||||||
// arithmetic model to keep in sync. Top half of row k -> slot k, bottom
|
// 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.
|
// half -> slot k+1, past the last row -> N (append). <=40 rows per page.
|
||||||
function slotFromPointer(winY) {
|
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++) {
|
for (var i = 0; i < n; i++) {
|
||||||
var d = upcomingRepeater.itemAt(i)
|
var d = upcomingRepeater.itemAt(i)
|
||||||
if (d && winY < d.rowCenterY())
|
if (d && winY < d.rowCenterY())
|
||||||
@@ -223,7 +242,13 @@ Rectangle {
|
|||||||
root.coverMap = Object.assign({}, m)
|
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()
|
onDocChanged: dispatchCovers()
|
||||||
onQueueTrackArtworkChanged: dispatchCovers()
|
onQueueTrackArtworkChanged: dispatchCovers()
|
||||||
// Collect the covers of the document CURRENTLY on the property and ask the
|
// Collect the covers of the document CURRENTLY on the property and ask the
|
||||||
@@ -1026,13 +1051,18 @@ Rectangle {
|
|||||||
font.letterSpacing: 0.5
|
font.letterSpacing: 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add: QbzRowAdd { enabled: upcomingModel.animate }
|
||||||
|
move: QbzRowDisplaced { enabled: upcomingModel.animate }
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
id: upcomingRepeater
|
id: upcomingRepeater
|
||||||
model: root.upcoming
|
model: upcomingModel
|
||||||
delegate: Column {
|
delegate: Column {
|
||||||
id: upDelegate
|
id: upDelegate
|
||||||
required property var modelData
|
required property string rowKey
|
||||||
|
required property int rowRev
|
||||||
required property int index
|
required property int index
|
||||||
|
readonly property var modelData: upcomingModel.row(rowKey, rowRev)
|
||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
// Row center in window space — slotFromPointer()
|
// Row center in window space — slotFromPointer()
|
||||||
// reads it off the Repeater's items.
|
// reads it off the Repeater's items.
|
||||||
@@ -1167,9 +1197,16 @@ Rectangle {
|
|||||||
font.weight: theme.weightSemibold
|
font.weight: theme.weightSemibold
|
||||||
font.letterSpacing: 0.5
|
font.letterSpacing: 0.5
|
||||||
}
|
}
|
||||||
|
add: QbzRowAdd { enabled: historyModel.animate }
|
||||||
|
move: QbzRowDisplaced { enabled: historyModel.animate }
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.historyRows
|
model: historyModel
|
||||||
delegate: QueueRow {
|
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
|
width: parent.width
|
||||||
row: modelData
|
row: modelData
|
||||||
rowIndex: index
|
rowIndex: index
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ Rectangle {
|
|||||||
&& root.doc.currentIndex < root.rows.length
|
&& root.doc.currentIndex < root.rows.length
|
||||||
readonly property int actionRailWidth: kioskHost ? 72 : 52
|
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
|
// Timing for the chronological projection currently on screen. History is
|
||||||
// fully elapsed, the current row contributes the player's live position,
|
// fully elapsed, the current row contributes the player's live position,
|
||||||
// and upcoming rows are fully remaining. A filtered listen list therefore
|
// and upcoming rows are fully remaining. A filtered listen list therefore
|
||||||
@@ -128,6 +161,7 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
|
rowsModel.views = [queueList]
|
||||||
QbzQueue.queueExtendedOpened()
|
QbzQueue.queueExtendedOpened()
|
||||||
root.scheduleVisibleCovers()
|
root.scheduleVisibleCovers()
|
||||||
}
|
}
|
||||||
@@ -139,8 +173,6 @@ Rectangle {
|
|||||||
root.scheduleVisibleCovers()
|
root.scheduleVisibleCovers()
|
||||||
if (!root.hasPlayingRow)
|
if (!root.hasPlayingRow)
|
||||||
root.cancelPlayingFollow()
|
root.cancelPlayingFollow()
|
||||||
else if (root.followPlaying)
|
|
||||||
Qt.callLater(function () { root.followPlayingRow() })
|
|
||||||
}
|
}
|
||||||
onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow()
|
onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow()
|
||||||
|
|
||||||
@@ -170,20 +202,22 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestVisibleCovers() {
|
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
|
return
|
||||||
var first = queueList.indexAt(4, queueList.contentY + 2)
|
var first = queueList.indexAt(4, queueList.contentY + 2)
|
||||||
if (first < 0)
|
if (first < 0)
|
||||||
first = 0
|
first = 0
|
||||||
var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2)
|
var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2)
|
||||||
if (last < first)
|
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)
|
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 urls = []
|
||||||
var wanted = ({})
|
var wanted = ({})
|
||||||
for (var i = first; i <= last; i++) {
|
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) {
|
if (url !== "" && !root.coverMap[url] && wanted[url] !== true) {
|
||||||
wanted[url] = true
|
wanted[url] = true
|
||||||
urls.push(url)
|
urls.push(url)
|
||||||
@@ -493,9 +527,12 @@ Rectangle {
|
|||||||
root.dropSlot = -1
|
root.dropSlot = -1
|
||||||
root.dropHot = false
|
root.dropHot = false
|
||||||
QbzShell.dragInlineVisual = false
|
QbzShell.dragInlineVisual = false
|
||||||
if (shouldCommit)
|
if (shouldCommit) {
|
||||||
|
root.dropSettling = true
|
||||||
|
dropSettle.restart()
|
||||||
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
|
QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId,
|
||||||
targetPhase, slot)
|
targetPhase, slot)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dragProxyY() {
|
function dragProxyY() {
|
||||||
@@ -806,7 +843,11 @@ Rectangle {
|
|||||||
spacing: 3
|
spacing: 3
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
reuseItems: true
|
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()
|
onContentYChanged: root.scheduleVisibleCovers()
|
||||||
onMovementStarted: {
|
onMovementStarted: {
|
||||||
if (!root.programmaticListPosition)
|
if (!root.programmaticListPosition)
|
||||||
@@ -815,8 +856,10 @@ Rectangle {
|
|||||||
|
|
||||||
delegate: Item {
|
delegate: Item {
|
||||||
id: rowHost
|
id: rowHost
|
||||||
required property var modelData
|
required property string rowKey
|
||||||
|
required property int rowRev
|
||||||
required property int index
|
required property int index
|
||||||
|
readonly property var modelData: rowsModel.row(rowKey, rowRev)
|
||||||
|
|
||||||
readonly property string heading: root.sectionText(index, modelData)
|
readonly property string heading: root.sectionText(index, modelData)
|
||||||
readonly property var displayItem: Object.assign({}, modelData, {
|
readonly property var displayItem: Object.assign({}, modelData, {
|
||||||
@@ -832,7 +875,12 @@ Rectangle {
|
|||||||
trackRow.recycleActive = false
|
trackRow.recycleActive = false
|
||||||
trackRow.releaseForReuse()
|
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 {
|
Text {
|
||||||
visible: rowHost.heading !== ""
|
visible: rowHost.heading !== ""
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates
|
// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates
|
||||||
// instead of a model swap. Guards the order and identity contract under
|
// instead of a model swap. Guards the order and identity contract under
|
||||||
// random edits, the minimal-move reorder, the silent reset, a removal that
|
// 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
|
// keeps the viewport and the delegates, the fade that keeps its content, the
|
||||||
// the pooled delegate that must come back opaque (76 blank rows, 2026-09-14).
|
// 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 QtQuick
|
||||||
import QtTest
|
import QtTest
|
||||||
import "../../crates/qbz-qt/qml/controls"
|
import "../../crates/qbz-qt/qml/controls"
|
||||||
@@ -83,6 +84,39 @@ Item {
|
|||||||
// --- no view: reconcile arithmetic -------------------------------------
|
// --- no view: reconcile arithmetic -------------------------------------
|
||||||
QbzKeyedModel { id: plain; rows: root.plainRows }
|
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 {
|
TestCase {
|
||||||
name: "KeyedModel"
|
name: "KeyedModel"
|
||||||
when: windowShown
|
when: windowShown
|
||||||
@@ -326,6 +360,38 @@ Item {
|
|||||||
plain.ignoredKeys = []
|
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() {
|
function test_i_repeated_entities_are_distinct_rows() {
|
||||||
root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }]
|
root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }]
|
||||||
plain.scope = "dups"
|
plain.scope = "dups"
|
||||||
|
|||||||
Reference in new issue
Block a user