diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b464321..4e13eb30 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. ([#149](https://github.com/THEOplayer/android-ui/pull/149)) ## [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..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 @@ -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,43 @@ 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], 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 + // 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) { + if (!enabled) return seekTime = value + pendingSeek.value = value player?.let { if (!it.isPaused) { wasPlayingBeforeSeek = true it.pause() } - it.currentTime = seekableRange.start + value.toDouble() } } @@ -134,11 +161,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 +176,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 +187,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..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 @@ -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) @@ -159,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() 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, )