float the up-next block behind the current track

Re-anchor the block whenever playback lands on a different context track
(skip, previous, jump, shuffle toggle, restore), so "Up next" always
plays next instead of stranding the block behind a track the listener
has moved past. reanchorFor reports whether the anchor actually moved,
since the current track's combined position can be unchanged even when
the block shifts.
This commit is contained in:
austinried
2026-10-04 00:30:00 +00:00
parent d5a9a11cef
commit 9832f7c217
4 changed files with 131 additions and 15 deletions
@@ -110,6 +110,11 @@ data class QueueWindowItem(
val upNext: Boolean = false,
)
data class Reanchored(
val position: Long,
val changed: Boolean,
)
class QueueRepository(
private val db: SubtracksDatabase,
) {
@@ -336,6 +341,18 @@ class QueueRepository(
return if (currentPosition >= snapshot.anchorPlay + 1 + before) currentPosition + 1 else currentPosition
}
suspend fun reanchorFor(
snapshot: QueueSnapshot,
target: Long,
): Reanchored {
if (snapshot.upNextSize == 0L || snapshot.isUpNext(target)) return Reanchored(target, false)
val play = snapshot.contextPlay(target)
val flat = snapshot.flatContext(play) ?: return Reanchored(target, false)
if (flat == snapshot.upNextAnchor) return Reanchored(target, false)
cursorMutex.withLock { dao.setCursor(cursorRow().copy(upNextAnchor = flat)) }
return Reanchored(play, true)
}
suspend fun removeUpNextAt(
snapshot: QueueSnapshot,
index: Long,
@@ -273,13 +273,20 @@ class PlaybackController(
fun playAt(position: Long) {
scope.launch {
startLock.withLock {
val snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock
val player = player ?: return@withLock
val target = position.coerceIn(0, snapshot.size - 1)
var snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, position)
if (anchor.changed) {
snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot
}
val target = anchor.position.coerceIn(0, snapshot.size - 1)
windowJob?.cancel()
queueRepository.setCursor(target)
if (target in windowStart..windowEnd) {
if (anchor.changed) {
loadWindow(target, autoplay = true)
} else if (target in windowStart..windowEnd) {
player.ensurePrepared()
lastPosition = target
player.seekToIndex((target - windowStart).toInt())
@@ -635,10 +642,13 @@ class PlaybackController(
enabling -> reordered.combined(0)
else -> reordered.combined(flat)
}
queueRepository.setCursor(position)
rebuildWindow(queueRepository.window(reordered, position, QUEUE_WINDOW_RADIUS), position)
val anchor = queueRepository.reanchorFor(reordered, position)
val finalSnapshot = if (anchor.changed) readSnapshot() else reordered
this@PlaybackController.snapshot = finalSnapshot
queueRepository.setCursor(anchor.position)
rebuildWindow(queueRepository.window(finalSnapshot, anchor.position, QUEUE_WINDOW_RADIUS), anchor.position)
endedHandled = false
refresh(position)
refresh(anchor.position)
}
}
}
@@ -741,9 +751,15 @@ class PlaybackController(
return@withLock
}
this.queueSourceId = queueSourceId
val position = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
val cursor = queueRepository.cursor().coerceIn(0, snapshot.size - 1)
val anchor = queueRepository.reanchorFor(snapshot, cursor)
if (anchor.changed) {
snapshot = queueRepository.snapshot()
this.snapshot = snapshot
queueRepository.setCursor(anchor.position)
}
lastSavedPositionMs = queueRepository.cursorPositionMs().coerceAtLeast(0)
loadWindow(position, autoplay = false, startPositionMs = lastSavedPositionMs)
loadWindow(anchor.position, autoplay = false, startPositionMs = lastSavedPositionMs)
}
private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -809,17 +825,24 @@ class PlaybackController(
private fun jumpTo(target: Long) {
scope.launch {
startLock.withLock {
val snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot
val player = player ?: return@withLock
var snapshot = readSnapshot()
if (snapshot.size == 0L) return@withLock
val anchor = queueRepository.reanchorFor(snapshot, target)
if (anchor.changed) snapshot = readSnapshot()
this@PlaybackController.snapshot = snapshot
val current = currentPosition() ?: return@withLock
val dest = target.coerceIn(0, snapshot.size - 1)
val wasEnded = player?.isEnded == true
val dest = anchor.position.coerceIn(0, snapshot.size - 1)
val wasEnded = player.isEnded
if (dest == current && !wasEnded) return@withLock
windowJob?.cancel()
queueRepository.setCursor(dest)
if (anchor.changed) {
loadWindow(dest, autoplay = player.playWhenReady || wasEnded)
endedHandled = false
return@withLock
}
shiftWindowLocked(dest)
val player = player ?: return@withLock
player.ensurePrepared()
player.seekToIndex((dest - windowStart).toInt())
if (wasEnded) player.play()
@@ -353,6 +353,82 @@ class PlaybackControllerTest {
assertTrue(runBlocking { queues.snapshot().upNext }.isEmpty())
}
@Test
fun skippingPastTheQueueBringsItBackBehindTheCurrentTrack() {
seedAlbum(4, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.playAt(4)
await {
controller.state.value.item
?.id == "s4"
}
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun goingBackInTheContextBringsTheQueueBackWithIt() {
seedAlbum(4, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.previous()
await {
controller.state.value.item
?.id == "s2"
}
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun togglingShuffleKeepsTheQueueBehindTheCurrentTrack() {
seedAlbum(4, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.toggleShuffle()
await { controller.state.value.shuffle }
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
}
@Test
fun theDurationUsesTheNextTracksMetadataWhileItLoads() {
seedAlbum(3, sourceId = 1)
+1 -1
View File
@@ -84,7 +84,7 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` (a flat index into the context) pins the block between a context track and its successor, and `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation.
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation.
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.