From 2459d7cb934767c640563e7fca58d34ecd5feb7e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:39:29 +0000 Subject: [PATCH] replace only the queue items a delete actually changed Deleting any download re-set the whole queue window, which makes the player re-prepare the current item and audibly pauses. It now rebuilds only the items whose local file appeared or went away, comparing what the window reports with what the repository can resolve, and falls back to the full reload when the window no longer lines up. --- .../subtracks/playback/Media3PlayerHandle.kt | 14 ++++++++++ .../subtracks/playback/PlaybackController.kt | 26 ++++++++++++++++++- .../com/subtracks/playback/PlayerHandle.kt | 9 +++++++ .../com/subtracks/playback/FakePlayer.kt | 14 ++++++++++ .../playback/PlaybackControllerTest.kt | 20 ++++++++++++-- 5 files changed, 80 insertions(+), 3 deletions(-) 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 {