From fd0eb0a9a084751bc4f297904a64d5ce4953d3d2 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:33:31 +0000 Subject: [PATCH] playback: retain durable reset identity through Media3 --- .../subtracks/playback/Media3PlayerHandle.kt | 3 +++ .../com/subtracks/playback/FakePlayer.kt | 19 ++++++++++++++----- .../com/subtracks/playback/MediaItemTest.kt | 10 +++++++++- .../playback/PlaybackControllerTest.kt | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 0cd8e662..be22c886 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -266,6 +266,7 @@ internal fun MediaItem.resetItem(): QueueResetItem? { upNext = extras.getBoolean(EXTRA_RESET_UP_NEXT), entryOccurrence = extras.getInt(EXTRA_RESET_ENTRY_OCCURRENCE), occurrence = extras.getLong(EXTRA_RESET_OCCURRENCE), + originEntryId = extras.takeIf { it.containsKey(EXTRA_RESET_ORIGIN_ENTRY_ID) }?.getLong(EXTRA_RESET_ORIGIN_ENTRY_ID), ) } @@ -291,6 +292,7 @@ private const val EXTRA_RESET_POSITION = "resetPosition" private const val EXTRA_RESET_UP_NEXT = "resetUpNext" private const val EXTRA_RESET_ENTRY_OCCURRENCE = "resetEntryOccurrence" private const val EXTRA_RESET_OCCURRENCE = "resetOccurrence" +private const val EXTRA_RESET_ORIGIN_ENTRY_ID = "resetOriginEntryId" internal fun MediaItem?.replayGain(): ReplayGain { val extras = this?.mediaMetadata?.extras ?: return ReplayGain() @@ -348,6 +350,7 @@ internal fun queueMediaItem( putBoolean(EXTRA_RESET_UP_NEXT, reset.upNext) putInt(EXTRA_RESET_ENTRY_OCCURRENCE, reset.entryOccurrence) putLong(EXTRA_RESET_OCCURRENCE, reset.occurrence) + reset.originEntryId?.let { putLong(EXTRA_RESET_ORIGIN_ENTRY_ID, it) } } }, ).build() diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index eff21d40..cb7e9f80 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -48,6 +48,15 @@ class FakePlayerHandle : PlayerHandle { @Volatile var beforeRemoveFirst: (() -> Unit)? = null + @Volatile + var roundTripItemsThroughMedia3 = false + + private fun storedItem(item: QueueItem): QueueItem { + if (!roundTripItemsThroughMedia3) return item + val mediaItem = queueMediaItem(item, local = null, stream = "http://stream/${item.id}", transcode = false) + return queueItemFromMediaItem(mediaItem) + } + private fun checkThread() { val expected = expectedThread ?: return check(Thread.currentThread() == expected) { "Expected $expected, was ${Thread.currentThread()}" } @@ -92,7 +101,7 @@ class FakePlayerHandle : PlayerHandle { item: QueueItem, ) { operations += "replaceItem($index, ${item.id})" - if (index in items.indices) items[index] = item + if (index in items.indices) items[index] = storedItem(item) } override fun setWindow( @@ -102,7 +111,7 @@ class FakePlayerHandle : PlayerHandle { ) { operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)" this.items.clear() - this.items += items + this.items += items.map(::storedItem) index = startIndex positionMs = startPositionMs idle = true @@ -115,13 +124,13 @@ class FakePlayerHandle : PlayerHandle { override fun addFirst(item: QueueItem) { operations += "addFirst(${item.id})" - items.add(0, item) + items.add(0, storedItem(item)) index++ } override fun addLast(item: QueueItem) { operations += "addLast(${item.id})" - items += item + items += storedItem(item) } override fun insertAt( @@ -129,7 +138,7 @@ class FakePlayerHandle : PlayerHandle { item: QueueItem, ) { operations += "insertAt($index, ${item.id})" - items.add(index, item) + items.add(index, storedItem(item)) if (index <= this.index) this.index++ } diff --git a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt index ee038f17..4d2d8747 100644 --- a/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/MediaItemTest.kt @@ -106,7 +106,15 @@ class MediaItemTest { @Test fun theItemKeepsItsResetIdentity() { val resetItem = - QueueResetItem(QueueOrigin(QueueKind.Playlist, 1, "p1"), "s1", 2, upNext = false, entryOccurrence = 1, occurrence = 1) + QueueResetItem( + QueueOrigin(QueueKind.Playlist, 1, "p1"), + "s1", + 2, + upNext = false, + entryOccurrence = 1, + occurrence = 1, + originEntryId = 7, + ) val item = queueMediaItem(song.copy(resetItem = resetItem), local = null, stream = "http://server/stream?s=s1", transcode = false) diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index cb81f76f..1c68ad68 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -3611,6 +3611,24 @@ class PlaybackControllerTest { assertEquals(3L, controller.state.value.position) } + @Test + fun aResetKeepsAMovedSecondDuplicateContextEntryByDurableIdentity() { + handle.roundTripItemsThroughMedia3 = true + seedPlaylist(2) + runBlocking { queues.replace(listOf(queues.playlistEntry(1, "p1"), queues.playlistEntry(1, "p1"))) } + controller.connect() + await { controller.state.value.item != null } + controller.playAt(3) + await { handle.currentItem?.id == "s2" && controller.state.value.position == 3L } + runBlocking { controller.move(2, 0) } + val point = runBlocking { queues.captureResetPoint() } + + runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point) } + + assertEquals(1L, controller.state.value.position) + assertEquals("s2", handle.currentItem?.id) + } + @Test fun repeatedResetsKeepTheSecondDuplicateUpNextEntry() { seedAlbum(3, sourceId = 1)