diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 258d2b58..67eb8376 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -95,6 +95,20 @@ class Media3PlayerHandle( override fun clear() = controller.clearMediaItems() + override fun itemUris(): List = + (0 until controller.mediaItemCount).map { + controller + .getMediaItemAt(it) + .localConfiguration + ?.uri + ?.toString() + } + + override fun replaceItem( + index: Int, + item: QueueItem, + ) = controller.replaceMediaItem(index, toMediaItem(item)) + override fun seekToIndex(index: Int) { Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}") controller.seekTo(index, 0) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 21325ffd..b93f536c 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -959,7 +959,30 @@ class PlaybackController( fun refreshMediaItems() { scope.launch { - startLock.withLock { reloadWindowLocked() } + startLock.withLock { + val player = player ?: return@withLock + val snapshot = snapshot ?: return@withLock + if (snapshot.size == 0L || player.isEnded) return@withLock + downloads.awaitLoaded() + val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1) + val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS) + val uris = player.itemUris() + if (window.size != player.itemCount || uris.size != window.size) { + reloadWindowLocked() + return@withLock + } + // Only an item whose local file appeared or went away needs rebuilding. Re-setting + // the whole window would re-prepare the player and audibly pause playback. + var replaced = false + window.forEachIndexed { index, entry -> + val local = downloads.localUri(entry.item.song.id) != null + if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) { + player.replaceItem(index, entry.item.toQueueItem()) + replaced = true + } + } + if (replaced) refresh(position) + } } } @@ -1249,5 +1272,6 @@ class PlaybackController( const val WINDOW_SHIFT_DELAY_MS = 400L const val RESTART_THRESHOLD_MS = 3_000L const val SEED_PIN_TRIES = 1_000_000 + const val FILE_SCHEME = "file:" } } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index b397f7b4..47e940d2 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -53,6 +53,15 @@ interface PlayerHandle { fun clear() + /** The resolved URI of each item in the current window, in window order. */ + fun itemUris(): List = emptyList() + + /** Rebuilds one window item in place, without re-preparing the playing one. */ + fun replaceItem( + index: Int, + item: QueueItem, + ) = Unit + fun seekToIndex(index: Int) fun seekTo(positionMs: Long) diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index fcb279e2..89dab613 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -61,6 +61,20 @@ class FakePlayerHandle : PlayerHandle { override val currentItem: QueueItem? get() = items.getOrNull(index) + /** Overrides the URIs the window reports; defaults to a stream URI per item. */ + @Volatile + var uris: List = emptyList() + + override fun itemUris(): List = uris.ifEmpty { items.map { "http://stream/${it.id}" } } + + override fun replaceItem( + index: Int, + item: QueueItem, + ) { + operations += "replaceItem($index, ${item.id})" + if (index in items.indices) items[index] = item + } + override fun setWindow( items: List, startIndex: Int, diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index b71001dd..2e3d7683 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -2300,7 +2300,7 @@ class PlaybackControllerTest { } @Test - fun refreshingMediaItemsRebuildsTheWindowAtTheSamePosition() { + fun refreshingMediaItemsLeavesAnUnaffectedQueueAlone() { seedAlbum(3, sourceId = 1) controller.playAlbum(1, "al1", 0) await { handle.operations.contains("play") } @@ -2308,12 +2308,28 @@ class PlaybackControllerTest { val windows = handle.operations.count { it.startsWith("setWindow") } controller.refreshMediaItems() + Thread.sleep(200) - await { handle.operations.count { it.startsWith("setWindow") } > windows } + assertEquals(windows, handle.operations.count { it.startsWith("setWindow") }) assertEquals("s1", handle.currentItem?.id) assertEquals(42_000L, handle.positionMs) } + @Test + fun refreshingMediaItemsReplacesOnlyAnItemWhoseLocalFileIsGone() { + seedAlbum(3, sourceId = 1) + controller.playAlbum(1, "al1", 0) + await { handle.operations.contains("play") } + val windows = handle.operations.count { it.startsWith("setWindow") } + handle.uris = handle.items.mapIndexed { index, item -> if (index == 0) "file:///gone" else "http://stream/${item.id}" } + + controller.refreshMediaItems() + + await { handle.operations.any { it.startsWith("replaceItem(0,") } } + assertEquals(windows, handle.operations.count { it.startsWith("setWindow") }) + assertEquals(0, handle.operations.count { it.startsWith("replaceItem(1,") }) + } + @Test fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() = runBlocking {