From 38b9b45125c6fc8f10cdbe8da39363872c475452 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 17:14:31 +0000 Subject: [PATCH 001/158] Extract Player and its helpers into a new ui-common module Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- settings.gradle.kts | 1 + ui-common/.gitignore | 1 + ui-common/build.gradle.kts | 121 ++++++++++++++++++ ui-common/proguard-rules.pro | 21 +++ ui-common/src/main/AndroidManifest.xml | 2 + .../android/ui/FullscreenHandler.kt | 0 .../theoplayer/android/ui/InternalUiApi.kt | 19 +++ .../java/com/theoplayer/android/ui/Player.kt | 12 +- .../com/theoplayer/android/ui/TimeRanges.kt | 0 .../android/ui/util/PictureInPictureUtil.kt | 4 +- ui/build.gradle.kts | 10 ++ 11 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 ui-common/.gitignore create mode 100644 ui-common/build.gradle.kts create mode 100644 ui-common/proguard-rules.pro create mode 100644 ui-common/src/main/AndroidManifest.xml rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt (100%) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/InternalUiApi.kt rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/Player.kt (99%) rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/TimeRanges.kt (100%) rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt (94%) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c36ccb5..9887aa76 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,3 +19,4 @@ dependencyResolutionManagement { rootProject.name = "THEOplayer Open Video UI for Android" include(":app") include(":ui") +include(":ui-common") diff --git a/ui-common/.gitignore b/ui-common/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/ui-common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts new file mode 100644 index 00000000..00476f2a --- /dev/null +++ b/ui-common/build.gradle.kts @@ -0,0 +1,121 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +buildscript { + dependencies { + classpath(libs.dokka.base) + } +} + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.dokka) + alias(libs.plugins.dokka.javadoc) + id("android-ui.library-conventions") +} + +android { + namespace = "com.theoplayer.android.ui.common" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + publishing { + singleVariant("release") { + withSourcesJar() + // We use Dokka for JavaDoc generation, see dokkaJavadocJar below + // withJavadocJar() + } + } +} + +kotlin { + compilerOptions { + apiVersion = KotlinVersion.KOTLIN_2_0 + jvmTarget = JvmTarget.JVM_1_8 + optIn.add("com.theoplayer.android.ui.InternalUiApi") + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui.ui) + + testImplementation(libs.junit4) + + compileOnly(libs.theoplayer.compile) + api(libs.theoplayer) + + dokkaPlugin(libs.dokka.plugin) +} + +dokka { + moduleName = "android-ui-common" + + dokkaSourceSets.configureEach { + sourceLink { + localDirectory = file("src/main/java") + remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-common/src/main/java") + remoteLineSuffix = "#L" + } + + externalDocumentationLinks { + register("com.theoplayer.android.api") { + url("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/") + packageListUrl("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/package-list") + } + } + } +} + +val dokkaJavadocJar = tasks.register("dokkaJavadocJar") { + group = "dokka" + from(tasks.dokkaGeneratePublicationJavadoc) + dependsOn(tasks.dokkaGeneratePublicationJavadoc) + archiveClassifier.set("javadoc") +} + +publishing { + publications { + register("release") { + groupId = "com.theoplayer.android-ui" + artifactId = "android-ui-common" + version = project.version as String + artifact(dokkaJavadocJar) + afterEvaluate { + from(components["release"]) + } + } + } +} diff --git a/ui-common/proguard-rules.pro b/ui-common/proguard-rules.pro new file mode 100644 index 00000000..ff59496d --- /dev/null +++ b/ui-common/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ui-common/src/main/AndroidManifest.xml b/ui-common/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fa57d87f --- /dev/null +++ b/ui-common/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt similarity index 100% rename from ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/InternalUiApi.kt b/ui-common/src/main/java/com/theoplayer/android/ui/InternalUiApi.kt new file mode 100644 index 00000000..6fae3ad0 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/InternalUiApi.kt @@ -0,0 +1,19 @@ +package com.theoplayer.android.ui + +/** + * Marks declarations that are internal to the THEOplayer Open Video UI for Android. + * + * These are only public so they can be shared between the UI modules, and may change at any time. + */ +@RequiresOptIn( + message = "This API is internal to the THEOplayer Open Video UI for Android and may change at any time.", + level = RequiresOptIn.Level.ERROR +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR +) +annotation class InternalUiApi diff --git a/ui/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt similarity index 99% rename from ui/src/main/java/com/theoplayer/android/ui/Player.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index 342eaf7d..6f3eba70 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -66,7 +66,8 @@ import com.theoplayer.android.api.event.track.texttrack.list.RemoveTrackEvent as import com.theoplayer.android.api.event.track.texttrack.list.TrackListChangeEvent as TextTrackListChangeEvent import com.theoplayer.android.api.player.Player as THEOplayer -internal val LocalPlayer = staticCompositionLocalOf { null } +@InternalUiApi +val LocalPlayer = staticCompositionLocalOf { null } /** * A player holding a [THEOplayer] instance that can be hosted by a [DefaultUI] or [UIController]. @@ -326,7 +327,8 @@ enum class StreamType { Dvr } -internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { +@InternalUiApi +class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { override val player = theoplayerView?.player override val ads = theoplayerView?.player?.ads override var currentTime by mutableDoubleStateOf(0.0) @@ -505,10 +507,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } } - val fullscreenListener = + internal val fullscreenListener = FullscreenHandler.OnFullscreenChangeListener { updateFullscreen() } - internal var activityInPipMode: Boolean by mutableStateOf(false) + var activityInPipMode: Boolean by mutableStateOf(false) private var pipManagerInPipMode: Boolean by mutableStateOf(false) override val pictureInPicture: Boolean by derivedStateOf { @@ -538,7 +540,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player pipManagerInPipMode = theoplayerView?.piPManager?.isInPiP == true } - val presentationModeChangeListener = + internal val presentationModeChangeListener = EventListener { updatePictureInPicture() } override val loading by derivedStateOf { diff --git a/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TimeRanges.kt similarity index 100% rename from ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/TimeRanges.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt similarity index 94% rename from ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt index 6747788e..b47fa400 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -15,6 +15,7 @@ import androidx.core.app.OnPictureInPictureUiStateChangedProvider import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.app.PictureInPictureUiStateCompat import androidx.core.util.Consumer +import com.theoplayer.android.ui.InternalUiApi /** * From [android.content.pm.ActivityInfo] @@ -34,8 +35,9 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { /** * Returns whether the activity is in (or transitioning to) picture-in-picture mode. */ +@InternalUiApi @Composable -internal fun rememberIsInPipMode(): Boolean { +fun rememberIsInPipMode(): Boolean { // https://developer.android.com/develop/ui/compose/system/picture-in-picture#handle-ui if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false val activity = LocalActivity.current ?: return false diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 2e21584d..47311863 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -67,10 +67,13 @@ kotlin { compilerOptions { apiVersion = KotlinVersion.KOTLIN_2_0 jvmTarget = JvmTarget.JVM_1_8 + optIn.add("com.theoplayer.android.ui.InternalUiApi") } } dependencies { + api(project(":ui-common")) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ktx) @@ -103,11 +106,18 @@ dokka { moduleName = rootProject.name dokkaSourceSets.configureEach { + sourceRoots.from(rootProject.file("ui-common/src/main/java")) + sourceLink { localDirectory = file("src/main/java") remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui/src/main/java") remoteLineSuffix = "#L" } + sourceLink { + localDirectory = rootProject.file("ui-common/src/main/java") + remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-common/src/main/java") + remoteLineSuffix = "#L" + } externalDocumentationLinks { register("com.theoplayer.android.api") { From a9d1399e8852c6480420c57a1c1ca1df4a70ab1d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 17:14:31 +0000 Subject: [PATCH 002/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7283f5..7142b290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ## [Unreleased] +* 🏠 Moved `Player` and its helpers into a new `android-ui-common` artifact. `android-ui` depends on it, so apps don't need any changes. + ## [1.14.2] (2026-10-13) * 🐛 Fixed a listener leak in picture-in-picture mode detection on Android 15 and higher. ([#113](https://github.com/THEOplayer/android-ui/pull/113)) From a0158e1066f52e4d18f3ec15bdc2f70e37569b61 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 17:17:50 +0000 Subject: [PATCH 003/158] Hide internal APIs from the API docs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-common/build.gradle.kts | 4 ---- .../src/main/java/com/theoplayer/android/ui/Player.kt | 10 ++++++++++ .../theoplayer/android/ui/util/PictureInPictureUtil.kt | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 00476f2a..04c053dc 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -21,10 +21,6 @@ android { defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() - - vectorDrawables { - useSupportLibrary = true - } } buildTypes { diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index 6f3eba70..01e93897 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -66,6 +66,11 @@ import com.theoplayer.android.api.event.track.texttrack.list.RemoveTrackEvent as import com.theoplayer.android.api.event.track.texttrack.list.TrackListChangeEvent as TextTrackListChangeEvent import com.theoplayer.android.api.player.Player as THEOplayer +/** + * The [Player] provided by the enclosing [UIController]. + * + * @suppress + */ @InternalUiApi val LocalPlayer = staticCompositionLocalOf { null } @@ -327,6 +332,11 @@ enum class StreamType { Dvr } +/** + * Internal implementation of [Player]. + * + * @suppress + */ @InternalUiApi class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { override val player = theoplayerView?.player diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt index b47fa400..07927e07 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -34,6 +34,8 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { /** * Returns whether the activity is in (or transitioning to) picture-in-picture mode. + * + * @suppress */ @InternalUiApi @Composable From 0386b98387b8871a0601e168be661e3b0270458b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 18:15:04 +0000 Subject: [PATCH 004/158] 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 7142b290..5f8cf492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🏠 Moved `Player` and its helpers into a new `android-ui-common` artifact. `android-ui` depends on it, so apps don't need any changes. +* 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 8efe5e3e0047059ecd80ebc15875618b6f5d1822 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:30:46 +0000 Subject: [PATCH 005/158] Add ExperimentalPlayerStateApi and LocalTHEOplayerView Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-common/build.gradle.kts | 2 ++ .../android/ui/ExperimentalPlayerStateApi.kt | 20 +++++++++++++++++++ .../android/ui/LocalTHEOplayerView.kt | 9 +++++++++ ui/build.gradle.kts | 1 + .../com/theoplayer/android/ui/UIController.kt | 5 ++++- 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/ExperimentalPlayerStateApi.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/LocalTHEOplayerView.kt diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 04c053dc..aa7acf6c 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -58,6 +58,7 @@ kotlin { apiVersion = KotlinVersion.KOTLIN_2_0 jvmTarget = JvmTarget.JVM_1_8 optIn.add("com.theoplayer.android.ui.InternalUiApi") + optIn.add("com.theoplayer.android.ui.ExperimentalPlayerStateApi") } } @@ -69,6 +70,7 @@ dependencies { implementation(libs.androidx.compose.ui.ui) testImplementation(libs.junit4) + testImplementation(libs.mockk) compileOnly(libs.theoplayer.compile) api(libs.theoplayer) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ExperimentalPlayerStateApi.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ExperimentalPlayerStateApi.kt new file mode 100644 index 00000000..b8cbe81f --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ExperimentalPlayerStateApi.kt @@ -0,0 +1,20 @@ +package com.theoplayer.android.ui + +/** + * Marks declarations that are part of the experimental state holder API of + * the THEOplayer Open Video UI for Android. + * + * The state holder API is experimental and may change in future releases. + */ +@RequiresOptIn( + message = "This state holder API is experimental and may change in future releases.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR +) +annotation class ExperimentalPlayerStateApi diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/LocalTHEOplayerView.kt b/ui-common/src/main/java/com/theoplayer/android/ui/LocalTHEOplayerView.kt new file mode 100644 index 00000000..9ca1d6e3 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LocalTHEOplayerView.kt @@ -0,0 +1,9 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import com.theoplayer.android.api.THEOplayerView + +/** + * The [THEOplayerView] controlled by the enclosing [UIController]. + */ +val LocalTHEOplayerView = staticCompositionLocalOf { null } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 47311863..fa314f76 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -68,6 +68,7 @@ kotlin { apiVersion = KotlinVersion.KOTLIN_2_0 jvmTarget = JvmTarget.JVM_1_8 optIn.add("com.theoplayer.android.ui.InternalUiApi") + optIn.add("com.theoplayer.android.ui.ExperimentalPlayerStateApi") } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 60b9b46a..7b3bb564 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -220,7 +220,10 @@ fun UIController( } ) ) { - CompositionLocalProvider(LocalPlayer provides player) { + CompositionLocalProvider( + LocalPlayer provides player, + LocalTHEOplayerView provides player.theoplayerView + ) { AnimatedContent( label = "ContentAnimation", modifier = Modifier From c25805d66bb279cf4883ae086b540c4552f6a8d3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:30:46 +0000 Subject: [PATCH 006/158] Add state holders for the playback and time controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/LiveButtonState.kt | 113 ++++++ .../theoplayer/android/ui/PlayButtonState.kt | 79 ++++ .../android/ui/PlaybackTimeState.kt | 94 +++++ .../android/ui/PlayerStateListeners.kt | 48 +++ .../com/theoplayer/android/ui/SeekBarState.kt | 147 +++++++ .../theoplayer/android/ui/SeekButtonState.kt | 90 +++++ .../theoplayer/android/ui/PlayerStateTest.kt | 372 ++++++++++++++++++ 7 files changed, 943 insertions(+) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt 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 new file mode 100644 index 00000000..b6f21fd9 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt @@ -0,0 +1,113 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +private const val DVR_WINDOW_THRESHOLD = 60.0 + +/** + * State holder for a "live" button, such as [LiveButton]. + * + * Create an instance with [rememberLiveButtonState]. + */ +@ExperimentalPlayerStateApi +class LiveButtonState( + private val player: Player?, + private val liveThreshold: Double +) { + private val listeners = PlayerStateListeners() + + /** + * Whether the button should be shown. Only live and DVR streams have a live point. + */ + val visible: Boolean + get() = streamType == StreamType.Live || streamType == StreamType.Dvr + + /** + * Whether the player is currently playing at the live point. + */ + val isLive: Boolean + get() = !paused && ((seekable.lastEnd ?: 0.0) - currentTime) <= liveThreshold + + private var paused by mutableStateOf(true) + private var currentTime by mutableStateOf(0.0) + private var seekable by mutableStateOf(TimeRanges.empty()) + private var duration by mutableStateOf(Double.NaN) + + private val streamType: StreamType + get() = if (duration.isInfinite()) { + val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 + // TODO Make DVR threshold configurable? + if (slidingWindow >= DVR_WINDOW_THRESHOLD) { + StreamType.Dvr + } else { + StreamType.Live + } + } else { + StreamType.Vod + } + + private fun updatePlaybackState() { + paused = player?.isPaused ?: true + } + + private fun update() { + currentTime = player?.currentTime ?: 0.0 + duration = player?.duration ?: Double.NaN + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() + updatePlaybackState() + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) + player.add(PlayerEventTypes.PLAY, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Seeks to the live point and resumes playback. + */ + fun onClick() { + player?.let { + it.currentTime = Double.POSITIVE_INFINITY + it.play() + } + } +} + +/** + * Creates a [LiveButtonState] that's remembered across compositions. + * + * @param liveThreshold the maximum distance (in seconds) from the live point that the player's + * current time can be for it to still be considered "at the live point" + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberLiveButtonState(player: Player?, liveThreshold: Double): LiveButtonState { + val state = remember(player, liveThreshold) { LiveButtonState(player, liveThreshold) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt new file mode 100644 index 00000000..86df260d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -0,0 +1,79 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for a play/pause button, such as [PlayButton]. + * + * Create an instance with [rememberPlayButtonState]. + */ +@ExperimentalPlayerStateApi +class PlayButtonState(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * Whether the player is paused. + */ + var paused by mutableStateOf(true) + private set + + /** + * Whether playback has reached the end of the media. + */ + var ended by mutableStateOf(false) + private set + + private fun updatePlaybackState() { + paused = player?.isPaused ?: true + ended = player?.isEnded ?: false + } + + internal fun observe() { + val player = player ?: return + updatePlaybackState() + listeners.run { + player.add(PlayerEventTypes.PLAY, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.ENDED, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { updatePlaybackState() }) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Toggles between play and pause. + */ + fun onClick() { + if (paused) { + player?.play() + } else { + player?.pause() + } + } +} + +/** + * Creates a [PlayButtonState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberPlayButtonState(player: Player?): PlayButtonState { + val state = remember(player) { PlayButtonState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} 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 new file mode 100644 index 00000000..bc0e6d09 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt @@ -0,0 +1,94 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +private const val DVR_WINDOW_THRESHOLD = 60.0 + +/** + * State holder for a playback time display, such as [CurrentTimeDisplay] or [DurationDisplay]. + * + * Create an instance with [rememberPlaybackTimeState]. + */ +@ExperimentalPlayerStateApi +class PlaybackTimeState(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The current playback position of the media, in seconds. + */ + var currentTime by mutableStateOf(0.0) + private set + + /** + * The duration of the media, in seconds. + * + * For live streams, this is the end of the seekable window if it has one. + */ + var duration by mutableStateOf(Double.NaN) + private set + + private var seekable by mutableStateOf(TimeRanges.empty()) + private var rawDuration by mutableStateOf(Double.NaN) + + /** + * The [StreamType] of the media. + */ + val streamType: StreamType + get() = if (rawDuration.isInfinite()) { + val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 + // TODO Make DVR threshold configurable? + if (slidingWindow >= DVR_WINDOW_THRESHOLD) { + StreamType.Dvr + } else { + StreamType.Live + } + } else { + StreamType.Vod + } + + private fun update() { + currentTime = player?.currentTime ?: 0.0 + rawDuration = player?.duration ?: Double.NaN + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() + duration = seekable.lastEnd ?: rawDuration + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates a [PlaybackTimeState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberPlaybackTimeState(player: Player?): PlaybackTimeState { + val state = remember(player) { PlaybackTimeState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt new file mode 100644 index 00000000..df196947 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt @@ -0,0 +1,48 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.player.Player + +/** + * Tracks the event listeners registered by a state holder, so they can all be removed again + * on dispose. + */ +internal class PlayerStateListeners { + private val listeners = mutableListOf, EventListener<*>>>() + + fun > Player.add(type: EventType, listener: EventListener) { + addEventListener(type, listener) + listeners.add(Triple(this, type, listener)) + } + + fun > Ads.add(type: EventType, listener: EventListener) { + addEventListener(type, listener) + listeners.add(Triple(this, type, listener)) + } + + fun removeAll() { + for ((target, type, listener) in listeners) { + remove(target, type, listener) + } + listeners.clear() + } + + @Suppress("UNCHECKED_CAST") + private fun remove(target: Any?, type: EventType<*>, listener: EventListener<*>) { + when (target) { + is Player -> target.removeEventListener( + type as EventType>, + listener as EventListener> + ) + is Ads -> target.removeEventListener( + type as EventType>, + listener as EventListener> + ) + } + } +} 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 new file mode 100644 index 00000000..793fb02e --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -0,0 +1,147 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * 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. + * + * Create an instance with [rememberSeekBarState]. + */ +@ExperimentalPlayerStateApi +class SeekBarState(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The current scrub position of the seek bar, relative to the start of [seekableRange]. + */ + val value: Float + get() = seekTime ?: (currentTime - seekableRange.start).toFloat() + + /** + * The range of [value]. Always starts at zero. + */ + val valueRange: ClosedFloatingPointRange + get() = 0f..(seekableRange.endInclusive - seekableRange.start).toFloat() + + /** + * Whether the seek bar can be interacted with. + */ + val enabled: Boolean + get() = !playingAd && seekable.isNotEmpty() + + /** + * Whether to show the seek bar's thumb. Hidden while an ad is playing. + */ + 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) + + // 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. + private val seekableRange: ClosedFloatingPointRange + get() = seekable.bounds ?: run { + 0.0..(if (duration.isFinite()) duration.coerceAtLeast(0.0) else 0.0) + } + + private fun updateCurrentTime() { + currentTime = player?.currentTime ?: 0.0 + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() + } + + private fun updateDuration() { + duration = player?.duration ?: Double.NaN + } + + private fun updatePlayingAd() { + playingAd = player?.ads?.isPlaying ?: false + } + + internal fun observe() { + val player = player ?: return + updateCurrentTime() + updateDuration() + updatePlayingAd() + listeners.run { + player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { updateCurrentTime() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { updateDuration() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { updateCurrentTime() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { updateCurrentTime() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { + updateCurrentTime() + updateDuration() + updatePlayingAd() + }) + player.ads?.let { ads -> + val adListener = EventListener> { updatePlayingAd() } + ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) + ads.add(AdsEventTypes.AD_SKIP, adListener) + ads.add(AdsEventTypes.AD_BREAK_END, adListener) + } + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Called while the user drags the seek bar. Temporarily pauses a playing player and seeks + * to the given position within [valueRange]. + */ + fun onValueChange(value: Float) { + seekTime = value + player?.let { + if (!it.isPaused) { + wasPlayingBeforeSeek = true + it.pause() + } + it.currentTime = seekableRange.start + value.toDouble() + } + } + + /** + * Called when the user stops dragging the seek bar. Resumes playback if the player was + * playing before the drag started. + */ + fun onValueChangeFinished() { + seekTime = null + if (wasPlayingBeforeSeek) { + player?.play() + wasPlayingBeforeSeek = false + } + } +} + +/** + * Creates a [SeekBarState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberSeekBarState(player: Player?): SeekBarState { + val state = remember(player) { SeekBarState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt new file mode 100644 index 00000000..8a80ff86 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -0,0 +1,90 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for a button that seeks forward or backward by a fixed offset, + * such as [SeekButton]. + * + * Create an instance with [rememberSeekButtonState]. + */ +@ExperimentalPlayerStateApi +class SeekButtonState( + private val player: Player?, + private val seekOffset: Int +) { + private val listeners = PlayerStateListeners() + + /** + * Whether the button can be interacted with. + */ + val enabled: Boolean + get() = !playingAd && seekable.isNotEmpty() + + private var seekable by mutableStateOf(TimeRanges.empty()) + private var playingAd by mutableStateOf(false) + + private fun update() { + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() + playingAd = player?.ads?.isPlaying ?: false + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + player.ads?.let { ads -> + val adListener = EventListener> { update() } + ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) + ads.add(AdsEventTypes.AD_SKIP, adListener) + ads.add(AdsEventTypes.AD_BREAK_END, adListener) + } + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Seeks forward (if [seekOffset] is positive) or backward (if negative) by [seekOffset] + * seconds, clamped to the media duration. + */ + fun onClick() { + player?.let { + if (!it.duration.isNaN()) { + it.currentTime = (it.currentTime + seekOffset).coerceIn(0.0, it.duration) + } + } + } +} + +/** + * Creates a [SeekButtonState] that's remembered across compositions. + * + * @param seekOffset the offset (in seconds) by which to seek forward (if positive) + * or backward (if negative) + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberSeekButtonState(player: Player?, seekOffset: Int): SeekButtonState { + val state = remember(player, seekOffset) { SeekButtonState(player, seekOffset) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt new file mode 100644 index 00000000..56e63b99 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -0,0 +1,372 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.timerange.TimeRange +import com.theoplayer.android.api.timerange.TimeRanges +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A mocked [Player] that records the listeners added to it and to its [Ads], + * so tests can fire events at them. + */ +private class MockPlayer( + var paused: Boolean = true, + var ended: Boolean = false, + var currentTime: Double = 0.0, + var duration: Double = 100.0, + var seekableRanges: List> = listOf(0.0 to 100.0), + var adPlaying: Boolean = false, +) { + val playerListeners = mutableListOf>() + val adListeners = mutableListOf>() + + val ads: Ads = mockk { + every { isPlaying } answers { this@MockPlayer.adPlaying } + every { + addEventListener(any>>(), any()) + } answers { + adListeners.add(secondArg>>()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + adListeners.remove(secondArg>>()) + Unit + } + } + + val player: Player = mockk { + every { isPaused } answers { this@MockPlayer.paused } + every { isEnded } answers { this@MockPlayer.ended } + every { getCurrentTime() } answers { this@MockPlayer.currentTime } + every { getDuration() } answers { this@MockPlayer.duration } + every { getSeekable() } answers { mockTimeRanges(this@MockPlayer.seekableRanges) } + every { getAds() } returns this@MockPlayer.ads + every { setCurrentTime(any()) } answers { this@MockPlayer.currentTime = firstArg() } + every { play() } answers { this@MockPlayer.paused = false } + every { pause() } answers { this@MockPlayer.paused = true } + every { + addEventListener(any>>(), any()) + } answers { + playerListeners.add(secondArg>>()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + playerListeners.remove(secondArg>>()) + Unit + } + } + + fun firePlayerEvent() { + playerListeners.toList().forEach { + @Suppress("UNCHECKED_CAST") + (it as EventListener>).handleEvent(mockk()) + } + } + + fun fireAdEvent() { + adListeners.toList().forEach { + @Suppress("UNCHECKED_CAST") + (it as EventListener>).handleEvent(mockk()) + } + } +} + +private fun mockTimeRanges(ranges: List>): TimeRanges = mockk { + every { iterator() } answers { + ranges.map { (start, end) -> + mockk { + every { this@mockk.start } returns start + every { this@mockk.end } returns end + } + }.toMutableList().listIterator() + } +} + +class PlayButtonStateTest { + @Test + fun `seeds state and reacts to playback events`() { + val mock = MockPlayer(paused = true, ended = false) + val state = PlayButtonState(mock.player) + state.observe() + + assertTrue(state.paused) + assertFalse(state.ended) + + mock.paused = false + mock.firePlayerEvent() + assertFalse(state.paused) + + mock.ended = true + mock.firePlayerEvent() + assertTrue(state.ended) + } + + @Test + fun `onClick plays when paused and pauses when playing`() { + val mock = MockPlayer(paused = true) + val state = PlayButtonState(mock.player) + state.observe() + + state.onClick() + verify(exactly = 1) { mock.player.play() } + + mock.firePlayerEvent() + state.onClick() + verify(exactly = 1) { mock.player.pause() } + } + + @Test + fun `dispose removes all listeners`() { + val mock = MockPlayer() + val state = PlayButtonState(mock.player) + state.observe() + val added = mock.playerListeners.toList() + assertTrue(added.isNotEmpty()) + + state.dispose() + assertTrue(mock.playerListeners.isEmpty()) + verify(exactly = added.size) { + mock.player.removeEventListener(any>>(), any()) + } + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = PlayButtonState(null) + state.observe() + assertTrue(state.paused) + state.onClick() + state.dispose() + } +} + +class SeekBarStateTest { + @Test + fun `computes value within zero-based range`() { + val mock = MockPlayer(currentTime = 30.0, duration = 100.0) + val state = SeekBarState(mock.player) + state.observe() + + assertEquals(0f..100f, state.valueRange) + assertEquals(30f, state.value) + assertTrue(state.enabled) + assertTrue(state.showThumb) + } + + @Test + fun `huge seekable start maps to zero-based range`() { + val mock = MockPlayer( + currentTime = 1.7e9 + 30.0, + duration = 1.7e9 + 100.0, + seekableRanges = listOf(1.7e9 to 1.7e9 + 100.0) + ) + val state = SeekBarState(mock.player) + state.observe() + + assertEquals(0f, state.valueRange.start) + assertEquals(100f, state.valueRange.endInclusive, 0.001f) + assertEquals(30f, state.value, 0.001f) + } + + @Test + fun `scrubbing pauses a playing player and resumes on finish`() { + val mock = MockPlayer(paused = false, currentTime = 10.0) + val state = SeekBarState(mock.player) + state.observe() + + 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.play() } + } + + @Test + fun `scrubbing a paused player does not resume it`() { + val mock = MockPlayer(paused = true, currentTime = 10.0) + val state = SeekBarState(mock.player) + state.observe() + + state.onValueChange(50f) + state.onValueChangeFinished() + verify(exactly = 0) { mock.player.play() } + } + + @Test + fun `disabled during ads and thumb hidden`() { + val mock = MockPlayer(adPlaying = true) + val state = SeekBarState(mock.player) + state.observe() + + assertFalse(state.enabled) + assertFalse(state.showThumb) + + mock.adPlaying = false + mock.fireAdEvent() + assertTrue(state.enabled) + assertTrue(state.showThumb) + } + + @Test + fun `dispose removes all listeners`() { + val mock = MockPlayer() + val state = SeekBarState(mock.player) + state.observe() + val added = mock.playerListeners.toList() + assertTrue(added.isNotEmpty()) + assertTrue(mock.adListeners.isNotEmpty()) + + state.dispose() + assertTrue(mock.playerListeners.isEmpty()) + assertTrue(mock.adListeners.isEmpty()) + } +} + +class PlaybackTimeStateTest { + @Test + fun `seeds and updates on time events`() { + val mock = MockPlayer(currentTime = 5.0, duration = 100.0) + val state = PlaybackTimeState(mock.player) + state.observe() + + assertEquals(5.0, state.currentTime, 0.0) + assertEquals(100.0, state.duration, 0.0) + + mock.currentTime = 42.0 + mock.firePlayerEvent() + assertEquals(42.0, state.currentTime, 0.0) + } + + @Test + fun `duration prefers end of seekable range`() { + val mock = MockPlayer( + duration = Double.POSITIVE_INFINITY, + seekableRanges = listOf(10.0 to 70.0) + ) + val state = PlaybackTimeState(mock.player) + state.observe() + + assertEquals(70.0, state.duration, 0.0) + } + + @Test + fun `reports live and dvr stream types`() { + val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) + val dvr = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 120.0)) + val vod = MockPlayer(duration = 100.0) + + assertEquals(StreamType.Live, PlaybackTimeState(live.player).apply { observe() }.streamType) + assertEquals(StreamType.Dvr, PlaybackTimeState(dvr.player).apply { observe() }.streamType) + assertEquals(StreamType.Vod, PlaybackTimeState(vod.player).apply { observe() }.streamType) + } +} + +class LiveButtonStateTest { + @Test + fun `visible for live but not for vod`() { + val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) + val vod = MockPlayer(duration = 100.0) + + assertTrue(LiveButtonState(live.player, liveThreshold = 10.0).apply { observe() }.visible) + assertFalse(LiveButtonState(vod.player, liveThreshold = 10.0).apply { observe() }.visible) + } + + @Test + fun `isLive at threshold boundary`() { + val mock = MockPlayer( + paused = false, + duration = Double.POSITIVE_INFINITY, + currentTime = 20.0, + seekableRanges = listOf(0.0 to 30.0) + ) + val atThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) + atThreshold.observe() + assertTrue(atThreshold.isLive) + + mock.currentTime = 19.9 + val behindThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) + behindThreshold.observe() + assertFalse(behindThreshold.isLive) + } + + @Test + fun `onClick seeks to live and plays`() { + val mock = MockPlayer(duration = Double.POSITIVE_INFINITY) + val state = LiveButtonState(mock.player, liveThreshold = 10.0) + state.observe() + + state.onClick() + verify { mock.player.currentTime = Double.POSITIVE_INFINITY } + verify(exactly = 1) { mock.player.play() } + } +} + +class SeekButtonStateTest { + @Test + fun `onClick seeks by offset and clamps to duration`() { + val mock = MockPlayer(currentTime = 95.0, duration = 100.0) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + state.onClick() + verify { mock.player.currentTime = 100.0 } + } + + @Test + fun `onClick clamps negative seek to zero`() { + val mock = MockPlayer(currentTime = 5.0, duration = 100.0) + val state = SeekButtonState(mock.player, seekOffset = -10) + state.observe() + + state.onClick() + verify { mock.player.currentTime = 0.0 } + } + + @Test + fun `onClick does nothing for streams without duration`() { + val mock = MockPlayer(currentTime = 50.0, duration = Double.NaN) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + state.onClick() + verify(exactly = 0) { mock.player.currentTime = any() } + } + + @Test + fun `disabled during ads`() { + val mock = MockPlayer(adPlaying = true) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + assertFalse(state.enabled) + } + + @Test + fun `null player is disabled and onClick does nothing`() { + val state = SeekButtonState(null, seekOffset = 10) + state.observe() + assertFalse(state.enabled) + state.onClick() + state.dispose() + } +} From 0d0587b3af96b5c34224cfdc658b717bf23d9443 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:30:46 +0000 Subject: [PATCH 007/158] Delegate the controls to the new state holders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/CurrentTimeDisplay.kt | 31 ++++++- .../theoplayer/android/ui/DurationDisplay.kt | 21 ++++- .../com/theoplayer/android/ui/LiveButton.kt | 84 ++++++++++++++++--- .../com/theoplayer/android/ui/PlayButton.kt | 69 +++++++++++---- .../java/com/theoplayer/android/ui/SeekBar.kt | 75 +++++++---------- .../com/theoplayer/android/ui/SeekButton.kt | 36 +++++--- 6 files changed, 231 insertions(+), 85 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt index c5187de5..d798c68d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt @@ -20,9 +20,34 @@ fun CurrentTimeDisplay( showRemaining: Boolean = false, showDuration: Boolean = false, ) { - val player = Player.current - val currentTime = player?.currentTime ?: 0.0 - val duration = player?.seekable?.lastEnd ?: player?.duration ?: Double.NaN + CurrentTimeDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier, + showRemaining = showRemaining, + showDuration = showDuration + ) +} + +/** + * A text display that shows the player's current time. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") + */ +@ExperimentalPlayerStateApi +@Composable +fun CurrentTimeDisplay( + state: PlaybackTimeState, + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false, +) { + val currentTime = state.currentTime + val duration = state.duration val time = if (showRemaining) { -(duration - currentTime) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DurationDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/DurationDisplay.kt index 3527ab30..b9e3b3f8 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DurationDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DurationDisplay.kt @@ -13,8 +13,23 @@ import androidx.compose.ui.Modifier fun DurationDisplay( modifier: Modifier = Modifier ) { - val player = Player.current - val duration = player?.seekable?.lastEnd ?: player?.duration ?: Double.NaN + DurationDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier + ) +} - Text(modifier = modifier, text = formatTime(duration)) +/** + * A text display that shows the player's duration. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text + */ +@ExperimentalPlayerStateApi +@Composable +fun DurationDisplay( + state: PlaybackTimeState, + modifier: Modifier = Modifier +) { + Text(modifier = modifier, text = formatTime(state.duration)) } \ No newline at end of file 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 ad0ad9f3..289ceedc 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt @@ -80,22 +80,82 @@ fun LiveButton( ) } ) { - val player = Player.current - if (player?.streamType == StreamType.Live || player?.streamType == StreamType.Dvr) { - val isLive = - !player.paused && ((player.seekable.lastEnd - ?: 0.0) - player.currentTime) <= liveThreshold + LiveButton( + state = rememberLiveButtonState(LocalTHEOplayerView.current?.player, liveThreshold), + modifier = modifier, + contentPadding = contentPadding, + colors = colors, + live = live, + dvr = dvr + ) +} + +/** + * A button that shows whether the player is currently playing at the live point, + * and seeks to the live point when clicked. + * + * @param state the [LiveButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param colors [ButtonColors] that will be used to resolve the colors for this button in different + * states. See [IconButtonDefaults.iconButtonColors] + * @param live button content when the player is playing at the live point, + * typically a red circle icon followed by the text "LIVE" + * @param dvr button content when the player is playing behind the live point, + * typically a gray circle icon followed by the text "LIVE" + */ +@ExperimentalPlayerStateApi +@Composable +fun LiveButton( + state: LiveButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + live: @Composable RowScope.() -> Unit = { + Icon( + Icons.Rounded.Circle, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically), + tint = THEOplayerTheme.playerColors.liveButtonLive, + contentDescription = null + ) + Spacer( + modifier = Modifier + .width(4.dp) + ) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(R.string.theoplayer_ui_btn_live) + ) + }, + dvr: @Composable RowScope. () -> Unit = { + Icon( + Icons.Rounded.Circle, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically), + tint = THEOplayerTheme.playerColors.liveButtonDvr, + contentDescription = null + ) + Spacer( + modifier = Modifier + .width(4.dp) + ) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(R.string.theoplayer_ui_btn_live) + ) + } +) { + if (state.visible) { TextButton( modifier = modifier, contentPadding = contentPadding, colors = colors.toButtonColors(), - onClick = { - player.player?.let { - it.currentTime = Double.POSITIVE_INFINITY - it.play() - } - }) { - if (isLive) { + onClick = { state.onClick() }) { + if (state.isLive) { live() } else { dvr() diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt index 9e448fd7..c039b516 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt @@ -48,24 +48,65 @@ fun PlayButton( ) } ) { - val player = Player.current + PlayButton( + state = rememberPlayButtonState(LocalTHEOplayerView.current?.player), + modifier = modifier, + iconModifier = iconModifier, + contentPadding = contentPadding, + play = play, + pause = pause, + replay = replay + ) +} + +/** + * A button that toggles whether the player is playing or paused. + * + * @param state the [PlayButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param iconModifier the [Modifier] to be applied to the [Icon]s inside this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param play button content when the player is paused but not ended, typically a "play" icon + * @param pause button content when the player is playing, typically a "pause" icon + * @param replay button content when the player is paused and ended, typically a "replay" icon + */ +@ExperimentalPlayerStateApi +@Composable +fun PlayButton( + state: PlayButtonState, + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + play: @Composable () -> Unit = { + Icon( + painter = painterResource(id = R.drawable.play), + modifier = iconModifier, + contentDescription = stringResource(id = R.string.theoplayer_ui_btn_play) + ) + }, + pause: @Composable () -> Unit = { + Icon( + painter = painterResource(id = R.drawable.pause), + modifier = iconModifier, + contentDescription = stringResource(id = R.string.theoplayer_ui_btn_pause) + ) + }, + replay: @Composable () -> Unit = { + Icon( + Icons.Rounded.Replay, + modifier = iconModifier, + contentDescription = stringResource(id = R.string.theoplayer_ui_btn_replay) + ) + } +) { IconButton( modifier = modifier, contentPadding = contentPadding, - onClick = { - player?.let { - if (it.paused) { - it.play() - } else { - it.pause() - } - } - }) { - if (player == null) { - play() - } else if (!player.paused) { + onClick = { state.onClick() }) { + if (!state.paused) { pause() - } else if (player.ended) { + } else if (state.ended) { replay() } else { 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 2fc386e8..004e9770 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -19,11 +19,8 @@ import androidx.compose.material3.SliderDefaults import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color @@ -39,44 +36,51 @@ import androidx.compose.ui.unit.dp * @param colors [SliderColors] that will be used to resolve the colors used for this seek bar in * different states. See [SliderDefaults.colors]. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SeekBar( modifier: Modifier = Modifier, colors: SliderColors = SliderDefaults.colors() ) { - val player = Player.current - val currentTime = player?.currentTime ?: 0.0 - val seekable = player?.seekable ?: TimeRanges.empty() - val duration = player?.duration ?: Double.NaN - val enabled = player?.canSeek ?: false - val playingAd = player?.playingAd ?: false - - val seekableRange = remember(seekable, duration) { - seekable.bounds ?: run { - 0.0..(if (duration.isFinite()) duration.coerceAtLeast(0.0) else 0.0) - } - } - var seekTime by remember { mutableStateOf(null) } - var wasPlayingBeforeSeek by remember { mutableStateOf(false) } + SeekBar( + state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + modifier = modifier, + colors = colors + ) +} +/** + * A seek bar showing the current time of the player, and which seeks the player when clicked or dragged. + * + * While the user is dragging the seek bar, the player is temporarily paused. + * + * @param state the [SeekBarState] to read and control + * @param modifier the [Modifier] to be applied to this seek bar + * @param colors [SliderColors] that will be used to resolve the colors used for this seek bar in + * different states. See [SliderDefaults.colors]. + */ +@ExperimentalPlayerStateApi +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SeekBar( + state: SeekBarState, + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors() +) { val interactionSource = remember { MutableInteractionSource() } Slider( modifier = modifier.systemGestureExclusion(), colors = colors, - // 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. - value = seekTime ?: (currentTime - seekableRange.start).toFloat(), - valueRange = 0f..(seekableRange.endInclusive - seekableRange.start).toFloat(), - enabled = enabled, + value = state.value, + valueRange = state.valueRange, + enabled = state.enabled, interactionSource = interactionSource, thumb = { - if (!playingAd) { + if (state.showThumb) { SeekBarThumb( interactionSource = interactionSource, colors = colors, - enabled = enabled + enabled = state.enabled ) } }, @@ -84,7 +88,7 @@ fun SeekBar( SliderDefaults.Track( modifier = Modifier.height(4.dp), colors = colors, - enabled = enabled, + enabled = state.enabled, sliderState = sliderState, // Don't draw the stop indicator at the end of the track drawStopIndicator = {}, @@ -92,23 +96,8 @@ fun SeekBar( thumbTrackGapSize = 0.dp ) }, - onValueChange = { time -> - seekTime = time - player?.player?.let { - if (!it.isPaused) { - wasPlayingBeforeSeek = true - it.pause() - } - it.currentTime = seekableRange.start + time.toDouble() - } - }, - onValueChangeFinished = { - seekTime = null - if (wasPlayingBeforeSeek) { - player?.player?.play() - wasPlayingBeforeSeek = false - } - } + onValueChange = state::onValueChange, + onValueChangeFinished = state::onValueChangeFinished ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index 5aeca4ef..e986916d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -87,19 +87,35 @@ fun SeekButton( contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable () -> Unit ) { - val player = Player.current - val enabled = player?.canSeek ?: false + SeekButton( + state = rememberSeekButtonState(LocalTHEOplayerView.current?.player, seekOffset), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +/** + * A button that seeks forward or backward by a fixed offset. + * + * @param state the [SeekButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + */ +@ExperimentalPlayerStateApi +@Composable +fun SeekButton( + state: SeekButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit +) { IconButton( modifier = modifier, contentPadding = contentPadding, - enabled = enabled, - onClick = { - player?.player?.let { - if (!it.duration.isNaN()) { - it.currentTime = (it.currentTime + seekOffset).coerceIn(0.0, it.duration) - } - } - }, + enabled = state.enabled, + onClick = { state.onClick() }, content = content ) } From 56907dec8935b48da720d8e76ca3cc381db5852f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:30:46 +0000 Subject: [PATCH 008/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8cf492..da1a72f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From d87e70db65365ac7fd1aee2b59aec718cbccf63d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:39:26 +0000 Subject: [PATCH 009/158] Clarify that the cast check in canSeek is only needed for THEOplayer 7.11 and lower Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-common/src/main/java/com/theoplayer/android/ui/Player.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index 01e93897..890d18a6 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -370,8 +370,8 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { false } else { seekable.isNotEmpty() || run { - // `player.seekable` is (incorrectly) empty while casting, see #35 - // Temporary fix: always allow seeking while casting. + // On THEOplayer 7.11 and lower, `player.seekable` is (incorrectly) empty while casting, see #35. + // Always allow seeking while casting. This is no longer needed on THEOplayer 7.12.0 or higher. castState == PlayerCastState.CONNECTED } } From 0e37a90aa5f28d502acfb1626e9ca4c785c0baf1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:42:03 +0000 Subject: [PATCH 010/158] Update state holders on the same events as Player Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/LiveButtonState.kt | 23 +-- .../theoplayer/android/ui/PlayButtonState.kt | 4 + .../android/ui/PlaybackTimeState.kt | 21 +-- .../java/com/theoplayer/android/ui/Player.kt | 26 +-- .../com/theoplayer/android/ui/SeekBarState.kt | 33 ++-- .../theoplayer/android/ui/SeekButtonState.kt | 7 + .../theoplayer/android/ui/PlayerStateTest.kt | 167 ++++++++++++++++-- 7 files changed, 208 insertions(+), 73 deletions(-) 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 b6f21fd9..b7ccb985 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 @@ -11,8 +11,6 @@ import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player -private const val DVR_WINDOW_THRESHOLD = 60.0 - /** * State holder for a "live" button, such as [LiveButton]. * @@ -43,17 +41,7 @@ class LiveButtonState( private var duration by mutableStateOf(Double.NaN) private val streamType: StreamType - get() = if (duration.isInfinite()) { - val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 - // TODO Make DVR threshold configurable? - if (slidingWindow >= DVR_WINDOW_THRESHOLD) { - StreamType.Dvr - } else { - StreamType.Live - } - } else { - StreamType.Vod - } + get() = streamTypeOf(duration, seekable) private fun updatePlaybackState() { paused = player?.isPaused ?: true @@ -71,12 +59,15 @@ class LiveButtonState( update() listeners.run { player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.PLAY, EventListener> { update() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) + player.add(PlayerEventTypes.ENDED, EventListener> { update() }) player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) - player.add(PlayerEventTypes.PLAY, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.ERROR, EventListener> { update() }) } } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt index 86df260d..2f2b451b 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -44,7 +44,11 @@ class PlayButtonState(private val player: Player?) { player.add(PlayerEventTypes.PLAY, EventListener> { updatePlaybackState() }) player.add(PlayerEventTypes.PAUSE, EventListener> { updatePlaybackState() }) player.add(PlayerEventTypes.ENDED, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { updatePlaybackState() }) player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { updatePlaybackState() }) + player.add(PlayerEventTypes.ERROR, EventListener> { updatePlaybackState() }) } } 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 bc0e6d09..5132262a 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 @@ -11,8 +11,6 @@ import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player -private const val DVR_WINDOW_THRESHOLD = 60.0 - /** * State holder for a playback time display, such as [CurrentTimeDisplay] or [DurationDisplay]. * @@ -43,17 +41,7 @@ class PlaybackTimeState(private val player: Player?) { * The [StreamType] of the media. */ val streamType: StreamType - get() = if (rawDuration.isInfinite()) { - val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 - // TODO Make DVR threshold configurable? - if (slidingWindow >= DVR_WINDOW_THRESHOLD) { - StreamType.Dvr - } else { - StreamType.Live - } - } else { - StreamType.Vod - } + get() = streamTypeOf(rawDuration, seekable) private fun update() { currentTime = player?.currentTime ?: 0.0 @@ -67,10 +55,15 @@ class PlaybackTimeState(private val player: Player?) { update() listeners.run { player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.PLAY, EventListener> { update() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) + player.add(PlayerEventTypes.ENDED, EventListener> { update() }) player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) + player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.ERROR, EventListener> { update() }) } } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index 890d18a6..211b501b 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -332,6 +332,20 @@ enum class StreamType { Dvr } +// TODO Make DVR threshold configurable? +private const val DVR_WINDOW_THRESHOLD = 60.0 + +/** + * Returns the [StreamType] for a stream with the given duration and seekable range. + */ +internal fun streamTypeOf(duration: Double, seekable: TimeRanges): StreamType { + if (duration.isInfinite()) { + val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 + return if (slidingWindow >= DVR_WINDOW_THRESHOLD) StreamType.Dvr else StreamType.Live + } + return StreamType.Vod +} + /** * Internal implementation of [Player]. * @@ -558,17 +572,7 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { } override val streamType by derivedStateOf { - if (duration.isInfinite()) { - val slidingWindow = seekable.bounds?.let { it.endInclusive - it.start } ?: 0.0 - // TODO Make DVR threshold configurable? - if (slidingWindow >= 60) { - StreamType.Dvr - } else { - StreamType.Live - } - } else { - StreamType.Vod - } + streamTypeOf(duration, seekable) } private var activeVideoTrack: MediaTrack? = null 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 793fb02e..1331693b 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 @@ -63,36 +63,29 @@ class SeekBarState(private val player: Player?) { 0.0..(if (duration.isFinite()) duration.coerceAtLeast(0.0) else 0.0) } - private fun updateCurrentTime() { + private fun update() { currentTime = player?.currentTime ?: 0.0 seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() - } - - private fun updateDuration() { duration = player?.duration ?: Double.NaN - } - - private fun updatePlayingAd() { playingAd = player?.ads?.isPlaying ?: false } internal fun observe() { val player = player ?: return - updateCurrentTime() - updateDuration() - updatePlayingAd() + update() listeners.run { - player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { updateCurrentTime() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { updateDuration() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { updateCurrentTime() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { updateCurrentTime() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { - updateCurrentTime() - updateDuration() - updatePlayingAd() - }) + player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) + player.add(PlayerEventTypes.PLAY, EventListener> { update() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) + player.add(PlayerEventTypes.ENDED, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) + player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.ERROR, EventListener> { update() }) player.ads?.let { ads -> - val adListener = EventListener> { updatePlayingAd() } + val adListener = EventListener> { update() } ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) ads.add(AdsEventTypes.AD_SKIP, adListener) ads.add(AdsEventTypes.AD_BREAK_END, adListener) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt index 8a80ff86..9393e4c8 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -45,7 +45,14 @@ class SeekButtonState( update() listeners.run { player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) + player.add(PlayerEventTypes.PLAY, EventListener> { update() }) + player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) + player.add(PlayerEventTypes.ENDED, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) + player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) + player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) + player.add(PlayerEventTypes.ERROR, EventListener> { update() }) player.ads?.let { ads -> val adListener = EventListener> { update() } ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt index 56e63b99..8cbfa508 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -31,21 +31,21 @@ private class MockPlayer( var seekableRanges: List> = listOf(0.0 to 100.0), var adPlaying: Boolean = false, ) { - val playerListeners = mutableListOf>() - val adListeners = mutableListOf>() + val playerListeners = mutableListOf, EventListener<*>>>() + val adListeners = mutableListOf, EventListener<*>>>() val ads: Ads = mockk { every { isPlaying } answers { this@MockPlayer.adPlaying } every { addEventListener(any>>(), any()) } answers { - adListeners.add(secondArg>>()) + adListeners.add(firstArg>() to secondArg>()) Unit } every { removeEventListener(any>>(), any()) } answers { - adListeners.remove(secondArg>>()) + adListeners.remove(firstArg>() to secondArg>()) Unit } } @@ -63,28 +63,36 @@ private class MockPlayer( every { addEventListener(any>>(), any()) } answers { - playerListeners.add(secondArg>>()) + playerListeners.add(firstArg>() to secondArg>()) Unit } every { removeEventListener(any>>(), any()) } answers { - playerListeners.remove(secondArg>>()) + playerListeners.remove(firstArg>() to secondArg>()) Unit } } - fun firePlayerEvent() { - playerListeners.toList().forEach { - @Suppress("UNCHECKED_CAST") - (it as EventListener>).handleEvent(mockk()) + val playerEventTypes: Set> + get() = playerListeners.map { it.first }.toSet() + + val adEventTypes: Set> + get() = adListeners.map { it.first }.toSet() + + fun firePlayerEvent(type: EventType<*>? = null) { + playerListeners.toList().forEach { (eventType, listener) -> + if (type == null || eventType == type) { + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } } } fun fireAdEvent() { - adListeners.toList().forEach { + adListeners.toList().forEach { (_, listener) -> @Suppress("UNCHECKED_CAST") - (it as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent(mockk()) } } } @@ -100,7 +108,45 @@ private fun mockTimeRanges(ranges: List>): TimeRanges = moc } } +private val playbackEventTypes = setOf( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR +) + +private val adEventTypes = setOf( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END +) + class PlayButtonStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + PlayButtonState(mock.player).observe() + assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) + } + + @Test + fun `a seeked event clears ended`() { + val mock = MockPlayer(paused = true, ended = true) + val state = PlayButtonState(mock.player) + state.observe() + assertTrue(state.ended) + + mock.ended = false + mock.firePlayerEvent(PlayerEventTypes.SEEKED) + assertFalse(state.ended) + assertTrue(state.paused) + } + @Test fun `seeds state and reacts to playback events`() { val mock = MockPlayer(paused = true, ended = false) @@ -148,6 +194,25 @@ class PlayButtonStateTest { } } + @Test + fun `dispose removes the identical listener instances`() { + val mock = MockPlayer() + val state = PlayButtonState(mock.player) + state.observe() + val added = mock.playerListeners.toList() + + state.dispose() + added.forEach { (type, listener) -> + @Suppress("UNCHECKED_CAST") + verify { + mock.player.removeEventListener( + type as EventType>, + listener as EventListener> + ) + } + } + } + @Test fun `null player gives defaults and no listeners`() { val state = PlayButtonState(null) @@ -159,6 +224,30 @@ class PlayButtonStateTest { } class SeekBarStateTest { + @Test + fun `registers the playback and ad events`() { + val mock = MockPlayer() + SeekBarState(mock.player).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + assertEquals(adEventTypes, mock.adEventTypes) + } + + @Test + fun `an error event re-reads playingAd`() { + val mock = MockPlayer(adPlaying = false) + val state = SeekBarState(mock.player) + state.observe() + assertTrue(state.showThumb) + + mock.adPlaying = true + mock.firePlayerEvent(PlayerEventTypes.ERROR) + assertFalse(state.enabled) + assertFalse(state.showThumb) + } + @Test fun `computes value within zero-based range`() { val mock = MockPlayer(currentTime = 30.0, duration = 100.0) @@ -243,6 +332,42 @@ class SeekBarStateTest { } class PlaybackTimeStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + PlaybackTimeState(mock.player).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + } + + @Test + fun `streamTypeOf classifies vod, live and dvr`() { + assertEquals( + StreamType.Vod, + streamTypeOf(100.0, com.theoplayer.android.ui.TimeRanges(listOf(0.0..100.0))) + ) + assertEquals( + StreamType.Vod, + streamTypeOf(Double.NaN, com.theoplayer.android.ui.TimeRanges.empty()) + ) + assertEquals( + StreamType.Live, + streamTypeOf( + Double.POSITIVE_INFINITY, + com.theoplayer.android.ui.TimeRanges(listOf(0.0..30.0)) + ) + ) + assertEquals( + StreamType.Dvr, + streamTypeOf( + Double.POSITIVE_INFINITY, + com.theoplayer.android.ui.TimeRanges(listOf(0.0..60.0)) + ) + ) + } + @Test fun `seeds and updates on time events`() { val mock = MockPlayer(currentTime = 5.0, duration = 100.0) @@ -282,6 +407,16 @@ class PlaybackTimeStateTest { } class LiveButtonStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + LiveButtonState(mock.player, liveThreshold = 10.0).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + } + @Test fun `visible for live but not for vod`() { val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) @@ -322,6 +457,14 @@ class LiveButtonStateTest { } class SeekButtonStateTest { + @Test + fun `registers the playback and ad events`() { + val mock = MockPlayer() + SeekButtonState(mock.player, seekOffset = 10).observe() + assertEquals(playbackEventTypes, mock.playerEventTypes) + assertEquals(adEventTypes, mock.adEventTypes) + } + @Test fun `onClick seeks by offset and clamps to duration`() { val mock = MockPlayer(currentTime = 95.0, duration = 100.0) From 681b388de4bbeb3c504087c80446730a99df14bd Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:42:42 +0000 Subject: [PATCH 011/158] 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 da1a72f3..f2ffed67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. +* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 6446254902404ed3a13c6ceea2f54a38dfd6db98 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 12 Oct 2026 19:56:22 +0000 Subject: [PATCH 012/158] Share one event listener per state holder Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/LiveButtonState.kt | 21 ++++++++++--------- .../theoplayer/android/ui/PlayButtonState.kt | 17 ++++++++------- .../android/ui/PlaybackTimeState.kt | 21 ++++++++++--------- .../com/theoplayer/android/ui/SeekBarState.kt | 21 ++++++++++--------- .../theoplayer/android/ui/SeekButtonState.kt | 19 +++++++++-------- .../theoplayer/android/ui/PlayerStateTest.kt | 7 +++++-- 6 files changed, 57 insertions(+), 49 deletions(-) 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 b7ccb985..0710ac65 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 @@ -58,16 +58,17 @@ class LiveButtonState( val player = player ?: return update() listeners.run { - player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.PLAY, EventListener> { update() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) - player.add(PlayerEventTypes.ENDED, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) - player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.ERROR, EventListener> { update() }) + val listener = EventListener> { update() } + player.add(PlayerEventTypes.TIMEUPDATE, listener) + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.DURATIONCHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) } } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt index 2f2b451b..1c256fb7 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -41,14 +41,15 @@ class PlayButtonState(private val player: Player?) { val player = player ?: return updatePlaybackState() listeners.run { - player.add(PlayerEventTypes.PLAY, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.ENDED, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { updatePlaybackState() }) - player.add(PlayerEventTypes.ERROR, EventListener> { updatePlaybackState() }) + val listener = EventListener> { updatePlaybackState() } + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) } } 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 5132262a..529e9efa 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 @@ -54,16 +54,17 @@ class PlaybackTimeState(private val player: Player?) { val player = player ?: return update() listeners.run { - player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.PLAY, EventListener> { update() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) - player.add(PlayerEventTypes.ENDED, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) - player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.ERROR, EventListener> { update() }) + val listener = EventListener> { update() } + player.add(PlayerEventTypes.TIMEUPDATE, listener) + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.DURATIONCHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) } } 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 1331693b..d429b7dd 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 @@ -74,16 +74,17 @@ class SeekBarState(private val player: Player?) { val player = player ?: return update() listeners.run { - player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.PLAY, EventListener> { update() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) - player.add(PlayerEventTypes.ENDED, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) - player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.DURATIONCHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.ERROR, EventListener> { update() }) + val listener = EventListener> { update() } + player.add(PlayerEventTypes.TIMEUPDATE, listener) + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.DURATIONCHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) player.ads?.let { ads -> val adListener = EventListener> { update() } ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt index 9393e4c8..2172927a 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -44,15 +44,16 @@ class SeekButtonState( val player = player ?: return update() listeners.run { - player.add(PlayerEventTypes.TIMEUPDATE, EventListener> { update() }) - player.add(PlayerEventTypes.PLAY, EventListener> { update() }) - player.add(PlayerEventTypes.PAUSE, EventListener> { update() }) - player.add(PlayerEventTypes.ENDED, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKING, EventListener> { update() }) - player.add(PlayerEventTypes.SEEKED, EventListener> { update() }) - player.add(PlayerEventTypes.READYSTATECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.SOURCECHANGE, EventListener> { update() }) - player.add(PlayerEventTypes.ERROR, EventListener> { update() }) + val listener = EventListener> { update() } + player.add(PlayerEventTypes.TIMEUPDATE, listener) + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) player.ads?.let { ads -> val adListener = EventListener> { update() } ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt index 8cbfa508..97c8b372 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -195,14 +195,17 @@ class PlayButtonStateTest { } @Test - fun `dispose removes the identical listener instances`() { + fun `shares one listener instance and removes it for every type`() { val mock = MockPlayer() val state = PlayButtonState(mock.player) state.observe() + val added = mock.playerListeners.toList() + val listener = added.first().second + assertTrue(added.all { it.second === listener }) state.dispose() - added.forEach { (type, listener) -> + added.forEach { (type, _) -> @Suppress("UNCHECKED_CAST") verify { mock.player.removeEventListener( From ed7ca6ab2ce9e5f4cf428db83e36fbf0c15fdb0e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:05:59 +0000 Subject: [PATCH 013/158] Make state holder constructors internal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/main/java/com/theoplayer/android/ui/LiveButtonState.kt | 2 +- .../src/main/java/com/theoplayer/android/ui/PlayButtonState.kt | 2 +- .../main/java/com/theoplayer/android/ui/PlaybackTimeState.kt | 2 +- .../src/main/java/com/theoplayer/android/ui/SeekBarState.kt | 2 +- .../src/main/java/com/theoplayer/android/ui/SeekButtonState.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 0710ac65..b39585d6 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 @@ -17,7 +17,7 @@ import com.theoplayer.android.api.player.Player * Create an instance with [rememberLiveButtonState]. */ @ExperimentalPlayerStateApi -class LiveButtonState( +class LiveButtonState internal constructor( private val player: Player?, private val liveThreshold: Double ) { diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt index 1c256fb7..ac7ffa42 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -17,7 +17,7 @@ import com.theoplayer.android.api.player.Player * Create an instance with [rememberPlayButtonState]. */ @ExperimentalPlayerStateApi -class PlayButtonState(private val player: Player?) { +class PlayButtonState internal constructor(private val player: Player?) { private val listeners = PlayerStateListeners() /** 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 529e9efa..ff41965a 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 @@ -17,7 +17,7 @@ import com.theoplayer.android.api.player.Player * Create an instance with [rememberPlaybackTimeState]. */ @ExperimentalPlayerStateApi -class PlaybackTimeState(private val player: Player?) { +class PlaybackTimeState internal constructor(private val player: Player?) { private val listeners = PlayerStateListeners() /** 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 d429b7dd..a55ca809 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 @@ -22,7 +22,7 @@ import com.theoplayer.android.api.player.Player * Create an instance with [rememberSeekBarState]. */ @ExperimentalPlayerStateApi -class SeekBarState(private val player: Player?) { +class SeekBarState internal constructor(private val player: Player?) { private val listeners = PlayerStateListeners() /** diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt index 2172927a..9adc9992 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -20,7 +20,7 @@ import com.theoplayer.android.api.player.Player * Create an instance with [rememberSeekButtonState]. */ @ExperimentalPlayerStateApi -class SeekButtonState( +class SeekButtonState internal constructor( private val player: Player?, private val seekOffset: Int ) { From 3c473bbb39c3e5bcddbd1e7f5e85fd092e90cd7f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:33:30 +0000 Subject: [PATCH 014/158] Add state holders for the mute, loading, error, first play, video size and ad states Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/AdState.kt | 68 +++++ .../com/theoplayer/android/ui/ErrorState.kt | 84 ++++++ .../theoplayer/android/ui/FirstPlayState.kt | 74 +++++ .../com/theoplayer/android/ui/LoadingState.kt | 76 ++++++ .../theoplayer/android/ui/MuteButtonState.kt | 70 +++++ .../theoplayer/android/ui/VideoSizeState.kt | 68 +++++ .../theoplayer/android/ui/PlayerStateTest.kt | 256 +++++++++++++++++- 7 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt new file mode 100644 index 00000000..20faba8c --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt @@ -0,0 +1,68 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for UI that depends on whether an ad is playing. + * + * Create an instance with [rememberAdState]. + */ +@ExperimentalPlayerStateApi +class AdState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * Whether an ad is currently playing. + */ + var playingAd by mutableStateOf(false) + private set + + private fun update() { + playingAd = player?.ads?.isPlaying ?: false + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) + player.ads?.let { ads -> + val adListener = EventListener> { update() } + ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) + ads.add(AdsEventTypes.AD_SKIP, adListener) + ads.add(AdsEventTypes.AD_BREAK_END, adListener) + } + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates an [AdState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberAdState(player: Player?): AdState { + val state = remember(player) { AdState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt new file mode 100644 index 00000000..21e4b605 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt @@ -0,0 +1,84 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.error.THEOplayerException +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.ErrorEvent +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for an error display, such as [ErrorDisplay]. + * + * The error is only captured from the player's `error` event. Create this state *before* + * an error can happen, for example at the top of your UI instead of inside the error overlay + * that is only shown once there's an error, because a state created after the error + * will not see it. + * + * Create an instance with [rememberErrorState]. + */ +@ExperimentalPlayerStateApi +class ErrorState internal constructor( + private val player: Player?, + private val initialError: THEOplayerException? = null +) { + private val listeners = PlayerStateListeners() + + /** + * The fatal error the player has encountered, if any. + */ + var error by mutableStateOf(null) + private set + + internal fun observe() { + val player = player ?: return + error = initialError + listeners.run { + val listener = EventListener> { event -> + error = (event as? ErrorEvent)?.errorObject + } + player.add(PlayerEventTypes.ERROR, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates an [ErrorState] that's remembered across compositions. + * + * The error is only captured from the player's `error` event, so create this state *before* + * an error can happen. For example, create it at the top of your UI and pass it down to the + * error overlay, instead of creating it inside the error overlay that is only shown + * once there's an error. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberErrorState(player: Player?): ErrorState = + rememberErrorState(player, initialError = null) + +/** + * Creates an [ErrorState] that's remembered across compositions, seeded with a known error. + * + * @suppress + */ +@InternalUiApi +@ExperimentalPlayerStateApi +@Composable +fun rememberErrorState(player: Player?, initialError: THEOplayerException?): ErrorState { + val state = remember(player) { ErrorState(player, initialError) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt new file mode 100644 index 00000000..149fa3df --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt @@ -0,0 +1,74 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.event.player.SourceChangeEvent +import com.theoplayer.android.api.player.Player + +/** + * State holder for UI that should only appear once playback has started for the first time. + * + * Create an instance with [rememberFirstPlayState]. + */ +@ExperimentalPlayerStateApi +class FirstPlayState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * Whether playback has started for the first time. + */ + var firstPlay by mutableStateOf(false) + private set + + private fun updatePlaying() { + if (player?.isPaused == false) { + firstPlay = true + } + } + + internal fun observe() { + val player = player ?: return + firstPlay = !player.isPaused || (player.played?.length() ?: 0) > 0 + listeners.run { + val listener = EventListener> { event -> + if (event is SourceChangeEvent) { + firstPlay = false + } + updatePlaying() + } + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates a [FirstPlayState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberFirstPlayState(player: Player?): FirstPlayState { + val state = remember(player) { FirstPlayState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt new file mode 100644 index 00000000..8c2e2940 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt @@ -0,0 +1,76 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.ReadyState + +/** + * State holder for a loading indicator, such as [LoadingSpinner]. + * + * Create an instance with [rememberLoadingState]. + */ +@ExperimentalPlayerStateApi +class LoadingState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * Whether the player is currently waiting for more data to resume playback. + */ + val loading: Boolean + get() = !paused && !ended && + (seeking || readyState.ordinal < ReadyState.HAVE_FUTURE_DATA.ordinal) + + private var paused by mutableStateOf(true) + private var ended by mutableStateOf(false) + private var seeking by mutableStateOf(false) + private var readyState by mutableStateOf(ReadyState.HAVE_NOTHING) + + private fun update() { + paused = player?.isPaused ?: true + ended = player?.isEnded ?: false + seeking = player?.isSeeking ?: false + readyState = player?.readyState ?: ReadyState.HAVE_NOTHING + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.PLAY, listener) + player.add(PlayerEventTypes.PAUSE, listener) + player.add(PlayerEventTypes.ENDED, listener) + player.add(PlayerEventTypes.SEEKING, listener) + player.add(PlayerEventTypes.SEEKED, listener) + player.add(PlayerEventTypes.READYSTATECHANGE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates a [LoadingState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberLoadingState(player: Player?): LoadingState { + val state = remember(player) { LoadingState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt new file mode 100644 index 00000000..0de45c2a --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt @@ -0,0 +1,70 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for a mute button, such as [MuteButton]. + * + * Create an instance with [rememberMuteButtonState]. + */ +@ExperimentalPlayerStateApi +class MuteButtonState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * Whether the audio is muted. + */ + var muted by mutableStateOf(false) + private set + + private fun update() { + muted = player?.isMuted ?: false + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.VOLUMECHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Toggles whether the audio is muted. + */ + fun onClick() { + player?.let { + val newMuted = !it.isMuted + muted = newMuted + it.isMuted = newMuted + } + } +} + +/** + * Creates a [MuteButtonState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberMuteButtonState(player: Player?): MuteButtonState { + val state = remember(player) { MuteButtonState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt new file mode 100644 index 00000000..92271402 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt @@ -0,0 +1,68 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for the video dimensions. + * + * Create an instance with [rememberVideoSizeState]. + */ +@ExperimentalPlayerStateApi +class VideoSizeState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The width of the video, in pixels. + */ + var videoWidth by mutableIntStateOf(0) + private set + + /** + * The height of the video, in pixels. + */ + var videoHeight by mutableIntStateOf(0) + private set + + private fun update() { + videoWidth = player?.videoWidth ?: 0 + videoHeight = player?.videoHeight ?: 0 + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.RESIZE, listener) + player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.add(PlayerEventTypes.ERROR, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } +} + +/** + * Creates a [VideoSizeState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberVideoSizeState(player: Player?): VideoSizeState { + val state = remember(player) { VideoSizeState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt index 97c8b372..8497163f 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -6,9 +6,13 @@ import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.EventType import com.theoplayer.android.api.event.ads.AdEvent import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.event.player.SourceChangeEvent import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.ReadyState import com.theoplayer.android.api.timerange.TimeRange import com.theoplayer.android.api.timerange.TimeRanges import io.mockk.every @@ -30,7 +34,14 @@ private class MockPlayer( var duration: Double = 100.0, var seekableRanges: List> = listOf(0.0 to 100.0), var adPlaying: Boolean = false, + var muted: Boolean = false, + var seeking: Boolean = false, + var readyState: ReadyState = ReadyState.HAVE_NOTHING, + var videoWidth: Int = 0, + var videoHeight: Int = 0, + var playedLength: Int = 0, ) { + var errorObject: THEOplayerException = mockk() val playerListeners = mutableListOf, EventListener<*>>>() val adListeners = mutableListOf, EventListener<*>>>() @@ -60,6 +71,15 @@ private class MockPlayer( every { setCurrentTime(any()) } answers { this@MockPlayer.currentTime = firstArg() } every { play() } answers { this@MockPlayer.paused = false } every { pause() } answers { this@MockPlayer.paused = true } + every { isMuted } answers { this@MockPlayer.muted } + every { setMuted(any()) } answers { this@MockPlayer.muted = firstArg() } + every { isSeeking } answers { this@MockPlayer.seeking } + every { getReadyState() } answers { this@MockPlayer.readyState } + every { getVideoWidth() } answers { this@MockPlayer.videoWidth } + every { getVideoHeight() } answers { this@MockPlayer.videoHeight } + every { getPlayed() } answers { + mockk { every { length() } returns this@MockPlayer.playedLength } + } every { addEventListener(any>>(), any()) } answers { @@ -80,15 +100,25 @@ private class MockPlayer( val adEventTypes: Set> get() = adListeners.map { it.first }.toSet() - fun firePlayerEvent(type: EventType<*>? = null) { + fun firePlayerEvent(type: EventType<*>? = null, event: PlayerEvent<*>? = null) { playerListeners.toList().forEach { (eventType, listener) -> if (type == null || eventType == type) { @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent( + event ?: mockEvent(eventType) + ) } } } + private fun mockEvent(type: EventType<*>): PlayerEvent<*> = when (type) { + PlayerEventTypes.ERROR -> mockk { + every { errorObject } returns this@MockPlayer.errorObject + } + PlayerEventTypes.SOURCECHANGE -> mockk() + else -> mockk() + } + fun fireAdEvent() { adListeners.toList().forEach { (_, listener) -> @Suppress("UNCHECKED_CAST") @@ -516,3 +546,225 @@ class SeekButtonStateTest { state.dispose() } } + +class MuteButtonStateTest { + @Test + fun `registers only the volume change event`() { + val mock = MockPlayer() + MuteButtonState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.VOLUMECHANGE), mock.playerEventTypes) + } + + @Test + fun `seeds muted and updates on volume change`() { + val mock = MockPlayer(muted = true) + val state = MuteButtonState(mock.player) + state.observe() + assertTrue(state.muted) + + mock.muted = false + mock.firePlayerEvent(PlayerEventTypes.VOLUMECHANGE) + assertFalse(state.muted) + } + + @Test + fun `onClick toggles player and state`() { + val mock = MockPlayer(muted = false) + val state = MuteButtonState(mock.player) + state.observe() + + state.onClick() + verify { mock.player.isMuted = true } + assertTrue(state.muted) + } + + @Test + fun `null player gives defaults and onClick does nothing`() { + val state = MuteButtonState(null) + state.observe() + assertFalse(state.muted) + state.onClick() + state.dispose() + } +} + +class LoadingStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + LoadingState(mock.player).observe() + assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) + } + + @Test + fun `loading truth table`() { + fun state(paused: Boolean, ended: Boolean = false, seeking: Boolean = false, readyState: ReadyState) = + LoadingState(MockPlayer(paused = paused, ended = ended, seeking = seeking, readyState = readyState).player) + .apply { observe() }.loading + + assertFalse(state(paused = false, readyState = ReadyState.HAVE_ENOUGH_DATA)) + assertTrue(state(paused = false, readyState = ReadyState.HAVE_CURRENT_DATA)) + assertTrue(state(paused = false, seeking = true, readyState = ReadyState.HAVE_ENOUGH_DATA)) + assertFalse(state(paused = true, readyState = ReadyState.HAVE_CURRENT_DATA)) + assertFalse(state(paused = false, ended = true, readyState = ReadyState.HAVE_CURRENT_DATA)) + } +} + +class ErrorStateTest { + @Test + fun `registers error and sourcechange`() { + val mock = MockPlayer() + ErrorState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE), mock.playerEventTypes) + } + + @Test + fun `error event sets error and sourcechange clears it`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(mock.player) + state.observe() + assertEquals(null, state.error) + + mock.errorObject = exception + mock.firePlayerEvent(PlayerEventTypes.ERROR) + assertEquals(exception, state.error) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertEquals(null, state.error) + } + + @Test + fun `seeds initial error and sourcechange clears it`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(mock.player, initialError = exception) + state.observe() + assertEquals(exception, state.error) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertEquals(null, state.error) + } + + @Test + fun `null player with initial error stays null`() { + val state = ErrorState(null, initialError = mockk()) + state.observe() + assertEquals(null, state.error) + state.dispose() + } +} + +class FirstPlayStateTest { + @Test + fun `seeds false when paused with empty played`() { + val mock = MockPlayer(paused = true, playedLength = 0) + val state = FirstPlayState(mock.player) + state.observe() + assertFalse(state.firstPlay) + } + + @Test + fun `seeds true when paused with non-empty played`() { + val mock = MockPlayer(paused = true, playedLength = 1) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + } + + @Test + fun `seeds true when playing`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + } + + @Test + fun `play sets firstPlay`() { + val mock = MockPlayer(paused = true) + val state = FirstPlayState(mock.player) + state.observe() + + mock.paused = false + mock.firePlayerEvent(PlayerEventTypes.PLAY) + assertTrue(state.firstPlay) + } + + @Test + fun `sourcechange resets firstPlay while paused`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + + mock.paused = true + mock.playedLength = 0 + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertFalse(state.firstPlay) + } + + @Test + fun `sourcechange keeps firstPlay while playing`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertTrue(state.firstPlay) + } +} + +class VideoSizeStateTest { + @Test + fun `registers resize sourcechange and error`() { + val mock = MockPlayer() + VideoSizeState(mock.player).observe() + assertEquals( + setOf(PlayerEventTypes.RESIZE, PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), + mock.playerEventTypes + ) + } + + @Test + fun `seeds size and updates on resize`() { + val mock = MockPlayer(videoWidth = 1920, videoHeight = 1080) + val state = VideoSizeState(mock.player) + state.observe() + assertEquals(1920, state.videoWidth) + assertEquals(1080, state.videoHeight) + + mock.videoWidth = 1280 + mock.videoHeight = 720 + mock.firePlayerEvent(PlayerEventTypes.RESIZE) + assertEquals(1280, state.videoWidth) + assertEquals(720, state.videoHeight) + } +} + +class AdStateTest { + @Test + fun `registers sourcechange error and ad events`() { + val mock = MockPlayer() + AdState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), mock.playerEventTypes) + assertEquals(adEventTypes, mock.adEventTypes) + } + + @Test + fun `seeds playingAd and updates on ad events and sourcechange`() { + val mock = MockPlayer(adPlaying = true) + val state = AdState(mock.player) + state.observe() + assertTrue(state.playingAd) + + mock.adPlaying = false + mock.fireAdEvent() + assertFalse(state.playingAd) + + mock.adPlaying = true + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertTrue(state.playingAd) + } +} From d22bc1bb012dbfd648752fe678544cdffe9975f4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:33:30 +0000 Subject: [PATCH 015/158] Delegate the remaining components to state holders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/DefaultUI.kt | 3 +- .../com/theoplayer/android/ui/ErrorDisplay.kt | 72 +++++++++++++------ .../theoplayer/android/ui/LoadingSpinner.kt | 32 ++++++++- .../com/theoplayer/android/ui/MuteButton.kt | 47 ++++++++++-- 4 files changed, 120 insertions(+), 34 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 520b7f3e..81263587 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -71,6 +71,7 @@ fun DefaultUI( player: Player = rememberPlayer(), title: String? = null ) { + val errorState = rememberErrorState(player.player) UIController( modifier = modifier, player = player, @@ -135,7 +136,7 @@ fun DefaultUI( }, errorOverlay = { Box(contentAlignment = Alignment.Center) { - ErrorDisplay() + ErrorDisplay(errorState) // Ensure the user can still exit fullscreen if (player.fullscreen) { FullscreenButton(modifier = Modifier.align(Alignment.BottomEnd)) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt index b44a93a2..56f697b2 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.error.THEOplayerException /** * A display for a fatal error, if the player encounters one. @@ -26,32 +27,57 @@ import androidx.compose.ui.unit.dp fun ErrorDisplay( modifier: Modifier = Modifier, ) { - Player.current?.error?.let { error -> - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Column { - Icon( - Icons.Rounded.Error, - modifier = Modifier.size(48.dp), - contentDescription = null - ) - } - Column { - Box( - modifier = Modifier.defaultMinSize(minHeight = 48.dp), - contentAlignment = Alignment.CenterStart - ) { - Text( - text = stringResource(R.string.theoplayer_ui_error_title), - style = MaterialTheme.typography.headlineMedium - ) - } + ErrorDisplay( + state = rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), + modifier = modifier + ) +} + +/** + * A display for a fatal error, if the player encounters one. + * + * The error is only captured from the player's `error` event, so create the [ErrorState] + * *before* an error can happen: at the top of your UI instead of inside the error overlay + * that is only shown once there's an error. + * + * @param state the [ErrorState] to read + * @param modifier the [Modifier] to be applied to this display + */ +@ExperimentalPlayerStateApi +@Composable +fun ErrorDisplay( + state: ErrorState, + modifier: Modifier = Modifier, +) { + state.error?.let { ErrorDisplayContent(it, modifier) } +} + +@Composable +private fun ErrorDisplayContent(error: THEOplayerException, modifier: Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column { + Icon( + Icons.Rounded.Error, + modifier = Modifier.size(48.dp), + contentDescription = null + ) + } + Column { + Box( + modifier = Modifier.defaultMinSize(minHeight = 48.dp), + contentAlignment = Alignment.CenterStart + ) { Text( - text = "${error.message}" + text = stringResource(R.string.theoplayer_ui_error_title), + style = MaterialTheme.typography.headlineMedium ) } + Text( + text = "${error.message}" + ) } } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt index 5f54ef7a..330477a0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt @@ -30,10 +30,36 @@ fun LoadingSpinner( strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, delay: Duration = THEOplayerTheme.playerAnimations.loadingSpinnerDelay ) { - val player = Player.current - val loading = player?.loading ?: false + LoadingSpinner( + state = rememberLoadingState(LocalTHEOplayerView.current?.player), + modifier = modifier, + color = color, + strokeWidth = strokeWidth, + delay = delay + ) +} + +/** + * An indicator that shows whether the player is currently waiting for more data to resume playback. + * + * @param state the [LoadingState] to read + * @param modifier the [Modifier] to be applied to this indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param delay delay for the progress indicator to become visible after the player starts buffering. + * In general, it's less jarring to *not* show the indicator for very short buffering periods. + */ +@ExperimentalPlayerStateApi +@Composable +fun LoadingSpinner( + state: LoadingState, + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, + strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, + delay: Duration = THEOplayerTheme.playerAnimations.loadingSpinnerDelay +) { AnimatedVisibility( - visible = loading, + visible = state.loading, // Show the loading spinner only after a small delay enter = fadeIn(snap(delayMillis = delay.toInt(DurationUnit.MILLISECONDS))), // Hide the loading spinner immediately when loaded diff --git a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt index c892e07c..8232c740 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt @@ -36,16 +36,49 @@ fun MuteButton( ) } ) { - val player = Player.current + MuteButton( + state = rememberMuteButtonState(LocalTHEOplayerView.current?.player), + modifier = modifier, + contentPadding = contentPadding, + mute = mute, + unmute = unmute + ) +} + +/** + * A button that toggles whether audio is muted or not. + * + * @param state the [MuteButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param mute button content when the player is unmuted, typically a "mute" icon + * @param unmute button content when the player is muted, typically an "unmute" icon + */ +@ExperimentalPlayerStateApi +@Composable +fun MuteButton( + state: MuteButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + mute: @Composable () -> Unit = { + Icon( + Icons.AutoMirrored.Rounded.VolumeUp, + contentDescription = stringResource(R.string.theoplayer_ui_btn_mute) + ) + }, + unmute: @Composable () -> Unit = { + Icon( + Icons.AutoMirrored.Rounded.VolumeOff, + contentDescription = stringResource(R.string.theoplayer_ui_btn_unmute) + ) + } +) { IconButton( modifier = modifier, contentPadding = contentPadding, - onClick = { - player?.let { - it.muted = !it.muted - } - }) { - if (player?.muted == true) { + onClick = { state.onClick() }) { + if (state.muted) { unmute() } else { mute() From 6d0dfe3ef9704c6aa548b92765d2949e090591b4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:33:31 +0000 Subject: [PATCH 016/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ffed67..23f10a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) * 🏠 Moved `Player` and its helpers into 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)) From d5922870b7c75ecb3b939b412fd53ff886846491 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:36:27 +0000 Subject: [PATCH 017/158] 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 23f10a6d..729fe972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. +* 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) * 🏠 Moved `Player` and its helpers into 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)) From e1adc40731cec8d065fa85164d6262c8f750a431 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 10:39:30 +0000 Subject: [PATCH 018/158] Initialize ErrorState with the initial error Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/ErrorState.kt | 5 ++--- .../java/com/theoplayer/android/ui/PlayerStateTest.kt | 9 ++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt index 21e4b605..d3ea1ad7 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt @@ -26,19 +26,18 @@ import com.theoplayer.android.api.player.Player @ExperimentalPlayerStateApi class ErrorState internal constructor( private val player: Player?, - private val initialError: THEOplayerException? = null + initialError: THEOplayerException? = null ) { private val listeners = PlayerStateListeners() /** * The fatal error the player has encountered, if any. */ - var error by mutableStateOf(null) + var error by mutableStateOf(initialError) private set internal fun observe() { val player = player ?: return - error = initialError listeners.run { val listener = EventListener> { event -> error = (event as? ErrorEvent)?.errorObject diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt index 8497163f..b7c6df8c 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -647,10 +647,13 @@ class ErrorStateTest { } @Test - fun `null player with initial error stays null`() { - val state = ErrorState(null, initialError = mockk()) + fun `null player keeps initial error and registers no listeners`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(null, initialError = exception) state.observe() - assertEquals(null, state.error) + assertEquals(exception, state.error) + assertTrue(mock.playerListeners.isEmpty()) state.dispose() } } From 7d5a548d4150862b189145ba88d9696f523caf33 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 11:39:26 +0000 Subject: [PATCH 019/158] Generalize PlayerStateListeners Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/Player.kt | 8 ----- .../android/ui/PlayerStateListeners.kt | 36 ++++--------------- .../theoplayer/android/ui/TextTrackUtils.kt | 15 ++++++++ 3 files changed, 21 insertions(+), 38 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/TextTrackUtils.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index 211b501b..d8b85ecf 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -897,12 +897,4 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { updateCast(null) fullscreenHandler?.onFullscreenChangeListener = null } -} - -private fun isSubtitleTrack(track: TextTrack): Boolean { - return when (track.kind) { - TextTrackKind.SUBTITLES.type -> true - TextTrackKind.CAPTIONS.type -> true - else -> false - } } \ No newline at end of file diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt index df196947..b15aa8b4 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt @@ -1,48 +1,24 @@ package com.theoplayer.android.ui -import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventDispatcher import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.EventType -import com.theoplayer.android.api.event.ads.AdEvent -import com.theoplayer.android.api.event.player.PlayerEvent -import com.theoplayer.android.api.player.Player /** * Tracks the event listeners registered by a state holder, so they can all be removed again * on dispose. */ internal class PlayerStateListeners { - private val listeners = mutableListOf, EventListener<*>>>() + private val removers = mutableListOf<() -> Unit>() - fun > Player.add(type: EventType, listener: EventListener) { + fun , T : B> EventDispatcher.add(type: EventType, listener: EventListener) { addEventListener(type, listener) - listeners.add(Triple(this, type, listener)) - } - - fun > Ads.add(type: EventType, listener: EventListener) { - addEventListener(type, listener) - listeners.add(Triple(this, type, listener)) + removers.add { removeEventListener(type, listener) } } fun removeAll() { - for ((target, type, listener) in listeners) { - remove(target, type, listener) - } - listeners.clear() - } - - @Suppress("UNCHECKED_CAST") - private fun remove(target: Any?, type: EventType<*>, listener: EventListener<*>) { - when (target) { - is Player -> target.removeEventListener( - type as EventType>, - listener as EventListener> - ) - is Ads -> target.removeEventListener( - type as EventType>, - listener as EventListener> - ) - } + removers.forEach { it() } + removers.clear() } } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackUtils.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackUtils.kt new file mode 100644 index 00000000..e8c58cd6 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackUtils.kt @@ -0,0 +1,15 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackKind + +/** + * Returns whether the given [TextTrack] can be shown as subtitles. + */ +internal fun isSubtitleTrack(track: TextTrack): Boolean { + return when (track.kind) { + TextTrackKind.SUBTITLES.type -> true + TextTrackKind.CAPTIONS.type -> true + else -> false + } +} From d34417e44ec1a7b6921ef76abbdf0556b3191046 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 11:39:26 +0000 Subject: [PATCH 020/158] Add track, quality and playback rate state holders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/AudioTrackState.kt | 85 ++++ .../android/ui/PlaybackRateState.kt | 67 +++ .../android/ui/SubtitleTrackState.kt | 86 ++++ .../android/ui/VideoQualityState.kt | 110 +++++ .../theoplayer/android/ui/PlayerStateTest.kt | 445 ++++++++++++++++++ 5 files changed, 793 insertions(+) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt new file mode 100644 index 00000000..e65e11dd --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt @@ -0,0 +1,85 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality + +/** + * State holder for audio tracks, such as [AudioTrackList] or [AudioTrackMenu]. + * + * Create an instance with [rememberAudioTrackState]. + */ +@ExperimentalPlayerStateApi +class AudioTrackState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The available audio tracks. + */ + var audioTracks by mutableStateOf>>(listOf()) + private set + + /** + * The currently enabled audio track, if any. + */ + var activeAudioTrack by mutableStateOf?>(null) + private set + + private fun update() { + val newAudioTracks = player?.audioTracks?.toList() ?: listOf() + if (audioTracks != newAudioTracks) { + audioTracks = newAudioTracks + } + activeAudioTrack = audioTracks.find { it.isEnabled } + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.audioTracks.add(AudioTrackListEventTypes.ADDTRACK, listener) + player.audioTracks.add(AudioTrackListEventTypes.REMOVETRACK, listener) + player.audioTracks.add(AudioTrackListEventTypes.TRACKLISTCHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Selects the active audio track, or `null` to disable all audio tracks. + */ + fun select(track: MediaTrack?) { + activeAudioTrack = track + if (track != null) { + track.isEnabled = true + } else { + player?.audioTracks?.forEach { it.isEnabled = false } + } + } +} + +/** + * Creates an [AudioTrackState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberAudioTrackState(player: Player?): AudioTrackState { + val state = remember(player) { AudioTrackState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt new file mode 100644 index 00000000..9172d0cf --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt @@ -0,0 +1,67 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for the playback rate, such as [PlaybackRateList] or [PlaybackRateMenu]. + * + * Create an instance with [rememberPlaybackRateState]. + */ +@ExperimentalPlayerStateApi +class PlaybackRateState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The playback rate of the player. `1.0` is normal speed. + */ + var playbackRate by mutableDoubleStateOf(1.0) + private set + + private fun update() { + playbackRate = player?.playbackRate ?: 1.0 + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.RATECHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Selects the desired playback rate. + */ + fun select(rate: Double) { + playbackRate = rate + player?.playbackRate = rate + } +} + +/** + * Creates a [PlaybackRateState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberPlaybackRateState(player: Player?): PlaybackRateState { + val state = remember(player) { PlaybackRateState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt new file mode 100644 index 00000000..3195a3a8 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt @@ -0,0 +1,86 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode + +/** + * State holder for subtitle tracks, such as [SubtitleTrackList] or [SubtitleMenu]. + * + * Create an instance with [rememberSubtitleTrackState]. + */ +@ExperimentalPlayerStateApi +class SubtitleTrackState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + + /** + * The available subtitle tracks. + */ + var subtitleTracks by mutableStateOf>(listOf()) + private set + + /** + * The currently shown subtitle track, if any. + */ + var activeSubtitleTrack by mutableStateOf(null) + private set + + private fun update() { + val newSubtitleTracks = player?.textTracks?.filter { isSubtitleTrack(it) } ?: listOf() + if (subtitleTracks != newSubtitleTracks) { + subtitleTracks = newSubtitleTracks + } + activeSubtitleTrack = subtitleTracks.find { it.mode == TextTrackMode.SHOWING } + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.textTracks.add(TextTrackListEventTypes.ADDTRACK, listener) + player.textTracks.add(TextTrackListEventTypes.REMOVETRACK, listener) + player.textTracks.add(TextTrackListEventTypes.TRACKLISTCHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + } + + /** + * Selects the subtitle track to show, or `null` to hide all subtitles. + * All other subtitle tracks are disabled; non-subtitle text tracks are not touched. + */ + fun select(track: TextTrack?) { + activeSubtitleTrack = track + player?.textTracks?.forEach { + if (isSubtitleTrack(it)) { + it.mode = if (it == track) TextTrackMode.SHOWING else TextTrackMode.DISABLED + } + } + } +} + +/** + * Creates a [SubtitleTrackState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberSubtitleTrackState(player: Player?): SubtitleTrackState { + val state = remember(player) { SubtitleTrackState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt new file mode 100644 index 00000000..c500f488 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt @@ -0,0 +1,110 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes +import com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality + +/** + * State holder for video qualities, such as [QualityList] or [QualityMenu]. + * + * Create an instance with [rememberVideoQualityState]. + */ +@ExperimentalPlayerStateApi +class VideoQualityState internal constructor(private val player: Player?) { + private val listeners = PlayerStateListeners() + private val trackListeners = PlayerStateListeners() + + /** + * The qualities of the active video track. + */ + var videoQualities by mutableStateOf>(listOf()) + private set + + /** + * The currently playing video quality, if any. + */ + var activeVideoQuality by mutableStateOf(null) + private set + + /** + * The desired video quality, or `null` for automatic selection. + */ + var targetVideoQuality by mutableStateOf(null) + private set + + private var activeVideoTrack: MediaTrack? = null + + private fun update() { + setActiveVideoTrack(player?.videoTracks?.find { it.isEnabled }) + } + + private fun setActiveVideoTrack(videoTrack: MediaTrack?) { + if (activeVideoTrack == videoTrack) { + return + } + trackListeners.removeAll() + activeVideoTrack = videoTrack + videoQualities = videoTrack?.qualities?.toList() ?: listOf() + activeVideoQuality = videoTrack?.activeQuality + targetVideoQuality = videoTrack?.targetQuality + videoTrack?.let { track -> + trackListeners.run { + val listener = EventListener> { + activeVideoQuality = track.activeQuality + targetVideoQuality = track.targetQuality + } + track.add(VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT, listener) + track.add(VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT, listener) + } + } + } + + internal fun observe() { + val player = player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.videoTracks.add(VideoTrackListEventTypes.ADDTRACK, listener) + player.videoTracks.add(VideoTrackListEventTypes.REMOVETRACK, listener) + player.videoTracks.add(VideoTrackListEventTypes.TRACKLISTCHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + trackListeners.removeAll() + activeVideoTrack = null + } + + /** + * Selects the desired video quality, or `null` for automatic selection. + */ + fun select(quality: VideoQuality?) { + targetVideoQuality = quality + activeVideoTrack?.targetQuality = quality + } +} + +/** + * Creates a [VideoQualityState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberVideoQualityState(player: Player?): VideoQualityState { + val state = remember(player) { VideoQualityState(player) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt index b7c6df8c..aa046e65 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt @@ -2,6 +2,7 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventDispatcher import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.EventType import com.theoplayer.android.api.event.ads.AdEvent @@ -11,8 +12,18 @@ import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.event.player.SourceChangeEvent +import com.theoplayer.android.api.event.track.TrackEvent +import com.theoplayer.android.api.event.track.tracklist.TrackListEvent import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.ReadyState +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList +import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackKind +import com.theoplayer.android.api.player.track.texttrack.TextTrackList +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode import com.theoplayer.android.api.timerange.TimeRange import com.theoplayer.android.api.timerange.TimeRanges import io.mockk.every @@ -40,10 +51,17 @@ private class MockPlayer( var videoWidth: Int = 0, var videoHeight: Int = 0, var playedLength: Int = 0, + var playbackRate: Double = 1.0, ) { var errorObject: THEOplayerException = mockk() val playerListeners = mutableListOf, EventListener<*>>>() val adListeners = mutableListOf, EventListener<*>>>() + val audioTrackListeners = mutableListOf, EventListener<*>>>() + val textTrackListeners = mutableListOf, EventListener<*>>>() + val videoTrackListeners = mutableListOf, EventListener<*>>>() + val audioTrackList = mutableListOf>() + val textTrackList = mutableListOf() + val videoTrackList = mutableListOf>() val ads: Ads = mockk { every { isPlaying } answers { this@MockPlayer.adPlaying } @@ -61,6 +79,21 @@ private class MockPlayer( } } + val audioTracks: MediaTrackList = mockTrackList( + { audioTrackList.toMutableList().listIterator() }, + audioTrackListeners + ) + + val textTracks: TextTrackList = mockTrackList( + { textTrackList.toMutableList().listIterator() }, + textTrackListeners + ) + + val videoTracks: MediaTrackList = mockTrackList( + { videoTrackList.toMutableList().listIterator() }, + videoTrackListeners + ) + val player: Player = mockk { every { isPaused } answers { this@MockPlayer.paused } every { isEnded } answers { this@MockPlayer.ended } @@ -80,6 +113,11 @@ private class MockPlayer( every { getPlayed() } answers { mockk { every { length() } returns this@MockPlayer.playedLength } } + every { getPlaybackRate() } answers { this@MockPlayer.playbackRate } + every { setPlaybackRate(any()) } answers { this@MockPlayer.playbackRate = firstArg() } + every { getAudioTracks() } returns this@MockPlayer.audioTracks + every { getTextTracks() } returns this@MockPlayer.textTracks + every { getVideoTracks() } returns this@MockPlayer.videoTracks every { addEventListener(any>>(), any()) } answers { @@ -138,6 +176,110 @@ private fun mockTimeRanges(ranges: List>): TimeRanges = moc } } +private var nextTrackUid = 0 + +/** + * A mocked track list that records the listeners added to it. + */ +private inline fun mockTrackList( + noinline items: () -> MutableIterator<*>, + listeners: MutableList, EventListener<*>>> +): L where L : EventDispatcher>, L : Iterable<*> = mockk { + every { iterator() } answers { items() } + every { addEventListener(any>>(), any()) } answers { + listeners.add(firstArg>() to secondArg>()) + Unit + } + every { removeEventListener(any>>(), any()) } answers { + listeners.remove(firstArg>() to secondArg>()) + Unit + } +} + +/** + * A mocked [MediaTrack] of [AudioQuality] with a mutable [MediaTrack.isEnabled]. + */ +private class MockAudioTrack(var enabled: Boolean = false) { + val uid = nextTrackUid++ + val track: MediaTrack = mockk { + every { isEnabled } answers { this@MockAudioTrack.enabled } + every { setEnabled(any()) } answers { this@MockAudioTrack.enabled = firstArg() } + every { this@mockk.uid } returns this@MockAudioTrack.uid + } +} + +/** + * A mocked [TextTrack] with a fixed [TextTrack.getKind] and a mutable [TextTrack.getMode]. + */ +private class MockTextTrack(kind: TextTrackKind, var mode: TextTrackMode) { + val uid = nextTrackUid++ + private val kindType = kind.type + val track: TextTrack = mockk { + every { this@mockk.kind } returns kindType + every { this@mockk.mode } answers { this@MockTextTrack.mode } + every { setMode(any()) } answers { this@MockTextTrack.mode = firstArg() } + every { this@mockk.uid } returns this@MockTextTrack.uid + } +} + +private fun mockVideoQuality(): VideoQuality { + val qualityUid = nextTrackUid++ + return mockk { + every { this@mockk.uid } returns qualityUid + every { this@mockk.height } returns 1080 + every { this@mockk.bandwidth } returns 5_000_000L + } +} + +/** + * A mocked [MediaTrack] of [VideoQuality] that records its own event listeners. + */ +private class MockVideoTrack( + var enabled: Boolean = false, + var activeQuality: VideoQuality? = null, + var targetQuality: VideoQuality? = null, + val qualities: List = listOf() +) { + val uid = nextTrackUid++ + val listeners = mutableListOf, EventListener<*>>>() + + val track: MediaTrack = mockk { + every { isEnabled } answers { this@MockVideoTrack.enabled } + every { setEnabled(any()) } answers { this@MockVideoTrack.enabled = firstArg() } + every { this@mockk.uid } returns this@MockVideoTrack.uid + every { getActiveQuality() } answers { this@MockVideoTrack.activeQuality } + every { getTargetQuality() } answers { this@MockVideoTrack.targetQuality } + every { setTargetQuality(any()) } answers { this@MockVideoTrack.targetQuality = firstArg() } + every { getQualities() } answers { + mockk> { + every { iterator() } answers { this@MockVideoTrack.qualities.toMutableList().listIterator() } + } + } + every { addEventListener(any>>(), any()) } answers { + listeners.add(firstArg>() to secondArg>()) + Unit + } + every { removeEventListener(any>>(), any()) } answers { + listeners.remove(firstArg>() to secondArg>()) + Unit + } + } + + fun fireEvent() { + listeners.toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } + } +} + +private fun MutableList, EventListener<*>>>.fireTrackListEvent() { + toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } +} + private val playbackEventTypes = setOf( PlayerEventTypes.TIMEUPDATE, PlayerEventTypes.PLAY, @@ -771,3 +913,306 @@ class AdStateTest { assertTrue(state.playingAd) } } + +class PlaybackRateStateTest { + @Test + fun `registers only the ratechange event`() { + val mock = MockPlayer() + PlaybackRateState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.RATECHANGE), mock.playerEventTypes) + } + + @Test + fun `seeds rate and updates on ratechange`() { + val mock = MockPlayer(playbackRate = 1.5) + val state = PlaybackRateState(mock.player) + state.observe() + assertEquals(1.5, state.playbackRate, 0.0) + + mock.playbackRate = 2.0 + mock.firePlayerEvent(PlayerEventTypes.RATECHANGE) + assertEquals(2.0, state.playbackRate, 0.0) + } + + @Test + fun `select sets player rate and state`() { + val mock = MockPlayer() + val state = PlaybackRateState(mock.player) + state.observe() + + state.select(2.0) + verify { mock.player.playbackRate = 2.0 } + assertEquals(2.0, state.playbackRate, 0.0) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = PlaybackRateState(null) + state.observe() + assertEquals(1.0, state.playbackRate, 0.0) + state.select(2.0) + state.dispose() + } +} + +class AudioTrackStateTest { + private fun playerWithTracks(vararg tracks: MockAudioTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.audioTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers the track list events with one listener`() { + val mock = playerWithTracks(MockAudioTrack(enabled = true)) + val state = AudioTrackState(mock.player) + state.observe() + + val listener = mock.audioTrackListeners.first().second + assertTrue(mock.audioTrackListeners.all { it.second === listener }) + assertEquals(3, mock.audioTrackListeners.size) + + state.dispose() + assertTrue(mock.audioTrackListeners.isEmpty()) + } + + @Test + fun `seeds tracks and active track`() { + val disabled = MockAudioTrack(enabled = false) + val enabled = MockAudioTrack(enabled = true) + val mock = playerWithTracks(disabled, enabled) + val state = AudioTrackState(mock.player) + state.observe() + + assertEquals(listOf(disabled.track, enabled.track), state.audioTracks) + assertEquals(enabled.track, state.activeAudioTrack) + } + + @Test + fun `addtrack updates the list`() { + val mock = playerWithTracks(MockAudioTrack(enabled = true)) + val state = AudioTrackState(mock.player) + state.observe() + assertEquals(1, state.audioTracks.size) + + val added = MockAudioTrack(enabled = false) + mock.audioTrackList.add(added.track) + mock.audioTrackListeners.fireTrackListEvent() + assertEquals(2, state.audioTracks.size) + } + + @Test + fun `tracklistchange updates the active track`() { + val first = MockAudioTrack(enabled = true) + val second = MockAudioTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = AudioTrackState(mock.player) + state.observe() + assertEquals(first.track, state.activeAudioTrack) + + first.enabled = false + second.enabled = true + mock.audioTrackListeners.fireTrackListEvent() + assertEquals(second.track, state.activeAudioTrack) + } + + @Test + fun `select enables the track and updates the state`() { + val first = MockAudioTrack(enabled = true) + val second = MockAudioTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = AudioTrackState(mock.player) + state.observe() + + state.select(second.track) + assertTrue(second.enabled) + assertEquals(second.track, state.activeAudioTrack) + } + + @Test + fun `select null disables all tracks`() { + val enabled = MockAudioTrack(enabled = true) + val mock = playerWithTracks(enabled) + val state = AudioTrackState(mock.player) + state.observe() + + state.select(null) + assertFalse(enabled.enabled) + assertEquals(null, state.activeAudioTrack) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = AudioTrackState(null) + state.observe() + assertTrue(state.audioTracks.isEmpty()) + assertEquals(null, state.activeAudioTrack) + state.dispose() + } +} + +class SubtitleTrackStateTest { + private fun playerWithTracks(vararg tracks: MockTextTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.textTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers the track list events with one listener`() { + val mock = playerWithTracks(MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING)) + val state = SubtitleTrackState(mock.player) + state.observe() + + val listener = mock.textTrackListeners.first().second + assertTrue(mock.textTrackListeners.all { it.second === listener }) + assertEquals(3, mock.textTrackListeners.size) + + state.dispose() + assertTrue(mock.textTrackListeners.isEmpty()) + } + + @Test + fun `filters out non-subtitle tracks`() { + val subtitles = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) + val captions = MockTextTrack(TextTrackKind.CAPTIONS, TextTrackMode.SHOWING) + val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) + val mock = playerWithTracks(subtitles, captions, metadata) + val state = SubtitleTrackState(mock.player) + state.observe() + + assertEquals(listOf(subtitles.track, captions.track), state.subtitleTracks) + assertEquals(captions.track, state.activeSubtitleTrack) + } + + @Test + fun `select shows the track and disables the other subtitles`() { + val first = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) + val second = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) + val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) + val mock = playerWithTracks(first, second, metadata) + val state = SubtitleTrackState(mock.player) + state.observe() + + state.select(second.track) + assertEquals(TextTrackMode.DISABLED, first.mode) + assertEquals(TextTrackMode.SHOWING, second.mode) + assertEquals(TextTrackMode.DISABLED, metadata.mode) + assertEquals(second.track, state.activeSubtitleTrack) + } + + @Test + fun `select null disables all subtitle tracks`() { + val showing = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) + val mock = playerWithTracks(showing) + val state = SubtitleTrackState(mock.player) + state.observe() + + state.select(null) + assertEquals(TextTrackMode.DISABLED, showing.mode) + assertEquals(null, state.activeSubtitleTrack) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = SubtitleTrackState(null) + state.observe() + assertTrue(state.subtitleTracks.isEmpty()) + assertEquals(null, state.activeSubtitleTrack) + state.dispose() + } +} + +class VideoQualityStateTest { + private fun playerWithTracks(vararg tracks: MockVideoTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.videoTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers list events plus quality events on the enabled track`() { + val enabled = MockVideoTrack(enabled = true) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + assertEquals(3, mock.videoTrackListeners.size) + assertEquals(2, enabled.listeners.size) + + state.dispose() + assertTrue(mock.videoTrackListeners.isEmpty()) + assertTrue(enabled.listeners.isEmpty()) + } + + @Test + fun `seeds qualities from the enabled track`() { + val quality = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, activeQuality = quality, qualities = listOf(quality)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + assertEquals(listOf(quality), state.videoQualities) + assertEquals(quality, state.activeVideoQuality) + } + + @Test + fun `quality events update active and target`() { + val old = mockVideoQuality() + val new = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, activeQuality = old, qualities = listOf(old, new)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + enabled.activeQuality = new + enabled.targetQuality = new + enabled.fireEvent() + assertEquals(new, state.activeVideoQuality) + assertEquals(new, state.targetVideoQuality) + } + + @Test + fun `select sets the track target quality`() { + val quality = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, qualities = listOf(quality)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + state.select(quality) + assertEquals(quality, enabled.targetQuality) + assertEquals(quality, state.targetVideoQuality) + + state.select(null) + assertEquals(null, enabled.targetQuality) + } + + @Test + fun `switching the enabled track moves the quality listeners`() { + val first = MockVideoTrack(enabled = true) + val second = MockVideoTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = VideoQualityState(mock.player) + state.observe() + assertEquals(2, first.listeners.size) + + first.enabled = false + second.enabled = true + mock.videoTrackListeners.fireTrackListEvent() + + assertEquals(0, first.listeners.size) + assertEquals(2, second.listeners.size) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = VideoQualityState(null) + state.observe() + assertTrue(state.videoQualities.isEmpty()) + assertEquals(null, state.activeVideoQuality) + assertEquals(null, state.targetVideoQuality) + state.dispose() + } +} From 8d34b3aad8aa32cec830976525d11fd9f68da7ad Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 11:39:26 +0000 Subject: [PATCH 021/158] Add state overloads for track lists and menus Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/AudioTrackList.kt | 34 +++- .../com/theoplayer/android/ui/LanguageMenu.kt | 148 +++++++++++++++--- .../android/ui/LanguageMenuButton.kt | 46 +++++- .../theoplayer/android/ui/PlaybackRateList.kt | 30 +++- .../com/theoplayer/android/ui/QualityList.kt | 38 +++-- .../com/theoplayer/android/ui/SettingsMenu.kt | 70 ++++++++- .../android/ui/SubtitleTrackList.kt | 38 +++-- 7 files changed, 340 insertions(+), 64 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt index a430fbf5..3de7a336 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt @@ -19,25 +19,43 @@ fun AudioTrackList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - val player = Player.current - val audioTracks = player?.audioTracks ?: listOf() - val activeAudioTrack = player?.activeAudioTrack + AudioTrackList( + state = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of audio tracks, from which the user can choose an active audio track. + * + * @param state the [AudioTrackState] to read and control + * @param modifier the [Modifier] to be applied to this menu + * @param onClick called when an audio track in the list is clicked + */ +@ExperimentalPlayerStateApi +@Composable +fun AudioTrackList( + state: AudioTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { LazyColumn(modifier = modifier) { items( - count = audioTracks.size, - key = { audioTracks[it].uid } + count = state.audioTracks.size, + key = { state.audioTracks[it].uid } ) { - val audioTrack = audioTracks[it] + val audioTrack = state.audioTracks[it] ListItem( headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, leadingContent = { RadioButton( - selected = (activeAudioTrack == audioTrack), + selected = (state.activeAudioTrack == audioTrack), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.activeAudioTrack = audioTrack + state.select(audioTrack) onClick?.let { it() } }) ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index c3824d49..a7e14d25 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -26,6 +26,30 @@ import androidx.compose.ui.unit.dp */ @Composable fun MenuScope.LanguageMenu() { + LanguageMenu( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the spoken language and subtitles of the stream. + * + * Depending on the available screen width, this shows either [compact][LanguageMenuCompact] + * or [expanded][LanguageMenuExpanded] contents. + * + * Remember the states inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param audioTrackState the [AudioTrackState] to read and control + * @param subtitleTrackState the [SubtitleTrackState] to read and control + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenu( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_language)) }, backIcon = { @@ -36,25 +60,24 @@ fun MenuScope.LanguageMenu() { }, ) { BoxWithConstraints { - val player = Player.current val neededWidth = - (if (showAudioTracks(player)) 300.dp else 0.dp) + - (if (showSubtitleTracks(player)) 300.dp else 0.dp) + (if (showAudioTracks(audioTrackState)) 300.dp else 0.dp) + + (if (showSubtitleTracks(subtitleTrackState)) 300.dp else 0.dp) if (maxWidth < neededWidth) { - LanguageMenuCompact() + LanguageMenuCompact(audioTrackState, subtitleTrackState) } else { - LanguageMenuExpanded() + LanguageMenuExpanded(audioTrackState, subtitleTrackState) } } } } -internal fun showAudioTracks(player: Player?): Boolean { - return player != null && player.audioTracks.size >= 2 +internal fun showAudioTracks(audioTrackState: AudioTrackState): Boolean { + return audioTrackState.audioTracks.size >= 2 } -internal fun showSubtitleTracks(player: Player?): Boolean { - return player != null && player.subtitleTracks.isNotEmpty() +internal fun showSubtitleTracks(subtitleTrackState: SubtitleTrackState): Boolean { + return subtitleTrackState.subtitleTracks.isNotEmpty() } /** @@ -68,9 +91,34 @@ internal fun showSubtitleTracks(player: Player?): Boolean { */ @Composable fun MenuScope.LanguageMenuCompact() { - val player = Player.current + LanguageMenuCompact( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * The compact menu contents of the [LanguageMenu]. + * + * In this form, the currently selected audio and subtitle track are shown inside buttons. + * Click these buttons opens a separate menu to change the audio track or subtitle track. + * + * Remember the states inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param audioTrackState the [AudioTrackState] to read and control + * @param subtitleTrackState the [SubtitleTrackState] to read and control + * @see AudioTrackMenu + * @see SubtitleMenu + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenuCompact( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +) { Column(modifier = Modifier.padding(horizontal = 16.dp)) { - if (showAudioTracks(player)) { + if (showAudioTracks(audioTrackState)) { Row { Text( modifier = Modifier @@ -86,7 +134,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeAudioTrack?.let { formatTrackLabel(it) } + text = audioTrackState.activeAudioTrack?.let { formatTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_audio_none ), @@ -99,7 +147,7 @@ fun MenuScope.LanguageMenuCompact() { } } } - if (showSubtitleTracks(player)) { + if (showSubtitleTracks(subtitleTrackState)) { Row { Text( modifier = Modifier @@ -115,7 +163,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( + text = subtitleTrackState.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center @@ -140,9 +188,33 @@ fun MenuScope.LanguageMenuCompact() { */ @Composable fun MenuScope.LanguageMenuExpanded() { - val player = Player.current + LanguageMenuExpanded( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * The expanded menu contents of the [LanguageMenu]. + * + * In this form, the list of audio and subtitle tracks are shown side-by-side. + * + * Remember the states inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param audioTrackState the [AudioTrackState] to read and control + * @param subtitleTrackState the [SubtitleTrackState] to read and control + * @see AudioTrackList + * @see SubtitleTrackList + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenuExpanded( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +) { Row(modifier = Modifier.padding(horizontal = 16.dp)) { - if (showAudioTracks(player)) { + if (showAudioTracks(audioTrackState)) { Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier @@ -152,10 +224,10 @@ fun MenuScope.LanguageMenuExpanded() { textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) - AudioTrackList() + AudioTrackList(audioTrackState) } } - if (showSubtitleTracks(player)) { + if (showSubtitleTracks(subtitleTrackState)) { Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier @@ -165,7 +237,7 @@ fun MenuScope.LanguageMenuExpanded() { textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) - SubtitleTrackList() + SubtitleTrackList(subtitleTrackState) } } } @@ -178,10 +250,27 @@ fun MenuScope.LanguageMenuExpanded() { */ @Composable fun MenuScope.AudioTrackMenu() { + AudioTrackMenu( + state = rememberAudioTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the spoken language of the stream. + * + * Remember the state inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param state the [AudioTrackState] to read and control + * @see AudioTrackList + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.AudioTrackMenu(state: AudioTrackState) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_audio)) } ) { - AudioTrackList(onClick = { closeCurrentMenu() }) + AudioTrackList(state, onClick = { closeCurrentMenu() }) } } @@ -192,9 +281,26 @@ fun MenuScope.AudioTrackMenu() { */ @Composable fun MenuScope.SubtitleMenu() { + SubtitleMenu( + state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the subtitles of the stream. + * + * Remember the state inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param state the [SubtitleTrackState] to read and control + * @see SubtitleTrackList + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SubtitleMenu(state: SubtitleTrackState) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_subtitles)) } ) { - SubtitleTrackList(onClick = { closeCurrentMenu() }) + SubtitleTrackList(state, onClick = { closeCurrentMenu() }) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt index d736449b..312c0192 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt @@ -26,8 +26,43 @@ fun MenuScope.LanguageMenuButton( ) } ) { - val player = Player.current - if (!showLanguageMenuButton(player)) { + LanguageMenuButton( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +/** + * A button that opens the [language menu][LanguageMenu]. + * + * The states are only used to decide whether the button is shown. They are *not* passed + * to the opened menu, because this button leaves the composition while the menu is shown: + * the menu remembers its own states instead. + * + * @param audioTrackState the [AudioTrackState] to read + * @param subtitleTrackState the [SubtitleTrackState] to read + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + painter = painterResource(id = R.drawable.language), + contentDescription = stringResource(R.string.theoplayer_ui_menu_language) + ) + } +) { + if (!showLanguageMenuButton(audioTrackState, subtitleTrackState)) { // Hide when no alternative audio or subtitle tracks are available return } @@ -40,6 +75,9 @@ fun MenuScope.LanguageMenuButton( } } -internal fun showLanguageMenuButton(player: Player?): Boolean { - return showAudioTracks(player) || showSubtitleTracks(player) +internal fun showLanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +): Boolean { + return showAudioTracks(audioTrackState) || showSubtitleTracks(subtitleTrackState) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index 81adba7f..abe905ca 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -24,8 +24,30 @@ fun PlaybackRateList( playbackRates: List = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0), onClick: (() -> Unit)? = null ) { - val player = Player.current - val currentPlaybackRate = player?.playbackRate ?: 1 + PlaybackRateList( + state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player), + modifier = modifier, + playbackRates = playbackRates, + onClick = onClick + ) +} + +/** + * A list of playback rates, from which the user can choose a desired playback rate. + * + * @param state the [PlaybackRateState] to read and control + * @param modifier the [Modifier] to be applied to this menu + * @param playbackRates the list of possible playback rates + * @param onClick called when a playback rate in the list is clicked + */ +@ExperimentalPlayerStateApi +@Composable +fun PlaybackRateList( + state: PlaybackRateState, + modifier: Modifier = Modifier, + playbackRates: List = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0), + onClick: (() -> Unit)? = null +) { LazyColumn(modifier = modifier) { items( count = playbackRates.size, @@ -36,12 +58,12 @@ fun PlaybackRateList( headlineContent = { Text(text = formatPlaybackRate(playbackRate)) }, leadingContent = { RadioButton( - selected = (currentPlaybackRate == playbackRate), + selected = (state.playbackRate == playbackRate), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.playbackRate = playbackRate + state.select(playbackRate) onClick?.let { it() } }) ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 29cc90ea..2256b919 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -22,30 +22,48 @@ fun QualityList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - val player = Player.current - val videoQualities = player?.videoQualities ?: listOf() - val targetVideoQuality = player?.targetVideoQuality + QualityList( + state = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of video qualities, from which the user can choose a target video quality. + * + * @param state the [VideoQualityState] to read and control + * @param modifier the [Modifier] to be applied to this menu + * @param onClick called when a video quality in the list is clicked + */ +@ExperimentalPlayerStateApi +@Composable +fun QualityList( + state: VideoQualityState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { LazyColumn(modifier = modifier) { item(key = null) { ListItem( headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_quality_automatic)) }, leadingContent = { RadioButton( - selected = (targetVideoQuality == null), + selected = (state.targetVideoQuality == null), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.targetVideoQuality = null + state.select(null) onClick?.let { it() } }) ) } items( - count = videoQualities.size, - key = { videoQualities[it].uid } + count = state.videoQualities.size, + key = { state.videoQualities[it].uid } ) { - val quality = videoQualities[it] + val quality = state.videoQualities[it] ListItem( headlineContent = { Text( @@ -58,12 +76,12 @@ fun QualityList( supportingContent = { Text(text = formatBandwidth(quality.bandwidth)) }, leadingContent = { RadioButton( - selected = (targetVideoQuality == quality), + selected = (state.targetVideoQuality == quality), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.targetVideoQuality = quality + state.select(quality) onClick?.let { it() } }) ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 12796853..56202c8b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -24,6 +24,29 @@ import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality */ @Composable fun MenuScope.SettingsMenu() { + SettingsMenu( + videoQualityState = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + playbackRateState = rememberPlaybackRateState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the settings of the player, such as the video quality or the playback rate. + * + * Remember the states inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param videoQualityState the [VideoQualityState] to read + * @param playbackRateState the [PlaybackRateState] to read + * @see QualityMenu + * @see PlaybackRateMenu + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SettingsMenu( + videoQualityState: VideoQualityState, + playbackRateState: PlaybackRateState +) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_settings)) }, backIcon = { @@ -33,7 +56,6 @@ fun MenuScope.SettingsMenu() { ) }, ) { - val player = Player.current Column(modifier = Modifier.padding(horizontal = 16.dp)) { Row { Text( @@ -51,8 +73,8 @@ fun MenuScope.SettingsMenu() { Text( modifier = Modifier.weight(1f), text = formatActiveQualityLabel( - player?.targetVideoQuality, - player?.activeVideoQuality + videoQualityState.targetVideoQuality, + videoQualityState.activeVideoQuality ), textAlign = TextAlign.Center ) @@ -77,7 +99,7 @@ fun MenuScope.SettingsMenu() { ) { Text( modifier = Modifier.weight(1f), - text = formatPlaybackRate(player?.playbackRate ?: 1.0), + text = formatPlaybackRate(playbackRateState.playbackRate), textAlign = TextAlign.Center ) Icon( @@ -97,10 +119,27 @@ fun MenuScope.SettingsMenu() { */ @Composable fun MenuScope.QualityMenu() { + QualityMenu( + state = rememberVideoQualityState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the video quality of the stream. + * + * Remember the state inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param state the [VideoQualityState] to read and control + * @see QualityList + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.QualityMenu(state: VideoQualityState) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_quality)) } ) { - QualityList(onClick = { closeCurrentMenu() }) + QualityList(state, onClick = { closeCurrentMenu() }) } } @@ -111,10 +150,27 @@ fun MenuScope.QualityMenu() { */ @Composable fun MenuScope.PlaybackRateMenu() { + PlaybackRateMenu( + state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A [Menu] to change the playback rate of the player. + * + * Remember the state inside the `openMenu { }` block that opens this menu, not where + * the opening button is, because the button leaves the composition while the menu is shown. + * + * @param state the [PlaybackRateState] to read and control + * @see PlaybackRateList + */ +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { Menu( title = { Text(text = stringResource(R.string.theoplayer_ui_menu_playback_rate)) } ) { - PlaybackRateList(onClick = { closeCurrentMenu() }) + PlaybackRateList(state, onClick = { closeCurrentMenu() }) } } @@ -136,4 +192,4 @@ internal fun formatActiveQualityLabel( } else { stringResource(R.string.theoplayer_ui_quality_automatic) } -} \ No newline at end of file +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index c29b3a99..36fffd96 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -20,40 +20,58 @@ fun SubtitleTrackList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - val player = Player.current - val subtitleTracks = player?.subtitleTracks ?: listOf() - val activeSubtitleTrack = player?.activeSubtitleTrack + SubtitleTrackList( + state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of subtitle tracks, from which the user can choose an active subtitle track. + * + * @param state the [SubtitleTrackState] to read and control + * @param modifier the [Modifier] to be applied to this menu + * @param onClick called when a subtitle track in the list is clicked + */ +@ExperimentalPlayerStateApi +@Composable +fun SubtitleTrackList( + state: SubtitleTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { LazyColumn(modifier = modifier) { item(key = null) { ListItem( headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_subtitles_off)) }, leadingContent = { RadioButton( - selected = (activeSubtitleTrack == null), + selected = (state.activeSubtitleTrack == null), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.activeSubtitleTrack = null + state.select(null) onClick?.let { it() } }) ) } items( - count = subtitleTracks.size, - key = { subtitleTracks[it].uid } + count = state.subtitleTracks.size, + key = { state.subtitleTracks[it].uid } ) { - val subtitleTrack = subtitleTracks[it] + val subtitleTrack = state.subtitleTracks[it] ListItem( headlineContent = { Text(text = formatTrackLabel(subtitleTrack)) }, leadingContent = { RadioButton( - selected = (activeSubtitleTrack == subtitleTrack), + selected = (state.activeSubtitleTrack == subtitleTrack), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.activeSubtitleTrack = subtitleTrack + state.select(subtitleTrack) onClick?.let { it() } }) ) From 8c752dac9b46c124fd9e03f6d237fb6b191069eb Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 11:39:26 +0000 Subject: [PATCH 022/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729fe972..a84fa620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) * 🏠 Moved `Player` and its helpers into 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)) From 430cb024ca1c0fd7d100b8f82abc69101e41174f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:04:54 +0000 Subject: [PATCH 023/158] 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 a84fa620..93929d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. +* 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) * 🏠 Moved `Player` and its helpers into 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)) From 47ea726a7957bb46b2fa8aa00ca8e33c7d944e01 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:16:13 +0000 Subject: [PATCH 024/158] Move default playback rates to a module-level val Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/PlaybackRateList.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index abe905ca..d33c748e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -11,6 +11,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import java.text.DecimalFormat +/** + * The default playback rates shown in a [PlaybackRateList]. + */ +private val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) + /** * A list of playback rates, from which the user can choose a desired playback rate. * @@ -21,7 +26,7 @@ import java.text.DecimalFormat @Composable fun PlaybackRateList( modifier: Modifier = Modifier, - playbackRates: List = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0), + playbackRates: List = defaultPlaybackRates, onClick: (() -> Unit)? = null ) { PlaybackRateList( @@ -45,7 +50,7 @@ fun PlaybackRateList( fun PlaybackRateList( state: PlaybackRateState, modifier: Modifier = Modifier, - playbackRates: List = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0), + playbackRates: List = defaultPlaybackRates, onClick: (() -> Unit)? = null ) { LazyColumn(modifier = modifier) { From 497cc96189ef8eb242783b38b2902b71bb9dec6f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:20:51 +0000 Subject: [PATCH 025/158] Document state caveat on openMenu Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/LanguageMenu.kt | 15 +++++---------- .../main/java/com/theoplayer/android/ui/Menu.kt | 13 +++++++++++++ .../com/theoplayer/android/ui/SettingsMenu.kt | 9 +++------ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index a7e14d25..a65d8a03 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -38,8 +38,7 @@ fun MenuScope.LanguageMenu() { * Depending on the available screen width, this shows either [compact][LanguageMenuCompact] * or [expanded][LanguageMenuExpanded] contents. * - * Remember the states inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param audioTrackState the [AudioTrackState] to read and control * @param subtitleTrackState the [SubtitleTrackState] to read and control @@ -103,8 +102,7 @@ fun MenuScope.LanguageMenuCompact() { * In this form, the currently selected audio and subtitle track are shown inside buttons. * Click these buttons opens a separate menu to change the audio track or subtitle track. * - * Remember the states inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the states inside the `openMenu` block that opens the enclosing menu, see [MenuScope.openMenu]. * * @param audioTrackState the [AudioTrackState] to read and control * @param subtitleTrackState the [SubtitleTrackState] to read and control @@ -199,8 +197,7 @@ fun MenuScope.LanguageMenuExpanded() { * * In this form, the list of audio and subtitle tracks are shown side-by-side. * - * Remember the states inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the states inside the `openMenu` block that opens the enclosing menu, see [MenuScope.openMenu]. * * @param audioTrackState the [AudioTrackState] to read and control * @param subtitleTrackState the [SubtitleTrackState] to read and control @@ -258,8 +255,7 @@ fun MenuScope.AudioTrackMenu() { /** * A [Menu] to change the spoken language of the stream. * - * Remember the state inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param state the [AudioTrackState] to read and control * @see AudioTrackList @@ -289,8 +285,7 @@ fun MenuScope.SubtitleMenu() { /** * A [Menu] to change the subtitles of the stream. * - * Remember the state inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param state the [SubtitleTrackState] to read and control * @see SubtitleTrackList diff --git a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt index 49b6f1de..9981dbd4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt @@ -80,6 +80,19 @@ interface MenuScope { * Open a new submenu. * * When the submenu is closed, the original menu is shown again. + * + * Only the top-most menu is composed. While the submenu is open, the original menu + * (or the player controls, if no menu was open yet) leaves the composition, and any state + * remembered there is disposed. So instead of passing state from the caller into [menu], + * remember the state inside [menu]: + * ```kotlin + * // Don't: audioTrackState is disposed while the menu is open + * val audioTrackState = rememberAudioTrackState(player) + * openMenu { AudioTrackMenu(audioTrackState) } + * + * // Do + * openMenu { AudioTrackMenu(rememberAudioTrackState(player)) } + * ``` */ fun openMenu(menu: MenuContent) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 56202c8b..1c3aada3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -33,8 +33,7 @@ fun MenuScope.SettingsMenu() { /** * A [Menu] to change the settings of the player, such as the video quality or the playback rate. * - * Remember the states inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param videoQualityState the [VideoQualityState] to read * @param playbackRateState the [PlaybackRateState] to read @@ -127,8 +126,7 @@ fun MenuScope.QualityMenu() { /** * A [Menu] to change the video quality of the stream. * - * Remember the state inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param state the [VideoQualityState] to read and control * @see QualityList @@ -158,8 +156,7 @@ fun MenuScope.PlaybackRateMenu() { /** * A [Menu] to change the playback rate of the player. * - * Remember the state inside the `openMenu { }` block that opens this menu, not where - * the opening button is, because the button leaves the composition while the menu is shown. + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. * * @param state the [PlaybackRateState] to read and control * @see PlaybackRateList From 9b3b8c7d025989747c68de2254c397dc41086199 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:23:12 +0000 Subject: [PATCH 026/158] Move test mocks to MockPlayer.kt Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/MockPlayer.kt | 294 ++++ .../theoplayer/android/ui/PlayerStateTest.kt | 1218 ----------------- 2 files changed, 294 insertions(+), 1218 deletions(-) create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt delete mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt new file mode 100644 index 00000000..12998d65 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt @@ -0,0 +1,294 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventDispatcher +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.player.ErrorEvent +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.event.player.SourceChangeEvent +import com.theoplayer.android.api.event.track.TrackEvent +import com.theoplayer.android.api.event.track.tracklist.TrackListEvent +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.ReadyState +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList +import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackKind +import com.theoplayer.android.api.player.track.texttrack.TextTrackList +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode +import com.theoplayer.android.api.timerange.TimeRange +import com.theoplayer.android.api.timerange.TimeRanges +import io.mockk.every +import io.mockk.mockk + +/** + * A mocked [Player] that records the listeners added to it and to its [Ads], + * so tests can fire events at them. + */ +internal class MockPlayer( + var paused: Boolean = true, + var ended: Boolean = false, + var currentTime: Double = 0.0, + var duration: Double = 100.0, + var seekableRanges: List> = listOf(0.0 to 100.0), + var adPlaying: Boolean = false, + var muted: Boolean = false, + var seeking: Boolean = false, + var readyState: ReadyState = ReadyState.HAVE_NOTHING, + var videoWidth: Int = 0, + var videoHeight: Int = 0, + var playedLength: Int = 0, + var playbackRate: Double = 1.0, +) { + var errorObject: THEOplayerException = mockk() + val playerListeners = mutableListOf, EventListener<*>>>() + val adListeners = mutableListOf, EventListener<*>>>() + val audioTrackListeners = mutableListOf, EventListener<*>>>() + val textTrackListeners = mutableListOf, EventListener<*>>>() + val videoTrackListeners = mutableListOf, EventListener<*>>>() + val audioTrackList = mutableListOf>() + val textTrackList = mutableListOf() + val videoTrackList = mutableListOf>() + + val ads: Ads = mockk { + every { isPlaying } answers { this@MockPlayer.adPlaying } + every { + addEventListener(any>>(), any()) + } answers { + adListeners.add(firstArg>() to secondArg>()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + adListeners.remove(firstArg>() to secondArg>()) + Unit + } + } + + val audioTracks: MediaTrackList = mockTrackList( + { audioTrackList.toMutableList().listIterator() }, + audioTrackListeners + ) + + val textTracks: TextTrackList = mockTrackList( + { textTrackList.toMutableList().listIterator() }, + textTrackListeners + ) + + val videoTracks: MediaTrackList = mockTrackList( + { videoTrackList.toMutableList().listIterator() }, + videoTrackListeners + ) + + val player: Player = mockk { + every { isPaused } answers { this@MockPlayer.paused } + every { isEnded } answers { this@MockPlayer.ended } + every { getCurrentTime() } answers { this@MockPlayer.currentTime } + every { getDuration() } answers { this@MockPlayer.duration } + every { getSeekable() } answers { mockTimeRanges(this@MockPlayer.seekableRanges) } + every { getAds() } returns this@MockPlayer.ads + every { setCurrentTime(any()) } answers { this@MockPlayer.currentTime = firstArg() } + every { play() } answers { this@MockPlayer.paused = false } + every { pause() } answers { this@MockPlayer.paused = true } + every { isMuted } answers { this@MockPlayer.muted } + every { setMuted(any()) } answers { this@MockPlayer.muted = firstArg() } + every { isSeeking } answers { this@MockPlayer.seeking } + every { getReadyState() } answers { this@MockPlayer.readyState } + every { getVideoWidth() } answers { this@MockPlayer.videoWidth } + every { getVideoHeight() } answers { this@MockPlayer.videoHeight } + every { getPlayed() } answers { + mockk { every { length() } returns this@MockPlayer.playedLength } + } + every { getPlaybackRate() } answers { this@MockPlayer.playbackRate } + every { setPlaybackRate(any()) } answers { this@MockPlayer.playbackRate = firstArg() } + every { getAudioTracks() } returns this@MockPlayer.audioTracks + every { getTextTracks() } returns this@MockPlayer.textTracks + every { getVideoTracks() } returns this@MockPlayer.videoTracks + every { + addEventListener(any>>(), any()) + } answers { + playerListeners.add(firstArg>() to secondArg>()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + playerListeners.remove(firstArg>() to secondArg>()) + Unit + } + } + + val playerEventTypes: Set> + get() = playerListeners.map { it.first }.toSet() + + val adEventTypes: Set> + get() = adListeners.map { it.first }.toSet() + + fun firePlayerEvent(type: EventType<*>? = null, event: PlayerEvent<*>? = null) { + playerListeners.toList().forEach { (eventType, listener) -> + if (type == null || eventType == type) { + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent( + event ?: mockEvent(eventType) + ) + } + } + } + + private fun mockEvent(type: EventType<*>): PlayerEvent<*> = when (type) { + PlayerEventTypes.ERROR -> mockk { + every { errorObject } returns this@MockPlayer.errorObject + } + PlayerEventTypes.SOURCECHANGE -> mockk() + else -> mockk() + } + + fun fireAdEvent() { + adListeners.toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } + } +} + +internal fun mockTimeRanges(ranges: List>): TimeRanges = mockk { + every { iterator() } answers { + ranges.map { (start, end) -> + mockk { + every { this@mockk.start } returns start + every { this@mockk.end } returns end + } + }.toMutableList().listIterator() + } +} + +internal var nextTrackUid = 0 + +/** + * A mocked track list that records the listeners added to it. + */ +internal inline fun mockTrackList( + noinline items: () -> MutableIterator<*>, + listeners: MutableList, EventListener<*>>> +): L where L : EventDispatcher>, L : Iterable<*> = mockk { + every { iterator() } answers { items() } + every { addEventListener(any>>(), any()) } answers { + listeners.add(firstArg>() to secondArg>()) + Unit + } + every { removeEventListener(any>>(), any()) } answers { + listeners.remove(firstArg>() to secondArg>()) + Unit + } +} + +/** + * A mocked [MediaTrack] of [AudioQuality] with a mutable [MediaTrack.isEnabled]. + */ +internal class MockAudioTrack(var enabled: Boolean = false) { + val uid = nextTrackUid++ + val track: MediaTrack = mockk { + every { isEnabled } answers { this@MockAudioTrack.enabled } + every { setEnabled(any()) } answers { this@MockAudioTrack.enabled = firstArg() } + every { this@mockk.uid } returns this@MockAudioTrack.uid + } +} + +/** + * A mocked [TextTrack] with a fixed [TextTrack.getKind] and a mutable [TextTrack.getMode]. + */ +internal class MockTextTrack(kind: TextTrackKind, var mode: TextTrackMode) { + val uid = nextTrackUid++ + private val kindType = kind.type + val track: TextTrack = mockk { + every { this@mockk.kind } returns kindType + every { this@mockk.mode } answers { this@MockTextTrack.mode } + every { setMode(any()) } answers { this@MockTextTrack.mode = firstArg() } + every { this@mockk.uid } returns this@MockTextTrack.uid + } +} + +internal fun mockVideoQuality(): VideoQuality { + val qualityUid = nextTrackUid++ + return mockk { + every { this@mockk.uid } returns qualityUid + every { this@mockk.height } returns 1080 + every { this@mockk.bandwidth } returns 5_000_000L + } +} + +/** + * A mocked [MediaTrack] of [VideoQuality] that records its own event listeners. + */ +internal class MockVideoTrack( + var enabled: Boolean = false, + var activeQuality: VideoQuality? = null, + var targetQuality: VideoQuality? = null, + val qualities: List = listOf() +) { + val uid = nextTrackUid++ + val listeners = mutableListOf, EventListener<*>>>() + + val track: MediaTrack = mockk { + every { isEnabled } answers { this@MockVideoTrack.enabled } + every { setEnabled(any()) } answers { this@MockVideoTrack.enabled = firstArg() } + every { this@mockk.uid } returns this@MockVideoTrack.uid + every { getActiveQuality() } answers { this@MockVideoTrack.activeQuality } + every { getTargetQuality() } answers { this@MockVideoTrack.targetQuality } + every { setTargetQuality(any()) } answers { this@MockVideoTrack.targetQuality = firstArg() } + every { getQualities() } answers { + mockk> { + every { iterator() } answers { this@MockVideoTrack.qualities.toMutableList().listIterator() } + } + } + every { addEventListener(any>>(), any()) } answers { + listeners.add(firstArg>() to secondArg>()) + Unit + } + every { removeEventListener(any>>(), any()) } answers { + listeners.remove(firstArg>() to secondArg>()) + Unit + } + } + + fun fireEvent() { + listeners.toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } + } +} + +internal fun MutableList, EventListener<*>>>.fireTrackListEvent() { + toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } +} + +internal val playbackEventTypes = setOf( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR +) + +internal val adEventTypes = setOf( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END +) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt deleted file mode 100644 index aa046e65..00000000 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayerStateTest.kt +++ /dev/null @@ -1,1218 +0,0 @@ -package com.theoplayer.android.ui - -import com.theoplayer.android.api.ads.Ads -import com.theoplayer.android.api.event.Event -import com.theoplayer.android.api.event.EventDispatcher -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.EventType -import com.theoplayer.android.api.event.ads.AdEvent -import com.theoplayer.android.api.event.ads.AdsEventTypes -import com.theoplayer.android.api.error.THEOplayerException -import com.theoplayer.android.api.event.player.ErrorEvent -import com.theoplayer.android.api.event.player.PlayerEvent -import com.theoplayer.android.api.event.player.PlayerEventTypes -import com.theoplayer.android.api.event.player.SourceChangeEvent -import com.theoplayer.android.api.event.track.TrackEvent -import com.theoplayer.android.api.event.track.tracklist.TrackListEvent -import com.theoplayer.android.api.player.Player -import com.theoplayer.android.api.player.ReadyState -import com.theoplayer.android.api.player.track.mediatrack.MediaTrack -import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList -import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality -import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality -import com.theoplayer.android.api.player.track.texttrack.TextTrack -import com.theoplayer.android.api.player.track.texttrack.TextTrackKind -import com.theoplayer.android.api.player.track.texttrack.TextTrackList -import com.theoplayer.android.api.player.track.texttrack.TextTrackMode -import com.theoplayer.android.api.timerange.TimeRange -import com.theoplayer.android.api.timerange.TimeRanges -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * A mocked [Player] that records the listeners added to it and to its [Ads], - * so tests can fire events at them. - */ -private class MockPlayer( - var paused: Boolean = true, - var ended: Boolean = false, - var currentTime: Double = 0.0, - var duration: Double = 100.0, - var seekableRanges: List> = listOf(0.0 to 100.0), - var adPlaying: Boolean = false, - var muted: Boolean = false, - var seeking: Boolean = false, - var readyState: ReadyState = ReadyState.HAVE_NOTHING, - var videoWidth: Int = 0, - var videoHeight: Int = 0, - var playedLength: Int = 0, - var playbackRate: Double = 1.0, -) { - var errorObject: THEOplayerException = mockk() - val playerListeners = mutableListOf, EventListener<*>>>() - val adListeners = mutableListOf, EventListener<*>>>() - val audioTrackListeners = mutableListOf, EventListener<*>>>() - val textTrackListeners = mutableListOf, EventListener<*>>>() - val videoTrackListeners = mutableListOf, EventListener<*>>>() - val audioTrackList = mutableListOf>() - val textTrackList = mutableListOf() - val videoTrackList = mutableListOf>() - - val ads: Ads = mockk { - every { isPlaying } answers { this@MockPlayer.adPlaying } - every { - addEventListener(any>>(), any()) - } answers { - adListeners.add(firstArg>() to secondArg>()) - Unit - } - every { - removeEventListener(any>>(), any()) - } answers { - adListeners.remove(firstArg>() to secondArg>()) - Unit - } - } - - val audioTracks: MediaTrackList = mockTrackList( - { audioTrackList.toMutableList().listIterator() }, - audioTrackListeners - ) - - val textTracks: TextTrackList = mockTrackList( - { textTrackList.toMutableList().listIterator() }, - textTrackListeners - ) - - val videoTracks: MediaTrackList = mockTrackList( - { videoTrackList.toMutableList().listIterator() }, - videoTrackListeners - ) - - val player: Player = mockk { - every { isPaused } answers { this@MockPlayer.paused } - every { isEnded } answers { this@MockPlayer.ended } - every { getCurrentTime() } answers { this@MockPlayer.currentTime } - every { getDuration() } answers { this@MockPlayer.duration } - every { getSeekable() } answers { mockTimeRanges(this@MockPlayer.seekableRanges) } - every { getAds() } returns this@MockPlayer.ads - every { setCurrentTime(any()) } answers { this@MockPlayer.currentTime = firstArg() } - every { play() } answers { this@MockPlayer.paused = false } - every { pause() } answers { this@MockPlayer.paused = true } - every { isMuted } answers { this@MockPlayer.muted } - every { setMuted(any()) } answers { this@MockPlayer.muted = firstArg() } - every { isSeeking } answers { this@MockPlayer.seeking } - every { getReadyState() } answers { this@MockPlayer.readyState } - every { getVideoWidth() } answers { this@MockPlayer.videoWidth } - every { getVideoHeight() } answers { this@MockPlayer.videoHeight } - every { getPlayed() } answers { - mockk { every { length() } returns this@MockPlayer.playedLength } - } - every { getPlaybackRate() } answers { this@MockPlayer.playbackRate } - every { setPlaybackRate(any()) } answers { this@MockPlayer.playbackRate = firstArg() } - every { getAudioTracks() } returns this@MockPlayer.audioTracks - every { getTextTracks() } returns this@MockPlayer.textTracks - every { getVideoTracks() } returns this@MockPlayer.videoTracks - every { - addEventListener(any>>(), any()) - } answers { - playerListeners.add(firstArg>() to secondArg>()) - Unit - } - every { - removeEventListener(any>>(), any()) - } answers { - playerListeners.remove(firstArg>() to secondArg>()) - Unit - } - } - - val playerEventTypes: Set> - get() = playerListeners.map { it.first }.toSet() - - val adEventTypes: Set> - get() = adListeners.map { it.first }.toSet() - - fun firePlayerEvent(type: EventType<*>? = null, event: PlayerEvent<*>? = null) { - playerListeners.toList().forEach { (eventType, listener) -> - if (type == null || eventType == type) { - @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent( - event ?: mockEvent(eventType) - ) - } - } - } - - private fun mockEvent(type: EventType<*>): PlayerEvent<*> = when (type) { - PlayerEventTypes.ERROR -> mockk { - every { errorObject } returns this@MockPlayer.errorObject - } - PlayerEventTypes.SOURCECHANGE -> mockk() - else -> mockk() - } - - fun fireAdEvent() { - adListeners.toList().forEach { (_, listener) -> - @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) - } - } -} - -private fun mockTimeRanges(ranges: List>): TimeRanges = mockk { - every { iterator() } answers { - ranges.map { (start, end) -> - mockk { - every { this@mockk.start } returns start - every { this@mockk.end } returns end - } - }.toMutableList().listIterator() - } -} - -private var nextTrackUid = 0 - -/** - * A mocked track list that records the listeners added to it. - */ -private inline fun mockTrackList( - noinline items: () -> MutableIterator<*>, - listeners: MutableList, EventListener<*>>> -): L where L : EventDispatcher>, L : Iterable<*> = mockk { - every { iterator() } answers { items() } - every { addEventListener(any>>(), any()) } answers { - listeners.add(firstArg>() to secondArg>()) - Unit - } - every { removeEventListener(any>>(), any()) } answers { - listeners.remove(firstArg>() to secondArg>()) - Unit - } -} - -/** - * A mocked [MediaTrack] of [AudioQuality] with a mutable [MediaTrack.isEnabled]. - */ -private class MockAudioTrack(var enabled: Boolean = false) { - val uid = nextTrackUid++ - val track: MediaTrack = mockk { - every { isEnabled } answers { this@MockAudioTrack.enabled } - every { setEnabled(any()) } answers { this@MockAudioTrack.enabled = firstArg() } - every { this@mockk.uid } returns this@MockAudioTrack.uid - } -} - -/** - * A mocked [TextTrack] with a fixed [TextTrack.getKind] and a mutable [TextTrack.getMode]. - */ -private class MockTextTrack(kind: TextTrackKind, var mode: TextTrackMode) { - val uid = nextTrackUid++ - private val kindType = kind.type - val track: TextTrack = mockk { - every { this@mockk.kind } returns kindType - every { this@mockk.mode } answers { this@MockTextTrack.mode } - every { setMode(any()) } answers { this@MockTextTrack.mode = firstArg() } - every { this@mockk.uid } returns this@MockTextTrack.uid - } -} - -private fun mockVideoQuality(): VideoQuality { - val qualityUid = nextTrackUid++ - return mockk { - every { this@mockk.uid } returns qualityUid - every { this@mockk.height } returns 1080 - every { this@mockk.bandwidth } returns 5_000_000L - } -} - -/** - * A mocked [MediaTrack] of [VideoQuality] that records its own event listeners. - */ -private class MockVideoTrack( - var enabled: Boolean = false, - var activeQuality: VideoQuality? = null, - var targetQuality: VideoQuality? = null, - val qualities: List = listOf() -) { - val uid = nextTrackUid++ - val listeners = mutableListOf, EventListener<*>>>() - - val track: MediaTrack = mockk { - every { isEnabled } answers { this@MockVideoTrack.enabled } - every { setEnabled(any()) } answers { this@MockVideoTrack.enabled = firstArg() } - every { this@mockk.uid } returns this@MockVideoTrack.uid - every { getActiveQuality() } answers { this@MockVideoTrack.activeQuality } - every { getTargetQuality() } answers { this@MockVideoTrack.targetQuality } - every { setTargetQuality(any()) } answers { this@MockVideoTrack.targetQuality = firstArg() } - every { getQualities() } answers { - mockk> { - every { iterator() } answers { this@MockVideoTrack.qualities.toMutableList().listIterator() } - } - } - every { addEventListener(any>>(), any()) } answers { - listeners.add(firstArg>() to secondArg>()) - Unit - } - every { removeEventListener(any>>(), any()) } answers { - listeners.remove(firstArg>() to secondArg>()) - Unit - } - } - - fun fireEvent() { - listeners.toList().forEach { (_, listener) -> - @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) - } - } -} - -private fun MutableList, EventListener<*>>>.fireTrackListEvent() { - toList().forEach { (_, listener) -> - @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) - } -} - -private val playbackEventTypes = setOf( - PlayerEventTypes.TIMEUPDATE, - PlayerEventTypes.PLAY, - PlayerEventTypes.PAUSE, - PlayerEventTypes.ENDED, - PlayerEventTypes.SEEKING, - PlayerEventTypes.SEEKED, - PlayerEventTypes.READYSTATECHANGE, - PlayerEventTypes.SOURCECHANGE, - PlayerEventTypes.ERROR -) - -private val adEventTypes = setOf( - AdsEventTypes.AD_BREAK_BEGIN, - AdsEventTypes.AD_SKIP, - AdsEventTypes.AD_BREAK_END -) - -class PlayButtonStateTest { - @Test - fun `registers the playback events`() { - val mock = MockPlayer() - PlayButtonState(mock.player).observe() - assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) - } - - @Test - fun `a seeked event clears ended`() { - val mock = MockPlayer(paused = true, ended = true) - val state = PlayButtonState(mock.player) - state.observe() - assertTrue(state.ended) - - mock.ended = false - mock.firePlayerEvent(PlayerEventTypes.SEEKED) - assertFalse(state.ended) - assertTrue(state.paused) - } - - @Test - fun `seeds state and reacts to playback events`() { - val mock = MockPlayer(paused = true, ended = false) - val state = PlayButtonState(mock.player) - state.observe() - - assertTrue(state.paused) - assertFalse(state.ended) - - mock.paused = false - mock.firePlayerEvent() - assertFalse(state.paused) - - mock.ended = true - mock.firePlayerEvent() - assertTrue(state.ended) - } - - @Test - fun `onClick plays when paused and pauses when playing`() { - val mock = MockPlayer(paused = true) - val state = PlayButtonState(mock.player) - state.observe() - - state.onClick() - verify(exactly = 1) { mock.player.play() } - - mock.firePlayerEvent() - state.onClick() - verify(exactly = 1) { mock.player.pause() } - } - - @Test - fun `dispose removes all listeners`() { - val mock = MockPlayer() - val state = PlayButtonState(mock.player) - state.observe() - val added = mock.playerListeners.toList() - assertTrue(added.isNotEmpty()) - - state.dispose() - assertTrue(mock.playerListeners.isEmpty()) - verify(exactly = added.size) { - mock.player.removeEventListener(any>>(), any()) - } - } - - @Test - fun `shares one listener instance and removes it for every type`() { - val mock = MockPlayer() - val state = PlayButtonState(mock.player) - state.observe() - - val added = mock.playerListeners.toList() - val listener = added.first().second - assertTrue(added.all { it.second === listener }) - - state.dispose() - added.forEach { (type, _) -> - @Suppress("UNCHECKED_CAST") - verify { - mock.player.removeEventListener( - type as EventType>, - listener as EventListener> - ) - } - } - } - - @Test - fun `null player gives defaults and no listeners`() { - val state = PlayButtonState(null) - state.observe() - assertTrue(state.paused) - state.onClick() - state.dispose() - } -} - -class SeekBarStateTest { - @Test - fun `registers the playback and ad events`() { - val mock = MockPlayer() - SeekBarState(mock.player).observe() - assertEquals( - playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, - mock.playerEventTypes - ) - assertEquals(adEventTypes, mock.adEventTypes) - } - - @Test - fun `an error event re-reads playingAd`() { - val mock = MockPlayer(adPlaying = false) - val state = SeekBarState(mock.player) - state.observe() - assertTrue(state.showThumb) - - mock.adPlaying = true - mock.firePlayerEvent(PlayerEventTypes.ERROR) - assertFalse(state.enabled) - assertFalse(state.showThumb) - } - - @Test - fun `computes value within zero-based range`() { - val mock = MockPlayer(currentTime = 30.0, duration = 100.0) - val state = SeekBarState(mock.player) - state.observe() - - assertEquals(0f..100f, state.valueRange) - assertEquals(30f, state.value) - assertTrue(state.enabled) - assertTrue(state.showThumb) - } - - @Test - fun `huge seekable start maps to zero-based range`() { - val mock = MockPlayer( - currentTime = 1.7e9 + 30.0, - duration = 1.7e9 + 100.0, - seekableRanges = listOf(1.7e9 to 1.7e9 + 100.0) - ) - val state = SeekBarState(mock.player) - state.observe() - - assertEquals(0f, state.valueRange.start) - assertEquals(100f, state.valueRange.endInclusive, 0.001f) - assertEquals(30f, state.value, 0.001f) - } - - @Test - fun `scrubbing pauses a playing player and resumes on finish`() { - val mock = MockPlayer(paused = false, currentTime = 10.0) - val state = SeekBarState(mock.player) - state.observe() - - 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.play() } - } - - @Test - fun `scrubbing a paused player does not resume it`() { - val mock = MockPlayer(paused = true, currentTime = 10.0) - val state = SeekBarState(mock.player) - state.observe() - - state.onValueChange(50f) - state.onValueChangeFinished() - verify(exactly = 0) { mock.player.play() } - } - - @Test - fun `disabled during ads and thumb hidden`() { - val mock = MockPlayer(adPlaying = true) - val state = SeekBarState(mock.player) - state.observe() - - assertFalse(state.enabled) - assertFalse(state.showThumb) - - mock.adPlaying = false - mock.fireAdEvent() - assertTrue(state.enabled) - assertTrue(state.showThumb) - } - - @Test - fun `dispose removes all listeners`() { - val mock = MockPlayer() - val state = SeekBarState(mock.player) - state.observe() - val added = mock.playerListeners.toList() - assertTrue(added.isNotEmpty()) - assertTrue(mock.adListeners.isNotEmpty()) - - state.dispose() - assertTrue(mock.playerListeners.isEmpty()) - assertTrue(mock.adListeners.isEmpty()) - } -} - -class PlaybackTimeStateTest { - @Test - fun `registers the playback events`() { - val mock = MockPlayer() - PlaybackTimeState(mock.player).observe() - assertEquals( - playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, - mock.playerEventTypes - ) - } - - @Test - fun `streamTypeOf classifies vod, live and dvr`() { - assertEquals( - StreamType.Vod, - streamTypeOf(100.0, com.theoplayer.android.ui.TimeRanges(listOf(0.0..100.0))) - ) - assertEquals( - StreamType.Vod, - streamTypeOf(Double.NaN, com.theoplayer.android.ui.TimeRanges.empty()) - ) - assertEquals( - StreamType.Live, - streamTypeOf( - Double.POSITIVE_INFINITY, - com.theoplayer.android.ui.TimeRanges(listOf(0.0..30.0)) - ) - ) - assertEquals( - StreamType.Dvr, - streamTypeOf( - Double.POSITIVE_INFINITY, - com.theoplayer.android.ui.TimeRanges(listOf(0.0..60.0)) - ) - ) - } - - @Test - fun `seeds and updates on time events`() { - val mock = MockPlayer(currentTime = 5.0, duration = 100.0) - val state = PlaybackTimeState(mock.player) - state.observe() - - assertEquals(5.0, state.currentTime, 0.0) - assertEquals(100.0, state.duration, 0.0) - - mock.currentTime = 42.0 - mock.firePlayerEvent() - assertEquals(42.0, state.currentTime, 0.0) - } - - @Test - fun `duration prefers end of seekable range`() { - val mock = MockPlayer( - duration = Double.POSITIVE_INFINITY, - seekableRanges = listOf(10.0 to 70.0) - ) - val state = PlaybackTimeState(mock.player) - state.observe() - - assertEquals(70.0, state.duration, 0.0) - } - - @Test - fun `reports live and dvr stream types`() { - val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) - val dvr = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 120.0)) - val vod = MockPlayer(duration = 100.0) - - assertEquals(StreamType.Live, PlaybackTimeState(live.player).apply { observe() }.streamType) - assertEquals(StreamType.Dvr, PlaybackTimeState(dvr.player).apply { observe() }.streamType) - assertEquals(StreamType.Vod, PlaybackTimeState(vod.player).apply { observe() }.streamType) - } -} - -class LiveButtonStateTest { - @Test - fun `registers the playback events`() { - val mock = MockPlayer() - LiveButtonState(mock.player, liveThreshold = 10.0).observe() - assertEquals( - playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, - mock.playerEventTypes - ) - } - - @Test - fun `visible for live but not for vod`() { - val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) - val vod = MockPlayer(duration = 100.0) - - assertTrue(LiveButtonState(live.player, liveThreshold = 10.0).apply { observe() }.visible) - assertFalse(LiveButtonState(vod.player, liveThreshold = 10.0).apply { observe() }.visible) - } - - @Test - fun `isLive at threshold boundary`() { - val mock = MockPlayer( - paused = false, - duration = Double.POSITIVE_INFINITY, - currentTime = 20.0, - seekableRanges = listOf(0.0 to 30.0) - ) - val atThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) - atThreshold.observe() - assertTrue(atThreshold.isLive) - - mock.currentTime = 19.9 - val behindThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) - behindThreshold.observe() - assertFalse(behindThreshold.isLive) - } - - @Test - fun `onClick seeks to live and plays`() { - val mock = MockPlayer(duration = Double.POSITIVE_INFINITY) - val state = LiveButtonState(mock.player, liveThreshold = 10.0) - state.observe() - - state.onClick() - verify { mock.player.currentTime = Double.POSITIVE_INFINITY } - verify(exactly = 1) { mock.player.play() } - } -} - -class SeekButtonStateTest { - @Test - fun `registers the playback and ad events`() { - val mock = MockPlayer() - SeekButtonState(mock.player, seekOffset = 10).observe() - assertEquals(playbackEventTypes, mock.playerEventTypes) - assertEquals(adEventTypes, mock.adEventTypes) - } - - @Test - fun `onClick seeks by offset and clamps to duration`() { - val mock = MockPlayer(currentTime = 95.0, duration = 100.0) - val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() - - state.onClick() - verify { mock.player.currentTime = 100.0 } - } - - @Test - fun `onClick clamps negative seek to zero`() { - val mock = MockPlayer(currentTime = 5.0, duration = 100.0) - val state = SeekButtonState(mock.player, seekOffset = -10) - state.observe() - - state.onClick() - verify { mock.player.currentTime = 0.0 } - } - - @Test - fun `onClick does nothing for streams without duration`() { - val mock = MockPlayer(currentTime = 50.0, duration = Double.NaN) - val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() - - state.onClick() - verify(exactly = 0) { mock.player.currentTime = any() } - } - - @Test - fun `disabled during ads`() { - val mock = MockPlayer(adPlaying = true) - val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() - - assertFalse(state.enabled) - } - - @Test - fun `null player is disabled and onClick does nothing`() { - val state = SeekButtonState(null, seekOffset = 10) - state.observe() - assertFalse(state.enabled) - state.onClick() - state.dispose() - } -} - -class MuteButtonStateTest { - @Test - fun `registers only the volume change event`() { - val mock = MockPlayer() - MuteButtonState(mock.player).observe() - assertEquals(setOf(PlayerEventTypes.VOLUMECHANGE), mock.playerEventTypes) - } - - @Test - fun `seeds muted and updates on volume change`() { - val mock = MockPlayer(muted = true) - val state = MuteButtonState(mock.player) - state.observe() - assertTrue(state.muted) - - mock.muted = false - mock.firePlayerEvent(PlayerEventTypes.VOLUMECHANGE) - assertFalse(state.muted) - } - - @Test - fun `onClick toggles player and state`() { - val mock = MockPlayer(muted = false) - val state = MuteButtonState(mock.player) - state.observe() - - state.onClick() - verify { mock.player.isMuted = true } - assertTrue(state.muted) - } - - @Test - fun `null player gives defaults and onClick does nothing`() { - val state = MuteButtonState(null) - state.observe() - assertFalse(state.muted) - state.onClick() - state.dispose() - } -} - -class LoadingStateTest { - @Test - fun `registers the playback events`() { - val mock = MockPlayer() - LoadingState(mock.player).observe() - assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) - } - - @Test - fun `loading truth table`() { - fun state(paused: Boolean, ended: Boolean = false, seeking: Boolean = false, readyState: ReadyState) = - LoadingState(MockPlayer(paused = paused, ended = ended, seeking = seeking, readyState = readyState).player) - .apply { observe() }.loading - - assertFalse(state(paused = false, readyState = ReadyState.HAVE_ENOUGH_DATA)) - assertTrue(state(paused = false, readyState = ReadyState.HAVE_CURRENT_DATA)) - assertTrue(state(paused = false, seeking = true, readyState = ReadyState.HAVE_ENOUGH_DATA)) - assertFalse(state(paused = true, readyState = ReadyState.HAVE_CURRENT_DATA)) - assertFalse(state(paused = false, ended = true, readyState = ReadyState.HAVE_CURRENT_DATA)) - } -} - -class ErrorStateTest { - @Test - fun `registers error and sourcechange`() { - val mock = MockPlayer() - ErrorState(mock.player).observe() - assertEquals(setOf(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE), mock.playerEventTypes) - } - - @Test - fun `error event sets error and sourcechange clears it`() { - val exception = mockk() - val mock = MockPlayer() - val state = ErrorState(mock.player) - state.observe() - assertEquals(null, state.error) - - mock.errorObject = exception - mock.firePlayerEvent(PlayerEventTypes.ERROR) - assertEquals(exception, state.error) - - mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) - assertEquals(null, state.error) - } - - @Test - fun `seeds initial error and sourcechange clears it`() { - val exception = mockk() - val mock = MockPlayer() - val state = ErrorState(mock.player, initialError = exception) - state.observe() - assertEquals(exception, state.error) - - mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) - assertEquals(null, state.error) - } - - @Test - fun `null player keeps initial error and registers no listeners`() { - val exception = mockk() - val mock = MockPlayer() - val state = ErrorState(null, initialError = exception) - state.observe() - assertEquals(exception, state.error) - assertTrue(mock.playerListeners.isEmpty()) - state.dispose() - } -} - -class FirstPlayStateTest { - @Test - fun `seeds false when paused with empty played`() { - val mock = MockPlayer(paused = true, playedLength = 0) - val state = FirstPlayState(mock.player) - state.observe() - assertFalse(state.firstPlay) - } - - @Test - fun `seeds true when paused with non-empty played`() { - val mock = MockPlayer(paused = true, playedLength = 1) - val state = FirstPlayState(mock.player) - state.observe() - assertTrue(state.firstPlay) - } - - @Test - fun `seeds true when playing`() { - val mock = MockPlayer(paused = false) - val state = FirstPlayState(mock.player) - state.observe() - assertTrue(state.firstPlay) - } - - @Test - fun `play sets firstPlay`() { - val mock = MockPlayer(paused = true) - val state = FirstPlayState(mock.player) - state.observe() - - mock.paused = false - mock.firePlayerEvent(PlayerEventTypes.PLAY) - assertTrue(state.firstPlay) - } - - @Test - fun `sourcechange resets firstPlay while paused`() { - val mock = MockPlayer(paused = false) - val state = FirstPlayState(mock.player) - state.observe() - assertTrue(state.firstPlay) - - mock.paused = true - mock.playedLength = 0 - mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) - assertFalse(state.firstPlay) - } - - @Test - fun `sourcechange keeps firstPlay while playing`() { - val mock = MockPlayer(paused = false) - val state = FirstPlayState(mock.player) - state.observe() - assertTrue(state.firstPlay) - - mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) - assertTrue(state.firstPlay) - } -} - -class VideoSizeStateTest { - @Test - fun `registers resize sourcechange and error`() { - val mock = MockPlayer() - VideoSizeState(mock.player).observe() - assertEquals( - setOf(PlayerEventTypes.RESIZE, PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), - mock.playerEventTypes - ) - } - - @Test - fun `seeds size and updates on resize`() { - val mock = MockPlayer(videoWidth = 1920, videoHeight = 1080) - val state = VideoSizeState(mock.player) - state.observe() - assertEquals(1920, state.videoWidth) - assertEquals(1080, state.videoHeight) - - mock.videoWidth = 1280 - mock.videoHeight = 720 - mock.firePlayerEvent(PlayerEventTypes.RESIZE) - assertEquals(1280, state.videoWidth) - assertEquals(720, state.videoHeight) - } -} - -class AdStateTest { - @Test - fun `registers sourcechange error and ad events`() { - val mock = MockPlayer() - AdState(mock.player).observe() - assertEquals(setOf(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), mock.playerEventTypes) - assertEquals(adEventTypes, mock.adEventTypes) - } - - @Test - fun `seeds playingAd and updates on ad events and sourcechange`() { - val mock = MockPlayer(adPlaying = true) - val state = AdState(mock.player) - state.observe() - assertTrue(state.playingAd) - - mock.adPlaying = false - mock.fireAdEvent() - assertFalse(state.playingAd) - - mock.adPlaying = true - mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) - assertTrue(state.playingAd) - } -} - -class PlaybackRateStateTest { - @Test - fun `registers only the ratechange event`() { - val mock = MockPlayer() - PlaybackRateState(mock.player).observe() - assertEquals(setOf(PlayerEventTypes.RATECHANGE), mock.playerEventTypes) - } - - @Test - fun `seeds rate and updates on ratechange`() { - val mock = MockPlayer(playbackRate = 1.5) - val state = PlaybackRateState(mock.player) - state.observe() - assertEquals(1.5, state.playbackRate, 0.0) - - mock.playbackRate = 2.0 - mock.firePlayerEvent(PlayerEventTypes.RATECHANGE) - assertEquals(2.0, state.playbackRate, 0.0) - } - - @Test - fun `select sets player rate and state`() { - val mock = MockPlayer() - val state = PlaybackRateState(mock.player) - state.observe() - - state.select(2.0) - verify { mock.player.playbackRate = 2.0 } - assertEquals(2.0, state.playbackRate, 0.0) - } - - @Test - fun `null player gives defaults and no listeners`() { - val state = PlaybackRateState(null) - state.observe() - assertEquals(1.0, state.playbackRate, 0.0) - state.select(2.0) - state.dispose() - } -} - -class AudioTrackStateTest { - private fun playerWithTracks(vararg tracks: MockAudioTrack): MockPlayer { - return MockPlayer().also { mock -> - mock.audioTrackList.addAll(tracks.map { it.track }) - } - } - - @Test - fun `registers the track list events with one listener`() { - val mock = playerWithTracks(MockAudioTrack(enabled = true)) - val state = AudioTrackState(mock.player) - state.observe() - - val listener = mock.audioTrackListeners.first().second - assertTrue(mock.audioTrackListeners.all { it.second === listener }) - assertEquals(3, mock.audioTrackListeners.size) - - state.dispose() - assertTrue(mock.audioTrackListeners.isEmpty()) - } - - @Test - fun `seeds tracks and active track`() { - val disabled = MockAudioTrack(enabled = false) - val enabled = MockAudioTrack(enabled = true) - val mock = playerWithTracks(disabled, enabled) - val state = AudioTrackState(mock.player) - state.observe() - - assertEquals(listOf(disabled.track, enabled.track), state.audioTracks) - assertEquals(enabled.track, state.activeAudioTrack) - } - - @Test - fun `addtrack updates the list`() { - val mock = playerWithTracks(MockAudioTrack(enabled = true)) - val state = AudioTrackState(mock.player) - state.observe() - assertEquals(1, state.audioTracks.size) - - val added = MockAudioTrack(enabled = false) - mock.audioTrackList.add(added.track) - mock.audioTrackListeners.fireTrackListEvent() - assertEquals(2, state.audioTracks.size) - } - - @Test - fun `tracklistchange updates the active track`() { - val first = MockAudioTrack(enabled = true) - val second = MockAudioTrack(enabled = false) - val mock = playerWithTracks(first, second) - val state = AudioTrackState(mock.player) - state.observe() - assertEquals(first.track, state.activeAudioTrack) - - first.enabled = false - second.enabled = true - mock.audioTrackListeners.fireTrackListEvent() - assertEquals(second.track, state.activeAudioTrack) - } - - @Test - fun `select enables the track and updates the state`() { - val first = MockAudioTrack(enabled = true) - val second = MockAudioTrack(enabled = false) - val mock = playerWithTracks(first, second) - val state = AudioTrackState(mock.player) - state.observe() - - state.select(second.track) - assertTrue(second.enabled) - assertEquals(second.track, state.activeAudioTrack) - } - - @Test - fun `select null disables all tracks`() { - val enabled = MockAudioTrack(enabled = true) - val mock = playerWithTracks(enabled) - val state = AudioTrackState(mock.player) - state.observe() - - state.select(null) - assertFalse(enabled.enabled) - assertEquals(null, state.activeAudioTrack) - } - - @Test - fun `null player gives defaults and no listeners`() { - val state = AudioTrackState(null) - state.observe() - assertTrue(state.audioTracks.isEmpty()) - assertEquals(null, state.activeAudioTrack) - state.dispose() - } -} - -class SubtitleTrackStateTest { - private fun playerWithTracks(vararg tracks: MockTextTrack): MockPlayer { - return MockPlayer().also { mock -> - mock.textTrackList.addAll(tracks.map { it.track }) - } - } - - @Test - fun `registers the track list events with one listener`() { - val mock = playerWithTracks(MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING)) - val state = SubtitleTrackState(mock.player) - state.observe() - - val listener = mock.textTrackListeners.first().second - assertTrue(mock.textTrackListeners.all { it.second === listener }) - assertEquals(3, mock.textTrackListeners.size) - - state.dispose() - assertTrue(mock.textTrackListeners.isEmpty()) - } - - @Test - fun `filters out non-subtitle tracks`() { - val subtitles = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) - val captions = MockTextTrack(TextTrackKind.CAPTIONS, TextTrackMode.SHOWING) - val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) - val mock = playerWithTracks(subtitles, captions, metadata) - val state = SubtitleTrackState(mock.player) - state.observe() - - assertEquals(listOf(subtitles.track, captions.track), state.subtitleTracks) - assertEquals(captions.track, state.activeSubtitleTrack) - } - - @Test - fun `select shows the track and disables the other subtitles`() { - val first = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) - val second = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) - val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) - val mock = playerWithTracks(first, second, metadata) - val state = SubtitleTrackState(mock.player) - state.observe() - - state.select(second.track) - assertEquals(TextTrackMode.DISABLED, first.mode) - assertEquals(TextTrackMode.SHOWING, second.mode) - assertEquals(TextTrackMode.DISABLED, metadata.mode) - assertEquals(second.track, state.activeSubtitleTrack) - } - - @Test - fun `select null disables all subtitle tracks`() { - val showing = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) - val mock = playerWithTracks(showing) - val state = SubtitleTrackState(mock.player) - state.observe() - - state.select(null) - assertEquals(TextTrackMode.DISABLED, showing.mode) - assertEquals(null, state.activeSubtitleTrack) - } - - @Test - fun `null player gives defaults and no listeners`() { - val state = SubtitleTrackState(null) - state.observe() - assertTrue(state.subtitleTracks.isEmpty()) - assertEquals(null, state.activeSubtitleTrack) - state.dispose() - } -} - -class VideoQualityStateTest { - private fun playerWithTracks(vararg tracks: MockVideoTrack): MockPlayer { - return MockPlayer().also { mock -> - mock.videoTrackList.addAll(tracks.map { it.track }) - } - } - - @Test - fun `registers list events plus quality events on the enabled track`() { - val enabled = MockVideoTrack(enabled = true) - val mock = playerWithTracks(enabled) - val state = VideoQualityState(mock.player) - state.observe() - - assertEquals(3, mock.videoTrackListeners.size) - assertEquals(2, enabled.listeners.size) - - state.dispose() - assertTrue(mock.videoTrackListeners.isEmpty()) - assertTrue(enabled.listeners.isEmpty()) - } - - @Test - fun `seeds qualities from the enabled track`() { - val quality = mockVideoQuality() - val enabled = MockVideoTrack(enabled = true, activeQuality = quality, qualities = listOf(quality)) - val mock = playerWithTracks(enabled) - val state = VideoQualityState(mock.player) - state.observe() - - assertEquals(listOf(quality), state.videoQualities) - assertEquals(quality, state.activeVideoQuality) - } - - @Test - fun `quality events update active and target`() { - val old = mockVideoQuality() - val new = mockVideoQuality() - val enabled = MockVideoTrack(enabled = true, activeQuality = old, qualities = listOf(old, new)) - val mock = playerWithTracks(enabled) - val state = VideoQualityState(mock.player) - state.observe() - - enabled.activeQuality = new - enabled.targetQuality = new - enabled.fireEvent() - assertEquals(new, state.activeVideoQuality) - assertEquals(new, state.targetVideoQuality) - } - - @Test - fun `select sets the track target quality`() { - val quality = mockVideoQuality() - val enabled = MockVideoTrack(enabled = true, qualities = listOf(quality)) - val mock = playerWithTracks(enabled) - val state = VideoQualityState(mock.player) - state.observe() - - state.select(quality) - assertEquals(quality, enabled.targetQuality) - assertEquals(quality, state.targetVideoQuality) - - state.select(null) - assertEquals(null, enabled.targetQuality) - } - - @Test - fun `switching the enabled track moves the quality listeners`() { - val first = MockVideoTrack(enabled = true) - val second = MockVideoTrack(enabled = false) - val mock = playerWithTracks(first, second) - val state = VideoQualityState(mock.player) - state.observe() - assertEquals(2, first.listeners.size) - - first.enabled = false - second.enabled = true - mock.videoTrackListeners.fireTrackListEvent() - - assertEquals(0, first.listeners.size) - assertEquals(2, second.listeners.size) - } - - @Test - fun `null player gives defaults and no listeners`() { - val state = VideoQualityState(null) - state.observe() - assertTrue(state.videoQualities.isEmpty()) - assertEquals(null, state.activeVideoQuality) - assertEquals(null, state.targetVideoQuality) - state.dispose() - } -} From 60fbf4342ced9e98d8e403be4a36e03acc985924 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:23:36 +0000 Subject: [PATCH 027/158] Split state holder tests into one file per class Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/AdStateTest.kt | 33 +++++ .../android/ui/AudioTrackStateTest.kt | 102 +++++++++++++++ .../theoplayer/android/ui/ErrorStateTest.kt | 56 +++++++++ .../android/ui/FirstPlayStateTest.kt | 67 ++++++++++ .../android/ui/LiveButtonStateTest.kt | 58 +++++++++ .../theoplayer/android/ui/LoadingStateTest.kt | 30 +++++ .../android/ui/MuteButtonStateTest.kt | 49 ++++++++ .../android/ui/PlayButtonStateTest.kt | 111 +++++++++++++++++ .../android/ui/PlaybackRateStateTest.kt | 47 +++++++ .../android/ui/PlaybackTimeStateTest.kt | 80 ++++++++++++ .../theoplayer/android/ui/SeekBarStateTest.kt | 116 ++++++++++++++++++ .../android/ui/SeekButtonStateTest.kt | 65 ++++++++++ .../android/ui/SubtitleTrackStateTest.kt | 79 ++++++++++++ .../android/ui/VideoQualityStateTest.kt | 99 +++++++++++++++ .../android/ui/VideoSizeStateTest.kt | 32 +++++ 15 files changed, 1024 insertions(+) create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt new file mode 100644 index 00000000..c9d060ae --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt @@ -0,0 +1,33 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdStateTest { + @Test + fun `registers sourcechange error and ad events`() { + val mock = MockPlayer() + AdState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), mock.playerEventTypes) + assertEquals(adEventTypes, mock.adEventTypes) + } + + @Test + fun `seeds playingAd and updates on ad events and sourcechange`() { + val mock = MockPlayer(adPlaying = true) + val state = AdState(mock.player) + state.observe() + assertTrue(state.playingAd) + + mock.adPlaying = false + mock.fireAdEvent() + assertFalse(state.playingAd) + + mock.adPlaying = true + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertTrue(state.playingAd) + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt new file mode 100644 index 00000000..59b0e55f --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt @@ -0,0 +1,102 @@ +package com.theoplayer.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioTrackStateTest { + private fun playerWithTracks(vararg tracks: MockAudioTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.audioTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers the track list events with one listener`() { + val mock = playerWithTracks(MockAudioTrack(enabled = true)) + val state = AudioTrackState(mock.player) + state.observe() + + val listener = mock.audioTrackListeners.first().second + assertTrue(mock.audioTrackListeners.all { it.second === listener }) + assertEquals(3, mock.audioTrackListeners.size) + + state.dispose() + assertTrue(mock.audioTrackListeners.isEmpty()) + } + + @Test + fun `seeds tracks and active track`() { + val disabled = MockAudioTrack(enabled = false) + val enabled = MockAudioTrack(enabled = true) + val mock = playerWithTracks(disabled, enabled) + val state = AudioTrackState(mock.player) + state.observe() + + assertEquals(listOf(disabled.track, enabled.track), state.audioTracks) + assertEquals(enabled.track, state.activeAudioTrack) + } + + @Test + fun `addtrack updates the list`() { + val mock = playerWithTracks(MockAudioTrack(enabled = true)) + val state = AudioTrackState(mock.player) + state.observe() + assertEquals(1, state.audioTracks.size) + + val added = MockAudioTrack(enabled = false) + mock.audioTrackList.add(added.track) + mock.audioTrackListeners.fireTrackListEvent() + assertEquals(2, state.audioTracks.size) + } + + @Test + fun `tracklistchange updates the active track`() { + val first = MockAudioTrack(enabled = true) + val second = MockAudioTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = AudioTrackState(mock.player) + state.observe() + assertEquals(first.track, state.activeAudioTrack) + + first.enabled = false + second.enabled = true + mock.audioTrackListeners.fireTrackListEvent() + assertEquals(second.track, state.activeAudioTrack) + } + + @Test + fun `select enables the track and updates the state`() { + val first = MockAudioTrack(enabled = true) + val second = MockAudioTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = AudioTrackState(mock.player) + state.observe() + + state.select(second.track) + assertTrue(second.enabled) + assertEquals(second.track, state.activeAudioTrack) + } + + @Test + fun `select null disables all tracks`() { + val enabled = MockAudioTrack(enabled = true) + val mock = playerWithTracks(enabled) + val state = AudioTrackState(mock.player) + state.observe() + + state.select(null) + assertFalse(enabled.enabled) + assertEquals(null, state.activeAudioTrack) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = AudioTrackState(null) + state.observe() + assertTrue(state.audioTracks.isEmpty()) + assertEquals(null, state.activeAudioTrack) + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt new file mode 100644 index 00000000..4e4984e8 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt @@ -0,0 +1,56 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ErrorStateTest { + @Test + fun `registers error and sourcechange`() { + val mock = MockPlayer() + ErrorState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE), mock.playerEventTypes) + } + + @Test + fun `error event sets error and sourcechange clears it`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(mock.player) + state.observe() + assertEquals(null, state.error) + + mock.errorObject = exception + mock.firePlayerEvent(PlayerEventTypes.ERROR) + assertEquals(exception, state.error) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertEquals(null, state.error) + } + + @Test + fun `seeds initial error and sourcechange clears it`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(mock.player, initialError = exception) + state.observe() + assertEquals(exception, state.error) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertEquals(null, state.error) + } + + @Test + fun `null player keeps initial error and registers no listeners`() { + val exception = mockk() + val mock = MockPlayer() + val state = ErrorState(null, initialError = exception) + state.observe() + assertEquals(exception, state.error) + assertTrue(mock.playerListeners.isEmpty()) + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt new file mode 100644 index 00000000..abc28629 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt @@ -0,0 +1,67 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirstPlayStateTest { + @Test + fun `seeds false when paused with empty played`() { + val mock = MockPlayer(paused = true, playedLength = 0) + val state = FirstPlayState(mock.player) + state.observe() + assertFalse(state.firstPlay) + } + + @Test + fun `seeds true when paused with non-empty played`() { + val mock = MockPlayer(paused = true, playedLength = 1) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + } + + @Test + fun `seeds true when playing`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + } + + @Test + fun `play sets firstPlay`() { + val mock = MockPlayer(paused = true) + val state = FirstPlayState(mock.player) + state.observe() + + mock.paused = false + mock.firePlayerEvent(PlayerEventTypes.PLAY) + assertTrue(state.firstPlay) + } + + @Test + fun `sourcechange resets firstPlay while paused`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + + mock.paused = true + mock.playedLength = 0 + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertFalse(state.firstPlay) + } + + @Test + fun `sourcechange keeps firstPlay while playing`() { + val mock = MockPlayer(paused = false) + val state = FirstPlayState(mock.player) + state.observe() + assertTrue(state.firstPlay) + + mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) + assertTrue(state.firstPlay) + } +} 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 new file mode 100644 index 00000000..1d9f3b70 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/LiveButtonStateTest.kt @@ -0,0 +1,58 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveButtonStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + LiveButtonState(mock.player, liveThreshold = 10.0).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + } + + @Test + fun `visible for live but not for vod`() { + val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) + val vod = MockPlayer(duration = 100.0) + + assertTrue(LiveButtonState(live.player, liveThreshold = 10.0).apply { observe() }.visible) + assertFalse(LiveButtonState(vod.player, liveThreshold = 10.0).apply { observe() }.visible) + } + + @Test + fun `isLive at threshold boundary`() { + val mock = MockPlayer( + paused = false, + duration = Double.POSITIVE_INFINITY, + currentTime = 20.0, + seekableRanges = listOf(0.0 to 30.0) + ) + val atThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) + atThreshold.observe() + assertTrue(atThreshold.isLive) + + mock.currentTime = 19.9 + val behindThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) + behindThreshold.observe() + assertFalse(behindThreshold.isLive) + } + + @Test + fun `onClick seeks to live and plays`() { + val mock = MockPlayer(duration = Double.POSITIVE_INFINITY) + val state = LiveButtonState(mock.player, liveThreshold = 10.0) + state.observe() + + state.onClick() + verify { mock.player.currentTime = Double.POSITIVE_INFINITY } + verify(exactly = 1) { mock.player.play() } + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt new file mode 100644 index 00000000..ca8768c4 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt @@ -0,0 +1,30 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.ReadyState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoadingStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + LoadingState(mock.player).observe() + assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) + } + + @Test + fun `loading truth table`() { + fun state(paused: Boolean, ended: Boolean = false, seeking: Boolean = false, readyState: ReadyState) = + LoadingState(MockPlayer(paused = paused, ended = ended, seeking = seeking, readyState = readyState).player) + .apply { observe() }.loading + + assertFalse(state(paused = false, readyState = ReadyState.HAVE_ENOUGH_DATA)) + assertTrue(state(paused = false, readyState = ReadyState.HAVE_CURRENT_DATA)) + assertTrue(state(paused = false, seeking = true, readyState = ReadyState.HAVE_ENOUGH_DATA)) + assertFalse(state(paused = true, readyState = ReadyState.HAVE_CURRENT_DATA)) + assertFalse(state(paused = false, ended = true, readyState = ReadyState.HAVE_CURRENT_DATA)) + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt new file mode 100644 index 00000000..cc84cbc2 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt @@ -0,0 +1,49 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MuteButtonStateTest { + @Test + fun `registers only the volume change event`() { + val mock = MockPlayer() + MuteButtonState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.VOLUMECHANGE), mock.playerEventTypes) + } + + @Test + fun `seeds muted and updates on volume change`() { + val mock = MockPlayer(muted = true) + val state = MuteButtonState(mock.player) + state.observe() + assertTrue(state.muted) + + mock.muted = false + mock.firePlayerEvent(PlayerEventTypes.VOLUMECHANGE) + assertFalse(state.muted) + } + + @Test + fun `onClick toggles player and state`() { + val mock = MockPlayer(muted = false) + val state = MuteButtonState(mock.player) + state.observe() + + state.onClick() + verify { mock.player.isMuted = true } + assertTrue(state.muted) + } + + @Test + fun `null player gives defaults and onClick does nothing`() { + val state = MuteButtonState(null) + state.observe() + assertFalse(state.muted) + state.onClick() + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt new file mode 100644 index 00000000..2480b190 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt @@ -0,0 +1,111 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayButtonStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + PlayButtonState(mock.player).observe() + assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) + } + + @Test + fun `a seeked event clears ended`() { + val mock = MockPlayer(paused = true, ended = true) + val state = PlayButtonState(mock.player) + state.observe() + assertTrue(state.ended) + + mock.ended = false + mock.firePlayerEvent(PlayerEventTypes.SEEKED) + assertFalse(state.ended) + assertTrue(state.paused) + } + + @Test + fun `seeds state and reacts to playback events`() { + val mock = MockPlayer(paused = true, ended = false) + val state = PlayButtonState(mock.player) + state.observe() + + assertTrue(state.paused) + assertFalse(state.ended) + + mock.paused = false + mock.firePlayerEvent() + assertFalse(state.paused) + + mock.ended = true + mock.firePlayerEvent() + assertTrue(state.ended) + } + + @Test + fun `onClick plays when paused and pauses when playing`() { + val mock = MockPlayer(paused = true) + val state = PlayButtonState(mock.player) + state.observe() + + state.onClick() + verify(exactly = 1) { mock.player.play() } + + mock.firePlayerEvent() + state.onClick() + verify(exactly = 1) { mock.player.pause() } + } + + @Test + fun `dispose removes all listeners`() { + val mock = MockPlayer() + val state = PlayButtonState(mock.player) + state.observe() + val added = mock.playerListeners.toList() + assertTrue(added.isNotEmpty()) + + state.dispose() + assertTrue(mock.playerListeners.isEmpty()) + verify(exactly = added.size) { + mock.player.removeEventListener(any>>(), any()) + } + } + + @Test + fun `shares one listener instance and removes it for every type`() { + val mock = MockPlayer() + val state = PlayButtonState(mock.player) + state.observe() + + val added = mock.playerListeners.toList() + val listener = added.first().second + assertTrue(added.all { it.second === listener }) + + state.dispose() + added.forEach { (type, _) -> + @Suppress("UNCHECKED_CAST") + verify { + mock.player.removeEventListener( + type as EventType>, + listener as EventListener> + ) + } + } + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = PlayButtonState(null) + state.observe() + assertTrue(state.paused) + state.onClick() + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt new file mode 100644 index 00000000..3ecb3290 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt @@ -0,0 +1,47 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackRateStateTest { + @Test + fun `registers only the ratechange event`() { + val mock = MockPlayer() + PlaybackRateState(mock.player).observe() + assertEquals(setOf(PlayerEventTypes.RATECHANGE), mock.playerEventTypes) + } + + @Test + fun `seeds rate and updates on ratechange`() { + val mock = MockPlayer(playbackRate = 1.5) + val state = PlaybackRateState(mock.player) + state.observe() + assertEquals(1.5, state.playbackRate, 0.0) + + mock.playbackRate = 2.0 + mock.firePlayerEvent(PlayerEventTypes.RATECHANGE) + assertEquals(2.0, state.playbackRate, 0.0) + } + + @Test + fun `select sets player rate and state`() { + val mock = MockPlayer() + val state = PlaybackRateState(mock.player) + state.observe() + + state.select(2.0) + verify { mock.player.playbackRate = 2.0 } + assertEquals(2.0, state.playbackRate, 0.0) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = PlaybackRateState(null) + state.observe() + assertEquals(1.0, state.playbackRate, 0.0) + state.select(2.0) + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt new file mode 100644 index 00000000..40bccb94 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt @@ -0,0 +1,80 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackTimeStateTest { + @Test + fun `registers the playback events`() { + val mock = MockPlayer() + PlaybackTimeState(mock.player).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + } + + @Test + fun `streamTypeOf classifies vod, live and dvr`() { + assertEquals( + StreamType.Vod, + streamTypeOf(100.0, com.theoplayer.android.ui.TimeRanges(listOf(0.0..100.0))) + ) + assertEquals( + StreamType.Vod, + streamTypeOf(Double.NaN, com.theoplayer.android.ui.TimeRanges.empty()) + ) + assertEquals( + StreamType.Live, + streamTypeOf( + Double.POSITIVE_INFINITY, + com.theoplayer.android.ui.TimeRanges(listOf(0.0..30.0)) + ) + ) + assertEquals( + StreamType.Dvr, + streamTypeOf( + Double.POSITIVE_INFINITY, + com.theoplayer.android.ui.TimeRanges(listOf(0.0..60.0)) + ) + ) + } + + @Test + fun `seeds and updates on time events`() { + val mock = MockPlayer(currentTime = 5.0, duration = 100.0) + val state = PlaybackTimeState(mock.player) + state.observe() + + assertEquals(5.0, state.currentTime, 0.0) + assertEquals(100.0, state.duration, 0.0) + + mock.currentTime = 42.0 + mock.firePlayerEvent() + assertEquals(42.0, state.currentTime, 0.0) + } + + @Test + fun `duration prefers end of seekable range`() { + val mock = MockPlayer( + duration = Double.POSITIVE_INFINITY, + seekableRanges = listOf(10.0 to 70.0) + ) + val state = PlaybackTimeState(mock.player) + state.observe() + + assertEquals(70.0, state.duration, 0.0) + } + + @Test + fun `reports live and dvr stream types`() { + val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) + val dvr = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 120.0)) + val vod = MockPlayer(duration = 100.0) + + assertEquals(StreamType.Live, PlaybackTimeState(live.player).apply { observe() }.streamType) + assertEquals(StreamType.Dvr, PlaybackTimeState(dvr.player).apply { observe() }.streamType) + assertEquals(StreamType.Vod, PlaybackTimeState(vod.player).apply { observe() }.streamType) + } +} 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 new file mode 100644 index 00000000..ff89fe33 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt @@ -0,0 +1,116 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SeekBarStateTest { + @Test + fun `registers the playback and ad events`() { + val mock = MockPlayer() + SeekBarState(mock.player).observe() + assertEquals( + playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, + mock.playerEventTypes + ) + assertEquals(adEventTypes, mock.adEventTypes) + } + + @Test + fun `an error event re-reads playingAd`() { + val mock = MockPlayer(adPlaying = false) + val state = SeekBarState(mock.player) + state.observe() + assertTrue(state.showThumb) + + mock.adPlaying = true + mock.firePlayerEvent(PlayerEventTypes.ERROR) + assertFalse(state.enabled) + assertFalse(state.showThumb) + } + + @Test + fun `computes value within zero-based range`() { + val mock = MockPlayer(currentTime = 30.0, duration = 100.0) + val state = SeekBarState(mock.player) + state.observe() + + assertEquals(0f..100f, state.valueRange) + assertEquals(30f, state.value) + assertTrue(state.enabled) + assertTrue(state.showThumb) + } + + @Test + fun `huge seekable start maps to zero-based range`() { + val mock = MockPlayer( + currentTime = 1.7e9 + 30.0, + duration = 1.7e9 + 100.0, + seekableRanges = listOf(1.7e9 to 1.7e9 + 100.0) + ) + val state = SeekBarState(mock.player) + state.observe() + + assertEquals(0f, state.valueRange.start) + assertEquals(100f, state.valueRange.endInclusive, 0.001f) + assertEquals(30f, state.value, 0.001f) + } + + @Test + fun `scrubbing pauses a playing player and resumes on finish`() { + val mock = MockPlayer(paused = false, currentTime = 10.0) + val state = SeekBarState(mock.player) + state.observe() + + 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.play() } + } + + @Test + fun `scrubbing a paused player does not resume it`() { + val mock = MockPlayer(paused = true, currentTime = 10.0) + val state = SeekBarState(mock.player) + state.observe() + + state.onValueChange(50f) + state.onValueChangeFinished() + verify(exactly = 0) { mock.player.play() } + } + + @Test + fun `disabled during ads and thumb hidden`() { + val mock = MockPlayer(adPlaying = true) + val state = SeekBarState(mock.player) + state.observe() + + assertFalse(state.enabled) + assertFalse(state.showThumb) + + mock.adPlaying = false + mock.fireAdEvent() + assertTrue(state.enabled) + assertTrue(state.showThumb) + } + + @Test + fun `dispose removes all listeners`() { + val mock = MockPlayer() + val state = SeekBarState(mock.player) + state.observe() + val added = mock.playerListeners.toList() + assertTrue(added.isNotEmpty()) + assertTrue(mock.adListeners.isNotEmpty()) + + state.dispose() + assertTrue(mock.playerListeners.isEmpty()) + assertTrue(mock.adListeners.isEmpty()) + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt new file mode 100644 index 00000000..eb9f82fe --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt @@ -0,0 +1,65 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class SeekButtonStateTest { + @Test + fun `registers the playback and ad events`() { + val mock = MockPlayer() + SeekButtonState(mock.player, seekOffset = 10).observe() + assertEquals(playbackEventTypes, mock.playerEventTypes) + assertEquals(adEventTypes, mock.adEventTypes) + } + + @Test + fun `onClick seeks by offset and clamps to duration`() { + val mock = MockPlayer(currentTime = 95.0, duration = 100.0) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + state.onClick() + verify { mock.player.currentTime = 100.0 } + } + + @Test + fun `onClick clamps negative seek to zero`() { + val mock = MockPlayer(currentTime = 5.0, duration = 100.0) + val state = SeekButtonState(mock.player, seekOffset = -10) + state.observe() + + state.onClick() + verify { mock.player.currentTime = 0.0 } + } + + @Test + fun `onClick does nothing for streams without duration`() { + val mock = MockPlayer(currentTime = 50.0, duration = Double.NaN) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + state.onClick() + verify(exactly = 0) { mock.player.currentTime = any() } + } + + @Test + fun `disabled during ads`() { + val mock = MockPlayer(adPlaying = true) + val state = SeekButtonState(mock.player, seekOffset = 10) + state.observe() + + assertFalse(state.enabled) + } + + @Test + fun `null player is disabled and onClick does nothing`() { + val state = SeekButtonState(null, seekOffset = 10) + state.observe() + assertFalse(state.enabled) + state.onClick() + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt new file mode 100644 index 00000000..ceb82267 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt @@ -0,0 +1,79 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.player.track.texttrack.TextTrackKind +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubtitleTrackStateTest { + private fun playerWithTracks(vararg tracks: MockTextTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.textTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers the track list events with one listener`() { + val mock = playerWithTracks(MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING)) + val state = SubtitleTrackState(mock.player) + state.observe() + + val listener = mock.textTrackListeners.first().second + assertTrue(mock.textTrackListeners.all { it.second === listener }) + assertEquals(3, mock.textTrackListeners.size) + + state.dispose() + assertTrue(mock.textTrackListeners.isEmpty()) + } + + @Test + fun `filters out non-subtitle tracks`() { + val subtitles = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) + val captions = MockTextTrack(TextTrackKind.CAPTIONS, TextTrackMode.SHOWING) + val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) + val mock = playerWithTracks(subtitles, captions, metadata) + val state = SubtitleTrackState(mock.player) + state.observe() + + assertEquals(listOf(subtitles.track, captions.track), state.subtitleTracks) + assertEquals(captions.track, state.activeSubtitleTrack) + } + + @Test + fun `select shows the track and disables the other subtitles`() { + val first = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) + val second = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) + val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) + val mock = playerWithTracks(first, second, metadata) + val state = SubtitleTrackState(mock.player) + state.observe() + + state.select(second.track) + assertEquals(TextTrackMode.DISABLED, first.mode) + assertEquals(TextTrackMode.SHOWING, second.mode) + assertEquals(TextTrackMode.DISABLED, metadata.mode) + assertEquals(second.track, state.activeSubtitleTrack) + } + + @Test + fun `select null disables all subtitle tracks`() { + val showing = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) + val mock = playerWithTracks(showing) + val state = SubtitleTrackState(mock.player) + state.observe() + + state.select(null) + assertEquals(TextTrackMode.DISABLED, showing.mode) + assertEquals(null, state.activeSubtitleTrack) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = SubtitleTrackState(null) + state.observe() + assertTrue(state.subtitleTracks.isEmpty()) + assertEquals(null, state.activeSubtitleTrack) + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt new file mode 100644 index 00000000..87725970 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt @@ -0,0 +1,99 @@ +package com.theoplayer.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class VideoQualityStateTest { + private fun playerWithTracks(vararg tracks: MockVideoTrack): MockPlayer { + return MockPlayer().also { mock -> + mock.videoTrackList.addAll(tracks.map { it.track }) + } + } + + @Test + fun `registers list events plus quality events on the enabled track`() { + val enabled = MockVideoTrack(enabled = true) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + assertEquals(3, mock.videoTrackListeners.size) + assertEquals(2, enabled.listeners.size) + + state.dispose() + assertTrue(mock.videoTrackListeners.isEmpty()) + assertTrue(enabled.listeners.isEmpty()) + } + + @Test + fun `seeds qualities from the enabled track`() { + val quality = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, activeQuality = quality, qualities = listOf(quality)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + assertEquals(listOf(quality), state.videoQualities) + assertEquals(quality, state.activeVideoQuality) + } + + @Test + fun `quality events update active and target`() { + val old = mockVideoQuality() + val new = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, activeQuality = old, qualities = listOf(old, new)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + enabled.activeQuality = new + enabled.targetQuality = new + enabled.fireEvent() + assertEquals(new, state.activeVideoQuality) + assertEquals(new, state.targetVideoQuality) + } + + @Test + fun `select sets the track target quality`() { + val quality = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, qualities = listOf(quality)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + state.observe() + + state.select(quality) + assertEquals(quality, enabled.targetQuality) + assertEquals(quality, state.targetVideoQuality) + + state.select(null) + assertEquals(null, enabled.targetQuality) + } + + @Test + fun `switching the enabled track moves the quality listeners`() { + val first = MockVideoTrack(enabled = true) + val second = MockVideoTrack(enabled = false) + val mock = playerWithTracks(first, second) + val state = VideoQualityState(mock.player) + state.observe() + assertEquals(2, first.listeners.size) + + first.enabled = false + second.enabled = true + mock.videoTrackListeners.fireTrackListEvent() + + assertEquals(0, first.listeners.size) + assertEquals(2, second.listeners.size) + } + + @Test + fun `null player gives defaults and no listeners`() { + val state = VideoQualityState(null) + state.observe() + assertTrue(state.videoQualities.isEmpty()) + assertEquals(null, state.activeVideoQuality) + assertEquals(null, state.targetVideoQuality) + state.dispose() + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt new file mode 100644 index 00000000..98f334c4 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt @@ -0,0 +1,32 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.player.PlayerEventTypes +import org.junit.Assert.assertEquals +import org.junit.Test + +class VideoSizeStateTest { + @Test + fun `registers resize sourcechange and error`() { + val mock = MockPlayer() + VideoSizeState(mock.player).observe() + assertEquals( + setOf(PlayerEventTypes.RESIZE, PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), + mock.playerEventTypes + ) + } + + @Test + fun `seeds size and updates on resize`() { + val mock = MockPlayer(videoWidth = 1920, videoHeight = 1080) + val state = VideoSizeState(mock.player) + state.observe() + assertEquals(1920, state.videoWidth) + assertEquals(1080, state.videoHeight) + + mock.videoWidth = 1280 + mock.videoHeight = 720 + mock.firePlayerEvent(PlayerEventTypes.RESIZE) + assertEquals(1280, state.videoWidth) + assertEquals(720, state.videoHeight) + } +} From f4b440101a17b15b8d876881368fee0740b2906e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:47:12 +0000 Subject: [PATCH 028/158] Share one fullscreen handler per THEOplayerView Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/FullscreenHandler.kt | 37 +++++++++++++++++-- .../java/com/theoplayer/android/ui/Player.kt | 8 ++-- ui-common/src/main/res/values/ids.xml | 4 ++ 3 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 ui-common/src/main/res/values/ids.xml diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt index 81192e4a..b148e4fa 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -7,6 +7,8 @@ import android.view.ViewGroup import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.common.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -15,7 +17,8 @@ import kotlin.coroutines.suspendCoroutine internal interface FullscreenHandler { val fullscreen: Boolean - var onFullscreenChangeListener: OnFullscreenChangeListener? + fun addOnFullscreenChangeListener(listener: OnFullscreenChangeListener) + fun removeOnFullscreenChangeListener(listener: OnFullscreenChangeListener) fun requestFullscreen() fun exitFullscreen() @@ -24,10 +27,34 @@ internal interface FullscreenHandler { } } +/** + * The [FullscreenHandler] shared by everything that controls fullscreen for this view. + * + * A fullscreen handler moves the view within the activity, so there can only be one per + * [THEOplayerView]. The shared instance is stored in the view's tag. + */ +internal val THEOplayerView.fullscreenHandler: FullscreenHandler? + get() { + (getTag(R.id.theoplayer_ui_fullscreen_handler) as? FullscreenHandler)?.let { return it } + val container = findViewById(com.theoplayer.android.R.id.theo_player_container) + ?: return null + return FullscreenHandlerImpl(container).also { + setTag(R.id.theoplayer_ui_fullscreen_handler, it) + } + } + internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler { override var fullscreen: Boolean = false private set - override var onFullscreenChangeListener: FullscreenHandler.OnFullscreenChangeListener? = null + private val listeners = mutableListOf() + + override fun addOnFullscreenChangeListener(listener: FullscreenHandler.OnFullscreenChangeListener) { + listeners.add(listener) + } + + override fun removeOnFullscreenChangeListener(listener: FullscreenHandler.OnFullscreenChangeListener) { + listeners.remove(listener) + } private var previousSystemBarsBehavior: Int = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT private var previousRequestedOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -41,6 +68,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler } suspend fun requestFullscreenAsync() { + if (fullscreen) return val activity = view.context as? Activity ?: return val window = activity.window @@ -74,7 +102,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler } fullscreen = true - onFullscreenChangeListener?.onFullscreenChange(fullscreen) + listeners.toList().forEach { it.onFullscreenChange(fullscreen) } } override fun exitFullscreen() { @@ -82,6 +110,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler } private suspend fun exitFullscreenAsync() { + if (!fullscreen) return val activity = view.context as? Activity ?: return val window = activity.window @@ -106,7 +135,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler previousViewIndex = 0 fullscreen = false - onFullscreenChangeListener?.onFullscreenChange(fullscreen) + listeners.toList().forEach { it.onFullscreenChange(fullscreen) } } } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt index d8b85ecf..57d09d8c 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -507,9 +507,7 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { private val rateChangeListener = EventListener { updatePlaybackRate() } - private val fullscreenHandler: FullscreenHandler? = - theoplayerView?.findViewById(com.theoplayer.android.R.id.theo_player_container) - ?.let { FullscreenHandlerImpl(it) } + private val fullscreenHandler: FullscreenHandler? = theoplayerView?.fullscreenHandler private var _fullscreen by mutableStateOf(false) private var onExitFullscreen: (() -> Unit)? = null override var fullscreen: Boolean @@ -829,7 +827,7 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { ads?.addEventListener(AdsEventTypes.AD_BREAK_BEGIN, adListener) ads?.addEventListener(AdsEventTypes.AD_SKIP, adListener) ads?.addEventListener(AdsEventTypes.AD_BREAK_END, adListener) - fullscreenHandler?.onFullscreenChangeListener = fullscreenListener + fullscreenHandler?.addOnFullscreenChangeListener(fullscreenListener) } fun dispose() { @@ -895,6 +893,6 @@ class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { ads?.removeEventListener(AdsEventTypes.AD_SKIP, adListener) ads?.removeEventListener(AdsEventTypes.AD_BREAK_END, adListener) updateCast(null) - fullscreenHandler?.onFullscreenChangeListener = null + fullscreenHandler?.removeOnFullscreenChangeListener(fullscreenListener) } } \ No newline at end of file diff --git a/ui-common/src/main/res/values/ids.xml b/ui-common/src/main/res/values/ids.xml new file mode 100644 index 00000000..ab213491 --- /dev/null +++ b/ui-common/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + From 26e778d1c1dd1bcf9bc856f8d81ec0d987ffa096 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 12:47:12 +0000 Subject: [PATCH 029/158] Add experimental FullscreenState Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + .../theoplayer/android/ui/FullscreenState.kt | 72 +++++++++ .../android/ui/FullscreenStateTest.kt | 153 ++++++++++++++++++ .../theoplayer/android/ui/FullscreenButton.kt | 47 +++++- 4 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 93929d0c..82f498d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. * 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt new file mode 100644 index 00000000..1d0bb2ea --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt @@ -0,0 +1,72 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.THEOplayerView + +/** + * State holder for a fullscreen button, such as [FullscreenButton]. + * + * All fullscreen states (and the [Player]) for the same view share one fullscreen handler. + * + * Create an instance with [rememberFullscreenState]. + */ +@ExperimentalPlayerStateApi +class FullscreenState internal constructor(private val theoplayerView: THEOplayerView?) { + private val handler: FullscreenHandler? = theoplayerView?.fullscreenHandler + private val listener = FullscreenHandler.OnFullscreenChangeListener { update() } + + /** + * Whether the player is shown in fullscreen mode. + */ + var fullscreen by mutableStateOf(false) + private set + + private fun update() { + fullscreen = handler?.fullscreen ?: false + } + + internal fun observe() { + val handler = handler ?: return + update() + handler.addOnFullscreenChangeListener(listener) + } + + internal fun dispose() { + handler?.removeOnFullscreenChangeListener(listener) + } + + /** + * Shows the player in fullscreen mode. + */ + fun enterFullscreen() { + fullscreen = true + handler?.requestFullscreen() + } + + /** + * Shows the player inline again. + */ + fun exitFullscreen() { + fullscreen = false + handler?.exitFullscreen() + } +} + +/** + * Creates a [FullscreenState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberFullscreenState(theoplayerView: THEOplayerView?): FullscreenState { + val state = remember(theoplayerView) { FullscreenState(theoplayerView) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt new file mode 100644 index 00000000..dd17e5a6 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt @@ -0,0 +1,153 @@ +package com.theoplayer.android.ui + +import android.view.View +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.common.R +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A [FullscreenHandler] that records its listeners and request calls, + * so tests can fire fullscreen changes at them. + */ +private class FakeFullscreenHandler : FullscreenHandler { + override var fullscreen: Boolean = false + private set + val listeners = mutableListOf() + var requestCount = 0 + var exitCount = 0 + + override fun addOnFullscreenChangeListener(listener: FullscreenHandler.OnFullscreenChangeListener) { + listeners.add(listener) + } + + override fun removeOnFullscreenChangeListener(listener: FullscreenHandler.OnFullscreenChangeListener) { + listeners.remove(listener) + } + + override fun requestFullscreen() { + requestCount++ + } + + override fun exitFullscreen() { + exitCount++ + } + + fun fire(fullscreen: Boolean) { + this.fullscreen = fullscreen + listeners.toList().forEach { it.onFullscreenChange(fullscreen) } + } +} + +private fun mockViewWithHandler(handler: FullscreenHandler?): THEOplayerView = mockk { + every { getTag(R.id.theoplayer_ui_fullscreen_handler) } returns handler +} + +class FullscreenStateTest { + @Test + fun `null view gives defaults and does not throw`() { + val state = FullscreenState(null) + state.observe() + assertFalse(state.fullscreen) + state.enterFullscreen() + state.exitFullscreen() + assertTrue(state.fullscreen.not()) + state.dispose() + } + + @Test + fun `observe seeds fullscreen and adds one listener`() { + val handler = FakeFullscreenHandler().also { it.fire(true) } + val state = FullscreenState(mockViewWithHandler(handler)) + state.observe() + + assertTrue(state.fullscreen) + assertEquals(1, handler.listeners.size) + } + + @Test + fun `dispose removes the listener`() { + val handler = FakeFullscreenHandler() + val state = FullscreenState(mockViewWithHandler(handler)) + state.observe() + assertEquals(1, handler.listeners.size) + + state.dispose() + assertTrue(handler.listeners.isEmpty()) + } + + @Test + fun `handler notification updates fullscreen`() { + val handler = FakeFullscreenHandler() + val state = FullscreenState(mockViewWithHandler(handler)) + state.observe() + assertFalse(state.fullscreen) + + handler.fire(true) + assertTrue(state.fullscreen) + } + + @Test + fun `enterFullscreen updates state and calls the handler once`() { + val handler = FakeFullscreenHandler() + val state = FullscreenState(mockViewWithHandler(handler)) + state.observe() + + state.enterFullscreen() + assertTrue(state.fullscreen) + assertEquals(1, handler.requestCount) + } + + @Test + fun `exitFullscreen updates state and calls the handler once`() { + val handler = FakeFullscreenHandler().also { it.fire(true) } + val state = FullscreenState(mockViewWithHandler(handler)) + state.observe() + + state.exitFullscreen() + assertFalse(state.fullscreen) + assertEquals(1, handler.exitCount) + } + + @Test + fun `two states on the same view share the handler`() { + val handler = FakeFullscreenHandler() + val view = mockViewWithHandler(handler) + val first = FullscreenState(view) + val second = FullscreenState(view) + first.observe() + second.observe() + assertEquals(2, handler.listeners.size) + + handler.fire(true) + assertTrue(first.fullscreen) + assertTrue(second.fullscreen) + } + + @Test + fun `fullscreenHandler returns the tagged handler without findViewById`() { + val handler = FakeFullscreenHandler() + val view = mockViewWithHandler(handler) + + assertSame(handler, view.fullscreenHandler) + verify(exactly = 0) { + view.findViewById(com.theoplayer.android.R.id.theo_player_container) + } + } + + @Test + fun `fullscreenHandler returns null without a container`() { + val view = mockk { + every { getTag(R.id.theoplayer_ui_fullscreen_handler) } returns null + every { findViewById(com.theoplayer.android.R.id.theo_player_container) } returns null + } + + assertEquals(null, view.fullscreenHandler) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt index 16b63c8a..a28f1f06 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt @@ -36,16 +36,55 @@ fun FullscreenButton( ) } ) { - val player = Player.current + FullscreenButton( + state = rememberFullscreenState(LocalTHEOplayerView.current), + modifier = modifier, + contentPadding = contentPadding, + enter = enter, + exit = exit + ) +} + +/** + * A button that toggles fullscreen. + * + * @param state the [FullscreenState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param enter button content when the player is not fullscreen, typically an "enter fullscreen" icon + * @param exit button content when the player is fullscreen, typically an "exit fullscreen" icon + */ +@ExperimentalPlayerStateApi +@Composable +fun FullscreenButton( + state: FullscreenState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + enter: @Composable () -> Unit = { + Icon( + Icons.Rounded.Fullscreen, + contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_enter) + ) + }, + exit: @Composable () -> Unit = { + Icon( + Icons.Rounded.FullscreenExit, + contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_exit) + ) + } +) { IconButton( modifier = modifier, contentPadding = contentPadding, onClick = { - player?.let { - it.fullscreen = !it.fullscreen + if (state.fullscreen) { + state.exitFullscreen() + } else { + state.enterFullscreen() } }) { - if (player?.fullscreen == true) { + if (state.fullscreen) { exit() } else { enter() From a2b41b1a2103fbb7ae09dad80ccee5d23a4c6e7d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 13:11:23 +0000 Subject: [PATCH 030/158] 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 82f498d2..b6c9d197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. +* 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. ([#114](https://github.com/THEOplayer/android-ui/pull/114)) * 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) From f6c09f6ee8c48be25f9eb9c796e35388957c87a4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 13:21:16 +0000 Subject: [PATCH 031/158] Use CopyOnWriteArrayList for fullscreen listeners Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/FullscreenHandler.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt index b148e4fa..eafbbf4f 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -9,6 +9,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.ui.common.R +import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -46,7 +47,7 @@ internal val THEOplayerView.fullscreenHandler: FullscreenHandler? internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler { override var fullscreen: Boolean = false private set - private val listeners = mutableListOf() + private val listeners = CopyOnWriteArrayList() override fun addOnFullscreenChangeListener(listener: FullscreenHandler.OnFullscreenChangeListener) { listeners.add(listener) @@ -102,7 +103,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler } fullscreen = true - listeners.toList().forEach { it.onFullscreenChange(fullscreen) } + listeners.forEach { it.onFullscreenChange(fullscreen) } } override fun exitFullscreen() { @@ -135,7 +136,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler previousViewIndex = 0 fullscreen = false - listeners.toList().forEach { it.onFullscreenChange(fullscreen) } + listeners.forEach { it.onFullscreenChange(fullscreen) } } } From d1a5a22c0a151c25f08363c0b41462150335843e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:09:21 +0000 Subject: [PATCH 032/158] Add picture-in-picture and cast state holders Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/CastState.kt | 118 ++++++++++++++ .../android/ui/PictureInPictureState.kt | 109 +++++++++++++ .../theoplayer/android/ui/CastStateTest.kt | 146 ++++++++++++++++++ .../android/ui/FullscreenStateTest.kt | 2 +- .../android/ui/PictureInPictureStateTest.kt | 119 ++++++++++++++ 5 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt new file mode 100644 index 00000000..14e37220 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt @@ -0,0 +1,118 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.THEOplayerView +import com.theoplayer.android.api.cast.Cast +import com.theoplayer.android.api.cast.chromecast.PlayerCastState +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.chromecast.ChromecastEvent +import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes + +/** + * State holder for Chromecast controls, such as [ChromecastButton] and [ChromecastDisplay]. + * + * The cast integration is only registered after the view is created, so this keeps track + * of the current [Cast] integration and moves its listeners when it changes. + * + * Create an instance with [rememberCastState]. + */ +@ExperimentalPlayerStateApi +class CastState internal constructor(private val theoplayerView: THEOplayerView?) { + private val listeners = PlayerStateListeners() + private var cast: Cast? = null + private var observing = false + + /** + * The current cast state of the player. + */ + val castState: PlayerCastState + get() { + if (observing) updateCast(theoplayerView?.cast) + return _castState + } + private var _castState: PlayerCastState by mutableStateOf(PlayerCastState.UNAVAILABLE) + + /** + * The name of the connected Chromecast receiver, if any. + */ + val castReceiverName: String? + get() { + if (observing) updateCast(theoplayerView?.cast) + return _castReceiverName + } + private var _castReceiverName: String? by mutableStateOf(null) + + /** + * Whether the player is casting. + */ + val casting: Boolean + get() { + if (observing) updateCast(theoplayerView?.cast) + return _casting + } + private var _casting: Boolean by mutableStateOf(false) + + private fun update() { + _castState = cast?.chromecast?.state ?: PlayerCastState.UNAVAILABLE + _castReceiverName = cast?.chromecast?.receiverName + _casting = cast?.chromecast?.isCasting == true + } + + private fun updateCast(newCast: Cast?) { + if (newCast === cast) return + listeners.removeAll() + cast = newCast + newCast?.chromecast?.let { chromecast -> + listeners.run { + val listener = EventListener> { update() } + chromecast.add(ChromecastEventTypes.STATECHANGE, listener) + chromecast.add(ChromecastEventTypes.ERROR, listener) + } + } + update() + } + + internal fun observe() { + observing = true + updateCast(theoplayerView?.cast) + } + + internal fun dispose() { + observing = false + listeners.removeAll() + cast = null + } + + /** + * Starts casting to a Chromecast receiver. + */ + fun startCasting() { + theoplayerView?.cast?.chromecast?.start() + } + + /** + * Stops casting. + */ + fun stopCasting() { + theoplayerView?.cast?.chromecast?.stop() + } +} + +/** + * Creates a [CastState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberCastState(theoplayerView: THEOplayerView?): CastState { + val state = remember(theoplayerView) { CastState(theoplayerView) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt new file mode 100644 index 00000000..7e2bca95 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt @@ -0,0 +1,109 @@ +package com.theoplayer.android.ui + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.THEOplayerView +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.pip.PiPType +import com.theoplayer.android.ui.util.rememberIsInPipMode +import com.theoplayer.android.ui.util.supportsPictureInPictureMode + +/** + * State holder for a picture-in-picture button, such as [PictureInPictureButton]. + * + * Create an instance with [rememberPictureInPictureState]. + */ +@ExperimentalPlayerStateApi +class PictureInPictureState internal constructor(private val theoplayerView: THEOplayerView?) { + private val listeners = PlayerStateListeners() + private var pendingPiPListener: FullscreenHandler.OnFullscreenChangeListener? = null + + /** + * Whether the activity and player support picture-in-picture mode. + */ + val pictureInPictureSupported: Boolean = theoplayerView?.let { view -> + (view.context as? Activity)?.let { activity -> + view.piPManager != null && activity.supportsPictureInPictureMode() + } + } ?: false + + /** + * Whether the player is in (or transitioning to) picture-in-picture mode. + */ + val pictureInPicture: Boolean + get() = activityInPipMode || pipManagerInPipMode + + internal var activityInPipMode by mutableStateOf(false) + private var pipManagerInPipMode by mutableStateOf(false) + + private fun update() { + pipManagerInPipMode = theoplayerView?.piPManager?.isInPiP == true + } + + internal fun observe() { + val player = theoplayerView?.player ?: return + update() + listeners.run { + val listener = EventListener> { update() } + player.add(PlayerEventTypes.PRESENTATIONMODECHANGE, listener) + } + } + + internal fun dispose() { + listeners.removeAll() + pendingPiPListener?.let { theoplayerView?.fullscreenHandler?.removeOnFullscreenChangeListener(it) } + pendingPiPListener = null + } + + /** + * Enters picture-in-picture mode, leaving fullscreen first if needed. + */ + fun enterPictureInPicture(pipType: PiPType) { + val piPManager = theoplayerView?.piPManager + val handler = theoplayerView?.fullscreenHandler + if (handler?.fullscreen == true) { + pendingPiPListener?.let { handler.removeOnFullscreenChangeListener(it) } + val listener = FullscreenHandler.OnFullscreenChangeListener { fullscreen -> + if (!fullscreen) { + handler.removeOnFullscreenChangeListener(pendingPiPListener ?: return@OnFullscreenChangeListener) + pendingPiPListener = null + piPManager?.enterPiP(pipType) + } + } + pendingPiPListener = listener + handler.addOnFullscreenChangeListener(listener) + handler.exitFullscreen() + } else { + piPManager?.enterPiP(pipType) + } + } + + /** + * Leaves picture-in-picture mode. + */ + fun exitPictureInPicture() { + theoplayerView?.piPManager?.exitPiP() + } +} + +/** + * Creates a [PictureInPictureState] that's remembered across compositions. + */ +@ExperimentalPlayerStateApi +@Composable +fun rememberPictureInPictureState(theoplayerView: THEOplayerView?): PictureInPictureState { + val state = remember(theoplayerView) { PictureInPictureState(theoplayerView) } + DisposableEffect(state) { + state.observe() + onDispose { state.dispose() } + } + state.activityInPipMode = rememberIsInPipMode() + return state +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt new file mode 100644 index 00000000..d0786e27 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt @@ -0,0 +1,146 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.cast.Cast +import com.theoplayer.android.api.cast.chromecast.Chromecast +import com.theoplayer.android.api.cast.chromecast.PlayerCastState +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.chromecast.ChromecastEvent +import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CastStateTest { + private fun mockCast( + state: PlayerCastState = PlayerCastState.AVAILABLE, + receiverName: String? = "Receiver", + casting: Boolean = false + ): Triple, EventListener<*>>>, Chromecast> { + val listeners = mutableListOf, EventListener<*>>>() + val chromecast = mockk { + every { this@mockk.state } answers { state } + every { this@mockk.receiverName } returns receiverName + every { isCasting } answers { casting } + every { start() } returns Unit + every { stop() } returns Unit + every { + addEventListener(any>>(), any()) + } answers { + listeners.add(firstArg>() to secondArg>()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + listeners.remove(firstArg>() to secondArg>()) + Unit + } + } + return Triple( + mockk { every { this@mockk.chromecast } returns chromecast }, + listeners, + chromecast + ) + } + + private fun mockView(cast: Cast): THEOplayerView = mockk { + every { this@mockk.cast } returns cast + } + + @Test + fun `null view and null cast give defaults`() { + val state = CastState(null) + state.observe() + assertEquals(PlayerCastState.UNAVAILABLE, state.castState) + assertEquals(null, state.castReceiverName) + assertFalse(state.casting) + state.startCasting() + state.stopCasting() + state.dispose() + } + + @Test + fun `observe registers both events with one listener and dispose removes them`() { + val (cast, listeners, _) = mockCast() + val view = mockView(cast) + val state = CastState(view) + state.observe() + + assertEquals(2, listeners.size) + assertEquals( + setOf(ChromecastEventTypes.STATECHANGE, ChromecastEventTypes.ERROR), + listeners.map { it.first }.toSet() + ) + assertTrue(listeners.all { it.second === listeners.first().second }) + + state.dispose() + assertTrue(listeners.isEmpty()) + } + + @Test + fun `seeds and updates on events`() { + val (cast, listeners, _) = mockCast(state = PlayerCastState.CONNECTED, casting = true) + val view = mockView(cast) + val state = CastState(view) + state.observe() + + assertEquals(PlayerCastState.CONNECTED, state.castState) + assertEquals("Receiver", state.castReceiverName) + assertTrue(state.casting) + + listeners.toList().forEach { (_, listener) -> + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(mockk()) + } + assertEquals(PlayerCastState.CONNECTED, state.castState) + } + + @Test + fun `start and stop delegate to the chromecast`() { + val (cast, _, chromecast) = mockCast() + val view = mockView(cast) + val state = CastState(view) + state.observe() + + state.startCasting() + verify { chromecast.start() } + state.stopCasting() + verify { chromecast.stop() } + } + + @Test + fun `replacing the cast integration moves the listeners`() { + val (oldCast, oldListeners, _) = mockCast() + val (newCast, newListeners, _) = mockCast(state = PlayerCastState.CONNECTED) + var currentCast: Cast = oldCast + val view = mockk { + every { this@mockk.cast } answers { currentCast } + } + val state = CastState(view) + state.observe() + assertEquals(2, oldListeners.size) + + currentCast = newCast + assertEquals(PlayerCastState.CONNECTED, state.castState) + assertTrue(oldListeners.isEmpty()) + assertEquals(2, newListeners.size) + } + + @Test + fun `getters before observe register nothing`() { + val (cast, listeners, _) = mockCast() + val view = mockView(cast) + val state = CastState(view) + + state.castState + state.castReceiverName + state.casting + assertTrue(listeners.isEmpty()) + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt index dd17e5a6..50291464 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt @@ -16,7 +16,7 @@ import org.junit.Test * A [FullscreenHandler] that records its listeners and request calls, * so tests can fire fullscreen changes at them. */ -private class FakeFullscreenHandler : FullscreenHandler { +internal class FakeFullscreenHandler : FullscreenHandler { override var fullscreen: Boolean = false private set val listeners = mutableListOf() diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt new file mode 100644 index 00000000..b12bacb7 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt @@ -0,0 +1,119 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.pip.PiPManager +import com.theoplayer.android.api.pip.PiPType +import com.theoplayer.android.ui.common.R +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PictureInPictureStateTest { + private fun mockView( + player: MockPlayer = MockPlayer(), + piPManager: PiPManager? = mockk(relaxed = true), + fullscreenHandler: FullscreenHandler? = null + ): THEOplayerView = mockk { + every { context } returns mockk(relaxed = true) + every { this@mockk.player } returns player.player + every { this@mockk.piPManager } returns piPManager + every { getTag(R.id.theoplayer_ui_fullscreen_handler) } returns fullscreenHandler + } + + @Test + fun `null view gives defaults and does not throw`() { + val state = PictureInPictureState(null) + state.observe() + assertFalse(state.pictureInPictureSupported) + assertFalse(state.pictureInPicture) + state.enterPictureInPicture(PiPType.ACTIVITY) + state.exitPictureInPicture() + state.dispose() + } + + @Test + fun `observe registers presentationmodechange and dispose removes it`() { + val mock = MockPlayer() + val state = PictureInPictureState(mockView(player = mock)) + state.observe() + + assertEquals(setOf(PlayerEventTypes.PRESENTATIONMODECHANGE), mock.playerEventTypes) + + state.dispose() + assertTrue(mock.playerListeners.isEmpty()) + } + + @Test + fun `seeds and updates pip mode on presentationmodechange`() { + val piPManager = mockk { + every { isInPiP } returns true + } + val mock = MockPlayer() + val state = PictureInPictureState(mockView(player = mock, piPManager = piPManager)) + state.observe() + assertTrue(state.pictureInPicture) + + every { piPManager.isInPiP } returns false + mock.firePlayerEvent(PlayerEventTypes.PRESENTATIONMODECHANGE) + assertFalse(state.pictureInPicture) + } + + @Test + fun `activityInPipMode alone counts as pictureInPicture`() { + val state = PictureInPictureState(mockView()) + state.activityInPipMode = true + assertTrue(state.pictureInPicture) + } + + @Test + fun `enter when not fullscreen calls enterPiP directly`() { + val piPManager = mockk(relaxed = true) + val handler = FakeFullscreenHandler() + val state = PictureInPictureState( + mockView(piPManager = piPManager, fullscreenHandler = handler) + ) + + state.enterPictureInPicture(PiPType.ACTIVITY) + verify(exactly = 1) { piPManager.enterPiP(PiPType.ACTIVITY) } + assertTrue(handler.listeners.isEmpty()) + } + + @Test + fun `enter while fullscreen waits for the exit notification`() { + val piPManager = mockk(relaxed = true) + val handler = FakeFullscreenHandler().also { it.fire(true) } + val state = PictureInPictureState( + mockView(piPManager = piPManager, fullscreenHandler = handler) + ) + + state.enterPictureInPicture(PiPType.ACTIVITY) + assertEquals(1, handler.exitCount) + verify(exactly = 0) { piPManager.enterPiP(any()) } + assertEquals(1, handler.listeners.size) + + handler.fire(false) + verify(exactly = 1) { piPManager.enterPiP(PiPType.ACTIVITY) } + assertTrue(handler.listeners.isEmpty()) + } + + @Test + fun `dispose cancels a pending enterPiP`() { + val piPManager = mockk(relaxed = true) + val handler = FakeFullscreenHandler().also { it.fire(true) } + val state = PictureInPictureState( + mockView(piPManager = piPManager, fullscreenHandler = handler) + ) + + state.enterPictureInPicture(PiPType.ACTIVITY) + state.dispose() + assertTrue(handler.listeners.isEmpty()) + + handler.fire(false) + verify(exactly = 0) { piPManager.enterPiP(any()) } + } +} From a321931e4dfb550c91dd74bc796c5356cfbbe3a3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:09:21 +0000 Subject: [PATCH 033/158] Add state overloads for the picture-in-picture and Chromecast components Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/ChromecastButton.kt | 65 +++++++++++++--- .../android/ui/ChromecastDisplay.kt | 78 +++++++++++++++---- .../android/ui/PictureInPictureButton.kt | 57 ++++++++++++-- 3 files changed, 170 insertions(+), 30 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt index 6e317b3e..00908bd1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt @@ -48,9 +48,56 @@ fun ChromecastButton( ) } ) { - val player = Player.current - val castState = player?.castState ?: PlayerCastState.UNAVAILABLE - if (castState == PlayerCastState.UNAVAILABLE) { + ChromecastButton( + state = rememberCastState(LocalTHEOplayerView.current), + modifier = modifier, + contentPadding = contentPadding, + availableIcon = availableIcon, + connectingIcon = connectingIcon, + connectedIcon = connectedIcon + ) +} + +/** + * A button to start and stop casting using Chromecast. + * + * @param state the [CastState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param availableIcon button content when Chromecast is available but not connected, + * typically a "start casting" icon + * @param connectingIcon button content while connecting to Chromecast, + * typically a "connecting to Chromecast" icon + * @param connectedIcon button content when connected to Chromecast, + * typically a "stop casting" icon + */ +@ExperimentalPlayerStateApi +@Composable +fun ChromecastButton( + state: CastState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + availableIcon: @Composable () -> Unit = { + Icon( + Icons.Rounded.Cast, + contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_start) + ) + }, + connectingIcon: @Composable () -> Unit = { + Icon( + Icons.Rounded.Cast, + contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + ) + }, + connectedIcon: @Composable () -> Unit = { + Icon( + Icons.Rounded.CastConnected, + contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + ) + } +) { + if (state.castState == PlayerCastState.UNAVAILABLE) { // Hide when Chromecast is not available return } @@ -59,15 +106,13 @@ fun ChromecastButton( modifier = modifier, contentPadding = contentPadding, onClick = { - player?.cast?.chromecast?.let { - if (it.isCasting) { - it.stop() - } else { - it.start() - } + if (state.casting) { + state.stopCasting() + } else { + state.startCasting() } }) { - when (player?.castState) { + when (state.castState) { PlayerCastState.AVAILABLE -> availableIcon() PlayerCastState.CONNECTING -> connectingIcon() PlayerCastState.CONNECTED -> connectedIcon() diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt index df7817f2..6ff83f8a 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt @@ -31,16 +31,32 @@ import com.theoplayer.android.api.cast.chromecast.PlayerCastState fun ChromecastDisplay( modifier: Modifier = Modifier ) { - val player = Player.current - if (player?.castState != PlayerCastState.CONNECTED) { - // Hide when not connected to Chromecast - return - } + ChromecastDisplay( + state = rememberCastState(LocalTHEOplayerView.current), + modifier = modifier + ) +} + +/** + * A display for the state of an active Chromecast session. + * + * Depending on the available screen height, this shows either [compact][ChromecastDisplayCompact] + * or [expanded][ChromecastDisplayExpanded] contents. + * + * @param state the [CastState] to read + * @param modifier the [Modifier] to be applied to this display + */ +@ExperimentalPlayerStateApi +@Composable +fun ChromecastDisplay( + state: CastState, + modifier: Modifier = Modifier +) { BoxWithConstraints { if (maxHeight < 100.dp) { - ChromecastDisplayCompact(modifier = modifier) + ChromecastDisplayCompact(state = state, modifier = modifier) } else { - ChromecastDisplayExpanded(modifier = modifier) + ChromecastDisplayExpanded(state = state, modifier = modifier) } } } @@ -54,8 +70,25 @@ fun ChromecastDisplay( fun ChromecastDisplayCompact( modifier: Modifier = Modifier ) { - val player = Player.current - if (player?.castState != PlayerCastState.CONNECTED) { + ChromecastDisplayCompact( + state = rememberCastState(LocalTHEOplayerView.current), + modifier = modifier + ) +} + +/** + * A compact display for the state of an active Chromecast session. + * + * @param state the [CastState] to read + * @param modifier the [Modifier] to be applied to this display + */ +@ExperimentalPlayerStateApi +@Composable +fun ChromecastDisplayCompact( + state: CastState, + modifier: Modifier = Modifier +) { + if (state.castState != PlayerCastState.CONNECTED) { // Hide when not connected to Chromecast return } @@ -69,7 +102,7 @@ fun ChromecastDisplayCompact( ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text( - text = player.castReceiverName?.let { + text = state.castReceiverName?.let { stringResource( R.string.theoplayer_ui_chromecast_playing_on_receiver, it @@ -89,8 +122,25 @@ fun ChromecastDisplayCompact( fun ChromecastDisplayExpanded( modifier: Modifier = Modifier ) { - val player = Player.current - if (player?.castState != PlayerCastState.CONNECTED) { + ChromecastDisplayExpanded( + state = rememberCastState(LocalTHEOplayerView.current), + modifier = modifier + ) +} + +/** + * An expanded display for the state of an active Chromecast session. + * + * @param state the [CastState] to read + * @param modifier the [Modifier] to be applied to this display + */ +@ExperimentalPlayerStateApi +@Composable +fun ChromecastDisplayExpanded( + state: CastState, + modifier: Modifier = Modifier +) { + if (state.castState != PlayerCastState.CONNECTED) { // Hide when not connected to Chromecast return } @@ -108,7 +158,7 @@ fun ChromecastDisplayExpanded( } Column { Text( - text = player.castReceiverName?.let { + text = state.castReceiverName?.let { stringResource( R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_first_line, it @@ -118,7 +168,7 @@ fun ChromecastDisplayExpanded( style = MaterialTheme.typography.bodyLarge ) Text( - text = player.castReceiverName?.let { + text = state.castReceiverName?.let { stringResource( R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_second_line, it diff --git a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt index 62f48fcb..9c24524e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -41,19 +41,64 @@ fun PictureInPictureButton( ) } ) { - val player = Player.current ?: return - if (!player.pictureInPictureSupported) return + PictureInPictureButton( + state = rememberPictureInPictureState(LocalTHEOplayerView.current), + modifier = modifier, + contentPadding = contentPadding, + pipType = pipType, + enter = enter, + exit = exit + ) +} + +/** + * A button that toggles picture-in-picture mode. + * + * @param state the [PictureInPictureState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container + * and the content + * @param pipType the type of the picture-in-picture window when entering + * @param enter button content when the player is not in picture-in-picture mode, + * typically an "enter picture-in-picture" icon + * @param exit button content when the player is in picture-in-picture mode, + * typically an "exit picture-in-picture" icon + */ +@ExperimentalPlayerStateApi +@Composable +fun PictureInPictureButton( + state: PictureInPictureState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + pipType: PiPType = PiPType.ACTIVITY, + enter: @Composable () -> Unit = { + Icon( + Icons.Rounded.PictureInPictureAlt, + contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), + ) + }, + exit: @Composable () -> Unit = { + Icon( + Icons.Rounded.Fullscreen, + contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + ) + } +) { + if (!state.pictureInPictureSupported) { + // Hide when picture-in-picture is not supported + return + } IconButton( modifier = modifier, contentPadding = contentPadding, onClick = { - if (player.pictureInPicture) { - player.exitPictureInPicture() + if (state.pictureInPicture) { + state.exitPictureInPicture() } else { - player.enterPictureInPicture(pipType) + state.enterPictureInPicture(pipType) } }) { - if (player.pictureInPicture) { + if (state.pictureInPicture) { exit() } else { enter() From a89d0afe75cb2f428e6fecd4b197db5c7a291a04 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:09:21 +0000 Subject: [PATCH 034/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c9d197..f121a3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🚀 Added experimental state holders for picture-in-picture and Chromecast, along with state overloads for `PictureInPictureButton`, `ChromecastButton` and `ChromecastDisplay`. * 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. ([#114](https://github.com/THEOplayer/android-ui/pull/114)) * 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) From d80fe1e0165007894964033f48185a4c935df7d1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:11:12 +0000 Subject: [PATCH 035/158] 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 f121a3cc..73aca38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🚀 Added experimental state holders for picture-in-picture and Chromecast, along with state overloads for `PictureInPictureButton`, `ChromecastButton` and `ChromecastDisplay`. +* 🚀 Added experimental state holders for picture-in-picture and Chromecast, along with state overloads for `PictureInPictureButton`, `ChromecastButton` and `ChromecastDisplay`. ([#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. ([#114](https://github.com/THEOplayer/android-ui/pull/114)) * 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) * 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) From 108ab83ca4afea251a23d8bfa6bdfc1966660693 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:46:55 +0000 Subject: [PATCH 036/158] Use state holders in UIController and DefaultUI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/DefaultUI.kt | 32 +++++++++++-------- .../com/theoplayer/android/ui/UIController.kt | 32 ++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 81263587..140e3519 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -72,16 +72,20 @@ fun DefaultUI( title: String? = null ) { val errorState = rememberErrorState(player.player) + val firstPlayState = rememberFirstPlayState(player.player) + val adState = rememberAdState(player.player) + val playbackTimeState = rememberPlaybackTimeState(player.player) + val fullscreenState = rememberFullscreenState(player.theoplayerView) UIController( modifier = modifier, player = player, centerOverlay = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { LoadingSpinner() } }, topChrome = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { Row(verticalAlignment = Alignment.CenterVertically) { title?.let { Text( @@ -90,7 +94,7 @@ fun DefaultUI( ) } Spacer(modifier = Modifier.weight(1f)) - if (!player.playingAd) { + if (!adState.playingAd) { LanguageMenuButton() SettingsMenuButton() } @@ -99,32 +103,32 @@ fun DefaultUI( } }, centerChrome = { - if (player.firstPlay && !player.playingAd) { + if (firstPlayState.firstPlay && !adState.playingAd) { SeekButton(seekOffset = -10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } PlayButton(iconModifier = Modifier.size(96.dp), contentPadding = PaddingValues(8.dp)) - if (player.firstPlay && !player.playingAd) { + if (firstPlayState.firstPlay && !adState.playingAd) { SeekButton(seekOffset = 10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } }, bottomChrome = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { ChromecastDisplay(modifier = Modifier.padding(8.dp)) - if (!player.playingAd && player.streamType != StreamType.Live) { + if (!adState.playingAd && playbackTimeState.streamType != StreamType.Live) { SeekBar() } Row(verticalAlignment = Alignment.CenterVertically) { MuteButton() - if (player.playingAd) { - if (player.streamType != StreamType.Live) { + if (adState.playingAd) { + if (playbackTimeState.streamType != StreamType.Live) { SeekBar() } } else { LiveButton() - if (player.streamType != StreamType.Live) { + if (playbackTimeState.streamType != StreamType.Live) { CurrentTimeDisplay( - showRemaining = player.streamType == StreamType.Dvr, - showDuration = player.streamType == StreamType.Vod + showRemaining = playbackTimeState.streamType == StreamType.Dvr, + showDuration = playbackTimeState.streamType == StreamType.Vod ) } } @@ -138,8 +142,8 @@ fun DefaultUI( Box(contentAlignment = Alignment.Center) { ErrorDisplay(errorState) // Ensure the user can still exit fullscreen - if (player.fullscreen) { - FullscreenButton(modifier = Modifier.align(Alignment.BottomEnd)) + if (fullscreenState.fullscreen) { + FullscreenButton(fullscreenState, modifier = Modifier.align(Alignment.BottomEnd)) } } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 7b3bb564..2b6cd7e9 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -134,6 +134,16 @@ fun UIController( centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null ) { + val theoplayerView = player.theoplayerView + val nativePlayer = player.player + val firstPlayState = rememberFirstPlayState(nativePlayer) + val castState = rememberCastState(theoplayerView) + val playButtonState = rememberPlayButtonState(nativePlayer) + val pictureInPictureState = rememberPictureInPictureState(theoplayerView) + val errorState = rememberErrorState(nativePlayer, player.error) + val adState = rememberAdState(nativePlayer) + val videoSizeState = rememberVideoSizeState(nativePlayer) + var tapCount by remember { mutableIntStateOf(0) } var isRecentlyTapped by remember { mutableStateOf(false) } LaunchedEffect(tapCount) { @@ -153,28 +163,28 @@ fun UIController( isReady = true } - val controlsVisible = remember { + val controlsVisible = remember(firstPlayState, castState, playButtonState) { derivedStateOf { if (!isReady) { false - } else if (!player.firstPlay || player.castState == PlayerCastState.CONNECTED) { + } else if (!firstPlayState.firstPlay || castState.castState == PlayerCastState.CONNECTED) { true } else if (forceControlsHidden) { false } else { - isRecentlyTapped || player.paused + isRecentlyTapped || playButtonState.paused } } } val scope = remember(player) { UIControllerScopeImpl(player) } - val uiState by remember { + val uiState by remember(scope, pictureInPictureState, errorState) { derivedStateOf { val currentMenu = scope.currentMenu when { - player.pictureInPicture -> UIState.Hidden - player.error != null -> UIState.Error + pictureInPictureState.pictureInPicture -> UIState.Hidden + errorState.error != null -> UIState.Error currentMenu != null -> UIState.Menu(currentMenu) else -> UIState.Controls } @@ -207,7 +217,7 @@ fun UIController( modifier = Modifier .background(Color.Black) .then(modifier) - .playerAspectRatio(player) + .playerAspectRatio(videoSizeState) .toggleControlsOnTap( controlsVisible = controlsVisible, showControlsTemporarily = { @@ -267,7 +277,7 @@ fun UIController( is UIState.Controls -> { scope.PlayerControls( controlsVisible = controlsVisible.value, - animationsActive = isReady && !player.playingAd, + animationsActive = isReady && !adState.playingAd, centerOverlay = centerOverlay, topChrome = topChrome, centerChrome = centerChrome, @@ -547,10 +557,10 @@ internal fun setupTHEOplayerView(theoplayerView: THEOplayerView): THEOplayerView return theoplayerView } -internal fun Modifier.playerAspectRatio(player: Player): Modifier { +internal fun Modifier.playerAspectRatio(videoSizeState: VideoSizeState): Modifier { return this.constrainedAspectRatio( - if (player.videoWidth != 0 && player.videoHeight != 0) { - player.videoWidth.toFloat() / player.videoHeight.toFloat() + if (videoSizeState.videoWidth != 0 && videoSizeState.videoHeight != 0) { + videoSizeState.videoWidth.toFloat() / videoSizeState.videoHeight.toFloat() } else { 16f / 9f } From 31e9f2d921d150a69c122c10e228040905b9bfd9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:46:55 +0000 Subject: [PATCH 037/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73aca38a..9ffb39d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## [Unreleased] +* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. * 🚀 Added experimental state holders for picture-in-picture and Chromecast, along with state overloads for `PictureInPictureButton`, `ChromecastButton` and `ChromecastDisplay`. ([#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. ([#114](https://github.com/THEOplayer/android-ui/pull/114)) * 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) From 7af1b66c0b9e5997f2f3ed722772f4493f3de137 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:48:11 +0000 Subject: [PATCH 038/158] Consolidate state holder changelog entries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffb39d4..115cdc52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,7 @@ ## [Unreleased] * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. -* 🚀 Added experimental state holders for picture-in-picture and Chromecast, along with state overloads for `PictureInPictureButton`, `ChromecastButton` and `ChromecastDisplay`. ([#115](https://github.com/THEOplayer/android-ui/pull/115)) -* 🚀 Added an experimental state holder for the fullscreen button, sharing a single fullscreen handler per `THEOplayerView`. ([#114](https://github.com/THEOplayer/android-ui/pull/114)) -* 🚀 Added experimental state holders for audio tracks, subtitle tracks, video qualities and playback rate, along with state overloads for the track, quality and playback rate lists and the language and settings menus. ([#111](https://github.com/THEOplayer/android-ui/pull/111)) -* 🚀 Added experimental state holders for the mute button, loading spinner, error display, first play, video size and ad playback. ([#110](https://github.com/THEOplayer/android-ui/pull/110)) -* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`) for the play button, seek bar, time displays, live button and seek buttons, so custom UIs can reuse their logic. Also added `LocalTHEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108)) +* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From eefcef8038dac65b133d9764bbe8f8c5ef29942f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:48:56 +0000 Subject: [PATCH 039/158] 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 115cdc52..9c4d0d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [Unreleased] -* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. +* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 Moved `Player` and its helpers into 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)) From 87c230abed05364b2ee7b838bc62d08b4f14d14b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 14:52:51 +0000 Subject: [PATCH 040/158] Reorder changelog entries 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 9c4d0d67..96602cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ ## [Unreleased] -* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) +* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 08577f5e670f508cec6b2b0c257076a2d6d1b381 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:03:20 +0000 Subject: [PATCH 041/158] Add facade-free UIController and DefaultUI entry points Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/DefaultUI.kt | 74 ++++++-- .../com/theoplayer/android/ui/ErrorDisplay.kt | 3 +- .../com/theoplayer/android/ui/UIController.kt | 159 ++++++++++++++++-- 3 files changed, 207 insertions(+), 29 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 140e3519..05f0c097 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView @@ -41,12 +42,13 @@ fun DefaultUI( source: SourceDescription? = null, title: String? = null ) { - val player = rememberPlayer(config) - LaunchedEffect(player, source) { - player.source = source + val theoplayerView = rememberTHEOplayerViewInternal(config) + theoplayerView?.let { setupTHEOplayerView(it) } + LaunchedEffect(theoplayerView, source) { + theoplayerView?.player?.source = source } - DefaultUI(modifier = modifier, player = player, title = title) + DefaultUIImpl(modifier = modifier, theoplayerView = theoplayerView, player = null, title = title) } /** @@ -71,14 +73,64 @@ fun DefaultUI( player: Player = rememberPlayer(), title: String? = null ) { - val errorState = rememberErrorState(player.player) - val firstPlayState = rememberFirstPlayState(player.player) - val adState = rememberAdState(player.player) - val playbackTimeState = rememberPlaybackTimeState(player.player) - val fullscreenState = rememberFullscreenState(player.theoplayerView) - UIController( + DefaultUIImpl( modifier = modifier, + theoplayerView = player.theoplayerView, player = player, + title = title + ) +} + +/** + * A default THEOplayer UI component. + * + * This component provides a great player experience out-of-the-box, that works for all types + * of streams. It provides all the common playback controls for playing, seeking, changing + * languages and qualities. + * + * No [Player] is created unless [UIControllerScope.player] is accessed, so + * [Player.current] is `null` inside this UI: use [LocalTHEOplayerView] and the experimental + * state holders instead. Like [rememberPlayer] on a [THEOplayerView], this couples the + * lifecycle of the view to the current activity, and does not destroy the view when the + * composition is disposed. + * + * The colors and fonts can be changed by wrapping this inside a [THEOplayerTheme]. + * For more extensive customizations, we recommend defining your own custom UI using + * a [UIController]. + * + * @param modifier the [Modifier] to be applied to this UI + * @param theoplayerView the THEOplayer view to host in this UI + * @param title the stream's title, shown at the top of the player + * @see UIController + */ +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun DefaultUI( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + title: String? = null +) { + setupTHEOplayerView(theoplayerView) + DefaultUIImpl(modifier = modifier, theoplayerView = theoplayerView, player = null, title = title) +} + +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +private fun DefaultUIImpl( + modifier: Modifier, + theoplayerView: THEOplayerView?, + player: Player?, + title: String? +) { + val firstPlayState = rememberFirstPlayState(theoplayerView?.player) + val adState = rememberAdState(theoplayerView?.player) + val playbackTimeState = rememberPlaybackTimeState(theoplayerView?.player) + val fullscreenState = rememberFullscreenState(theoplayerView) + UIControllerImpl( + modifier = modifier, + theoplayerView = theoplayerView, + player = player, + color = Color.Black.copy(alpha = 0.5f), centerOverlay = { if (firstPlayState.firstPlay) { LoadingSpinner() @@ -140,7 +192,7 @@ fun DefaultUI( }, errorOverlay = { Box(contentAlignment = Alignment.Center) { - ErrorDisplay(errorState) + ErrorDisplay() // Ensure the user can still exit fullscreen if (fullscreenState.fullscreen) { FullscreenButton(fullscreenState, modifier = Modifier.align(Alignment.BottomEnd)) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt index 56f697b2..28394ed7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt @@ -28,7 +28,8 @@ fun ErrorDisplay( modifier: Modifier = Modifier, ) { ErrorDisplay( - state = rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), + state = LocalUIControllerErrorState.current + ?: rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), modifier = modifier ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 2b6cd7e9..9369c27c 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -32,8 +32,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -88,14 +90,16 @@ fun UIController( centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null ) { - val player = rememberPlayer(config) - LaunchedEffect(player, source) { - player.source = source + val theoplayerView = rememberTHEOplayerViewInternal(config) + theoplayerView?.let { setupTHEOplayerView(it) } + LaunchedEffect(theoplayerView, source) { + theoplayerView?.player?.source = source } - UIController( + UIControllerImpl( modifier = modifier, - player = player, + theoplayerView = theoplayerView, + player = null, color = color, centerOverlay = centerOverlay, errorOverlay = errorOverlay, @@ -134,13 +138,88 @@ fun UIController( centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null ) { - val theoplayerView = player.theoplayerView - val nativePlayer = player.player + UIControllerImpl( + modifier = modifier, + theoplayerView = player.theoplayerView, + player = player, + color = color, + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) +} + +/** + * A container component for a THEOplayer UI. + * + * This component provides a basic layout structure for a player UI using the given + * [theoplayerView]. No [Player] is created unless [UIControllerScope.player] is accessed, + * so [Player.current] is `null` inside this UI: use [LocalTHEOplayerView] and the + * experimental state holders instead. + * + * Like [rememberPlayer] on a [THEOplayerView], this couples the lifecycle of the view to the + * current activity, and does not destroy the view when the composition is disposed. + * + * The colors and fonts can be changed by wrapping this inside a [THEOplayerTheme]. + * + * @param modifier the [Modifier] to be applied to this container + * @param theoplayerView the THEOplayer view to host in this container + * @param color the background color for the overlay while showing the UI controls + * @param centerOverlay content to show in the center of the player, typically a [LoadingSpinner]. + * @param errorOverlay content to show when the player encountered a fatal error, + * typically an [ErrorDisplay]. + * @param topChrome controls to show at the top of the player, for example the stream's title. + * @param centerChrome controls to show in the center of the player, for example a large [PlayButton]. + * @param bottomChrome controls to show at the bottom of the player, for example a [SeekBar] + * or a [Row] containing a [MuteButton] and a [FullscreenButton]. + */ +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun UIController( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + color: Color = Color.Black.copy(alpha = 0.5f), + centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + topChrome: (@Composable UIControllerScope.() -> Unit)? = null, + centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null +) { + setupTHEOplayerView(theoplayerView) + UIControllerImpl( + modifier = modifier, + theoplayerView = theoplayerView, + player = null, + color = color, + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) +} + +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +internal fun UIControllerImpl( + modifier: Modifier, + theoplayerView: THEOplayerView?, + player: Player?, + color: Color, + centerOverlay: (@Composable UIControllerScope.() -> Unit)?, + errorOverlay: (@Composable UIControllerScope.() -> Unit)?, + topChrome: (@Composable UIControllerScope.() -> Unit)?, + centerChrome: (@Composable UIControllerScope.() -> Unit)?, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? +) { + val nativePlayer = theoplayerView?.player val firstPlayState = rememberFirstPlayState(nativePlayer) val castState = rememberCastState(theoplayerView) val playButtonState = rememberPlayButtonState(nativePlayer) val pictureInPictureState = rememberPictureInPictureState(theoplayerView) - val errorState = rememberErrorState(nativePlayer, player.error) + val errorState = rememberErrorState(nativePlayer, player?.error) val adState = rememberAdState(nativePlayer) val videoSizeState = rememberVideoSizeState(nativePlayer) @@ -177,7 +256,14 @@ fun UIController( } } - val scope = remember(player) { UIControllerScopeImpl(player) } + val scope = remember(theoplayerView, player) { UIControllerScopeImpl(theoplayerView, player) } + if (player == null) { + val isInPipMode = rememberIsInPipMode() + SideEffect { scope.activityInPipMode = isInPipMode } + } + DisposableEffect(scope) { + onDispose { scope.dispose() } + } val uiState by remember(scope, pictureInPictureState, errorState) { derivedStateOf { @@ -213,7 +299,7 @@ fun UIController( ) PlayerContainer( - player = player, + theoplayerView = theoplayerView, modifier = Modifier .background(Color.Black) .then(modifier) @@ -232,7 +318,8 @@ fun UIController( ) { CompositionLocalProvider( LocalPlayer provides player, - LocalTHEOplayerView provides player.theoplayerView + LocalTHEOplayerView provides theoplayerView, + LocalUIControllerErrorState provides errorState ) { AnimatedContent( label = "ContentAnimation", @@ -298,12 +385,24 @@ fun UIController( interface UIControllerScope : MenuScope { /** * The player hosted in this [UIController]. + * + * If the UIController was created from a [THEOplayerView], the [Player] is created on + * first access (and only then adds its listeners). Prefer [theoplayerView] with the + * experimental state holders instead. */ val player: Player + + /** + * The THEOplayer view hosted in this [UIController], or `null` in inspection mode. + */ + val theoplayerView: THEOplayerView? + get() = player.theoplayerView } -private class UIControllerScopeImpl(override val player: Player) : - UIControllerScope { +private class UIControllerScopeImpl( + override val theoplayerView: THEOplayerView?, + private val providedPlayer: Player? +) : UIControllerScope { private var menuStack = mutableStateListOf() val currentMenu: MenuContent? @@ -311,6 +410,27 @@ private class UIControllerScopeImpl(override val player: Player) : var lastWasClosed: Boolean = false private set + var activityInPipMode: Boolean = false + set(value) { + field = value + if (lazyPlayer.isInitialized()) { + lazyPlayer.value.activityInPipMode = value + } + } + + private val lazyPlayer = lazy { + PlayerImpl(theoplayerView).also { it.activityInPipMode = activityInPipMode } + } + + override val player: Player + get() = providedPlayer ?: lazyPlayer.value + + fun dispose() { + if (lazyPlayer.isInitialized()) { + lazyPlayer.value.dispose() + } + } + override fun openMenu(menu: MenuContent) { menuStack.add(menu) lastWasClosed = false @@ -323,6 +443,8 @@ private class UIControllerScopeImpl(override val player: Player) : } } +internal val LocalUIControllerErrorState = staticCompositionLocalOf { null } + private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() @@ -333,10 +455,9 @@ private sealed class UIState { @Composable private fun PlayerContainer( modifier: Modifier = Modifier, - player: Player, + theoplayerView: THEOplayerView?, ui: @Composable () -> Unit ) { - val theoplayerView = player.theoplayerView if (theoplayerView == null) { Box( modifier = modifier @@ -453,6 +574,11 @@ private fun UIControllerScope.PlayerControls( */ @Composable fun rememberPlayer(config: THEOplayerConfig? = null): Player { + return rememberPlayerInternal(rememberTHEOplayerViewInternal(config)) +} + +@Composable +internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { val theoplayerView = if (LocalInspectionMode.current) { null } else { @@ -465,8 +591,7 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { theoplayerView?.onDestroy() } } - - return rememberPlayerInternal(theoplayerView) + return theoplayerView } /** From f3e924a37585e9e1e9f8f5ca6e9ed6d2ac4d97b5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:03:20 +0000 Subject: [PATCH 042/158] Use the facade-free entry points in the demo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/MainActivity.kt | 27 +++++------ .../android/ui/demo/nitflex/NitflexUI.kt | 45 ++++++++++++------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt index b6735326..43776945 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt @@ -54,10 +54,10 @@ import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.pip.PiPType import com.theoplayer.android.api.pip.PipConfiguration import com.theoplayer.android.ui.DefaultUI -import com.theoplayer.android.ui.Player +import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.demo.nitflex.NitflexUI import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme -import com.theoplayer.android.ui.rememberPlayer +import com.theoplayer.android.ui.rememberPictureInPictureState import com.theoplayer.android.ui.theme.THEOplayerTheme import androidx.compose.material3.Icon as Material3Icon @@ -161,21 +161,21 @@ fun MainContent( var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) } var streamMenuOpen by remember { mutableStateOf(false) } - val player = rememberPlayer(theoplayerView) - LaunchedEffect(player, stream) { - player.source = stream.source + LaunchedEffect(theoplayerView, stream) { + theoplayerView.player.source = stream.source } var themeMenuOpen by remember { mutableStateOf(false) } var theme by rememberSaveable { mutableStateOf(PlayerTheme.Default) } val activity = LocalActivity.current as? ComponentActivity + val pictureInPictureState = rememberPictureInPictureState(theoplayerView) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && pictureInPictureState.pictureInPicture) { // Only show player while in picture-in-picture mode Surface(modifier = Modifier.fillMaxSize()) { PlayerContent( modifier = Modifier.fillMaxSize(), - player = player, + theoplayerView = theoplayerView, stream = stream, theme = theme ) @@ -201,8 +201,8 @@ fun MainContent( } } IconButton(onClick = { - player.source = stream.source - player.play() + theoplayerView.player.source = stream.source + theoplayerView.player.play() }) { Material3Icon(Icons.Rounded.Refresh, contentDescription = "Reload") } @@ -220,7 +220,7 @@ fun MainContent( modifier = Modifier .padding(padding) .fillMaxSize(1f), - player = player, + theoplayerView = theoplayerView, stream = stream, theme = theme ) @@ -249,10 +249,11 @@ fun MainContent( } } +@OptIn(ExperimentalPlayerStateApi::class) @Composable fun PlayerContent( modifier: Modifier = Modifier, - player: Player, + theoplayerView: THEOplayerView, stream: Stream, theme: PlayerTheme ) { @@ -260,7 +261,7 @@ fun PlayerContent( PlayerTheme.Default -> { DefaultUI( modifier = modifier, - player = player, + theoplayerView = theoplayerView, title = stream.title ) } @@ -269,7 +270,7 @@ fun PlayerContent( NitflexTheme(useDarkTheme = true) { NitflexUI( modifier = modifier, - player = player, + theoplayerView = theoplayerView, title = stream.title ) } diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt index 39520cb4..1e1e7a44 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt @@ -19,28 +19,34 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.ui.CurrentTimeDisplay import com.theoplayer.android.ui.ErrorDisplay +import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.FullscreenButton import com.theoplayer.android.ui.LanguageMenu import com.theoplayer.android.ui.LoadingSpinner import com.theoplayer.android.ui.PlaybackRateMenu -import com.theoplayer.android.ui.Player import com.theoplayer.android.ui.UIController import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme -import com.theoplayer.android.ui.rememberPlayer +import com.theoplayer.android.ui.rememberFirstPlayState +import com.theoplayer.android.ui.rememberFullscreenState +@OptIn(ExperimentalPlayerStateApi::class) @Composable fun NitflexUI( modifier: Modifier = Modifier, @@ -48,31 +54,40 @@ fun NitflexUI( source: SourceDescription? = null, title: String? = null ) { - val player = rememberPlayer(config) - LaunchedEffect(player, source) { - player.source = source + val context = LocalContext.current + val theoplayerView = remember { THEOplayerView(context, config) } + DisposableEffect(theoplayerView) { + onDispose { + theoplayerView.onDestroy() + } + } + LaunchedEffect(theoplayerView, source) { + theoplayerView.player.source = source } - NitflexUI(modifier = modifier, player = player, title = title) + NitflexUI(modifier = modifier, theoplayerView = theoplayerView, title = title) } +@OptIn(ExperimentalPlayerStateApi::class) @Composable fun NitflexUI( modifier: Modifier = Modifier, - player: Player = rememberPlayer(), + theoplayerView: THEOplayerView, title: String? = null ) { + val firstPlayState = rememberFirstPlayState(theoplayerView.player) + val fullscreenState = rememberFullscreenState(theoplayerView) ProvideTextStyle(value = TextStyle(color = Color.White)) { UIController( modifier = modifier, - player = player, + theoplayerView = theoplayerView, centerOverlay = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { LoadingSpinner() } }, topChrome = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -94,7 +109,7 @@ fun NitflexUI( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = -10, iconSize = 48.dp, @@ -105,7 +120,7 @@ fun NitflexUI( iconModifier = Modifier.size(48.dp), contentPadding = PaddingValues(8.dp) ) - if (player.firstPlay) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = 10, iconSize = 48.dp, @@ -115,7 +130,7 @@ fun NitflexUI( } }, bottomChrome = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -168,7 +183,7 @@ fun NitflexUI( Box(contentAlignment = Alignment.Center) { ErrorDisplay() // Ensure the user can still exit fullscreen - if (player.fullscreen) { + if (fullscreenState.fullscreen) { FullscreenButton(modifier = Modifier.align(Alignment.BottomEnd)) } } @@ -183,4 +198,4 @@ fun NitflexPreview() { NitflexTheme { NitflexUI(config = THEOplayerConfig.Builder().build()) } -} \ No newline at end of file +} From cdbc3e974991f13d4794e7b85f8cb10b4b468654 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:03:20 +0000 Subject: [PATCH 043/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96602cff..7ff993a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) +* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 89de8ae4701977be1e7fde3db007bfdf90cf7b54 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:23:53 +0000 Subject: [PATCH 044/158] Add rememberTHEOplayerView and decouple the theoplayerView overloads from the view lifecycle Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/DefaultUI.kt | 17 ++++-- .../com/theoplayer/android/ui/UIController.kt | 58 +++++++++++++++---- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 05f0c097..1a0cce2f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -43,7 +43,6 @@ fun DefaultUI( title: String? = null ) { val theoplayerView = rememberTHEOplayerViewInternal(config) - theoplayerView?.let { setupTHEOplayerView(it) } LaunchedEffect(theoplayerView, source) { theoplayerView?.player?.source = source } @@ -90,9 +89,11 @@ fun DefaultUI( * * No [Player] is created unless [UIControllerScope.player] is accessed, so * [Player.current] is `null` inside this UI: use [LocalTHEOplayerView] and the experimental - * state holders instead. Like [rememberPlayer] on a [THEOplayerView], this couples the - * lifecycle of the view to the current activity, and does not destroy the view when the - * composition is disposed. + * state holders instead. + * + * This does not touch the lifecycle of the given view: use [rememberTHEOplayerView] to + * create a view owned by the composition, or forward [THEOplayerView.onPause], + * [THEOplayerView.onResume] and [THEOplayerView.onDestroy] yourself. * * The colors and fonts can be changed by wrapping this inside a [THEOplayerTheme]. * For more extensive customizations, we recommend defining your own custom UI using @@ -110,8 +111,12 @@ fun DefaultUI( theoplayerView: THEOplayerView, title: String? = null ) { - setupTHEOplayerView(theoplayerView) - DefaultUIImpl(modifier = modifier, theoplayerView = theoplayerView, player = null, title = title) + DefaultUIImpl( + modifier = modifier, + theoplayerView = theoplayerView.takeUnless { it.isInEditMode }, + player = null, + title = title + ) } @OptIn(ExperimentalPlayerStateApi::class) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 9369c27c..fed910c7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -91,7 +91,6 @@ fun UIController( bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null ) { val theoplayerView = rememberTHEOplayerViewInternal(config) - theoplayerView?.let { setupTHEOplayerView(it) } LaunchedEffect(theoplayerView, source) { theoplayerView?.player?.source = source } @@ -159,8 +158,9 @@ fun UIController( * so [Player.current] is `null` inside this UI: use [LocalTHEOplayerView] and the * experimental state holders instead. * - * Like [rememberPlayer] on a [THEOplayerView], this couples the lifecycle of the view to the - * current activity, and does not destroy the view when the composition is disposed. + * This does not touch the lifecycle of the given view: use [rememberTHEOplayerView] to + * create a view owned by the composition, or forward [THEOplayerView.onPause], + * [THEOplayerView.onResume] and [THEOplayerView.onDestroy] yourself. * * The colors and fonts can be changed by wrapping this inside a [THEOplayerTheme]. * @@ -187,10 +187,9 @@ fun UIController( centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null ) { - setupTHEOplayerView(theoplayerView) UIControllerImpl( modifier = modifier, - theoplayerView = theoplayerView, + theoplayerView = theoplayerView.takeUnless { it.isInEditMode }, player = null, color = color, centerOverlay = centerOverlay, @@ -574,11 +573,6 @@ private fun UIControllerScope.PlayerControls( */ @Composable fun rememberPlayer(config: THEOplayerConfig? = null): Player { - return rememberPlayerInternal(rememberTHEOplayerViewInternal(config)) -} - -@Composable -internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { val theoplayerView = if (LocalInspectionMode.current) { null } else { @@ -591,9 +585,42 @@ internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplay theoplayerView?.onDestroy() } } + + return rememberPlayerInternal(theoplayerView) +} + +/** + * Creates and remembers a [THEOplayerView] owned by the composition. + * + * This couples the lifecycle of the view to the current activity: it automatically calls + * [THEOplayerView.onPause] and [THEOplayerView.onResume] whenever the current activity is + * [paused][Activity.onPause] or [resumed][Activity.onResume] (resuming an ad that was + * interrupted), and it destroys the view when it leaves the composition. + * + * In previews, the SDK does not initialize the view ([View.isInEditMode]), so do not + * access its [THEOplayerView.player] there. [UIController] and [DefaultUI] handle this + * automatically. + * + * @param config the player configuration + */ +@Composable +fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { + val context = LocalContext.current + val theoplayerView = remember { THEOplayerView(context, config) } + setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) + DisposableEffect(theoplayerView) { + onDispose { + theoplayerView.onDestroy() + } + } return theoplayerView } +@Composable +internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { + return if (LocalInspectionMode.current) null else rememberTHEOplayerView(config) +} + /** * Create a [Player] wrapping an existing [THEOplayerView]. * @@ -649,7 +676,10 @@ internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { } @Composable -internal fun setupTHEOplayerView(theoplayerView: THEOplayerView): THEOplayerView { +internal fun setupTHEOplayerView( + theoplayerView: THEOplayerView, + destroyOnLifecycleDestroy: Boolean = true +): THEOplayerView { var wasPlayingAd by remember { mutableStateOf(false) } val lifecycle = LocalLifecycleOwner.current.lifecycle @@ -669,7 +699,11 @@ internal fun setupTHEOplayerView(theoplayerView: THEOplayerView): THEOplayerView theoplayerView.onPause() } - Lifecycle.Event.ON_DESTROY -> theoplayerView.onDestroy() + Lifecycle.Event.ON_DESTROY -> { + if (destroyOnLifecycleDestroy) { + theoplayerView.onDestroy() + } + } else -> {} } } From bf689b71bcbca12527a36770156cb93cc223a036 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:23:53 +0000 Subject: [PATCH 045/158] Use rememberTHEOplayerView and forward the view lifecycle in the demo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/MainActivity.kt | 10 +++++ .../android/ui/demo/nitflex/NitflexUI.kt | 37 +++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt index 43776945..42f8b3b0 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt @@ -145,6 +145,16 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi } } + override fun onPause() { + super.onPause() + theoplayerView.onPause() + } + + override fun onResume() { + super.onResume() + theoplayerView.onResume() + } + override fun onDestroy() { super.onDestroy() pip.close() diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt index 1e1e7a44..a0212b95 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt @@ -19,13 +19,10 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -45,6 +42,7 @@ import com.theoplayer.android.ui.UIController import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme import com.theoplayer.android.ui.rememberFirstPlayState import com.theoplayer.android.ui.rememberFullscreenState +import com.theoplayer.android.ui.rememberTHEOplayerView @OptIn(ExperimentalPlayerStateApi::class) @Composable @@ -54,15 +52,11 @@ fun NitflexUI( source: SourceDescription? = null, title: String? = null ) { - val context = LocalContext.current - val theoplayerView = remember { THEOplayerView(context, config) } - DisposableEffect(theoplayerView) { - onDispose { - theoplayerView.onDestroy() - } - } + val theoplayerView = rememberTHEOplayerView(config) LaunchedEffect(theoplayerView, source) { - theoplayerView.player.source = source + if (!theoplayerView.isInEditMode) { + theoplayerView.player.source = source + } } NitflexUI(modifier = modifier, theoplayerView = theoplayerView, title = title) @@ -75,19 +69,19 @@ fun NitflexUI( theoplayerView: THEOplayerView, title: String? = null ) { - val firstPlayState = rememberFirstPlayState(theoplayerView.player) - val fullscreenState = rememberFullscreenState(theoplayerView) ProvideTextStyle(value = TextStyle(color = Color.White)) { UIController( modifier = modifier, theoplayerView = theoplayerView, centerOverlay = { - if (firstPlayState.firstPlay) { + val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay + if (firstPlay == true) { LoadingSpinner() } }, topChrome = { - if (firstPlayState.firstPlay) { + val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay + if (firstPlay == true) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -105,11 +99,12 @@ fun NitflexUI( } }, centerChrome = { + val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - if (firstPlayState.firstPlay) { + if (firstPlay == true) { NitflexSeekButton( seekOffset = -10, iconSize = 48.dp, @@ -120,7 +115,7 @@ fun NitflexUI( iconModifier = Modifier.size(48.dp), contentPadding = PaddingValues(8.dp) ) - if (firstPlayState.firstPlay) { + if (firstPlay == true) { NitflexSeekButton( seekOffset = 10, iconSize = 48.dp, @@ -130,7 +125,8 @@ fun NitflexUI( } }, bottomChrome = { - if (firstPlayState.firstPlay) { + val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay + if (firstPlay == true) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -183,8 +179,9 @@ fun NitflexUI( Box(contentAlignment = Alignment.Center) { ErrorDisplay() // Ensure the user can still exit fullscreen - if (fullscreenState.fullscreen) { - FullscreenButton(modifier = Modifier.align(Alignment.BottomEnd)) + val fullscreen = theoplayerView?.let { rememberFullscreenState(it) } + if (fullscreen?.fullscreen == true) { + FullscreenButton(fullscreen, modifier = Modifier.align(Alignment.BottomEnd)) } } } From 854be82dc52f74ab28d4eb9a684a5d2bec6a37c9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:23:53 +0000 Subject: [PATCH 046/158] Update changelog entry 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 7ff993a9..9011ca43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) -* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. +* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. * 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 3bc0ddc729fa35b0809070ce79305502cb0a5f5f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 15:24:39 +0000 Subject: [PATCH 047/158] Handle edit mode in rememberTHEOplayerView and NitflexUI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/nitflex/NitflexUI.kt | 22 +++++++++---------- .../com/theoplayer/android/ui/UIController.kt | 10 +++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt index a0212b95..66e311a3 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt @@ -69,19 +69,20 @@ fun NitflexUI( theoplayerView: THEOplayerView, title: String? = null ) { + val view = theoplayerView.takeUnless { it.isInEditMode } + val firstPlayState = rememberFirstPlayState(view?.player) + val fullscreenState = rememberFullscreenState(view) ProvideTextStyle(value = TextStyle(color = Color.White)) { UIController( modifier = modifier, theoplayerView = theoplayerView, centerOverlay = { - val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay - if (firstPlay == true) { + if (firstPlayState.firstPlay) { LoadingSpinner() } }, topChrome = { - val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay - if (firstPlay == true) { + if (firstPlayState.firstPlay) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -99,12 +100,11 @@ fun NitflexUI( } }, centerChrome = { - val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - if (firstPlay == true) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = -10, iconSize = 48.dp, @@ -115,7 +115,7 @@ fun NitflexUI( iconModifier = Modifier.size(48.dp), contentPadding = PaddingValues(8.dp) ) - if (firstPlay == true) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = 10, iconSize = 48.dp, @@ -125,8 +125,7 @@ fun NitflexUI( } }, bottomChrome = { - val firstPlay = theoplayerView?.let { rememberFirstPlayState(it.player) }?.firstPlay - if (firstPlay == true) { + if (firstPlayState.firstPlay) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -179,9 +178,8 @@ fun NitflexUI( Box(contentAlignment = Alignment.Center) { ErrorDisplay() // Ensure the user can still exit fullscreen - val fullscreen = theoplayerView?.let { rememberFullscreenState(it) } - if (fullscreen?.fullscreen == true) { - FullscreenButton(fullscreen, modifier = Modifier.align(Alignment.BottomEnd)) + if (fullscreenState.fullscreen) { + FullscreenButton(fullscreenState, modifier = Modifier.align(Alignment.BottomEnd)) } } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index fed910c7..fd2e6e23 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -607,10 +607,12 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { val context = LocalContext.current val theoplayerView = remember { THEOplayerView(context, config) } - setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) - DisposableEffect(theoplayerView) { - onDispose { - theoplayerView.onDestroy() + if (!theoplayerView.isInEditMode) { + setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) + DisposableEffect(theoplayerView) { + onDispose { + theoplayerView.onDestroy() + } } } return theoplayerView From e5e4f8ca51b1139a66ff12de0f122cb6769dabf6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 16:14:34 +0000 Subject: [PATCH 048/158] Reorder changelog entries 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 9011ca43..2e793da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ ## [Unreleased] +* 🏠 Moved `Player` and its helpers into 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)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. -* 🏠 Moved `Player` and its helpers into 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)) ## [1.14.2] (2026-10-13) From 1a1a3982f1d99bad983e579600508211504904e6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 16:14:55 +0000 Subject: [PATCH 049/158] 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 2e793da7..1d2207cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ * 🏠 Moved `Player` and its helpers into 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)) * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) -* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. +* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) ## [1.14.2] (2026-10-13) From 66268e39f7ac304d3377173ab82f02c816800f13 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 16:25:43 +0000 Subject: [PATCH 050/158] Fix state holder opt-ins in the demo app Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/demo/MainActivity.kt | 5 +---- .../java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt index 42f8b3b0..598c877d 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt @@ -5,7 +5,6 @@ import android.os.Build import android.os.Bundle import android.util.Rational import androidx.activity.ComponentActivity -import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -162,7 +161,7 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPlayerStateApi::class) @Composable fun MainContent( theoplayerView: THEOplayerView, @@ -177,7 +176,6 @@ fun MainContent( var themeMenuOpen by remember { mutableStateOf(false) } var theme by rememberSaveable { mutableStateOf(PlayerTheme.Default) } - val activity = LocalActivity.current as? ComponentActivity val pictureInPictureState = rememberPictureInPictureState(theoplayerView) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && pictureInPictureState.pictureInPicture) { @@ -259,7 +257,6 @@ fun MainContent( } } -@OptIn(ExperimentalPlayerStateApi::class) @Composable fun PlayerContent( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt index 66e311a3..947c3b26 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexUI.kt @@ -44,7 +44,6 @@ import com.theoplayer.android.ui.rememberFirstPlayState import com.theoplayer.android.ui.rememberFullscreenState import com.theoplayer.android.ui.rememberTHEOplayerView -@OptIn(ExperimentalPlayerStateApi::class) @Composable fun NitflexUI( modifier: Modifier = Modifier, From e354bbb43ff76242522a90bb1727849df467b73d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 16:37:49 +0000 Subject: [PATCH 051/158] Use a wrapped context for THEOplayerView in previews Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/UIController.kt | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index fd2e6e23..a7eb4de1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -1,6 +1,9 @@ package com.theoplayer.android.ui import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper import android.view.ViewGroup import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -606,7 +609,10 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { @Composable fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { val context = LocalContext.current - val theoplayerView = remember { THEOplayerView(context, config) } + val inspectionMode = LocalInspectionMode.current + val theoplayerView = remember { + THEOplayerView(if (inspectionMode) PreviewContext(context) else context, config) + } if (!theoplayerView.isInEditMode) { setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) DisposableEffect(theoplayerView) { @@ -618,6 +624,24 @@ fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { return theoplayerView } +/** + * A context for creating a [THEOplayerView] in a preview. + * + * The [THEOplayerView] constructor casts the application context to [Application], + * but layoutlib's application context is not an [Application]. + */ +private class PreviewContext(base: Context) : ContextWrapper(base) { + private val application = PreviewApplication(base.applicationContext) + + override fun getApplicationContext(): Context = application +} + +private class PreviewApplication(base: Context) : Application() { + init { + attachBaseContext(base) + } +} + @Composable internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { return if (LocalInspectionMode.current) null else rememberTHEOplayerView(config) From 0284695e93a0221efb83571a89f786363b1cb566 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 13 Oct 2026 17:08:52 +0000 Subject: [PATCH 052/158] Show the controls immediately in previews Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/src/main/java/com/theoplayer/android/ui/UIController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index a7eb4de1..49015270 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -238,7 +238,8 @@ internal fun UIControllerImpl( // Wait a little bit before showing the controls and enabling animations, // so we can hide the UI immediately in case of autoplay. - var isReady by remember { mutableStateOf(false) } + val inspectionMode = LocalInspectionMode.current + var isReady by remember { mutableStateOf(inspectionMode) } LaunchedEffect(Unit) { delay(50.milliseconds) isReady = true From ec4d685965777f1d1bd23840a76b0642cc76a291 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:34:31 +0000 Subject: [PATCH 053/158] Add coroutine-based listen helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/EventDispatcherUtil.kt | 37 +++++++++++++++++++ .../android/ui/FullscreenHandler.kt | 26 +++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt new file mode 100644 index 00000000..c9f5ad5f --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt @@ -0,0 +1,37 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventDispatcher +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Listens to events of the given [eventTypes] until the calling coroutine is cancelled, + * passing each event to [onEvent]. One listener instance is shared between all event types. + * If [onEvent] throws, the listener is removed and the exception is rethrown. + * Must be called on the main thread. + */ +internal suspend fun > EventDispatcher.listen( + vararg eventTypes: EventType, + onEvent: (E) -> Unit +): Nothing { + var continuation: CancellableContinuation? = null + val listener = EventListener { event -> + val cont = continuation + if (cont == null || !cont.isActive) return@EventListener + try { + onEvent(event) + } catch (t: Throwable) { + cont.resumeWithException(t) + } + } + eventTypes.forEach { addEventListener(it, listener) } + try { + suspendCancellableCoroutine { continuation = it } + } finally { + eventTypes.forEach { removeEventListener(it, listener) } + } +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt index eafbbf4f..8773bafd 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -10,10 +10,13 @@ import androidx.core.view.WindowInsetsControllerCompat import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.ui.common.R import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine internal interface FullscreenHandler { @@ -28,6 +31,29 @@ internal interface FullscreenHandler { } } +/** + * Calls [onChange] on every fullscreen change until the calling coroutine is cancelled. + * If [onChange] throws, the listener is removed and the exception is rethrown. + */ +internal suspend fun FullscreenHandler.listen(onChange: (Boolean) -> Unit): Nothing { + var continuation: CancellableContinuation? = null + val listener = FullscreenHandler.OnFullscreenChangeListener { isFullscreen -> + val cont = continuation + if (cont == null || !cont.isActive) return@OnFullscreenChangeListener + try { + onChange(isFullscreen) + } catch (t: Throwable) { + cont.resumeWithException(t) + } + } + addOnFullscreenChangeListener(listener) + try { + suspendCancellableCoroutine { continuation = it } + } finally { + removeOnFullscreenChangeListener(listener) + } +} + /** * The [FullscreenHandler] shared by everything that controls fullscreen for this view. * From fa96bff045727b06369a687a22ff683c6946e8cd Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:34:31 +0000 Subject: [PATCH 054/158] Observe state holders with coroutines Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/AdState.kt | 38 ++++------ .../theoplayer/android/ui/AudioTrackState.kt | 28 +++----- .../com/theoplayer/android/ui/CastState.kt | 55 ++++++-------- .../com/theoplayer/android/ui/ErrorState.kt | 25 ++----- .../theoplayer/android/ui/FirstPlayState.kt | 50 ++++++------- .../theoplayer/android/ui/FullscreenState.kt | 16 ++--- .../theoplayer/android/ui/LiveButtonState.kt | 42 ++++------- .../com/theoplayer/android/ui/LoadingState.kt | 38 ++++------ .../theoplayer/android/ui/MuteButtonState.kt | 22 ++---- .../android/ui/PictureInPictureState.kt | 33 ++++----- .../theoplayer/android/ui/PlayButtonState.kt | 38 ++++------ .../android/ui/PlaybackRateState.kt | 22 ++---- .../android/ui/PlaybackTimeState.kt | 42 ++++------- .../android/ui/PlayerStateListeners.kt | 24 ------- .../com/theoplayer/android/ui/SeekBarState.kt | 56 +++++++-------- .../theoplayer/android/ui/SeekButtonState.kt | 54 +++++++------- .../android/ui/SubtitleTrackState.kt | 27 +++---- .../android/ui/VideoQualityState.kt | 72 +++++++++---------- .../theoplayer/android/ui/VideoSizeState.kt | 27 +++---- 19 files changed, 253 insertions(+), 456 deletions(-) delete mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt index 20faba8c..adec7d5c 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt @@ -1,17 +1,17 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.ads.AdEvent import com.theoplayer.android.api.event.ads.AdsEventTypes -import com.theoplayer.android.api.event.player.PlayerEvent 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 UI that depends on whether an ad is playing. @@ -20,8 +20,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class AdState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * Whether an ad is currently playing. */ @@ -32,25 +30,22 @@ class AdState internal constructor(private val player: Player?) { playingAd = player?.ads?.isPlaying ?: false } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) + coroutineScope { player.ads?.let { ads -> - val adListener = EventListener> { update() } - ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) - ads.add(AdsEventTypes.AD_SKIP, adListener) - ads.add(AdsEventTypes.AD_BREAK_END, adListener) + launch(start = CoroutineStart.UNDISPATCHED) { + ads.listen( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END + ) { update() } + } } + player.listen(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR) { update() } } } - - internal fun dispose() { - listeners.removeAll() - } } /** @@ -60,9 +55,6 @@ class AdState internal constructor(private val player: Player?) { @Composable fun rememberAdState(player: Player?): AdState { val state = remember(player) { AdState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt index e65e11dd..7a669d7b 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.Event -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.track.mediatrack.MediaTrack @@ -20,8 +18,6 @@ import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality */ @ExperimentalPlayerStateApi class AudioTrackState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * The available audio tracks. */ @@ -42,19 +38,14 @@ class AudioTrackState internal constructor(private val player: Player?) { activeAudioTrack = audioTracks.find { it.isEnabled } } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.audioTracks.add(AudioTrackListEventTypes.ADDTRACK, listener) - player.audioTracks.add(AudioTrackListEventTypes.REMOVETRACK, listener) - player.audioTracks.add(AudioTrackListEventTypes.TRACKLISTCHANGE, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.audioTracks.listen( + AudioTrackListEventTypes.ADDTRACK, + AudioTrackListEventTypes.REMOVETRACK, + AudioTrackListEventTypes.TRACKLISTCHANGE + ) { update() } } /** @@ -77,9 +68,6 @@ class AudioTrackState internal constructor(private val player: Player?) { @Composable fun rememberAudioTrackState(player: Player?): AudioTrackState { val state = remember(player) { AudioTrackState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt index 14e37220..5a919ef0 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt @@ -1,17 +1,17 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.cast.Cast import com.theoplayer.android.api.cast.chromecast.PlayerCastState -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.chromecast.ChromecastEvent import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes +import kotlinx.coroutines.flow.collectLatest /** * State holder for Chromecast controls, such as [ChromecastButton] and [ChromecastDisplay]. @@ -23,16 +23,14 @@ import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes */ @ExperimentalPlayerStateApi class CastState internal constructor(private val theoplayerView: THEOplayerView?) { - private val listeners = PlayerStateListeners() - private var cast: Cast? = null - private var observing = false + private var cast: Cast? by mutableStateOf(null) /** * The current cast state of the player. */ val castState: PlayerCastState get() { - if (observing) updateCast(theoplayerView?.cast) + refreshCast() return _castState } private var _castState: PlayerCastState by mutableStateOf(PlayerCastState.UNAVAILABLE) @@ -42,7 +40,7 @@ class CastState internal constructor(private val theoplayerView: THEOplayerView? */ val castReceiverName: String? get() { - if (observing) updateCast(theoplayerView?.cast) + refreshCast() return _castReceiverName } private var _castReceiverName: String? by mutableStateOf(null) @@ -52,40 +50,29 @@ class CastState internal constructor(private val theoplayerView: THEOplayerView? */ val casting: Boolean get() { - if (observing) updateCast(theoplayerView?.cast) + refreshCast() return _casting } private var _casting: Boolean by mutableStateOf(false) - private fun update() { + private fun update(cast: Cast?) { _castState = cast?.chromecast?.state ?: PlayerCastState.UNAVAILABLE _castReceiverName = cast?.chromecast?.receiverName _casting = cast?.chromecast?.isCasting == true } - private fun updateCast(newCast: Cast?) { - if (newCast === cast) return - listeners.removeAll() - cast = newCast - newCast?.chromecast?.let { chromecast -> - listeners.run { - val listener = EventListener> { update() } - chromecast.add(ChromecastEventTypes.STATECHANGE, listener) - chromecast.add(ChromecastEventTypes.ERROR, listener) - } - } - update() - } - - internal fun observe() { - observing = true - updateCast(theoplayerView?.cast) + internal fun refreshCast() { + cast = theoplayerView?.cast } - internal fun dispose() { - observing = false - listeners.removeAll() - cast = null + internal suspend fun observe() { + refreshCast() + snapshotFlow { cast }.collectLatest { cast -> + update(cast) + cast?.chromecast?.listen(ChromecastEventTypes.STATECHANGE, ChromecastEventTypes.ERROR) { + update(cast) + } + } } /** @@ -110,9 +97,7 @@ class CastState internal constructor(private val theoplayerView: THEOplayerView? @Composable fun rememberCastState(theoplayerView: THEOplayerView?): CastState { val state = remember(theoplayerView) { CastState(theoplayerView) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } + state.refreshCast() return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt index d3ea1ad7..d901b515 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt @@ -1,15 +1,13 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.error.THEOplayerException -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.player.ErrorEvent -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -28,28 +26,18 @@ class ErrorState internal constructor( private val player: Player?, initialError: THEOplayerException? = null ) { - private val listeners = PlayerStateListeners() - /** * The fatal error the player has encountered, if any. */ var error by mutableStateOf(initialError) private set - internal fun observe() { + internal suspend fun observe() { val player = player ?: return - listeners.run { - val listener = EventListener> { event -> - error = (event as? ErrorEvent)?.errorObject - } - player.add(PlayerEventTypes.ERROR, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) + player.listen(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE) { event -> + error = (event as? ErrorEvent)?.errorObject } } - - internal fun dispose() { - listeners.removeAll() - } } /** @@ -75,9 +63,6 @@ fun rememberErrorState(player: Player?): ErrorState = @Composable fun rememberErrorState(player: Player?, initialError: THEOplayerException?): ErrorState { val state = remember(player) { ErrorState(player, initialError) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt index 149fa3df..e77a1ef2 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.event.player.SourceChangeEvent import com.theoplayer.android.api.player.Player @@ -19,8 +17,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class FirstPlayState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * Whether playback has started for the first time. */ @@ -33,30 +29,29 @@ class FirstPlayState internal constructor(private val player: Player?) { } } - internal fun observe() { + private fun updateFirstPlay() { + firstPlay = player?.let { !it.isPaused || (it.played?.length() ?: 0) > 0 } == true + } + + internal suspend fun observe() { val player = player ?: return - firstPlay = !player.isPaused || (player.played?.length() ?: 0) > 0 - listeners.run { - val listener = EventListener> { event -> - if (event is SourceChangeEvent) { - firstPlay = false - } - updatePlaying() + updateFirstPlay() + player.listen( + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { event -> + if (event is SourceChangeEvent) { + firstPlay = false } - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) + updatePlaying() } } - - internal fun dispose() { - listeners.removeAll() - } } /** @@ -66,9 +61,6 @@ class FirstPlayState internal constructor(private val player: Player?) { @Composable fun rememberFirstPlayState(player: Player?): FirstPlayState { val state = remember(player) { FirstPlayState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt index 1d0bb2ea..6c91738a 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt @@ -1,7 +1,7 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,7 +18,6 @@ import com.theoplayer.android.api.THEOplayerView @ExperimentalPlayerStateApi class FullscreenState internal constructor(private val theoplayerView: THEOplayerView?) { private val handler: FullscreenHandler? = theoplayerView?.fullscreenHandler - private val listener = FullscreenHandler.OnFullscreenChangeListener { update() } /** * Whether the player is shown in fullscreen mode. @@ -30,14 +29,10 @@ class FullscreenState internal constructor(private val theoplayerView: THEOplaye fullscreen = handler?.fullscreen ?: false } - internal fun observe() { + internal suspend fun observe() { val handler = handler ?: return update() - handler.addOnFullscreenChangeListener(listener) - } - - internal fun dispose() { - handler?.removeOnFullscreenChangeListener(listener) + handler.listen { update() } } /** @@ -64,9 +59,6 @@ class FullscreenState internal constructor(private val theoplayerView: THEOplaye @Composable fun rememberFullscreenState(theoplayerView: THEOplayerView?): FullscreenState { val state = remember(theoplayerView) { FullscreenState(theoplayerView) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } 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 b39585d6..e2deb530 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 @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -21,8 +19,6 @@ class LiveButtonState internal constructor( private val player: Player?, private val liveThreshold: Double ) { - private val listeners = PlayerStateListeners() - /** * Whether the button should be shown. Only live and DVR streams have a live point. */ @@ -54,26 +50,21 @@ class LiveButtonState internal constructor( updatePlaybackState() } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.TIMEUPDATE, listener) - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.DURATIONCHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.DURATIONCHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { update() } } /** @@ -97,9 +88,6 @@ class LiveButtonState internal constructor( @Composable fun rememberLiveButtonState(player: Player?, liveThreshold: Double): LiveButtonState { val state = remember(player, liveThreshold) { LiveButtonState(player, liveThreshold) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt index 8c2e2940..6c35063a 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.ReadyState @@ -19,8 +17,6 @@ import com.theoplayer.android.api.player.ReadyState */ @ExperimentalPlayerStateApi class LoadingState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * Whether the player is currently waiting for more data to resume playback. */ @@ -40,24 +36,19 @@ class LoadingState internal constructor(private val player: Player?) { readyState = player?.readyState ?: ReadyState.HAVE_NOTHING } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen( + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { update() } } } @@ -68,9 +59,6 @@ class LoadingState internal constructor(private val player: Player?) { @Composable fun rememberLoadingState(player: Player?): LoadingState { val state = remember(player) { LoadingState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt index 0de45c2a..36d9896b 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -18,8 +16,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class MuteButtonState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * Whether the audio is muted. */ @@ -30,17 +26,10 @@ class MuteButtonState internal constructor(private val player: Player?) { muted = player?.isMuted ?: false } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.VOLUMECHANGE, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen(PlayerEventTypes.VOLUMECHANGE) { update() } } /** @@ -62,9 +51,6 @@ class MuteButtonState internal constructor(private val player: Player?) { @Composable fun rememberMuteButtonState(player: Player?): MuteButtonState { val state = remember(player) { MuteButtonState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt index 7e2bca95..ace8923b 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt @@ -2,18 +2,17 @@ package com.theoplayer.android.ui import android.app.Activity import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.THEOplayerView -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.pip.PiPType import com.theoplayer.android.ui.util.rememberIsInPipMode import com.theoplayer.android.ui.util.supportsPictureInPictureMode +import kotlinx.coroutines.awaitCancellation /** * State holder for a picture-in-picture button, such as [PictureInPictureButton]. @@ -22,7 +21,6 @@ import com.theoplayer.android.ui.util.supportsPictureInPictureMode */ @ExperimentalPlayerStateApi class PictureInPictureState internal constructor(private val theoplayerView: THEOplayerView?) { - private val listeners = PlayerStateListeners() private var pendingPiPListener: FullscreenHandler.OnFullscreenChangeListener? = null /** @@ -47,21 +45,19 @@ class PictureInPictureState internal constructor(private val theoplayerView: THE pipManagerInPipMode = theoplayerView?.piPManager?.isInPiP == true } - internal fun observe() { - val player = theoplayerView?.player ?: return - update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.PRESENTATIONMODECHANGE, listener) + internal suspend fun observe() { + try { + val player = theoplayerView?.player ?: awaitCancellation() + update() + player.listen(PlayerEventTypes.PRESENTATIONMODECHANGE) { update() } + } finally { + pendingPiPListener?.let { + theoplayerView?.fullscreenHandler?.removeOnFullscreenChangeListener(it) + } + pendingPiPListener = null } } - internal fun dispose() { - listeners.removeAll() - pendingPiPListener?.let { theoplayerView?.fullscreenHandler?.removeOnFullscreenChangeListener(it) } - pendingPiPListener = null - } - /** * Enters picture-in-picture mode, leaving fullscreen first if needed. */ @@ -100,10 +96,7 @@ class PictureInPictureState internal constructor(private val theoplayerView: THE @Composable fun rememberPictureInPictureState(theoplayerView: THEOplayerView?): PictureInPictureState { val state = remember(theoplayerView) { PictureInPictureState(theoplayerView) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } state.activityInPipMode = rememberIsInPipMode() return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt index ac7ffa42..1b0c9d7f 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -18,8 +16,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class PlayButtonState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * Whether the player is paused. */ @@ -37,24 +33,19 @@ class PlayButtonState internal constructor(private val player: Player?) { ended = player?.isEnded ?: false } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return updatePlaybackState() - listeners.run { - val listener = EventListener> { updatePlaybackState() } - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen( + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { updatePlaybackState() } } /** @@ -76,9 +67,6 @@ class PlayButtonState internal constructor(private val player: Player?) { @Composable fun rememberPlayButtonState(player: Player?): PlayButtonState { val state = remember(player) { PlayButtonState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt index 9172d0cf..2972c1ba 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -18,8 +16,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class PlaybackRateState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * The playback rate of the player. `1.0` is normal speed. */ @@ -30,17 +26,10 @@ class PlaybackRateState internal constructor(private val player: Player?) { playbackRate = player?.playbackRate ?: 1.0 } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.RATECHANGE, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen(PlayerEventTypes.RATECHANGE) { update() } } /** @@ -59,9 +48,6 @@ class PlaybackRateState internal constructor(private val player: Player?) { @Composable fun rememberPlaybackRateState(player: Player?): PlaybackRateState { val state = remember(player) { PlaybackRateState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } 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 ff41965a..65af43fc 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 @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -18,8 +16,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class PlaybackTimeState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - /** * The current playback position of the media, in seconds. */ @@ -50,26 +46,21 @@ class PlaybackTimeState internal constructor(private val player: Player?) { duration = seekable.lastEnd ?: rawDuration } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.TIMEUPDATE, listener) - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.DURATIONCHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.DURATIONCHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { update() } } } @@ -80,9 +71,6 @@ class PlaybackTimeState internal constructor(private val player: Player?) { @Composable fun rememberPlaybackTimeState(player: Player?): PlaybackTimeState { val state = remember(player) { PlaybackTimeState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt deleted file mode 100644 index b15aa8b4..00000000 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerStateListeners.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.theoplayer.android.ui - -import com.theoplayer.android.api.event.Event -import com.theoplayer.android.api.event.EventDispatcher -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.EventType - -/** - * Tracks the event listeners registered by a state holder, so they can all be removed again - * on dispose. - */ -internal class PlayerStateListeners { - private val removers = mutableListOf<() -> Unit>() - - fun , T : B> EventDispatcher.add(type: EventType, listener: EventListener) { - addEventListener(type, listener) - removers.add { removeEventListener(type, listener) } - } - - fun removeAll() { - removers.forEach { it() } - removers.clear() - } -} 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 a55ca809..9be6a5cc 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 @@ -1,17 +1,17 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.ads.AdEvent import com.theoplayer.android.api.event.ads.AdsEventTypes -import com.theoplayer.android.api.event.player.PlayerEvent 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 seek bar, such as [SeekBar]. @@ -23,7 +23,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class SeekBarState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() /** * The current scrub position of the seek bar, relative to the start of [seekableRange]. @@ -70,34 +69,34 @@ class SeekBarState internal constructor(private val player: Player?) { playingAd = player?.ads?.isPlaying ?: false } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.TIMEUPDATE, listener) - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.DURATIONCHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) + coroutineScope { player.ads?.let { ads -> - val adListener = EventListener> { update() } - ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) - ads.add(AdsEventTypes.AD_SKIP, adListener) - ads.add(AdsEventTypes.AD_BREAK_END, adListener) + 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() } } } - internal fun dispose() { - listeners.removeAll() - } - /** * Called while the user drags the seek bar. Temporarily pauses a playing player and seeks * to the given position within [valueRange]. @@ -133,9 +132,6 @@ class SeekBarState internal constructor(private val player: Player?) { @Composable fun rememberSeekBarState(player: Player?): SeekBarState { val state = remember(player) { SeekBarState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt index 9adc9992..beba3402 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -1,17 +1,17 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.EventListener -import com.theoplayer.android.api.event.ads.AdEvent import com.theoplayer.android.api.event.ads.AdsEventTypes -import com.theoplayer.android.api.event.player.PlayerEvent 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 button that seeks forward or backward by a fixed offset, @@ -24,7 +24,6 @@ class SeekButtonState internal constructor( private val player: Player?, private val seekOffset: Int ) { - private val listeners = PlayerStateListeners() /** * Whether the button can be interacted with. @@ -40,33 +39,33 @@ class SeekButtonState internal constructor( playingAd = player?.ads?.isPlaying ?: false } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.TIMEUPDATE, listener) - player.add(PlayerEventTypes.PLAY, listener) - player.add(PlayerEventTypes.PAUSE, listener) - player.add(PlayerEventTypes.ENDED, listener) - player.add(PlayerEventTypes.SEEKING, listener) - player.add(PlayerEventTypes.SEEKED, listener) - player.add(PlayerEventTypes.READYSTATECHANGE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) + coroutineScope { player.ads?.let { ads -> - val adListener = EventListener> { update() } - ads.add(AdsEventTypes.AD_BREAK_BEGIN, adListener) - ads.add(AdsEventTypes.AD_SKIP, adListener) - ads.add(AdsEventTypes.AD_BREAK_END, adListener) + 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.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { update() } } } - internal fun dispose() { - listeners.removeAll() - } - /** * Seeks forward (if [seekOffset] is positive) or backward (if negative) by [seekOffset] * seconds, clamped to the media duration. @@ -90,9 +89,6 @@ class SeekButtonState internal constructor( @Composable fun rememberSeekButtonState(player: Player?, seekOffset: Int): SeekButtonState { val state = remember(player, seekOffset) { SeekButtonState(player, seekOffset) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt index 3195a3a8..8ab1a206 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.Event -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.track.texttrack.TextTrack @@ -20,7 +18,6 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrackMode */ @ExperimentalPlayerStateApi class SubtitleTrackState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() /** * The available subtitle tracks. @@ -42,19 +39,14 @@ class SubtitleTrackState internal constructor(private val player: Player?) { activeSubtitleTrack = subtitleTracks.find { it.mode == TextTrackMode.SHOWING } } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.textTracks.add(TextTrackListEventTypes.ADDTRACK, listener) - player.textTracks.add(TextTrackListEventTypes.REMOVETRACK, listener) - player.textTracks.add(TextTrackListEventTypes.TRACKLISTCHANGE, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.textTracks.listen( + TextTrackListEventTypes.ADDTRACK, + TextTrackListEventTypes.REMOVETRACK, + TextTrackListEventTypes.TRACKLISTCHANGE + ) { update() } } /** @@ -78,9 +70,6 @@ class SubtitleTrackState internal constructor(private val player: Player?) { @Composable fun rememberSubtitleTrackState(player: Player?): SubtitleTrackState { val state = remember(player) { SubtitleTrackState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt index c500f488..cb0d389c 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt @@ -1,18 +1,21 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect 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.Event -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes import com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch /** * State holder for video qualities, such as [QualityList] or [QualityMenu]. @@ -21,8 +24,6 @@ import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality */ @ExperimentalPlayerStateApi class VideoQualityState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() - private val trackListeners = PlayerStateListeners() /** * The qualities of the active video track. @@ -44,46 +45,48 @@ class VideoQualityState internal constructor(private val player: Player?) { private var activeVideoTrack: MediaTrack? = null - private fun update() { - setActiveVideoTrack(player?.videoTracks?.find { it.isEnabled }) - } + private fun findActiveVideoTrack() = player?.videoTracks?.find { it.isEnabled } private fun setActiveVideoTrack(videoTrack: MediaTrack?) { if (activeVideoTrack == videoTrack) { return } - trackListeners.removeAll() activeVideoTrack = videoTrack videoQualities = videoTrack?.qualities?.toList() ?: listOf() activeVideoQuality = videoTrack?.activeQuality targetVideoQuality = videoTrack?.targetQuality - videoTrack?.let { track -> - trackListeners.run { - val listener = EventListener> { - activeVideoQuality = track.activeQuality - targetVideoQuality = track.targetQuality - } - track.add(VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT, listener) - track.add(VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT, listener) - } - } } - internal fun observe() { - val player = player ?: return - update() - listeners.run { - val listener = EventListener> { update() } - player.videoTracks.add(VideoTrackListEventTypes.ADDTRACK, listener) - player.videoTracks.add(VideoTrackListEventTypes.REMOVETRACK, listener) - player.videoTracks.add(VideoTrackListEventTypes.TRACKLISTCHANGE, listener) - } + init { + setActiveVideoTrack(findActiveVideoTrack()) } - internal fun dispose() { - listeners.removeAll() - trackListeners.removeAll() - activeVideoTrack = null + internal suspend fun observe() { + val player = player ?: return + val activeTrack = MutableStateFlow(findActiveVideoTrack()) + try { + coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { + activeTrack.collectLatest { track -> + setActiveVideoTrack(track) + track?.listen( + VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT, + VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT + ) { + activeVideoQuality = track.activeQuality + targetVideoQuality = track.targetQuality + } + } + } + player.videoTracks.listen( + VideoTrackListEventTypes.ADDTRACK, + VideoTrackListEventTypes.REMOVETRACK, + VideoTrackListEventTypes.TRACKLISTCHANGE + ) { activeTrack.value = findActiveVideoTrack() } + } + } finally { + activeVideoTrack = null + } } /** @@ -102,9 +105,6 @@ class VideoQualityState internal constructor(private val player: Player?) { @Composable fun rememberVideoQualityState(player: Player?): VideoQualityState { val state = remember(player) { VideoQualityState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt index 92271402..8bde7a53 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -18,7 +16,6 @@ import com.theoplayer.android.api.player.Player */ @ExperimentalPlayerStateApi class VideoSizeState internal constructor(private val player: Player?) { - private val listeners = PlayerStateListeners() /** * The width of the video, in pixels. @@ -37,19 +34,14 @@ class VideoSizeState internal constructor(private val player: Player?) { videoHeight = player?.videoHeight ?: 0 } - internal fun observe() { + internal suspend fun observe() { val player = player ?: return update() - listeners.run { - val listener = EventListener> { update() } - player.add(PlayerEventTypes.RESIZE, listener) - player.add(PlayerEventTypes.SOURCECHANGE, listener) - player.add(PlayerEventTypes.ERROR, listener) - } - } - - internal fun dispose() { - listeners.removeAll() + player.listen( + PlayerEventTypes.RESIZE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR + ) { update() } } } @@ -60,9 +52,6 @@ class VideoSizeState internal constructor(private val player: Player?) { @Composable fun rememberVideoSizeState(player: Player?): VideoSizeState { val state = remember(player) { VideoSizeState(player) } - DisposableEffect(state) { - state.observe() - onDispose { state.dispose() } - } + LaunchedEffect(state) { state.observe() } return state } From 8da65d5666e88bfcbe070f7de2fa55add200ad0d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:34:31 +0000 Subject: [PATCH 055/158] Run state holder tests on coroutines Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 + ui-common/build.gradle.kts | 1 + .../com/theoplayer/android/ui/AdStateTest.kt | 9 ++- .../android/ui/AudioTrackStateTest.kt | 34 ++++---- .../theoplayer/android/ui/CastStateTest.kt | 40 ++++++---- .../theoplayer/android/ui/ErrorStateTest.kt | 20 ++--- .../android/ui/EventDispatcherUtilTest.kt | 66 ++++++++++++++++ .../android/ui/FirstPlayStateTest.kt | 25 +++--- .../android/ui/FullscreenStateTest.kt | 40 +++++----- .../android/ui/LiveButtonStateTest.kt | 21 ++--- .../theoplayer/android/ui/LoadingStateTest.kt | 9 ++- .../com/theoplayer/android/ui/MockPlayer.kt | 79 +++++++++++++++++-- .../android/ui/MuteButtonStateTest.kt | 20 ++--- .../android/ui/PictureInPictureStateTest.kt | 29 ++++--- .../android/ui/PlayButtonStateTest.kt | 36 +++++---- .../android/ui/PlaybackRateStateTest.kt | 20 ++--- .../android/ui/PlaybackTimeStateTest.kt | 23 +++--- .../theoplayer/android/ui/SeekBarStateTest.kt | 36 +++++---- .../android/ui/SeekButtonStateTest.kt | 28 ++++--- .../android/ui/SubtitleTrackStateTest.kt | 26 +++--- .../com/theoplayer/android/ui/TestUtil.kt | 9 +++ .../android/ui/VideoQualityStateTest.kt | 31 ++++---- .../android/ui/VideoSizeStateTest.kt | 9 ++- 23 files changed, 398 insertions(+), 215 deletions(-) create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/TestUtil.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaf7f30a..3c3563ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ appcompat = "1.7.1" compose-bom = "2026.03.01" junit4 = "4.13.2" mockk = "1.14.9" +kotlinx-coroutines = "1.9.0" robolectric = "4.14.1" playServices-castFramework = "22.3.1" ui-test-junit4 = "1.10.6" # ...not in BOM for some reason? @@ -54,6 +55,7 @@ dokka-plugin = { group = "org.jetbrains.dokka", name = "android-documentation-pl kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" } theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" } diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index aa7acf6c..befb04ad 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.androidx.compose.ui.ui) testImplementation(libs.junit4) + testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) compileOnly(libs.theoplayer.compile) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt index c9d060ae..9654a30f 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/AdStateTest.kt @@ -4,22 +4,23 @@ import com.theoplayer.android.api.event.player.PlayerEventTypes import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.test.runTest import org.junit.Test class AdStateTest { @Test - fun `registers sourcechange error and ad events`() { + fun `registers sourcechange error and ad events`() = runTest { val mock = MockPlayer() - AdState(mock.player).observe() + launchObserve { AdState(mock.player).observe() } assertEquals(setOf(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), mock.playerEventTypes) assertEquals(adEventTypes, mock.adEventTypes) } @Test - fun `seeds playingAd and updates on ad events and sourcechange`() { + fun `seeds playingAd and updates on ad events and sourcechange`() = runTest { val mock = MockPlayer(adPlaying = true) val state = AdState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.playingAd) mock.adPlaying = false diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt index 59b0e55f..5008df44 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/AudioTrackStateTest.kt @@ -3,6 +3,8 @@ package com.theoplayer.android.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class AudioTrackStateTest { @@ -13,36 +15,36 @@ class AudioTrackStateTest { } @Test - fun `registers the track list events with one listener`() { + fun `registers the track list events with one listener`() = runTest { val mock = playerWithTracks(MockAudioTrack(enabled = true)) val state = AudioTrackState(mock.player) - state.observe() + val job = launchObserve { state.observe() } val listener = mock.audioTrackListeners.first().second assertTrue(mock.audioTrackListeners.all { it.second === listener }) assertEquals(3, mock.audioTrackListeners.size) - state.dispose() + job.cancelAndJoin() assertTrue(mock.audioTrackListeners.isEmpty()) } @Test - fun `seeds tracks and active track`() { + fun `seeds tracks and active track`() = runTest { val disabled = MockAudioTrack(enabled = false) val enabled = MockAudioTrack(enabled = true) val mock = playerWithTracks(disabled, enabled) val state = AudioTrackState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(listOf(disabled.track, enabled.track), state.audioTracks) assertEquals(enabled.track, state.activeAudioTrack) } @Test - fun `addtrack updates the list`() { + fun `addtrack updates the list`() = runTest { val mock = playerWithTracks(MockAudioTrack(enabled = true)) val state = AudioTrackState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(1, state.audioTracks.size) val added = MockAudioTrack(enabled = false) @@ -52,12 +54,12 @@ class AudioTrackStateTest { } @Test - fun `tracklistchange updates the active track`() { + fun `tracklistchange updates the active track`() = runTest { val first = MockAudioTrack(enabled = true) val second = MockAudioTrack(enabled = false) val mock = playerWithTracks(first, second) val state = AudioTrackState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(first.track, state.activeAudioTrack) first.enabled = false @@ -67,12 +69,12 @@ class AudioTrackStateTest { } @Test - fun `select enables the track and updates the state`() { + fun `select enables the track and updates the state`() = runTest { val first = MockAudioTrack(enabled = true) val second = MockAudioTrack(enabled = false) val mock = playerWithTracks(first, second) val state = AudioTrackState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(second.track) assertTrue(second.enabled) @@ -80,11 +82,11 @@ class AudioTrackStateTest { } @Test - fun `select null disables all tracks`() { + fun `select null disables all tracks`() = runTest { val enabled = MockAudioTrack(enabled = true) val mock = playerWithTracks(enabled) val state = AudioTrackState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(null) assertFalse(enabled.enabled) @@ -92,11 +94,11 @@ class AudioTrackStateTest { } @Test - fun `null player gives defaults and no listeners`() { + fun `null player gives defaults and no listeners`() = runTest { val state = AudioTrackState(null) - state.observe() + val job = launchObserve { state.observe() } assertTrue(state.audioTracks.isEmpty()) assertEquals(null, state.activeAudioTrack) - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt index d0786e27..481c5be8 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt @@ -14,6 +14,8 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class CastStateTest { @@ -54,23 +56,23 @@ class CastStateTest { } @Test - fun `null view and null cast give defaults`() { + fun `null view and null cast give defaults`() = runTest { val state = CastState(null) - state.observe() + val job = launchObserve { state.observe() } assertEquals(PlayerCastState.UNAVAILABLE, state.castState) assertEquals(null, state.castReceiverName) assertFalse(state.casting) state.startCasting() state.stopCasting() - state.dispose() + job.cancelAndJoin() } @Test - fun `observe registers both events with one listener and dispose removes them`() { + fun `observe registers both events with one listener and dispose removes them`() = runTest { val (cast, listeners, _) = mockCast() val view = mockView(cast) val state = CastState(view) - state.observe() + val job = launchObserve { state.observe() } assertEquals(2, listeners.size) assertEquals( @@ -79,34 +81,39 @@ class CastStateTest { ) assertTrue(listeners.all { it.second === listeners.first().second }) - state.dispose() + job.cancelAndJoin() assertTrue(listeners.isEmpty()) } @Test - fun `seeds and updates on events`() { + fun `seeds and updates on events`() = runTest { val (cast, listeners, _) = mockCast(state = PlayerCastState.CONNECTED, casting = true) val view = mockView(cast) val state = CastState(view) - state.observe() + launchObserve { state.observe() } assertEquals(PlayerCastState.CONNECTED, state.castState) assertEquals("Receiver", state.castReceiverName) assertTrue(state.casting) - listeners.toList().forEach { (_, listener) -> + listeners.toList().forEach { (type, listener) -> @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent( + when (type) { + ChromecastEventTypes.STATECHANGE -> mockk() + else -> mockk() + } + ) } assertEquals(PlayerCastState.CONNECTED, state.castState) } @Test - fun `start and stop delegate to the chromecast`() { + fun `start and stop delegate to the chromecast`() = runTest { val (cast, _, chromecast) = mockCast() val view = mockView(cast) val state = CastState(view) - state.observe() + launchObserve { state.observe() } state.startCasting() verify { chromecast.start() } @@ -115,7 +122,7 @@ class CastStateTest { } @Test - fun `replacing the cast integration moves the listeners`() { + fun `replacing the cast integration moves the listeners`() = runTest { val (oldCast, oldListeners, _) = mockCast() val (newCast, newListeners, _) = mockCast(state = PlayerCastState.CONNECTED) var currentCast: Cast = oldCast @@ -123,17 +130,20 @@ class CastStateTest { every { this@mockk.cast } answers { currentCast } } val state = CastState(view) - state.observe() + launchObserve { state.observe() } assertEquals(2, oldListeners.size) currentCast = newCast + state.castState + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + testScheduler.runCurrent() assertEquals(PlayerCastState.CONNECTED, state.castState) assertTrue(oldListeners.isEmpty()) assertEquals(2, newListeners.size) } @Test - fun `getters before observe register nothing`() { + fun `getters before observe register nothing`() = runTest { val (cast, listeners, _) = mockCast() val view = mockView(cast) val state = CastState(view) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt index 4e4984e8..ebc5b46a 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/ErrorStateTest.kt @@ -5,22 +5,24 @@ import com.theoplayer.android.api.event.player.PlayerEventTypes import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class ErrorStateTest { @Test - fun `registers error and sourcechange`() { + fun `registers error and sourcechange`() = runTest { val mock = MockPlayer() - ErrorState(mock.player).observe() + launchObserve { ErrorState(mock.player).observe() } assertEquals(setOf(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE), mock.playerEventTypes) } @Test - fun `error event sets error and sourcechange clears it`() { + fun `error event sets error and sourcechange clears it`() = runTest { val exception = mockk() val mock = MockPlayer() val state = ErrorState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(null, state.error) mock.errorObject = exception @@ -32,11 +34,11 @@ class ErrorStateTest { } @Test - fun `seeds initial error and sourcechange clears it`() { + fun `seeds initial error and sourcechange clears it`() = runTest { val exception = mockk() val mock = MockPlayer() val state = ErrorState(mock.player, initialError = exception) - state.observe() + launchObserve { state.observe() } assertEquals(exception, state.error) mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) @@ -44,13 +46,13 @@ class ErrorStateTest { } @Test - fun `null player keeps initial error and registers no listeners`() { + fun `null player keeps initial error and registers no listeners`() = runTest { val exception = mockk() val mock = MockPlayer() val state = ErrorState(null, initialError = exception) - state.observe() + val job = launchObserve { state.observe() } assertEquals(exception, state.error) assertTrue(mock.playerListeners.isEmpty()) - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt new file mode 100644 index 00000000..a476fc27 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt @@ -0,0 +1,66 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class EventDispatcherUtilTest { + @Test + fun `one listener instance is registered for all types and removed on cancel`() = runTest { + val mock = MockPlayer() + val job = launchObserve { + mock.player.listen(PlayerEventTypes.PLAY, PlayerEventTypes.PAUSE) {} + } + + assertEquals( + listOf(PlayerEventTypes.PLAY, PlayerEventTypes.PAUSE), + mock.playerListeners.map { it.first } + ) + assertSame(mock.playerListeners[0].second, mock.playerListeners[1].second) + + job.cancelAndJoin() + assertTrue(mock.playerListeners.isEmpty()) + } + + @Test + fun `a throwing onEvent removes the listeners and rethrows`() = runTest { + val mock = MockPlayer() + val exception = RuntimeException("boom") + var received: Throwable? = null + val job = launchObserve { + try { + mock.player.listen(PlayerEventTypes.PLAY) { throw exception } + } catch (t: Throwable) { + received = t + } + } + + mock.firePlayerEvent(PlayerEventTypes.PLAY) + job.join() + // The test dispatcher may copy exceptions for stack-trace recovery. + assertEquals(exception.message, (received as? RuntimeException)?.message) + assertTrue(mock.playerListeners.isEmpty()) + } + + @Test + fun `a captured listener does not call onEvent after cancel`() = runTest { + val mock = MockPlayer() + var calls = 0 + val job = launchObserve { + mock.player.listen(PlayerEventTypes.PLAY) { calls++ } + } + val captured = mock.playerListeners.single().second as EventListener> + mock.firePlayerEvent(PlayerEventTypes.PLAY) + assertEquals(1, calls) + + job.cancelAndJoin() + captured.handleEvent(io.mockk.mockk()) + assertEquals(1, calls) + } +} diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt index abc28629..762f6fc0 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FirstPlayStateTest.kt @@ -3,38 +3,39 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.player.PlayerEventTypes import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.test.runTest import org.junit.Test class FirstPlayStateTest { @Test - fun `seeds false when paused with empty played`() { + fun `seeds false when paused with empty played`() = runTest { val mock = MockPlayer(paused = true, playedLength = 0) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } assertFalse(state.firstPlay) } @Test - fun `seeds true when paused with non-empty played`() { + fun `seeds true when paused with non-empty played`() = runTest { val mock = MockPlayer(paused = true, playedLength = 1) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.firstPlay) } @Test - fun `seeds true when playing`() { + fun `seeds true when playing`() = runTest { val mock = MockPlayer(paused = false) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.firstPlay) } @Test - fun `play sets firstPlay`() { + fun `play sets firstPlay`() = runTest { val mock = MockPlayer(paused = true) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } mock.paused = false mock.firePlayerEvent(PlayerEventTypes.PLAY) @@ -42,10 +43,10 @@ class FirstPlayStateTest { } @Test - fun `sourcechange resets firstPlay while paused`() { + fun `sourcechange resets firstPlay while paused`() = runTest { val mock = MockPlayer(paused = false) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.firstPlay) mock.paused = true @@ -55,10 +56,10 @@ class FirstPlayStateTest { } @Test - fun `sourcechange keeps firstPlay while playing`() { + fun `sourcechange keeps firstPlay while playing`() = runTest { val mock = MockPlayer(paused = false) val state = FirstPlayState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.firstPlay) mock.firePlayerEvent(PlayerEventTypes.SOURCECHANGE) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt index 50291464..5f62f507 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt @@ -10,6 +10,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test /** @@ -51,42 +53,42 @@ private fun mockViewWithHandler(handler: FullscreenHandler?): THEOplayerView = m class FullscreenStateTest { @Test - fun `null view gives defaults and does not throw`() { + fun `null view gives defaults and does not throw`() = runTest { val state = FullscreenState(null) - state.observe() + val job = launchObserve { state.observe() } assertFalse(state.fullscreen) state.enterFullscreen() state.exitFullscreen() assertTrue(state.fullscreen.not()) - state.dispose() + job.cancelAndJoin() } @Test - fun `observe seeds fullscreen and adds one listener`() { + fun `observe seeds fullscreen and adds one listener`() = runTest { val handler = FakeFullscreenHandler().also { it.fire(true) } val state = FullscreenState(mockViewWithHandler(handler)) - state.observe() + launchObserve { state.observe() } assertTrue(state.fullscreen) assertEquals(1, handler.listeners.size) } @Test - fun `dispose removes the listener`() { + fun `dispose removes the listener`() = runTest { val handler = FakeFullscreenHandler() val state = FullscreenState(mockViewWithHandler(handler)) - state.observe() + val job = launchObserve { state.observe() } assertEquals(1, handler.listeners.size) - state.dispose() + job.cancelAndJoin() assertTrue(handler.listeners.isEmpty()) } @Test - fun `handler notification updates fullscreen`() { + fun `handler notification updates fullscreen`() = runTest { val handler = FakeFullscreenHandler() val state = FullscreenState(mockViewWithHandler(handler)) - state.observe() + launchObserve { state.observe() } assertFalse(state.fullscreen) handler.fire(true) @@ -94,10 +96,10 @@ class FullscreenStateTest { } @Test - fun `enterFullscreen updates state and calls the handler once`() { + fun `enterFullscreen updates state and calls the handler once`() = runTest { val handler = FakeFullscreenHandler() val state = FullscreenState(mockViewWithHandler(handler)) - state.observe() + launchObserve { state.observe() } state.enterFullscreen() assertTrue(state.fullscreen) @@ -105,10 +107,10 @@ class FullscreenStateTest { } @Test - fun `exitFullscreen updates state and calls the handler once`() { + fun `exitFullscreen updates state and calls the handler once`() = runTest { val handler = FakeFullscreenHandler().also { it.fire(true) } val state = FullscreenState(mockViewWithHandler(handler)) - state.observe() + launchObserve { state.observe() } state.exitFullscreen() assertFalse(state.fullscreen) @@ -116,13 +118,13 @@ class FullscreenStateTest { } @Test - fun `two states on the same view share the handler`() { + fun `two states on the same view share the handler`() = runTest { val handler = FakeFullscreenHandler() val view = mockViewWithHandler(handler) val first = FullscreenState(view) val second = FullscreenState(view) - first.observe() - second.observe() + launchObserve { first.observe() } + launchObserve { second.observe() } assertEquals(2, handler.listeners.size) handler.fire(true) @@ -131,7 +133,7 @@ class FullscreenStateTest { } @Test - fun `fullscreenHandler returns the tagged handler without findViewById`() { + fun `fullscreenHandler returns the tagged handler without findViewById`() = runTest { val handler = FakeFullscreenHandler() val view = mockViewWithHandler(handler) @@ -142,7 +144,7 @@ class FullscreenStateTest { } @Test - fun `fullscreenHandler returns null without a container`() { + fun `fullscreenHandler returns null without a container`() = runTest { val view = mockk { every { getTag(R.id.theoplayer_ui_fullscreen_handler) } returns null every { findViewById(com.theoplayer.android.R.id.theo_player_container) } returns null 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 1d9f3b70..b4be5b0d 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 @@ -5,13 +5,14 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.test.runTest import org.junit.Test class LiveButtonStateTest { @Test - fun `registers the playback events`() { + fun `registers the playback events`() = runTest { val mock = MockPlayer() - LiveButtonState(mock.player, liveThreshold = 10.0).observe() + launchObserve { LiveButtonState(mock.player, liveThreshold = 10.0).observe() } assertEquals( playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, mock.playerEventTypes @@ -19,16 +20,16 @@ class LiveButtonStateTest { } @Test - fun `visible for live but not for vod`() { + fun `visible for live but not for vod`() = runTest { val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) val vod = MockPlayer(duration = 100.0) - assertTrue(LiveButtonState(live.player, liveThreshold = 10.0).apply { observe() }.visible) - assertFalse(LiveButtonState(vod.player, liveThreshold = 10.0).apply { observe() }.visible) + assertTrue(LiveButtonState(live.player, liveThreshold = 10.0).also { launchObserve { it.observe() } }.visible) + assertFalse(LiveButtonState(vod.player, liveThreshold = 10.0).also { launchObserve { it.observe() } }.visible) } @Test - fun `isLive at threshold boundary`() { + fun `isLive at threshold boundary`() = runTest { val mock = MockPlayer( paused = false, duration = Double.POSITIVE_INFINITY, @@ -36,20 +37,20 @@ class LiveButtonStateTest { seekableRanges = listOf(0.0 to 30.0) ) val atThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) - atThreshold.observe() + launchObserve { atThreshold.observe() } assertTrue(atThreshold.isLive) mock.currentTime = 19.9 val behindThreshold = LiveButtonState(mock.player, liveThreshold = 10.0) - behindThreshold.observe() + launchObserve { behindThreshold.observe() } assertFalse(behindThreshold.isLive) } @Test - fun `onClick seeks to live and plays`() { + fun `onClick seeks to live and plays`() = runTest { val mock = MockPlayer(duration = Double.POSITIVE_INFINITY) val state = LiveButtonState(mock.player, liveThreshold = 10.0) - state.observe() + launchObserve { state.observe() } state.onClick() verify { mock.player.currentTime = Double.POSITIVE_INFINITY } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt index ca8768c4..c953cc75 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/LoadingStateTest.kt @@ -5,21 +5,22 @@ import com.theoplayer.android.api.player.ReadyState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.test.runTest import org.junit.Test class LoadingStateTest { @Test - fun `registers the playback events`() { + fun `registers the playback events`() = runTest { val mock = MockPlayer() - LoadingState(mock.player).observe() + launchObserve { LoadingState(mock.player).observe() } assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) } @Test - fun `loading truth table`() { + fun `loading truth table`() = runTest { fun state(paused: Boolean, ended: Boolean = false, seeking: Boolean = false, readyState: ReadyState) = LoadingState(MockPlayer(paused = paused, ended = ended, seeking = seeking, readyState = readyState).player) - .apply { observe() }.loading + .also { launchObserve { it.observe() } }.loading assertFalse(state(paused = false, readyState = ReadyState.HAVE_ENOUGH_DATA)) assertTrue(state(paused = false, readyState = ReadyState.HAVE_CURRENT_DATA)) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt index 12998d65..68d5a9d9 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt @@ -5,13 +5,28 @@ import com.theoplayer.android.api.event.Event import com.theoplayer.android.api.event.EventDispatcher import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdBreakBeginEvent +import com.theoplayer.android.api.event.ads.AdBreakEndEvent import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdSkipEvent import com.theoplayer.android.api.event.ads.AdsEventTypes import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.player.DurationChangeEvent +import com.theoplayer.android.api.event.player.EndedEvent import com.theoplayer.android.api.event.player.ErrorEvent +import com.theoplayer.android.api.event.player.PauseEvent +import com.theoplayer.android.api.event.player.PlayEvent import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.event.player.PresentationModeChange +import com.theoplayer.android.api.event.player.RateChangeEvent +import com.theoplayer.android.api.event.player.ReadyStateChangeEvent +import com.theoplayer.android.api.event.player.ResizeEvent +import com.theoplayer.android.api.event.player.SeekedEvent +import com.theoplayer.android.api.event.player.SeekingEvent import com.theoplayer.android.api.event.player.SourceChangeEvent +import com.theoplayer.android.api.event.player.TimeUpdateEvent +import com.theoplayer.android.api.event.player.VolumeChangeEvent import com.theoplayer.android.api.event.track.TrackEvent import com.theoplayer.android.api.event.track.tracklist.TrackListEvent import com.theoplayer.android.api.player.Player @@ -145,17 +160,36 @@ internal class MockPlayer( } private fun mockEvent(type: EventType<*>): PlayerEvent<*> = when (type) { + PlayerEventTypes.PLAY -> mockk() + PlayerEventTypes.PAUSE -> mockk() + PlayerEventTypes.ENDED -> mockk() + PlayerEventTypes.SEEKING -> mockk() + PlayerEventTypes.SEEKED -> mockk() + PlayerEventTypes.READYSTATECHANGE -> mockk() + PlayerEventTypes.SOURCECHANGE -> mockk() PlayerEventTypes.ERROR -> mockk { every { errorObject } returns this@MockPlayer.errorObject } - PlayerEventTypes.SOURCECHANGE -> mockk() + PlayerEventTypes.TIMEUPDATE -> mockk() + PlayerEventTypes.DURATIONCHANGE -> mockk() + PlayerEventTypes.VOLUMECHANGE -> mockk() + PlayerEventTypes.RESIZE -> mockk() + PlayerEventTypes.RATECHANGE -> mockk() + PlayerEventTypes.PRESENTATIONMODECHANGE -> mockk() else -> mockk() } fun fireAdEvent() { - adListeners.toList().forEach { (_, listener) -> + adListeners.toList().forEach { (type, listener) -> @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent( + when (type) { + AdsEventTypes.AD_BREAK_BEGIN -> mockk() + AdsEventTypes.AD_SKIP -> mockk() + AdsEventTypes.AD_BREAK_END -> mockk() + else -> mockk() + } + ) } } } @@ -235,6 +269,7 @@ internal class MockVideoTrack( var targetQuality: VideoQuality? = null, val qualities: List = listOf() ) { + // Fires at all listeners with an event matching their registered event type. val uid = nextTrackUid++ val listeners = mutableListOf, EventListener<*>>>() @@ -261,17 +296,47 @@ internal class MockVideoTrack( } fun fireEvent() { - listeners.toList().forEach { (_, listener) -> + listeners.toList().forEach { (type, listener) -> @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent( + when (type) { + com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT -> + mockk() + else -> mockk() + } + ) } } } internal fun MutableList, EventListener<*>>>.fireTrackListEvent() { - toList().forEach { (_, listener) -> + toList().forEach { (type, listener) -> @Suppress("UNCHECKED_CAST") - (listener as EventListener>).handleEvent(mockk()) + (listener as EventListener>).handleEvent( + when (type) { + com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.ADDTRACK -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.REMOVETRACK -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.TRACKLISTCHANGE -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.ADDTRACK -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.REMOVETRACK -> + mockk() + com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.TRACKLISTCHANGE -> + mockk() + com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.ADDTRACK -> + mockk() + com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.REMOVETRACK -> + mockk() + com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.TRACKLISTCHANGE -> + mockk() + else -> mockk() + } + ) } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt index cc84cbc2..0b1ee720 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/MuteButtonStateTest.kt @@ -5,21 +5,23 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class MuteButtonStateTest { @Test - fun `registers only the volume change event`() { + fun `registers only the volume change event`() = runTest { val mock = MockPlayer() - MuteButtonState(mock.player).observe() + launchObserve { MuteButtonState(mock.player).observe() } assertEquals(setOf(PlayerEventTypes.VOLUMECHANGE), mock.playerEventTypes) } @Test - fun `seeds muted and updates on volume change`() { + fun `seeds muted and updates on volume change`() = runTest { val mock = MockPlayer(muted = true) val state = MuteButtonState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.muted) mock.muted = false @@ -28,10 +30,10 @@ class MuteButtonStateTest { } @Test - fun `onClick toggles player and state`() { + fun `onClick toggles player and state`() = runTest { val mock = MockPlayer(muted = false) val state = MuteButtonState(mock.player) - state.observe() + launchObserve { state.observe() } state.onClick() verify { mock.player.isMuted = true } @@ -39,11 +41,11 @@ class MuteButtonStateTest { } @Test - fun `null player gives defaults and onClick does nothing`() { + fun `null player gives defaults and onClick does nothing`() = runTest { val state = MuteButtonState(null) - state.observe() + val job = launchObserve { state.observe() } assertFalse(state.muted) state.onClick() - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt index b12bacb7..749d6de0 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt @@ -11,6 +11,8 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class PictureInPictureStateTest { @@ -26,36 +28,36 @@ class PictureInPictureStateTest { } @Test - fun `null view gives defaults and does not throw`() { + fun `null view gives defaults and does not throw`() = runTest { val state = PictureInPictureState(null) - state.observe() + val job = launchObserve { state.observe() } assertFalse(state.pictureInPictureSupported) assertFalse(state.pictureInPicture) state.enterPictureInPicture(PiPType.ACTIVITY) state.exitPictureInPicture() - state.dispose() + job.cancelAndJoin() } @Test - fun `observe registers presentationmodechange and dispose removes it`() { + fun `observe registers presentationmodechange and dispose removes it`() = runTest { val mock = MockPlayer() val state = PictureInPictureState(mockView(player = mock)) - state.observe() + val job = launchObserve { state.observe() } assertEquals(setOf(PlayerEventTypes.PRESENTATIONMODECHANGE), mock.playerEventTypes) - state.dispose() + job.cancelAndJoin() assertTrue(mock.playerListeners.isEmpty()) } @Test - fun `seeds and updates pip mode on presentationmodechange`() { + fun `seeds and updates pip mode on presentationmodechange`() = runTest { val piPManager = mockk { every { isInPiP } returns true } val mock = MockPlayer() val state = PictureInPictureState(mockView(player = mock, piPManager = piPManager)) - state.observe() + val job = launchObserve { state.observe() } assertTrue(state.pictureInPicture) every { piPManager.isInPiP } returns false @@ -64,14 +66,14 @@ class PictureInPictureStateTest { } @Test - fun `activityInPipMode alone counts as pictureInPicture`() { + fun `activityInPipMode alone counts as pictureInPicture`() = runTest { val state = PictureInPictureState(mockView()) state.activityInPipMode = true assertTrue(state.pictureInPicture) } @Test - fun `enter when not fullscreen calls enterPiP directly`() { + fun `enter when not fullscreen calls enterPiP directly`() = runTest { val piPManager = mockk(relaxed = true) val handler = FakeFullscreenHandler() val state = PictureInPictureState( @@ -84,7 +86,7 @@ class PictureInPictureStateTest { } @Test - fun `enter while fullscreen waits for the exit notification`() { + fun `enter while fullscreen waits for the exit notification`() = runTest { val piPManager = mockk(relaxed = true) val handler = FakeFullscreenHandler().also { it.fire(true) } val state = PictureInPictureState( @@ -102,15 +104,16 @@ class PictureInPictureStateTest { } @Test - fun `dispose cancels a pending enterPiP`() { + fun `dispose cancels a pending enterPiP`() = runTest { val piPManager = mockk(relaxed = true) val handler = FakeFullscreenHandler().also { it.fire(true) } val state = PictureInPictureState( mockView(piPManager = piPManager, fullscreenHandler = handler) ) + val job = launchObserve { state.observe() } state.enterPictureInPicture(PiPType.ACTIVITY) - state.dispose() + job.cancelAndJoin() assertTrue(handler.listeners.isEmpty()) handler.fire(false) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt index 2480b190..54a2c9c9 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt @@ -8,21 +8,23 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class PlayButtonStateTest { @Test - fun `registers the playback events`() { + fun `registers the playback events`() = runTest { val mock = MockPlayer() - PlayButtonState(mock.player).observe() + launchObserve { PlayButtonState(mock.player).observe() } assertEquals(playbackEventTypes - PlayerEventTypes.TIMEUPDATE, mock.playerEventTypes) } @Test - fun `a seeked event clears ended`() { + fun `a seeked event clears ended`() = runTest { val mock = MockPlayer(paused = true, ended = true) val state = PlayButtonState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.ended) mock.ended = false @@ -32,10 +34,10 @@ class PlayButtonStateTest { } @Test - fun `seeds state and reacts to playback events`() { + fun `seeds state and reacts to playback events`() = runTest { val mock = MockPlayer(paused = true, ended = false) val state = PlayButtonState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.paused) assertFalse(state.ended) @@ -50,10 +52,10 @@ class PlayButtonStateTest { } @Test - fun `onClick plays when paused and pauses when playing`() { + fun `onClick plays when paused and pauses when playing`() = runTest { val mock = MockPlayer(paused = true) val state = PlayButtonState(mock.player) - state.observe() + launchObserve { state.observe() } state.onClick() verify(exactly = 1) { mock.player.play() } @@ -64,14 +66,14 @@ class PlayButtonStateTest { } @Test - fun `dispose removes all listeners`() { + fun `dispose removes all listeners`() = runTest { val mock = MockPlayer() val state = PlayButtonState(mock.player) - state.observe() + val job = launchObserve { state.observe() } val added = mock.playerListeners.toList() assertTrue(added.isNotEmpty()) - state.dispose() + job.cancelAndJoin() assertTrue(mock.playerListeners.isEmpty()) verify(exactly = added.size) { mock.player.removeEventListener(any>>(), any()) @@ -79,16 +81,16 @@ class PlayButtonStateTest { } @Test - fun `shares one listener instance and removes it for every type`() { + fun `shares one listener instance and removes it for every type`() = runTest { val mock = MockPlayer() val state = PlayButtonState(mock.player) - state.observe() + val job = launchObserve { state.observe() } val added = mock.playerListeners.toList() val listener = added.first().second assertTrue(added.all { it.second === listener }) - state.dispose() + job.cancelAndJoin() added.forEach { (type, _) -> @Suppress("UNCHECKED_CAST") verify { @@ -101,11 +103,11 @@ class PlayButtonStateTest { } @Test - fun `null player gives defaults and no listeners`() { + fun `null player gives defaults and no listeners`() = runTest { val state = PlayButtonState(null) - state.observe() + val job = launchObserve { state.observe() } assertTrue(state.paused) state.onClick() - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt index 3ecb3290..80e4c0c8 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackRateStateTest.kt @@ -3,21 +3,23 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.player.PlayerEventTypes import io.mockk.verify import org.junit.Assert.assertEquals +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class PlaybackRateStateTest { @Test - fun `registers only the ratechange event`() { + fun `registers only the ratechange event`() = runTest { val mock = MockPlayer() - PlaybackRateState(mock.player).observe() + launchObserve { PlaybackRateState(mock.player).observe() } assertEquals(setOf(PlayerEventTypes.RATECHANGE), mock.playerEventTypes) } @Test - fun `seeds rate and updates on ratechange`() { + fun `seeds rate and updates on ratechange`() = runTest { val mock = MockPlayer(playbackRate = 1.5) val state = PlaybackRateState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(1.5, state.playbackRate, 0.0) mock.playbackRate = 2.0 @@ -26,10 +28,10 @@ class PlaybackRateStateTest { } @Test - fun `select sets player rate and state`() { + fun `select sets player rate and state`() = runTest { val mock = MockPlayer() val state = PlaybackRateState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(2.0) verify { mock.player.playbackRate = 2.0 } @@ -37,11 +39,11 @@ class PlaybackRateStateTest { } @Test - fun `null player gives defaults and no listeners`() { + fun `null player gives defaults and no listeners`() = runTest { val state = PlaybackRateState(null) - state.observe() + val job = launchObserve { state.observe() } assertEquals(1.0, state.playbackRate, 0.0) state.select(2.0) - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt index 40bccb94..2cd6b138 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt @@ -2,13 +2,14 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.player.PlayerEventTypes import org.junit.Assert.assertEquals +import kotlinx.coroutines.test.runTest import org.junit.Test class PlaybackTimeStateTest { @Test - fun `registers the playback events`() { + fun `registers the playback events`() = runTest { val mock = MockPlayer() - PlaybackTimeState(mock.player).observe() + launchObserve { PlaybackTimeState(mock.player).observe() } assertEquals( playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, mock.playerEventTypes @@ -16,7 +17,7 @@ class PlaybackTimeStateTest { } @Test - fun `streamTypeOf classifies vod, live and dvr`() { + fun `streamTypeOf classifies vod, live and dvr`() = runTest { assertEquals( StreamType.Vod, streamTypeOf(100.0, com.theoplayer.android.ui.TimeRanges(listOf(0.0..100.0))) @@ -42,10 +43,10 @@ class PlaybackTimeStateTest { } @Test - fun `seeds and updates on time events`() { + fun `seeds and updates on time events`() = runTest { val mock = MockPlayer(currentTime = 5.0, duration = 100.0) val state = PlaybackTimeState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(5.0, state.currentTime, 0.0) assertEquals(100.0, state.duration, 0.0) @@ -56,25 +57,25 @@ class PlaybackTimeStateTest { } @Test - fun `duration prefers end of seekable range`() { + fun `duration prefers end of seekable range`() = runTest { val mock = MockPlayer( duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(10.0 to 70.0) ) val state = PlaybackTimeState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(70.0, state.duration, 0.0) } @Test - fun `reports live and dvr stream types`() { + fun `reports live and dvr stream types`() = runTest { val live = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 30.0)) val dvr = MockPlayer(duration = Double.POSITIVE_INFINITY, seekableRanges = listOf(0.0 to 120.0)) val vod = MockPlayer(duration = 100.0) - assertEquals(StreamType.Live, PlaybackTimeState(live.player).apply { observe() }.streamType) - assertEquals(StreamType.Dvr, PlaybackTimeState(dvr.player).apply { observe() }.streamType) - assertEquals(StreamType.Vod, PlaybackTimeState(vod.player).apply { observe() }.streamType) + assertEquals(StreamType.Live, PlaybackTimeState(live.player).also { launchObserve { it.observe() } }.streamType) + assertEquals(StreamType.Dvr, PlaybackTimeState(dvr.player).also { launchObserve { it.observe() } }.streamType) + assertEquals(StreamType.Vod, PlaybackTimeState(vod.player).also { launchObserve { it.observe() } }.streamType) } } 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 ff89fe33..57057a39 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 @@ -5,13 +5,15 @@ import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class SeekBarStateTest { @Test - fun `registers the playback and ad events`() { + fun `registers the playback and ad events`() = runTest { val mock = MockPlayer() - SeekBarState(mock.player).observe() + launchObserve { SeekBarState(mock.player).observe() } assertEquals( playbackEventTypes + PlayerEventTypes.DURATIONCHANGE, mock.playerEventTypes @@ -20,10 +22,10 @@ class SeekBarStateTest { } @Test - fun `an error event re-reads playingAd`() { + fun `an error event re-reads playingAd`() = runTest { val mock = MockPlayer(adPlaying = false) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } assertTrue(state.showThumb) mock.adPlaying = true @@ -33,10 +35,10 @@ class SeekBarStateTest { } @Test - fun `computes value within zero-based range`() { + fun `computes value within zero-based range`() = runTest { val mock = MockPlayer(currentTime = 30.0, duration = 100.0) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(0f..100f, state.valueRange) assertEquals(30f, state.value) @@ -45,14 +47,14 @@ class SeekBarStateTest { } @Test - fun `huge seekable start maps to zero-based range`() { + fun `huge seekable start maps to zero-based range`() = runTest { val mock = MockPlayer( currentTime = 1.7e9 + 30.0, duration = 1.7e9 + 100.0, seekableRanges = listOf(1.7e9 to 1.7e9 + 100.0) ) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(0f, state.valueRange.start) assertEquals(100f, state.valueRange.endInclusive, 0.001f) @@ -60,10 +62,10 @@ class SeekBarStateTest { } @Test - fun `scrubbing pauses a playing player and resumes on finish`() { + fun `scrubbing pauses a playing player and resumes on finish`() = runTest { val mock = MockPlayer(paused = false, currentTime = 10.0) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } state.onValueChange(50f) verify(exactly = 1) { mock.player.pause() } @@ -75,10 +77,10 @@ class SeekBarStateTest { } @Test - fun `scrubbing a paused player does not resume it`() { + fun `scrubbing a paused player does not resume it`() = runTest { val mock = MockPlayer(paused = true, currentTime = 10.0) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } state.onValueChange(50f) state.onValueChangeFinished() @@ -86,10 +88,10 @@ class SeekBarStateTest { } @Test - fun `disabled during ads and thumb hidden`() { + fun `disabled during ads and thumb hidden`() = runTest { val mock = MockPlayer(adPlaying = true) val state = SeekBarState(mock.player) - state.observe() + launchObserve { state.observe() } assertFalse(state.enabled) assertFalse(state.showThumb) @@ -101,15 +103,15 @@ class SeekBarStateTest { } @Test - fun `dispose removes all listeners`() { + fun `dispose removes all listeners`() = runTest { val mock = MockPlayer() val state = SeekBarState(mock.player) - state.observe() + val job = launchObserve { state.observe() } val added = mock.playerListeners.toList() assertTrue(added.isNotEmpty()) assertTrue(mock.adListeners.isNotEmpty()) - state.dispose() + job.cancelAndJoin() assertTrue(mock.playerListeners.isEmpty()) assertTrue(mock.adListeners.isEmpty()) } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt index eb9f82fe..dda490c1 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekButtonStateTest.kt @@ -4,62 +4,64 @@ import com.theoplayer.android.api.event.player.PlayerEventTypes import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class SeekButtonStateTest { @Test - fun `registers the playback and ad events`() { + fun `registers the playback and ad events`() = runTest { val mock = MockPlayer() - SeekButtonState(mock.player, seekOffset = 10).observe() + launchObserve { SeekButtonState(mock.player, seekOffset = 10).observe() } assertEquals(playbackEventTypes, mock.playerEventTypes) assertEquals(adEventTypes, mock.adEventTypes) } @Test - fun `onClick seeks by offset and clamps to duration`() { + fun `onClick seeks by offset and clamps to duration`() = runTest { val mock = MockPlayer(currentTime = 95.0, duration = 100.0) val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() + launchObserve { state.observe() } state.onClick() verify { mock.player.currentTime = 100.0 } } @Test - fun `onClick clamps negative seek to zero`() { + fun `onClick clamps negative seek to zero`() = runTest { val mock = MockPlayer(currentTime = 5.0, duration = 100.0) val state = SeekButtonState(mock.player, seekOffset = -10) - state.observe() + launchObserve { state.observe() } state.onClick() verify { mock.player.currentTime = 0.0 } } @Test - fun `onClick does nothing for streams without duration`() { + fun `onClick does nothing for streams without duration`() = runTest { val mock = MockPlayer(currentTime = 50.0, duration = Double.NaN) val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() + launchObserve { state.observe() } state.onClick() verify(exactly = 0) { mock.player.currentTime = any() } } @Test - fun `disabled during ads`() { + fun `disabled during ads`() = runTest { val mock = MockPlayer(adPlaying = true) val state = SeekButtonState(mock.player, seekOffset = 10) - state.observe() + launchObserve { state.observe() } assertFalse(state.enabled) } @Test - fun `null player is disabled and onClick does nothing`() { + fun `null player is disabled and onClick does nothing`() = runTest { val state = SeekButtonState(null, seekOffset = 10) - state.observe() + val job = launchObserve { state.observe() } assertFalse(state.enabled) state.onClick() - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt index ceb82267..0f8c9055 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SubtitleTrackStateTest.kt @@ -4,6 +4,8 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrackKind import com.theoplayer.android.api.player.track.texttrack.TextTrackMode import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class SubtitleTrackStateTest { @@ -14,40 +16,40 @@ class SubtitleTrackStateTest { } @Test - fun `registers the track list events with one listener`() { + fun `registers the track list events with one listener`() = runTest { val mock = playerWithTracks(MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING)) val state = SubtitleTrackState(mock.player) - state.observe() + val job = launchObserve { state.observe() } val listener = mock.textTrackListeners.first().second assertTrue(mock.textTrackListeners.all { it.second === listener }) assertEquals(3, mock.textTrackListeners.size) - state.dispose() + job.cancelAndJoin() assertTrue(mock.textTrackListeners.isEmpty()) } @Test - fun `filters out non-subtitle tracks`() { + fun `filters out non-subtitle tracks`() = runTest { val subtitles = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) val captions = MockTextTrack(TextTrackKind.CAPTIONS, TextTrackMode.SHOWING) val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) val mock = playerWithTracks(subtitles, captions, metadata) val state = SubtitleTrackState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(listOf(subtitles.track, captions.track), state.subtitleTracks) assertEquals(captions.track, state.activeSubtitleTrack) } @Test - fun `select shows the track and disables the other subtitles`() { + fun `select shows the track and disables the other subtitles`() = runTest { val first = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) val second = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.DISABLED) val metadata = MockTextTrack(TextTrackKind.METADATA, TextTrackMode.DISABLED) val mock = playerWithTracks(first, second, metadata) val state = SubtitleTrackState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(second.track) assertEquals(TextTrackMode.DISABLED, first.mode) @@ -57,11 +59,11 @@ class SubtitleTrackStateTest { } @Test - fun `select null disables all subtitle tracks`() { + fun `select null disables all subtitle tracks`() = runTest { val showing = MockTextTrack(TextTrackKind.SUBTITLES, TextTrackMode.SHOWING) val mock = playerWithTracks(showing) val state = SubtitleTrackState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(null) assertEquals(TextTrackMode.DISABLED, showing.mode) @@ -69,11 +71,11 @@ class SubtitleTrackStateTest { } @Test - fun `null player gives defaults and no listeners`() { + fun `null player gives defaults and no listeners`() = runTest { val state = SubtitleTrackState(null) - state.observe() + val job = launchObserve { state.observe() } assertTrue(state.subtitleTracks.isEmpty()) assertEquals(null, state.activeSubtitleTrack) - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/TestUtil.kt b/ui-common/src/test/java/com/theoplayer/android/ui/TestUtil.kt new file mode 100644 index 00000000..719852ce --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/TestUtil.kt @@ -0,0 +1,9 @@ +package com.theoplayer.android.ui + +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +internal fun TestScope.launchObserve(block: suspend () -> Unit): Job = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { block() } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt index 87725970..82bc9d7d 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt @@ -2,6 +2,8 @@ package com.theoplayer.android.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest import org.junit.Test class VideoQualityStateTest { @@ -12,40 +14,40 @@ class VideoQualityStateTest { } @Test - fun `registers list events plus quality events on the enabled track`() { + fun `registers list events plus quality events on the enabled track`() = runTest { val enabled = MockVideoTrack(enabled = true) val mock = playerWithTracks(enabled) val state = VideoQualityState(mock.player) - state.observe() + val job = launchObserve { state.observe() } assertEquals(3, mock.videoTrackListeners.size) assertEquals(2, enabled.listeners.size) - state.dispose() + job.cancelAndJoin() assertTrue(mock.videoTrackListeners.isEmpty()) assertTrue(enabled.listeners.isEmpty()) } @Test - fun `seeds qualities from the enabled track`() { + fun `seeds qualities from the enabled track`() = runTest { val quality = mockVideoQuality() val enabled = MockVideoTrack(enabled = true, activeQuality = quality, qualities = listOf(quality)) val mock = playerWithTracks(enabled) val state = VideoQualityState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(listOf(quality), state.videoQualities) assertEquals(quality, state.activeVideoQuality) } @Test - fun `quality events update active and target`() { + fun `quality events update active and target`() = runTest { val old = mockVideoQuality() val new = mockVideoQuality() val enabled = MockVideoTrack(enabled = true, activeQuality = old, qualities = listOf(old, new)) val mock = playerWithTracks(enabled) val state = VideoQualityState(mock.player) - state.observe() + launchObserve { state.observe() } enabled.activeQuality = new enabled.targetQuality = new @@ -55,12 +57,12 @@ class VideoQualityStateTest { } @Test - fun `select sets the track target quality`() { + fun `select sets the track target quality`() = runTest { val quality = mockVideoQuality() val enabled = MockVideoTrack(enabled = true, qualities = listOf(quality)) val mock = playerWithTracks(enabled) val state = VideoQualityState(mock.player) - state.observe() + launchObserve { state.observe() } state.select(quality) assertEquals(quality, enabled.targetQuality) @@ -71,29 +73,30 @@ class VideoQualityStateTest { } @Test - fun `switching the enabled track moves the quality listeners`() { + fun `switching the enabled track moves the quality listeners`() = runTest { val first = MockVideoTrack(enabled = true) val second = MockVideoTrack(enabled = false) val mock = playerWithTracks(first, second) val state = VideoQualityState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(2, first.listeners.size) first.enabled = false second.enabled = true mock.videoTrackListeners.fireTrackListEvent() + testScheduler.runCurrent() assertEquals(0, first.listeners.size) assertEquals(2, second.listeners.size) } @Test - fun `null player gives defaults and no listeners`() { + fun `null player gives defaults and no listeners`() = runTest { val state = VideoQualityState(null) - state.observe() + val job = launchObserve { state.observe() } assertTrue(state.videoQualities.isEmpty()) assertEquals(null, state.activeVideoQuality) assertEquals(null, state.targetVideoQuality) - state.dispose() + job.cancelAndJoin() } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt index 98f334c4..e047647e 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/VideoSizeStateTest.kt @@ -2,13 +2,14 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.player.PlayerEventTypes import org.junit.Assert.assertEquals +import kotlinx.coroutines.test.runTest import org.junit.Test class VideoSizeStateTest { @Test - fun `registers resize sourcechange and error`() { + fun `registers resize sourcechange and error`() = runTest { val mock = MockPlayer() - VideoSizeState(mock.player).observe() + launchObserve { VideoSizeState(mock.player).observe() } assertEquals( setOf(PlayerEventTypes.RESIZE, PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR), mock.playerEventTypes @@ -16,10 +17,10 @@ class VideoSizeStateTest { } @Test - fun `seeds size and updates on resize`() { + fun `seeds size and updates on resize`() = runTest { val mock = MockPlayer(videoWidth = 1920, videoHeight = 1080) val state = VideoSizeState(mock.player) - state.observe() + launchObserve { state.observe() } assertEquals(1920, state.videoWidth) assertEquals(1080, state.videoHeight) From 1645f75d0448d9cf2a4b6c9c9a2e458c4beaed5e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:34:41 +0000 Subject: [PATCH 056/158] Read initial state in state holder constructors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/main/java/com/theoplayer/android/ui/AdState.kt | 4 ++++ .../java/com/theoplayer/android/ui/AudioTrackState.kt | 4 ++++ .../main/java/com/theoplayer/android/ui/FirstPlayState.kt | 4 ++++ .../java/com/theoplayer/android/ui/FullscreenState.kt | 4 ++++ .../java/com/theoplayer/android/ui/LiveButtonState.kt | 4 ++++ .../main/java/com/theoplayer/android/ui/LoadingState.kt | 4 ++++ .../java/com/theoplayer/android/ui/MuteButtonState.kt | 4 ++++ .../java/com/theoplayer/android/ui/PlayButtonState.kt | 4 ++++ .../java/com/theoplayer/android/ui/PlaybackRateState.kt | 4 ++++ .../java/com/theoplayer/android/ui/PlaybackTimeState.kt | 4 ++++ .../main/java/com/theoplayer/android/ui/SeekBarState.kt | 4 ++++ .../java/com/theoplayer/android/ui/SeekButtonState.kt | 4 ++++ .../java/com/theoplayer/android/ui/SubtitleTrackState.kt | 4 ++++ .../java/com/theoplayer/android/ui/VideoQualityState.kt | 4 ++++ .../main/java/com/theoplayer/android/ui/VideoSizeState.kt | 4 ++++ .../java/com/theoplayer/android/ui/PlayButtonStateTest.kt | 7 +++++++ .../com/theoplayer/android/ui/PlaybackTimeStateTest.kt | 8 ++++++++ 17 files changed, 75 insertions(+) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt index adec7d5c..96795f5d 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt @@ -30,6 +30,10 @@ class AdState internal constructor(private val player: Player?) { playingAd = player?.ads?.isPlaying ?: false } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt index 7a669d7b..f7498a43 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt @@ -38,6 +38,10 @@ class AudioTrackState internal constructor(private val player: Player?) { activeAudioTrack = audioTracks.find { it.isEnabled } } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt index e77a1ef2..713d0dc8 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt @@ -33,6 +33,10 @@ class FirstPlayState internal constructor(private val player: Player?) { firstPlay = player?.let { !it.isPaused || (it.played?.length() ?: 0) > 0 } == true } + init { + updateFirstPlay() + } + internal suspend fun observe() { val player = player ?: return updateFirstPlay() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt index 6c91738a..9b88c6f4 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt @@ -29,6 +29,10 @@ class FullscreenState internal constructor(private val theoplayerView: THEOplaye fullscreen = handler?.fullscreen ?: false } + init { + update() + } + internal suspend fun observe() { val handler = handler ?: return update() 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 e2deb530..9c2ee160 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 @@ -50,6 +50,10 @@ class LiveButtonState internal constructor( updatePlaybackState() } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt index 6c35063a..eb5259d8 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt @@ -36,6 +36,10 @@ class LoadingState internal constructor(private val player: Player?) { readyState = player?.readyState ?: ReadyState.HAVE_NOTHING } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt index 36d9896b..01374ed6 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt @@ -26,6 +26,10 @@ class MuteButtonState internal constructor(private val player: Player?) { muted = player?.isMuted ?: false } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt index 1b0c9d7f..42c80beb 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -33,6 +33,10 @@ class PlayButtonState internal constructor(private val player: Player?) { ended = player?.isEnded ?: false } + init { + updatePlaybackState() + } + internal suspend fun observe() { val player = player ?: return updatePlaybackState() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt index 2972c1ba..ecc81b55 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt @@ -26,6 +26,10 @@ class PlaybackRateState internal constructor(private val player: Player?) { playbackRate = player?.playbackRate ?: 1.0 } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() 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 65af43fc..a30abee6 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 @@ -46,6 +46,10 @@ class PlaybackTimeState internal constructor(private val player: Player?) { duration = seekable.lastEnd ?: rawDuration } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() 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 9be6a5cc..45e76b87 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 @@ -69,6 +69,10 @@ class SeekBarState internal constructor(private val player: Player?) { playingAd = player?.ads?.isPlaying ?: false } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt index beba3402..d8fabf24 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -39,6 +39,10 @@ class SeekButtonState internal constructor( playingAd = player?.ads?.isPlaying ?: false } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt index 8ab1a206..45caa30c 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt @@ -39,6 +39,10 @@ class SubtitleTrackState internal constructor(private val player: Player?) { activeSubtitleTrack = subtitleTracks.find { it.mode == TextTrackMode.SHOWING } } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt index cb0d389c..66188562 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt @@ -61,6 +61,10 @@ class VideoQualityState internal constructor(private val player: Player?) { setActiveVideoTrack(findActiveVideoTrack()) } + init { + setActiveVideoTrack(findActiveVideoTrack()) + } + internal suspend fun observe() { val player = player ?: return val activeTrack = MutableStateFlow(findActiveVideoTrack()) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt index 8bde7a53..e21b1a29 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt @@ -34,6 +34,10 @@ class VideoSizeState internal constructor(private val player: Player?) { videoHeight = player?.videoHeight ?: 0 } + init { + update() + } + internal suspend fun observe() { val player = player ?: return update() diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt index 54a2c9c9..d8b48730 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt @@ -13,6 +13,13 @@ import kotlinx.coroutines.test.runTest import org.junit.Test class PlayButtonStateTest { + @Test + fun `exposes the player state before observe`() { + assertTrue(PlayButtonState(MockPlayer(paused = true).player).paused) + assertFalse(PlayButtonState(MockPlayer(paused = false, ended = false).player).paused) + assertTrue(PlayButtonState(MockPlayer(paused = false, ended = true).player).ended) + } + @Test fun `registers the playback events`() = runTest { val mock = MockPlayer() diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt index 2cd6b138..2f4253d1 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlaybackTimeStateTest.kt @@ -6,6 +6,14 @@ import kotlinx.coroutines.test.runTest import org.junit.Test class PlaybackTimeStateTest { + @Test + fun `exposes the player state before observe`() { + val state = PlaybackTimeState(MockPlayer(currentTime = 5.0, duration = 100.0).player) + assertEquals(5.0, state.currentTime, 0.0) + assertEquals(100.0, state.duration, 0.0) + assertEquals(StreamType.Vod, state.streamType) + } + @Test fun `registers the playback events`() = runTest { val mock = MockPlayer() From 13d06a5441cb3363ef82a052ef31028b112ff48d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:36:28 +0000 Subject: [PATCH 057/158] Always refresh video qualities on observe Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/VideoQualityState.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt index 66188562..33b2dca3 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt @@ -48,9 +48,6 @@ class VideoQualityState internal constructor(private val player: Player?) { private fun findActiveVideoTrack() = player?.videoTracks?.find { it.isEnabled } private fun setActiveVideoTrack(videoTrack: MediaTrack?) { - if (activeVideoTrack == videoTrack) { - return - } activeVideoTrack = videoTrack videoQualities = videoTrack?.qualities?.toList() ?: listOf() activeVideoQuality = videoTrack?.activeQuality @@ -61,10 +58,6 @@ class VideoQualityState internal constructor(private val player: Player?) { setActiveVideoTrack(findActiveVideoTrack()) } - init { - setActiveVideoTrack(findActiveVideoTrack()) - } - internal suspend fun observe() { val player = player ?: return val activeTrack = MutableStateFlow(findActiveVideoTrack()) From a5c0d506a4f205ca55cf3a26c984157b49260b2a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 12:36:28 +0000 Subject: [PATCH 058/158] Clean up state holder tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/CastStateTest.kt | 11 ++-- .../android/ui/EventDispatcherUtilTest.kt | 4 +- .../android/ui/FullscreenStateTest.kt | 2 +- .../com/theoplayer/android/ui/MockPlayer.kt | 57 +++++++++++-------- .../android/ui/PictureInPictureStateTest.kt | 4 +- .../android/ui/PlayButtonStateTest.kt | 2 +- .../theoplayer/android/ui/SeekBarStateTest.kt | 2 +- .../android/ui/VideoQualityStateTest.kt | 16 ++++++ 8 files changed, 63 insertions(+), 35 deletions(-) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt index 481c5be8..4523b79e 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/CastStateTest.kt @@ -1,11 +1,14 @@ package com.theoplayer.android.ui +import androidx.compose.runtime.snapshots.Snapshot import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.cast.Cast import com.theoplayer.android.api.cast.chromecast.Chromecast import com.theoplayer.android.api.cast.chromecast.PlayerCastState import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.chromecast.CastErrorEvent +import com.theoplayer.android.api.event.chromecast.CastStateChangeEvent import com.theoplayer.android.api.event.chromecast.ChromecastEvent import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes import io.mockk.every @@ -68,7 +71,7 @@ class CastStateTest { } @Test - fun `observe registers both events with one listener and dispose removes them`() = runTest { + fun `observe registers both events with one listener and cancelling removes them`() = runTest { val (cast, listeners, _) = mockCast() val view = mockView(cast) val state = CastState(view) @@ -100,8 +103,8 @@ class CastStateTest { @Suppress("UNCHECKED_CAST") (listener as EventListener>).handleEvent( when (type) { - ChromecastEventTypes.STATECHANGE -> mockk() - else -> mockk() + ChromecastEventTypes.STATECHANGE -> mockk() + else -> mockk() } ) } @@ -135,7 +138,7 @@ class CastStateTest { currentCast = newCast state.castState - androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + Snapshot.sendApplyNotifications() testScheduler.runCurrent() assertEquals(PlayerCastState.CONNECTED, state.castState) assertTrue(oldListeners.isEmpty()) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt index a476fc27..ebfc1314 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/EventDispatcherUtilTest.kt @@ -1,8 +1,10 @@ package com.theoplayer.android.ui import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.PlayEvent import com.theoplayer.android.api.event.player.PlayerEvent import com.theoplayer.android.api.event.player.PlayerEventTypes +import io.mockk.mockk import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -60,7 +62,7 @@ class EventDispatcherUtilTest { assertEquals(1, calls) job.cancelAndJoin() - captured.handleEvent(io.mockk.mockk()) + captured.handleEvent(mockk()) assertEquals(1, calls) } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt index 5f62f507..2b230a9c 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/FullscreenStateTest.kt @@ -74,7 +74,7 @@ class FullscreenStateTest { } @Test - fun `dispose removes the listener`() = runTest { + fun `cancelling removes the listener`() = runTest { val handler = FakeFullscreenHandler() val state = FullscreenState(mockViewWithHandler(handler)) val job = launchObserve { state.observe() } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt index 68d5a9d9..ae30611f 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/MockPlayer.kt @@ -28,12 +28,28 @@ import com.theoplayer.android.api.event.player.SourceChangeEvent import com.theoplayer.android.api.event.player.TimeUpdateEvent import com.theoplayer.android.api.event.player.VolumeChangeEvent import com.theoplayer.android.api.event.track.TrackEvent +import com.theoplayer.android.api.event.track.mediatrack.audio.list.AddTrackEvent as AudioAddTrackEvent +import com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes +import com.theoplayer.android.api.event.track.mediatrack.audio.list.RemoveTrackEvent as AudioRemoveTrackEvent +import com.theoplayer.android.api.event.track.mediatrack.audio.list.TrackListChangeEvent as AudioTrackListChangeEvent +import com.theoplayer.android.api.event.track.mediatrack.video.ActiveQualityChangedEvent +import com.theoplayer.android.api.event.track.mediatrack.video.TargetQualityChangedEvent +import com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes +import com.theoplayer.android.api.event.track.mediatrack.video.list.AddTrackEvent as VideoAddTrackEvent +import com.theoplayer.android.api.event.track.mediatrack.video.list.RemoveTrackEvent as VideoRemoveTrackEvent +import com.theoplayer.android.api.event.track.mediatrack.video.list.TrackListChangeEvent as VideoTrackListChangeEvent +import com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes +import com.theoplayer.android.api.event.track.texttrack.list.AddTrackEvent as TextAddTrackEvent +import com.theoplayer.android.api.event.track.texttrack.list.RemoveTrackEvent as TextRemoveTrackEvent +import com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes +import com.theoplayer.android.api.event.track.texttrack.list.TrackListChangeEvent as TextTrackListChangeEvent import com.theoplayer.android.api.event.track.tracklist.TrackListEvent import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.player.ReadyState import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality +import com.theoplayer.android.api.player.track.mediatrack.quality.QualityList import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackKind @@ -267,9 +283,8 @@ internal class MockVideoTrack( var enabled: Boolean = false, var activeQuality: VideoQuality? = null, var targetQuality: VideoQuality? = null, - val qualities: List = listOf() + var qualities: List = listOf() ) { - // Fires at all listeners with an event matching their registered event type. val uid = nextTrackUid++ val listeners = mutableListOf, EventListener<*>>>() @@ -281,7 +296,7 @@ internal class MockVideoTrack( every { getTargetQuality() } answers { this@MockVideoTrack.targetQuality } every { setTargetQuality(any()) } answers { this@MockVideoTrack.targetQuality = firstArg() } every { getQualities() } answers { - mockk> { + mockk> { every { iterator() } answers { this@MockVideoTrack.qualities.toMutableList().listIterator() } } } @@ -295,15 +310,16 @@ internal class MockVideoTrack( } } + // Fires at all listeners with an event matching their registered event type. fun fireEvent() { listeners.toList().forEach { (type, listener) -> @Suppress("UNCHECKED_CAST") (listener as EventListener>).handleEvent( when (type) { - com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT -> - mockk() + VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT -> + mockk() + VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT -> + mockk() else -> mockk() } ) @@ -316,24 +332,15 @@ internal fun MutableList, EventListener<*>>>.fireTrackListEven @Suppress("UNCHECKED_CAST") (listener as EventListener>).handleEvent( when (type) { - com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.ADDTRACK -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.REMOVETRACK -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackListEventTypes.TRACKLISTCHANGE -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.ADDTRACK -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.REMOVETRACK -> - mockk() - com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes.TRACKLISTCHANGE -> - mockk() - com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.ADDTRACK -> - mockk() - com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.REMOVETRACK -> - mockk() - com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes.TRACKLISTCHANGE -> - mockk() + AudioTrackListEventTypes.ADDTRACK -> mockk() + AudioTrackListEventTypes.REMOVETRACK -> mockk() + AudioTrackListEventTypes.TRACKLISTCHANGE -> mockk() + VideoTrackListEventTypes.ADDTRACK -> mockk() + VideoTrackListEventTypes.REMOVETRACK -> mockk() + VideoTrackListEventTypes.TRACKLISTCHANGE -> mockk() + TextTrackListEventTypes.ADDTRACK -> mockk() + TextTrackListEventTypes.REMOVETRACK -> mockk() + TextTrackListEventTypes.TRACKLISTCHANGE -> mockk() else -> mockk() } ) diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt index 749d6de0..488f4fcc 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PictureInPictureStateTest.kt @@ -39,7 +39,7 @@ class PictureInPictureStateTest { } @Test - fun `observe registers presentationmodechange and dispose removes it`() = runTest { + fun `observe registers presentationmodechange and cancelling removes it`() = runTest { val mock = MockPlayer() val state = PictureInPictureState(mockView(player = mock)) val job = launchObserve { state.observe() } @@ -104,7 +104,7 @@ class PictureInPictureStateTest { } @Test - fun `dispose cancels a pending enterPiP`() = runTest { + fun `cancelling cancels a pending enterPiP`() = runTest { val piPManager = mockk(relaxed = true) val handler = FakeFullscreenHandler().also { it.fire(true) } val state = PictureInPictureState( diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt index d8b48730..71a150b5 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/PlayButtonStateTest.kt @@ -73,7 +73,7 @@ class PlayButtonStateTest { } @Test - fun `dispose removes all listeners`() = runTest { + fun `cancelling removes all listeners`() = runTest { val mock = MockPlayer() val state = PlayButtonState(mock.player) val job = launchObserve { state.observe() } 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 57057a39..3059b206 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 @@ -103,7 +103,7 @@ class SeekBarStateTest { } @Test - fun `dispose removes all listeners`() = runTest { + fun `cancelling removes all listeners`() = runTest { val mock = MockPlayer() val state = SeekBarState(mock.player) val job = launchObserve { state.observe() } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt index 82bc9d7d..d975d247 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/VideoQualityStateTest.kt @@ -40,6 +40,22 @@ class VideoQualityStateTest { assertEquals(quality, state.activeVideoQuality) } + @Test + fun `observe refreshes qualities changed since construction`() = runTest { + val first = mockVideoQuality() + val second = mockVideoQuality() + val enabled = MockVideoTrack(enabled = true, activeQuality = first, qualities = listOf(first)) + val mock = playerWithTracks(enabled) + val state = VideoQualityState(mock.player) + + enabled.activeQuality = second + enabled.qualities = listOf(second) + launchObserve { state.observe() } + + assertEquals(listOf(second), state.videoQualities) + assertEquals(second, state.activeVideoQuality) + } + @Test fun `quality events update active and target`() = runTest { val old = mockVideoQuality() From 349149689004dbbb34c96f562af3fe1e343de95e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 13:05:50 +0000 Subject: [PATCH 059/158] Document why listen doesn't use callbackFlow Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/EventDispatcherUtil.kt | 30 ++++++++++++++++--- .../android/ui/FullscreenHandler.kt | 12 ++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt index c9f5ad5f..28010175 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt @@ -9,10 +9,32 @@ import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine /** - * Listens to events of the given [eventTypes] until the calling coroutine is cancelled, - * passing each event to [onEvent]. One listener instance is shared between all event types. - * If [onEvent] throws, the listener is removed and the exception is rethrown. - * Must be called on the main thread. + * Continuously listens to events of the given [eventTypes], passing each received event + * to [onEvent]. One listener instance is shared between all event types. + * + * This function must be called on the main thread, which is where the player dispatches its + * events. [onEvent] is invoked synchronously from within the event listener. + * + * If [onEvent] throws an exception, listening is terminated: the listener is removed, + * and the exception is rethrown from this function. + * + * A note on [callbackFlow][kotlinx.coroutines.flow.callbackFlow] vs [suspendCancellableCoroutine] + * (adapted from AndroidX Media3's `Player.listen`): + * + * Although a callback flow is recommended for a multi-shot API like an [EventDispatcher], + * a [Flow][kotlinx.coroutines.flow.Flow] buffers its values and delivers them to its collector + * later on. Here, [suspendCancellableCoroutine] is not used to wait for a single callback to + * resume the continuation (its common single-shot use). Instead, it keeps the coroutine suspended + * while [onEvent] handles incoming events directly, as they are dispatched. This keeps tighter + * control over the timing of events, so the UI state is updated in the same frame + * as the event that caused the change. + * + * The listener is removed in the `finally` block, which runs on the calling (main) thread. + * Until then, the listener checks whether the continuation is still active, so that events + * dispatched after cancellation are ignored. + * + * @return Nothing. This function never returns normally: it either keeps listening indefinitely, + * or terminates due to an exception or cancellation. */ internal suspend fun > EventDispatcher.listen( vararg eventTypes: EventType, diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt index 8773bafd..967ef8ce 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -32,8 +32,16 @@ internal interface FullscreenHandler { } /** - * Calls [onChange] on every fullscreen change until the calling coroutine is cancelled. - * If [onChange] throws, the listener is removed and the exception is rethrown. + * Continuously listens to fullscreen changes, passing the new fullscreen state to [onChange]. + * + * If [onChange] throws an exception, listening is terminated: the listener is removed, + * and the exception is rethrown from this function. + * + * See [EventDispatcher.listen][com.theoplayer.android.ui.listen] for why this uses + * [suspendCancellableCoroutine] rather than a [callbackFlow][kotlinx.coroutines.flow.callbackFlow]. + * + * @return Nothing. This function never returns normally: it either keeps listening indefinitely, + * or terminates due to an exception or cancellation. */ internal suspend fun FullscreenHandler.listen(onChange: (Boolean) -> Unit): Nothing { var continuation: CancellableContinuation? = null From 52d591f46e2924aac664e9ae03666083399cd75d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 10:16:54 +0000 Subject: [PATCH 060/158] Scroll to and announce the selected item in menu lists Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/AudioTrackList.kt | 23 +++- .../theoplayer/android/ui/PlaybackRateList.kt | 23 +++- .../com/theoplayer/android/ui/QualityList.kt | 38 ++++-- .../android/ui/SubtitleTrackList.kt | 38 ++++-- .../com/theoplayer/android/ui/MenuListTest.kt | 118 ++++++++++++++++++ 5 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 ui/src/test/java/com/theoplayer/android/ui/MenuListTest.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt index 3de7a336..cbcdfcae 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt @@ -1,12 +1,15 @@ package com.theoplayer.android.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role /** * A list of audio tracks, from which the user can choose an active audio track. @@ -40,7 +43,11 @@ fun AudioTrackList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - LazyColumn(modifier = modifier) { + val selectedIndex = state.audioTracks.indexOf(state.activeAudioTrack).coerceAtLeast(0) + LazyColumn( + modifier = modifier.selectableGroup(), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { items( count = state.audioTracks.size, key = { state.audioTracks[it].uid } @@ -54,10 +61,14 @@ fun AudioTrackList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(audioTrack) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.activeAudioTrack == audioTrack, + onClick = { + state.select(audioTrack) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index d33c748e..c2369ff1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -1,7 +1,9 @@ package com.theoplayer.android.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -9,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import java.text.DecimalFormat /** @@ -53,7 +56,11 @@ fun PlaybackRateList( playbackRates: List = defaultPlaybackRates, onClick: (() -> Unit)? = null ) { - LazyColumn(modifier = modifier) { + val selectedIndex = playbackRates.indexOf(state.playbackRate).coerceAtLeast(0) + LazyColumn( + modifier = modifier.selectableGroup(), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { items( count = playbackRates.size, key = { playbackRates[it] } @@ -67,10 +74,14 @@ fun PlaybackRateList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(playbackRate) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.playbackRate == playbackRate, + onClick = { + state.select(playbackRate) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 2256b919..2c390cb6 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -1,7 +1,9 @@ package com.theoplayer.android.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text @@ -9,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import java.text.DecimalFormat /** @@ -43,7 +46,14 @@ fun QualityList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - LazyColumn(modifier = modifier) { + val selectedIndex = state.targetVideoQuality + ?.let { state.videoQualities.indexOf(it) + 1 } + ?.coerceAtLeast(0) + ?: 0 + LazyColumn( + modifier = modifier.selectableGroup(), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { item(key = null) { ListItem( headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_quality_automatic)) }, @@ -53,10 +63,14 @@ fun QualityList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(null) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.targetVideoQuality == null, + onClick = { + state.select(null) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } items( @@ -80,10 +94,14 @@ fun QualityList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(quality) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.targetVideoQuality == quality, + onClick = { + state.select(quality) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index 36fffd96..a9d49d07 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -1,13 +1,16 @@ package com.theoplayer.android.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role /** * A list of subtitle tracks, from which the user can choose an active subtitle track. @@ -41,7 +44,14 @@ fun SubtitleTrackList( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - LazyColumn(modifier = modifier) { + val selectedIndex = state.activeSubtitleTrack + ?.let { state.subtitleTracks.indexOf(it) + 1 } + ?.coerceAtLeast(0) + ?: 0 + LazyColumn( + modifier = modifier.selectableGroup(), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { item(key = null) { ListItem( headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_subtitles_off)) }, @@ -51,10 +61,14 @@ fun SubtitleTrackList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(null) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.activeSubtitleTrack == null, + onClick = { + state.select(null) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } items( @@ -70,10 +84,14 @@ fun SubtitleTrackList( onClick = null ) }, - modifier = Modifier.clickable(onClick = { - state.select(subtitleTrack) - onClick?.let { it() } - }) + modifier = Modifier.selectable( + selected = state.activeSubtitleTrack == subtitleTrack, + onClick = { + state.select(subtitleTrack) + onClick?.let { it() } + }, + role = Role.RadioButton + ) ) } } diff --git a/ui/src/test/java/com/theoplayer/android/ui/MenuListTest.kt b/ui/src/test/java/com/theoplayer/android/ui/MenuListTest.kt new file mode 100644 index 00000000..79fe7006 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/MenuListTest.kt @@ -0,0 +1,118 @@ +package com.theoplayer.android.ui + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.player.Player as THEOplayer +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackKind +import com.theoplayer.android.api.player.track.texttrack.TextTrackList +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalPlayerStateApi::class) +class MenuListTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun playbackRateListScrollsToSelectedRate() { + val player = playbackRatePlayer(rate = 2.0) + composeRule.setContent { + PlaybackRateList( + state = rememberPlaybackRateState(player), + modifier = Modifier.height(120.dp) + ) + } + + assertTrue(composeRule.onAllNodes(hasText("2x")).fetchSemanticsNodes().isNotEmpty()) + assertTrue(composeRule.onAllNodes(hasText("0.25x")).fetchSemanticsNodes().isEmpty()) + } + + @Test + fun playbackRateListExposesSelectionSemantics() { + val player = playbackRatePlayer(rate = 1.5) + composeRule.setContent { + PlaybackRateList(state = rememberPlaybackRateState(player)) + } + + composeRule.onNode(hasText("1.5x") and isSelectable()).assertIsSelected() + composeRule.onNode(hasText("1.25x") and isSelectable()).assertIsNotSelected() + } + + @Test + fun subtitleTrackListSelectsActiveTrackInsteadOfOff() { + val track = mockSubtitleTrack() + val player = subtitlePlayer(track) + var state: SubtitleTrackState? = null + composeRule.setContent { + val subtitleState = rememberSubtitleTrackState(player) + state = subtitleState + SubtitleTrackList(state = subtitleState) + } + + assertEquals(listOf(track), state?.subtitleTracks) + assertEquals(track, state?.activeSubtitleTrack) + composeRule.onNode(hasText("English") and isSelectable()).assertIsSelected() + composeRule.onNode(hasText("Off") and isSelectable()).assertIsNotSelected() + } + + @Test + fun clickingPlaybackRateSelectsItAndCallsOnClick() { + val player = playbackRatePlayer(rate = 1.0) + var selectedState: PlaybackRateState? = null + var clickCount = 0 + composeRule.setContent { + val state = rememberPlaybackRateState(player) + selectedState = state + PlaybackRateList(state = state, onClick = { clickCount++ }) + } + + composeRule.onNodeWithText("1.5x").performClick() + + assertEquals(1.5, selectedState?.playbackRate ?: 0.0, 0.0) + assertEquals(1, clickCount) + verify(exactly = 1) { player.playbackRate = 1.5 } + } + + private fun playbackRatePlayer(rate: Double): THEOplayer { + val player = mockk(relaxed = true) + every { player.playbackRate } returns rate + return player + } + + private fun mockSubtitleTrack(): TextTrack = mockk(relaxed = true) { + every { kind } returns TextTrackKind.SUBTITLES.type + every { mode } returns TextTrackMode.SHOWING + every { uid } returns 1 + every { label } returns "English" + } + + private fun subtitlePlayer(track: TextTrack): THEOplayer { + val textTracks = mockk(relaxed = true) { + every { iterator() } answers { listOf(track).toMutableList().listIterator() } + } + return mockk(relaxed = true) { + every { this@mockk.textTracks } returns textTracks + } + } +} From cad17b51394b94306f3fb271439086fee5e7e278 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 10:27:29 +0000 Subject: [PATCH 061/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d2207cb..4fdf464b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) +* 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) ## [1.14.2] (2026-10-13) From ac14a7d429161a66b9d700afde23b7606215805e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 13:48:02 +0000 Subject: [PATCH 062/158] Move UI hosting and menu infrastructure to android-ui-common Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 1 + ui-common/build.gradle.kts | 2 + .../com/theoplayer/android/ui/MenuScope.kt | 38 +++ .../theoplayer/android/ui/PlayerContainer.kt | 81 ++++++ .../android/ui/THEOplayerViewLifecycle.kt | 124 ++++++++++ .../android/ui/UIControllerScope.kt | 72 ++++++ .../java/com/theoplayer/android/ui/Menu.kt | 35 --- .../com/theoplayer/android/ui/UIController.kt | 233 ------------------ 8 files changed, 318 insertions(+), 268 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/THEOplayerViewLifecycle.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c3563ee..4c6f2d45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ core-pip = "1.0.0-alpha02" androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index befb04ad..0104fa31 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -66,7 +66,9 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ktx) + implementation(libs.androidx.lifecycle.compose) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui.ui) testImplementation(libs.junit4) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt new file mode 100644 index 00000000..b4026823 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt @@ -0,0 +1,38 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable + +/** + * The content for a new menu, typically this should draw a single [Menu]. + */ +typealias MenuContent = @Composable MenuScope.() -> Unit; + +/** + * Scope for the contents of a [Menu]. + */ +interface MenuScope { + /** + * Open a new submenu. + * + * When the submenu is closed, the original menu is shown again. + * + * Only the top-most menu is composed. While the submenu is open, the original menu + * (or the player controls, if no menu was open yet) leaves the composition, and any state + * remembered there is disposed. So instead of passing state from the caller into [menu], + * remember the state inside [menu]: + * ```kotlin + * // Don't: audioTrackState is disposed while the menu is open + * val audioTrackState = rememberAudioTrackState(player) + * openMenu { AudioTrackMenu(audioTrackState) } + * + * // Do + * openMenu { AudioTrackMenu(rememberAudioTrackState(player)) } + * ``` + */ + fun openMenu(menu: MenuContent) + + /** + * Close the currently open menu. + */ + fun closeCurrentMenu() +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt new file mode 100644 index 00000000..2da1f0ad --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt @@ -0,0 +1,81 @@ +package com.theoplayer.android.ui + +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.theoplayer.android.api.THEOplayerView + +/** + * Hosts [theoplayerView] and draws [ui] inside its UI container. + */ +@InternalUiApi +@Composable +fun PlayerContainer( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView?, + ui: @Composable () -> Unit +) { + if (theoplayerView == null) { + Box( + modifier = modifier + ) { + ui() + } + } else { + val lifecycle = LocalLifecycleOwner.current.lifecycle + var uiContainer by remember { mutableStateOf(null) } + var composeView by remember { mutableStateOf(null) } + + AndroidView( + modifier = modifier, + factory = { context -> + uiContainer = + theoplayerView.findViewById(com.theoplayer.android.R.id.theo_ui_container) + // Wrap our UI inside a ComposeView + composeView = ComposeView(context).apply { + // When entering fullscreen, we remove the view from its original location + // and add it to the activity's root view. + // When it is temporarily removed (and detached from the window), + // we do *not* want to lose the composition. Therefore, don't use the default + // DisposeOnDetachedFromWindow or DisposeOnDetachedFromWindowOrReleasedFromPool + // strategies! + setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle) + ) + setContent { + ui() + } + } + // Host the THEOplayer view inside our AndroidView + (theoplayerView.parent as? ViewGroup)?.removeView(theoplayerView) + theoplayerView + }) + + // Install inside THEOplayerView's UI container + DisposableEffect(uiContainer, composeView) { + val container = uiContainer + val view = composeView + if (container != null && view != null) { + container.addView(view) + theoplayerView.postInvalidate() + } + onDispose { + if (container != null && view != null) { + container.removeView(view) + view.disposeComposition() + theoplayerView.postInvalidate() + } + } + } + } +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/THEOplayerViewLifecycle.kt b/ui-common/src/main/java/com/theoplayer/android/ui/THEOplayerViewLifecycle.kt new file mode 100644 index 00000000..1102afb7 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/THEOplayerViewLifecycle.kt @@ -0,0 +1,124 @@ +package com.theoplayer.android.ui + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView + +/** + * Creates and remembers a [THEOplayerView] owned by the composition. + * + * This couples the lifecycle of the view to the current activity: it automatically calls + * [THEOplayerView.onPause] and [THEOplayerView.onResume] whenever the current activity is + * [paused][Activity.onPause] or [resumed][Activity.onResume] (resuming an ad that was + * interrupted), and it destroys the view when it leaves the composition. + * + * In previews, the SDK does not initialize the view ([View.isInEditMode]), so do not + * access its [THEOplayerView.player] there. [UIController] and [DefaultUI] handle this + * automatically. + * + * @param config the player configuration + */ +@Composable +fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { + val context = LocalContext.current + val inspectionMode = LocalInspectionMode.current + val theoplayerView = remember { + THEOplayerView(if (inspectionMode) PreviewContext(context) else context, config) + } + if (!theoplayerView.isInEditMode) { + setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) + DisposableEffect(theoplayerView) { + onDispose { + theoplayerView.onDestroy() + } + } + } + return theoplayerView +} + +/** + * A context for creating a [THEOplayerView] in a preview. + * + * The [THEOplayerView] constructor casts the application context to [Application], + * but layoutlib's application context is not an [Application]. + */ +private class PreviewContext(base: Context) : ContextWrapper(base) { + private val application = PreviewApplication(base.applicationContext) + + override fun getApplicationContext(): Context = application +} + +private class PreviewApplication(base: Context) : Application() { + init { + attachBaseContext(base) + } +} + +/** + * Remembers a THEOplayer view for use by a UI module. + */ +@InternalUiApi +@Composable +fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { + return if (LocalInspectionMode.current) null else rememberTHEOplayerView(config) +} + +/** + * Couples a THEOplayer view to the current lifecycle owner. + */ +@InternalUiApi +@Composable +fun setupTHEOplayerView( + theoplayerView: THEOplayerView, + destroyOnLifecycleDestroy: Boolean = true +): THEOplayerView { + var wasPlayingAd by remember { mutableStateOf(false) } + + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, theoplayerView) { + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + theoplayerView.onResume() + if (wasPlayingAd) { + theoplayerView.player.play() + wasPlayingAd = false + } + } + + Lifecycle.Event.ON_PAUSE -> { + wasPlayingAd = theoplayerView.player.ads.isPlaying + theoplayerView.onPause() + } + + Lifecycle.Event.ON_DESTROY -> { + if (destroyOnLifecycleDestroy) { + theoplayerView.onDestroy() + } + } + else -> {} + } + } + lifecycle.addObserver(lifecycleObserver) + onDispose { + lifecycle.removeObserver(lifecycleObserver) + } + } + + return theoplayerView +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt new file mode 100644 index 00000000..5ac5b51b --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt @@ -0,0 +1,72 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.mutableStateListOf +import com.theoplayer.android.api.THEOplayerView + +/** + * Scope for the contents of a [UIController]. + */ +interface UIControllerScope : MenuScope { + /** + * The player hosted in this [UIController]. + * + * If the UIController was created from a [THEOplayerView], the [Player] is created on + * first access (and only then adds its listeners). Prefer [theoplayerView] with the + * experimental state holders instead. + */ + val player: Player + + /** + * The THEOplayer view hosted in this [UIController], or `null` in inspection mode. + */ + val theoplayerView: THEOplayerView? + get() = player.theoplayerView +} + +/** + * Default implementation of [UIControllerScope], managing the menu stack. + */ +@InternalUiApi +class UIControllerScopeImpl( + override val theoplayerView: THEOplayerView?, + private val providedPlayer: Player? +) : UIControllerScope { + private var menuStack = mutableStateListOf() + + val currentMenu: MenuContent? + get() = menuStack.lastOrNull() + var lastWasClosed: Boolean = false + private set + + var activityInPipMode: Boolean = false + set(value) { + field = value + if (lazyPlayer.isInitialized()) { + lazyPlayer.value.activityInPipMode = value + } + } + + private val lazyPlayer = lazy { + PlayerImpl(theoplayerView).also { it.activityInPipMode = activityInPipMode } + } + + override val player: Player + get() = providedPlayer ?: lazyPlayer.value + + fun dispose() { + if (lazyPlayer.isInitialized()) { + lazyPlayer.value.dispose() + } + } + + override fun openMenu(menu: MenuContent) { + menuStack.add(menu) + lastWasClosed = false + } + + override fun closeCurrentMenu() { + menuStack.removeLastOrNull()?.also { + lastWasClosed = true + } + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt index 9981dbd4..fd2e412a 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt @@ -17,11 +17,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -/** - * The content for a new menu, typically this should draw a single [Menu]. - */ -typealias MenuContent = @Composable MenuScope.() -> Unit; - /** * A menu that can be opened on top of the player. * @@ -71,33 +66,3 @@ fun MenuScope.Menu( } } } - -/** - * Scope for the contents of a [Menu]. - */ -interface MenuScope { - /** - * Open a new submenu. - * - * When the submenu is closed, the original menu is shown again. - * - * Only the top-most menu is composed. While the submenu is open, the original menu - * (or the player controls, if no menu was open yet) leaves the composition, and any state - * remembered there is disposed. So instead of passing state from the caller into [menu], - * remember the state inside [menu]: - * ```kotlin - * // Don't: audioTrackState is disposed while the menu is open - * val audioTrackState = rememberAudioTrackState(player) - * openMenu { AudioTrackMenu(audioTrackState) } - * - * // Do - * openMenu { AudioTrackMenu(rememberAudioTrackState(player)) } - * ``` - */ - fun openMenu(menu: MenuContent) - - /** - * Close the currently open menu. - */ - fun closeCurrentMenu() -} \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 49015270..20b647d9 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -1,10 +1,6 @@ package com.theoplayer.android.ui import android.app.Activity -import android.app.Application -import android.content.Context -import android.content.ContextWrapper -import android.view.ViewGroup import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -21,7 +17,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,7 +28,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember @@ -42,14 +36,8 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.cast.chromecast.PlayerCastState @@ -382,70 +370,6 @@ internal fun UIControllerImpl( } } -/** - * Scope for the contents of a [UIController]. - */ -interface UIControllerScope : MenuScope { - /** - * The player hosted in this [UIController]. - * - * If the UIController was created from a [THEOplayerView], the [Player] is created on - * first access (and only then adds its listeners). Prefer [theoplayerView] with the - * experimental state holders instead. - */ - val player: Player - - /** - * The THEOplayer view hosted in this [UIController], or `null` in inspection mode. - */ - val theoplayerView: THEOplayerView? - get() = player.theoplayerView -} - -private class UIControllerScopeImpl( - override val theoplayerView: THEOplayerView?, - private val providedPlayer: Player? -) : UIControllerScope { - private var menuStack = mutableStateListOf() - - val currentMenu: MenuContent? - get() = menuStack.lastOrNull() - var lastWasClosed: Boolean = false - private set - - var activityInPipMode: Boolean = false - set(value) { - field = value - if (lazyPlayer.isInitialized()) { - lazyPlayer.value.activityInPipMode = value - } - } - - private val lazyPlayer = lazy { - PlayerImpl(theoplayerView).also { it.activityInPipMode = activityInPipMode } - } - - override val player: Player - get() = providedPlayer ?: lazyPlayer.value - - fun dispose() { - if (lazyPlayer.isInitialized()) { - lazyPlayer.value.dispose() - } - } - - override fun openMenu(menu: MenuContent) { - menuStack.add(menu) - lastWasClosed = false - } - - override fun closeCurrentMenu() { - menuStack.removeLastOrNull()?.also { - lastWasClosed = true - } - } -} - internal val LocalUIControllerErrorState = staticCompositionLocalOf { null } private sealed class UIState { @@ -455,67 +379,6 @@ private sealed class UIState { object Hidden } -@Composable -private fun PlayerContainer( - modifier: Modifier = Modifier, - theoplayerView: THEOplayerView?, - ui: @Composable () -> Unit -) { - if (theoplayerView == null) { - Box( - modifier = modifier - ) { - ui() - } - } else { - val lifecycle = LocalLifecycleOwner.current.lifecycle - var uiContainer by remember { mutableStateOf(null) } - var composeView by remember { mutableStateOf(null) } - - AndroidView( - modifier = modifier, - factory = { context -> - uiContainer = - theoplayerView.findViewById(com.theoplayer.android.R.id.theo_ui_container) - // Wrap our UI inside a ComposeView - composeView = ComposeView(context).apply { - // When entering fullscreen, we remove the view from its original location - // and add it to the activity's root view. - // When it is temporarily removed (and detached from the window), - // we do *not* want to lose the composition. Therefore, don't use the default - // DisposeOnDetachedFromWindow or DisposeOnDetachedFromWindowOrReleasedFromPool - // strategies! - setViewCompositionStrategy( - ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle) - ) - setContent { - ui() - } - } - // Host the THEOplayer view inside our AndroidView - (theoplayerView.parent as? ViewGroup)?.removeView(theoplayerView) - theoplayerView - }) - - // Install inside THEOplayerView's UI container - DisposableEffect(uiContainer, composeView) { - val container = uiContainer - val view = composeView - if (container != null && view != null) { - container.addView(view) - theoplayerView.postInvalidate() - } - onDispose { - if (container != null && view != null) { - container.removeView(view) - view.disposeComposition() - theoplayerView.postInvalidate() - } - } - } - } -} - @Composable private fun UIControllerScope.PlayerControls( controlsVisible: Boolean, @@ -593,61 +456,6 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { return rememberPlayerInternal(theoplayerView) } -/** - * Creates and remembers a [THEOplayerView] owned by the composition. - * - * This couples the lifecycle of the view to the current activity: it automatically calls - * [THEOplayerView.onPause] and [THEOplayerView.onResume] whenever the current activity is - * [paused][Activity.onPause] or [resumed][Activity.onResume] (resuming an ad that was - * interrupted), and it destroys the view when it leaves the composition. - * - * In previews, the SDK does not initialize the view ([View.isInEditMode]), so do not - * access its [THEOplayerView.player] there. [UIController] and [DefaultUI] handle this - * automatically. - * - * @param config the player configuration - */ -@Composable -fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { - val context = LocalContext.current - val inspectionMode = LocalInspectionMode.current - val theoplayerView = remember { - THEOplayerView(if (inspectionMode) PreviewContext(context) else context, config) - } - if (!theoplayerView.isInEditMode) { - setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false) - DisposableEffect(theoplayerView) { - onDispose { - theoplayerView.onDestroy() - } - } - } - return theoplayerView -} - -/** - * A context for creating a [THEOplayerView] in a preview. - * - * The [THEOplayerView] constructor casts the application context to [Application], - * but layoutlib's application context is not an [Application]. - */ -private class PreviewContext(base: Context) : ContextWrapper(base) { - private val application = PreviewApplication(base.applicationContext) - - override fun getApplicationContext(): Context = application -} - -private class PreviewApplication(base: Context) : Application() { - init { - attachBaseContext(base) - } -} - -@Composable -internal fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? { - return if (LocalInspectionMode.current) null else rememberTHEOplayerView(config) -} - /** * Create a [Player] wrapping an existing [THEOplayerView]. * @@ -702,47 +510,6 @@ internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { return player } -@Composable -internal fun setupTHEOplayerView( - theoplayerView: THEOplayerView, - destroyOnLifecycleDestroy: Boolean = true -): THEOplayerView { - var wasPlayingAd by remember { mutableStateOf(false) } - - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle, theoplayerView) { - val lifecycleObserver = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - theoplayerView.onResume() - if (wasPlayingAd) { - theoplayerView.player.play() - wasPlayingAd = false - } - } - - Lifecycle.Event.ON_PAUSE -> { - wasPlayingAd = theoplayerView.player.ads.isPlaying - theoplayerView.onPause() - } - - Lifecycle.Event.ON_DESTROY -> { - if (destroyOnLifecycleDestroy) { - theoplayerView.onDestroy() - } - } - else -> {} - } - } - lifecycle.addObserver(lifecycleObserver) - onDispose { - lifecycle.removeObserver(lifecycleObserver) - } - } - - return theoplayerView -} - internal fun Modifier.playerAspectRatio(videoSizeState: VideoSizeState): Modifier { return this.constrainedAspectRatio( if (videoSizeState.videoWidth != 0 && videoSizeState.videoHeight != 0) { From 81ee1e9c54d7e6594942c5ab8fb44e044f5060c6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 13:57:29 +0000 Subject: [PATCH 063/158] Remove trailing semicolon Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt index b4026823..2dc3e798 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable /** * The content for a new menu, typically this should draw a single [Menu]. */ -typealias MenuContent = @Composable MenuScope.() -> Unit; +typealias MenuContent = @Composable MenuScope.() -> Unit /** * Scope for the contents of a [Menu]. From b5656d327a18f42af22cb32d2e0c00be5ecf9062 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:09:43 +0000 Subject: [PATCH 064/158] Show controls on key press and handle media keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/PlayerKeys.kt | 91 +++++ .../com/theoplayer/android/ui/UIController.kt | 8 + .../theoplayer/android/ui/PlayerKeysTest.kt | 323 ++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt create mode 100644 ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt new file mode 100644 index 00000000..ac56aee8 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -0,0 +1,91 @@ +package com.theoplayer.android.ui + +import android.view.KeyEvent as NativeKeyEvent +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import com.theoplayer.android.api.player.Player + +/** Handles keyboard and remote keys for a player UI. */ +@InternalUiApi +fun Modifier.handlePlayerKeys( + player: Player?, + controlsVisible: () -> Boolean, + showControls: () -> Unit, + seekOffset: Int = 10 +): Modifier = onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + + val nativeKeyEvent = event.nativeKeyEvent + when { + nativeKeyEvent.keyCode.isMediaKey() -> { + showControls() + handleMediaKey(player, nativeKeyEvent.keyCode, seekOffset) + true + } + nativeKeyEvent.isSystem -> false + else -> { + val wasVisible = controlsVisible() + showControls() + !wasVisible + } + } +} + +/** Checks whether a key code represents a media transport key. */ +private fun Int.isMediaKey(): Boolean = when (this) { + NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + NativeKeyEvent.KEYCODE_MEDIA_PLAY, + NativeKeyEvent.KEYCODE_MEDIA_PAUSE, + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> true + else -> false +} + +/** Performs the action for a media transport key. */ +private fun handleMediaKey(player: Player?, keyCode: Int, seekOffset: Int) { + when (keyCode) { + NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayPause(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY -> playOrReplay(player) + NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) + } +} + +/** Toggles playback, replaying when paused at the end. */ +private fun togglePlayPause(player: Player?) { + if (player == null) return + if (player.isPaused) { + playOrReplay(player) + } else { + player.pause() + } +} + +/** Starts playback or replays ended media. */ +private fun playOrReplay(player: Player?) { + if (player == null) return + if (player.isEnded) { + replay(player) + } else { + player.play() + } +} + +/** Starts playback from the ended state, like [PlayButtonState.onClick]. */ +private fun replay(player: Player) { + player.play() +} + +/** Seeks by [seekOffset], clamped to the media duration. */ +private fun seekBy(player: Player?, seekOffset: Int) { + if (player == null || player.ads.isPlaying) return + val duration = player.duration + if (!duration.isNaN()) { + player.currentTime = (player.currentTime + seekOffset).coerceIn(0.0, duration) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 20b647d9..030aeae6 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -306,6 +306,14 @@ internal fun UIControllerImpl( tapCount++ } ) + .handlePlayerKeys( + player = nativePlayer, + controlsVisible = { controlsVisible.value }, + showControls = { + forceControlsHidden = false + tapCount++ + } + ) ) { CompositionLocalProvider( LocalPlayer provides player, diff --git a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt new file mode 100644 index 00000000..ce6010c7 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt @@ -0,0 +1,323 @@ +package com.theoplayer.android.ui + +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.player.Player +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(InternalUiApi::class) +class PlayerKeysTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun hiddenControlsShowAndConsumeDpadKey() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { false }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + true + } + ) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + assertEquals(1, showControlsCalls) + assertFalse(childSawKey) + } + + @Test + fun visibleControlsShowAndPassDpadKeyToFocusedChild() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { true }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + true + } + ) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + assertEquals(1, showControlsCalls) + assertTrue(childSawKey) + } + + @Test + fun mediaPlayPausePausesWhilePlaying() { + val player = mockPlayer(paused = false) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.pause() } + } + + @Test + fun mediaPlayPausePlaysWhilePaused() { + val player = mockPlayer(paused = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.play() } + } + + @Test + fun mediaPlayPauseReplaysWhenPausedAndEnded() { + val player = mockPlayer(paused = true, ended = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun mediaPlayReplaysWhenEnded() { + val player = mockPlayer(paused = true, ended = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY)) + verify(exactly = 1) { player.play() } + } + + @Test + fun mediaFastForwardSeeksForwardByTenSeconds() { + val player = mockPlayer(currentTime = 40.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 1) { player.currentTime = 50.0 } + } + + @Test + fun mediaRewindSeeksBackwardByTenSeconds() { + val player = mockPlayer(currentTime = 40.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_REWIND)) + verify(exactly = 1) { player.currentTime = 30.0 } + } + + @Test + fun mediaFastForwardIsClampedToDuration() { + val player = mockPlayer(currentTime = 95.0, duration = 100.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 1) { player.currentTime = 100.0 } + } + + @Test + fun mediaRewindIsClampedToZero() { + val player = mockPlayer(currentTime = 5.0, duration = 100.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_REWIND)) + verify(exactly = 1) { player.currentTime = 0.0 } + } + + @Test + fun mediaSeekIsSkippedDuringAds() { + val player = mockPlayer(currentTime = 40.0, adPlaying = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun mediaSeekIsSkippedForUnknownDuration() { + val player = mockPlayer(currentTime = 40.0, duration = Double.NaN) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun backPassesThroughWithoutShowingControls() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { false }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + false + } + ) + + dispatchKey(KeyEvent.KEYCODE_BACK) + assertEquals(0, showControlsCalls) + assertTrue(childSawKey) + } + + @Test + fun keysFromThePlayerControlsComposeViewReachTheContainerModifier() { + val player = mockPlayer() + val focusRequester = FocusRequester() + val showControlsCalls = mutableIntStateOf(0) + var focused = false + var childSawKey = false + lateinit var theoplayerView: THEOplayerView + + composeRule.setContent { + val renderCount = showControlsCalls.intValue + val view = remember { THEOplayerView(composeRule.activity) } + theoplayerView = view + + Box(Modifier.fillMaxSize()) { + PlayerContainer( + theoplayerView = view, + modifier = Modifier + .fillMaxSize() + .handlePlayerKeys( + player = player, + controlsVisible = { true }, + showControls = { showControlsCalls.intValue++ } + ) + ) { + Box( + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused } + .onKeyEvent { + childSawKey = true + true + } + .focusable() + ) + } + Spacer(Modifier.size(renderCount.dp)) + } + } + + composeRule.waitUntil(10_000) { + val playerUiContainer = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + (0 until playerUiContainer.childCount) + .any { playerUiContainer.getChildAt(it) is ComposeView } + } + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.waitUntil(10_000) { focused } + + val container = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + val hostedComposeView = (0 until container.childCount) + .map(container::getChildAt) + .filterIsInstance() + .single() + val hostedComposeViewIndex = container.indexOfChild(hostedComposeView) + var addedViews = 0 + var removedViews = 0 + container.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + addedViews++ + } + + override fun onChildViewRemoved(parent: View?, child: View?) { + removedViews++ + } + }) + + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + ) + composeRule.waitForIdle() + + assertEquals(1, showControlsCalls.intValue) + assertTrue(focused) + assertTrue(childSawKey) + assertSame(hostedComposeView, container.getChildAt(hostedComposeViewIndex)) + assertEquals(0, addedViews) + assertEquals(0, removedViews) + } + + private fun setContent( + player: Player? = null, + controlsVisible: () -> Boolean = { false }, + showControls: () -> Unit = {}, + onChildKey: (ComposeKeyEvent) -> Boolean = { false } + ) { + val focusRequester = FocusRequester() + composeRule.setContent { + Box( + Modifier + .fillMaxSize() + .handlePlayerKeys(player, controlsVisible, showControls) + ) { + Box( + Modifier + .size(100.dp) + .focusRequester(focusRequester) + .onKeyEvent(onChildKey) + .focusable() + ) + } + } + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, keyCode) + ) + + private fun mockPlayer( + paused: Boolean = true, + ended: Boolean = false, + currentTime: Double = 40.0, + duration: Double = 100.0, + adPlaying: Boolean = false + ): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns adPlaying + val player = mockk(relaxed = true) + every { player.isPaused } returns paused + every { player.isEnded } returns ended + every { player.ads } returns ads + every { player.currentTime } returns currentTime + every { player.duration } returns duration + return player + } +} From 77d99281d5a5c7c77b4b0560a64d724414d3c9d0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:11:25 +0000 Subject: [PATCH 065/158] Simplify media play key handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/PlayerKeys.kt | 21 +++---------------- .../theoplayer/android/ui/PlayerKeysTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt index ac56aee8..5796cf9e 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -49,38 +49,23 @@ private fun Int.isMediaKey(): Boolean = when (this) { private fun handleMediaKey(player: Player?, keyCode: Int, seekOffset: Int) { when (keyCode) { NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayPause(player) - NativeKeyEvent.KEYCODE_MEDIA_PLAY -> playOrReplay(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY -> player?.play() NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) } } -/** Toggles playback, replaying when paused at the end. */ +/** Toggles between play and pause. */ private fun togglePlayPause(player: Player?) { if (player == null) return if (player.isPaused) { - playOrReplay(player) + player.play() } else { player.pause() } } -/** Starts playback or replays ended media. */ -private fun playOrReplay(player: Player?) { - if (player == null) return - if (player.isEnded) { - replay(player) - } else { - player.play() - } -} - -/** Starts playback from the ended state, like [PlayButtonState.onClick]. */ -private fun replay(player: Player) { - player.play() -} - /** Seeks by [seekOffset], clamped to the media duration. */ private fun seekBy(player: Player?, seekOffset: Int) { if (player == null || player.ads.isPlaying) return diff --git a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt index ce6010c7..3335239e 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt @@ -98,7 +98,7 @@ class PlayerKeysTest { } @Test - fun mediaPlayPauseReplaysWhenPausedAndEnded() { + fun mediaPlayPauseCallsPlayWhenPausedAndEnded() { val player = mockPlayer(paused = true, ended = true) setContent(player = player) @@ -108,7 +108,7 @@ class PlayerKeysTest { } @Test - fun mediaPlayReplaysWhenEnded() { + fun mediaPlayCallsPlayWhenEnded() { val player = mockPlayer(paused = true, ended = true) setContent(player = player) From 9f6a216c5c99b72f538cf5b11fe5a154a984faa1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:14:04 +0000 Subject: [PATCH 066/158] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdf464b..11b47ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) * 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) +* 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) ## [1.14.2] (2026-10-13) From 19a05ea133138363105e54ca2b176eccd5200a63 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:40:57 +0000 Subject: [PATCH 067/158] Don't hide keys from open menus Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/UIController.kt | 20 +-- .../android/ui/UIControllerKeysTest.kt | 120 ++++++++++++++++++ 2 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 030aeae6..575b3ce4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -256,7 +256,7 @@ internal fun UIControllerImpl( onDispose { scope.dispose() } } - val uiState by remember(scope, pictureInPictureState, errorState) { + val uiState = remember(scope, pictureInPictureState, errorState) { derivedStateOf { val currentMenu = scope.currentMenu when { @@ -267,11 +267,7 @@ internal fun UIControllerImpl( } } } - val backgroundVisible = when (uiState) { - is UIState.Controls -> controlsVisible.value - is UIState.Hidden -> false - else -> true - } + val backgroundVisible = uiState.value.isVisible(controlsVisible.value) val background by animateColorAsState( label = "BackgroundAnimation", targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), @@ -308,7 +304,7 @@ internal fun UIControllerImpl( ) .handlePlayerKeys( player = nativePlayer, - controlsVisible = { controlsVisible.value }, + controlsVisible = { uiState.value.isVisible(controlsVisible.value) }, showControls = { forceControlsHidden = false tapCount++ @@ -325,7 +321,7 @@ internal fun UIControllerImpl( modifier = Modifier .fillMaxSize() .background(background), - targetState = uiState, + targetState = uiState.value, transitionSpec = { if (targetState is UIState.Error) { // Show errors immediately @@ -384,7 +380,13 @@ private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() object Controls : UIState() - object Hidden + object Hidden : UIState() +} + +private fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { + is UIState.Controls -> controlsVisible + is UIState.Hidden -> false + else -> true } @Composable diff --git a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt new file mode 100644 index 00000000..79d482b7 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt @@ -0,0 +1,120 @@ +package com.theoplayer.android.ui + +import android.content.Context +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.player.Player as NativePlayer +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class UIControllerKeysTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun dpadDownReachesOpenMenuWhenPlayingControlsAreHidden() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + val menuFocusRequester = FocusRequester() + lateinit var uiControllerScope: UIControllerScope + var menuItemComposed = false + var menuItemFocused = false + var menuItemSawKey = false + + composeRule.setContent { + UIController( + theoplayerView = theoplayerView, + centerOverlay = { + uiControllerScope = this + Box(Modifier.size(1.dp).testTag("center-overlay")) + }, + bottomChrome = { + Box(Modifier.size(1.dp).testTag("controls-visible")) + } + ) + } + + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertTrue( + composeRule.onAllNodesWithTag("center-overlay") + .fetchSemanticsNodes() + .isNotEmpty() + ) + assertTrue( + composeRule.onAllNodesWithTag("controls-visible") + .fetchSemanticsNodes() + .isEmpty() + ) + composeRule.runOnIdle { + uiControllerScope.openMenu { + Box( + Modifier + .fillMaxSize() + .focusRequester(menuFocusRequester) + .onGloballyPositioned { menuItemComposed = true } + .onFocusChanged { menuItemFocused = it.isFocused } + .onKeyEvent { + menuItemSawKey = true + true + } + .focusable() + .testTag("menu-item") + ) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("center-overlay").fetchSemanticsNodes().isEmpty() + } + composeRule.waitUntil(10_000) { menuItemComposed } + composeRule.runOnIdle { menuFocusRequester.requestFocus() } + composeRule.waitUntil(10_000) { menuItemFocused } + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + ) + assertTrue(menuItemSawKey) + } + + private fun mockPlayingPlayer(): NativePlayer { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + every { player.isPaused } returns false + every { player.isEnded } returns false + every { player.ads } returns ads + return player + } + + private class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: NativePlayer + ) : THEOplayerView(context) { + override fun getPlayer(): NativePlayer = nativePlayer + } +} From 888b193004786efef66901fb2066fb077b1fd87b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:08:21 +0000 Subject: [PATCH 068/158] Use delegated uiState in UIController Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/UIController.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 575b3ce4..61806fdb 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -256,7 +256,7 @@ internal fun UIControllerImpl( onDispose { scope.dispose() } } - val uiState = remember(scope, pictureInPictureState, errorState) { + val uiState by remember(scope, pictureInPictureState, errorState) { derivedStateOf { val currentMenu = scope.currentMenu when { @@ -267,7 +267,7 @@ internal fun UIControllerImpl( } } } - val backgroundVisible = uiState.value.isVisible(controlsVisible.value) + val backgroundVisible = uiState.isVisible(controlsVisible.value) val background by animateColorAsState( label = "BackgroundAnimation", targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), @@ -304,7 +304,7 @@ internal fun UIControllerImpl( ) .handlePlayerKeys( player = nativePlayer, - controlsVisible = { uiState.value.isVisible(controlsVisible.value) }, + controlsVisible = { uiState.isVisible(controlsVisible.value) }, showControls = { forceControlsHidden = false tapCount++ @@ -321,7 +321,7 @@ internal fun UIControllerImpl( modifier = Modifier .fillMaxSize() .background(background), - targetState = uiState.value, + targetState = uiState, transitionSpec = { if (targetState is UIState.Error) { // Show errors immediately @@ -383,6 +383,7 @@ private sealed class UIState { object Hidden : UIState() } +/** Returns whether this UI state is visible. */ private fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { is UIState.Controls -> controlsVisible is UIState.Hidden -> false From 00892173ad4581ae94a3b6e165640c30b4ca783a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:49:46 +0000 Subject: [PATCH 069/158] Share UIController core between mobile and TV Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 1 + ui-common/build.gradle.kts | 1 + .../android/ui/ControlsVisibility.kt | 79 +++++ .../theoplayer/android/ui/UIControllerCore.kt | 233 ++++++++++++++ .../android/ui/UIControllerState.kt | 75 +++++ .../android/ui/theme/PlayerAnimations.kt | 37 +++ .../com/theoplayer/android/ui/UIController.kt | 296 +++--------------- .../com/theoplayer/android/ui/theme/Theme.kt | 24 -- .../android/ui/UIControllerKeysTest.kt | 68 ++++ 9 files changed, 543 insertions(+), 271 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c6f2d45..b5be91c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 0104fa31..95b5c990 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.androidx.ktx) implementation(libs.androidx.lifecycle.compose) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui.ui) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt new file mode 100644 index 00000000..abec7ca6 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt @@ -0,0 +1,79 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Visibility state and callbacks for player controls. + */ +@InternalUiApi +class ControlsVisibility( + val visible: State, + val animationsActive: Boolean, + val show: () -> Unit, + val hide: () -> Unit +) + +/** + * Remembers control visibility and interaction state. + */ +@InternalUiApi +@Composable +fun rememberControlsVisibility( + firstPlay: Boolean, + paused: Boolean, + forceVisible: Boolean, + hideDelay: Duration +): ControlsVisibility { + var activityCount by remember { mutableIntStateOf(0) } + var recentlyActive by remember { mutableStateOf(false) } + LaunchedEffect(activityCount, hideDelay) { + if (activityCount > 0) { + recentlyActive = true + delay(hideDelay) + recentlyActive = false + } + } + + var forceControlsHidden by remember { mutableStateOf(false) } + val inspectionMode = LocalInspectionMode.current + var isReady by remember { mutableStateOf(inspectionMode) } + LaunchedEffect(Unit) { + delay(50.milliseconds) + isReady = true + } + + val visible = remember(firstPlay, paused, forceVisible) { + derivedStateOf { + when { + !isReady -> false + !firstPlay || forceVisible -> true + forceControlsHidden -> false + else -> recentlyActive || paused + } + } + } + return ControlsVisibility( + visible = visible, + animationsActive = isReady, + show = { + forceControlsHidden = false + activityCount++ + }, + hide = { + forceControlsHidden = true + activityCount++ + } + ) +} 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 new file mode 100644 index 00000000..f3fc663c --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt @@ -0,0 +1,233 @@ +package com.theoplayer.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import kotlin.time.DurationUnit + +/** + * Renders the shared player UI around the active controls, menu, or error state. + */ +@InternalUiApi +@Composable +fun UIControllerCore( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView?, + player: Player?, + scope: UIControllerScopeImpl, + uiState: State, + errorState: ErrorState, + controls: ControlsVisibility, + color: Color, + contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, + controlsModifier: Modifier = Modifier, + centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + topChrome: (@Composable UIControllerScope.() -> Unit)? = null, + centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null +) { + val nativePlayer = theoplayerView?.player + val adState = rememberAdState(nativePlayer) + val playerAnimations = LocalPlayerAnimations.current + val backgroundVisible = uiState.value.isVisible(controls.visible.value) + val animatedBackground by animateColorAsState( + label = "BackgroundAnimation", + targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), + animationSpec = tween( + easing = LinearEasing, + durationMillis = if (backgroundVisible) { + playerAnimations.controlsEnterDuration.toInt(DurationUnit.MILLISECONDS) + } else { + playerAnimations.controlsExitDuration.toInt(DurationUnit.MILLISECONDS) + } + ) + ) + + BackHandler(enabled = scope.currentMenu != null) { + scope.closeCurrentMenu() + } + + PlayerContainer( + theoplayerView = theoplayerView, + modifier = Modifier + .background(Color.Black) + .then(modifier) + .handlePlayerKeys( + player = nativePlayer, + controlsVisible = { + uiState.value.isVisible(controls.visible.value) + }, + showControls = controls.show + ) + ) { + UIControllerContent( + theoplayerView = theoplayerView, + player = player, + scope = scope, + uiState = uiState.value, + errorState = errorState, + controls = controls, + adPlaying = adState.playingAd, + background = animatedBackground, + controlsModifier = controlsModifier, + contentTransform = contentTransform, + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) + } +} + +/** + * Provides controller locals and displays the active content. + */ +@Composable +private fun UIControllerContent( + theoplayerView: THEOplayerView?, + player: Player?, + scope: UIControllerScopeImpl, + uiState: UIState, + errorState: ErrorState, + controls: ControlsVisibility, + adPlaying: Boolean, + background: Color, + controlsModifier: Modifier, + contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, + centerOverlay: (@Composable UIControllerScope.() -> Unit)?, + errorOverlay: (@Composable UIControllerScope.() -> Unit)?, + topChrome: (@Composable UIControllerScope.() -> Unit)?, + centerChrome: (@Composable UIControllerScope.() -> Unit)?, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? +) { + CompositionLocalProvider( + LocalPlayer provides player, + LocalTHEOplayerView provides theoplayerView, + LocalUIControllerErrorState provides errorState + ) { + AnimatedContent( + label = "ContentAnimation", + modifier = Modifier + .fillMaxSize() + .background(background), + targetState = uiState, + transitionSpec = contentTransform + ) { state -> + when (state) { + UIState.Error -> errorOverlay?.let { scope.it() } + is UIState.Menu -> state.menu.let { scope.it() } + UIState.Controls -> scope.PlayerControls( + controlsVisible = controls.visible.value, + animationsActive = controls.animationsActive && !adPlaying, + controlsModifier = controlsModifier, + centerOverlay = centerOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) + UIState.Hidden -> Unit + } + } + } +} + +/** + * Displays the center overlay and animated control slots. + */ +@Composable +private fun UIControllerScope.PlayerControls( + controlsVisible: Boolean, + animationsActive: Boolean, + controlsModifier: Modifier, + centerOverlay: (@Composable UIControllerScope.() -> Unit)?, + topChrome: (@Composable UIControllerScope.() -> Unit)?, + centerChrome: (@Composable UIControllerScope.() -> Unit)?, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? +) { + val playerAnimations = LocalPlayerAnimations.current + Box(modifier = Modifier.fillMaxSize()) { + centerOverlay?.let { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + it() + } + } + AnimatedVisibility( + visible = controlsVisible, + enter = if (animationsActive) fadeIn( + animationSpec = tween( + easing = LinearEasing, + durationMillis = playerAnimations.controlsEnterDuration.toInt( + DurationUnit.MILLISECONDS + ) + ) + ) else EnterTransition.None, + exit = if (animationsActive) fadeOut( + animationSpec = tween( + easing = LinearEasing, + durationMillis = playerAnimations.controlsExitDuration.toInt( + DurationUnit.MILLISECONDS + ) + ) + ) else ExitTransition.None, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .then(controlsModifier) + ) { + centerChrome?.let { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + it() + } + } + Column(modifier = Modifier.fillMaxSize()) { + topChrome?.let { it() } + Spacer(modifier = Modifier.weight(1f)) + bottomChrome?.let { it() } + } + } + } + } +} + +/** + * The current error state provided by a UI controller. + */ +@InternalUiApi +val LocalUIControllerErrorState = staticCompositionLocalOf { null } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt new file mode 100644 index 00000000..4e821ca2 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt @@ -0,0 +1,75 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.util.rememberIsInPipMode + +/** + * The active content state for a player UI. + */ +@InternalUiApi +sealed class UIState { + data object Error : UIState() + data class Menu(val menu: MenuContent) : UIState() + data object Controls : UIState() + data object Hidden : UIState() +} + +/** + * Returns whether this state should be treated as visible. + */ +@InternalUiApi +fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { + is UIState.Controls -> controlsVisible + is UIState.Hidden -> false + else -> true +} + +/** + * Remembers the UI controller scope and disposes it with the composition. + */ +@InternalUiApi +@Composable +fun rememberUIControllerScope( + theoplayerView: THEOplayerView?, + player: Player? +): UIControllerScopeImpl { + val scope = remember(theoplayerView, player) { + UIControllerScopeImpl(theoplayerView, player) + } + if (player == null) { + val isInPipMode = rememberIsInPipMode() + SideEffect { scope.activityInPipMode = isInPipMode } + } + DisposableEffect(scope) { + onDispose { scope.dispose() } + } + return scope +} + +/** + * Derives the active UI state from menus, errors, and picture-in-picture mode. + */ +@InternalUiApi +@Composable +fun rememberUIState( + scope: UIControllerScopeImpl, + errorState: ErrorState, + pictureInPictureState: PictureInPictureState? +): State = remember(scope, errorState, pictureInPictureState) { + derivedStateOf { + val currentMenu = scope.currentMenu + when { + pictureInPictureState?.pictureInPicture == true -> UIState.Hidden + errorState.error != null -> UIState.Error + currentMenu != null -> UIState.Menu(currentMenu) + else -> UIState.Controls + } + } +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt new file mode 100644 index 00000000..2e32b673 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt @@ -0,0 +1,37 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import com.theoplayer.android.ui.InternalUiApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Animation settings used by THEOplayer UI components. + */ +@Immutable +data class PlayerAnimations( + /** + * The animation duration when fading in the player controls. + */ + val controlsEnterDuration: Duration = 0.milliseconds, + /** + * The animation duration when fading out the player controls. + */ + val controlsExitDuration: Duration = 500.milliseconds, + /** + * The delay for a loading spinner to become visible after the player starts buffering. + */ + val loadingSpinnerDelay: Duration = 500.milliseconds, + /** + * How long the controls stay visible after user interaction while playing. + */ + val controlsHideDelay: Duration = 2.seconds +) + +/** + * Animation settings shared by player UI modules. + */ +@InternalUiApi +val LocalPlayerAnimations = staticCompositionLocalOf { PlayerAnimations() } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 61806fdb..1a54559b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -1,13 +1,10 @@ package com.theoplayer.android.ui import android.app.Activity -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -15,25 +12,10 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -44,10 +26,6 @@ import com.theoplayer.android.api.cast.chromecast.PlayerCastState import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.ui.theme.THEOplayerTheme import com.theoplayer.android.ui.util.rememberIsInPipMode -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit /** * A container component for a THEOplayer UI. @@ -210,237 +188,61 @@ internal fun UIControllerImpl( val playButtonState = rememberPlayButtonState(nativePlayer) val pictureInPictureState = rememberPictureInPictureState(theoplayerView) val errorState = rememberErrorState(nativePlayer, player?.error) - val adState = rememberAdState(nativePlayer) val videoSizeState = rememberVideoSizeState(nativePlayer) - - var tapCount by remember { mutableIntStateOf(0) } - var isRecentlyTapped by remember { mutableStateOf(false) } - LaunchedEffect(tapCount) { - if (tapCount > 0) { - isRecentlyTapped = true - delay(2.seconds) - isRecentlyTapped = false - } - } - var forceControlsHidden by remember { mutableStateOf(false) } - - // Wait a little bit before showing the controls and enabling animations, - // so we can hide the UI immediately in case of autoplay. - val inspectionMode = LocalInspectionMode.current - var isReady by remember { mutableStateOf(inspectionMode) } - LaunchedEffect(Unit) { - delay(50.milliseconds) - isReady = true - } - - val controlsVisible = remember(firstPlayState, castState, playButtonState) { - derivedStateOf { - if (!isReady) { - false - } else if (!firstPlayState.firstPlay || castState.castState == PlayerCastState.CONNECTED) { - true - } else if (forceControlsHidden) { - false - } else { - isRecentlyTapped || playButtonState.paused - } - } - } - - val scope = remember(theoplayerView, player) { UIControllerScopeImpl(theoplayerView, player) } - if (player == null) { - val isInPipMode = rememberIsInPipMode() - SideEffect { scope.activityInPipMode = isInPipMode } - } - DisposableEffect(scope) { - onDispose { scope.dispose() } - } - - val uiState by remember(scope, pictureInPictureState, errorState) { - derivedStateOf { - val currentMenu = scope.currentMenu - when { - pictureInPictureState.pictureInPicture -> UIState.Hidden - errorState.error != null -> UIState.Error - currentMenu != null -> UIState.Menu(currentMenu) - else -> UIState.Controls - } - } - } - val backgroundVisible = uiState.isVisible(controlsVisible.value) - val background by animateColorAsState( - label = "BackgroundAnimation", - targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), - animationSpec = tween( - easing = LinearEasing, - durationMillis = if (backgroundVisible) { - THEOplayerTheme.playerAnimations.controlsEnterDuration.toInt( - DurationUnit.MILLISECONDS - ) - } else { - THEOplayerTheme.playerAnimations.controlsExitDuration.toInt( - DurationUnit.MILLISECONDS - ) - } - ) + val scope = rememberUIControllerScope(theoplayerView, player) + val uiState = rememberUIState(scope, errorState, pictureInPictureState) + val controls = rememberControlsVisibility( + firstPlay = firstPlayState.firstPlay, + paused = playButtonState.paused, + forceVisible = castState.castState == PlayerCastState.CONNECTED, + hideDelay = THEOplayerTheme.playerAnimations.controlsHideDelay ) - PlayerContainer( - theoplayerView = theoplayerView, - modifier = Modifier - .background(Color.Black) - .then(modifier) + UIControllerCore( + modifier = modifier .playerAspectRatio(videoSizeState) .toggleControlsOnTap( - controlsVisible = controlsVisible, - showControlsTemporarily = { - forceControlsHidden = false - tapCount++ - }, - hideControls = { - forceControlsHidden = true - tapCount++ - } - ) - .handlePlayerKeys( - player = nativePlayer, - controlsVisible = { uiState.isVisible(controlsVisible.value) }, - showControls = { - forceControlsHidden = false - tapCount++ - } - ) - ) { - CompositionLocalProvider( - LocalPlayer provides player, - LocalTHEOplayerView provides theoplayerView, - LocalUIControllerErrorState provides errorState - ) { - AnimatedContent( - label = "ContentAnimation", - modifier = Modifier - .fillMaxSize() - .background(background), - targetState = uiState, - transitionSpec = { - if (targetState is UIState.Error) { - // Show errors immediately - EnterTransition.None togetherWith ExitTransition.None - } else if (initialState is UIState.Menu && targetState is UIState.Menu) { - if (scope.lastWasClosed) { - // Close menu towards the right - slideInHorizontally { -it } togetherWith - slideOutHorizontally { it } - } else { - // Open new menu towards the left - slideInHorizontally(initialOffsetX = { it }) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - } - } else if (targetState is UIState.Menu) { - // Open first menu from the bottom - slideInVertically { it / 4 } + fadeIn() togetherWith fadeOut() - } else if (initialState is UIState.Menu) { - // Close last menu towards the bottom - fadeIn() togetherWith slideOutVertically { it / 4 } + fadeOut() - } else { - EnterTransition.None togetherWith ExitTransition.None - } - } - ) { uiState -> - when (uiState) { - is UIState.Error -> { - errorOverlay?.let { scope.it() } - } - - is UIState.Menu -> { - uiState.menu.let { scope.it() } - } - - is UIState.Controls -> { - scope.PlayerControls( - controlsVisible = controlsVisible.value, - animationsActive = isReady && !adState.playingAd, - centerOverlay = centerOverlay, - topChrome = topChrome, - centerChrome = centerChrome, - bottomChrome = bottomChrome - ) - } - - is UIState.Hidden -> {} - } - } - } - } -} - -internal val LocalUIControllerErrorState = staticCompositionLocalOf { null } - -private sealed class UIState { - object Error : UIState() - data class Menu(val menu: MenuContent) : UIState() - object Controls : UIState() - object Hidden : UIState() -} - -/** Returns whether this UI state is visible. */ -private fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { - is UIState.Controls -> controlsVisible - is UIState.Hidden -> false - else -> true + controlsVisible = controls.visible, + showControlsTemporarily = controls.show, + hideControls = controls.hide + ), + theoplayerView = theoplayerView, + player = player, + scope = scope, + uiState = uiState, + errorState = errorState, + controls = controls, + color = color, + contentTransform = mobileContentTransform(scope), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) } -@Composable -private fun UIControllerScope.PlayerControls( - controlsVisible: Boolean, - animationsActive: Boolean, - centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, - topChrome: (@Composable UIControllerScope.() -> Unit)? = null, - centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, - bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null -) { - centerOverlay?.let { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - it() - } - } - AnimatedVisibility( - visible = controlsVisible, - enter = if (animationsActive) fadeIn( - animationSpec = tween( - easing = LinearEasing, - durationMillis = THEOplayerTheme.playerAnimations.controlsEnterDuration.toInt( - DurationUnit.MILLISECONDS - ) - ) - ) else EnterTransition.None, - exit = if (animationsActive) fadeOut( - animationSpec = tween( - easing = LinearEasing, - durationMillis = THEOplayerTheme.playerAnimations.controlsExitDuration.toInt( - DurationUnit.MILLISECONDS - ) - ) - ) else ExitTransition.None, - ) { - centerChrome?.let { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - it() - } - } - Column(modifier = Modifier.fillMaxSize()) { - topChrome?.let { it() } - Spacer(modifier = Modifier.weight(1f)) - bottomChrome?.let { it() } +/** + * Returns the mobile menu transitions. + */ +private fun mobileContentTransform( + scope: UIControllerScopeImpl +): AnimatedContentTransitionScope.() -> ContentTransform = { + if (targetState is UIState.Error) { + EnterTransition.None togetherWith ExitTransition.None + } else if (initialState is UIState.Menu && targetState is UIState.Menu) { + if (scope.lastWasClosed) { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + } else { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) } + } else if (targetState is UIState.Menu) { + slideInVertically { it / 4 } + fadeIn() togetherWith fadeOut() + } else if (initialState is UIState.Menu) { + fadeIn() togetherWith slideOutVertically { it / 4 } + fadeOut() + } else { + EnterTransition.None togetherWith ExitTransition.None } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt index 7bd5372e..94d0e668 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt @@ -13,9 +13,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import com.theoplayer.android.ui.LiveButton -import com.theoplayer.android.ui.LoadingSpinner -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -99,27 +96,6 @@ data class PlayerColors( private val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } -/** - * Animation settings used by THEOplayer UI components. - */ -@Immutable -data class PlayerAnimations( - /** - * The animation duration when fading in the player controls. - */ - val controlsEnterDuration: Duration = 0.milliseconds, - /** - * The animation duration when fading out the player controls. - */ - val controlsExitDuration: Duration = 500.milliseconds, - /** - * The delay for the [LoadingSpinner] to become visible after the player starts buffering. - */ - val loadingSpinnerDelay: Duration = 500.milliseconds, -) - -private val LocalPlayerAnimations = staticCompositionLocalOf { PlayerAnimations() } - /** * Provides theme colors, shapes and fonts to be used by the player. * diff --git a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt index 79d482b7..9faffeab 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt @@ -3,6 +3,7 @@ package com.theoplayer.android.ui import android.content.Context import android.view.KeyEvent import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -22,6 +23,7 @@ import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.player.Player as NativePlayer import io.mockk.every import io.mockk.mockk +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -101,6 +103,72 @@ class UIControllerKeysTest { assertTrue(menuItemSawKey) } + @Test + fun backClosesMenuBeforeFallingThroughToActivity() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + lateinit var uiControllerScope: UIControllerScope + + composeRule.setContent { + UIController( + theoplayerView = theoplayerView, + centerOverlay = { uiControllerScope = this } + ) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + uiControllerScope.openMenu { + Box(Modifier.size(20.dp).testTag("menu")) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("menu").fetchSemanticsNodes().isNotEmpty() + } + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTrue(composeRule.onAllNodesWithTag("menu").fetchSemanticsNodes().isEmpty()) + assertEquals(0, activityBackCalls) + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + assertEquals(1, activityBackCalls) + } + + @Test + fun backFallsThroughWhenNoMenuIsOpen() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + composeRule.setContent { + UIController(theoplayerView = theoplayerView) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + private fun mockPlayingPlayer(): NativePlayer { val ads = mockk(relaxed = true) every { ads.isPlaying } returns false From 9f8474ce366c683dd58c2cd47afde250ff2513f5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:50:25 +0000 Subject: [PATCH 070/158] Add Android TV UI module with UIController Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + .../com/theoplayer/android/ui/PlayerKeys.kt | 16 +- ui-tv/build.gradle.kts | 129 ++++ ui-tv/proguard-rules.pro | 0 .../android/ui/tv/ExperimentalTvUiApi.kt | 17 + .../theoplayer/android/ui/tv/UIController.kt | 201 +++++++ .../theoplayer/android/ui/tv/theme/Theme.kt | 34 ++ .../android/ui/tv/UIControllerTest.kt | 557 ++++++++++++++++++ 9 files changed, 950 insertions(+), 7 deletions(-) create mode 100644 ui-tv/build.gradle.kts create mode 100644 ui-tv/proguard-rules.pro create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5be91c5..2030670d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ theoplayer-compile = { prefer = "11.+", strictly = "[10.13.0, 12.0)" } theoplayer-min = { strictly = "7.6.0" } core = "1.18.0" core-pip = "1.0.0-alpha02" +tv-material = "1.1.0" [libraries] androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } @@ -49,6 +50,7 @@ androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" } androidx-core = { group = "androidx.core", name = "core", version.ref = "core" } androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "core-pip" } +androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tv-material" } androidTools-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" } playServices-castFramework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServices-castFramework" } gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9887aa76..77607b5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,3 +20,4 @@ rootProject.name = "THEOplayer Open Video UI for Android" include(":app") include(":ui") include(":ui-common") +include(":ui-tv") diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt index 5796cf9e..9b708015 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -48,16 +48,17 @@ private fun Int.isMediaKey(): Boolean = when (this) { /** Performs the action for a media transport key. */ private fun handleMediaKey(player: Player?, keyCode: Int, seekOffset: Int) { when (keyCode) { - NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayPause(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayerPlayPause(player) NativeKeyEvent.KEYCODE_MEDIA_PLAY -> player?.play() NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() - NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) - NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekPlayerBy(player, seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekPlayerBy(player, -seekOffset) } } -/** Toggles between play and pause. */ -private fun togglePlayPause(player: Player?) { +/** Toggles playback between play and pause. */ +@InternalUiApi +fun togglePlayerPlayPause(player: Player?) { if (player == null) return if (player.isPaused) { player.play() @@ -66,8 +67,9 @@ private fun togglePlayPause(player: Player?) { } } -/** Seeks by [seekOffset], clamped to the media duration. */ -private fun seekBy(player: Player?, seekOffset: Int) { +/** Seeks by [seekOffset], skipping ads and clamping to the media duration. */ +@InternalUiApi +fun seekPlayerBy(player: Player?, seekOffset: Int) { if (player == null || player.ads.isPlaying) return val duration = player.duration if (!duration.isNaN()) { diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts new file mode 100644 index 00000000..cf65d3ad --- /dev/null +++ b/ui-tv/build.gradle.kts @@ -0,0 +1,129 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +buildscript { + dependencies { + classpath(libs.dokka.base) + } +} + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.dokka) + alias(libs.plugins.dokka.javadoc) + id("android-ui.library-conventions") +} + +android { + namespace = "com.theoplayer.android.ui.tv" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + buildFeatures { + compose = true + } + testOptions { + unitTests.isIncludeAndroidResources = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +kotlin { + compilerOptions { + apiVersion = KotlinVersion.KOTLIN_2_0 + jvmTarget = JvmTarget.JVM_1_8 + optIn.add("com.theoplayer.android.ui.InternalUiApi") + optIn.add("com.theoplayer.android.ui.ExperimentalPlayerStateApi") + optIn.add("com.theoplayer.android.ui.tv.ExperimentalTvUiApi") + } +} + +dependencies { + api(project(":ui-common")) + + implementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.tv.material) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.ui) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui.toolingPreview) + + testImplementation(libs.junit4) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.testJunit4) + testImplementation(libs.theoplayer) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.testManifest) + + dokkaPlugin(libs.dokka.plugin) +} + +dokka { + moduleName = "android-ui-tv" + + dokkaSourceSets.configureEach { + sourceLink { + localDirectory = file("src/main/java") + remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-tv/src/main/java") + remoteLineSuffix = "#L" + } + + externalDocumentationLinks { + register("com.theoplayer.android.api") { + url("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/") + packageListUrl("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/package-list") + } + } + } +} + +val dokkaJavadocJar = tasks.register("dokkaJavadocJar") { + group = "dokka" + from(tasks.dokkaGeneratePublicationJavadoc) + dependsOn(tasks.dokkaGeneratePublicationJavadoc) + archiveClassifier.set("javadoc") +} + +publishing { + publications { + register("release") { + groupId = "com.theoplayer.android-ui" + artifactId = "android-ui-tv" + version = project.version as String + artifact(dokkaJavadocJar) + afterEvaluate { + from(components["release"]) + } + } + } +} diff --git a/ui-tv/proguard-rules.pro b/ui-tv/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt new file mode 100644 index 00000000..2504a044 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt @@ -0,0 +1,17 @@ +package com.theoplayer.android.ui.tv + +/** + * Marks declarations that are part of the experimental THEOplayer Android TV UI API. + */ +@RequiresOptIn( + message = "This TV UI API is experimental and may change in future releases.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR +) +annotation class ExperimentalTvUiApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt new file mode 100644 index 00000000..10c0e71e --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -0,0 +1,201 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent as NativeKeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.focusGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.ui.ControlsVisibility +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.UIControllerCore +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.UIState +import com.theoplayer.android.ui.isVisible +import com.theoplayer.android.ui.rememberControlsVisibility +import com.theoplayer.android.ui.rememberErrorState +import com.theoplayer.android.ui.rememberFirstPlayState +import com.theoplayer.android.ui.rememberPlayButtonState +import com.theoplayer.android.ui.rememberUIControllerScope +import com.theoplayer.android.ui.rememberUIState +import com.theoplayer.android.ui.seekPlayerBy +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.togglePlayerPlayPause + +/** + * A TV-oriented UI controller for a THEOplayer view. + * + * @param modifier the [Modifier] to be applied to this controller + * @param theoplayerView the THEOplayer view to host + * @param color the background color behind visible controls + * @param centerOverlay content displayed over the center of the player + * @param errorOverlay content displayed when the player encounters a fatal error + * @param topChrome controls displayed at the top of the player + * @param centerChrome controls displayed at the center of the player + * @param bottomChrome controls displayed at the bottom of the player + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun UIController( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + color: Color = Color.Black.copy(alpha = 0.5f), + centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + topChrome: (@Composable UIControllerScope.() -> Unit)? = null, + centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null +) { + val view = theoplayerView.takeUnless { it.isInEditMode } + val nativePlayer = view?.player + val firstPlayState = rememberFirstPlayState(nativePlayer) + val playButtonState = rememberPlayButtonState(nativePlayer) + val errorState = rememberErrorState(nativePlayer) + val scope = rememberUIControllerScope(view, player = null) + val uiState = rememberUIState( + scope = scope, + errorState = errorState, + pictureInPictureState = null + ) + val playerAnimations = LocalPlayerAnimations.current + val controls = rememberControlsVisibility( + firstPlay = firstPlayState.firstPlay, + paused = playButtonState.paused, + forceVisible = false, + hideDelay = playerAnimations.controlsHideDelay + ) + val focusRequester = remember { FocusRequester() } + val currentUiState = uiState.value + + rememberControlsFocus( + uiState = currentUiState, + menuOpen = scope.currentMenu != null, + controls = controls, + focusRequester = focusRequester + ) + BackHandler( + enabled = currentUiState is UIState.Controls && + controls.visible.value && firstPlayState.firstPlay + ) { + controls.hide() + } + + UIControllerCore( + modifier = modifier.tvHiddenControlsKeys( + player = nativePlayer, + controlsVisible = { uiState.value.isVisible(controls.visible.value) }, + showControls = controls.show + ), + theoplayerView = view, + player = null, + scope = scope, + uiState = uiState, + errorState = errorState, + controls = controls, + color = color, + contentTransform = tvContentTransform(), + controlsModifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .focusGroup(), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) +} + +/** + * Restores focus to controls when they become visible or a menu closes. + */ +@OptIn(InternalUiApi::class) +@Composable +private fun rememberControlsFocus( + uiState: UIState, + menuOpen: Boolean, + controls: ControlsVisibility, + focusRequester: FocusRequester +) { + var menuWasOpen by remember { mutableStateOf(false) } + LaunchedEffect(controls.visible.value, menuOpen, uiState) { + if (uiState is UIState.Controls && !menuOpen && + (controls.visible.value || menuWasOpen) + ) { + focusRequester.requestFocus() + } + menuWasOpen = menuOpen + } +} + +/** + * Returns fade-only transitions for TV content. + */ +private fun tvContentTransform(): AnimatedContentTransitionScope.() -> ContentTransform = { + when { + targetState is UIState.Error -> + EnterTransition.None togetherWith ExitTransition.None + targetState is UIState.Menu || initialState is UIState.Menu -> + fadeIn() togetherWith fadeOut() + else -> + EnterTransition.None togetherWith ExitTransition.None + } +} + +/** + * Handles TV transport keys while controls are hidden. + */ +@OptIn(InternalUiApi::class) +private fun Modifier.tvHiddenControlsKeys( + player: Player?, + controlsVisible: () -> Boolean, + showControls: () -> Unit +): Modifier = onPreviewKeyEvent { event -> + if (controlsVisible() || event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + + when (event.nativeKeyEvent.keyCode) { + NativeKeyEvent.KEYCODE_DPAD_CENTER, + NativeKeyEvent.KEYCODE_ENTER -> { + showControls() + togglePlayerPlayPause(player) + true + } + NativeKeyEvent.KEYCODE_DPAD_LEFT -> { + showControls() + seekPlayerBy(player, -SEEK_OFFSET_SECONDS) + true + } + NativeKeyEvent.KEYCODE_DPAD_RIGHT -> { + showControls() + seekPlayerBy(player, SEEK_OFFSET_SECONDS) + true + } + else -> false + } +} + +private const val SEEK_OFFSET_SECONDS = 10 diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt new file mode 100644 index 00000000..347fe822 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt @@ -0,0 +1,34 @@ +package com.theoplayer.android.ui.tv.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.tv.material3.ColorScheme +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Typography +import androidx.tv.material3.darkColorScheme +import com.theoplayer.android.ui.tv.ExperimentalTvUiApi +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.theme.PlayerAnimations +import kotlin.time.Duration.Companion.seconds + +/** + * Provides TV Material theme values and animations to a player UI. + * + * @param playerAnimations animation settings for the TV player UI + */ +@ExperimentalTvUiApi +@Composable +fun THEOplayerTvTheme( + colorScheme: ColorScheme = darkColorScheme(), + typography: Typography = MaterialTheme.typography, + playerAnimations: PlayerAnimations = PlayerAnimations(controlsHideDelay = 5.seconds), + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalPlayerAnimations provides playerAnimations) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt new file mode 100644 index 00000000..d8269f3f --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -0,0 +1,557 @@ +package com.theoplayer.android.ui.tv + +import android.content.Context +import android.view.KeyEvent +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.ErrorEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.timerange.TimeRanges +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class, InternalUiApi::class) +class UIControllerTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun hiddenDpadDownShowsControlsAndConsumesKey() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + var childSawKey = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester) { + childSawKey = true + false + }, + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + + assertTagExists("controls") + assertFalse(childSawKey) + } + + @Test + fun hiddenDpadLeftAndRightSeekByTenSeconds() { + val player = mockPlayingPlayer(currentTime = 40.0) + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester) + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + verify(exactly = 1) { player.currentTime = 50.0 } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT)) + verify(exactly = 1) { player.currentTime = 30.0 } + } + + @Test + fun hiddenDpadCenterPausesAndShowsControls() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER)) + + verify(exactly = 1) { player.pause() } + waitForTag("controls") + assertTagExists("controls") + } + + @Test + fun visibleDpadRightReachesFocusedControlWithoutSeeking() { + val player = mockPlayingPlayer(currentTime = 40.0) + val keyFocusRequester = FocusRequester() + var focusedControlSawKey = false + var focused = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box( + Modifier + .size(20.dp) + .onFocusChanged { focused = it.isFocused } + .onKeyEvent { + focusedControlSawKey = true + true + } + .focusable() + .testTag("control") + ) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitUntil(10_000) { focused } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + + assertTrue(focusedControlSawKey) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun controlsAutoHideAfterFiveSecondsWhilePlaying() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + assertTagExists("controls") + + composeRule.mainClock.advanceTimeBy(CONTROLS_AUTO_HIDE_DURATION_MILLIS) + composeRule.waitForIdle() + + assertTagDoesNotExist("controls") + } + + @Test + fun backClosesMenuBeforeFallingThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box( + Modifier + .size(20.dp) + .clickable { + openMenu { + Box(Modifier.fillMaxSize().testTag("menu")) + } + } + .focusable() + .testTag("open-menu") + ) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("open-menu") + composeRule.onNodeWithTag("open-menu").performClick() + waitForTag("menu") + assertTagExists("menu") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTagDoesNotExist("menu") + assertEquals(0, activityBackCalls) + waitForTag("open-menu") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + assertTagDoesNotExist("open-menu") + assertEquals(0, activityBackCalls) + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun backHidesVisibleControlsAndFallsThroughWhenHidden() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + assertTagExists("controls") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTagDoesNotExist("controls") + assertEquals(0, activityBackCalls) + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun dpadCenterReachesMenuWhenControlsAreHidden() { + val player = mockPlayingPlayer() + var menuItemSawKey = false + openMenuWhileControlsAreHidden(player) { + menuItemSawKey = true + true + } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER)) + + assertTrue(menuItemSawKey) + verify(exactly = 0) { player.pause() } + verify(exactly = 0) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun dpadRightReachesMenuWhenControlsAreHidden() { + val player = mockPlayingPlayer() + var menuItemSawKey = false + openMenuWhileControlsAreHidden(player) { + menuItemSawKey = true + true + } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + + assertTrue(menuItemSawKey) + verify(exactly = 0) { player.pause() } + verify(exactly = 0) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun backBeforeFirstPlayFallsThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + setController( + player = mockNotPlayedPlayer(), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + waitForTag("controls") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun backOnErrorFallsThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + val player = mockNotPlayedPlayer() + val errorListener = slot>() + val errorListeners = mutableListOf>() + every { + player.addEventListener(PlayerEventTypes.ERROR, capture(errorListener)) + } answers { + errorListeners += errorListener.captured + } + setController( + player = player, + errorOverlay = { + Box(Modifier.size(20.dp).testTag("error")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitUntil(10_000) { errorListeners.isNotEmpty() } + composeRule.waitForIdle() + val errorEvent = mockk() + every { errorEvent.errorObject } returns mockk() + composeRule.runOnIdle { + errorListeners.forEach { it.handleEvent(errorEvent) } + } + waitForTag("error") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + assertTagExists("error") + } + + @Test + fun controlsRequestFocusOnFirstBottomChromeControl() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + var firstControlFocused = false + var secondControlFocused = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Row { + Box( + Modifier + .size(20.dp) + .onFocusChanged { firstControlFocused = it.isFocused } + .focusable() + .testTag("first-control") + ) + Box( + Modifier + .size(20.dp) + .onFocusChanged { secondControlFocused = it.isFocused } + .focusable() + .testTag("second-control") + ) + } + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitUntil(10_000) { firstControlFocused } + + assertTrue(firstControlFocused) + assertFalse(secondControlFocused) + } + + private fun setController( + player: Player, + centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + topChrome: (@Composable UIControllerScope.() -> Unit)? = null, + centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null + ) { + lateinit var theoplayerView: THEOplayerView + composeRule.setContent { + val view = remember { + PlayerBackedTHEOplayerView(composeRule.activity, player) + } + theoplayerView = view + THEOplayerTvTheme { + UIController( + modifier = Modifier.fillMaxSize(), + theoplayerView = view, + color = Color.Black.copy(alpha = 0.5f), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) + } + } + composeRule.waitUntil(10_000) { + val container = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + (0 until container.childCount).any { + container.getChildAt(it) is ComposeView + } + } + } + + private fun openMenuWhileControlsAreHidden( + player: Player, + onMenuKeyEvent: (ComposeKeyEvent) -> Boolean + ) { + val menuFocusRequester = FocusRequester() + lateinit var scope: UIControllerScope + var menuItemFocused = false + setController( + player = player, + centerOverlay = { + scope = this + Box(Modifier.size(1.dp).testTag("center-overlay")) + }, + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertTagDoesNotExist("controls") + + composeRule.runOnIdle { + scope.openMenu { + Box( + Modifier + .size(20.dp) + .focusRequester(menuFocusRequester) + .onFocusChanged { menuItemFocused = it.isFocused } + .onKeyEvent(onMenuKeyEvent) + .focusable() + .testTag("menu-item") + ) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("center-overlay").fetchSemanticsNodes().isEmpty() + } + waitForTag("menu-item") + composeRule.runOnIdle { menuFocusRequester.requestFocus() } + composeRule.waitUntil(10_000) { menuItemFocused } + } + + private fun waitForTag(tag: String) { + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun assertTagExists(tag: String) { + assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()) + } + + private fun assertTagDoesNotExist(tag: String) { + assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()) + } + + private fun focusableOverlay( + focusRequester: FocusRequester, + onKeyEvent: (ComposeKeyEvent) -> Boolean = { false } + ): @Composable UIControllerScope.() -> Unit = { + Box( + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .onKeyEvent(onKeyEvent) + .focusable() + ) + } + + private fun mockPlayingPlayer(currentTime: Double = 40.0): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + every { player.isPaused } returns false + every { player.isEnded } returns false + every { player.ads } returns ads + every { player.currentTime } returns currentTime + every { player.duration } returns 100.0 + return player + } + + private fun mockNotPlayedPlayer(): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + val played = mockk(relaxed = true) + every { played.length() } returns 0 + every { player.isPaused } returns true + every { player.played } returns played + every { player.ads } returns ads + return player + } + + private class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: Player + ) : THEOplayerView(context) { + override fun getPlayer(): Player = nativePlayer + } + + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, keyCode) + ) + + private companion object { + const val CONTROLS_AUTO_HIDE_DURATION_MILLIS = 5_000L + } +} From 07ca894d61780f9d0f38ce6cd7ca0893bfdcfbc4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:50:39 +0000 Subject: [PATCH 071/158] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b47ffe..5d1fd2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ * 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) * 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) +* 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) +* 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124)) ## [1.14.2] (2026-10-13) From 040bf9ebd6a072b468b1715d22bfd5ae9712fbb6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:06 +0000 Subject: [PATCH 072/158] Move shared resources and playback time formatting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/PlaybackTimeFormat.kt | 51 ++++++++++++++ .../com/theoplayer/android/ui/theme/Color.kt | 68 +++++++++++++++++++ .../src/main/res/drawable/language.xml | 0 .../src/main/res/drawable/pause.xml | 0 .../src/main/res/drawable/play.xml | 0 .../src/main/res/values/strings.xml | 0 .../theoplayer/android/ui/ChromecastButton.kt | 13 ++-- .../android/ui/ChromecastDisplay.kt | 13 ++-- .../android/ui/CurrentTimeDisplay.kt | 9 +-- .../com/theoplayer/android/ui/ErrorDisplay.kt | 3 +- .../theoplayer/android/ui/FullscreenButton.kt | 9 +-- .../java/com/theoplayer/android/ui/Helper.kt | 46 ++----------- .../com/theoplayer/android/ui/LanguageMenu.kt | 21 +++--- .../android/ui/LanguageMenuButton.kt | 9 +-- .../com/theoplayer/android/ui/LiveButton.kt | 9 +-- .../java/com/theoplayer/android/ui/Menu.kt | 3 +- .../com/theoplayer/android/ui/MuteButton.kt | 9 +-- .../android/ui/PictureInPictureButton.kt | 9 +-- .../com/theoplayer/android/ui/PlayButton.kt | 21 +++--- .../theoplayer/android/ui/PlaybackRateList.kt | 5 +- .../com/theoplayer/android/ui/QualityList.kt | 11 +-- .../com/theoplayer/android/ui/SeekButton.kt | 5 +- .../com/theoplayer/android/ui/SettingsMenu.kt | 19 +++--- .../android/ui/SettingsMenuButton.kt | 3 +- .../android/ui/SubtitleTrackList.kt | 3 +- 25 files changed, 220 insertions(+), 119 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt rename {ui => ui-common}/src/main/res/drawable/language.xml (100%) rename {ui => ui-common}/src/main/res/drawable/pause.xml (100%) rename {ui => ui-common}/src/main/res/drawable/play.xml (100%) rename {ui => ui-common}/src/main/res/values/strings.xml (100%) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt new file mode 100644 index 00000000..dfb38bb0 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt @@ -0,0 +1,51 @@ +package com.theoplayer.android.ui + +import kotlin.math.absoluteValue + +/** + * Formats a playback time as a human-readable string. + */ +@InternalUiApi +fun formatPlaybackTime( + time: Double, + guide: Double = 0.0, + preferNegative: Boolean = false +): String { + val isNegative = time < 0 || (preferNegative && time == 0.0) + val absoluteTime = time.absoluteValue.toInt() + + val guideMinutes = ((guide / 60) % 60).toInt() + val guideHours = (guide / 3600).toInt() + + val result = StringBuilder() + if (isNegative) { + result.append('-') + } + if (time.isNaN() || time.isInfinite()) { + if (guideHours > 0) { + result.append("--:") + } + result.append("--:--") + } else { + val seconds = absoluteTime % 60 + val minutes = (absoluteTime / 60) % 60 + val hours = absoluteTime / 3600 + // Check if we need to show hours. + val showHours = hours > 0 || guideHours > 0 + if (showHours) { + result.append(hours) + result.append(':') + } + // If hours are showing, we may need to add a leading zero to minutes. + // Always show at least one digit of minutes. + val showMinutesLeadingZero = (showHours || guideMinutes >= 0) + if (showMinutesLeadingZero) { + result.append(minutes.toString().padStart(2, '0')) + } else { + result.append(minutes) + } + result.append(':') + result.append(seconds.toString().padStart(2, '0')) + } + return result.toString() +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt new file mode 100644 index 00000000..9c05554a --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -0,0 +1,68 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.ui.graphics.Color + +internal val md_theme_light_primary = Color(0xFF775A00) +internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) +internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) +internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) +internal val md_theme_light_secondary = Color(0xFF6A5D3F) +internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) +internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) +internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) +internal val md_theme_light_tertiary = Color(0xFF496548) +internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) +internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) +internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) +internal val md_theme_light_error = Color(0xFFBA1A1A) +internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) +internal val md_theme_light_onError = Color(0xFFFFFFFF) +internal val md_theme_light_onErrorContainer = Color(0xFF410002) +internal val md_theme_light_background = Color(0xFFFFFBFF) +internal val md_theme_light_onBackground = Color(0xFF1E1B16) +internal val md_theme_light_surface = Color(0xFFFFFBFF) +internal val md_theme_light_onSurface = Color(0xFF1E1B16) +internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) +internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) +internal val md_theme_light_outline = Color(0xFF7E7667) +internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) +internal val md_theme_light_inverseSurface = Color(0xFF33302A) +internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) +internal val md_theme_light_shadow = Color(0xFF000000) +internal val md_theme_light_surfaceTint = Color(0xFF775A00) +internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) +internal val md_theme_light_scrim = Color(0xFF000000) + +internal val md_theme_dark_primary = Color(0xFFFFC50F) +internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) +internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) +internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) +internal val md_theme_dark_secondary = Color(0xFFD7C5A0) +internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) +internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) +internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) +internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) +internal val md_theme_dark_onTertiary = Color(0xFF1C361D) +internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) +internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) +internal val md_theme_dark_error = Color(0xFFFFB4AB) +internal val md_theme_dark_errorContainer = Color(0xFF93000A) +internal val md_theme_dark_onError = Color(0xFF690005) +internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +internal val md_theme_dark_background = Color(0xFF1E1B16) +internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) +internal val md_theme_dark_surface = Color(0xFF1E1B16) +internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) +internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) +internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) +internal val md_theme_dark_outline = Color(0xFF999080) +internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) +internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) +internal val md_theme_dark_inversePrimary = Color(0xFF775A00) +internal val md_theme_dark_shadow = Color(0xFF000000) +internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) +internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) +internal val md_theme_dark_scrim = Color(0xFF000000) + + +private val seed = Color(0xFFFFC50F) diff --git a/ui/src/main/res/drawable/language.xml b/ui-common/src/main/res/drawable/language.xml similarity index 100% rename from ui/src/main/res/drawable/language.xml rename to ui-common/src/main/res/drawable/language.xml diff --git a/ui/src/main/res/drawable/pause.xml b/ui-common/src/main/res/drawable/pause.xml similarity index 100% rename from ui/src/main/res/drawable/pause.xml rename to ui-common/src/main/res/drawable/pause.xml diff --git a/ui/src/main/res/drawable/play.xml b/ui-common/src/main/res/drawable/play.xml similarity index 100% rename from ui/src/main/res/drawable/play.xml rename to ui-common/src/main/res/drawable/play.xml diff --git a/ui/src/main/res/values/strings.xml b/ui-common/src/main/res/values/strings.xml similarity index 100% rename from ui/src/main/res/values/strings.xml rename to ui-common/src/main/res/values/strings.xml diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt index 00908bd1..c2e629c7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Cast @@ -32,19 +33,19 @@ fun ChromecastButton( availableIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_start) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_start) ) }, connectingIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) }, connectedIcon: @Composable () -> Unit = { Icon( Icons.Rounded.CastConnected, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) } ) { @@ -81,19 +82,19 @@ fun ChromecastButton( availableIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_start) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_start) ) }, connectingIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) }, connectedIcon: @Composable () -> Unit = { Icon( Icons.Rounded.CastConnected, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt index 6ff83f8a..bb73e5b2 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -104,11 +105,11 @@ fun ChromecastDisplayCompact( Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver) + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver) ) } } @@ -160,21 +161,21 @@ fun ChromecastDisplayExpanded( Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_first_line, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_first_line, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_first_line), + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_first_line), style = MaterialTheme.typography.bodyLarge ) Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_second_line, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_second_line, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_second_line), + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_second_line), style = MaterialTheme.typography.headlineSmall ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt index d798c68d..37c39dd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -58,9 +59,9 @@ fun CurrentTimeDisplay( val text = if (showDuration) { stringResource( if (showRemaining) { - R.string.theoplayer_ui_current_time_remaining_with_duration + CommonR.string.theoplayer_ui_current_time_remaining_with_duration } else { - R.string.theoplayer_ui_current_time_with_duration + CommonR.string.theoplayer_ui_current_time_with_duration }, formatTime(time, duration, preferNegative = showRemaining), formatTime(duration) @@ -68,9 +69,9 @@ fun CurrentTimeDisplay( } else { stringResource( if (showRemaining) { - R.string.theoplayer_ui_current_time_remaining + CommonR.string.theoplayer_ui_current_time_remaining } else { - R.string.theoplayer_ui_current_time + CommonR.string.theoplayer_ui_current_time }, formatTime(time, duration, preferNegative = showRemaining), ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt index 28394ed7..81ed05d7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -72,7 +73,7 @@ private fun ErrorDisplayContent(error: THEOplayerException, modifier: Modifier) contentAlignment = Alignment.CenterStart ) { Text( - text = stringResource(R.string.theoplayer_ui_error_title), + text = stringResource(CommonR.string.theoplayer_ui_error_title), style = MaterialTheme.typography.headlineMedium ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt index a28f1f06..ca446b5b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Fullscreen @@ -26,13 +27,13 @@ fun FullscreenButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_enter) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_enter) ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.FullscreenExit, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_exit) ) } ) { @@ -64,13 +65,13 @@ fun FullscreenButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_enter) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_enter) ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.FullscreenExit, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_exit) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt index aa6a4852..2b00abd1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource @@ -7,7 +8,6 @@ import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.ui.util.constructLabel -import kotlin.math.absoluteValue /** * Return the given time in a human-readable format, such as `"0:10"` or `"01:23:45"`. @@ -21,43 +21,7 @@ import kotlin.math.absoluteValue * for example because it represents the time remaining in the video. */ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = false): String { - val isNegative = time < 0 || (preferNegative && time == 0.0) - val absoluteTime = time.absoluteValue.toInt() - - val guideMinutes = ((guide / 60) % 60).toInt() - val guideHours = (guide / 3600).toInt() - - val result = StringBuilder() - if (isNegative) { - result.append('-') - } - if (time.isNaN() || time.isInfinite()) { - if (guideHours > 0) { - result.append("--:") - } - result.append("--:--") - } else { - val seconds = absoluteTime % 60 - val minutes = (absoluteTime / 60) % 60 - val hours = absoluteTime / 3600 - // Check if we need to show hours. - val showHours = hours > 0 || guideHours > 0 - if (showHours) { - result.append(hours) - result.append(':') - } - // If hours are showing, we may need to add a leading zero to minutes. - // Always show at least one digit of minutes. - val showMinutesLeadingZero = (showHours || guideMinutes >= 0) - if (showMinutesLeadingZero) { - result.append(minutes.toString().padStart(2, '0')) - } else { - result.append(minutes) - } - result.append(':') - result.append(seconds.toString().padStart(2, '0')) - } - return result.toString() + return formatPlaybackTime(time, guide, preferNegative) } /** @@ -73,7 +37,7 @@ fun formatTrackLabel(track: Track): String { is MediaTrack<*> -> constructLabel(track) else -> null } - } ?: stringResource(R.string.theoplayer_ui_track_unknown) + } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } /** @@ -84,7 +48,7 @@ fun formatTrackLabel(track: Track): String { @Composable fun formatTrackLabel(track: MediaTrack<*>): String { return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(R.string.theoplayer_ui_track_unknown) + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } /** @@ -95,5 +59,5 @@ fun formatTrackLabel(track: MediaTrack<*>): String { @Composable fun formatTrackLabel(track: TextTrack): String { return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(R.string.theoplayer_ui_track_unknown) + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index a65d8a03..bf7fef07 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,11 +51,11 @@ fun MenuScope.LanguageMenu( subtitleTrackState: SubtitleTrackState ) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_language)) }, + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_language)) }, backIcon = { Icon( Icons.Rounded.Close, - contentDescription = stringResource(R.string.theoplayer_ui_btn_menu_close) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_menu_close) ) }, ) { @@ -122,7 +123,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_audio) + text = stringResource(CommonR.string.theoplayer_ui_menu_audio) ) TextButton( modifier = Modifier @@ -134,7 +135,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier.weight(1f), text = audioTrackState.activeAudioTrack?.let { formatTrackLabel(it) } ?: stringResource( - R.string.theoplayer_ui_audio_none + CommonR.string.theoplayer_ui_audio_none ), textAlign = TextAlign.Center ) @@ -151,7 +152,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_subtitles) + text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles) ) TextButton( modifier = Modifier @@ -162,7 +163,7 @@ fun MenuScope.LanguageMenuCompact( Text( modifier = Modifier.weight(1f), text = subtitleTrackState.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( - R.string.theoplayer_ui_subtitles_off + CommonR.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center ) @@ -217,7 +218,7 @@ fun MenuScope.LanguageMenuExpanded( modifier = Modifier .fillMaxWidth(1f) .padding(0.dp, 8.dp), - text = stringResource(R.string.theoplayer_ui_menu_audio), + text = stringResource(CommonR.string.theoplayer_ui_menu_audio), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) @@ -230,7 +231,7 @@ fun MenuScope.LanguageMenuExpanded( modifier = Modifier .fillMaxWidth(1f) .padding(0.dp, 8.dp), - text = stringResource(R.string.theoplayer_ui_menu_subtitles), + text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) @@ -264,7 +265,7 @@ fun MenuScope.AudioTrackMenu() { @Composable fun MenuScope.AudioTrackMenu(state: AudioTrackState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_audio)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) } ) { AudioTrackList(state, onClick = { closeCurrentMenu() }) } @@ -294,7 +295,7 @@ fun MenuScope.SubtitleMenu() { @Composable fun MenuScope.SubtitleMenu(state: SubtitleTrackState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_subtitles)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) } ) { SubtitleTrackList(state, onClick = { closeCurrentMenu() }) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt index 312c0192..f3b0ed60 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -21,8 +22,8 @@ fun MenuScope.LanguageMenuButton( contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.language), - contentDescription = stringResource(R.string.theoplayer_ui_menu_language) + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) ) } ) { @@ -57,8 +58,8 @@ fun MenuScope.LanguageMenuButton( contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.language), - contentDescription = stringResource(R.string.theoplayer_ui_menu_language) + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) ) } ) { 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 289ceedc..03c7dc50 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -58,7 +59,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) }, dvr: @Composable RowScope. () -> Unit = { @@ -76,7 +77,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) } ) { @@ -127,7 +128,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) }, dvr: @Composable RowScope. () -> Unit = { @@ -145,7 +146,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt index fd2e412a..cc28088f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -34,7 +35,7 @@ fun MenuScope.Menu( backIcon: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.theoplayer_ui_btn_back) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_back) ) }, content: @Composable () -> Unit, diff --git a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt index 8232c740..749c0c3b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.VolumeOff @@ -26,13 +27,13 @@ fun MuteButton( mute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeUp, - contentDescription = stringResource(R.string.theoplayer_ui_btn_mute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_mute) ) }, unmute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = stringResource(R.string.theoplayer_ui_btn_unmute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_unmute) ) } ) { @@ -64,13 +65,13 @@ fun MuteButton( mute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeUp, - contentDescription = stringResource(R.string.theoplayer_ui_btn_mute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_mute) ) }, unmute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = stringResource(R.string.theoplayer_ui_btn_unmute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_unmute) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt index 9c24524e..df831828 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Fullscreen @@ -31,13 +32,13 @@ fun PictureInPictureButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.PictureInPictureAlt, - contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_enter), ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_exit) ) } ) { @@ -74,13 +75,13 @@ fun PictureInPictureButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.PictureInPictureAlt, - contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_enter), ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_exit) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt index c039b516..1f42de81 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Replay @@ -28,23 +29,23 @@ fun PlayButton( contentPadding: PaddingValues = PaddingValues(0.dp), play: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.play), + painter = painterResource(id = CommonR.drawable.play), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_play) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_play) ) }, pause: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.pause), + painter = painterResource(id = CommonR.drawable.pause), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_pause) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_pause) ) }, replay: @Composable () -> Unit = { Icon( Icons.Rounded.Replay, modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_replay) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_replay) ) } ) { @@ -80,23 +81,23 @@ fun PlayButton( contentPadding: PaddingValues = PaddingValues(0.dp), play: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.play), + painter = painterResource(id = CommonR.drawable.play), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_play) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_play) ) }, pause: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.pause), + painter = painterResource(id = CommonR.drawable.pause), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_pause) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_pause) ) }, replay: @Composable () -> Unit = { Icon( Icons.Rounded.Replay, modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_replay) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_replay) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index c2369ff1..9a21bfd4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -90,9 +91,9 @@ fun PlaybackRateList( @Composable internal fun formatPlaybackRate(rate: Double): String { return if (rate == 1.0) { - stringResource(R.string.theoplayer_ui_playback_rate_normal, rate) + stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) } else { - val format = stringResource(R.string.theoplayer_ui_playback_rate_format) + val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) val decimalFormat = remember(format) { DecimalFormat(format) } decimalFormat.format(rate) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 2c390cb6..77898c73 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -56,7 +57,7 @@ fun QualityList( ) { item(key = null) { ListItem( - headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_quality_automatic)) }, + headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_quality_automatic)) }, leadingContent = { RadioButton( selected = (state.targetVideoQuality == null), @@ -82,7 +83,7 @@ fun QualityList( headlineContent = { Text( text = stringResource( - R.string.theoplayer_ui_quality_with_height, + CommonR.string.theoplayer_ui_quality_with_height, quality.height ) ) @@ -111,11 +112,11 @@ fun QualityList( internal fun formatBandwidth(bandwidth: Long): String { val format = stringResource( if (bandwidth >= 1e7) { - R.string.theoplayer_ui_bandwidth_format_10mbps + CommonR.string.theoplayer_ui_bandwidth_format_10mbps } else if (bandwidth >= 1e6) { - R.string.theoplayer_ui_bandwidth_format_1mbps + CommonR.string.theoplayer_ui_bandwidth_format_1mbps } else { - R.string.theoplayer_ui_bandwidth_format_kbps + CommonR.string.theoplayer_ui_bandwidth_format_kbps } ) val decimalFormat = remember(format) { DecimalFormat(format) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index e986916d..9069ec98 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.theoplayer.android.ui.common.R as CommonR import kotlin.math.absoluteValue /** @@ -52,9 +53,9 @@ fun SeekButton( .scale(scaleX = if (seekOffset >= 0) -1f else 1f, scaleY = 1f), contentDescription = pluralStringResource( if (seekOffset >= 0) { - R.plurals.theoplayer_ui_btn_seek_forward + CommonR.plurals.theoplayer_ui_btn_seek_forward } else { - R.plurals.theoplayer_ui_btn_seek_backward + CommonR.plurals.theoplayer_ui_btn_seek_backward }, seekOffset.absoluteValue, seekOffset.absoluteValue diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 1c3aada3..74beede0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -47,11 +48,11 @@ fun MenuScope.SettingsMenu( playbackRateState: PlaybackRateState ) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_settings)) }, + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_settings)) }, backIcon = { Icon( Icons.Rounded.Close, - contentDescription = stringResource(R.string.theoplayer_ui_btn_menu_close) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_menu_close) ) }, ) { @@ -61,7 +62,7 @@ fun MenuScope.SettingsMenu( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_quality) + text = stringResource(CommonR.string.theoplayer_ui_menu_quality) ) TextButton( modifier = Modifier @@ -88,7 +89,7 @@ fun MenuScope.SettingsMenu( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_playback_rate) + text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate) ) TextButton( modifier = Modifier @@ -135,7 +136,7 @@ fun MenuScope.QualityMenu() { @Composable fun MenuScope.QualityMenu(state: VideoQualityState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_quality)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) } ) { QualityList(state, onClick = { closeCurrentMenu() }) } @@ -165,7 +166,7 @@ fun MenuScope.PlaybackRateMenu() { @Composable fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_playback_rate)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) } ) { PlaybackRateList(state, onClick = { closeCurrentMenu() }) } @@ -178,15 +179,15 @@ internal fun formatActiveQualityLabel( ): String { return if (targetVideoQuality != null) { stringResource( - R.string.theoplayer_ui_quality_with_height, + CommonR.string.theoplayer_ui_quality_with_height, targetVideoQuality.height ) } else if (activeVideoQuality != null) { stringResource( - R.string.theoplayer_ui_quality_automatic_with_height, + CommonR.string.theoplayer_ui_quality_automatic_with_height, activeVideoQuality.height ) } else { - stringResource(R.string.theoplayer_ui_quality_automatic) + stringResource(CommonR.string.theoplayer_ui_quality_automatic) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt index 7a10fd04..48594cd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Settings @@ -23,7 +24,7 @@ fun MenuScope.SettingsMenuButton( content: @Composable () -> Unit = { Icon( Icons.Rounded.Settings, - contentDescription = stringResource(R.string.theoplayer_ui_menu_settings) + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_settings) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index a9d49d07..981cfed1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -54,7 +55,7 @@ fun SubtitleTrackList( ) { item(key = null) { ListItem( - headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_subtitles_off)) }, + headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_subtitles_off)) }, leadingContent = { RadioButton( selected = (state.activeSubtitleTrack == null), From d7233e7e250f2eee7874190eb919f96290be183f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:37 +0000 Subject: [PATCH 073/158] Share THEOplayer palette and player colors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/theme/Color.kt | 184 ++++++++++++------ .../android/ui/theme/PlayerColors.kt | 27 +++ .../theoplayer/android/ui/tv/theme/Theme.kt | 76 +++++++- .../com/theoplayer/android/ui/theme/Color.kt | 68 ------- .../com/theoplayer/android/ui/theme/Theme.kt | 21 -- 5 files changed, 221 insertions(+), 155 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt delete mode 100644 ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt index 9c05554a..65cc7d12 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -1,68 +1,126 @@ package com.theoplayer.android.ui.theme import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi -internal val md_theme_light_primary = Color(0xFF775A00) -internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) -internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) -internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) -internal val md_theme_light_secondary = Color(0xFF6A5D3F) -internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) -internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) -internal val md_theme_light_tertiary = Color(0xFF496548) -internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) -internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) -internal val md_theme_light_error = Color(0xFFBA1A1A) -internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) -internal val md_theme_light_onError = Color(0xFFFFFFFF) -internal val md_theme_light_onErrorContainer = Color(0xFF410002) -internal val md_theme_light_background = Color(0xFFFFFBFF) -internal val md_theme_light_onBackground = Color(0xFF1E1B16) -internal val md_theme_light_surface = Color(0xFFFFFBFF) -internal val md_theme_light_onSurface = Color(0xFF1E1B16) -internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) -internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) -internal val md_theme_light_outline = Color(0xFF7E7667) -internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) -internal val md_theme_light_inverseSurface = Color(0xFF33302A) -internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) -internal val md_theme_light_shadow = Color(0xFF000000) -internal val md_theme_light_surfaceTint = Color(0xFF775A00) -internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) -internal val md_theme_light_scrim = Color(0xFF000000) +@InternalUiApi +val md_theme_light_primary = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_primaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_light_onPrimaryContainer = Color(0xFF251A00) +@InternalUiApi +val md_theme_light_secondary = Color(0xFF6A5D3F) +@InternalUiApi +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_light_onSecondaryContainer = Color(0xFF241A04) +@InternalUiApi +val md_theme_light_tertiary = Color(0xFF496548) +@InternalUiApi +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_light_onTertiaryContainer = Color(0xFF06210A) +@InternalUiApi +val md_theme_light_error = Color(0xFFBA1A1A) +@InternalUiApi +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_light_onError = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_onErrorContainer = Color(0xFF410002) +@InternalUiApi +val md_theme_light_background = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onBackground = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surface = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surfaceVariant = Color(0xFFECE1CF) +@InternalUiApi +val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_light_outline = Color(0xFF7E7667) +@InternalUiApi +val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) +@InternalUiApi +val md_theme_light_inverseSurface = Color(0xFF33302A) +@InternalUiApi +val md_theme_light_inversePrimary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_light_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_light_surfaceTint = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_outlineVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_light_scrim = Color(0xFF000000) -internal val md_theme_dark_primary = Color(0xFFFFC50F) -internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) -internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) -internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) -internal val md_theme_dark_secondary = Color(0xFFD7C5A0) -internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) -internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) -internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) -internal val md_theme_dark_onTertiary = Color(0xFF1C361D) -internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) -internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_dark_error = Color(0xFFFFB4AB) -internal val md_theme_dark_errorContainer = Color(0xFF93000A) -internal val md_theme_dark_onError = Color(0xFF690005) -internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -internal val md_theme_dark_background = Color(0xFF1E1B16) -internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) -internal val md_theme_dark_surface = Color(0xFF1E1B16) -internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) -internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) -internal val md_theme_dark_outline = Color(0xFF999080) -internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) -internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_inversePrimary = Color(0xFF775A00) -internal val md_theme_dark_shadow = Color(0xFF000000) -internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) -internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) -internal val md_theme_dark_scrim = Color(0xFF000000) - - -private val seed = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_primary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_onPrimary = Color(0xFF3F2E00) +@InternalUiApi +val md_theme_dark_primaryContainer = Color(0xFF5A4300) +@InternalUiApi +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_dark_secondary = Color(0xFFD7C5A0) +@InternalUiApi +val md_theme_dark_onSecondary = Color(0xFF3A2F15) +@InternalUiApi +val md_theme_dark_secondaryContainer = Color(0xFF52452A) +@InternalUiApi +val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_dark_tertiary = Color(0xFFAFCFAB) +@InternalUiApi +val md_theme_dark_onTertiary = Color(0xFF1C361D) +@InternalUiApi +val md_theme_dark_tertiaryContainer = Color(0xFF324D32) +@InternalUiApi +val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_dark_error = Color(0xFFFFB4AB) +@InternalUiApi +val md_theme_dark_errorContainer = Color(0xFF93000A) +@InternalUiApi +val md_theme_dark_onError = Color(0xFF690005) +@InternalUiApi +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_dark_background = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onBackground = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_dark_outline = Color(0xFF999080) +@InternalUiApi +val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_inversePrimary = Color(0xFF775A00) +@InternalUiApi +val md_theme_dark_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_dark_surfaceTint = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_outlineVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt new file mode 100644 index 00000000..467ff558 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt @@ -0,0 +1,27 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi + +/** + * Additional colors used by THEOplayer UI components. + */ +@Immutable +data class PlayerColors( + /** + * The color of the circle icon of a live button when playing at the live point. + */ + val liveButtonLive: Color = Color.Red, + /** + * The color of the circle icon of a live button when playing behind the live point. + */ + val liveButtonDvr: Color = Color.Gray +) + +/** + * Player component colors shared by UI modules. + */ +@InternalUiApi +val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt index 347fe822..ee8e43cc 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt @@ -6,25 +6,95 @@ import androidx.tv.material3.ColorScheme import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Typography import androidx.tv.material3.darkColorScheme +import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.tv.ExperimentalTvUiApi import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.theme.LocalPlayerColors import com.theoplayer.android.ui.theme.PlayerAnimations +import com.theoplayer.android.ui.theme.PlayerColors +import com.theoplayer.android.ui.theme.md_theme_dark_background +import com.theoplayer.android.ui.theme.md_theme_dark_error +import com.theoplayer.android.ui.theme.md_theme_dark_errorContainer +import com.theoplayer.android.ui.theme.md_theme_dark_inverseOnSurface +import com.theoplayer.android.ui.theme.md_theme_dark_inversePrimary +import com.theoplayer.android.ui.theme.md_theme_dark_inverseSurface +import com.theoplayer.android.ui.theme.md_theme_dark_onBackground +import com.theoplayer.android.ui.theme.md_theme_dark_onError +import com.theoplayer.android.ui.theme.md_theme_dark_onErrorContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onPrimary +import com.theoplayer.android.ui.theme.md_theme_dark_onPrimaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onSecondary +import com.theoplayer.android.ui.theme.md_theme_dark_onSecondaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onSurface +import com.theoplayer.android.ui.theme.md_theme_dark_onSurfaceVariant +import com.theoplayer.android.ui.theme.md_theme_dark_onTertiary +import com.theoplayer.android.ui.theme.md_theme_dark_onTertiaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_outline +import com.theoplayer.android.ui.theme.md_theme_dark_outlineVariant +import com.theoplayer.android.ui.theme.md_theme_dark_primary +import com.theoplayer.android.ui.theme.md_theme_dark_primaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_scrim +import com.theoplayer.android.ui.theme.md_theme_dark_secondary +import com.theoplayer.android.ui.theme.md_theme_dark_secondaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_surface +import com.theoplayer.android.ui.theme.md_theme_dark_surfaceVariant +import com.theoplayer.android.ui.theme.md_theme_dark_tertiary +import com.theoplayer.android.ui.theme.md_theme_dark_tertiaryContainer import kotlin.time.Duration.Companion.seconds +private val THEOplayerDarkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + inversePrimary = md_theme_dark_inversePrimary, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + border = md_theme_dark_outline, + borderVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim +) + /** - * Provides TV Material theme values and animations to a player UI. + * Provides THEOplayer's TV color scheme, player colors and animations to a player UI. * + * @param colorScheme TV Material colors for this hierarchy + * @param typography TV Material typography for this hierarchy + * @param playerColors additional colors used by THEOplayer UI components * @param playerAnimations animation settings for the TV player UI */ @ExperimentalTvUiApi +@OptIn(InternalUiApi::class) @Composable fun THEOplayerTvTheme( - colorScheme: ColorScheme = darkColorScheme(), + colorScheme: ColorScheme = THEOplayerDarkColorScheme, typography: Typography = MaterialTheme.typography, + playerColors: PlayerColors = PlayerColors(), playerAnimations: PlayerAnimations = PlayerAnimations(controlsHideDelay = 5.seconds), content: @Composable () -> Unit ) { - CompositionLocalProvider(LocalPlayerAnimations provides playerAnimations) { + CompositionLocalProvider( + LocalPlayerColors provides playerColors, + LocalPlayerAnimations provides playerAnimations + ) { MaterialTheme( colorScheme = colorScheme, typography = typography, diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt deleted file mode 100644 index 9c05554a..00000000 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.theoplayer.android.ui.theme - -import androidx.compose.ui.graphics.Color - -internal val md_theme_light_primary = Color(0xFF775A00) -internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) -internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) -internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) -internal val md_theme_light_secondary = Color(0xFF6A5D3F) -internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) -internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) -internal val md_theme_light_tertiary = Color(0xFF496548) -internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) -internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) -internal val md_theme_light_error = Color(0xFFBA1A1A) -internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) -internal val md_theme_light_onError = Color(0xFFFFFFFF) -internal val md_theme_light_onErrorContainer = Color(0xFF410002) -internal val md_theme_light_background = Color(0xFFFFFBFF) -internal val md_theme_light_onBackground = Color(0xFF1E1B16) -internal val md_theme_light_surface = Color(0xFFFFFBFF) -internal val md_theme_light_onSurface = Color(0xFF1E1B16) -internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) -internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) -internal val md_theme_light_outline = Color(0xFF7E7667) -internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) -internal val md_theme_light_inverseSurface = Color(0xFF33302A) -internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) -internal val md_theme_light_shadow = Color(0xFF000000) -internal val md_theme_light_surfaceTint = Color(0xFF775A00) -internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) -internal val md_theme_light_scrim = Color(0xFF000000) - -internal val md_theme_dark_primary = Color(0xFFFFC50F) -internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) -internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) -internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) -internal val md_theme_dark_secondary = Color(0xFFD7C5A0) -internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) -internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) -internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) -internal val md_theme_dark_onTertiary = Color(0xFF1C361D) -internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) -internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_dark_error = Color(0xFFFFB4AB) -internal val md_theme_dark_errorContainer = Color(0xFF93000A) -internal val md_theme_dark_onError = Color(0xFF690005) -internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -internal val md_theme_dark_background = Color(0xFF1E1B16) -internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) -internal val md_theme_dark_surface = Color(0xFF1E1B16) -internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) -internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) -internal val md_theme_dark_outline = Color(0xFF999080) -internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) -internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_inversePrimary = Color(0xFF775A00) -internal val md_theme_dark_shadow = Color(0xFF000000) -internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) -internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) -internal val md_theme_dark_scrim = Color(0xFF000000) - - -private val seed = Color(0xFFFFC50F) diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt index 94d0e668..dea694d0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt @@ -9,10 +9,6 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color -import com.theoplayer.android.ui.LiveButton private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -79,23 +75,6 @@ private val DarkColors = darkColorScheme( scrim = md_theme_dark_scrim, ) -/** - * Additional colors used by THEOplayer UI components. - */ -@Immutable -data class PlayerColors( - /** - * The color of the circle icon of a [LiveButton] when playing at the live point. - */ - val liveButtonLive: Color = Color.Red, - /** - * The color of the circle icon of a [LiveButton] when playing behind the live point. - */ - val liveButtonDvr: Color = Color.Gray -) - -private val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } - /** * Provides theme colors, shapes and fonts to be used by the player. * From 5a5cdc95fd1b1888259ea43de905508e2bc5ebeb Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:48 +0000 Subject: [PATCH 074/158] Add direct seek operation to SeekBarState Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/SeekBarState.kt | 8 +++++++ .../theoplayer/android/ui/SeekBarStateTest.kt | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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 45e76b87..18f9cf24 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 @@ -116,6 +116,14 @@ class SeekBarState internal constructor(private val player: Player?) { } } + /** + * Seeks to a position within [valueRange] without changing playback state. + */ + fun seekTo(value: Float) { + if (!enabled) return + player?.currentTime = seekableRange.start + value.toDouble() + } + /** * Called when the user stops dragging the seek bar. Resumes playback if the player was * playing before the drag started. 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 3059b206..681de2fd 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 @@ -76,6 +76,30 @@ class SeekBarStateTest { verify(exactly = 1) { mock.player.play() } } + @Test + fun `seekTo seeks without changing playback state`() { + val mock = MockPlayer(paused = false, currentTime = 50.0) + val state = SeekBarState(mock.player) + + state.seekTo(60f) + + verify { mock.player.currentTime = 60.0 } + verify(exactly = 0) { mock.player.pause() } + verify(exactly = 0) { mock.player.play() } + } + + @Test + fun `seekTo does nothing when disabled or player is missing`() { + val mock = MockPlayer(seekableRanges = emptyList()) + + SeekBarState(mock.player).seekTo(60f) + SeekBarState(null).seekTo(60f) + + verify(exactly = 0) { mock.player.currentTime = any() } + verify(exactly = 0) { mock.player.pause() } + verify(exactly = 0) { mock.player.play() } + } + @Test fun `scrubbing a paused player does not resume it`() = runTest { val mock = MockPlayer(paused = true, currentTime = 10.0) From 05e18a5b033cb05db8db5b4d07583cad88d833a0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:01:16 +0000 Subject: [PATCH 075/158] Add Android TV playback controls and tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/build.gradle.kts | 1 + .../android/ui/tv/CurrentTimeDisplay.kt | 71 ++++++ .../android/ui/tv/DurationDisplay.kt | 35 +++ .../theoplayer/android/ui/tv/ErrorDisplay.kt | 74 ++++++ .../theoplayer/android/ui/tv/LiveButton.kt | 103 ++++++++ .../android/ui/tv/LoadingSpinner.kt | 106 +++++++++ .../theoplayer/android/ui/tv/PlayButton.kt | 106 +++++++++ .../com/theoplayer/android/ui/tv/SeekBar.kt | 225 ++++++++++++++++++ .../android/ui/tv/PlayButtonTest.kt | 118 +++++++++ .../theoplayer/android/ui/tv/SeekBarTest.kt | 213 +++++++++++++++++ .../theoplayer/android/ui/tv/TestPlayer.kt | 129 ++++++++++ .../android/ui/tv/UIControllerTest.kt | 24 +- 12 files changed, 1189 insertions(+), 16 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts index cf65d3ad..18cc1c76 100644 --- a/ui-tv/build.gradle.kts +++ b/ui-tv/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(libs.androidx.tv.material) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.ui) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.ui.toolingPreview) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt new file mode 100644 index 00000000..8294d826 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt @@ -0,0 +1,71 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackTimeState +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.formatPlaybackTime +import com.theoplayer.android.ui.rememberPlaybackTimeState + +/** + * Displays the player's current time. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun CurrentTimeDisplay( + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false +) { + CurrentTimeDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier, + showRemaining = showRemaining, + showDuration = showDuration + ) +} + +/** + * Displays playback time from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun CurrentTimeDisplay( + state: PlaybackTimeState, + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false +) { + val currentTime = state.currentTime + val duration = state.duration + val time = if (showRemaining) -(duration - currentTime) else currentTime + val text = if (showDuration) { + stringResource( + if (showRemaining) { + R.string.theoplayer_ui_current_time_remaining_with_duration + } else { + R.string.theoplayer_ui_current_time_with_duration + }, + formatPlaybackTime(time, duration, preferNegative = showRemaining), + formatPlaybackTime(duration) + ) + } else { + stringResource( + if (showRemaining) { + R.string.theoplayer_ui_current_time_remaining + } else { + R.string.theoplayer_ui_current_time + }, + formatPlaybackTime(time, duration, preferNegative = showRemaining) + ) + } + Text(modifier = modifier, text = text) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt new file mode 100644 index 00000000..164dff4c --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt @@ -0,0 +1,35 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackTimeState +import com.theoplayer.android.ui.formatPlaybackTime +import com.theoplayer.android.ui.rememberPlaybackTimeState + +/** + * Displays the player's duration. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun DurationDisplay(modifier: Modifier = Modifier) { + DurationDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier + ) +} + +/** + * Displays the duration from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun DurationDisplay(state: PlaybackTimeState, modifier: Modifier = Modifier) { + Text(modifier = modifier, text = formatPlaybackTime(state.duration)) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt new file mode 100644 index 00000000..06ad8e11 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt @@ -0,0 +1,74 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.ui.ErrorState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.LocalUIControllerErrorState +import com.theoplayer.android.ui.Player +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberErrorState + +/** + * Displays a fatal player error, if one occurs. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun ErrorDisplay(modifier: Modifier = Modifier) { + ErrorDisplay( + state = LocalUIControllerErrorState.current + ?: rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), + modifier = modifier + ) +} + +/** + * Displays the error held by [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun ErrorDisplay(state: ErrorState, modifier: Modifier = Modifier) { + state.error?.let { ErrorDisplayContent(it, modifier) } +} + +@Composable +private fun ErrorDisplayContent(error: THEOplayerException, modifier: Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + Icons.Rounded.Error, + modifier = Modifier.size(48.dp), + contentDescription = null + ) + Column { + Box( + modifier = Modifier.defaultMinSize(minHeight = 48.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = stringResource(R.string.theoplayer_ui_error_title), + style = MaterialTheme.typography.headlineMedium + ) + } + Text(text = "${error.message}") + } + } +} 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 new file mode 100644 index 00000000..75bb6900 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt @@ -0,0 +1,103 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonColors +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LiveButtonState +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberLiveButtonState +import com.theoplayer.android.ui.theme.LocalPlayerColors + +/** + * Shows live status and seeks to the live point when clicked. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun LiveButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + colors: ButtonColors = ButtonDefaults.colors(), + liveThreshold: Double = 10.0, + live: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonLive) + }, + dvr: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonDvr) + } +) { + LiveButton( + state = rememberLiveButtonState(LocalTHEOplayerView.current?.player, liveThreshold), + modifier = modifier, + contentPadding = contentPadding, + colors = colors, + live = live, + dvr = dvr + ) +} + +/** + * Shows live status from [state] and seeks to the live point when clicked. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun LiveButton( + state: LiveButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + colors: ButtonColors = ButtonDefaults.colors(), + live: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonLive) + }, + dvr: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonDvr) + } +) { + if (state.visible) { + Button( + onClick = state::onClick, + modifier = modifier, + contentPadding = contentPadding, + colors = colors + ) { + if (state.isLive) live() else dvr() + } + } +} + +@Composable +private fun RowScope.LiveButtonContent(color: Color) { + Icon( + Icons.Rounded.Circle, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically), + tint = color, + contentDescription = null + ) + Spacer(Modifier.width(4.dp)) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(R.string.theoplayer_ui_btn_live) + ) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt new file mode 100644 index 00000000..9e89fa0a --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -0,0 +1,106 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LoadingState +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.rememberLoadingState +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import kotlin.time.Duration +import kotlinx.coroutines.delay as delayMillis + +/** + * Shows a spinner while the player is loading. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun LoadingSpinner( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp, + delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay +) { + LoadingSpinner( + state = rememberLoadingState(LocalTHEOplayerView.current?.player), + modifier = modifier, + color = color, + strokeWidth = strokeWidth, + delay = delay + ) +} + +/** + * Shows a spinner while [state] is loading. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun LoadingSpinner( + state: LoadingState, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp, + delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay +) { + val inspectionMode = LocalInspectionMode.current + var delayElapsed by remember(state, delay) { mutableStateOf(false) } + LaunchedEffect(state.loading, delay) { + delayElapsed = false + if (state.loading && !inspectionMode) { + delayMillis(delay.inWholeMilliseconds.coerceAtLeast(0)) + delayElapsed = true + } + } + + if (state.loading && (inspectionMode || delayElapsed)) { + val transition = rememberInfiniteTransition(label = "loading-spinner") + val rotation by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1_000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + Canvas(modifier = modifier.size(40.dp)) { + val stroke = strokeWidth.toPx() + val inset = stroke / 2 + drawArc( + color = color, + startAngle = rotation - 90f, + sweepAngle = 270f, + useCenter = false, + topLeft = Offset(inset, inset), + size = Size(size.width - stroke, size.height - stroke), + style = Stroke(width = stroke, cap = StrokeCap.Round) + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt new file mode 100644 index 00000000..edc27511 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt @@ -0,0 +1,106 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlayButtonState +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberPlayButtonState + +/** + * A button that toggles playback. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun PlayButton( + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + play: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.play), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_play) + ) + }, + pause: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.pause), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_pause) + ) + }, + replay: @Composable () -> Unit = { + Icon( + Icons.Rounded.Replay, + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_replay) + ) + } +) { + PlayButton( + state = rememberPlayButtonState(LocalTHEOplayerView.current?.player), + modifier = modifier, + iconModifier = iconModifier, + contentPadding = contentPadding, + play = play, + pause = pause, + replay = replay + ) +} + +/** + * A button that toggles playback using [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun PlayButton( + state: PlayButtonState, + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + play: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.play), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_play) + ) + }, + pause: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.pause), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_pause) + ) + }, + replay: @Composable () -> Unit = { + Icon( + Icons.Rounded.Replay, + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_replay) + ) + } +) { + IconButton(onClick = state::onClick, modifier = modifier) { + Box(Modifier.padding(contentPadding)) { + when { + !state.paused -> pause() + state.ended -> replay() + else -> play() + } + } + } +} 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 new file mode 100644 index 00000000..6b19cbaa --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -0,0 +1,225 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +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 kotlin.math.max + +/** + * Displays the player's seek bar. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun SeekBar( + modifier: Modifier = Modifier, + colors: SeekBarColors = SeekBarDefaults.colors(), + seekOffset: Int = 10 +) { + SeekBar( + state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + modifier = modifier, + colors = colors, + seekOffset = seekOffset + ) +} + +/** + * Displays the seek bar from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun SeekBar( + state: SeekBarState, + modifier: Modifier = Modifier, + colors: SeekBarColors = SeekBarDefaults.colors(), + seekOffset: Int = 10 +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val thumbRadius by animateDpAsState( + targetValue = if (focused) 8.dp else 4.dp, + label = "seekbar-thumb-radius" + ) + val range = state.valueRange + + Box( + modifier = modifier + .fillMaxWidth() + .height(16.dp) + .semantics { + progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) + setProgress { value -> + state.seekTo(value.coerceIn(range)) + true + } + } + .seekBarKeyEvents(state, seekOffset) + .focusable(interactionSource = interactionSource) + ) { + Canvas(Modifier.fillMaxSize()) { + drawSeekBar(state, colors, thumbRadius) + } + } +} + +@Composable +private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Modifier { + var isScrubbing by remember(state) { mutableStateOf(false) } + var scrubValue by remember(state) { mutableStateOf(0f) } + val range = state.valueRange + + fun finishScrubbing() { + if (isScrubbing) { + state.onValueChangeFinished() + isScrubbing = false + } + } + + return onFocusChanged { + if (!it.isFocused) finishScrubbing() + }.onKeyEvent { event -> + val direction = when (event.key) { + Key.DirectionLeft -> -1 + Key.DirectionRight -> 1 + else -> return@onKeyEvent false + } + if (!state.enabled && !isScrubbing) return@onKeyEvent false + + when (event.type) { + KeyEventType.KeyDown -> { + if (event.nativeKeyEvent.repeatCount == 0) { + state.seekTo((state.value + direction * seekOffset).coerceIn(range)) + } else { + if (!isScrubbing) { + scrubValue = state.value + isScrubbing = true + } + val step = (range.endInclusive - range.start) * 0.01f + scrubValue = (scrubValue + direction * step).coerceIn(range) + state.onValueChange(scrubValue) + } + true + } + + KeyEventType.KeyUp -> { + finishScrubbing() + true + } + + else -> false + } + } +} + +private fun DrawScope.drawSeekBar( + state: SeekBarState, + colors: SeekBarColors, + thumbRadius: Dp +) { + val range = state.valueRange + val edge = 8.dp.toPx() + val start = edge + val end = max(start, size.width - edge) + val width = end - start + val valueWidth = (range.endInclusive - range.start).coerceAtLeast(0f) + val fraction = if (valueWidth == 0f) { + 0f + } else { + ((state.value - range.start) / valueWidth).coerceIn(0f, 1f) + } + val thumbX = start + width * fraction + val centerY = size.height / 2 + val trackWidth = 4.dp.toPx() + + drawLine( + color = colors.inactiveTrackColor, + start = Offset(start, centerY), + end = Offset(end, centerY), + strokeWidth = trackWidth, + cap = StrokeCap.Round + ) + drawLine( + color = colors.activeTrackColor, + start = Offset(start, centerY), + end = Offset(thumbX, centerY), + strokeWidth = trackWidth, + cap = StrokeCap.Round + ) + if (state.showThumb) { + drawCircle( + color = colors.thumbColor, + radius = thumbRadius.toPx(), + center = Offset(thumbX, centerY) + ) + } +} + +/** + * Colors used to draw a TV seek bar. + */ +@ExperimentalTvUiApi +@Immutable +class SeekBarColors( + /** Thumb color. */ + val thumbColor: Color, + /** Active track color. */ + val activeTrackColor: Color, + /** Inactive track color. */ + val inactiveTrackColor: Color +) + +/** + * Creates colors for a TV seek bar. + */ +@ExperimentalTvUiApi +object SeekBarDefaults { + /** + * Creates a seek bar color set from the current TV theme. + */ + @Composable + fun colors( + thumbColor: Color = MaterialTheme.colorScheme.primary, + activeTrackColor: Color = MaterialTheme.colorScheme.primary, + inactiveTrackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + ): SeekBarColors = SeekBarColors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = inactiveTrackColor + ) +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt new file mode 100644 index 00000000..7e7eff2a --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt @@ -0,0 +1,118 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.player.EndedEvent +import com.theoplayer.android.api.event.player.PlayEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class PlayButtonTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun pausedPlayerShowsPlayAndClickStartsPlayback() { + val player = TestPlayer(paused = true) + val focusRequester = FocusRequester() + setPlayButton(player, Modifier.focusRequester(focusRequester)) + focusPlayButton(focusRequester) + val playDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_play) + + composeRule.onNodeWithContentDescription(playDescription).assertExists() + pressCenter() + + verify(exactly = 1) { player.player.play() } + } + + @Test + fun playingPlayerShowsPauseAndClickPausesPlayback() { + val player = TestPlayer(paused = true) + val focusRequester = FocusRequester() + setPlayButton(player, Modifier.focusRequester(focusRequester)) + focusPlayButton(focusRequester) + player.paused = false + dispatch(player, PlayerEventTypes.PLAY, mockk()) + val pauseDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_pause) + + composeRule.onNodeWithContentDescription(pauseDescription).assertExists() + pressCenter() + + verify(exactly = 1) { player.player.pause() } + } + + @Test + fun endedPlayerShowsReplay() { + val player = TestPlayer(paused = true) + setPlayButton(player) + player.ended = true + dispatch(player, PlayerEventTypes.ENDED, mockk()) + + val replayDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_replay) + composeRule.onNodeWithContentDescription(replayDescription).assertExists() + } + + private fun setPlayButton(player: TestPlayer, modifier: Modifier = Modifier) { + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player.player) } + CompositionLocalProvider(LocalTHEOplayerView provides view) { + THEOplayerTvTheme { + PlayButton(modifier = modifier.testTag("play-button")) + } + } + } + composeRule.waitForIdle() + } + + private fun focusPlayButton(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.onNodeWithTag("play-button").assertIsFocused() + } + + private fun pressCenter() { + assertTrue( + dispatchKeyEvent( + composeRule.activity, + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + assertTrue( + dispatchKeyEvent( + composeRule.activity, + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + } + + private fun dispatch(player: TestPlayer, eventType: EventType<*>, event: Event<*>) { + composeRule.runOnIdle { + player.dispatch(eventType, event) + } + composeRule.waitForIdle() + } +} 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 new file mode 100644 index 00000000..df010280 --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt @@ -0,0 +1,213 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.event.player.TimeUpdateEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class SeekBarTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun seekBarIsFocusable() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester).testTag("seek-bar")) + + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.onNodeWithTag("seek-bar").assertIsFocused() + } + + @Test + fun singleRightPressSeeksWithoutPausing() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(60.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.pause() } + } + + @Test + fun singleLeftPressClampsToSeekableStart() { + val player = TestPlayer(currentTime = 5.0) + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + + assertEquals(0.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.pause() } + } + + @Test + fun heldRightPressScrubsAndResumesPlayback() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + val positions = mutableListOf(player.currentTime) + for (repeat in 1..3) { + assertTrue( + dispatchKey( + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.ACTION_DOWN, + repeat = repeat + ) + ) + positions += player.currentTime + } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertTrue(positions.zipWithNext().all { (previous, next) -> next > previous }) + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun heldScrubResumesPlaybackWhenSeekBarBecomesDisabled() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + + player.seekableRanges = emptyList() + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun heldScrubResumesPlaybackWhenFocusLeavesSeekBar() { + val player = TestPlayer() + val focusRequester = FocusRequester() + val otherFocusRequester = FocusRequester() + setSeekBar( + player, + Modifier.focusRequester(focusRequester), + otherFocusRequester + ) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + + composeRule.runOnIdle { otherFocusRequester.requestFocus() } + composeRule.waitForIdle() + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun disabledSeekBarDoesNotConsumeOrSeek() { + val player = TestPlayer(seekableRanges = emptyList()) + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertEquals(50.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.currentTime = any() } + } + + private fun setSeekBar( + player: TestPlayer, + modifier: Modifier = Modifier, + otherFocusRequester: FocusRequester? = null + ) { + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player.player) } + CompositionLocalProvider(LocalTHEOplayerView provides view) { + THEOplayerTvTheme { + Column { + SeekBar(modifier = modifier) + if (otherFocusRequester != null) { + Box( + Modifier + .size(16.dp) + .focusRequester(otherFocusRequester) + .focusable() + ) + } + } + } + } + } + composeRule.waitForIdle() + } + + private fun requestFocus(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey( + keyCode: Int, + action: Int, + repeat: Int = 0 + ): Boolean = dispatchKeyEvent( + composeRule.activity, + KeyEvent(1_000L, eventTime++, action, keyCode, repeat) + ) + + private var eventTime = 1_001L +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt new file mode 100644 index 00000000..77e3cccb --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt @@ -0,0 +1,129 @@ +package com.theoplayer.android.ui.tv + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.ReadyState +import com.theoplayer.android.api.timerange.TimeRange +import com.theoplayer.android.api.timerange.TimeRanges +import io.mockk.every +import io.mockk.mockk + +internal class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: Player +) : THEOplayerView(context) { + override fun getPlayer(): Player = nativePlayer +} + +internal class PlayerEventListeners { + private val listeners = mutableListOf, EventListener<*>>>() + + fun add(type: EventType<*>, listener: EventListener<*>) { + listeners.add(type to listener) + } + + fun remove(type: EventType<*>, listener: EventListener<*>) { + listeners.remove(type to listener) + } + + fun hasListeners(type: EventType<*>): Boolean = listeners.any { it.first == type } + + fun dispatch(type: EventType<*>, event: Event<*>) { + listeners.toList().forEach { (eventType, listener) -> + if (eventType == type) { + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(event) + } + } + } +} + +internal class TestPlayer( + var currentTime: Double = 50.0, + var paused: Boolean = false, + var ended: Boolean = false, + var seekableRanges: List> = listOf(0.0 to 100.0), + private val duration: Double = 100.0 +) { + val eventListeners = PlayerEventListeners() + private val adEventListeners = PlayerEventListeners() + private val ads = mockk { + every { isPlaying } returns false + every { + addEventListener(any>>(), any()) + } answers { + adEventListeners.add(firstArg(), secondArg()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + adEventListeners.remove(firstArg(), secondArg()) + Unit + } + } + val player: Player = mockk { + every { isPaused } answers { this@TestPlayer.paused } + every { isEnded } answers { this@TestPlayer.ended } + every { getCurrentTime() } answers { this@TestPlayer.currentTime } + every { getDuration() } answers { this@TestPlayer.duration } + every { getSeekable() } answers { timeRanges(this@TestPlayer.seekableRanges) } + every { getAds() } returns this@TestPlayer.ads + every { getReadyState() } returns ReadyState.HAVE_FUTURE_DATA + every { isSeeking } returns false + every { setCurrentTime(any()) } answers { + this@TestPlayer.currentTime = firstArg() + } + every { play() } answers { + this@TestPlayer.paused = false + Unit + } + every { pause() } answers { + this@TestPlayer.paused = true + Unit + } + every { + addEventListener(any>>(), any()) + } answers { + eventListeners.add(firstArg(), secondArg()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + eventListeners.remove(firstArg(), secondArg()) + Unit + } + } + + fun dispatch(type: EventType<*>, event: Event<*>) { + eventListeners.dispatch(type, event) + } +} + +internal fun dispatchKeyEvent(activity: Activity, event: KeyEvent): Boolean = + activity.dispatchKeyEvent(event) + +private fun timeRanges(ranges: List>): TimeRanges = mockk { + every { iterator() } answers { + ranges.map { (start, end) -> + timeRange(start, end) + }.toMutableList().listIterator() + } +} + +private fun timeRange(start: Double, end: Double): TimeRange { + val range = mockk() + every { range.start } returns start + every { range.end } returns end + return range +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt index d8269f3f..5ea45761 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -1,6 +1,5 @@ package com.theoplayer.android.ui.tv -import android.content.Context import android.view.KeyEvent import android.view.ViewGroup import androidx.activity.ComponentActivity @@ -30,7 +29,6 @@ import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.error.THEOplayerException -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -40,7 +38,6 @@ import com.theoplayer.android.ui.UIControllerScope import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -342,12 +339,12 @@ class UIControllerTest { } composeRule.activity.onBackPressedDispatcher.addCallback(callback) val player = mockNotPlayedPlayer() - val errorListener = slot>() - val errorListeners = mutableListOf>() + val errorListeners = PlayerEventListeners() every { - player.addEventListener(PlayerEventTypes.ERROR, capture(errorListener)) + player.addEventListener(PlayerEventTypes.ERROR, any()) } answers { - errorListeners += errorListener.captured + errorListeners.add(firstArg(), secondArg()) + Unit } setController( player = player, @@ -356,12 +353,14 @@ class UIControllerTest { } ) composeRule.mainClock.advanceTimeBy(100) - composeRule.waitUntil(10_000) { errorListeners.isNotEmpty() } + composeRule.waitUntil(10_000) { + errorListeners.hasListeners(PlayerEventTypes.ERROR) + } composeRule.waitForIdle() val errorEvent = mockk() every { errorEvent.errorObject } returns mockk() composeRule.runOnIdle { - errorListeners.forEach { it.handleEvent(errorEvent) } + errorListeners.dispatch(PlayerEventTypes.ERROR, errorEvent) } waitForTag("error") @@ -540,13 +539,6 @@ class UIControllerTest { return player } - private class PlayerBackedTHEOplayerView( - context: Context, - private val nativePlayer: Player - ) : THEOplayerView(context) { - override fun getPlayer(): Player = nativePlayer - } - private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, keyCode) ) From ac763a2b652a4601280981679a51b2f7a76665e4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:08:05 +0000 Subject: [PATCH 076/158] Update 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 5d1fd2d6..9840501e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) * 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) ## [1.14.2] (2026-10-13) From 454cd837eb200ea2bf32d6f259efdaf1a5f17af7 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:14:31 +0000 Subject: [PATCH 077/158] Explain seek bar key repeat handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt | 3 +++ 1 file changed, 3 insertions(+) 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 6b19cbaa..f64fdf1e 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 @@ -123,6 +123,9 @@ private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Mod when (event.type) { KeyEventType.KeyDown -> { + // The first key down is a single press: skip by seekOffset and keep the play state. + // Holding the key auto-repeats key down events (repeatCount > 0): scrub instead, + // pausing playback until the key is released. if (event.nativeKeyEvent.repeatCount == 0) { state.seekTo((state.value + direction * seekOffset).coerceIn(range)) } else { From 41d9332d62020799e6c78a8f7c0f469fb98c28f8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:28:11 +0000 Subject: [PATCH 078/158] Extract Slider from SeekBar --- .../com/theoplayer/android/ui/tv/SeekBar.kt | 206 ++------- .../com/theoplayer/android/ui/tv/Slider.kt | 391 ++++++++++++++++++ .../theoplayer/android/ui/tv/SliderTest.kt | 288 +++++++++++++ 3 files changed, 703 insertions(+), 182 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt 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 f64fdf1e..def51243 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 @@ -1,43 +1,13 @@ package com.theoplayer.android.ui.tv -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.semantics.progressBarRangeInfo -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.setProgress -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme 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 kotlin.math.max /** * Displays the player's seek bar. @@ -47,7 +17,7 @@ import kotlin.math.max @Composable fun SeekBar( modifier: Modifier = Modifier, - colors: SeekBarColors = SeekBarDefaults.colors(), + colors: SliderColors = SliderDefaults.colors(), seekOffset: Int = 10 ) { SeekBar( @@ -67,162 +37,34 @@ fun SeekBar( fun SeekBar( state: SeekBarState, modifier: Modifier = Modifier, - colors: SeekBarColors = SeekBarDefaults.colors(), + colors: SliderColors = SliderDefaults.colors(), seekOffset: Int = 10 ) { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - val thumbRadius by animateDpAsState( - targetValue = if (focused) 8.dp else 4.dp, - label = "seekbar-thumb-radius" - ) - val range = state.valueRange - - Box( - modifier = modifier - .fillMaxWidth() - .height(16.dp) - .semantics { - progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) - setProgress { value -> - state.seekTo(value.coerceIn(range)) - true - } - } - .seekBarKeyEvents(state, seekOffset) - .focusable(interactionSource = interactionSource) - ) { - Canvas(Modifier.fillMaxSize()) { - drawSeekBar(state, colors, thumbRadius) - } - } -} - -@Composable -private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Modifier { - var isScrubbing by remember(state) { mutableStateOf(false) } - var scrubValue by remember(state) { mutableStateOf(0f) } - val range = state.valueRange - - fun finishScrubbing() { - if (isScrubbing) { - state.onValueChangeFinished() - isScrubbing = false - } - } - - return onFocusChanged { - if (!it.isFocused) finishScrubbing() - }.onKeyEvent { event -> - val direction = when (event.key) { - Key.DirectionLeft -> -1 - Key.DirectionRight -> 1 - else -> return@onKeyEvent false - } - if (!state.enabled && !isScrubbing) return@onKeyEvent false - - when (event.type) { - KeyEventType.KeyDown -> { - // The first key down is a single press: skip by seekOffset and keep the play state. - // Holding the key auto-repeats key down events (repeatCount > 0): scrub instead, - // pausing playback until the key is released. - if (event.nativeKeyEvent.repeatCount == 0) { - state.seekTo((state.value + direction * seekOffset).coerceIn(range)) - } else { - if (!isScrubbing) { - scrubValue = state.value - isScrubbing = true - } - val step = (range.endInclusive - range.start) * 0.01f - scrubValue = (scrubValue + direction * step).coerceIn(range) - state.onValueChange(scrubValue) - } - true + val sliderState = rememberSyncedSliderState(state.value, state.valueRange) + Slider( + state = sliderState, + onValueChange = { value -> + if (sliderState.isDragging) { + state.onValueChange(value) + } else { + state.seekTo(value) } - - KeyEventType.KeyUp -> { - finishScrubbing() - true + }, + modifier = modifier, + enabled = state.enabled, + keyStep = seekOffset.toFloat(), + keyRepeatStep = (state.valueRange.endInclusive - state.valueRange.start) / 100f, + onValueChangeFinished = state::onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = { + if (state.showThumb) { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) } - - else -> false + }, + track = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) } - } -} - -private fun DrawScope.drawSeekBar( - state: SeekBarState, - colors: SeekBarColors, - thumbRadius: Dp -) { - val range = state.valueRange - val edge = 8.dp.toPx() - val start = edge - val end = max(start, size.width - edge) - val width = end - start - val valueWidth = (range.endInclusive - range.start).coerceAtLeast(0f) - val fraction = if (valueWidth == 0f) { - 0f - } else { - ((state.value - range.start) / valueWidth).coerceIn(0f, 1f) - } - val thumbX = start + width * fraction - val centerY = size.height / 2 - val trackWidth = 4.dp.toPx() - - drawLine( - color = colors.inactiveTrackColor, - start = Offset(start, centerY), - end = Offset(end, centerY), - strokeWidth = trackWidth, - cap = StrokeCap.Round - ) - drawLine( - color = colors.activeTrackColor, - start = Offset(start, centerY), - end = Offset(thumbX, centerY), - strokeWidth = trackWidth, - cap = StrokeCap.Round - ) - if (state.showThumb) { - drawCircle( - color = colors.thumbColor, - radius = thumbRadius.toPx(), - center = Offset(thumbX, centerY) - ) - } -} - -/** - * Colors used to draw a TV seek bar. - */ -@ExperimentalTvUiApi -@Immutable -class SeekBarColors( - /** Thumb color. */ - val thumbColor: Color, - /** Active track color. */ - val activeTrackColor: Color, - /** Inactive track color. */ - val inactiveTrackColor: Color -) - -/** - * Creates colors for a TV seek bar. - */ -@ExperimentalTvUiApi -object SeekBarDefaults { - /** - * Creates a seek bar color set from the current TV theme. - */ - @Composable - fun colors( - thumbColor: Color = MaterialTheme.colorScheme.primary, - activeTrackColor: Color = MaterialTheme.colorScheme.primary, - inactiveTrackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) - ): SeekBarColors = SeekBarColors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor ) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt new file mode 100644 index 00000000..a9f58c48 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt @@ -0,0 +1,391 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import kotlin.math.roundToInt + +private val ThumbSize = 16.dp +private const val TrackLayoutId = "track" +private const val ThumbLayoutId = "thumb" + +/** + * Holds the value and range for a TV slider. + */ +@ExperimentalTvUiApi +@Stable +class SliderState( + value: Float = 0f, + valueRange: ClosedFloatingPointRange = 0f..1f +) { + private var valueState by mutableFloatStateOf(value.coerceIn(valueRange)) + + /** The current slider value, constrained to [valueRange]. */ + var value: Float + get() = valueState + set(value) { + valueState = value.coerceIn(valueRange) + } + + /** The range of values available to the slider. */ + var valueRange by mutableStateOf(valueRange) + + /** The current value's fraction within [valueRange]. */ + val coercedValueAsFraction: Float + get() { + val range = valueRange + val width = range.endInclusive - range.start + if (width == 0f) return 0f + return ((value.coerceIn(range) - range.start) / width).coerceIn(0f, 1f) + } + + /** Whether the user is holding Left or Right to scrub. */ + var isDragging by mutableStateOf(false) + internal set +} + +/** + * Displays a focusable slider controlled by D-pad keys. + */ +@ExperimentalTvUiApi +@Composable +fun Slider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + keyStep: Float = (valueRange.endInclusive - valueRange.start) / 100f, + keyRepeatStep: Float = keyStep, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) + } +) { + val state = rememberSyncedSliderState(value, valueRange) + Slider( + state = state, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + keyStep = keyStep, + keyRepeatStep = keyRepeatStep, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = thumb, + track = track + ) +} + +/** + * Displays a slider backed by [state]. + */ +@ExperimentalTvUiApi +@Composable +fun Slider( + state: SliderState, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + keyStep: Float = (state.valueRange.endInclusive - state.valueRange.start) / 100f, + keyRepeatStep: Float = keyStep, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) + } +) { + val range = state.valueRange + val sliderModifier = modifier + .fillMaxWidth() + .height(ThumbSize) + .semantics { + progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) + setProgress { value -> + if (!enabled) { + false + } else { + state.value = value + onValueChange(state.value) + onValueChangeFinished?.invoke() + true + } + } + if (!enabled) disabled() + } + .sliderKeyEvents( + state = state, + enabled = enabled, + keyStep = keyStep, + keyRepeatStep = keyRepeatStep, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished + ) + .focusable(interactionSource = interactionSource) + + SliderLayout( + state = state, + modifier = sliderModifier, + thumb = thumb, + track = track + ) +} + +/** + * Creates a remembered slider state synchronized with external values. + */ +@Composable +internal fun rememberSyncedSliderState( + value: Float, + valueRange: ClosedFloatingPointRange +): SliderState { + val state = remember { SliderState(value, valueRange) } + state.valueRange = valueRange + if (!state.isDragging) state.value = value + return state +} + +/** + * Colors used to draw a TV slider. + */ +@ExperimentalTvUiApi +@Immutable +class SliderColors( + /** Thumb color. */ + val thumbColor: Color, + /** Active track color. */ + val activeTrackColor: Color, + /** Inactive track color. */ + val inactiveTrackColor: Color +) + +/** + * Creates slider colors and default slider content. + */ +@ExperimentalTvUiApi +object SliderDefaults { + /** + * Creates a slider color set from the current TV theme. + */ + @Composable + fun colors( + thumbColor: Color = MaterialTheme.colorScheme.primary, + activeTrackColor: Color = MaterialTheme.colorScheme.primary, + inactiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + ): SliderColors = SliderColors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = inactiveTrackColor + ) + + /** + * Displays a slider thumb that grows when focused. + */ + @Composable + fun Thumb( + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + colors: SliderColors = colors() + ) { + val focused by interactionSource.collectIsFocusedAsState() + val radius by animateDpAsState( + targetValue = if (focused) 8.dp else 4.dp, + label = "slider-thumb-radius" + ) + + Canvas(modifier.size(ThumbSize)) { + drawCircle( + color = colors.thumbColor, + radius = radius.toPx() + ) + } + } + + /** + * Displays a slider track with active and inactive sections. + */ + @Composable + fun Track( + sliderState: SliderState, + modifier: Modifier = Modifier, + colors: SliderColors = colors() + ) { + Canvas(modifier.fillMaxWidth().height(4.dp)) { + val centerY = size.height / 2 + val end = Offset(size.width, centerY) + val strokeWidth = 4.dp.toPx() + drawLine( + color = colors.inactiveTrackColor, + start = Offset(0f, centerY), + end = end, + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + drawLine( + color = colors.activeTrackColor, + start = Offset(0f, centerY), + end = Offset(size.width * sliderState.coercedValueAsFraction, centerY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } + } +} + +@Composable +private fun Modifier.sliderKeyEvents( + state: SliderState, + enabled: Boolean, + keyStep: Float, + keyRepeatStep: Float, + onValueChange: (Float) -> Unit, + onValueChangeFinished: (() -> Unit)? +): Modifier { + val currentOnValueChange by rememberUpdatedState(onValueChange) + val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished) + var isKeyPressed by remember(state) { mutableStateOf(false) } + + fun finishDragging() { + if (state.isDragging) { + state.isDragging = false + currentOnValueChangeFinished?.invoke() + } + } + + LaunchedEffect(enabled) { + if (!enabled) finishDragging() + } + + return onFocusChanged { + if (!it.isFocused) { + isKeyPressed = false + finishDragging() + } + }.onKeyEvent { event -> + val direction = when (event.key) { + Key.DirectionLeft -> -1 + Key.DirectionRight -> 1 + else -> return@onKeyEvent false + } + + when (event.type) { + KeyEventType.KeyDown -> { + if (!enabled) return@onKeyEvent isKeyPressed + isKeyPressed = true + // The first key down is a single step; repeats indicate a held key, which scrubs. + // Accumulate repeats from SliderState because external values may update later. + if (event.nativeKeyEvent.repeatCount == 0) { + state.value += direction * keyStep + } else { + state.isDragging = true + state.value += direction * keyRepeatStep + } + currentOnValueChange(state.value) + true + } + + KeyEventType.KeyUp -> { + if (!isKeyPressed) return@onKeyEvent enabled + isKeyPressed = false + if (state.isDragging) { + finishDragging() + } else if (enabled) { + currentOnValueChangeFinished?.invoke() + } + true + } + + else -> false + } + } +} + +@Composable +private fun SliderLayout( + state: SliderState, + modifier: Modifier, + thumb: @Composable (SliderState) -> Unit, + track: @Composable (SliderState) -> Unit +) { + Layout( + modifier = modifier, + content = { + Box(Modifier.layoutId(TrackLayoutId)) { track(state) } + Box(Modifier.layoutId(ThumbLayoutId)) { thumb(state) } + } + ) { measurables, constraints -> + val thumbSize = ThumbSize.roundToPx() + val width = constraints.maxWidth + val height = thumbSize.coerceIn(constraints.minHeight, constraints.maxHeight) + val trackWidth = (width - thumbSize).coerceAtLeast(0) + val track = measurables.first { it.layoutId == TrackLayoutId }.measure( + Constraints( + minWidth = trackWidth, + maxWidth = trackWidth, + maxHeight = height + ) + ) + val thumb = measurables.first { it.layoutId == ThumbLayoutId }.measure( + Constraints(maxWidth = width, maxHeight = height) + ) + val thumbCenterX = thumbSize / 2 + (trackWidth * state.coercedValueAsFraction).roundToInt() + + layout(width, height) { + track.place( + x = thumbSize / 2, + y = (height - track.height) / 2 + ) + thumb.place( + x = thumbCenterX - thumb.width / 2, + y = (height - thumb.height) / 2 + ) + } + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt new file mode 100644 index 00000000..3adece8b --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt @@ -0,0 +1,288 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class SliderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun singlePressChangesValueAndFinishesOnce() { + val focusRequester = FocusRequester() + val changes = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = 0.2f, + onValueChange = changes::add, + modifier = modifier, + keyStep = 0.1f, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(1, changes.size) + assertEquals(0.3f, changes.single(), 0.0001f) + assertEquals(1, finished.size) + } + + @Test + fun heldKeyAccumulatesRepeatStepsOnSliderState() { + val focusRequester = FocusRequester() + val state = SliderState(value = 0.2f) + val changes = mutableListOf() + val draggingDuringChanges = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + state = state, + onValueChange = { + changes += it + draggingDuringChanges += state.isDragging + }, + modifier = modifier, + keyStep = 0.3f, + keyRepeatStep = 0.1f, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 2)) + + assertEquals(3, changes.size) + assertEquals(0.5f, changes[0], 0.0001f) + assertEquals(0.6f, changes[1], 0.0001f) + assertEquals(0.7f, changes[2], 0.0001f) + assertEquals(listOf(false, true, true), draggingDuringChanges) + assertTrue(state.isDragging) + assertEquals(0, finished.size) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + } + + @Test + fun valueClampsAtBothRangeEnds() { + val focusRequester = FocusRequester() + val value = mutableFloatStateOf(0f) + val changes = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = value.floatValue, + onValueChange = changes::add, + modifier = modifier, + keyStep = 0.1f + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + composeRule.runOnIdle { value.floatValue = 1f } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(2, changes.size) + assertEquals(0f, changes[0], 0f) + assertEquals(1f, changes[1], 0f) + } + + @Test + fun disabledSliderDoesNotConsumeKeysOrCallCallbacks() { + val focusRequester = FocusRequester() + val changes = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = 0.5f, + onValueChange = changes::add, + modifier = modifier, + enabled = false, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertTrue(changes.isEmpty()) + assertTrue(finished.isEmpty()) + } + + @Test + fun losingFocusDuringDragFinishesOnce() { + val focusRequester = FocusRequester() + val otherFocusRequester = FocusRequester() + val state = SliderState() + val finished = mutableListOf() + setSlider( + modifier = Modifier.focusRequester(focusRequester), + otherFocusRequester = otherFocusRequester + ) { modifier -> + Slider( + state = state, + onValueChange = {}, + modifier = modifier, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + composeRule.runOnIdle { otherFocusRequester.requestFocus() } + composeRule.waitForIdle() + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + } + + @Test + fun disablingDuringDragFinishesOnce() { + val focusRequester = FocusRequester() + val enabled = mutableStateOf(true) + val state = SliderState() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + state = state, + onValueChange = {}, + modifier = modifier, + enabled = enabled.value, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + composeRule.runOnIdle { enabled.value = false } + composeRule.waitForIdle() + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + assertEquals(1, finished.size) + } + + @Test + fun valueOverloadFollowsExternalValueWhenNotDragging() { + val value = mutableFloatStateOf(0.2f) + setSlider { modifier -> + Slider( + value = value.floatValue, + onValueChange = {}, + modifier = modifier, + thumb = { state -> + Box( + Modifier + .size(1.dp) + .semantics { contentDescription = "slider-value-${state.value}" } + ) + } + ) + } + + composeRule.onNodeWithContentDescription("slider-value-0.2").assertExists() + composeRule.runOnIdle { value.floatValue = 0.75f } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription("slider-value-0.75").assertExists() + } + + @Test + fun sliderIsFocusable() { + val focusRequester = FocusRequester() + setSlider(Modifier.focusRequester(focusRequester).testTag("slider")) { modifier -> + Slider(value = 0.5f, onValueChange = {}, modifier = modifier) + } + + requestFocus(focusRequester) + + composeRule.onNodeWithTag("slider").assertIsFocused() + } + + private fun setSlider( + modifier: Modifier = Modifier, + otherFocusRequester: FocusRequester? = null, + content: @Composable (Modifier) -> Unit + ) { + composeRule.setContent { + THEOplayerTvTheme { + Column { + content(modifier) + if (otherFocusRequester != null) { + Box( + Modifier + .size(16.dp) + .focusRequester(otherFocusRequester) + .focusable() + ) + } + } + } + } + composeRule.waitForIdle() + } + + private fun requestFocus(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey( + keyCode: Int, + action: Int, + repeat: Int = 0 + ): Boolean = dispatchKeyEvent( + composeRule.activity, + KeyEvent(1_000L, eventTime++, action, keyCode, repeat) + ) + + private var eventTime = 1_001L +} From cafefe75ebab85fa2f3e466ac30d5cfb7373eb3f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:40:32 +0000 Subject: [PATCH 079/158] Port Material3's indeterminate spinner to LoadingSpinner --- .../ui/tv/CircularProgressIndicator.kt | 222 ++++++++++++++++++ .../android/ui/tv/LoadingSpinner.kt | 83 +++---- 2 files changed, 250 insertions(+), 55 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt new file mode 100644 index 00000000..63976d2c --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.theoplayer.android.ui.tv + +// Adapted from androidx.compose.material3.ProgressIndicator: +// https://android.googlesource.com/platform/frameworks/support/+/080d2b3e5326ba80392d93442c4a51a02dc22650/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ProgressIndicator.kt + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.min + +/** + * [Material Design determinate circular progress + * indicator](https://m3.material.io/components/progress-indicators/overview) + * + * Progress indicators express an unspecified wait time or display the duration of a process. + * + * ![Circular progress indicator + * image](https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Flqdiyyvh-1P-progress-indicator-configurations.png?alt=media) + * + * @sample androidx.compose.material3.samples.IndeterminateCircularProgressIndicatorSample + * @param modifier the [Modifier] to be applied to this progress indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param trackColor color of the track behind the indicator, visible when the progress has not + * reached the area of the overall indicator yet + * @param strokeCap stroke cap to use for the ends of this progress indicator + * @param gapSize size of the gap between the progress indicator and the track + */ +@Composable +internal fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color, + strokeWidth: Dp, + trackColor: Color = Color.Transparent, + strokeCap: StrokeCap = StrokeCap.Round, + gapSize: Dp = CircularIndicatorTrackGapSize, +) { + val stroke = with(LocalDensity.current) { Stroke(width = strokeWidth.toPx(), cap = strokeCap) } + + val infiniteTransition = rememberInfiniteTransition() + // A global rotation that does a 360 degrees rotation in 6 seconds. + val globalRotation = + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = CircularGlobalRotationDegreesTarget, + animationSpec = circularIndeterminateGlobalRotationAnimationSpec, + ) + + // An additional rotation that moves by 90 degrees in 500ms and then rest for 1 second. + val additionalRotation = + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = CircularAdditionalRotationDegreesTarget, + animationSpec = circularIndeterminateRotationAnimationSpec, + ) + + // Indicator progress animation that will be changing the progress up and down as the indicator + // rotates. + val progressAnimation = + infiniteTransition.animateFloat( + initialValue = CircularIndeterminateMinProgress, + targetValue = CircularIndeterminateMaxProgress, + animationSpec = circularIndeterminateProgressAnimationSpec, + ) + + Canvas(modifier.progressSemantics().size(CircularIndicatorDiameter)) { + val sweep = progressAnimation.value * 360f + val adjustedGapSize = + if (strokeCap == StrokeCap.Butt || size.height > size.width) { + gapSize + } else { + gapSize + strokeWidth + } + val gapSizeSweep = (adjustedGapSize.value / (PI * size.width.toDp().value).toFloat()) * 360f + + rotate(globalRotation.value + additionalRotation.value) { + drawCircularIndicator( + sweep + min(sweep, gapSizeSweep), + 360f - sweep - min(sweep, gapSizeSweep) * 2, + trackColor, + stroke, + ) + drawDeterminateCircularIndicator(startAngle = 0f, sweep, color, stroke) + } + } +} + +private fun DrawScope.drawCircularIndicator( + startAngle: Float, + sweep: Float, + color: Color, + stroke: Stroke, +) { + // To draw this circle we need a rect with edges that line up with the midpoint of the stroke. + // To do this we need to remove half the stroke width from the total diameter for both sides. + val diameterOffset = stroke.width / 2 + val arcDimen = size.width - 2 * diameterOffset + drawArc( + color = color, + startAngle = startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(diameterOffset, diameterOffset), + size = Size(arcDimen, arcDimen), + style = stroke, + ) +} + +private fun DrawScope.drawDeterminateCircularIndicator( + startAngle: Float, + sweep: Float, + color: Color, + stroke: Stroke, +) = drawCircularIndicator(startAngle, sweep, color, stroke) + +/** A global animation spec for indeterminate circular progress indicator. */ +private val circularIndeterminateGlobalRotationAnimationSpec + get() = + infiniteRepeatable( + animation = tween(CircularAnimationProgressDuration, easing = LinearEasing) + ) + +/** + * An animation spec for indeterminate circular progress indicators that infinitely rotates a 360 + * degrees. + */ +private val circularIndeterminateRotationAnimationSpec + get() = + infiniteRepeatable( + animation = + keyframes { + durationMillis = CircularAnimationProgressDuration // 6000ms + 90f at + CircularAnimationAdditionalRotationDuration using + CircularIndeterminateRotationEasing // 300ms + 90f at CircularAnimationAdditionalRotationDelay // hold till 1500ms + 180f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay // 1800ms + 180f at CircularAnimationAdditionalRotationDelay * 2 // hold till 3000ms + 270f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay * 2 // 3300ms + 270f at CircularAnimationAdditionalRotationDelay * 3 // hold till 4500ms + 360f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay * 3 // 4800ms + 360f at CircularAnimationProgressDuration // hold till 6000ms + } + ) + +/** An animation spec for indeterminate circular progress indicators progress motion. */ +private val circularIndeterminateProgressAnimationSpec + get() = + infiniteRepeatable( + animation = + keyframes { + durationMillis = CircularAnimationProgressDuration // 6000ms + CircularIndeterminateMaxProgress at + CircularAnimationProgressDuration / 2 using + CircularProgressEasing // 3000ms + CircularIndeterminateMinProgress at CircularAnimationProgressDuration + } + ) + +// CircularProgressIndicator Material specs +// Diameter of the indicator circle +/*@VisibleForTesting*/ +private val CircularIndicatorDiameter = 40.dp // CircularProgressIndicatorTokens.Size +private val CircularIndicatorTrackGapSize = 4.dp // CircularProgressIndicatorTokens.TrackActiveSpace + +// Indeterminate circular indicator transition specs + +// The indeterminate circular indicator easing constants for its motion +private val CircularProgressEasing = + CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) // MotionTokens.EasingStandardCubicBezier +private val CircularIndeterminateRotationEasing = + CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) // MotionTokens.EasingEmphasizedDecelerateCubicBezier +private const val CircularIndeterminateMinProgress = 0.1f +private const val CircularIndeterminateMaxProgress = 0.87f + +private const val CircularAnimationProgressDuration = 6000 +private const val CircularAnimationAdditionalRotationDelay = 1500 +private const val CircularAnimationAdditionalRotationDuration = 300 +private const val CircularAdditionalRotationDegreesTarget = 360f +private const val CircularGlobalRotationDegreesTarget = 1080f diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt index 9e89fa0a..7596aaa6 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -1,26 +1,12 @@ package com.theoplayer.android.ui.tv -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.size +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -31,10 +17,16 @@ import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.rememberLoadingState import com.theoplayer.android.ui.theme.LocalPlayerAnimations import kotlin.time.Duration -import kotlinx.coroutines.delay as delayMillis +import kotlin.time.DurationUnit /** - * Shows a spinner while the player is loading. + * An indicator that shows whether the player is currently waiting for more data to resume playback. + * + * @param modifier the [Modifier] to be applied to this indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param delay delay for the progress indicator to become visible after the player starts buffering. + * In general, it's less jarring to *not* show the indicator for very short buffering periods. */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -55,7 +47,14 @@ fun LoadingSpinner( } /** - * Shows a spinner while [state] is loading. + * An indicator that shows whether the player is currently waiting for more data to resume playback. + * + * @param state the [LoadingState] to read + * @param modifier the [Modifier] to be applied to this indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param delay delay for the progress indicator to become visible after the player starts buffering. + * In general, it's less jarring to *not* show the indicator for very short buffering periods. */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi @@ -68,39 +67,13 @@ fun LoadingSpinner( strokeWidth: Dp = 4.dp, delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay ) { - val inspectionMode = LocalInspectionMode.current - var delayElapsed by remember(state, delay) { mutableStateOf(false) } - LaunchedEffect(state.loading, delay) { - delayElapsed = false - if (state.loading && !inspectionMode) { - delayMillis(delay.inWholeMilliseconds.coerceAtLeast(0)) - delayElapsed = true - } - } - - if (state.loading && (inspectionMode || delayElapsed)) { - val transition = rememberInfiniteTransition(label = "loading-spinner") - val rotation by transition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1_000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "rotation" - ) - Canvas(modifier = modifier.size(40.dp)) { - val stroke = strokeWidth.toPx() - val inset = stroke / 2 - drawArc( - color = color, - startAngle = rotation - 90f, - sweepAngle = 270f, - useCenter = false, - topLeft = Offset(inset, inset), - size = Size(size.width - stroke, size.height - stroke), - style = Stroke(width = stroke, cap = StrokeCap.Round) - ) - } + AnimatedVisibility( + visible = state.loading, + // Show the loading spinner only after a small delay + enter = fadeIn(snap(delayMillis = delay.toInt(DurationUnit.MILLISECONDS))), + // Hide the loading spinner immediately when loaded + exit = ExitTransition.None + ) { + CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } From 1bd9929606d4079fbbde79df6d38634ab2c86600 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:44:13 +0000 Subject: [PATCH 080/158] Add disabled slider colors and document TV controls --- .../android/ui/tv/CurrentTimeDisplay.kt | 17 ++- .../android/ui/tv/DurationDisplay.kt | 9 +- .../theoplayer/android/ui/tv/ErrorDisplay.kt | 13 +- .../theoplayer/android/ui/tv/LiveButton.kt | 29 +++- .../theoplayer/android/ui/tv/PlayButton.kt | 21 ++- .../com/theoplayer/android/ui/tv/SeekBar.kt | 33 ++++- .../com/theoplayer/android/ui/tv/Slider.kt | 124 +++++++++++++++--- 7 files changed, 214 insertions(+), 32 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt index 8294d826..6f3ac44d 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt @@ -13,7 +13,13 @@ import com.theoplayer.android.ui.formatPlaybackTime import com.theoplayer.android.ui.rememberPlaybackTimeState /** - * Displays the player's current time. + * A text display that shows the player's current time. + * + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -32,7 +38,14 @@ fun CurrentTimeDisplay( } /** - * Displays playback time from [state]. + * A text display that shows the player's current time. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt index 164dff4c..ff5ab0b0 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt @@ -11,7 +11,9 @@ import com.theoplayer.android.ui.formatPlaybackTime import com.theoplayer.android.ui.rememberPlaybackTimeState /** - * Displays the player's duration. + * A text display that shows the player's duration. + * + * @param modifier the [Modifier] to be applied to this text */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -24,7 +26,10 @@ fun DurationDisplay(modifier: Modifier = Modifier) { } /** - * Displays the duration from [state]. + * A text display that shows the player's duration. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt index 06ad8e11..23d7214c 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt @@ -27,7 +27,9 @@ import com.theoplayer.android.ui.common.R import com.theoplayer.android.ui.rememberErrorState /** - * Displays a fatal player error, if one occurs. + * A display for a fatal error, if the player encounters one. + * + * @param modifier the [Modifier] to be applied to this display */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -41,7 +43,14 @@ fun ErrorDisplay(modifier: Modifier = Modifier) { } /** - * Displays the error held by [state]. + * A display for a fatal error, if the player encounters one. + * + * The error is only captured from the player's `error` event, so create the [ErrorState] + * *before* an error can happen: at the top of your UI instead of inside the error overlay + * that is only shown once there's an error. + * + * @param state the [ErrorState] to read + * @param modifier the [Modifier] to be applied to this display */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi 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 75bb6900..252b238d 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 @@ -27,7 +27,20 @@ import com.theoplayer.android.ui.rememberLiveButtonState import com.theoplayer.android.ui.theme.LocalPlayerColors /** - * Shows live status and seeks to the live point when clicked. + * A button that shows whether the player is currently playing at the live point, + * and seeks to the live point when clicked. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param colors [ButtonColors] that resolve the colors for this button in different states. See + * [ButtonDefaults.colors]. + * @param liveThreshold the maximum distance (in seconds) from the live point that the player's + * current time can be for it to still be considered "at the live point" + * @param live button content when the player is playing at the live point, + * typically a red circle icon followed by the text "LIVE" + * @param dvr button content when the player is playing behind the live point, + * typically a gray circle icon followed by the text "LIVE" */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -55,7 +68,19 @@ fun LiveButton( } /** - * Shows live status from [state] and seeks to the live point when clicked. + * A button that shows whether the player is currently playing at the live point, + * and seeks to the live point when clicked. + * + * @param state the [LiveButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param colors [ButtonColors] that resolve the colors for this button in different states. See + * [ButtonDefaults.colors]. + * @param live button content when the player is playing at the live point, + * typically a red circle icon followed by the text "LIVE" + * @param dvr button content when the player is playing behind the live point, + * typically a gray circle icon followed by the text "LIVE" */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt index edc27511..80ab1444 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt @@ -19,7 +19,15 @@ import com.theoplayer.android.ui.common.R import com.theoplayer.android.ui.rememberPlayButtonState /** - * A button that toggles playback. + * A button that toggles whether the player is playing or paused. + * + * @param modifier the [Modifier] to be applied to this button + * @param iconModifier the [Modifier] to be applied to the [Icon]s inside this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param play button content when the player is paused but not ended, typically a "play" icon + * @param pause button content when the player is playing, typically a "pause" icon + * @param replay button content when the player is paused and ended, typically a "replay" icon */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class) @@ -62,7 +70,16 @@ fun PlayButton( } /** - * A button that toggles playback using [state]. + * A button that toggles whether the player is playing or paused. + * + * @param state the [PlayButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param iconModifier the [Modifier] to be applied to the [Icon]s inside this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param play button content when the player is paused but not ended, typically a "play" icon + * @param pause button content when the player is playing, typically a "pause" icon + * @param replay button content when the player is paused and ended, typically a "replay" icon */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi 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 def51243..fd6004a1 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 @@ -10,7 +10,15 @@ import com.theoplayer.android.ui.SeekBarState import com.theoplayer.android.ui.rememberSeekBarState /** - * Displays the player's seek bar. + * A seek bar showing the player's current time and allowing D-pad seeking. + * + * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the + * timeline; playback pauses while scrubbing. + * + * @param modifier the [Modifier] to be applied to this seek bar + * @param colors [SliderColors] that resolve the seek bar colors for enabled and disabled states. + * See [SliderDefaults.colors]. + * @param seekOffset the number of seconds to seek on a single Left or Right press */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class) @@ -29,7 +37,16 @@ fun SeekBar( } /** - * Displays the seek bar from [state]. + * A seek bar showing the current time from [state] and allowing D-pad seeking. + * + * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the + * timeline; playback pauses while scrubbing. + * + * @param state the [SeekBarState] to read and control + * @param modifier the [Modifier] to be applied to this seek bar + * @param colors [SliderColors] that resolve the seek bar colors for enabled and disabled states. + * See [SliderDefaults.colors]. + * @param seekOffset the number of seconds to seek on a single Left or Right press */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi @@ -60,11 +77,19 @@ fun SeekBar( interactionSource = interactionSource, thumb = { if (state.showThumb) { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = state.enabled + ) } }, track = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track( + sliderState = sliderState, + colors = colors, + enabled = state.enabled + ) } ) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt index a9f58c48..1309096e 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt @@ -47,6 +47,9 @@ private const val ThumbLayoutId = "thumb" /** * Holds the value and range for a TV slider. + * + * @param value the initial slider value, constrained to [valueRange] + * @param valueRange the range of values available to the slider */ @ExperimentalTvUiApi @Stable @@ -82,6 +85,22 @@ class SliderState( /** * Displays a focusable slider controlled by D-pad keys. + * + * A single Left or Right press changes the value by [keyStep]. Holding a key changes it by + * [keyRepeatStep] for each repeat. + * + * @param value the current slider value, constrained to [valueRange] + * @param onValueChange called with each value change + * @param modifier the [Modifier] to be applied to this slider + * @param enabled whether the slider responds to user input + * @param valueRange the range of values available to the slider + * @param keyStep the value change for a single Left or Right press + * @param keyRepeatStep the value change for each key repeat while Left or Right is held + * @param onValueChangeFinished called on key-up, when focus is lost, or when the slider is disabled + * @param colors the colors used for enabled and disabled states. See [SliderDefaults.colors]. + * @param interactionSource the [MutableInteractionSource] used to observe focus interactions + * @param thumb the thumb slot, which receives the current [SliderState] + * @param track the track slot, which receives the current [SliderState] */ @ExperimentalTvUiApi @Composable @@ -97,10 +116,14 @@ fun Slider( colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) }, track: @Composable (SliderState) -> Unit = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) } ) { val state = rememberSyncedSliderState(value, valueRange) @@ -120,7 +143,22 @@ fun Slider( } /** - * Displays a slider backed by [state]. + * Displays a slider backed by [state] and controlled by D-pad keys. + * + * A single Left or Right press changes the value by [keyStep]. Holding a key changes it by + * [keyRepeatStep] for each repeat. + * + * @param state the [SliderState] that holds the slider value and range + * @param onValueChange called with each value change + * @param modifier the [Modifier] to be applied to this slider + * @param enabled whether the slider responds to user input + * @param keyStep the value change for a single Left or Right press + * @param keyRepeatStep the value change for each key repeat while Left or Right is held + * @param onValueChangeFinished called on key-up, when focus is lost, or when the slider is disabled + * @param colors the colors used for enabled and disabled states. See [SliderDefaults.colors]. + * @param interactionSource the [MutableInteractionSource] used to observe focus interactions + * @param thumb the thumb slot, which receives the current [SliderState] + * @param track the track slot, which receives the current [SliderState] */ @ExperimentalTvUiApi @Composable @@ -135,10 +173,14 @@ fun Slider( colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) }, track: @Composable (SliderState) -> Unit = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) } ) { val range = state.valueRange @@ -192,18 +234,36 @@ internal fun rememberSyncedSliderState( } /** - * Colors used to draw a TV slider. + * Colors used to draw a TV slider in enabled and disabled states. */ @ExperimentalTvUiApi @Immutable class SliderColors( - /** Thumb color. */ + /** Thumb color when enabled. */ val thumbColor: Color, - /** Active track color. */ + /** Active track color when enabled. */ val activeTrackColor: Color, - /** Inactive track color. */ - val inactiveTrackColor: Color -) + /** Inactive track color when enabled. */ + val inactiveTrackColor: Color, + /** Thumb color when disabled. */ + val disabledThumbColor: Color, + /** Active track color when disabled. */ + val disabledActiveTrackColor: Color, + /** Inactive track color when disabled. */ + val disabledInactiveTrackColor: Color +) { + /** Returns the thumb color for [enabled]. */ + internal fun thumbColor(enabled: Boolean): Color = + if (enabled) thumbColor else disabledThumbColor + + /** Returns the active track color for [enabled]. */ + internal fun activeTrackColor(enabled: Boolean): Color = + if (enabled) activeTrackColor else disabledActiveTrackColor + + /** Returns the inactive track color for [enabled]. */ + internal fun inactiveTrackColor(enabled: Boolean): Color = + if (enabled) inactiveTrackColor else disabledInactiveTrackColor +} /** * Creates slider colors and default slider content. @@ -212,27 +272,49 @@ class SliderColors( object SliderDefaults { /** * Creates a slider color set from the current TV theme. + * + * @param thumbColor thumb color when enabled + * @param activeTrackColor active track color when enabled + * @param inactiveTrackColor inactive track color when enabled + * @param disabledThumbColor thumb color when disabled + * @param disabledActiveTrackColor active track color when disabled + * @param disabledInactiveTrackColor inactive track color when disabled */ @Composable fun colors( thumbColor: Color = MaterialTheme.colorScheme.primary, activeTrackColor: Color = MaterialTheme.colorScheme.primary, inactiveTrackColor: Color = - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f), + disabledThumbColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledActiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledInactiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ): SliderColors = SliderColors( thumbColor = thumbColor, activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor + inactiveTrackColor = inactiveTrackColor, + disabledThumbColor = disabledThumbColor, + disabledActiveTrackColor = disabledActiveTrackColor, + disabledInactiveTrackColor = disabledInactiveTrackColor ) /** * Displays a slider thumb that grows when focused. + * + * @param interactionSource the interaction source used to observe focus + * @param modifier the [Modifier] to be applied to the thumb + * @param colors the colors used for the thumb in enabled and disabled states + * @param enabled whether the slider is enabled */ @Composable fun Thumb( interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, - colors: SliderColors = colors() + colors: SliderColors = colors(), + enabled: Boolean = true ) { val focused by interactionSource.collectIsFocusedAsState() val radius by animateDpAsState( @@ -242,7 +324,7 @@ object SliderDefaults { Canvas(modifier.size(ThumbSize)) { drawCircle( - color = colors.thumbColor, + color = colors.thumbColor(enabled), radius = radius.toPx() ) } @@ -250,26 +332,32 @@ object SliderDefaults { /** * Displays a slider track with active and inactive sections. + * + * @param sliderState the state used to determine the active track length + * @param modifier the [Modifier] to be applied to the track + * @param colors the colors used for the track in enabled and disabled states + * @param enabled whether the slider is enabled */ @Composable fun Track( sliderState: SliderState, modifier: Modifier = Modifier, - colors: SliderColors = colors() + colors: SliderColors = colors(), + enabled: Boolean = true ) { Canvas(modifier.fillMaxWidth().height(4.dp)) { val centerY = size.height / 2 val end = Offset(size.width, centerY) val strokeWidth = 4.dp.toPx() drawLine( - color = colors.inactiveTrackColor, + color = colors.inactiveTrackColor(enabled), start = Offset(0f, centerY), end = end, strokeWidth = strokeWidth, cap = StrokeCap.Round ) drawLine( - color = colors.activeTrackColor, + color = colors.activeTrackColor(enabled), start = Offset(0f, centerY), end = Offset(size.width * sliderState.coercedValueAsFraction, centerY), strokeWidth = strokeWidth, From 2d67c21cf039bc5253e91832b45e2f456d8a3e2f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:48:09 +0000 Subject: [PATCH 081/158] Add CircularProgressIndicator preview --- .../com/theoplayer/android/ui/tv/LoadingSpinner.kt | 11 +++++++++++ .../java/com/theoplayer/android/ui/LoadingSpinner.kt | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt index 7596aaa6..f787c914 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -16,6 +17,7 @@ import com.theoplayer.android.ui.LoadingState import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.rememberLoadingState import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme import kotlin.time.Duration import kotlin.time.DurationUnit @@ -77,3 +79,12 @@ fun LoadingSpinner( CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } + +@OptIn(ExperimentalTvUiApi::class) +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun CircularProgressIndicatorPreview() { + THEOplayerTvTheme { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary, strokeWidth = 4.dp) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt index 330477a0..add24ee4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.theoplayer.android.ui.theme.THEOplayerTheme import kotlin.time.Duration @@ -68,3 +69,11 @@ fun LoadingSpinner( CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun CircularProgressIndicatorPreview() { + THEOplayerTheme(useDarkTheme = true) { + CircularProgressIndicator() + } +} From 7fd6ce0e14bf68886f30c408b72c92e344e37151 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 18:04:26 +0000 Subject: [PATCH 082/158] Ignore ui-tv build output Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 ui-tv/.gitignore diff --git a/ui-tv/.gitignore b/ui-tv/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/ui-tv/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file From f2ffa405161be74a72fffd7518f7c55fc9944327 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 08:54:02 +0000 Subject: [PATCH 083/158] Move shared formatting helpers to ui-common Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/TrackFormat.kt | 111 ++++++++++++++++++ .../com/theoplayer/android/ui/util/CeaUtil.kt | 7 +- .../com/theoplayer/android/ui/util/Compat.kt | 4 +- .../theoplayer/android/ui/util/TrackExts.kt | 10 +- .../theoplayer/android/ui/util/VersionUtil.kt | 7 +- .../java/com/theoplayer/android/ui/Helper.kt | 21 +--- .../theoplayer/android/ui/PlaybackRateList.kt | 22 +--- .../com/theoplayer/android/ui/QualityList.kt | 18 +-- .../com/theoplayer/android/ui/SettingsMenu.kt | 22 +--- 9 files changed, 141 insertions(+), 81 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt (88%) rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/util/Compat.kt (92%) rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt (89%) rename {ui => ui-common}/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt (92%) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt new file mode 100644 index 00000000..b15de631 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt @@ -0,0 +1,111 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.ui.common.R as CommonR +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.ui.util.constructLabel +import java.text.DecimalFormat + +/** + * Returns a human-readable label for the given media track or text track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: Track): String { + return remember(track.id, track.uid) { + when (track) { + is TextTrack -> constructLabel(track) + is MediaTrack<*> -> constructLabel(track) + else -> null + } + } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * Returns a human-readable label for the given media track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: MediaTrack<*>): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * Returns a human-readable label for the given text track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: TextTrack): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * The default playback rates shown in playback rate lists. + */ +@InternalUiApi +val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) + +/** + * Formats a playback rate for display. + */ +@InternalUiApi +@Composable +fun formatPlaybackRate(rate: Double): String { + return if (rate == 1.0) { + stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) + } else { + val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) + val decimalFormat = remember(format) { DecimalFormat(format) } + decimalFormat.format(rate) + } +} + +/** + * Formats a bandwidth for display. + */ +@InternalUiApi +@Composable +fun formatBandwidth(bandwidth: Long): String { + val format = stringResource( + if (bandwidth >= 1e7) { + CommonR.string.theoplayer_ui_bandwidth_format_10mbps + } else if (bandwidth >= 1e6) { + CommonR.string.theoplayer_ui_bandwidth_format_1mbps + } else { + CommonR.string.theoplayer_ui_bandwidth_format_kbps + } + ) + val decimalFormat = remember(format) { DecimalFormat(format) } + return decimalFormat.format(bandwidth / (if (bandwidth >= 1e6) 1e6 else 1e3)) +} + +/** + * Formats the active or target video quality for display. + */ +@InternalUiApi +@Composable +fun formatActiveQualityLabel( + targetVideoQuality: VideoQuality?, + activeVideoQuality: VideoQuality? +): String { + return if (targetVideoQuality != null) { + stringResource( + CommonR.string.theoplayer_ui_quality_with_height, + targetVideoQuality.height + ) + } else if (activeVideoQuality != null) { + stringResource( + CommonR.string.theoplayer_ui_quality_automatic_with_height, + activeVideoQuality.height + ) + } else { + stringResource(CommonR.string.theoplayer_ui_quality_automatic) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt similarity index 88% rename from ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index a6fd6857..2e65ec79 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -2,6 +2,7 @@ package com.theoplayer.android.ui.util import androidx.annotation.CheckResult import androidx.annotation.IntRange +import com.theoplayer.android.ui.InternalUiApi private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() @@ -9,7 +10,8 @@ private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() * Checks whether a provided label is CEA-608 or CEA-708 formed. */ @CheckResult -internal fun isLabelCeaFormatted(label: String?): Boolean { +@InternalUiApi +fun isLabelCeaFormatted(label: String?): Boolean { if (label.isNullOrEmpty()) { return false } @@ -33,7 +35,8 @@ internal fun isLabelCeaFormatted(label: String?): Boolean { * "CC" suffix, or `null` if the channel number is invalid. */ @CheckResult -internal fun getLabelForChannelNumber( +@InternalUiApi +fun getLabelForChannelNumber( @IntRange(from = 0L, to = 63L) channelNumber: Int, ): String? { // CEA-608 only supports channel numbers in [1, 4], diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt similarity index 92% rename from ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt index 164ed51b..4a38ad6f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -2,11 +2,13 @@ package com.theoplayer.android.ui.util import androidx.annotation.DoNotInline import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.ui.InternalUiApi /** * Returns [TextTrack.getCaptionChannel], if available. */ -internal val TextTrack.captionChannelCompat: Int? +@InternalUiApi +val TextTrack.captionChannelCompat: Int? get() { // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. return if (THEOplayerGlobalExt.version >= version1013) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt similarity index 89% rename from ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index b2bed135..f9af8e5e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -5,6 +5,7 @@ import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import com.theoplayer.android.ui.InternalUiApi import java.util.Locale private const val LANGUAGE_UNDEFINED = "und" @@ -18,7 +19,8 @@ private const val LANGUAGE_UNDEFINED = "und" * returns `null`. */ @get:CheckResult -internal val Track.localizedLanguageName: String? +@InternalUiApi +val Track.localizedLanguageName: String? get() { val languageCode = this.language ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } @@ -31,7 +33,8 @@ internal val Track.localizedLanguageName: String? /** * Constructs a label for the given [MediaTrack] instance. */ -internal fun constructLabel(track: MediaTrack<*>): String? { +@InternalUiApi +fun constructLabel(track: MediaTrack<*>): String? { return track.label?.takeUnless { it.isBlank() } ?: track.localizedLanguageName } @@ -39,7 +42,8 @@ internal fun constructLabel(track: MediaTrack<*>): String? { /** * Constructs a label for the given [TextTrack] instance. */ -internal fun constructLabel(track: TextTrack): String? { +@InternalUiApi +fun constructLabel(track: TextTrack): String? { val label = track.label?.takeIf { when { // Ignore empty labels. diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt similarity index 92% rename from ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index d654e984..1bdeccd3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,13 +1,15 @@ package com.theoplayer.android.ui.util import com.theoplayer.android.api.THEOplayerGlobal +import com.theoplayer.android.ui.InternalUiApi private const val VERSION_DELIMITER = '.' /** * A [semver](https://semver.org/) version. */ -internal data class Version( +@InternalUiApi +data class Version( /** * The major version. */ @@ -54,7 +56,8 @@ internal data class Version( } } -internal object THEOplayerGlobalExt { +@InternalUiApi +object THEOplayerGlobalExt { /** * Returns the version of THEOplayer, as a [Version]. */ diff --git a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt index 2b00abd1..98cde93b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,13 +1,9 @@ package com.theoplayer.android.ui -import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack -import com.theoplayer.android.ui.util.constructLabel /** * Return the given time in a human-readable format, such as `"0:10"` or `"01:23:45"`. @@ -29,15 +25,10 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * * @param track the media track or text track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: Track): String { - return remember(track.id, track.uid) { - when (track) { - is TextTrack -> constructLabel(track) - is MediaTrack<*> -> constructLabel(track) - else -> null - } - } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } /** @@ -45,10 +36,10 @@ fun formatTrackLabel(track: Track): String { * * @param track the media track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: MediaTrack<*>): String { - return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } /** @@ -56,8 +47,8 @@ fun formatTrackLabel(track: MediaTrack<*>): String { * * @param track the text track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: TextTrack): String { - return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index 9a21bfd4..ba5c7fd8 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -1,6 +1,5 @@ package com.theoplayer.android.ui -import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -9,16 +8,8 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import java.text.DecimalFormat - -/** - * The default playback rates shown in a [PlaybackRateList]. - */ -private val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) /** * A list of playback rates, from which the user can choose a desired playback rate. @@ -27,6 +18,7 @@ private val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) * @param playbackRates the list of possible playback rates * @param onClick called when a playback rate in the list is clicked */ +@OptIn(InternalUiApi::class) @Composable fun PlaybackRateList( modifier: Modifier = Modifier, @@ -50,6 +42,7 @@ fun PlaybackRateList( * @param onClick called when a playback rate in the list is clicked */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun PlaybackRateList( state: PlaybackRateState, @@ -86,15 +79,4 @@ fun PlaybackRateList( ) } } -} - -@Composable -internal fun formatPlaybackRate(rate: Double): String { - return if (rate == 1.0) { - stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) - } else { - val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) - val decimalFormat = remember(format) { DecimalFormat(format) } - decimalFormat.format(rate) - } } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 77898c73..323c4523 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -9,11 +9,9 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import java.text.DecimalFormat /** * A list of video qualities, from which the user can choose a target video quality. @@ -41,6 +39,7 @@ fun QualityList( * @param onClick called when a video quality in the list is clicked */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun QualityList( state: VideoQualityState, @@ -106,19 +105,4 @@ fun QualityList( ) } } -} - -@Composable -internal fun formatBandwidth(bandwidth: Long): String { - val format = stringResource( - if (bandwidth >= 1e7) { - CommonR.string.theoplayer_ui_bandwidth_format_10mbps - } else if (bandwidth >= 1e6) { - CommonR.string.theoplayer_ui_bandwidth_format_1mbps - } else { - CommonR.string.theoplayer_ui_bandwidth_format_kbps - } - ) - val decimalFormat = remember(format) { DecimalFormat(format) } - return decimalFormat.format(bandwidth / (if (bandwidth >= 1e6) 1e6 else 1e3)) } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 74beede0..45704a9f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality /** * A [Menu] to change the settings of the player, such as the video quality or the playback rate. @@ -42,6 +41,7 @@ fun MenuScope.SettingsMenu() { * @see PlaybackRateMenu */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun MenuScope.SettingsMenu( videoQualityState: VideoQualityState, @@ -171,23 +171,3 @@ fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { PlaybackRateList(state, onClick = { closeCurrentMenu() }) } } - -@Composable -internal fun formatActiveQualityLabel( - targetVideoQuality: VideoQuality?, - activeVideoQuality: VideoQuality? -): String { - return if (targetVideoQuality != null) { - stringResource( - CommonR.string.theoplayer_ui_quality_with_height, - targetVideoQuality.height - ) - } else if (activeVideoQuality != null) { - stringResource( - CommonR.string.theoplayer_ui_quality_automatic_with_height, - activeVideoQuality.height - ) - } else { - stringResource(CommonR.string.theoplayer_ui_quality_automatic) - } -} From 7e9d05c9b7116482d899f58c355bdabcc5c5d17d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 09:06:05 +0000 Subject: [PATCH 084/158] Add Android TV side-panel menus Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/tv/AudioTrackList.kt | 85 +++++ .../theoplayer/android/ui/tv/LanguageMenu.kt | 144 +++++++++ .../android/ui/tv/LanguageMenuButton.kt | 96 ++++++ .../java/com/theoplayer/android/ui/tv/Menu.kt | 145 +++++++++ .../android/ui/tv/PlaybackRateList.kt | 89 +++++ .../theoplayer/android/ui/tv/QualityList.kt | 116 +++++++ .../theoplayer/android/ui/tv/SettingsMenu.kt | 136 ++++++++ .../android/ui/tv/SettingsMenuButton.kt | 44 +++ .../android/ui/tv/SubtitleTrackList.kt | 106 ++++++ .../theoplayer/android/ui/tv/UIController.kt | 22 +- .../android/ui/tv/UIControllerTest.kt | 306 ++++++++++++++++++ 11 files changed, 1285 insertions(+), 4 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt new file mode 100644 index 00000000..40d13ca1 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt @@ -0,0 +1,85 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ListItem +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Text +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberAudioTrackState + +/** + * A list of audio tracks, from which the user can choose an active audio track. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when an audio track in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun AudioTrackList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + AudioTrackList( + state = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of audio tracks, from which the user can choose an active audio track. + * + * @param state the [AudioTrackState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when an audio track in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun AudioTrackList( + state: AudioTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.audioTracks.indexOf(state.activeAudioTrack).coerceAtLeast(0) + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { + items( + count = state.audioTracks.size, + key = { state.audioTracks[it].uid } + ) { index -> + val audioTrack = state.audioTracks[index] + val isActive = state.activeAudioTrack == audioTrack + ListItem( + selected = isActive, + onClick = { + state.select(audioTrack) + onClick?.invoke() + }, + headlineContent = { Text(text = formatTrackName(audioTrack)) }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (index == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt new file mode 100644 index 00000000..87042b78 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt @@ -0,0 +1,144 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberAudioTrackState +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A menu to change the spoken language and subtitles of the stream. + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.LanguageMenu() { + LanguageMenu( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A menu to change the spoken language and subtitles of the stream. + * + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param audioTrackState the [AudioTrackState] to read + * @param subtitleTrackState the [SubtitleTrackState] to read + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenu( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_language)) } + ) { + if (showAudioTracks(audioTrackState)) { + MenuSubmenuItem( + onClick = { openMenu { AudioTrackMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) + }, + supportingContent = { + Text( + text = audioTrackState.activeAudioTrack?.let { formatTrackName(it) } + ?: stringResource(CommonR.string.theoplayer_ui_audio_none) + ) + } + ) + } + if (showSubtitleTracks(subtitleTrackState)) { + MenuSubmenuItem( + onClick = { openMenu { SubtitleMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) + }, + supportingContent = { + Text( + text = subtitleTrackState.activeSubtitleTrack?.let { + formatTrackName(it) + } ?: stringResource(CommonR.string.theoplayer_ui_subtitles_off) + ) + } + ) + } + } +} + +internal fun showAudioTracks(audioTrackState: AudioTrackState): Boolean { + return audioTrackState.audioTracks.size >= 2 +} + +internal fun showSubtitleTracks(subtitleTrackState: SubtitleTrackState): Boolean { + return subtitleTrackState.subtitleTracks.isNotEmpty() +} + +/** + * A menu to change the spoken language of the stream. + * + * @see AudioTrackList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.AudioTrackMenu() { + AudioTrackMenu(state = rememberAudioTrackState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the spoken language of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [AudioTrackState] to read and control + * @see AudioTrackList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.AudioTrackMenu(state: AudioTrackState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) } + ) { + AudioTrackList(state, onClick = { closeCurrentMenu() }) + } +} + +/** + * A menu to change the subtitles of the stream. + * + * @see SubtitleTrackList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SubtitleMenu() { + SubtitleMenu(state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the subtitles of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [SubtitleTrackState] to read and control + * @see SubtitleTrackList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SubtitleMenu(state: SubtitleTrackState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) } + ) { + SubtitleTrackList(state, onClick = { closeCurrentMenu() }) + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt new file mode 100644 index 00000000..3eef4d4e --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt @@ -0,0 +1,96 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.rememberAudioTrackState +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A button that opens the [language menu][LanguageMenu]. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.LanguageMenuButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) + ) + } +) { + LanguageMenuButton( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +/** + * A button that opens the [language menu][LanguageMenu]. + * + * The states are only used to decide whether the button is shown. The opened menu remembers its + * own states. + * + * @param audioTrackState the [AudioTrackState] to read + * @param subtitleTrackState the [SubtitleTrackState] to read + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) + ) + } +) { + if (!showLanguageMenuButton(audioTrackState, subtitleTrackState)) { + return + } + + IconButton( + modifier = modifier, + onClick = { openMenu { LanguageMenu() } } + ) { + Box(Modifier.padding(contentPadding)) { + content() + } + } +} + +internal fun showLanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +): Boolean { + return showAudioTracks(audioTrackState) || showSubtitleTracks(subtitleTrackState) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt new file mode 100644 index 00000000..b0a93049 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -0,0 +1,145 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text +import com.theoplayer.android.ui.MenuContent +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +/** + * A side menu displayed at the end edge of the player. + * + * @param modifier the [Modifier] to be applied to this menu + * @param title the title + * @param content the menu content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.Menu( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + Surface( + modifier = modifier.fillMaxHeight().width(menuWidth), + shape = RoundedCornerShape( + topStart = menuCornerRadius, + bottomStart = menuCornerRadius + ), + colors = SurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { + Box( + modifier = Modifier + .padding(horizontal = menuTitlePadding) + .padding(top = menuTitlePadding) + ) { + title() + } + } + Column( + modifier = Modifier + .padding(horizontal = menuContentPadding) + .focusRequester(focusRequester) + .focusGroup() + ) { + content() + } + } + } + } +} + +/** A menu row that opens a nested menu. */ +@Composable +internal fun MenuSubmenuItem( + onClick: () -> Unit, + headlineContent: @Composable () -> Unit, + supportingContent: @Composable () -> Unit +) { + ListItem( + selected = false, + onClick = onClick, + headlineContent = headlineContent, + supportingContent = supportingContent, + trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } + ) +} + +private val menuWidth = 360.dp +private val menuCornerRadius = 16.dp +private val menuTitlePadding = 24.dp +private val menuContentPadding = 12.dp + +@OptIn(ExperimentalTvUiApi::class) +@Preview( + showBackground = true, + backgroundColor = 0xFF000000, + device = "spec:width=960dp,height=540dp" +) +@Composable +private fun MenuPreview() { + THEOplayerTvTheme { + PreviewMenuScope.Menu( + title = { Text("Language") } + ) { + ListItem( + selected = true, + onClick = {}, + headlineContent = { Text("English") }, + leadingContent = { RadioButton(selected = true, onClick = null) } + ) + ListItem( + selected = false, + onClick = {}, + headlineContent = { Text("Subtitles") }, + supportingContent = { Text("Off") }, + trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } + ) + } + } +} + +private object PreviewMenuScope : MenuScope { + override fun openMenu(menu: MenuContent) = Unit + + override fun closeCurrentMenu() = Unit +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt new file mode 100644 index 00000000..0a2a6ec3 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt @@ -0,0 +1,89 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.tv.material3.ListItem +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackRateState +import com.theoplayer.android.ui.defaultPlaybackRates +import com.theoplayer.android.ui.formatPlaybackRate +import com.theoplayer.android.ui.rememberPlaybackRateState + +/** + * A list of playback rates, from which the user can choose a desired playback rate. + * + * @param modifier the [Modifier] to be applied to this list + * @param playbackRates the list of possible playback rates + * @param onClick called when a playback rate in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun PlaybackRateList( + modifier: Modifier = Modifier, + playbackRates: List = defaultPlaybackRates, + onClick: (() -> Unit)? = null +) { + PlaybackRateList( + state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player), + modifier = modifier, + playbackRates = playbackRates, + onClick = onClick + ) +} + +/** + * A list of playback rates, from which the user can choose a desired playback rate. + * + * @param state the [PlaybackRateState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param playbackRates the list of possible playback rates + * @param onClick called when a playback rate in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun PlaybackRateList( + state: PlaybackRateState, + modifier: Modifier = Modifier, + playbackRates: List = defaultPlaybackRates, + onClick: (() -> Unit)? = null +) { + val selectedIndex = playbackRates.indexOf(state.playbackRate).coerceAtLeast(0) + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { + items( + items = playbackRates, + key = { it } + ) { playbackRate -> + val isActive = state.playbackRate == playbackRate + ListItem( + selected = isActive, + onClick = { + state.select(playbackRate) + onClick?.invoke() + }, + headlineContent = { Text(text = formatPlaybackRate(playbackRate)) }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (playbackRates.indexOf(playbackRate) == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt new file mode 100644 index 00000000..f7e10982 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt @@ -0,0 +1,116 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ListItem +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.VideoQualityState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatBandwidth +import com.theoplayer.android.ui.rememberVideoQualityState + +/** + * A list of video qualities, from which the user can choose a target video quality. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a video quality in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun QualityList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + QualityList( + state = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of video qualities, from which the user can choose a target video quality. + * + * @param state the [VideoQualityState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a video quality in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun QualityList( + state: VideoQualityState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.targetVideoQuality + ?.let { state.videoQualities.indexOf(it) + 1 } + ?: 0 + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { + item(key = null) { + val isActive = state.targetVideoQuality == null + ListItem( + selected = isActive, + onClick = { + state.select(null) + onClick?.invoke() + }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_quality_automatic)) + }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (selectedIndex == 0) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + items( + count = state.videoQualities.size, + key = { state.videoQualities[it].uid } + ) { index -> + val quality = state.videoQualities[index] + val isActive = state.targetVideoQuality == quality + ListItem( + selected = isActive, + onClick = { + state.select(quality) + onClick?.invoke() + }, + headlineContent = { + Text( + text = stringResource( + CommonR.string.theoplayer_ui_quality_with_height, + quality.height + ) + ) + }, + supportingContent = { + Text(text = formatBandwidth(quality.bandwidth)) + }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (index + 1 == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt new file mode 100644 index 00000000..8165278d --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt @@ -0,0 +1,136 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.PlaybackRateState +import com.theoplayer.android.ui.VideoQualityState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatActiveQualityLabel +import com.theoplayer.android.ui.formatPlaybackRate +import com.theoplayer.android.ui.rememberPlaybackRateState +import com.theoplayer.android.ui.rememberVideoQualityState + +/** + * A menu to change the settings of the player, such as video quality or playback rate. + * + * @see QualityMenu + * @see PlaybackRateMenu + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SettingsMenu() { + SettingsMenu( + videoQualityState = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + playbackRateState = rememberPlaybackRateState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A menu to change the settings of the player, such as video quality or playback rate. + * + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param videoQualityState the [VideoQualityState] to read + * @param playbackRateState the [PlaybackRateState] to read + * @see QualityMenu + * @see PlaybackRateMenu + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SettingsMenu( + videoQualityState: VideoQualityState, + playbackRateState: PlaybackRateState +) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_settings)) } + ) { + MenuSubmenuItem( + onClick = { openMenu { QualityMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) + }, + supportingContent = { + Text( + text = formatActiveQualityLabel( + videoQualityState.targetVideoQuality, + videoQualityState.activeVideoQuality + ) + ) + } + ) + MenuSubmenuItem( + onClick = { openMenu { PlaybackRateMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) + }, + supportingContent = { + Text(text = formatPlaybackRate(playbackRateState.playbackRate)) + } + ) + } +} + +/** + * A menu to change the video quality of the stream. + * + * @see QualityList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.QualityMenu() { + QualityMenu(state = rememberVideoQualityState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the video quality of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [VideoQualityState] to read and control + * @see QualityList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.QualityMenu(state: VideoQualityState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) } + ) { + QualityList(state, onClick = { closeCurrentMenu() }) + } +} + +/** + * A menu to change the playback rate of the player. + * + * @see PlaybackRateList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.PlaybackRateMenu() { + PlaybackRateMenu(state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the playback rate of the player. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [PlaybackRateState] to read and control + * @see PlaybackRateList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) } + ) { + PlaybackRateList(state, onClick = { closeCurrentMenu() }) + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt new file mode 100644 index 00000000..fd930fbf --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt @@ -0,0 +1,44 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.common.R as CommonR + +/** + * A button that opens the [settings menu][SettingsMenu]. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SettingsMenuButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + Icons.Rounded.Settings, + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_settings) + ) + } +) { + IconButton( + modifier = modifier, + onClick = { openMenu { SettingsMenu() } } + ) { + Box(Modifier.padding(contentPadding)) { + content() + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt new file mode 100644 index 00000000..6086ecd3 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt @@ -0,0 +1,106 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ListItem +import androidx.tv.material3.RadioButton +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A list of subtitle tracks, from which the user can choose an active subtitle track. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a subtitle track in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun SubtitleTrackList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + SubtitleTrackList( + state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of subtitle tracks, from which the user can choose an active subtitle track. + * + * @param state the [SubtitleTrackState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a subtitle track in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun SubtitleTrackList( + state: SubtitleTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.activeSubtitleTrack + ?.let { state.subtitleTracks.indexOf(it) + 1 } + ?: 0 + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + ) { + item(key = null) { + val isActive = state.activeSubtitleTrack == null + ListItem( + selected = isActive, + onClick = { + state.select(null) + onClick?.invoke() + }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_subtitles_off)) + }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (selectedIndex == 0) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + items( + count = state.subtitleTracks.size, + key = { state.subtitleTracks[it].uid } + ) { index -> + val subtitleTrack = state.subtitleTracks[index] + val isActive = state.activeSubtitleTrack == subtitleTrack + ListItem( + selected = isActive, + onClick = { + state.select(subtitleTrack) + onClick?.invoke() + }, + headlineContent = { Text(text = formatTrackName(subtitleTrack)) }, + leadingContent = { RadioButton(selected = isActive, onClick = null) }, + modifier = if (index + 1 == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt index 10c0e71e..85b6daf7 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -8,6 +8,8 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.focusGroup import androidx.compose.runtime.Composable @@ -24,6 +26,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.player.Player import com.theoplayer.android.ui.ControlsVisibility @@ -115,7 +119,7 @@ fun UIController( errorState = errorState, controls = controls, color = color, - contentTransform = tvContentTransform(), + contentTransform = tvContentTransform(LocalLayoutDirection.current), controlsModifier = Modifier .focusRequester(focusRequester) .focusRestorer() @@ -151,13 +155,23 @@ private fun rememberControlsFocus( } /** - * Returns fade-only transitions for TV content. + * Returns transitions for TV menus. */ -private fun tvContentTransform(): AnimatedContentTransitionScope.() -> ContentTransform = { +private fun tvContentTransform( + layoutDirection: LayoutDirection +): AnimatedContentTransitionScope.() -> ContentTransform = { when { targetState is UIState.Error -> EnterTransition.None togetherWith ExitTransition.None - targetState is UIState.Menu || initialState is UIState.Menu -> + initialState == UIState.Controls && targetState is UIState.Menu -> + slideInHorizontally { + if (layoutDirection == LayoutDirection.Ltr) it else -it + } + fadeIn() togetherWith fadeOut() + initialState is UIState.Menu && targetState == UIState.Controls -> + fadeIn() togetherWith slideOutHorizontally { + if (layoutDirection == LayoutDirection.Ltr) it else -it + } + fadeOut() + initialState is UIState.Menu && targetState is UIState.Menu -> fadeIn() togetherWith fadeOut() else -> EnterTransition.None togetherWith ExitTransition.None diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt index 5ea45761..7d01316f 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -21,9 +21,12 @@ import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerView @@ -32,6 +35,13 @@ import com.theoplayer.android.api.error.THEOplayerException import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList +import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality +import com.theoplayer.android.api.player.track.mediatrack.quality.QualityList +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackList import com.theoplayer.android.api.timerange.TimeRanges import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.UIControllerScope @@ -96,6 +106,184 @@ class UIControllerTest { verify(exactly = 1) { player.currentTime = 30.0 } } + @Test + fun languageMenuButtonIsHiddenWithoutAlternativeTracks() { + val onlyTrack = MenuAudioTrack(uid = 1, label = "English", enabled = true) + setControllerAndShowControls(mockMenuPlayer(audioTracks = listOf(onlyTrack.track))) { + LanguageMenuButton(modifier = Modifier.testTag("language-menu-button")) + } + assertTagDoesNotExist("language-menu-button") + } + + @Test + fun languageMenuButtonIsShownWithAlternativeAudioTracks() { + val onlyTrack = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val secondTrack = MenuAudioTrack(uid = 2, label = "French", enabled = false) + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(onlyTrack.track, secondTrack.track)) + ) { + LanguageMenuButton(modifier = Modifier.testTag("language-menu-button")) + } + waitForTag("language-menu-button") + } + + @Test + fun languageMenuShowsActiveTrackAndFocusesFirstRow() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = false) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + + waitForText("Language") + assertTextExists("Language") + waitForText("English") + assertTextExists("English") + composeRule.onNodeWithText("Audio").assertIsFocused() + } + + @Test + fun audioTrackMenuFocusesActiveTrackAndSelectionReturnsToLanguageMenu() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = false) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = true) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + waitForText("Audio") + composeRule.onNodeWithText("Audio").assertIsFocused() + pressCenter() + + waitForText("French") + composeRule.onNodeWithText("French").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_UP) + composeRule.onNodeWithText("English").assertIsFocused() + pressCenter() + waitForText("Language") + assertTextExists("Language") + assertTextExists("English") + assertTrue(english.enabled) + } + + @Test + fun backReturnsFromSubmenuAndRestoresFocusToControls() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = false) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + waitForText("Audio") + composeRule.onNodeWithText("Audio").assertIsFocused() + pressCenter() + waitForText("English") + assertTextExists("English") + + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + waitForText("Language") + assertTextExists("Language") + composeRule.onNodeWithText("Audio").assertIsFocused() + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + composeRule.waitForIdle() + + assertTagExists("language-menu-button") + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + } + + @Test + fun settingsMenuShowsCurrentValuesAndSelectsPlaybackRate() { + val player = mockMenuPlayer(playbackRate = 1.25) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls(player) { + SettingsMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("settings-menu-button") + ) + } + waitForTag("settings-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("settings-menu-button").assertIsFocused() + pressCenter() + + waitForText("Automatic") + assertTextExists("Automatic") + assertTextExists("1.25x") + composeRule.onNodeWithText("Quality").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("Playback speed").assertIsFocused() + waitForText("Playback speed") + pressCenter() + waitForText("1.5x") + composeRule.onNodeWithText("1.25x").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("1.5x").assertIsFocused() + pressCenter() + + verify(exactly = 1) { player.setPlaybackRate(1.5) } + } + + @Test + fun qualityListSelectsTargetQuality() { + val quality720 = menuVideoQuality(uid = 10, height = 720, bandwidth = 2_500_000) + val quality1080 = menuVideoQuality(uid = 11, height = 1080, bandwidth = 5_000_000) + val videoTrack = MenuVideoTrack(listOf(quality720, quality1080)) + val player = mockMenuPlayer(videoTracks = listOf(videoTrack.track)) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls(player) { + SettingsMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("settings-menu-button") + ) + } + waitForTag("settings-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("settings-menu-button").assertIsFocused() + pressCenter() + waitForText("Quality") + composeRule.onNodeWithText("Quality").assertIsFocused() + pressCenter() + waitForText("720p") + composeRule.onNodeWithText("Automatic").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("720p").assertIsFocused() + pressCenter() + + assertEquals(quality720, videoTrack.targetQuality) + } + @Test fun hiddenDpadCenterPausesAndShowsControls() { val player = mockPlayingPlayer() @@ -494,6 +682,12 @@ class UIControllerTest { } } + private fun waitForText(text: String) { + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + private fun assertTagExists(tag: String) { assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()) } @@ -502,6 +696,10 @@ class UIControllerTest { assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()) } + private fun assertTextExists(text: String) { + assertTrue(composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()) + } + private fun focusableOverlay( focusRequester: FocusRequester, onKeyEvent: (ComposeKeyEvent) -> Boolean = { false } @@ -539,6 +737,56 @@ class UIControllerTest { return player } + private fun setControllerAndShowControls( + player: Player, + bottomChrome: @Composable UIControllerScope.() -> Unit + ) { + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = bottomChrome + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitForIdle() + } + + private fun mockMenuPlayer( + audioTracks: List> = emptyList(), + videoTracks: List> = emptyList(), + playbackRate: Double = 1.0 + ): Player { + val player = mockPlayingPlayer() + every { player.getAudioTracks() } returns mockAudioTrackList(audioTracks) + every { player.getTextTracks() } returns mockTextTrackList(emptyList()) + every { player.getVideoTracks() } returns mockVideoTrackList(videoTracks) + every { player.getPlaybackRate() } returns playbackRate + return player + } + + private fun pressCenter() { + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + composeRule.waitForIdle() + } + + private fun pressDpad(keyCode: Int) { + assertTrue( + composeRule.activity.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + ) + composeRule.activity.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + composeRule.waitForIdle() + } + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, keyCode) ) @@ -547,3 +795,61 @@ class UIControllerTest { const val CONTROLS_AUTO_HIDE_DURATION_MILLIS = 5_000L } } + +private class MenuAudioTrack( + val uid: Int, + val label: String, + var enabled: Boolean +) { + val track: MediaTrack = mockk(relaxed = true) { + every { this@mockk.uid } returns this@MenuAudioTrack.uid + every { this@mockk.label } returns this@MenuAudioTrack.label + every { isEnabled } answers { this@MenuAudioTrack.enabled } + every { setEnabled(any()) } answers { this@MenuAudioTrack.enabled = firstArg() } + } +} + +private class MenuVideoTrack( + private val qualities: List +) { + var targetQuality: VideoQuality? = null + + val track: MediaTrack = mockk(relaxed = true) { + every { this@mockk.uid } returns 100 + every { isEnabled } returns true + every { getActiveQuality() } returns qualities.firstOrNull() + every { getTargetQuality() } answers { this@MenuVideoTrack.targetQuality } + every { setTargetQuality(any()) } answers { + this@MenuVideoTrack.targetQuality = firstArg() + } + every { getQualities() } answers { + mockk>(relaxed = true) { + every { iterator() } answers { + this@MenuVideoTrack.qualities.toMutableList().listIterator() + } + } + } + } +} + +private fun menuVideoQuality(uid: Int, height: Int, bandwidth: Long): VideoQuality = mockk { + every { this@mockk.uid } returns uid + every { this@mockk.height } returns height + every { this@mockk.bandwidth } returns bandwidth +} + +private fun mockAudioTrackList( + tracks: List> +): MediaTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} + +private fun mockVideoTrackList( + tracks: List> +): MediaTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} + +private fun mockTextTrackList(tracks: List): TextTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} From 76f4955a7045b0229e9ea4ba9727de36f84fb673 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 09:07:31 +0000 Subject: [PATCH 085/158] Add changelog link for TV menus 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 9840501e..f8d022cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) * 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) ## [1.14.2] (2026-10-13) From 814ef374fa48fcad2c87f1faaf611fc720a3f9d5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 09:45:45 +0000 Subject: [PATCH 086/158] Restyle TV menus after YouTube's Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/tv/AudioTrackList.kt | 17 +++++++---- .../theoplayer/android/ui/tv/LanguageMenu.kt | 10 +++++++ .../java/com/theoplayer/android/ui/tv/Menu.kt | 30 +++++++++++++++---- .../android/ui/tv/PlaybackRateList.kt | 15 ++++++++-- .../theoplayer/android/ui/tv/QualityList.kt | 22 +++++++++++--- .../theoplayer/android/ui/tv/SettingsMenu.kt | 10 +++++++ .../android/ui/tv/SubtitleTrackList.kt | 22 +++++++++++--- 7 files changed, 105 insertions(+), 21 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt index 40d13ca1..b0a77149 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt @@ -1,21 +1,22 @@ package com.theoplayer.android.ui.tv +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem -import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import com.theoplayer.android.ui.AudioTrackState import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView -import com.theoplayer.android.ui.common.R as CommonR import com.theoplayer.android.ui.formatTrackName import com.theoplayer.android.ui.rememberAudioTrackState @@ -58,7 +59,8 @@ fun AudioTrackList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), - state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { items( count = state.audioTracks.size, @@ -72,8 +74,13 @@ fun AudioTrackList( state.select(audioTrack) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text(text = formatTrackName(audioTrack)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (index == selectedIndex) { Modifier.focusRequester(selectedRequester) } else { diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt index 87042b78..58676119 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt @@ -1,7 +1,11 @@ package com.theoplayer.android.ui.tv +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Audiotrack +import androidx.compose.material.icons.rounded.ClosedCaption import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.theoplayer.android.ui.AudioTrackState import com.theoplayer.android.ui.ExperimentalPlayerStateApi @@ -49,6 +53,9 @@ fun MenuScope.LanguageMenu( headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) }, + leadingContent = { + Icon(Icons.Rounded.Audiotrack, contentDescription = null) + }, supportingContent = { Text( text = audioTrackState.activeAudioTrack?.let { formatTrackName(it) } @@ -63,6 +70,9 @@ fun MenuScope.LanguageMenu( headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) }, + leadingContent = { + Icon(Icons.Rounded.ClosedCaption, contentDescription = null) + }, supportingContent = { Text( text = subtitleTrackState.activeSubtitleTrack?.let { diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt index b0a93049..d6073ecc 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -1,7 +1,9 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxHeight @@ -10,7 +12,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ClosedCaption import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -22,9 +26,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle -import androidx.tv.material3.RadioButton import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text @@ -79,7 +83,8 @@ fun MenuScope.Menu( modifier = Modifier .padding(horizontal = menuContentPadding) .focusRequester(focusRequester) - .focusGroup() + .focusGroup(), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { content() } @@ -93,18 +98,28 @@ fun MenuScope.Menu( internal fun MenuSubmenuItem( onClick: () -> Unit, headlineContent: @Composable () -> Unit, - supportingContent: @Composable () -> Unit + supportingContent: @Composable () -> Unit, + leadingContent: @Composable BoxScope.() -> Unit ) { ListItem( selected = false, onClick = onClick, + colors = menuItemColors(), headlineContent = headlineContent, supportingContent = supportingContent, + leadingContent = leadingContent, trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } ) } -private val menuWidth = 360.dp +@Composable +internal fun menuItemColors() = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant +) + +internal val menuItemVerticalSpacing = 8.dp + +private val menuWidth = 320.dp private val menuCornerRadius = 16.dp private val menuTitlePadding = 24.dp private val menuContentPadding = 12.dp @@ -124,14 +139,19 @@ private fun MenuPreview() { ListItem( selected = true, onClick = {}, + colors = menuItemColors(), headlineContent = { Text("English") }, - leadingContent = { RadioButton(selected = true, onClick = null) } + trailingContent = { Icon(Icons.Rounded.Check, contentDescription = null) } ) ListItem( selected = false, onClick = {}, + colors = menuItemColors(), headlineContent = { Text("Subtitles") }, supportingContent = { Text("Off") }, + leadingContent = { + Icon(Icons.Rounded.ClosedCaption, contentDescription = null) + }, trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } ) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt index 0a2a6ec3..621293bb 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt @@ -1,16 +1,19 @@ package com.theoplayer.android.ui.tv +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRequester +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem -import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView @@ -63,7 +66,8 @@ fun PlaybackRateList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), - state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { items( items = playbackRates, @@ -76,8 +80,13 @@ fun PlaybackRateList( state.select(playbackRate) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text(text = formatPlaybackRate(playbackRate)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (playbackRates.indexOf(playbackRate) == selectedIndex) { Modifier.focusRequester(selectedRequester) } else { diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt index f7e10982..40a75f2a 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt @@ -1,7 +1,10 @@ package com.theoplayer.android.ui.tv +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -9,8 +12,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem -import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView @@ -60,7 +63,8 @@ fun QualityList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), - state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { item(key = null) { val isActive = state.targetVideoQuality == null @@ -70,10 +74,15 @@ fun QualityList( state.select(null) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_quality_automatic)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (selectedIndex == 0) { Modifier.focusRequester(selectedRequester) } else { @@ -93,6 +102,7 @@ fun QualityList( state.select(quality) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text( text = stringResource( @@ -104,7 +114,11 @@ fun QualityList( supportingContent = { Text(text = formatBandwidth(quality.bandwidth)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (index + 1 == selectedIndex) { Modifier.focusRequester(selectedRequester) } else { diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt index 8165278d..78a5b530 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt @@ -1,7 +1,11 @@ package com.theoplayer.android.ui.tv +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.HighQuality +import androidx.compose.material.icons.rounded.Speed import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView @@ -54,6 +58,9 @@ fun MenuScope.SettingsMenu( headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) }, + leadingContent = { + Icon(Icons.Rounded.HighQuality, contentDescription = null) + }, supportingContent = { Text( text = formatActiveQualityLabel( @@ -68,6 +75,9 @@ fun MenuScope.SettingsMenu( headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) }, + leadingContent = { + Icon(Icons.Rounded.Speed, contentDescription = null) + }, supportingContent = { Text(text = formatPlaybackRate(playbackRateState.playbackRate)) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt index 6086ecd3..7b2edd38 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt @@ -1,7 +1,10 @@ package com.theoplayer.android.ui.tv +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -9,8 +12,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem -import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView @@ -60,7 +63,8 @@ fun SubtitleTrackList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), - state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex) + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { item(key = null) { val isActive = state.activeSubtitleTrack == null @@ -70,10 +74,15 @@ fun SubtitleTrackList( state.select(null) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_subtitles_off)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (selectedIndex == 0) { Modifier.focusRequester(selectedRequester) } else { @@ -93,8 +102,13 @@ fun SubtitleTrackList( state.select(subtitleTrack) onClick?.invoke() }, + colors = menuItemColors(), headlineContent = { Text(text = formatTrackName(subtitleTrack)) }, - leadingContent = { RadioButton(selected = isActive, onClick = null) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, modifier = if (index + 1 == selectedIndex) { Modifier.focusRequester(selectedRequester) } else { From 912ab1117b1e167e9d36c4faed74ced53623707d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 12:45:35 +0000 Subject: [PATCH 087/158] Add spacing below the TV menu title Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt index d6073ecc..0ea4222f 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -73,8 +73,12 @@ fun MenuScope.Menu( ProvideTextStyle(MaterialTheme.typography.titleLarge) { Box( modifier = Modifier - .padding(horizontal = menuTitlePadding) - .padding(top = menuTitlePadding) + .padding( + start = menuTitlePadding, + top = menuTitlePadding, + end = menuTitlePadding, + bottom = menuTitleBottomSpacing + ) ) { title() } @@ -122,6 +126,7 @@ internal val menuItemVerticalSpacing = 8.dp private val menuWidth = 320.dp private val menuCornerRadius = 16.dp private val menuTitlePadding = 24.dp +private val menuTitleBottomSpacing = 16.dp private val menuContentPadding = 12.dp @OptIn(ExperimentalTvUiApi::class) From a3c4bccfe949effaffc73b24759dbe48e2e782f1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 13:15:35 +0000 Subject: [PATCH 088/158] Avoid clipping focused items in TV menu lists Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt | 2 ++ ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt | 3 +++ .../main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt | 2 ++ .../src/main/java/com/theoplayer/android/ui/tv/QualityList.kt | 2 ++ .../java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt | 2 ++ 5 files changed, 11 insertions(+) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt index b0a77149..f8b4ca12 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -60,6 +61,7 @@ fun AudioTrackList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { items( diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt index 0ea4222f..17184ab3 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -123,6 +123,9 @@ internal fun menuItemColors() = ListItemDefaults.colors( internal val menuItemVerticalSpacing = 8.dp +// Leaves room for the focused item's scale so lazy lists don't clip it. +internal val menuItemFocusOverflowPadding = 4.dp + private val menuWidth = 320.dp private val menuCornerRadius = 16.dp private val menuTitlePadding = 24.dp diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt index 621293bb..85237747 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -67,6 +68,7 @@ fun PlaybackRateList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { items( diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt index 40a75f2a..57e531f5 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -64,6 +65,7 @@ fun QualityList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { item(key = null) { diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt index 7b2edd38..4655546a 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -64,6 +65,7 @@ fun SubtitleTrackList( LazyColumn( modifier = modifier.focusRestorer(selectedRequester), state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) ) { item(key = null) { From 2505270bd3991740d5a76d3f2c21d331b92bec44 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:50:00 +0000 Subject: [PATCH 089/158] Keep focus in TV UIController while controls are hidden Without a focused node, D-pad keys pressed while the controls are hidden never reach the controller's key handler. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/tv/UIController.kt | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt index 85b6daf7..a71e58aa 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -12,6 +12,9 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -20,6 +23,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color @@ -91,13 +95,15 @@ fun UIController( hideDelay = playerAnimations.controlsHideDelay ) val focusRequester = remember { FocusRequester() } + val hiddenFocusRequester = remember { FocusRequester() } val currentUiState = uiState.value rememberControlsFocus( uiState = currentUiState, menuOpen = scope.currentMenu != null, controls = controls, - focusRequester = focusRequester + focusRequester = focusRequester, + hiddenFocusRequester = hiddenFocusRequester ) BackHandler( enabled = currentUiState is UIState.Controls && @@ -106,34 +112,47 @@ fun UIController( controls.hide() } - UIControllerCore( + Box( modifier = modifier.tvHiddenControlsKeys( player = nativePlayer, controlsVisible = { uiState.value.isVisible(controls.visible.value) }, showControls = controls.show - ), - theoplayerView = view, - player = null, - scope = scope, - uiState = uiState, - errorState = errorState, - controls = controls, - color = color, - contentTransform = tvContentTransform(LocalLayoutDirection.current), - controlsModifier = Modifier - .focusRequester(focusRequester) - .focusRestorer() - .focusGroup(), - centerOverlay = centerOverlay, - errorOverlay = errorOverlay, - topChrome = topChrome, - centerChrome = centerChrome, - bottomChrome = bottomChrome - ) + ) + ) { + // Holds focus while the controls are hidden, so key events still reach this controller. + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(hiddenFocusRequester) + .focusProperties { canFocus = !uiState.value.isVisible(controls.visible.value) } + .focusable() + ) + UIControllerCore( + modifier = Modifier.fillMaxSize(), + theoplayerView = view, + player = null, + scope = scope, + uiState = uiState, + errorState = errorState, + controls = controls, + color = color, + contentTransform = tvContentTransform(LocalLayoutDirection.current), + controlsModifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .focusGroup(), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) + } } /** - * Restores focus to controls when they become visible or a menu closes. + * Restores focus to controls when they become visible or a menu closes, + * and moves focus to [hiddenFocusRequester] when the controls are hidden. */ @OptIn(InternalUiApi::class) @Composable @@ -141,7 +160,8 @@ private fun rememberControlsFocus( uiState: UIState, menuOpen: Boolean, controls: ControlsVisibility, - focusRequester: FocusRequester + focusRequester: FocusRequester, + hiddenFocusRequester: FocusRequester ) { var menuWasOpen by remember { mutableStateOf(false) } LaunchedEffect(controls.visible.value, menuOpen, uiState) { @@ -149,6 +169,8 @@ private fun rememberControlsFocus( (controls.visible.value || menuWasOpen) ) { focusRequester.requestFocus() + } else if (uiState is UIState.Controls && !menuOpen && !controls.visible.value) { + hiddenFocusRequester.requestFocus() } menuWasOpen = menuOpen } From d8b47ec49a03592211ad31d61478d638b110ea47 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:50:00 +0000 Subject: [PATCH 090/158] Add TV DefaultUI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/tv/DefaultUI.kt | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt new file mode 100644 index 00000000..4341b6fb --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt @@ -0,0 +1,224 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.source.SourceDescription +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.StreamType +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.rememberAdState +import com.theoplayer.android.ui.rememberFirstPlayState +import com.theoplayer.android.ui.rememberPlaybackTimeState +import com.theoplayer.android.ui.rememberTHEOplayerView +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +/** + * A default THEOplayer UI component for Android TV. + * + * This component provides a great player experience out-of-the-box, that works for all types + * of streams. It provides all the common playback controls for playing, seeking, changing + * languages and qualities, and can be fully controlled with a D-pad. + * + * The colors and fonts can be changed by wrapping this inside a [THEOplayerTvTheme]. + * For more extensive customizations, we recommend defining your own custom UI using + * a [UIController]. + * + * @param modifier the [Modifier] to be applied to this UI + * @param config the player configuration to be used when constructing the [THEOplayerView] + * @param source the source description to load into the player + * @param title the stream's title, shown at the top of the player + * @see UIController + */ +@ExperimentalTvUiApi +@Composable +fun DefaultUI( + modifier: Modifier = Modifier, + config: THEOplayerConfig, + source: SourceDescription? = null, + title: String? = null +) { + val theoplayerView = rememberTHEOplayerView(config) + LaunchedEffect(theoplayerView, source) { + if (!theoplayerView.isInEditMode) { + theoplayerView.player.source = source + } + } + + DefaultUI(modifier = modifier, theoplayerView = theoplayerView, title = title) +} + +/** + * A default THEOplayer UI component for Android TV. + * + * This component provides a great player experience out-of-the-box, that works for all types + * of streams. It provides all the common playback controls for playing, seeking, changing + * languages and qualities, and can be fully controlled with a D-pad. + * + * Use [LocalTHEOplayerView] and the experimental state holders to access the player + * inside this UI. + * + * This does not touch the lifecycle of the given view: use [rememberTHEOplayerView] to + * create a view owned by the composition, or forward [THEOplayerView.onPause], + * [THEOplayerView.onResume] and [THEOplayerView.onDestroy] yourself. + * + * The colors and fonts can be changed by wrapping this inside a [THEOplayerTvTheme]. + * For more extensive customizations, we recommend defining your own custom UI using + * a [UIController]. + * + * @param modifier the [Modifier] to be applied to this UI + * @param theoplayerView the THEOplayer view to host in this UI + * @param title the stream's title, shown at the top of the player + * @see UIController + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun DefaultUI( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + title: String? = null +) { + val player = theoplayerView.takeUnless { it.isInEditMode }?.player + val firstPlayState = rememberFirstPlayState(player) + val adState = rememberAdState(player) + val playbackTimeState = rememberPlaybackTimeState(player) + // Show all controls in previews, which never start playing. + val firstPlay = firstPlayState.firstPlay || LocalInspectionMode.current + UIController( + modifier = modifier, + theoplayerView = theoplayerView, + color = Color.Black.copy(alpha = 0.5f), + centerOverlay = { + if (firstPlayState.firstPlay) { + LoadingSpinner() + } + }, + topChrome = { + if (firstPlay) { + title?.let { + Text( + modifier = Modifier.padding( + start = safeAreaHorizontalPadding, + top = safeAreaVerticalPadding, + end = safeAreaHorizontalPadding + ), + text = it, + style = MaterialTheme.typography.headlineMedium + ) + } + } + }, + centerChrome = { + if (!firstPlay) { + PlayButton( + modifier = Modifier.size(80.dp), + iconModifier = Modifier.size(48.dp) + ) + } + }, + bottomChrome = { + if (firstPlay) { + BottomControls( + playingAd = adState.playingAd, + streamType = playbackTimeState.streamType + ) + } + }, + errorOverlay = { + Box(contentAlignment = Alignment.Center) { + ErrorDisplay() + } + } + ) +} + +/** + * The time displays, seek bar and buttons at the bottom of [DefaultUI]. + */ +@ExperimentalTvUiApi +@Composable +private fun UIControllerScope.BottomControls(playingAd: Boolean, streamType: StreamType) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = safeAreaHorizontalPadding, + end = safeAreaHorizontalPadding, + bottom = safeAreaVerticalPadding + ) + ) { + if (streamType != StreamType.Live) { + if (!playingAd) { + Row(verticalAlignment = Alignment.CenterVertically) { + CurrentTimeDisplay(showRemaining = streamType == StreamType.Dvr) + Spacer(modifier = Modifier.weight(1f)) + if (streamType == StreamType.Vod) { + DurationDisplay() + } + } + } + SeekBar(modifier = Modifier.fillMaxWidth()) + } + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.weight(1f)) { + if (!playingAd) { + LiveButton() + } + } + PlayButton( + modifier = Modifier.size(56.dp), + iconModifier = Modifier.size(32.dp) + ) + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + if (!playingAd) { + LanguageMenuButton() + SettingsMenuButton() + } + } + } + } +} + +// Keep controls inside the overscan-safe area of TVs. +private val safeAreaHorizontalPadding = 48.dp +private val safeAreaVerticalPadding = 27.dp + +@OptIn(ExperimentalTvUiApi::class) +@Preview( + showBackground = true, + backgroundColor = 0xFF000000, + device = "spec:width=960dp,height=540dp" +) +@Composable +private fun DefaultUIPreview() { + THEOplayerTvTheme { + DefaultUI( + theoplayerView = rememberTHEOplayerView(), + title = "Big Buck Bunny" + ) + } +} From 45a067f2aca6e382d476e36680d8017fa0219f5a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:50:00 +0000 Subject: [PATCH 091/158] Add tests for TV DefaultUI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/tv/DefaultUITest.kt | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt new file mode 100644 index 00000000..7e42663a --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt @@ -0,0 +1,156 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithText +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.timerange.TimeRanges +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class DefaultUITest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val seekBar = SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo) + + @Test + fun beforeFirstPlayShowsOnlyPlayButton() { + setDefaultUI(mockPlayer(paused = true, played = false)) + + waitUntil { hasContentDescription("Play") } + assertFalse(hasText(TITLE)) + assertFalse(hasContentDescription("Settings")) + assertTrue(composeRule.onAllNodes(seekBar).fetchSemanticsNodes().isEmpty()) + } + + @Test + fun afterFirstPlayShowsTitleTimesSeekBarAndSettings() { + setDefaultUI(mockPlayer(paused = true, played = true)) + + waitUntil { hasText(TITLE) } + assertTrue(hasText("00:40")) + assertTrue(hasText("01:40")) + assertTrue(hasContentDescription("Play")) + assertTrue(hasContentDescription("Settings")) + assertTrue(composeRule.onAllNodes(seekBar).fetchSemanticsNodes().isNotEmpty()) + } + + @Test + fun seekBarIsFocusedWhenControlsAreShown() { + setDefaultUI(mockPlayer(paused = true, played = true)) + + composeRule.waitUntil(10_000) { + composeRule.onAllNodes(seekBar and SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) + .fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNode(seekBar).assertIsFocused() + } + + @Test + fun liveStreamShowsLiveButtonWithoutSeekBar() { + setDefaultUI(mockPlayer(paused = true, played = true, duration = Double.POSITIVE_INFINITY)) + + waitUntil { hasText("LIVE") } + assertTrue(composeRule.onAllNodes(seekBar).fetchSemanticsNodes().isEmpty()) + assertFalse(hasText("01:40")) + } + + @Test + fun hiddenDpadCenterPausesAndShowsControls() { + val player = mockPlayer(paused = false, played = true) + setDefaultUI(player) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertFalse(hasText(TITLE)) + + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + composeRule.waitForIdle() + + verify { player.pause() } + waitUntil { hasText(TITLE) } + } + + private fun setDefaultUI(player: Player) { + lateinit var theoplayerView: THEOplayerView + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player) } + theoplayerView = view + THEOplayerTvTheme { + DefaultUI( + modifier = Modifier.fillMaxSize(), + theoplayerView = view, + title = TITLE + ) + } + } + composeRule.waitUntil(10_000) { + val container = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + (0 until container.childCount).any { container.getChildAt(it) is ComposeView } + } + composeRule.waitForIdle() + } + + private fun waitUntil(condition: () -> Boolean) { + composeRule.waitUntil(10_000, condition) + } + + private fun hasText(text: String): Boolean = + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + + private fun hasContentDescription(description: String): Boolean = + composeRule.onAllNodesWithContentDescription(description).fetchSemanticsNodes().isNotEmpty() + + private fun mockPlayer( + paused: Boolean, + played: Boolean, + duration: Double = 100.0 + ): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val playedRanges = mockk(relaxed = true) + every { playedRanges.length() } returns if (played) 1 else 0 + val player = mockk(relaxed = true) + every { player.isPaused } returns paused + every { player.isEnded } returns false + every { player.played } returns playedRanges + every { player.ads } returns ads + every { player.currentTime } returns 40.0 + every { player.duration } returns duration + return player + } + + private companion object { + const val TITLE = "Big Buck Bunny" + } +} From 0e0f4e7ed651f6bc1405dbe4ad7888cac41e86c5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:50:10 +0000 Subject: [PATCH 092/158] Temporarily disable maven build type of demo app The demo app is about to use android-ui-tv, which isn't published to Maven yet. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3898a598..0b19f5fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,13 @@ android { } } +androidComponents { + // TODO Re-enable once android-ui-tv is published to Maven + beforeVariants(selector().withBuildType("maven")) { variantBuilder -> + variantBuilder.enable = false + } +} + kotlin { compilerOptions { apiVersion = KotlinVersion.KOTLIN_2_0 From 1514fce73a92ee8cc1af45278555256302418527 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:50:10 +0000 Subject: [PATCH 093/158] Add Android TV demo activity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/build.gradle.kts | 2 ++ app/src/main/AndroidManifest.xml | 21 +++++++++++ .../theoplayer/android/ui/demo/TvActivity.kt | 33 ++++++++++++++++++ app/src/main/res/drawable-xhdpi/tv_banner.png | Bin 0 -> 15512 bytes 4 files changed, 56 insertions(+) create mode 100644 app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt create mode 100644 app/src/main/res/drawable-xhdpi/tv_banner.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b19f5fe..28428bda 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -111,6 +111,8 @@ dependencies { debugImplementation(project(":ui")) releaseImplementation(project(":ui")) + debugImplementation(project(":ui-tv")) + releaseImplementation(project(":ui-tv")) mavenImplementation("com.theoplayer.android-ui:android-ui:1.+") latestPlayerImplementation(libs.theoplayer) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b73460f..cfe382b7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,8 +7,16 @@ + + + + + + + + + + KVQ%7L_IRpW@fs|1c5-9b#*k1ArRVd2!zJ< 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 Date: Thu, 15 Oct 2026 14:51:24 +0000 Subject: [PATCH 094/158] Add preview for DefaultUI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/DefaultUI.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 1a0cce2f..88dc6c01 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView @@ -131,6 +133,9 @@ private fun DefaultUIImpl( val adState = rememberAdState(theoplayerView?.player) val playbackTimeState = rememberPlaybackTimeState(theoplayerView?.player) val fullscreenState = rememberFullscreenState(theoplayerView) + + // Show all controls in previews, which never start playing. + val firstPlay = firstPlayState.firstPlay || LocalInspectionMode.current UIControllerImpl( modifier = modifier, theoplayerView = theoplayerView, @@ -142,7 +147,7 @@ private fun DefaultUIImpl( } }, topChrome = { - if (firstPlayState.firstPlay) { + if (firstPlay) { Row(verticalAlignment = Alignment.CenterVertically) { title?.let { Text( @@ -160,16 +165,16 @@ private fun DefaultUIImpl( } }, centerChrome = { - if (firstPlayState.firstPlay && !adState.playingAd) { + if (firstPlay && !adState.playingAd) { SeekButton(seekOffset = -10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } PlayButton(iconModifier = Modifier.size(96.dp), contentPadding = PaddingValues(8.dp)) - if (firstPlayState.firstPlay && !adState.playingAd) { + if (firstPlay && !adState.playingAd) { SeekButton(seekOffset = 10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } }, bottomChrome = { - if (firstPlayState.firstPlay) { + if (firstPlay) { ChromecastDisplay(modifier = Modifier.padding(8.dp)) if (!adState.playingAd && playbackTimeState.streamType != StreamType.Live) { SeekBar() @@ -206,3 +211,18 @@ private fun DefaultUIImpl( } ) } + +@Preview( + showBackground = true, + backgroundColor = 0xFF000000, + device = "spec:width=411dp,height=891dp,orientation=landscape" +) +@Composable +private fun DefaultUIPreview() { + THEOplayerTheme(useDarkTheme = true) { + DefaultUI( + theoplayerView = rememberTHEOplayerView(), + title = "Big Buck Bunny" + ) + } +} From 985cd154017b553763bc78238ed43fccc1bb7042 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:51:30 +0000 Subject: [PATCH 095/158] Generate multi-module API documentation Each module (android-ui, android-ui-common and android-ui-tv) now gets its own section in the API docs. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- build.gradle.kts | 23 ++++++++++++++++++++++- ui-common/build.gradle.kts | 1 + ui-tv/build.gradle.kts | 1 + ui/build.gradle.kts | 14 ++------------ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d0dbd162..df939592 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,7 +39,7 @@ jobs: # https://github.com/gradle/actions/blob/v6.0.0/README.md#licensing-notice cache-disabled: true - name: Build API documentation with Dokka - run: ./gradlew ui:dokkaGeneratePublicationHtml + run: ./gradlew :dokkaGeneratePublicationHtml - name: Build with Jekyll uses: actions/jekyll-build-pages@v1 with: diff --git a/build.gradle.kts b/build.gradle.kts index a88d168e..c9478436 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ import org.jetbrains.changelog.date +import java.time.Year +import kotlin.text.Typography.copyright // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { @@ -16,10 +18,29 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.compose.compiler) apply false - alias(libs.plugins.dokka) apply false + alias(libs.plugins.dokka) alias(libs.plugins.changelog) } +dependencies { + dokka(project(":ui")) + dokka(project(":ui-common")) + dokka(project(":ui-tv")) +} + +dokka { + moduleName = rootProject.name + + pluginsConfiguration.html { + customAssets.from("ui/assets/logo-icon.svg") + footerMessage = "$copyright ${Year.now().value} Dolby Laboratories, Inc. All rights reserved." + } + + dokkaPublications.html { + outputDirectory = rootDir.resolve("site/api") + } +} + changelog { version = project.version.toString() path = file("CHANGELOG.md").canonicalPath diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 95b5c990..13cb8525 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { dokka { moduleName = "android-ui-common" + modulePath = "android-ui-common" dokkaSourceSets.configureEach { sourceLink { diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts index 18cc1c76..93fec068 100644 --- a/ui-tv/build.gradle.kts +++ b/ui-tv/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { dokka { moduleName = "android-ui-tv" + modulePath = "android-ui-tv" dokkaSourceSets.configureEach { sourceLink { diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index fa314f76..207b9283 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -104,21 +104,15 @@ dependencies { } dokka { - moduleName = rootProject.name + moduleName = "android-ui" + modulePath = "android-ui" dokkaSourceSets.configureEach { - sourceRoots.from(rootProject.file("ui-common/src/main/java")) - sourceLink { localDirectory = file("src/main/java") remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui/src/main/java") remoteLineSuffix = "#L" } - sourceLink { - localDirectory = rootProject.file("ui-common/src/main/java") - remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-common/src/main/java") - remoteLineSuffix = "#L" - } externalDocumentationLinks { register("com.theoplayer.android.api") { @@ -133,10 +127,6 @@ dokka { footerMessage = "$copyright ${Year.now().value} Dolby Laboratories, Inc. All rights reserved." } - dokkaPublications.html { - outputDirectory = rootDir.resolve("site/api") - } - dokkaPublications.javadoc { enabled = true } From 51aaa5b974c9512aa62c8a67190d690b16c49279 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 14:54:52 +0000 Subject: [PATCH 096/158] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d022cf..a727e2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) * 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) +* 📝 The API documentation now has a separate section for each module (`android-ui`, `android-ui-common` and `android-ui-tv`). ([#128](https://github.com/THEOplayer/android-ui/pull/128)) ## [1.14.2] (2026-10-13) From 7f28b8f34a00018a794c862c8a17e77f2e048da0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 15:07:53 +0000 Subject: [PATCH 097/158] Set content color in DefaultUI previews Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../java/com/theoplayer/android/ui/tv/DefaultUI.kt | 11 +++++++---- .../main/java/com/theoplayer/android/ui/DefaultUI.kt | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt index 4341b6fb..f07e6de9 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView @@ -216,9 +217,11 @@ private val safeAreaVerticalPadding = 27.dp @Composable private fun DefaultUIPreview() { THEOplayerTvTheme { - DefaultUI( - theoplayerView = rememberTHEOplayerView(), - title = "Big Buck Bunny" - ) + Surface { + DefaultUI( + theoplayerView = rememberTHEOplayerView(), + title = "Big Buck Bunny" + ) + } } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 88dc6c01..175fa631 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -220,9 +221,11 @@ private fun DefaultUIImpl( @Composable private fun DefaultUIPreview() { THEOplayerTheme(useDarkTheme = true) { - DefaultUI( - theoplayerView = rememberTHEOplayerView(), - title = "Big Buck Bunny" - ) + Surface { + DefaultUI( + theoplayerView = rememberTHEOplayerView(), + title = "Big Buck Bunny" + ) + } } } From 1d1ffd02d036da4b44eac76232c78303f1b535e9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 15:07:53 +0000 Subject: [PATCH 098/158] Set content color in TvActivity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/build.gradle.kts | 2 ++ .../com/theoplayer/android/ui/demo/TvActivity.kt | 15 +++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28428bda..571520a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,6 +113,8 @@ dependencies { releaseImplementation(project(":ui")) debugImplementation(project(":ui-tv")) releaseImplementation(project(":ui-tv")) + debugImplementation(libs.androidx.tv.material) + releaseImplementation(libs.androidx.tv.material) mavenImplementation("com.theoplayer.android-ui:android-ui:1.+") latestPlayerImplementation(libs.theoplayer) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt index 8efbee81..4a96d43e 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt @@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier +import androidx.tv.material3.Surface import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.ui.tv.DefaultUI import com.theoplayer.android.ui.tv.ExperimentalTvUiApi @@ -21,12 +22,14 @@ class TvActivity : ComponentActivity() { val stream = streams.first() setContent { THEOplayerTvTheme { - DefaultUI( - modifier = Modifier.fillMaxSize(), - config = THEOplayerConfig.Builder().build(), - source = stream.source, - title = stream.title - ) + Surface(modifier = Modifier.fillMaxSize()) { + DefaultUI( + modifier = Modifier.fillMaxSize(), + config = THEOplayerConfig.Builder().build(), + source = stream.source, + title = stream.title + ) + } } } } From eebaf551d0719633fcf0b7716ce655e8f568dcdc Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 15:43:48 +0000 Subject: [PATCH 099/158] Add TextTrackStyle.marginBottomCompat Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/util/Compat.kt | 38 ++++++++++++ .../theoplayer/android/ui/util/CompatTest.kt | 60 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/util/CompatTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt index 4a38ad6f..f130b07d 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -2,6 +2,7 @@ package com.theoplayer.android.ui.util import androidx.annotation.DoNotInline import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle import com.theoplayer.android.ui.InternalUiApi /** @@ -16,8 +17,45 @@ val TextTrack.captionChannelCompat: Int? } else null } +/** + * The bottom margin of the subtitle drawing box, as a percentage of the player's height. + * + * Returns `null` and ignores new values if [TextTrackStyle.setMarginBottom] isn't available. + */ +@InternalUiApi +var TextTrackStyle.marginBottomCompat: Int? + get() { + // TextTrackStyle.getMarginBottom was added in THEOplayer 9.5.0. + return if (THEOplayerGlobalExt.version >= version95) { + TheoPlayer95Impl.getTextTrackStyleMarginBottom(this) + } else null + } + set(value) { + if (value != null && THEOplayerGlobalExt.version >= version95) { + TheoPlayer95Impl.setTextTrackStyleMarginBottom(this, value) + } + } + +private val version95 = Version(major = 9, minor = 5, patchAndPrerelease = "0") private val version1013 = Version(major = 10, minor = 13, patchAndPrerelease = "0") +/** + * This class must be loaded **only** with THEOplayer 9.5.0 or higher. + * + * @see TheoPlayer1013Impl + */ +private class TheoPlayer95Impl private constructor() { + companion object { + @DoNotInline + fun getTextTrackStyleMarginBottom(style: TextTrackStyle): Int = style.marginBottom + + @DoNotInline + fun setTextTrackStyleMarginBottom(style: TextTrackStyle, marginBottom: Int) { + style.marginBottom = marginBottom + } + } +} + /** * This class must be loaded **only** with THEOplayer 10.13.0 or higher. * diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/util/CompatTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/util/CompatTest.kt new file mode 100644 index 00000000..a191b261 --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/util/CompatTest.kt @@ -0,0 +1,60 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CompatTest { + private val style = mockk(relaxed = true) + + @After + fun tearDown() { + clearMocks(THEOplayerGlobalExt) + } + + @Test + fun `GIVEN THEOplayer 9_5_0 WHEN getting marginBottomCompat THEN returns the bottom margin`() { + mockPlayerVersion("9.5.0") + every { style.marginBottom } returns 20 + + assertEquals(20, style.marginBottomCompat) + } + + @Test + fun `GIVEN THEOplayer 9_5_0 WHEN setting marginBottomCompat THEN sets the bottom margin`() { + mockPlayerVersion("9.5.0") + + style.marginBottomCompat = 20 + + verify { style.marginBottom = 20 } + } + + @Test + fun `GIVEN THEOplayer 9_4_0 WHEN getting marginBottomCompat THEN returns null`() { + mockPlayerVersion("9.4.0") + + assertNull(style.marginBottomCompat) + verify(exactly = 0) { style.marginBottom } + } + + @Test + fun `GIVEN THEOplayer 9_4_0 WHEN setting marginBottomCompat THEN does nothing`() { + mockPlayerVersion("9.4.0") + + style.marginBottomCompat = 20 + + verify(exactly = 0) { style.marginBottom = any() } + } + + private fun mockPlayerVersion(version: String) { + mockkObject(THEOplayerGlobalExt) + every { THEOplayerGlobalExt.version } returns Version.parse(version) + } +} From f0bf67d78132cedefc5a93a623ed50556aed636c Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 15:45:36 +0000 Subject: [PATCH 100/158] Move subtitles out of the way of visible controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/TextTrackMargins.kt | 116 ++++++++++++++++++ .../theoplayer/android/ui/UIControllerCore.kt | 36 +++++- .../android/ui/TextTrackMarginsUpdaterTest.kt | 99 +++++++++++++++ .../theoplayer/android/ui/tv/DefaultUITest.kt | 15 +++ 4 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/TextTrackMargins.kt create mode 100644 ui-common/src/test/java/com/theoplayer/android/ui/TextTrackMarginsUpdaterTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackMargins.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackMargins.kt new file mode 100644 index 00000000..b55e9489 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackMargins.kt @@ -0,0 +1,116 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Rect +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle +import com.theoplayer.android.ui.util.marginBottomCompat +import kotlin.math.ceil + +/** + * The space covered by the top and bottom chrome, as a percentage of the player's height. + */ +internal data class ChromeMargins(val top: Int = 0, val bottom: Int = 0) + +/** + * The bounds of the player and its top and bottom chrome, in root coordinates. + */ +@Stable +internal class ChromeBounds { + var player by mutableStateOf(Rect.Zero) + var topChrome by mutableStateOf(Rect.Zero) + var bottomChrome by mutableStateOf(Rect.Zero) + + /** + * Returns the space covered by the top and bottom chrome. + */ + val margins: ChromeMargins + get() = ChromeMargins( + top = if (topChrome.isEmpty) 0 else percentOfPlayerHeight(topChrome.bottom - player.top), + bottom = if (bottomChrome.isEmpty) 0 else percentOfPlayerHeight(player.bottom - bottomChrome.top) + ) + + /** + * Converts a distance to a percentage of the player's height, rounded up. + */ + private fun percentOfPlayerHeight(distance: Float): Int { + if (player.height <= 0f) return 0 + return ceil(distance / player.height * 100f).toInt().coerceIn(0, 100) + } +} + +/** + * Moves the player's text tracks out of the way of the given [chromeMargins]. + * + * The margins are restored when the effect leaves the composition. + */ +@Composable +internal fun TextTrackMarginsEffect(player: Player?, chromeMargins: ChromeMargins) { + val updater = remember(player) { TextTrackMarginsUpdater() } + DisposableEffect(player, chromeMargins) { + player?.textTrackStyle?.let { updater.update(it, chromeMargins) } + onDispose {} + } + DisposableEffect(player) { + onDispose { + player?.textTrackStyle?.let { updater.update(it, ChromeMargins()) } + } + } +} + +/** + * Keeps text tracks clear of the chrome, without reducing the margins set by the user. + * + * Based on `UIContainer._updateTextTrackMargins` in [Open Video UI for Web](https://github.com/THEOplayer/web-ui). + */ +internal class TextTrackMarginsUpdater { + private val top = MarginUpdater( + get = { it.marginTop }, + set = { style, margin -> style.marginTop = margin } + ) + private val bottom = MarginUpdater( + get = { it.marginBottomCompat }, + set = { style, margin -> style.marginBottomCompat = margin } + ) + + /** + * Updates the margins of the [style] to keep clear of the [chromeMargins]. + */ + fun update(style: TextTrackStyle, chromeMargins: ChromeMargins) { + top.update(style, chromeMargins.top) + bottom.update(style, chromeMargins.bottom) + } +} + +/** + * Updates a single margin of a [TextTrackStyle]. + */ +private class MarginUpdater( + private val get: (TextTrackStyle) -> Int?, + private val set: (TextTrackStyle, Int) -> Unit +) { + private var userMargin: Int = 0 + private var appliedMargin: Int? = null + + /** + * Sets the margin to the largest of the user's margin and the [chromeMargin]. + */ + fun update(style: TextTrackStyle, chromeMargin: Int) { + val currentMargin = get(style) ?: return + // A value different from the last update was set by the user. + if (currentMargin != appliedMargin) { + userMargin = currentMargin + } + val margin = maxOf(userMargin, chromeMargin) + if (currentMargin != margin) { + set(style, margin) + } + appliedMargin = margin + } +} 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 f3fc663c..1c8c1c4f 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 @@ -23,10 +23,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.ui.theme.LocalPlayerAnimations import kotlin.time.DurationUnit @@ -70,6 +73,13 @@ fun UIControllerCore( ) ) + val chromeBounds = remember { ChromeBounds() } + val chromeVisible = uiState.value == UIState.Controls && controls.visible.value + TextTrackMarginsEffect( + player = nativePlayer.takeUnless { theoplayerView?.isInEditMode == true }, + chromeMargins = if (chromeVisible) chromeBounds.margins else ChromeMargins() + ) + BackHandler(enabled = scope.currentMenu != null) { scope.closeCurrentMenu() } @@ -96,6 +106,7 @@ fun UIControllerCore( controls = controls, adPlaying = adState.playingAd, background = animatedBackground, + chromeBounds = chromeBounds, controlsModifier = controlsModifier, contentTransform = contentTransform, centerOverlay = centerOverlay, @@ -120,6 +131,7 @@ private fun UIControllerContent( controls: ControlsVisibility, adPlaying: Boolean, background: Color, + chromeBounds: ChromeBounds, controlsModifier: Modifier, contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, centerOverlay: (@Composable UIControllerScope.() -> Unit)?, @@ -147,6 +159,7 @@ private fun UIControllerContent( UIState.Controls -> scope.PlayerControls( controlsVisible = controls.visible.value, animationsActive = controls.animationsActive && !adPlaying, + chromeBounds = chromeBounds, controlsModifier = controlsModifier, centerOverlay = centerOverlay, topChrome = topChrome, @@ -166,6 +179,7 @@ private fun UIControllerContent( private fun UIControllerScope.PlayerControls( controlsVisible: Boolean, animationsActive: Boolean, + chromeBounds: ChromeBounds, controlsModifier: Modifier, centerOverlay: (@Composable UIControllerScope.() -> Unit)?, topChrome: (@Composable UIControllerScope.() -> Unit)?, @@ -173,7 +187,11 @@ private fun UIControllerScope.PlayerControls( bottomChrome: (@Composable UIControllerScope.() -> Unit)? ) { val playerAnimations = LocalPlayerAnimations.current - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { chromeBounds.player = it.boundsInRoot() } + ) { centerOverlay?.let { Row( modifier = Modifier.fillMaxSize(), @@ -217,9 +235,21 @@ private fun UIControllerScope.PlayerControls( } } Column(modifier = Modifier.fillMaxSize()) { - topChrome?.let { it() } + topChrome?.let { chrome -> + Column( + modifier = Modifier.onGloballyPositioned { + chromeBounds.topChrome = it.boundsInRoot() + } + ) { chrome() } + } Spacer(modifier = Modifier.weight(1f)) - bottomChrome?.let { it() } + bottomChrome?.let { chrome -> + Column( + modifier = Modifier.onGloballyPositioned { + chromeBounds.bottomChrome = it.boundsInRoot() + } + ) { chrome() } + } } } } diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/TextTrackMarginsUpdaterTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/TextTrackMarginsUpdaterTest.kt new file mode 100644 index 00000000..5bfefebc --- /dev/null +++ b/ui-common/src/test/java/com/theoplayer/android/ui/TextTrackMarginsUpdaterTest.kt @@ -0,0 +1,99 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle +import com.theoplayer.android.ui.util.THEOplayerGlobalExt +import com.theoplayer.android.ui.util.Version +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class TextTrackMarginsUpdaterTest { + private var styleMarginTop = 0 + private var styleMarginBottom = 0 + private val style = mockk { + val newMargin = slot() + every { marginTop } answers { styleMarginTop } + every { marginTop = capture(newMargin) } answers { styleMarginTop = newMargin.captured } + every { marginBottom } answers { styleMarginBottom } + every { marginBottom = capture(newMargin) } answers { styleMarginBottom = newMargin.captured } + } + private val updater = TextTrackMarginsUpdater() + + @Before + fun setUp() { + mockPlayerVersion("11.0.0") + } + + @After + fun tearDown() { + clearMocks(THEOplayerGlobalExt) + } + + @Test + fun `WHEN chrome is visible THEN moves text tracks out of the way`() { + updater.update(style, ChromeMargins(top = 10, bottom = 25)) + + assertEquals(10, styleMarginTop) + assertEquals(25, styleMarginBottom) + } + + @Test + fun `WHEN chrome is hidden THEN restores the margins`() { + updater.update(style, ChromeMargins(top = 10, bottom = 25)) + updater.update(style, ChromeMargins()) + + assertEquals(0, styleMarginTop) + assertEquals(0, styleMarginBottom) + } + + @Test + fun `GIVEN a larger user margin WHEN chrome is visible THEN keeps the user margin`() { + styleMarginBottom = 30 + + updater.update(style, ChromeMargins(bottom = 25)) + + assertEquals(30, styleMarginBottom) + } + + @Test + fun `GIVEN a smaller user margin WHEN chrome is hidden THEN restores the user margin`() { + styleMarginBottom = 5 + + updater.update(style, ChromeMargins(bottom = 25)) + assertEquals(25, styleMarginBottom) + + updater.update(style, ChromeMargins()) + assertEquals(5, styleMarginBottom) + } + + @Test + fun `WHEN the user changes the margin while chrome is visible THEN restores the new user margin`() { + updater.update(style, ChromeMargins(bottom = 25)) + styleMarginBottom = 10 + + updater.update(style, ChromeMargins()) + + assertEquals(10, styleMarginBottom) + } + + @Test + fun `GIVEN THEOplayer before 9_5_0 WHEN chrome is visible THEN only sets the top margin`() { + mockPlayerVersion("9.4.0") + + updater.update(style, ChromeMargins(top = 10, bottom = 25)) + + assertEquals(10, styleMarginTop) + assertEquals(0, styleMarginBottom) + } + + private fun mockPlayerVersion(version: String) { + mockkObject(THEOplayerGlobalExt) + every { THEOplayerGlobalExt.version } returns Version.parse(version) + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt index 7e42663a..d8ee9f50 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt @@ -18,6 +18,7 @@ import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.player.Player import com.theoplayer.android.api.timerange.TimeRanges import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -99,6 +100,20 @@ class DefaultUITest { waitUntil { hasText(TITLE) } } + @Test + fun visibleControlsMoveSubtitlesOutOfTheWay() { + val textTrackStyle = mockk(relaxed = true) + val player = mockPlayer(paused = true, played = true) + every { player.textTrackStyle } returns textTrackStyle + setDefaultUI(player) + + waitUntil { hasText(TITLE) } + composeRule.waitForIdle() + + verify { textTrackStyle.marginTop = more(0) } + verify { textTrackStyle.marginBottom = more(0) } + } + private fun setDefaultUI(player: Player) { lateinit var theoplayerView: THEOplayerView composeRule.setContent { From f27cb49c7450417e6cfa402910d80d62c6e02c8e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 15 Oct 2026 15:46:08 +0000 Subject: [PATCH 101/158] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a727e2bc..31347233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) * 📝 The API documentation now has a separate section for each module (`android-ui`, `android-ui-common` and `android-ui-tv`). ([#128](https://github.com/THEOplayer/android-ui/pull/128)) +* 🚀 `UIController` now moves subtitles up or down while the controls are visible, so they don't render behind the top or bottom chrome. Moving subtitles up requires THEOplayer 9.5.0 or higher. ([#129](https://github.com/THEOplayer/android-ui/pull/129)) ## [1.14.2] (2026-10-13) From aa321facbb90c2bb5feabebebe45064872a8a0ef Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 10:52:05 +0000 Subject: [PATCH 102/158] Add TV SeekButton Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/tv/SeekButton.kt | 134 ++++++++++++++++++ .../android/ui/tv/SeekButtonTest.kt | 104 ++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekButton.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekButtonTest.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekButton.kt new file mode 100644 index 00000000..9807f905 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekButton.kt @@ -0,0 +1,134 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.SeekButtonState +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberSeekButtonState +import kotlin.math.absoluteValue + +/** + * A button that seeks forward or backward by a fixed offset. + * + * This draws a seek forward or backward icon inside the button, with the seek offset centered + * within the icon. If you want to use a different icon, use the other overload that accepts + * custom `content`. + * + * @param modifier the [Modifier] to be applied to this button + * @param seekOffset the offset (in seconds) by which to seek forward (if positive) + * or backward (if negative) + * @param iconSize the size of the icon + * @param contentPadding the spacing values to apply internally between the container and the + * content + */ +@ExperimentalTvUiApi +@Composable +fun SeekButton( + modifier: Modifier = Modifier, + seekOffset: Int = 10, + iconSize: Dp = 24.dp, + contentPadding: PaddingValues = PaddingValues(0.dp) +) { + SeekButton( + modifier = modifier, + seekOffset = seekOffset, + contentPadding = contentPadding + ) { + Box { + Icon( + Icons.Rounded.Replay, + modifier = Modifier + .size(iconSize) + .scale(scaleX = if (seekOffset >= 0) -1f else 1f, scaleY = 1f), + contentDescription = pluralStringResource( + if (seekOffset >= 0) { + R.plurals.theoplayer_ui_btn_seek_forward + } else { + R.plurals.theoplayer_ui_btn_seek_backward + }, + seekOffset.absoluteValue, + seekOffset.absoluteValue + ) + ) + Text( + modifier = Modifier + .align(Alignment.Center) + .offset(y = iconSize * 0.1f), + text = "${seekOffset.absoluteValue}", + fontSize = 6.sp * (iconSize / 24.dp) + ) + } + } +} + +/** + * A button that seeks forward or backward by a fixed offset. + * + * @param modifier the [Modifier] to be applied to this button + * @param seekOffset the offset (in seconds) by which to seek forward (if positive) + * or backward (if negative) + * @param contentPadding the spacing values to apply internally between the container and the + * content + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun SeekButton( + modifier: Modifier = Modifier, + seekOffset: Int = 10, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit +) { + SeekButton( + state = rememberSeekButtonState(LocalTHEOplayerView.current?.player, seekOffset), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +/** + * A button that seeks forward or backward by a fixed offset. + * + * @param state the [SeekButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun SeekButton( + state: SeekButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit +) { + IconButton( + onClick = state::onClick, + modifier = modifier, + enabled = state.enabled + ) { + Box(Modifier.padding(contentPadding)) { + content() + } + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekButtonTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekButtonTest.kt new file mode 100644 index 00000000..3cf2429c --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekButtonTest.kt @@ -0,0 +1,104 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.verify +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class SeekButtonTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun forwardButtonSeeksForward() { + val player = TestPlayer(currentTime = 50.0) + val focusRequester = FocusRequester() + setSeekButton(player, seekOffset = 10, Modifier.focusRequester(focusRequester)) + val description = composeRule.activity.resources + .getQuantityString(R.plurals.theoplayer_ui_btn_seek_forward, 10, 10) + + composeRule.onNodeWithContentDescription(description).assertExists() + focusSeekButton(focusRequester) + pressCenter() + + verify(exactly = 1) { player.player.currentTime = 60.0 } + } + + @Test + fun backwardButtonSeeksBackward() { + val player = TestPlayer(currentTime = 50.0) + val focusRequester = FocusRequester() + setSeekButton(player, seekOffset = -10, Modifier.focusRequester(focusRequester)) + val description = composeRule.activity.resources + .getQuantityString(R.plurals.theoplayer_ui_btn_seek_backward, 10, 10) + + composeRule.onNodeWithContentDescription(description).assertExists() + focusSeekButton(focusRequester) + pressCenter() + + verify(exactly = 1) { player.player.currentTime = 40.0 } + } + + @Test + fun buttonIsDisabledWhenNotSeekable() { + val player = TestPlayer(seekableRanges = emptyList()) + setSeekButton(player, seekOffset = 10) + + composeRule.onNodeWithTag(SEEK_BUTTON).assertIsNotEnabled() + } + + private fun setSeekButton(player: TestPlayer, seekOffset: Int, modifier: Modifier = Modifier) { + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player.player) } + CompositionLocalProvider(LocalTHEOplayerView provides view) { + THEOplayerTvTheme { + SeekButton(modifier = modifier.testTag(SEEK_BUTTON), seekOffset = seekOffset) + } + } + } + composeRule.waitForIdle() + } + + private fun focusSeekButton(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.onNodeWithTag(SEEK_BUTTON).assertIsFocused() + } + + private fun pressCenter() { + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + assertTrue( + dispatchKeyEvent( + composeRule.activity, + KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + } + composeRule.waitForIdle() + } + + private companion object { + const val SEEK_BUTTON = "seek-button" + } +} From 2e88921f9ccede274b18e0455c612c9169d18fab Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 10:52:05 +0000 Subject: [PATCH 103/158] Add UIController(config, source) overload for TV Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/tv/UIController.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt index a71e58aa..c306762d 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -32,8 +32,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection +import com.theoplayer.android.api.THEOplayerConfig import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.ui.ControlsVisibility import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.InternalUiApi @@ -45,6 +47,7 @@ import com.theoplayer.android.ui.rememberControlsVisibility import com.theoplayer.android.ui.rememberErrorState import com.theoplayer.android.ui.rememberFirstPlayState import com.theoplayer.android.ui.rememberPlayButtonState +import com.theoplayer.android.ui.rememberTHEOplayerView import com.theoplayer.android.ui.rememberUIControllerScope import com.theoplayer.android.ui.rememberUIState import com.theoplayer.android.ui.seekPlayerBy @@ -54,6 +57,58 @@ import com.theoplayer.android.ui.togglePlayerPlayPause /** * A TV-oriented UI controller for a THEOplayer view. * + * This creates a [THEOplayerView] with the given [config] that follows the lifecycle of the + * composition, and loads the given [source] into its player. + * + * @param modifier the [Modifier] to be applied to this controller + * @param config the player configuration to be used when constructing the [THEOplayerView] + * @param source the source description to load into the player + * @param color the background color behind visible controls + * @param centerOverlay content displayed over the center of the player + * @param errorOverlay content displayed when the player encounters a fatal error + * @param topChrome controls displayed at the top of the player + * @param centerChrome controls displayed at the center of the player + * @param bottomChrome controls displayed at the bottom of the player + */ +@ExperimentalTvUiApi +@Composable +fun UIController( + modifier: Modifier = Modifier, + config: THEOplayerConfig, + source: SourceDescription? = null, + color: Color = Color.Black.copy(alpha = 0.5f), + centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null, + topChrome: (@Composable UIControllerScope.() -> Unit)? = null, + centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, + bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null +) { + val theoplayerView = rememberTHEOplayerView(config) + LaunchedEffect(theoplayerView, source) { + if (!theoplayerView.isInEditMode) { + theoplayerView.player.source = source + } + } + + UIController( + modifier = modifier, + theoplayerView = theoplayerView, + color = color, + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) +} + +/** + * A TV-oriented UI controller for a THEOplayer view. + * + * This does not touch the lifecycle of the given view: use [rememberTHEOplayerView] to + * create a view owned by the composition, or forward [THEOplayerView.onPause], + * [THEOplayerView.onResume] and [THEOplayerView.onDestroy] yourself. + * * @param modifier the [Modifier] to be applied to this controller * @param theoplayerView the THEOplayer view to host * @param color the background color behind visible controls From 4cc52bc8dd92ef2ac73aab8b684940fa46f227a7 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 10:52:34 +0000 Subject: [PATCH 104/158] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31347233..e516c88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ * 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) * 📝 The API documentation now has a separate section for each module (`android-ui`, `android-ui-common` and `android-ui-tv`). ([#128](https://github.com/THEOplayer/android-ui/pull/128)) * 🚀 `UIController` now moves subtitles up or down while the controls are visible, so they don't render behind the top or bottom chrome. Moving subtitles up requires THEOplayer 9.5.0 or higher. ([#129](https://github.com/THEOplayer/android-ui/pull/129)) +* 🚀 Added `SeekButton` and a `UIController(config, source)` overload to `android-ui-tv`. ([#131](https://github.com/THEOplayer/android-ui/pull/131)) ## [1.14.2] (2026-10-13) From 293353b33eab2e64a70766113a705f8163761c87 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 10:54:22 +0000 Subject: [PATCH 105/158] Consolidate changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e516c88b..09d0313b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,18 +11,13 @@ ## [Unreleased] -* 🏠 Moved `Player` and its helpers into 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)) -* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115)) -* 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) -* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) +* 🚀 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)) +* 🚀 Added experimental state holders (such as `rememberPlayButtonState` and `rememberSeekBarState`), so custom UIs can reuse the logic of the built-in components. The components have new overloads that take their state, and `LocalTHEOplayerView` provides the current `THEOplayerView`. `UIController` and `DefaultUI` now use these state holders internally. ([#108](https://github.com/THEOplayer/android-ui/pull/108), [#110](https://github.com/THEOplayer/android-ui/pull/110), [#111](https://github.com/THEOplayer/android-ui/pull/111), [#114](https://github.com/THEOplayer/android-ui/pull/114), [#115](https://github.com/THEOplayer/android-ui/pull/115), [#116](https://github.com/THEOplayer/android-ui/pull/116)) +* 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which don't create a `Player`. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either, and `UIControllerScope.player` is only created when accessed. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) +* 🚀 `UIController` now handles keys: pressing any key shows the controls (which helps on tablets with a keyboard), media keys (play/pause, fast-forward and rewind) control playback even while the controls are hidden, and Back closes an open menu. Use the new `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#122](https://github.com/THEOplayer/android-ui/pull/122), [#124](https://github.com/THEOplayer/android-ui/pull/124)) +* 🚀 `UIController` now moves subtitles up or down while the controls are visible, so they don't render behind the top or bottom chrome. Moving subtitles up requires THEOplayer 9.5.0 or higher. ([#129](https://github.com/THEOplayer/android-ui/pull/129)) * 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) -* 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) -* 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#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)) * 📝 The API documentation now has a separate section for each module (`android-ui`, `android-ui-common` and `android-ui-tv`). ([#128](https://github.com/THEOplayer/android-ui/pull/128)) -* 🚀 `UIController` now moves subtitles up or down while the controls are visible, so they don't render behind the top or bottom chrome. Moving subtitles up requires THEOplayer 9.5.0 or higher. ([#129](https://github.com/THEOplayer/android-ui/pull/129)) -* 🚀 Added `SeekButton` and a `UIController(config, source)` overload to `android-ui-tv`. ([#131](https://github.com/THEOplayer/android-ui/pull/131)) ## [1.14.2] (2026-10-13) From 78c83ee7da7314f3e67c2512680969b9a24e8d06 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:43:53 +0000 Subject: [PATCH 106/158] Focus play button when entering TV DefaultUI's button row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/tv/DefaultUI.kt | 19 +++++++++++++++++-- .../theoplayer/android/ui/tv/DefaultUITest.kt | 12 ++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt index f07e6de9..f840b5c2 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui.tv +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,8 +12,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.tooling.preview.Preview @@ -181,14 +186,24 @@ private fun UIControllerScope.BottomControls(playingAd: Boolean, streamType: Str SeekBar(modifier = Modifier.fillMaxWidth()) } Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + // Entering this row focuses the play button, rather than the first button in the row. + // This matters when the controls open without a seek bar, e.g. for live streams without DVR. + val playButtonFocusRequester = remember { FocusRequester() } + Row( + modifier = Modifier + .focusProperties { onEnter = { playButtonFocusRequester.requestFocus() } } + .focusGroup(), + verticalAlignment = Alignment.CenterVertically + ) { Row(modifier = Modifier.weight(1f)) { if (!playingAd) { LiveButton() } } PlayButton( - modifier = Modifier.size(56.dp), + modifier = Modifier + .focusRequester(playButtonFocusRequester) + .size(56.dp), iconModifier = Modifier.size(32.dp) ) Row( diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt index d8ee9f50..1ab03394 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt @@ -81,6 +81,18 @@ class DefaultUITest { assertFalse(hasText("01:40")) } + @Test + fun playButtonIsFocusedWhenControlsAreShownForLiveStream() { + setDefaultUI(mockPlayer(paused = true, played = true, duration = Double.POSITIVE_INFINITY)) + val playButton = androidx.compose.ui.test.hasContentDescription("Play") + + composeRule.waitUntil(10_000) { + composeRule.onAllNodes(playButton and SemanticsMatcher.expectValue(SemanticsProperties.Focused, true)) + .fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNode(playButton).assertIsFocused() + } + @Test fun hiddenDpadCenterPausesAndShowsControls() { val player = mockPlayer(paused = false, played = true) From 8f08eda5a9c591aeff11d2955c88d0f009a0a3c6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:06:57 +0000 Subject: [PATCH 107/158] Add descriptions to demo streams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/demo/Streams.kt | 7 ++++++- .../com/theoplayer/android/ui/demo/{ => tv}/TvActivity.kt | 0 2 files changed, 6 insertions(+), 1 deletion(-) rename app/src/main/java/com/theoplayer/android/ui/demo/{ => tv}/TvActivity.kt (100%) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt index 838b0ed7..b91486a2 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt @@ -6,12 +6,13 @@ import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.api.source.TypedSource import com.theoplayer.android.api.source.addescription.GoogleImaAdDescription -data class Stream(val title: String, val source: SourceDescription) +data class Stream(val title: String, val description: String = "", val source: SourceDescription) val streams by lazy { listOf( Stream( title = "Bip Bop (HLS)", + description = "HLS · VOD", source = SourceDescription.Builder( TypedSource.Builder("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8") .build() @@ -19,6 +20,7 @@ val streams by lazy { ), Stream( title = "Elephant's Dream (HLS)", + description = "HLS · VOD", source = SourceDescription.Builder( TypedSource.Builder("https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8") .build() @@ -26,6 +28,7 @@ val streams by lazy { ), Stream( title = "Sintel (DASH) with preroll ad", + description = "DASH · VOD · Preroll ad", source = SourceDescription.Builder( TypedSource.Builder("https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd") .build() @@ -37,6 +40,7 @@ val streams by lazy { ), Stream( title = "DASH-IF Livesim", + description = "DASH · Live", source = SourceDescription.Builder( TypedSource.Builder("https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd") .build() @@ -44,6 +48,7 @@ val streams by lazy { ), Stream( title = "Test card (with CEA tracks)", + description = "DASH · VOD · CEA-608 captions", source = SourceDescription.Builder( TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd") .build() diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt similarity index 100% rename from app/src/main/java/com/theoplayer/android/ui/demo/TvActivity.kt rename to app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt From 308ea018c462df90115cde451a07d2d6a48e8f6a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:06:57 +0000 Subject: [PATCH 108/158] Add start screen to TV demo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 2 +- .../android/ui/demo/tv/TvActivity.kt | 89 +++++++- .../android/ui/demo/tv/TvHomeScreen.kt | 211 ++++++++++++++++++ .../android/ui/demo/tv/TvPlayerScreen.kt | 45 ++++ 4 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt create mode 100644 app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe382b7..def99f59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ (null) } + var theme by rememberSaveable { mutableStateOf(TvPlayerTheme.Default) } + + when (val currentStream = stream) { + null -> TvHomeScreen( + modifier = Modifier.fillMaxSize(), + streams = streams, + currentTheme = theme, + onSelectStream = { stream = it }, + onSelectTheme = { theme = it } + ) + + else -> TvPlayerScreen( + modifier = Modifier.fillMaxSize(), + theoplayerView = theoplayerView, + stream = currentStream, + theme = theme, + onExit = { stream = null } + ) + } +} + +/** + * The player themes available on Android TV. + */ +enum class TvPlayerTheme(val title: String) { + Default(title = "Default theme") +} + +/** + * Saves the selected stream by its index in [streams], or -1 when no stream is selected. + */ +private val OptionalStreamSaver = Saver( + save = { streams.indexOf(it) }, + restore = { streams.getOrNull(it) } +) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt new file mode 100644 index 00000000..0629795b --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt @@ -0,0 +1,211 @@ +package com.theoplayer.android.ui.demo.tv + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Carousel +import androidx.tv.material3.CarouselDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.FilterChip +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import androidx.tv.material3.rememberCarouselState +import com.theoplayer.android.ui.demo.Stream +import com.theoplayer.android.ui.demo.streams +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +/** + * The start screen of the Android TV demo: a carousel of streams, and a row to pick the theme. + */ +@Composable +fun TvHomeScreen( + modifier: Modifier = Modifier, + streams: List, + currentTheme: TvPlayerTheme, + onSelectStream: (Stream) -> Unit, + onSelectTheme: (TvPlayerTheme) -> Unit +) { + val carouselFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + carouselFocusRequester.requestFocus() + } + + Column( + modifier = modifier.padding(horizontal = safeAreaHorizontalPadding, vertical = safeAreaVerticalPadding) + ) { + Text(text = "Open Video UI for Android TV", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(16.dp)) + StreamCarousel( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .focusRequester(carouselFocusRequester), + streams = streams, + onSelectStream = onSelectStream + ) + Spacer(Modifier.height(24.dp)) + ThemeRow(currentTheme = currentTheme, onSelectTheme = onSelectTheme) + } +} + +/** + * A carousel with one slide per stream. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun StreamCarousel( + modifier: Modifier = Modifier, + streams: List, + onSelectStream: (Stream) -> Unit +) { + val carouselState = rememberCarouselState() + Carousel( + itemCount = streams.size, + modifier = modifier.clip(RoundedCornerShape(16.dp)), + carouselState = carouselState, + carouselIndicator = { + CarouselDefaults.IndicatorRow( + itemCount = streams.size, + activeItemIndex = carouselState.activeItemIndex, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) + } + ) { index -> + StreamSlide( + stream = streams[index], + background = slideBackgrounds[index % slideBackgrounds.size], + onPlay = { onSelectStream(streams[index]) } + ) + } +} + +/** + * A single carousel slide, with the stream's title, description and a play button. + */ +@Composable +private fun StreamSlide(stream: Stream, background: Brush, onPlay: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background(background) + ) { + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(32.dp) + ) { + SlideText(stream) + Spacer(Modifier.height(16.dp)) + Button(onClick = onPlay) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = "Play") + } + } + } +} + +/** + * The title and description of a carousel slide. + */ +@Composable +private fun ColumnScope.SlideText(stream: Stream) { + Text( + text = stream.title, + color = Color.White, + style = MaterialTheme.typography.displaySmall + ) + if (stream.description.isNotEmpty()) { + Text( + text = stream.description, + color = Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.titleMedium + ) + } +} + +/** + * A row of chips to pick the player theme. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ThemeRow(currentTheme: TvPlayerTheme, onSelectTheme: (TvPlayerTheme) -> Unit) { + Text(text = "Theme", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + for (theme in TvPlayerTheme.entries) { + val selected = theme == currentTheme + FilterChip( + selected = selected, + onClick = { onSelectTheme(theme) }, + leadingIcon = if (selected) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + } + ) { + Text(text = theme.title) + } + } + } +} + +// Dark gradients, so the white slide text stays readable. +private val slideBackgrounds = listOf( + Color(0xFF1A237E) to Color(0xFF4A148C), + Color(0xFF004D40) to Color(0xFF01579B), + Color(0xFFB71C1C) to Color(0xFF4E342E), + Color(0xFF263238) to Color(0xFF1B5E20), + Color(0xFF311B92) to Color(0xFF880E4F), +).map { (start, end) -> Brush.linearGradient(listOf(start, end)) } + +// Keep content inside the overscan-safe area of TVs. +private val safeAreaHorizontalPadding = 48.dp +private val safeAreaVerticalPadding = 27.dp + +@Preview(device = "spec:width=960dp,height=540dp") +@Composable +private fun TvHomeScreenPreview() { + THEOplayerTvTheme { + Surface(modifier = Modifier.fillMaxSize()) { + TvHomeScreen( + modifier = Modifier.fillMaxSize(), + streams = streams, + currentTheme = TvPlayerTheme.Default, + onSelectStream = {}, + onSelectTheme = {} + ) + } + } +} diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt new file mode 100644 index 00000000..9589fcc4 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt @@ -0,0 +1,45 @@ +package com.theoplayer.android.ui.demo.tv + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.demo.Stream +import com.theoplayer.android.ui.tv.DefaultUI +import com.theoplayer.android.ui.tv.ExperimentalTvUiApi + +/** + * Plays the given [stream] with the given [theme]. + * + * Back first hides the controls (handled by the player UI), and then calls [onExit]. + */ +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvPlayerScreen( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + stream: Stream, + theme: TvPlayerTheme, + onExit: () -> Unit +) { + DisposableEffect(theoplayerView, stream) { + val player = theoplayerView.player + player.isAutoplay = true + player.source = stream.source + onDispose { + player.source = null + } + } + + // Registered before the player UI, so the UI's own Back handler takes precedence. + BackHandler(onBack = onExit) + + when (theme) { + TvPlayerTheme.Default -> DefaultUI( + modifier = modifier, + theoplayerView = theoplayerView, + title = stream.title + ) + } +} From e16334372d4771cd2067f7d17c8e600559490d8c Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:45:09 +0000 Subject: [PATCH 109/158] Keep TV theme chips the same size when selected Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt index 0629795b..418eeb97 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -168,12 +167,7 @@ private fun ThemeRow(currentTheme: TvPlayerTheme, onSelectTheme: (TvPlayerTheme) val selected = theme == currentTheme FilterChip( selected = selected, - onClick = { onSelectTheme(theme) }, - leadingIcon = if (selected) { - { Icon(Icons.Rounded.Check, contentDescription = null) } - } else { - null - } + onClick = { onSelectTheme(theme) } ) { Text(text = theme.title) } From 6a64b4e4585f2ed201e4f5306e5bcd0db8a0a8a8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:47:52 +0000 Subject: [PATCH 110/158] Keep Left/Right inside the TV demo's stream carousel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/tv/TvHomeScreen.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt index 418eeb97..0b683aa0 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.demo.tv import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,7 +22,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -89,7 +92,9 @@ private fun StreamCarousel( val carouselState = rememberCarouselState() Carousel( itemCount = streams.size, - modifier = modifier.clip(RoundedCornerShape(16.dp)), + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .keepHorizontalFocusInside(), carouselState = carouselState, carouselIndicator = { CarouselDefaults.IndicatorRow( @@ -109,6 +114,22 @@ private fun StreamCarousel( } } +/** + * Keeps Left and Right focus moves inside this element, so [Carousel] changes slides + * instead of moving focus to the theme row. + * + * Works around tv-material 1.1.0, where [Carousel]'s own exit handler never cancels the move. + */ +private fun Modifier.keepHorizontalFocusInside(): Modifier = this + .focusProperties { + onExit = { + if (requestedFocusDirection == FocusDirection.Left || requestedFocusDirection == FocusDirection.Right) { + cancelFocusChange() + } + } + } + .focusGroup() + /** * A single carousel slide, with the stream's title, description and a play button. */ From de36c95a63a8f8100945bc8cb58bf972ad46e280 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:58:48 +0000 Subject: [PATCH 111/158] Link upstream issue for TV carousel focus workaround Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt index 0b683aa0..b4248d7b 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt @@ -119,6 +119,7 @@ private fun StreamCarousel( * instead of moving focus to the theme row. * * Works around tv-material 1.1.0, where [Carousel]'s own exit handler never cancels the move. + * See https://issuetracker.google.com/issues/575161906 */ private fun Modifier.keepHorizontalFocusInside(): Modifier = this .focusProperties { From 78838ccb7737a2f3a61382adf0e1346be9a69427 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:37:20 +0000 Subject: [PATCH 112/158] Add Nitflex theme to TV demo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/tv/TvActivity.kt | 4 +- .../android/ui/demo/tv/TvPlayerScreen.kt | 13 ++ .../ui/demo/tv/nitflex/TvNitflexTheme.kt | 74 +++++++ .../android/ui/demo/tv/nitflex/TvNitflexUI.kt | 206 ++++++++++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt create mode 100644 app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt index b1deddda..67e07a77 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt @@ -82,6 +82,7 @@ fun TvContent(theoplayerView: THEOplayerView) { theoplayerView = theoplayerView, stream = currentStream, theme = theme, + onNextStream = { stream = streams[(streams.indexOf(currentStream) + 1) % streams.size] }, onExit = { stream = null } ) } @@ -91,7 +92,8 @@ fun TvContent(theoplayerView: THEOplayerView) { * The player themes available on Android TV. */ enum class TvPlayerTheme(val title: String) { - Default(title = "Default theme") + Default(title = "Default theme"), + Nitflex(title = "Nitflex theme") } /** diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt index 9589fcc4..9a5c0fa5 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt @@ -6,12 +6,15 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.ui.demo.Stream +import com.theoplayer.android.ui.demo.tv.nitflex.TvNitflexTheme +import com.theoplayer.android.ui.demo.tv.nitflex.TvNitflexUI import com.theoplayer.android.ui.tv.DefaultUI import com.theoplayer.android.ui.tv.ExperimentalTvUiApi /** * Plays the given [stream] with the given [theme]. * + * [onNextStream] switches to the next stream, for themes that have a "next" button. * Back first hides the controls (handled by the player UI), and then calls [onExit]. */ @OptIn(ExperimentalTvUiApi::class) @@ -21,6 +24,7 @@ fun TvPlayerScreen( theoplayerView: THEOplayerView, stream: Stream, theme: TvPlayerTheme, + onNextStream: () -> Unit, onExit: () -> Unit ) { DisposableEffect(theoplayerView, stream) { @@ -41,5 +45,14 @@ fun TvPlayerScreen( theoplayerView = theoplayerView, title = stream.title ) + + TvPlayerTheme.Nitflex -> TvNitflexTheme { + TvNitflexUI( + modifier = modifier, + theoplayerView = theoplayerView, + title = stream.title, + onNextEpisode = onNextStream + ) + } } } diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt new file mode 100644 index 00000000..6fae571f --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt @@ -0,0 +1,74 @@ +package com.theoplayer.android.ui.demo.tv.nitflex + +import androidx.compose.runtime.Composable +import androidx.tv.material3.darkColorScheme +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_background +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_error +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_errorContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_inverseOnSurface +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_inversePrimary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_inverseSurface +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onBackground +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onError +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onErrorContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onPrimary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onPrimaryContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onSecondary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onSecondaryContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onSurface +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onSurfaceVariant +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onTertiary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_onTertiaryContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_outline +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_outlineVariant +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_primary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_primaryContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_scrim +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_secondary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_secondaryContainer +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_surface +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_surfaceVariant +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_tertiary +import com.theoplayer.android.ui.demo.nitflex.theme.md_theme_dark_tertiaryContainer +import com.theoplayer.android.ui.tv.ExperimentalTvUiApi +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +private val NitflexTvColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + inversePrimary = md_theme_dark_inversePrimary, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + border = md_theme_dark_outline, + borderVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim +) + +/** + * The Nitflex theme for Android TV, using the same dark colors as the mobile Nitflex theme. + */ +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvNitflexTheme(content: @Composable () -> Unit) { + THEOplayerTvTheme(colorScheme = NitflexTvColorScheme, content = content) +} diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt new file mode 100644 index 00000000..ee2e428a --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt @@ -0,0 +1,206 @@ +package com.theoplayer.android.ui.demo.tv.nitflex + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Pause +import androidx.compose.material.icons.sharp.PlayArrow +import androidx.compose.material.icons.sharp.Replay +import androidx.compose.material.icons.sharp.SkipNext +import androidx.compose.material.icons.sharp.Speed +import androidx.compose.material.icons.sharp.Subtitles +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.rememberFirstPlayState +import com.theoplayer.android.ui.rememberTHEOplayerView +import com.theoplayer.android.ui.tv.CurrentTimeDisplay +import com.theoplayer.android.ui.tv.ErrorDisplay +import com.theoplayer.android.ui.tv.ExperimentalTvUiApi +import com.theoplayer.android.ui.tv.LanguageMenu +import com.theoplayer.android.ui.tv.LoadingSpinner +import com.theoplayer.android.ui.tv.PlayButton +import com.theoplayer.android.ui.tv.PlaybackRateMenu +import com.theoplayer.android.ui.tv.SeekBar +import com.theoplayer.android.ui.tv.SeekButton +import com.theoplayer.android.ui.tv.UIController + +/** + * A Netflix-inspired player UI for Android TV, ported from the mobile `NitflexUI`. + * + * @param modifier the [Modifier] to be applied to this UI + * @param theoplayerView the THEOplayer view to host in this UI + * @param title the stream's title, shown at the top of the player + * @param onNextEpisode called when the "Next episode" button is clicked, + * or `null` to hide that button + */ +@OptIn(ExperimentalTvUiApi::class, ExperimentalPlayerStateApi::class) +@Composable +fun TvNitflexUI( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + title: String? = null, + onNextEpisode: (() -> Unit)? = null +) { + val view = theoplayerView.takeUnless { it.isInEditMode } + val firstPlayState = rememberFirstPlayState(view?.player) + // Show all controls in previews, which never start playing. + val firstPlay = firstPlayState.firstPlay || LocalInspectionMode.current + UIController( + modifier = modifier, + theoplayerView = theoplayerView, + centerOverlay = { + if (firstPlayState.firstPlay) { + LoadingSpinner() + } + }, + topChrome = { + if (firstPlay) { + title?.let { NitflexTitle(it) } + } + }, + centerChrome = { + NitflexCenterControls(firstPlay = firstPlay) + }, + bottomChrome = { + if (firstPlay) { + NitflexBottomControls(onNextEpisode = onNextEpisode) + } + }, + errorOverlay = { + Box(contentAlignment = Alignment.Center) { + ErrorDisplay() + } + } + ) +} + +/** + * The stream title, centered at the top. + */ +@Composable +private fun NitflexTitle(title: String) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = safeAreaVerticalPadding), + text = title, + color = Color.White, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) +} + +/** + * The play button, with seek buttons on either side once playback has started. + */ +@OptIn(ExperimentalTvUiApi::class) +@Composable +private fun NitflexCenterControls(firstPlay: Boolean) { + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (firstPlay) { + SeekButton(seekOffset = -10, iconSize = 48.dp, modifier = Modifier.size(72.dp)) + } + PlayButton( + modifier = Modifier.size(80.dp), + iconModifier = Modifier.size(48.dp), + play = { Icon(Icons.Sharp.PlayArrow, contentDescription = "Play") }, + pause = { Icon(Icons.Sharp.Pause, contentDescription = "Pause") }, + replay = { Icon(Icons.Sharp.Replay, contentDescription = "Replay") } + ) + if (firstPlay) { + SeekButton(seekOffset = 10, iconSize = 48.dp, modifier = Modifier.size(72.dp)) + } + } +} + +/** + * The seek bar with the remaining time, and a row of buttons below it. + */ +@OptIn(ExperimentalTvUiApi::class) +@Composable +private fun UIControllerScope.NitflexBottomControls(onNextEpisode: (() -> Unit)?) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = safeAreaHorizontalPadding, + end = safeAreaHorizontalPadding, + bottom = safeAreaVerticalPadding + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SeekBar(modifier = Modifier.weight(1f)) + Spacer(Modifier.size(16.dp)) + CurrentTimeDisplay(showRemaining = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround + ) { + NitflexTextButton(Icons.Sharp.Speed, "Speed") { openMenu { PlaybackRateMenu() } } + NitflexTextButton(Icons.Sharp.Subtitles, "Audio and subtitles") { openMenu { LanguageMenu() } } + onNextEpisode?.let { NitflexTextButton(Icons.Sharp.SkipNext, "Next episode", it) } + } + } +} + +/** + * A button with a leading icon and a label. + */ +@Composable +private fun NitflexTextButton(icon: ImageVector, text: String, onClick: () -> Unit) { + Button( + onClick = onClick, + colors = ButtonDefaults.colors(containerColor = Color.Transparent, contentColor = Color.White) + ) { + Icon(icon, contentDescription = null) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(text = text) + } +} + +// Keep content inside the overscan-safe area of TVs. +private val safeAreaHorizontalPadding = 48.dp +private val safeAreaVerticalPadding = 27.dp + +@Preview(device = "spec:width=960dp,height=540dp") +@Composable +private fun TvNitflexUIPreview() { + TvNitflexTheme { + Surface(modifier = Modifier.fillMaxSize()) { + TvNitflexUI( + theoplayerView = rememberTHEOplayerView(THEOplayerConfig.Builder().build()), + title = "Elephant's Dream", + onNextEpisode = {} + ) + } + } +} From 35941ec5e8e9416f7ae0d90a60614941be6c4e88 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 11:45:51 +0000 Subject: [PATCH 113/158] Focus play button when entering TV Nitflex center controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/demo/tv/nitflex/TvNitflexUI.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt index ee2e428a..2271611d 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui.demo.tv.nitflex +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,8 +18,12 @@ import androidx.compose.material.icons.sharp.SkipNext import androidx.compose.material.icons.sharp.Speed import androidx.compose.material.icons.sharp.Subtitles import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalInspectionMode @@ -121,7 +126,12 @@ private fun NitflexTitle(title: String) { @OptIn(ExperimentalTvUiApi::class) @Composable private fun NitflexCenterControls(firstPlay: Boolean) { + // Entering this row focuses the play button, rather than the seek back button. + val playButtonFocusRequester = remember { FocusRequester() } Row( + modifier = Modifier + .focusProperties { onEnter = { playButtonFocusRequester.requestFocus() } } + .focusGroup(), horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -129,7 +139,9 @@ private fun NitflexCenterControls(firstPlay: Boolean) { SeekButton(seekOffset = -10, iconSize = 48.dp, modifier = Modifier.size(72.dp)) } PlayButton( - modifier = Modifier.size(80.dp), + modifier = Modifier + .focusRequester(playButtonFocusRequester) + .size(80.dp), iconModifier = Modifier.size(48.dp), play = { Icon(Icons.Sharp.PlayArrow, contentDescription = "Play") }, pause = { Icon(Icons.Sharp.Pause, contentDescription = "Pause") }, From f62c11a26a21758364b6a6ee1591c6ccf1c8f955 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 19 Oct 2026 12:01:51 +0000 Subject: [PATCH 114/158] Size custom play icons in TV Nitflex center controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt index 2271611d..6576cf9b 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt @@ -142,10 +142,9 @@ private fun NitflexCenterControls(firstPlay: Boolean) { modifier = Modifier .focusRequester(playButtonFocusRequester) .size(80.dp), - iconModifier = Modifier.size(48.dp), - play = { Icon(Icons.Sharp.PlayArrow, contentDescription = "Play") }, - pause = { Icon(Icons.Sharp.Pause, contentDescription = "Pause") }, - replay = { Icon(Icons.Sharp.Replay, contentDescription = "Replay") } + play = { Icon(Icons.Sharp.PlayArrow, contentDescription = "Play", modifier = Modifier.size(56.dp)) }, + pause = { Icon(Icons.Sharp.Pause, contentDescription = "Pause", modifier = Modifier.size(56.dp)) }, + replay = { Icon(Icons.Sharp.Replay, contentDescription = "Replay", modifier = Modifier.size(56.dp)) } ) if (firstPlay) { SeekButton(seekOffset = 10, iconSize = 48.dp, modifier = Modifier.size(72.dp)) From 07f6a19a1f8228ea920690786174b995ff57fbe5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens <649348+MattiasBuelens@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:42:56 +0200 Subject: [PATCH 115/158] Hide @InternalUiApi declarations from API docs (#136) Bump Dokka to 2.3.0-Beta for suppressAnnotatedWith. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- ui-common/build.gradle.kts | 2 ++ ui-tv/build.gradle.kts | 2 ++ ui/build.gradle.kts | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2030670d..fa2d3e23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ androidx-junit = "1.3.0" androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" desugar-jdk-libs = "2.1.5" -dokka = "2.2.0" +dokka = "2.3.0-Beta" changelog = "2.5.0" theoplayer = { prefer = "11.+", strictly = "[7.6.0, 12.0)" } theoplayer-compile = { prefer = "11.+", strictly = "[10.13.0, 12.0)" } diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 13cb8525..c0e8c25e 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -87,6 +87,8 @@ dokka { modulePath = "android-ui-common" dokkaSourceSets.configureEach { + suppressAnnotatedWith.add("com.theoplayer.android.ui.InternalUiApi") + sourceLink { localDirectory = file("src/main/java") remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-common/src/main/java") diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts index 93fec068..efc443cb 100644 --- a/ui-tv/build.gradle.kts +++ b/ui-tv/build.gradle.kts @@ -94,6 +94,8 @@ dokka { modulePath = "android-ui-tv" dokkaSourceSets.configureEach { + suppressAnnotatedWith.add("com.theoplayer.android.ui.InternalUiApi") + sourceLink { localDirectory = file("src/main/java") remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-tv/src/main/java") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 207b9283..59e48d39 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -108,6 +108,8 @@ dokka { modulePath = "android-ui" dokkaSourceSets.configureEach { + suppressAnnotatedWith.add("com.theoplayer.android.ui.InternalUiApi") + sourceLink { localDirectory = file("src/main/java") remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui/src/main/java") From 6f542d3c13c64e2b6f681facb8b8a07b2959d9fb Mon Sep 17 00:00:00 2001 From: Mattias Buelens <649348+MattiasBuelens@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:28:41 +0200 Subject: [PATCH 116/158] Cancel outdated CI runs on pull requests (#138) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2e2f30..3121a844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,11 @@ on: paths-ignore: - '**.md' +# Cancel outdated runs on the same PR, but let runs on main/develop finish. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: runs-on: ubuntu-latest From 30d2fcade5df0e3dbf6353008692e30ea4e302b5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens <649348+MattiasBuelens@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:47:51 +0200 Subject: [PATCH 117/158] Add Android TV guides (#137) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 35 +++++ docs/assets/tv-default-ui-live.png | Bin 0 -> 158941 bytes docs/assets/tv-default-ui.png | Bin 0 -> 239091 bytes docs/assets/tv-demo-home.png | Bin 0 -> 295583 bytes docs/assets/tv-language-menu.png | Bin 0 -> 131937 bytes docs/assets/tv-nitflex-ui.png | Bin 0 -> 1690950 bytes docs/assets/tv-settings-menu.png | Bin 0 -> 230625 bytes docs/guides/android-tv/_category_.json | 10 ++ docs/guides/android-tv/custom-ui.md | 166 ++++++++++++++++++++ docs/guides/android-tv/getting-started.md | 179 ++++++++++++++++++++++ docs/guides/chromecast.md | 4 + docs/guides/custom-ui.md | 8 +- docs/guides/localization.md | 13 +- 13 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 docs/assets/tv-default-ui-live.png create mode 100644 docs/assets/tv-default-ui.png create mode 100644 docs/assets/tv-demo-home.png create mode 100644 docs/assets/tv-language-menu.png create mode 100644 docs/assets/tv-nitflex-ui.png create mode 100644 docs/assets/tv-settings-menu.png create mode 100644 docs/guides/android-tv/_category_.json create mode 100644 docs/guides/android-tv/custom-ui.md create mode 100644 docs/guides/android-tv/getting-started.md diff --git a/README.md b/README.md index a0e22764..c8a83828 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A component library for building a world-class video player experience powered b - Use the default UI for a great out-of-the-box experience, or use the individual components to build your own custom UI. - Built using [Jetpack Compose](https://developer.android.com/jetpack/compose), for a modern and fast Android UI development experience. - Easy to customize: [slot-based layouts](https://developer.android.com/develop/ui/compose/layouts/basics#slot-based-layouts) and [theme-aware components](https://developer.android.com/jetpack/compose/designsystems/custom) empower you to truly make your player UI your own. +- Works on phones and tablets, with experimental support for [Android TV](#android-tv). > **Note** > This project is under active development. While we believe it's ready for use in production, not all features are available in this first release. If you find a problem or have an idea for a new feature, don't hesitate to [open an issue](https://github.com/THEOplayer/android-ui/issues)! @@ -128,3 +129,37 @@ setContent { ) } ``` + +### Android TV + +Add the TV UI library and TV Material to your module-level `build.gradle` file: + +```groovy title="build.gradle" +dependencies { + implementation "com.theoplayer.theoplayer-sdk-android:core:11.+" + implementation "com.theoplayer.android-ui:android-ui-tv:1.+" + implementation "androidx.tv:tv-material:1.1.0" +} +``` + +Wrap `DefaultUI` in `THEOplayerTvTheme` and a TV Material `Surface`: + +```kotlin title="TvPlayer.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvPlayer() { + THEOplayerTvTheme { + Surface { + DefaultUI( + config = THEOplayerConfig.Builder().build(), + source = SourceDescription.Builder( + "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8" + ).build(), + title = "Big Buck Bunny" + ) + } + } +} +``` + +See the [Android TV getting started guide](https://optiview.dolby.com/docs/theoplayer/ui/android/open-video-ui/guides/android-tv/getting-started/) for more details, and [the TV demo app](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt) for a complete and working example. diff --git a/docs/assets/tv-default-ui-live.png b/docs/assets/tv-default-ui-live.png new file mode 100644 index 0000000000000000000000000000000000000000..27ad2ad486f27c1ded0e3f1151f387f87830d459 GIT binary patch literal 158941 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

FJcgq$73br8!aYrM9+-kk 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 Qd5a8AoPn7>Ro5x(pIrMko&W#< literal 0 HcmV?d00001 diff --git a/docs/assets/tv-language-menu.png b/docs/assets/tv-language-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..87f94e3c1e4ff2bed0f7cab79639b5afba63b366 GIT binary patch literal 131937 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/assets/tv-nitflex-ui.png b/docs/assets/tv-nitflex-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..aeb5a36aa301bace6b4464c7a983d68e7db66b31 GIT binary patch literal 1690950 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

Z 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 brxT6k+L)XdYm@i$zJ&_1Dl#Q+OalK8k_CLN literal 0 HcmV?d00001 diff --git a/docs/assets/tv-default-ui.png b/docs/assets/tv-default-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..cc41eb20d477f9dee0f405a19458c23d235ecb9a GIT binary patch literal 239091 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%0ALz!X)K9)wZrYTn6?;}kLKQVZ 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≧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ā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 DD+&sJ literal 0 HcmV?d00001 diff --git a/docs/assets/tv-demo-home.png b/docs/assets/tv-demo-home.png new file mode 100644 index 0000000000000000000000000000000000000000..dbd085803c75f8041795043e1245f21dc8a2e015 GIT binary patch literal 295583 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