From 8fe787fc75a84dc80dedef954ae5ed9a3ef37250 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:57:07 +0000 Subject: [PATCH 1/2] Disable LiveButton during ads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../theoplayer/android/ui/LiveButtonState.kt | 52 ++++++++++++++----- .../android/ui/LiveButtonStateTest.kt | 16 ++++++ .../theoplayer/android/ui/tv/LiveButton.kt | 1 + .../com/theoplayer/android/ui/LiveButton.kt | 1 + 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e13eb30..9d4a2ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * 💅 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)) +* 🐛 The `LiveButton` is now disabled while an ad is playing, so viewers can no longer seek to the live point during an ad. ([#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/LiveButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt index c4645f0d..ede235e4 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt @@ -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]. @@ -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) @@ -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 { @@ -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() diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt index bcaa2d06..918c8d16 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt @@ -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) + } } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt index eca04b5d..70f1acc8 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt @@ -102,6 +102,7 @@ fun LiveButton( Button( onClick = state::onClick, modifier = modifier, + enabled = state.enabled, contentPadding = contentPadding, colors = colors, ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt index 605ac9ac..744dd3ec 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt @@ -133,6 +133,7 @@ fun LiveButton( if (state.visible) { TextButton( modifier = modifier, + enabled = state.enabled, contentPadding = contentPadding, colors = colors.toButtonColors(), onClick = { state.onClick() }, From f1fdeb1d9264f9de25855a64a9e11fa6ed419acf Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:57:26 +0000 Subject: [PATCH 2/2] 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 9d4a2ec3..e59f9eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ * 💅 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)) -* 🐛 The `LiveButton` is now disabled while an ad is playing, so viewers can no longer seek to the live point during an ad. ([#TBD](https://github.com/THEOplayer/android-ui/pull/TBD)) +* 🐛 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)