From 76a0b1dd1322f9073044b37e01edeeff7c5228b0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:44:37 +0000 Subject: [PATCH 1/3] Debounce seeks while scrubbing the SeekBar SeekBarState now extends PlaybackTimeState, so time displays can show the position being scrubbed to. UIController provides its SeekBarState to the default SeekBar and time display overloads. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../android/ui/PlaybackTimeState.kt | 38 ++++-- .../com/theoplayer/android/ui/SeekBarState.kt | 110 +++++++++++++----- .../theoplayer/android/ui/UIControllerCore.kt | 9 ++ .../theoplayer/android/ui/SeekBarStateTest.kt | 59 +++++++++- .../com/theoplayer/android/ui/tv/SeekBar.kt | 4 +- .../theoplayer/android/ui/tv/SeekBarTest.kt | 5 +- .../java/com/theoplayer/android/ui/SeekBar.kt | 2 +- 8 files changed, 182 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b464321..51995037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ## [Unreleased] * 💅 On Android TV, pressing Center or Enter on the `SeekBar` now toggles play or pause, so viewers don't have to move focus to the play button first. Custom UIs can use the new `SeekBarState.togglePlayPause()` for this. ([#148](https://github.com/THEOplayer/android-ui/pull/148)) +* 💅 While scrubbing the `SeekBar`, the player now only seeks once the viewer stops moving for a moment or lets go, instead of on every position change. `SeekBarState` now extends `PlaybackTimeState`, and `CurrentTimeDisplay` shows the position being scrubbed to. ([#TBD](https://github.com/THEOplayer/android-ui/pull/TBD)) ## [1.15.0] (2026-10-23) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt index 8717ad83..58ca57a4 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt @@ -12,12 +12,19 @@ import com.theoplayer.android.api.player.Player /** * State holder for a playback time display, such as [CurrentTimeDisplay] or [DurationDisplay]. * + * A [SeekBarState] is also a [PlaybackTimeState], whose [currentTime] follows the seek bar while + * the user is scrubbing. Pass it to a time display to show the position the user is scrubbing to. + * * Create an instance with [rememberPlaybackTimeState]. */ @ExperimentalPlayerStateApi -class PlaybackTimeState internal constructor(private val player: Player?) { +open class PlaybackTimeState internal constructor(internal val player: Player?) { /** The current playback position of the media, in seconds. */ - var currentTime by mutableStateOf(0.0) + open val currentTime: Double + get() = playerTime + + /** The current playback position reported by the player, in seconds. */ + internal var playerTime by mutableStateOf(0.0) private set /** @@ -28,25 +35,32 @@ class PlaybackTimeState internal constructor(private val player: Player?) { var duration by mutableStateOf(Double.NaN) private set - private var seekable by mutableStateOf(TimeRanges.empty()) + internal var seekable by mutableStateOf(TimeRanges.empty()) + private set + private var rawDuration by mutableStateOf(Double.NaN) /** The [StreamType] of the media. */ val streamType: StreamType get() = streamTypeOf(rawDuration, seekable) - private fun update() { - currentTime = player?.currentTime ?: 0.0 + /** Re-reads the state from the player. */ + internal open fun update() { + updateTime() + } + + private fun updateTime() { + playerTime = player?.currentTime ?: 0.0 rawDuration = player?.duration ?: Double.NaN seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() duration = seekable.lastEnd ?: rawDuration } init { - update() + updateTime() } - internal suspend fun observe() { + internal open suspend fun observe() { val player = player ?: return update() player.listen( @@ -66,10 +80,18 @@ class PlaybackTimeState internal constructor(private val player: Player?) { } } -/** Creates a [PlaybackTimeState] that's remembered across compositions. */ +/** + * Creates a [PlaybackTimeState] that's remembered across compositions. + * + * Inside a `UIController` for the same [player], this returns the controller's [SeekBarState], so + * time displays follow its seek bar while the user is scrubbing. + */ @ExperimentalPlayerStateApi @Composable fun rememberPlaybackTimeState(player: Player?): PlaybackTimeState { + uiControllerSeekBarState(player)?.let { + return it + } val state = remember(player) { PlaybackTimeState(player) } LaunchedEffect(state) { state.observe() } return state diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt index 19ca2ed8..e1638fc1 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -7,31 +7,53 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.theoplayer.android.api.event.ads.AdsEventTypes -import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch /** * State holder for a seek bar, such as [SeekBar]. * * While the user drags the seek bar (see [onValueChange]), the player is temporarily paused, and - * resumed on [onValueChangeFinished] if it was playing. + * resumed on [onValueChangeFinished] if it was playing. To avoid loading media for positions that + * are scrubbed past, the player only seeks once the user stops moving for a moment, or when they + * finish. + * + * This is also a [PlaybackTimeState], whose [currentTime] is the scrub position while scrubbing. * * Create an instance with [rememberSeekBarState]. */ @ExperimentalPlayerStateApi -class SeekBarState internal constructor(private val player: Player?) { +class SeekBarState internal constructor(player: Player?) : PlaybackTimeState(player) { /** The current scrub position of the seek bar, relative to the start of [seekableRange]. */ val value: Float - get() = seekTime ?: (currentTime - seekableRange.start).toFloat() + get() = seekTime ?: (playerTime - seekableRange.start).toFloat() /** The range of [value]. Always starts at zero. */ val valueRange: ClosedFloatingPointRange get() = 0f..(seekableRange.endInclusive - seekableRange.start).toFloat() + /** + * The position the user is scrubbing to, in seconds, or `null` if the user is not scrubbing. + * + * The player may not have seeked to this position yet. + */ + val scrubTime: Double? + get() = seekTime?.let { seekableRange.start + it.toDouble() } + + /** + * The current playback position of the media, in seconds. While the user is scrubbing, this is + * the [scrubTime]. + */ + override val currentTime: Double + get() = scrubTime ?: playerTime + /** Whether the seek bar can be interacted with. */ val enabled: Boolean get() = !playingAd && seekable.isNotEmpty() @@ -40,12 +62,11 @@ class SeekBarState internal constructor(private val player: Player?) { val showThumb: Boolean get() = !playingAd - private var currentTime by mutableStateOf(0.0) - private var seekable by mutableStateOf(TimeRanges.empty()) - private var duration by mutableStateOf(Double.NaN) private var playingAd by mutableStateOf(false) private var seekTime by mutableStateOf(null) private var wasPlayingBeforeSeek by mutableStateOf(false) + private val pendingSeek = MutableStateFlow(null) + private var lastSeekValue: Float? = null // Seekable start can be very large (e.g. a Unix timestamp), which can lead to precision loss // when converted to a float. Instead, make the slider always start at zero. @@ -54,10 +75,8 @@ class SeekBarState internal constructor(private val player: Player?) { seekable.bounds ?: run { 0.0..(if (duration.isFinite()) duration.coerceAtLeast(0.0) else 0.0) } - private fun update() { - currentTime = player?.currentTime ?: 0.0 - seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() - duration = player?.duration ?: Double.NaN + override fun update() { + super.update() playingAd = player?.ads?.isPlaying ?: false } @@ -65,10 +84,10 @@ class SeekBarState internal constructor(private val player: Player?) { update() } - internal suspend fun observe() { + override suspend fun observe() { val player = player ?: return - update() coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { seekWhenIdle() } player.ads?.let { ads -> launch(start = CoroutineStart.UNDISPATCHED) { ads.listen( @@ -80,35 +99,41 @@ class SeekBarState internal constructor(private val player: Player?) { } } } - player.listen( - PlayerEventTypes.TIMEUPDATE, - PlayerEventTypes.PLAY, - PlayerEventTypes.PAUSE, - PlayerEventTypes.ENDED, - PlayerEventTypes.SEEKING, - PlayerEventTypes.SEEKED, - PlayerEventTypes.READYSTATECHANGE, - PlayerEventTypes.DURATIONCHANGE, - PlayerEventTypes.SOURCECHANGE, - PlayerEventTypes.ERROR, - ) { - update() + super.observe() + } + } + + /** Seeks to the pending scrub position once it hasn't changed for [SeekDebounce]. */ + private suspend fun seekWhenIdle() { + pendingSeek.collectLatest { value -> + if (value != null) { + delay(SeekDebounce) + seekToValue(value) } } } + /** Seeks the player to a position within [valueRange]. */ + private fun seekToValue(value: Float) { + lastSeekValue = value + player?.currentTime = seekableRange.start + value.toDouble() + // Re-read the position right away, so the seek bar doesn't jump back to the old position + // when scrubbing finishes before the player fires its seeking event. + update() + } + /** - * Called while the user drags the seek bar. Temporarily pauses a playing player and seeks to - * the given position within [valueRange]. + * Called while the user drags the seek bar. Temporarily pauses a playing player, and seeks to + * the given position within [valueRange] once the user stops dragging for a moment. */ fun onValueChange(value: Float) { seekTime = value + pendingSeek.value = value player?.let { if (!it.isPaused) { wasPlayingBeforeSeek = true it.pause() } - it.currentTime = seekableRange.start + value.toDouble() } } @@ -134,11 +159,14 @@ class SeekBarState internal constructor(private val player: Player?) { } /** - * Called when the user stops dragging the seek bar. Resumes playback if the player was playing - * before the drag started. + * Called when the user stops dragging the seek bar. Seeks to the final position, and resumes + * playback if the player was playing before the drag started. */ fun onValueChangeFinished() { + seekTime?.let { if (it != lastSeekValue) seekToValue(it) } seekTime = null + pendingSeek.value = null + lastSeekValue = null if (wasPlayingBeforeSeek) { player?.play() wasPlayingBeforeSeek = false @@ -146,6 +174,9 @@ class SeekBarState internal constructor(private val player: Player?) { } } +/** How long the scrub position must stay unchanged before the player seeks to it. */ +private val SeekDebounce = 250.milliseconds + /** Creates a [SeekBarState] that's remembered across compositions. */ @ExperimentalPlayerStateApi @Composable @@ -154,3 +185,20 @@ fun rememberSeekBarState(player: Player?): SeekBarState { LaunchedEffect(state) { state.observe() } return state } + +/** + * Returns the [SeekBarState] of the enclosing UI controller if it belongs to [player], or remembers + * a new one otherwise. + */ +@InternalUiApi +@ExperimentalPlayerStateApi +@Composable +fun rememberUIControllerSeekBarState(player: Player?): SeekBarState = + uiControllerSeekBarState(player) ?: rememberSeekBarState(player) + +/** Returns the [SeekBarState] of the enclosing UI controller if it belongs to [player]. */ +@OptIn(InternalUiApi::class) +@ExperimentalPlayerStateApi +@Composable +internal fun uiControllerSeekBarState(player: Player?): SeekBarState? = + LocalUIControllerSeekBarState.current?.takeIf { it.player == player } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt index ee4faad1..d1f77c85 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt @@ -69,6 +69,7 @@ fun UIControllerCore( ) { val nativePlayer = theoplayerView?.player val adState = rememberAdState(nativePlayer) + val seekBarState = rememberSeekBarState(nativePlayer) val playerAnimations = LocalPlayerAnimations.current val backgroundVisible = uiState.value.isVisible(controls.visible.value) val animatedBackground by @@ -113,6 +114,7 @@ fun UIControllerCore( scope = scope, uiState = uiState.value, errorState = errorState, + seekBarState = seekBarState, controls = controls, adPlaying = adState.playingAd, background = animatedBackground, @@ -137,6 +139,7 @@ private fun UIControllerContent( scope: UIControllerScopeImpl, uiState: UIState, errorState: ErrorState, + seekBarState: SeekBarState, controls: ControlsVisibility, adPlaying: Boolean, background: Color, @@ -166,6 +169,7 @@ private fun UIControllerContent( LocalPlayer provides player, LocalTHEOplayerView provides theoplayerView, LocalUIControllerErrorState provides errorState, + LocalUIControllerSeekBarState provides seekBarState, ) { AnimatedContent( label = "ContentAnimation", @@ -329,3 +333,8 @@ private fun InnerMenus( /** The current error state provided by a UI controller. */ @InternalUiApi val LocalUIControllerErrorState = staticCompositionLocalOf { null } + +/** The seek bar state provided by a UI controller, shared by its seek bar and time displays. */ +@InternalUiApi +@ExperimentalPlayerStateApi +val LocalUIControllerSeekBarState = staticCompositionLocalOf { null } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt index 85d03328..533917e2 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt @@ -3,9 +3,12 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.player.PlayerEventTypes import io.mockk.verify import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -70,13 +73,67 @@ class SeekBarStateTest { state.onValueChange(50f) verify(exactly = 1) { mock.player.pause() } - verify { mock.player.currentTime = 50.0 } assertEquals(50f, state.value) state.onValueChangeFinished() + verify(exactly = 1) { mock.player.currentTime = 50.0 } verify(exactly = 1) { mock.player.play() } } + @Test + fun `scrubbing seeks once the position stays unchanged`() = runTest { + val mock = MockPlayer(paused = false, currentTime = 10.0) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.onValueChange(40f) + advanceTimeBy(200) + state.onValueChange(50f) + advanceTimeBy(249) + verify(exactly = 0) { mock.player.currentTime = any() } + + advanceTimeBy(1) + runCurrent() + verify(exactly = 0) { mock.player.currentTime = 40.0 } + verify(exactly = 1) { mock.player.currentTime = 50.0 } + + state.onValueChangeFinished() + verify(exactly = 1) { mock.player.currentTime = any() } + } + + @Test + fun `finishing seeks immediately and cancels the pending seek`() = runTest { + val mock = MockPlayer(paused = false, currentTime = 10.0) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.onValueChange(40f) + state.onValueChange(50f) + state.onValueChangeFinished() + verify(exactly = 1) { mock.player.currentTime = 50.0 } + + advanceTimeBy(1_000) + runCurrent() + verify(exactly = 1) { mock.player.currentTime = any() } + } + + @Test + fun `scrubTime is the absolute scrub position`() = runTest { + val mock = MockPlayer(seekableRanges = listOf(100.0 to 200.0), currentTime = 110.0) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + assertNull(state.scrubTime) + + state.onValueChange(50f) + assertEquals(150.0, state.scrubTime!!, 0.001) + assertEquals(150.0, (state as PlaybackTimeState).currentTime, 0.001) + assertEquals(110.0, mock.currentTime, 0.0) + + state.onValueChangeFinished() + assertNull(state.scrubTime) + assertEquals(150.0, state.currentTime, 0.001) + } + @Test fun `seekTo seeks without changing playback state`() { val mock = MockPlayer(paused = false, currentTime = 50.0) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt index f627d2d1..50f4dbe9 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.input.key.type import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.SeekBarState -import com.theoplayer.android.ui.rememberSeekBarState +import com.theoplayer.android.ui.rememberUIControllerSeekBarState /** * A seek bar showing the player's current time and allowing D-pad seeking. @@ -38,7 +38,7 @@ fun SeekBar( seekOffset: Int = 10, ) { SeekBar( - state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + state = rememberUIControllerSeekBarState(LocalTHEOplayerView.current?.player), modifier = modifier, colors = colors, seekOffset = seekOffset, diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt index ca0a7cfb..03b00e38 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt @@ -87,7 +87,7 @@ class SeekBarTest { player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) } composeRule.waitForIdle() - val positions = mutableListOf(player.currentTime) + val start = player.currentTime for (repeat in 1..3) { assertTrue( dispatchKey( @@ -96,11 +96,10 @@ class SeekBarTest { repeat = repeat, ) ) - positions += player.currentTime } assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) - assertTrue(positions.zipWithNext().all { (previous, next) -> next > previous }) + assertTrue(player.currentTime > start) verify(exactly = 1) { player.player.pause() } verify(exactly = 1) { player.player.play() } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt index 53045b56..746940af 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -43,7 +43,7 @@ fun SeekBar( colors: SliderColors = SliderDefaults.colors(), ) { SeekBar( - state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + state = rememberUIControllerSeekBarState(LocalTHEOplayerView.current?.player), modifier = modifier, colors = colors, ) From 4a908aed7a3217d93a856b2b06456ebda24349f3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:44:54 +0000 Subject: [PATCH 2/3] Add PR link to changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51995037..4e13eb30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ## [Unreleased] * 💅 On Android TV, pressing Center or Enter on the `SeekBar` now toggles play or pause, so viewers don't have to move focus to the play button first. Custom UIs can use the new `SeekBarState.togglePlayPause()` for this. ([#148](https://github.com/THEOplayer/android-ui/pull/148)) -* 💅 While scrubbing the `SeekBar`, the player now only seeks once the viewer stops moving for a moment or lets go, instead of on every position change. `SeekBarState` now extends `PlaybackTimeState`, and `CurrentTimeDisplay` shows the position being scrubbed to. ([#TBD](https://github.com/THEOplayer/android-ui/pull/TBD)) +* 💅 While scrubbing the `SeekBar`, the player now only seeks once the viewer stops moving for a moment or lets go, instead of on every position change. `SeekBarState` now extends `PlaybackTimeState`, and `CurrentTimeDisplay` shows the position being scrubbed to. ([#149](https://github.com/THEOplayer/android-ui/pull/149)) ## [1.15.0] (2026-10-23) From 46e8b62377a7c61cc43e71120f28858cf07d5b5a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:56:15 +0000 Subject: [PATCH 3/3] Don't seek during ads while scrubbing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/SeekBarState.kt | 4 ++- .../theoplayer/android/ui/SeekBarStateTest.kt | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt index e1638fc1..49ead642 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -113,8 +113,9 @@ class SeekBarState internal constructor(player: Player?) : PlaybackTimeState(pla } } - /** Seeks the player to a position within [valueRange]. */ + /** Seeks the player to a position within [valueRange], unless the seek bar is disabled. */ private fun seekToValue(value: Float) { + if (!enabled) return lastSeekValue = value player?.currentTime = seekableRange.start + value.toDouble() // Re-read the position right away, so the seek bar doesn't jump back to the old position @@ -127,6 +128,7 @@ class SeekBarState internal constructor(player: Player?) : PlaybackTimeState(pla * the given position within [valueRange] once the user stops dragging for a moment. */ fun onValueChange(value: Float) { + if (!enabled) return seekTime = value pendingSeek.value = value player?.let { diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt index 533917e2..2a3deb86 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt @@ -216,6 +216,38 @@ class SeekBarStateTest { assertTrue(state.showThumb) } + @Test + fun `scrubbing does nothing during ads`() = runTest { + val mock = MockPlayer(paused = false, adPlaying = true) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.onValueChange(50f) + advanceTimeBy(1_000) + runCurrent() + state.onValueChangeFinished() + + assertNull(state.scrubTime) + verify(exactly = 0) { mock.player.pause() } + verify(exactly = 0) { mock.player.currentTime = any() } + } + + @Test + fun `an ad starting while scrubbing cancels the seek`() = runTest { + val mock = MockPlayer(paused = false) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.onValueChange(50f) + mock.adPlaying = true + mock.fireAdEvent() + advanceTimeBy(1_000) + runCurrent() + state.onValueChangeFinished() + + verify(exactly = 0) { mock.player.currentTime = any() } + } + @Test fun `cancelling removes all listeners`() = runTest { val mock = MockPlayer()