From 9832f7c217a4ced5606036793f0e68e8a963d17d Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:53:44 +0000 Subject: [PATCH] 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. --- .../subtracks/data/repo/QueueRepository.kt | 17 +++++ .../subtracks/playback/PlaybackController.kt | 51 +++++++++---- .../playback/PlaybackControllerTest.kt | 76 +++++++++++++++++++ docs/architecture.md | 2 +- 4 files changed, 131 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index d76f4938..51070496 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index f77ba681..9aef52a4 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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() diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 110a10ce..583383b4 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 0c903f27..c84e1b5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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.