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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

## [Unreleased]

* 🐛 The `LiveButton` is now disabled while an ad is playing, so viewers can no longer seek to the live point during an ad. ([#151](https://github.com/THEOplayer/android-ui/pull/151))

## [1.15.0] (2026-10-23)

* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. It includes a D-pad friendly `DefaultUI`. Code shared with `android-ui` now lives in a new `android-ui-common` artifact; `android-ui` depends on it, so apps don't need any changes. ([#107](https://github.com/THEOplayer/android-ui/pull/107), [#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125), [#126](https://github.com/THEOplayer/android-ui/pull/126), [#128](https://github.com/THEOplayer/android-ui/pull/128), [#131](https://github.com/THEOplayer/android-ui/pull/131))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import androidx.compose.runtime.getValue
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 kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

/**
* State holder for a "live" button, such as [LiveButton].
Expand All @@ -28,7 +32,12 @@ internal constructor(
val isLive: Boolean
get() = !paused && ((seekable.lastEnd ?: 0.0) - currentTime) <= liveThreshold

/** Whether the button can be clicked. Disabled while an ad is playing. */
val enabled: Boolean
get() = !playingAd

private var paused by mutableStateOf(true)
private var playingAd by mutableStateOf(false)
private var currentTime by mutableStateOf(0.0)
private var seekable by mutableStateOf(TimeRanges.empty())
private var duration by mutableStateOf(Double.NaN)
Expand All @@ -45,6 +54,7 @@ internal constructor(
duration = player?.duration ?: Double.NaN
seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty()
updatePlaybackState()
playingAd = player?.ads?.isPlaying ?: false
}

init {
Expand All @@ -54,24 +64,38 @@ internal constructor(
internal suspend fun observe() {
val player = player ?: return
update()
player.listen(
PlayerEventTypes.TIMEUPDATE,
PlayerEventTypes.PLAY,
PlayerEventTypes.PAUSE,
PlayerEventTypes.ENDED,
PlayerEventTypes.SEEKING,
PlayerEventTypes.SEEKED,
PlayerEventTypes.READYSTATECHANGE,
PlayerEventTypes.DURATIONCHANGE,
PlayerEventTypes.SOURCECHANGE,
PlayerEventTypes.ERROR,
) {
update()
coroutineScope {
player.ads?.let { ads ->
launch(start = CoroutineStart.UNDISPATCHED) {
ads.listen(
AdsEventTypes.AD_BREAK_BEGIN,
AdsEventTypes.AD_SKIP,
AdsEventTypes.AD_BREAK_END,
) {
update()
}
}
}
player.listen(
PlayerEventTypes.TIMEUPDATE,
PlayerEventTypes.PLAY,
PlayerEventTypes.PAUSE,
PlayerEventTypes.ENDED,
PlayerEventTypes.SEEKING,
PlayerEventTypes.SEEKED,
PlayerEventTypes.READYSTATECHANGE,
PlayerEventTypes.DURATIONCHANGE,
PlayerEventTypes.SOURCECHANGE,
PlayerEventTypes.ERROR,
) {
update()
}
}
}

/** Seeks to the live point and resumes playback. */
/** Seeks to the live point and resumes playback, unless the button is disabled. */
fun onClick() {
if (!enabled) return
player?.let {
it.currentTime = Double.POSITIVE_INFINITY
it.play()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,20 @@ class LiveButtonStateTest {
verify { mock.player.currentTime = Double.POSITIVE_INFINITY }
verify(exactly = 1) { mock.player.play() }
}

@Test
fun `disabled during ads`() = runTest {
val mock = MockPlayer(adPlaying = true, duration = Double.POSITIVE_INFINITY)
val state = LiveButtonState(mock.player, liveThreshold = 10.0)
launchObserve { state.observe() }
assertFalse(state.enabled)

state.onClick()
verify(exactly = 0) { mock.player.currentTime = any() }
verify(exactly = 0) { mock.player.play() }

mock.adPlaying = false
mock.fireAdEvent()
assertTrue(state.enabled)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ fun LiveButton(
Button(
onClick = state::onClick,
modifier = modifier,
enabled = state.enabled,
contentPadding = contentPadding,
colors = colors,
) {
Expand Down
1 change: 1 addition & 0 deletions ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ fun LiveButton(
if (state.visible) {
TextButton(
modifier = modifier,
enabled = state.enabled,
contentPadding = contentPadding,
colors = colors.toButtonColors(),
onClick = { state.onClick() },
Expand Down
Loading