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.
This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent eccf14aa58
commit 2459d7cb93
5 changed files with 80 additions and 3 deletions
@@ -95,6 +95,20 @@ class Media3PlayerHandle(
override fun clear() = controller.clearMediaItems()
override fun itemUris(): List<String?> =
(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)
@@ -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:"
}
}
@@ -53,6 +53,15 @@ interface PlayerHandle {
fun clear()
/** The resolved URI of each item in the current window, in window order. */
fun itemUris(): List<String?> = 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)
@@ -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<String> = emptyList()
override fun itemUris(): List<String?> = 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<QueueItem>,
startIndex: Int,
@@ -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 {