mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user