From deb5cbc872970de868254d1991f620d9640958a4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 29 Aug 2026 09:14:57 +0200 Subject: [PATCH 1/2] fix: coalesce repeated playback seeks --- .../player/PlaybackWindowCoordinator.kt | 32 ++++++++++++- .../player/PlaybackWindowCoordinatorTest.kt | 48 ++++++++++++------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/player/src/main/java/dev/typetype/player/PlaybackWindowCoordinator.kt b/player/src/main/java/dev/typetype/player/PlaybackWindowCoordinator.kt index b0044671..07d64409 100644 --- a/player/src/main/java/dev/typetype/player/PlaybackWindowCoordinator.kt +++ b/player/src/main/java/dev/typetype/player/PlaybackWindowCoordinator.kt @@ -6,6 +6,7 @@ import java.io.IOException internal class PlaybackWindowCoordinator( private val loader: PlaybackWindowLoader, private val tasks: PlaybackTaskDispatcher, + private val seekSettleDelayMs: Long = SEEK_SETTLE_DELAY_MS, ) { constructor( loader: PlaybackWindowLoader, @@ -13,6 +14,7 @@ internal class PlaybackWindowCoordinator( ) : this(loader, HandlerPlaybackTaskDispatcher(playbackHandler)) private var activeLoad: PlaybackLoadCancellation? = null + private var deferredSeek: Runnable? = null private val listeners = linkedSetOf() private var released = false private var failure: IOException? = null @@ -50,12 +52,24 @@ internal class PlaybackWindowCoordinator( fun seek(positionUs: Long) { if (released) return + val shouldSettle = isSeeking || deferredSeek != null operationId++ activeLoad?.cancel() activeLoad = null + deferredSeek?.let(tasks::remove) + deferredSeek = null isSeeking = true failure = null - startSeek(positionUs) + if (shouldSettle) { + val task = Runnable { + deferredSeek = null + if (!released) startSeek(positionUs) + } + deferredSeek = task + tasks.postDelayed(task, seekSettleDelayMs) + } else { + startSeek(positionUs) + } } fun maybeThrowError() { @@ -67,6 +81,8 @@ internal class PlaybackWindowCoordinator( operationId++ activeLoad?.cancel() activeLoad = null + deferredSeek?.let(tasks::remove) + deferredSeek = null isSeeking = false listeners.clear() loader.release() @@ -114,6 +130,10 @@ internal class PlaybackWindowCoordinator( internal interface PlaybackTaskDispatcher { fun post(task: Runnable) + + fun postDelayed(task: Runnable, delayMs: Long) + + fun remove(task: Runnable) } private class HandlerPlaybackTaskDispatcher( @@ -122,4 +142,14 @@ private class HandlerPlaybackTaskDispatcher( override fun post(task: Runnable) { handler.post(task) } + + override fun postDelayed(task: Runnable, delayMs: Long) { + handler.postDelayed(task, delayMs) + } + + override fun remove(task: Runnable) { + handler.removeCallbacks(task) + } } + +private const val SEEK_SETTLE_DELAY_MS = 80L diff --git a/player/src/test/java/dev/typetype/player/PlaybackWindowCoordinatorTest.kt b/player/src/test/java/dev/typetype/player/PlaybackWindowCoordinatorTest.kt index e8fd7fd8..83de566e 100644 --- a/player/src/test/java/dev/typetype/player/PlaybackWindowCoordinatorTest.kt +++ b/player/src/test/java/dev/typetype/player/PlaybackWindowCoordinatorTest.kt @@ -18,7 +18,7 @@ class PlaybackWindowCoordinatorTest { } @Test - fun `repeated seeks start immediately and only the latest result wins`() { + fun `repeated seeks coalesce and only the latest position starts`() { val loader = RecordingLoader() val dispatcher = RecordingDispatcher() val coordinator = PlaybackWindowCoordinator(loader, dispatcher) @@ -27,15 +27,17 @@ class PlaybackWindowCoordinatorTest { coordinator.seek(20_000L) coordinator.seek(30_000L) - assertEquals(listOf(10_000L, 20_000L, 30_000L), loader.seekPositions) - assertTrue(loader.cancellations.take(2).all { it.cancelled }) + assertEquals(listOf(10_000L), loader.seekPositions) + assertTrue(loader.cancellations.single().cancelled) - loader.callbacks[1](Result.success(playbackWindow(generation = 2L))) loader.callbacks[0](Result.success(playbackWindow(generation = 1L))) - loader.callbacks[2](Result.success(playbackWindow(generation = 3L))) + dispatcher.runPosted() + dispatcher.runDelayed() + loader.callbacks[1](Result.success(playbackWindow(generation = 2L))) dispatcher.runPosted() - assertEquals(3L, coordinator.window?.generation) + assertEquals(listOf(10_000L, 30_000L), loader.seekPositions) + assertEquals(2L, coordinator.window?.generation) } @Test @@ -48,6 +50,7 @@ class PlaybackWindowCoordinatorTest { coordinator.seek(20_000L) loader.callbacks.first()(Result.failure(IOException("cancelled"))) dispatcher.runPosted() + dispatcher.runDelayed() coordinator.maybeThrowError() loader.callbacks.last()(Result.success(playbackWindow(generation = 2L))) @@ -67,7 +70,7 @@ class PlaybackWindowCoordinatorTest { coordinator.seek(20_000L) coordinator.load(15_000L) - assertEquals(listOf(10_000L, 20_000L), loader.seekPositions) + assertEquals(listOf(10_000L), loader.seekPositions) assertEquals(0, loader.loadCount) } @@ -80,7 +83,8 @@ class PlaybackWindowCoordinatorTest { coordinator.seek(10_000L) coordinator.seek(20_000L) coordinator.release() - assertEquals(listOf(10_000L, 20_000L), loader.seekPositions) + dispatcher.runDelayed() + assertEquals(listOf(10_000L), loader.seekPositions) assertTrue(loader.cancellations.all { it.cancelled }) assertTrue(loader.released) } @@ -104,22 +108,21 @@ class PlaybackWindowCoordinatorTest { } @Test - fun `rapid seek results cannot overtake the final request`() { + fun `rapid seeks produce one immediate and one final request`() { val loader = RecordingLoader() val dispatcher = RecordingDispatcher() val coordinator = PlaybackWindowCoordinator(loader, dispatcher) repeat(5_000) { index -> coordinator.seek(index * 1_000L) } - (0 until 5_000).shuffled(Random(15)).forEach { index -> - loader.callbacks[index]( - Result.success(playbackWindow(generation = index.toLong())), - ) + dispatcher.runDelayed() + listOf(0, 1).shuffled(Random(15)).forEach { index -> + loader.callbacks[index](Result.success(playbackWindow(generation = index.toLong()))) } dispatcher.runPosted() - assertEquals(5_000, loader.seekPositions.size) - assertTrue(loader.cancellations.dropLast(1).all { it.cancelled }) - assertEquals(4_999L, coordinator.window?.generation) + assertEquals(listOf(0L, 4_999_000L), loader.seekPositions) + assertTrue(loader.cancellations.first().cancelled) + assertEquals(1L, coordinator.window?.generation) } } @@ -193,12 +196,25 @@ private class RecordingCancellation : PlaybackLoadCancellation { private class RecordingDispatcher : PlaybackTaskDispatcher { private val posted = mutableListOf() + private val delayed = mutableListOf() override fun post(task: Runnable) { posted += task } + override fun postDelayed(task: Runnable, delayMs: Long) { + delayed += task + } + + override fun remove(task: Runnable) { + delayed -= task + } + fun runPosted() { posted.toList().also(posted::removeAll).forEach(Runnable::run) } + + fun runDelayed() { + delayed.toList().also(delayed::removeAll).forEach(Runnable::run) + } } From 0fa615c3efd57f6d1f854ddd1109be8ee7ca7457 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 29 Aug 2026 09:16:44 +0200 Subject: [PATCH 2/2] chore: bump beta version to 1.7.0-beta.5 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd6a52af..710370c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10703 - versionName = "1.7.0-beta.4" + versionCode = 10704 + versionName = "1.7.0-beta.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") }