From acc820e2633bc338e3b5bae08ded646021a05638 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:37:54 +0000 Subject: [PATCH] playback: reconcile detached items during offline transitions --- .../subtracks/playback/PlaybackController.kt | 18 +++++ .../playback/PlaybackControllerTest.kt | 70 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 93a62daa..a8022faa 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -1192,6 +1192,7 @@ class PlaybackController( windowFiltered = false lastPosition = null lastEdit = null + clearDetached() player?.run { stop() clear() @@ -1674,7 +1675,17 @@ class PlaybackController( if (snapshot.size == 0L) return@withMutationLock val current = currentQueuePosition() ?: return@withMutationLock val detached = detachedWindow && currentPosition() == null + val detachedItem = if (detached) player.currentItem else null windowJob?.cancel() + if (offline && detachedItem != null && downloads.localUri(detachedItem.id) == null) { + val target = downloadedAtOrAfter(snapshot, current) + if (target == null) { + stopLocked() + } else { + loadWindow(target, autoplay = player.playWhenReady, startPositionMs = 0) + } + return@withMutationLock + } val target = if (offline) { downloadedAtOrAfter(snapshot, current) ?: if (detached) { @@ -1686,6 +1697,13 @@ class PlaybackController( } else { current } + if (detachedItem != null) { + val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true + if (!isLocal) { + val replacement = queueItemAt(snapshot, current)?.takeIf { it.id == detachedItem.id } ?: detachedItem + player.replaceItem(player.currentIndex, replacement) + } + } queueRepository.setCursor(target) if (preserveDetachedWindow(snapshot, target)) return@withMutationLock if (target == current) { diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index b4b3828b..d47be08c 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -1777,6 +1777,25 @@ class PlaybackControllerTest { assertEquals(null, controller.state.value.item) } + @Test + fun stoppingWhileDetachedClearsItsIdentityAndLaterPosition() { + seedPlaylist(3) + controller.playPlaylist(1, "p1", 1) + await { handle.currentItem?.id == "s2" } + val point = runBlocking { queues.captureResetPoint() } + setPlaylist("s1", "s3") + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + await { handle.currentItem?.id == "s2" && handle.items.any { it.id == "s3" } } + + controller.playAlbum(1, "missing", 0) + await { handle.operations.contains("clear") } + controller.cycleRepeat() + handle.emitEvents() + + assertNull(controller.state.value.item) + assertNull(controller.state.value.position) + } + @Test fun restoringAQueueFromAnotherSourceStopsIt() { seedAlbum(3, sourceId = 1) @@ -3394,6 +3413,57 @@ class PlaybackControllerTest { ) } + @Test + fun offlineDetachedCurrentThatIsNotDownloadedMovesToTheSurvivor() { + seedPlaylist(3) + markDownloaded(1, "s3") + controller.playPlaylist(1, "p1", 1) + await { handle.currentItem?.id == "s2" } + val point = runBlocking { queues.captureResetPoint() } + setPlaylist("s1", "s3") + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + await { handle.currentItem?.id == "s2" && handle.items.any { it.id == "s3" } } + + setOffline() + + await { handle.currentItem?.id == "s3" } + assertEquals(1L, controller.state.value.position) + } + + @Test + fun offlineDetachedCurrentWithoutADownloadedSurvivorStops() { + seedPlaylist(3) + controller.playPlaylist(1, "p1", 1) + await { handle.currentItem?.id == "s2" } + val point = runBlocking { queues.captureResetPoint() } + setPlaylist("s1", "s3") + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + await { handle.currentItem?.id == "s2" && handle.items.any { it.id == "s3" } } + + setOffline() + + await { controller.state.value.item == null && handle.itemCount == 0 } + } + + @Test + fun offlineDetachedCurrentThatWasDownloadedReplacesItsStreamUri() { + seedPlaylist(3) + markDownloaded(1, "s2") + markDownloaded(1, "s3") + controller.playPlaylist(1, "p1", 1) + await { handle.currentItem?.id == "s2" } + val point = runBlocking { queues.captureResetPoint() } + setPlaylist("s1", "s3") + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + await { handle.currentItem?.id == "s2" && handle.items.any { it.id == "s3" } } + handle.operations.clear() + + setOffline() + + await { handle.operations.contains("replaceItem(0, s2)") } + assertEquals("s2", handle.currentItem?.id) + } + @Test fun offlineNextCrossesTheLoadedWindowBoundary() { seedAlbum(30, sourceId = 1)