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 !== ""
|
||||
|
||||
Reference in new issue
Block a user