Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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,
playbackHandler: Handler,
) : this(loader, HandlerPlaybackTaskDispatcher(playbackHandler))

private var activeLoad: PlaybackLoadCancellation? = null
private var deferredSeek: Runnable? = null
private val listeners = linkedSetOf<Listener>()
private var released = false
private var failure: IOException? = null
Expand Down Expand Up @@ -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() {
Expand All @@ -67,6 +81,8 @@ internal class PlaybackWindowCoordinator(
operationId++
activeLoad?.cancel()
activeLoad = null
deferredSeek?.let(tasks::remove)
deferredSeek = null
isSeeking = false
listeners.clear()
loader.release()
Expand Down Expand Up @@ -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(
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)))
Expand All @@ -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)
}

Expand All @@ -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)
}
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -193,12 +196,25 @@ private class RecordingCancellation : PlaybackLoadCancellation {

private class RecordingDispatcher : PlaybackTaskDispatcher {
private val posted = mutableListOf<Runnable>()
private val delayed = mutableListOf<Runnable>()

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)
}
}