From a9738c6311654a8cdcf5ea2cb7d09457ffe16597 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:31:48 +0000 Subject: [PATCH] playback: preserve detached fallback queue state --- .../subtracks/playback/PlaybackController.kt | 10 ++-- .../playback/PlaybackControllerTest.kt | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 39106ebc..626577dd 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -161,6 +161,7 @@ class PlaybackController( private var windowFiltered = false private var detachedWindow = false private var detachedPosition: Long? = null + private var detachedFallbackPosition: Long? = null init { queueRepository.setResetListener { reset -> @@ -1505,9 +1506,11 @@ class PlaybackController( private fun markDetached( window: List, position: Long?, + fallbackPosition: Long?, ) { detachedWindow = true detachedPosition = position + detachedFallbackPosition = fallbackPosition windowFiltered = offline windowPositions = LongArray(window.size + 1) { if (it == 0) DETACHED_POSITION else window[it - 1].position } windowStart = window.firstOrNull()?.position ?: 0L @@ -1517,6 +1520,7 @@ class PlaybackController( private fun clearDetached() { detachedWindow = false detachedPosition = null + detachedFallbackPosition = null windowFiltered = offline } @@ -1536,7 +1540,7 @@ class PlaybackController( windowEnd = windowPositions.lastOrNull() ?: -1L } - private fun currentQueuePosition(): Long? = currentPosition() ?: detachedPosition + private fun currentQueuePosition(): Long? = currentPosition() ?: detachedPosition ?: detachedFallbackPosition private suspend fun rebuildWindow( window: List, @@ -1586,7 +1590,7 @@ class PlaybackController( if (!detachedWindow || currentPosition() != null) return false queueRepository.setCursor(position) rebuildDetachedSurvivors(snapshot, position) - refresh(position) + refresh(lastPosition) return true } @@ -1608,7 +1612,7 @@ class PlaybackController( updating = false } val next = window.firstOrNull()?.position - markDetached(window, next) + markDetached(window, next, nearest.takeIf { snapshot.size > 0L }) lastPosition = next } diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index e07af03b..cb81f76f 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -4021,6 +4021,59 @@ class PlaybackControllerTest { assertEquals(0L, controller.state.value.position) } + @Test + fun offlineDetachedPlaybackWithoutAForwardSurvivorCanPlayPrevious() { + detachOfflineTrackWithoutAForwardSurvivor() + + controller.previousTrack() + + await { handle.currentItem?.id == "s1" && controller.state.value.position == 0L } + } + + @Test + fun offlineDetachedPlaybackWithoutAForwardSurvivorCanRemoveAnEarlierTrack() { + detachOfflineTrackWithoutAForwardSurvivor() + + runBlocking { controller.removeAt(0) } + + val snapshot = runBlocking { queues.snapshot() } + assertEquals(1L, snapshot.size) + assertEquals("s3", runBlocking { queues.itemAt(snapshot, 0) }?.song?.id) + assertEquals(listOf("s2"), handle.items.map { it.id }) + assertNull(controller.state.value.position) + } + + @Test + fun offlineDetachedPlaybackWithoutAForwardSurvivorReloadsWhenOnline() { + detachOfflineTrackWithoutAForwardSurvivor() + + setOnline() + + await { handle.items.map { it.id } == listOf("s2", "s3") } + controller.next() + await { handle.currentItem?.id == "s3" && controller.state.value.position == 1L } + } + + @Test + fun clearingTheFinalDetachedSurvivorPublishesNoQueuePosition() { + seedAlbum(1, sourceId = 1) + seedSong("x1", "al2") + controller.playAlbum(1, "al1", 0) + await { handle.currentItem?.id == "s1" && controller.state.value.isPlaying } + controller.addToQueue(1, QueueKind.Song, "x1") + await { runBlocking { queues.snapshot().upNextSize } == 1L } + val point = runBlocking { queues.captureResetPoint() } + runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) } + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point) } + await { handle.items.map { it.id } == listOf("s1", "x1") && controller.state.value.position == 0L } + + controller.clearUpNext() + + await { handle.items.map { it.id } == listOf("s1") } + assertEquals(0L, runBlocking { queues.snapshot().size }) + assertNull(controller.state.value.position) + } + @Test fun aResetRemapsAContextTrackThatMovedToUpNext() { seedPlaylist(5)