Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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(
Expand All @@ -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
Expand Down
112 changes: 81 additions & 31 deletions ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Float>
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()
Expand All @@ -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<Float?>(null)
private var wasPlayingBeforeSeek by mutableStateOf(false)
private val pendingSeek = MutableStateFlow<Float?>(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.
Expand All @@ -54,21 +75,19 @@ 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
}

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

Expand All @@ -134,18 +161,24 @@ 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
}
}
}

/** 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
Expand All @@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,6 +114,7 @@ fun UIControllerCore(
scope = scope,
uiState = uiState.value,
errorState = errorState,
seekBarState = seekBarState,
controls = controls,
adPlaying = adState.playingAd,
background = animatedBackground,
Expand All @@ -137,6 +139,7 @@ private fun UIControllerContent(
scope: UIControllerScopeImpl,
uiState: UIState,
errorState: ErrorState,
seekBarState: SeekBarState,
controls: ControlsVisibility,
adPlaying: Boolean,
background: Color,
Expand Down Expand Up @@ -166,6 +169,7 @@ private fun UIControllerContent(
LocalPlayer provides player,
LocalTHEOplayerView provides theoplayerView,
LocalUIControllerErrorState provides errorState,
LocalUIControllerSeekBarState provides seekBarState,
) {
AnimatedContent(
label = "ContentAnimation",
Expand Down Expand Up @@ -329,3 +333,8 @@ private fun InnerMenus(

/** The current error state provided by a UI controller. */
@InternalUiApi val LocalUIControllerErrorState = staticCompositionLocalOf<ErrorState?> { null }

/** The seek bar state provided by a UI controller, shared by its seek bar and time displays. */
@InternalUiApi
@ExperimentalPlayerStateApi
val LocalUIControllerSeekBarState = staticCompositionLocalOf<SeekBarState?> { null }
Loading
Loading