diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index 08fb4ff12..f39c997d9 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -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 diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index f45e674a4..248b900ac 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -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 !== "" diff --git a/scripts/qml-tests/tst_keyed_model.qml b/scripts/qml-tests/tst_keyed_model.qml index c00d13ecc..6a3ffd137 100644 --- a/scripts/qml-tests/tst_keyed_model.qml +++ b/scripts/qml-tests/tst_keyed_model.qml @@ -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"