playback: reconcile detached items during offline transitions

This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent 4b5d02f17b
commit acc820e263
2 changed files with 88 additions and 0 deletions
@@ -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) {
@@ -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)