diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e4c405eb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,99 @@ +root = true + +# This .editorconfig section approximates ktfmt's formatting rules. You can include it in an +# existing .editorconfig file or use it standalone by copying it to /.editorconfig +# and making sure your editor is set to read settings from .editorconfig files. +# +# It includes editor-specific config options for IntelliJ IDEA. +# +# If any option is wrong, PR are welcome + +[{*.kt,*.kts}] +indent_style = space +insert_final_newline = true +max_line_length = 100 +indent_size = 4 +ij_continuation_indent_size = 4 +ij_java_names_count_to_use_import_on_demand = 9999 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_continuation_indent_size = 4 +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = off +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = * +ij_kotlin_indent_size = 4 +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 9999 +ij_kotlin_name_count_to_use_star_import_for_members = 9999 +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false +ktfmt_trailing_comma_management_strategy = complete diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..850ef3f2 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Reformat code with ktfmt +2d640dc965e55e1ee4f266bb4b33667be2fdbb43 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2e2f30..733a28f3 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 @@ -27,6 +32,8 @@ jobs: with: # https://github.com/gradle/actions/blob/v6.0.0/README.md#licensing-notice cache-disabled: true + - name: Check formatting + run: ./gradlew ktfmtCheck - name: Run tests run: ./gradlew test - name: Assemble 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/.idea/ktfmt.xml b/.idea/ktfmt.xml new file mode 100644 index 00000000..352ec5b6 --- /dev/null +++ b/.idea/ktfmt.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f7283f5..9b9c32d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ ## [Unreleased] +## [1.15.0] (2026-10-23) + +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. It includes a D-pad friendly `DefaultUI`. Code shared with `android-ui` now lives in a new `android-ui-common` artifact; `android-ui` depends on it, so apps don't need any changes. ([#107](https://github.com/THEOplayer/android-ui/pull/107), [#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125), [#126](https://github.com/THEOplayer/android-ui/pull/126), [#128](https://github.com/THEOplayer/android-ui/pull/128), [#131](https://github.com/THEOplayer/android-ui/pull/131)) +* 🚀 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)) +* 🚀 Added `TextTrackStyleMenu` to change the style of subtitles, such as the font family, font color and background color. In `DefaultUI`, open it from the subtitles menu. Use `rememberTextTrackStyleState` to build a custom menu. Changing the font family requires THEOplayer 9.9.0 or higher. ([#130](https://github.com/THEOplayer/android-ui/issues/130), [#143](https://github.com/THEOplayer/android-ui/pull/143)) +* 🚀 Added `MenuScope.closeAllMenus()` to close all open menus at once. On mobile, tapping outside the menu sheet now closes all open menus, instead of only going back to the previous menu. ([#144](https://github.com/THEOplayer/android-ui/pull/144)) +* 🐛 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)) +* 🐛 Fixed `UIController` and `DefaultUI` not keeping the player at the aspect ratio of the video (or 16:9 before the video is loaded). ([#140](https://github.com/THEOplayer/android-ui/pull/140)) +* 🐛 Fixed menus losing their scroll position (and on Android TV, their focused item) when returning from a submenu. ([#143](https://github.com/THEOplayer/android-ui/pull/143)) +* 💅 Menus on mobile now open in a sheet with a translucent background, so they're easier to read on top of the video: a bottom sheet on narrow players, and a side sheet at the end of the player on wide players. ([#141](https://github.com/THEOplayer/android-ui/pull/141)) +* 💅 Opening a submenu or going back to the previous menu now only animates the menu's contents, with a short slide and a fade, on both mobile and TV. Use the new `PlayerAnimations.menuTransitionDuration` to change how long this takes. ([#141](https://github.com/THEOplayer/android-ui/pull/141)) +* 📝 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) * 🐛 Fixed a listener leak in picture-in-picture mode detection on Android 15 and higher. ([#113](https://github.com/THEOplayer/android-ui/pull/113)) @@ -196,7 +212,8 @@ * 🚀 Initial release. -[Unreleased]: https://github.com/THEOplayer/android-ui/compare/v1.14.2...HEAD +[Unreleased]: https://github.com/THEOplayer/android-ui/compare/v1.15.0...HEAD +[1.15.0]: https://github.com/THEOplayer/android-ui/compare/v1.14.2...v1.15.0 [1.14.2]: https://github.com/THEOplayer/android-ui/compare/v1.14.1...v1.14.2 [1.14.1]: https://github.com/THEOplayer/android-ui/compare/v1.14.0...v1.14.1 [1.14.0]: https://github.com/THEOplayer/android-ui/compare/v1.13.4...v1.14.0 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/app/README.md b/app/README.md new file mode 100644 index 00000000..bfb4d850 --- /dev/null +++ b/app/README.md @@ -0,0 +1,12 @@ +# Demo app + +This app demonstrates the Open Video UI for Android, with a phone/tablet UI in [`MainActivity`](src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt) and an Android TV UI in [`TvActivity`](src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt). + +## Credits + +The Android TV home screen uses the following images: + +* Bip Bop: ["Oscilloscope sine square.jpg"](https://commons.wikimedia.org/wiki/File:Oscilloscope_sine_square.jpg) by Xato, Public domain. +* Elephant's Dream: ["Elephants Dream s5 both.jpg"](https://commons.wikimedia.org/wiki/File:Elephants_Dream_s5_both.jpg) by © Blender Foundation / Ton Roosendaal et al., [CC BY 2.5](https://creativecommons.org/licenses/by/2.5). +* Sintel: ["Sintel-screenshot-1.jpg"](https://commons.wikimedia.org/wiki/File:Sintel-screenshot-1.jpg) by © Blender Foundation (www.sintel.org), [CC BY 3.0](https://creativecommons.org/licenses/by/3.0). +* DASH-IF Livesim and Test card: Frames of the DASH-IF Livesim test stream, captured in the demo app. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3898a598..8c2d264b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,8 +4,11 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktfmt) } +ktfmt { kotlinLangStyle() } + android { namespace = "com.theoplayer.android.ui.demo" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -18,9 +21,7 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } + vectorDrawables { useSupportLibrary = true } } buildTypes { @@ -28,7 +29,7 @@ android { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) } // Build type that uses the latest android-ui from Maven @@ -57,13 +58,14 @@ android { // Required by Google IMA SDK v3.37.0+ isCoreLibraryDesugaringEnabled = true } - buildFeatures { - compose = true - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } + buildFeatures { compose = true } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } +} + +androidComponents { + // TODO Re-enable once android-ui-tv is published to Maven + beforeVariants(selector().withBuildType("maven")) { variantBuilder -> + variantBuilder.enable = false } } @@ -104,6 +106,10 @@ dependencies { debugImplementation(project(":ui")) 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/androidTest/java/com/theoplayer/android/ui/demo/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/theoplayer/android/ui/demo/ExampleInstrumentedTest.kt index 95750a2a..97256973 100644 --- a/app/src/androidTest/java/com/theoplayer/android/ui/demo/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/theoplayer/android/ui/demo/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.theoplayer.android.ui.demo -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.theoplayer.android.ui", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b73460f..def99f59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,8 +7,16 @@ + + + + + + + + + + ? { return null } -} \ No newline at end of file +} 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..f140ba54 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 @@ -21,6 +20,7 @@ import androidx.compose.material.icons.rounded.Movie import androidx.compose.material.icons.rounded.PictureInPicture import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon as Material3Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -54,12 +54,11 @@ 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 class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPictureEventListener { private lateinit var theoplayerView: THEOplayerView @@ -72,22 +71,21 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi CastContext.getSharedInstance(this) // Initialize THEOplayer - val config = THEOplayerConfig.Builder().apply { - pipConfiguration(PipConfiguration.Builder().build()) - }.build() - theoplayerView = THEOplayerView(this, config).apply { - // Add ads integration through Google IMA - player.addIntegration( - GoogleImaIntegrationFactory.createGoogleImaIntegration(this) - ) - // Add Chromecast integration - val castConfiguration = CastConfiguration.Builder().apply { - castStrategy(CastStrategy.AUTO) - }.build() - player.addIntegration( - CastIntegrationFactory.createCastIntegration(this, castConfiguration) - ) - } + val config = + THEOplayerConfig.Builder() + .apply { pipConfiguration(PipConfiguration.Builder().build()) } + .build() + theoplayerView = + THEOplayerView(this, config).apply { + // Add ads integration through Google IMA + player.addIntegration(GoogleImaIntegrationFactory.createGoogleImaIntegration(this)) + // Add Chromecast integration + val castConfiguration = + CastConfiguration.Builder().apply { castStrategy(CastStrategy.AUTO) }.build() + player.addIntegration( + CastIntegrationFactory.createCastIntegration(this, castConfiguration) + ) + } initializePictureInPicture() @@ -95,7 +93,7 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi THEOplayerTheme(useDarkTheme = true) { MainContent( theoplayerView = theoplayerView, - onEnterPip = ::enterPictureInPicture + onEnterPip = ::enterPictureInPicture, ) } } @@ -105,13 +103,15 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi pip = VideoPlaybackPictureInPicture(this) pip.addOnPictureInPictureEventListener( ContextCompat.getMainExecutor(this), - this + this, ) pip.setAspectRatio(Rational(16, 9)) pip.setPlayerView(theoplayerView) pip.setEnabled(true) - theoplayerView.player.addEventListener(PlayerEventTypes.RESIZE) { updatePictureInPictureAspectRatio() } + theoplayerView.player.addEventListener(PlayerEventTypes.RESIZE) { + updatePictureInPictureAspectRatio() + } } private fun enterPictureInPicture() { @@ -127,7 +127,7 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi override fun onPictureInPictureEvent( event: PictureInPictureDelegate.Event, - config: Configuration? + config: Configuration?, ) { val pipManager = theoplayerView.piPManager ?: return when (event) { @@ -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() @@ -152,32 +162,29 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPlayerStateApi::class) @Composable fun MainContent( theoplayerView: THEOplayerView, - onEnterPip: () -> Unit = {} + onEnterPip: () -> Unit = {}, ) { 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 + theme = theme, ) } return @@ -188,22 +195,22 @@ fun MainContent( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { - Text(text = "Demo") - }, + title = { Text(text = "Demo") }, actions = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { IconButton(onClick = onEnterPip) { Material3Icon( Icons.Rounded.PictureInPicture, - contentDescription = "Enter picture-in-picture" + contentDescription = "Enter picture-in-picture", ) } } - IconButton(onClick = { - player.source = stream.source - player.play() - }) { + IconButton( + onClick = { + theoplayerView.player.source = stream.source + theoplayerView.player.play() + } + ) { Material3Icon(Icons.Rounded.Refresh, contentDescription = "Reload") } IconButton(onClick = { streamMenuOpen = true }) { @@ -212,17 +219,15 @@ fun MainContent( IconButton(onClick = { themeMenuOpen = true }) { Material3Icon(Icons.Rounded.Brush, contentDescription = "Theme") } - } + }, ) - } + }, ) { padding -> PlayerContent( - modifier = Modifier - .padding(padding) - .fillMaxSize(1f), - player = player, + modifier = Modifier.padding(padding).fillMaxSize(1f), + theoplayerView = theoplayerView, stream = stream, - theme = theme + theme = theme, ) if (streamMenuOpen) { @@ -233,7 +238,7 @@ fun MainContent( stream = it streamMenuOpen = false }, - onDismissRequest = { streamMenuOpen = false } + onDismissRequest = { streamMenuOpen = false }, ) } if (themeMenuOpen) { @@ -243,7 +248,7 @@ fun MainContent( theme = it themeMenuOpen = false }, - onDismissRequest = { themeMenuOpen = false } + onDismissRequest = { themeMenuOpen = false }, ) } } @@ -252,16 +257,16 @@ fun MainContent( @Composable fun PlayerContent( modifier: Modifier = Modifier, - player: Player, + theoplayerView: THEOplayerView, stream: Stream, - theme: PlayerTheme + theme: PlayerTheme, ) { when (theme) { PlayerTheme.Default -> { DefaultUI( modifier = modifier, - player = player, - title = stream.title + theoplayerView = theoplayerView, + title = stream.title, ) } @@ -269,8 +274,8 @@ fun PlayerContent( NitflexTheme(useDarkTheme = true) { NitflexUI( modifier = modifier, - player = player, - title = stream.title + theoplayerView = theoplayerView, + title = stream.title, ) } } @@ -279,7 +284,7 @@ fun PlayerContent( enum class PlayerTheme(val title: String) { Default(title = "Default theme"), - Nitflex(title = "Nitflex theme") + Nitflex(title = "Nitflex theme"), } @Composable @@ -297,7 +302,7 @@ fun SelectStreamDialog( Column(modifier = Modifier.padding(16.dp)) { Text( text = "Select a stream", - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, ) LazyColumn { items(items = streams) { @@ -306,12 +311,10 @@ fun SelectStreamDialog( leadingContent = { RadioButton( selected = (it == currentStream), - onClick = null + onClick = null, ) }, - modifier = Modifier.clickable(onClick = { - onSelectStream(it) - }) + modifier = Modifier.clickable(onClick = { onSelectStream(it) }), ) } } @@ -334,7 +337,7 @@ fun SelectThemeDialog( Column(modifier = Modifier.padding(16.dp)) { Text( text = "Select a theme", - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, ) LazyColumn { items(items = PlayerTheme.entries) { @@ -343,12 +346,10 @@ fun SelectThemeDialog( leadingContent = { RadioButton( selected = (it == currentTheme), - onClick = null + onClick = null, ) }, - modifier = Modifier.clickable(onClick = { - onSelectTheme(it) - }) + modifier = Modifier.clickable(onClick = { onSelectTheme(it) }), ) } } @@ -360,7 +361,5 @@ fun SelectThemeDialog( @Preview(showBackground = true) @Composable fun DefaultPreview() { - MaterialTheme { - DefaultUI(config = THEOplayerConfig.Builder().build()) - } -} \ No newline at end of file + MaterialTheme { DefaultUI(config = THEOplayerConfig.Builder().build()) } +} 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..2c5f1cdb 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 @@ -1,58 +1,96 @@ package com.theoplayer.android.ui.demo +import androidx.annotation.DrawableRes import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope 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 = "", + @DrawableRes val image: Int, + val source: SourceDescription, +) val streams by lazy { listOf( Stream( title = "Bip Bop (HLS)", - source = SourceDescription.Builder( - TypedSource.Builder("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8") - .build() - ).build() + description = "HLS · VOD", + image = R.drawable.stream_bipbop, + source = + SourceDescription.Builder( + TypedSource.Builder( + "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8" + ) + .build() + ) + .build(), ), Stream( title = "Elephant's Dream (HLS)", - source = SourceDescription.Builder( - TypedSource.Builder("https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8") - .build() - ).build() + description = "HLS · VOD", + image = R.drawable.stream_elephants, + source = + SourceDescription.Builder( + TypedSource.Builder( + "https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8" + ) + .build() + ) + .build(), ), Stream( title = "Sintel (DASH) with preroll ad", - source = SourceDescription.Builder( - TypedSource.Builder("https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd") - .build() - ).ads( - GoogleImaAdDescription.Builder("https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml") - .timeOffset("start") - .build() - ).build() + description = "DASH · VOD · Preroll ad", + image = R.drawable.stream_sintel, + source = + SourceDescription.Builder( + TypedSource.Builder( + "https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd" + ) + .build() + ) + .ads( + GoogleImaAdDescription.Builder( + "https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml" + ) + .timeOffset("start") + .build() + ) + .build(), ), Stream( title = "DASH-IF Livesim", - source = SourceDescription.Builder( - TypedSource.Builder("https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd") - .build() - ).build() + description = "DASH · Live", + image = R.drawable.stream_livesim, + source = + SourceDescription.Builder( + TypedSource.Builder( + "https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd" + ) + .build() + ) + .build(), ), Stream( title = "Test card (with CEA tracks)", - source = SourceDescription.Builder( - TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd") - .build() - ).build() + description = "DASH · VOD · CEA-608 captions", + image = R.drawable.stream_testcard, + source = + SourceDescription.Builder( + TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd") + .build() + ) + .build(), ), ) } object StreamSaver : Saver { override fun restore(value: Int): Stream? = streams.getOrNull(value) + override fun SaverScope.save(value: Stream): Int = streams.indexOf(value) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexPlayButton.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexPlayButton.kt index bcb2c217..d6ec74d7 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexPlayButton.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexPlayButton.kt @@ -1,4 +1,5 @@ package com.theoplayer.android.ui.demo.nitflex + import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.sharp.Pause @@ -15,7 +16,7 @@ import com.theoplayer.android.ui.PlayButton fun NitflexPlayButton( modifier: Modifier = Modifier, iconModifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp) + contentPadding: PaddingValues = PaddingValues(0.dp), ) { PlayButton( modifier = modifier, @@ -26,7 +27,7 @@ fun NitflexPlayButton( Icons.Sharp.PlayArrow, modifier = iconModifier, tint = Color.White, - contentDescription = "Play" + contentDescription = "Play", ) }, pause = { @@ -34,7 +35,7 @@ fun NitflexPlayButton( Icons.Sharp.Pause, modifier = iconModifier, tint = Color.White, - contentDescription = "Pause" + contentDescription = "Pause", ) }, replay = { @@ -42,8 +43,8 @@ fun NitflexPlayButton( Icons.Sharp.Replay, modifier = iconModifier, tint = Color.White, - contentDescription = "Replay" + contentDescription = "Replay", ) - } + }, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekBar.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekBar.kt index ce63d66d..7ab2c0c5 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekBar.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekBar.kt @@ -1,4 +1,5 @@ package com.theoplayer.android.ui.demo.nitflex + import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable @@ -6,14 +7,13 @@ import androidx.compose.ui.Modifier import com.theoplayer.android.ui.SeekBar @Composable -fun NitflexSeekBar( - modifier: Modifier = Modifier -) { +fun NitflexSeekBar(modifier: Modifier = Modifier) { SeekBar( modifier = modifier, - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - ) + colors = + SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + ), ) } diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekButton.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekButton.kt index b2d87bb6..55325802 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekButton.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/NitflexSeekButton.kt @@ -1,4 +1,5 @@ package com.theoplayer.android.ui.demo.nitflex + import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.offset @@ -24,34 +25,33 @@ fun NitflexSeekButton( seekOffset: Int = 10, color: Color = Color.White, iconSize: Dp = 24.dp, - contentPadding: PaddingValues = PaddingValues(0.dp) + contentPadding: PaddingValues = PaddingValues(0.dp), ) { SeekButton( modifier = modifier, seekOffset = seekOffset, - contentPadding = contentPadding + contentPadding = contentPadding, ) { Box { Icon( Icons.Sharp.Replay, - modifier = Modifier - .size(iconSize) - .scale(scaleX = if (seekOffset >= 0) -1f else 1f, scaleY = 1f), + modifier = + Modifier.size(iconSize) + .scale(scaleX = if (seekOffset >= 0) -1f else 1f, scaleY = 1f), tint = color, - contentDescription = if (seekOffset >= 0) { - "Seek forward by $seekOffset seconds" - } else { - "Seek backward by $seekOffset seconds" - } + contentDescription = + if (seekOffset >= 0) { + "Seek forward by $seekOffset seconds" + } else { + "Seek backward by $seekOffset seconds" + }, ) Text( - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = iconSize * 0.4f), + modifier = Modifier.align(Alignment.TopCenter).offset(y = iconSize * 0.4f), color = color, text = "${seekOffset.absoluteValue}", - fontSize = 6.sp * (iconSize / 24.dp) + fontSize = 6.sp * (iconSize / 24.dp), ) } } -} \ No newline at end of file +} 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..16c41d24 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 @@ -29,61 +29,70 @@ 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 +import com.theoplayer.android.ui.rememberTHEOplayerView @Composable fun NitflexUI( modifier: Modifier = Modifier, config: THEOplayerConfig, source: SourceDescription? = null, - title: String? = null + title: String? = null, ) { - val player = rememberPlayer(config) - LaunchedEffect(player, source) { - player.source = source + val theoplayerView = rememberTHEOplayerView(config) + LaunchedEffect(theoplayerView, source) { + if (!theoplayerView.isInEditMode) { + 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(), - title: String? = null + 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, - 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, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { title?.let { Text( modifier = Modifier.padding(8.dp), text = it, textAlign = TextAlign.Center, - style = TextStyle(fontWeight = FontWeight.Bold) + style = TextStyle(fontWeight = FontWeight.Bold), ) } } @@ -92,33 +101,33 @@ fun NitflexUI( centerChrome = { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.SpaceEvenly, ) { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = -10, iconSize = 48.dp, - contentPadding = PaddingValues(8.dp) + contentPadding = PaddingValues(8.dp), ) } NitflexPlayButton( iconModifier = Modifier.size(48.dp), - contentPadding = PaddingValues(8.dp) + contentPadding = PaddingValues(8.dp), ) - if (player.firstPlay) { + if (firstPlayState.firstPlay) { NitflexSeekButton( seekOffset = 10, iconSize = 48.dp, - contentPadding = PaddingValues(8.dp) + contentPadding = PaddingValues(8.dp), ) } } }, bottomChrome = { - if (player.firstPlay) { + if (firstPlayState.firstPlay) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { NitflexSeekBar(modifier = Modifier.weight(1f)) CurrentTimeDisplay(showRemaining = true) @@ -126,37 +135,40 @@ fun NitflexUI( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { TextButton( shape = MaterialTheme.shapes.small, - onClick = { openMenu { PlaybackRateMenu() } }) { + onClick = { openMenu { PlaybackRateMenu() } }, + ) { Icon( Icons.Sharp.Speed, tint = Color.White, - contentDescription = null + contentDescription = null, ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text(text = "Speed") } TextButton( shape = MaterialTheme.shapes.small, - onClick = { openMenu { LanguageMenu() } }) { + onClick = { openMenu { LanguageMenu() } }, + ) { Icon( Icons.Sharp.Subtitles, tint = Color.White, - contentDescription = null + contentDescription = null, ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text(text = "Audio and subtitles") } TextButton( shape = MaterialTheme.shapes.small, - onClick = { /* TODO */ }) { + onClick = { /* TODO */ }, + ) { Icon( Icons.Sharp.SkipNext, tint = Color.White, - contentDescription = null + contentDescription = null, ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text(text = "Next episode") @@ -168,11 +180,14 @@ fun NitflexUI( Box(contentAlignment = Alignment.Center) { ErrorDisplay() // 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), + ) } } - } + }, ) } } @@ -180,7 +195,5 @@ fun NitflexUI( @Preview(showBackground = true) @Composable fun NitflexPreview() { - NitflexTheme { - NitflexUI(config = THEOplayerConfig.Builder().build()) - } -} \ No newline at end of file + NitflexTheme { NitflexUI(config = THEOplayerConfig.Builder().build()) } +} diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Color.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Color.kt index 44d8256a..989b9a5a 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Color.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Color.kt @@ -64,5 +64,4 @@ val md_theme_dark_surfaceTint = Color(0xFFFFB4AA) val md_theme_dark_outlineVariant = Color(0xFF534341) val md_theme_dark_scrim = Color(0xFF000000) - val seed = Color(0xFFE50914) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Theme.kt b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Theme.kt index 024ab62d..7579ec0c 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Theme.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/nitflex/theme/Theme.kt @@ -6,85 +6,86 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +private val LightColors = + lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, + ) -private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - primaryContainer = md_theme_light_primaryContainer, - onPrimaryContainer = md_theme_light_onPrimaryContainer, - secondary = md_theme_light_secondary, - onSecondary = md_theme_light_onSecondary, - secondaryContainer = md_theme_light_secondaryContainer, - onSecondaryContainer = md_theme_light_onSecondaryContainer, - tertiary = md_theme_light_tertiary, - onTertiary = md_theme_light_onTertiary, - tertiaryContainer = md_theme_light_tertiaryContainer, - onTertiaryContainer = md_theme_light_onTertiaryContainer, - error = md_theme_light_error, - errorContainer = md_theme_light_errorContainer, - onError = md_theme_light_onError, - onErrorContainer = md_theme_light_onErrorContainer, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, - surface = md_theme_light_surface, - onSurface = md_theme_light_onSurface, - surfaceVariant = md_theme_light_surfaceVariant, - onSurfaceVariant = md_theme_light_onSurfaceVariant, - outline = md_theme_light_outline, - inverseOnSurface = md_theme_light_inverseOnSurface, - inverseSurface = md_theme_light_inverseSurface, - inversePrimary = md_theme_light_inversePrimary, - surfaceTint = md_theme_light_surfaceTint, - outlineVariant = md_theme_light_outlineVariant, - scrim = md_theme_light_scrim, -) - - -private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - primaryContainer = md_theme_dark_primaryContainer, - onPrimaryContainer = md_theme_dark_onPrimaryContainer, - 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, - error = md_theme_dark_error, - errorContainer = md_theme_dark_errorContainer, - onError = md_theme_dark_onError, - onErrorContainer = md_theme_dark_onErrorContainer, - 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, - outline = md_theme_dark_outline, - inverseOnSurface = md_theme_dark_inverseOnSurface, - inverseSurface = md_theme_dark_inverseSurface, - inversePrimary = md_theme_dark_inversePrimary, - surfaceTint = md_theme_dark_surfaceTint, - outlineVariant = md_theme_dark_outlineVariant, - scrim = md_theme_dark_scrim, -) +private val DarkColors = + darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + 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, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + 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, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, + ) @Composable fun NitflexTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable() () -> Unit + content: @Composable() () -> Unit, ) { - val colors = if (!useDarkTheme) { - LightColors - } else { - DarkColors - } + val colors = + if (!useDarkTheme) { + LightColors + } else { + DarkColors + } MaterialTheme( colorScheme = colors, - content = content + content = content, ) -} \ No newline at end of file +} 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 new file mode 100644 index 00000000..fec5ba61 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt @@ -0,0 +1,102 @@ +package com.theoplayer.android.ui.demo.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.tv.material3.Surface +import com.theoplayer.android.api.THEOplayerConfig +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.ima.GoogleImaIntegrationFactory +import com.theoplayer.android.ui.demo.Stream +import com.theoplayer.android.ui.demo.streams +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +/** Demo activity for Android TV, launched from the Android TV launcher. */ +class TvActivity : ComponentActivity() { + private lateinit var theoplayerView: THEOplayerView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + theoplayerView = + THEOplayerView(this, THEOplayerConfig.Builder().build()).apply { + // Add ads integration through Google IMA + player.addIntegration(GoogleImaIntegrationFactory.createGoogleImaIntegration(this)) + } + + setContent { + THEOplayerTvTheme { + Surface(modifier = Modifier.fillMaxSize()) { + TvContent(theoplayerView = theoplayerView) + } + } + } + } + + override fun onPause() { + super.onPause() + theoplayerView.onPause() + } + + override fun onResume() { + super.onResume() + theoplayerView.onResume() + } + + override fun onDestroy() { + super.onDestroy() + theoplayerView.onDestroy() + } +} + +/** Shows the start screen until a stream is selected, then plays that stream. */ +@Composable +fun TvContent(theoplayerView: THEOplayerView) { + var stream by + rememberSaveable(stateSaver = OptionalStreamSaver) { mutableStateOf(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, + onNextStream = { + stream = streams[(streams.indexOf(currentStream) + 1) % streams.size] + }, + onExit = { stream = null }, + ) + } +} + +/** The player themes available on Android TV. */ +enum class TvPlayerTheme(val title: String) { + Default(title = "Default theme"), + Nitflex(title = "Nitflex 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..9b5b2369 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvHomeScreen.kt @@ -0,0 +1,229 @@ +package com.theoplayer.android.ui.demo.tv + +import androidx.compose.foundation.Image +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 +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.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.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 +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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)).keepHorizontalFocusInside(), + carouselState = carouselState, + carouselIndicator = { + CarouselDefaults.IndicatorRow( + itemCount = streams.size, + activeItemIndex = carouselState.activeItemIndex, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + }, + ) { index -> + StreamSlide( + stream = streams[index], + onPlay = { onSelectStream(streams[index]) }, + ) + } +} + +/** + * 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. See + * https://issuetracker.google.com/issues/575161906 + */ +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 background image, title, description and play button. + */ +@Composable +private fun StreamSlide(stream: Stream, onPlay: () -> Unit) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(stream.image), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + SlideScrim() + 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") + } + } + } +} + +/** Dark gradients keep the white slide text readable. */ +@Composable +private fun SlideScrim() { + Box( + Modifier.fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.8f), + 0.6f to Color.Transparent, + ) + ) + .background( + Brush.verticalGradient( + 0.4f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.6f), + ) + ) + ) +} + +/** 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) }, + ) { + Text(text = theme.title) + } + } + } +} + +// 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..e43f39db --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvPlayerScreen.kt @@ -0,0 +1,58 @@ +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.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) +@Composable +fun TvPlayerScreen( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + stream: Stream, + theme: TvPlayerTheme, + onNextStream: () -> Unit, + 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, + ) + + 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..73965529 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt @@ -0,0 +1,73 @@ +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..c9c67833 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt @@ -0,0 +1,220 @@ +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 +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.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 +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) { + // 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, + ) { + if (firstPlay) { + SeekButton(seekOffset = -10, iconSize = 48.dp, modifier = Modifier.size(72.dp)) + } + PlayButton( + modifier = Modifier.focusRequester(playButtonFocusRequester).size(80.dp), + 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)) + } + } +} + +/** 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 = {}, + ) + } + } +} diff --git a/app/src/main/res/drawable-nodpi/stream_bipbop.jpg b/app/src/main/res/drawable-nodpi/stream_bipbop.jpg new file mode 100644 index 00000000..cf9c9e81 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/stream_bipbop.jpg differ diff --git a/app/src/main/res/drawable-nodpi/stream_elephants.jpg b/app/src/main/res/drawable-nodpi/stream_elephants.jpg new file mode 100644 index 00000000..9a064325 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/stream_elephants.jpg differ diff --git a/app/src/main/res/drawable-nodpi/stream_livesim.jpg b/app/src/main/res/drawable-nodpi/stream_livesim.jpg new file mode 100644 index 00000000..1040bbf1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/stream_livesim.jpg differ diff --git a/app/src/main/res/drawable-nodpi/stream_sintel.jpg b/app/src/main/res/drawable-nodpi/stream_sintel.jpg new file mode 100644 index 00000000..1bae9371 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/stream_sintel.jpg differ diff --git a/app/src/main/res/drawable-nodpi/stream_testcard.jpg b/app/src/main/res/drawable-nodpi/stream_testcard.jpg new file mode 100644 index 00000000..048446ff Binary files /dev/null and b/app/src/main/res/drawable-nodpi/stream_testcard.jpg differ diff --git a/app/src/main/res/drawable-xhdpi/tv_banner.png b/app/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 00000000..68154f43 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/app/src/test/java/com/theoplayer/android/ui/demo/ExampleUnitTest.kt b/app/src/test/java/com/theoplayer/android/ui/demo/ExampleUnitTest.kt index 745a6a37..e6ee5b9e 100644 --- a/app/src/test/java/com/theoplayer/android/ui/demo/ExampleUnitTest.kt +++ b/app/src/test/java/com/theoplayer/android/ui/demo/ExampleUnitTest.kt @@ -1,8 +1,7 @@ package com.theoplayer.android.ui.demo -import org.junit.Test - import org.junit.Assert.* +import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index a88d168e..36de7f62 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,7 @@ +import com.ncorti.ktfmt.gradle.tasks.KtfmtCheckTask +import com.ncorti.ktfmt.gradle.tasks.KtfmtFormatTask +import java.time.Year +import kotlin.text.Typography.copyright import org.jetbrains.changelog.date // Top-level build file where you can add configuration options common to all sub-projects/modules. @@ -12,12 +16,50 @@ buildscript { classpath(libs.kotlin.gradle.plugin) } } + 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) + alias(libs.plugins.ktfmt) +} + +ktfmt { kotlinLangStyle() } + +val ktfmtCheckBuildSrc = + tasks.register("ktfmtCheckBuildSrc") { + source = project.fileTree("buildSrc") { exclude("build/**") } + include("**/*.kts") + } + +val ktfmtFormatBuildSrc = + tasks.register("ktfmtFormatBuildSrc") { + source = project.fileTree("buildSrc") { exclude("build/**") } + include("**/*.kts") + } + +tasks.named("ktfmtCheck") { dependsOn(ktfmtCheckBuildSrc) } + +tasks.named("ktfmtFormat") { dependsOn(ktfmtFormatBuildSrc) } + +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 { @@ -35,7 +77,8 @@ changelog { > - 📝 Documentation > - 🏠 Internal > - 💅 Polish - """.trimIndent() + """ + .trimIndent() itemPrefix = "*" keepUnreleasedSection = true unreleasedTerm = "Unreleased" diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index f02acbdf..1ddae9c0 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,6 +1,4 @@ -plugins { - `kotlin-dsl` -} +plugins { `kotlin-dsl` } repositories { gradlePluginPortal() // so that external plugins can be resolved in dependencies section diff --git a/buildSrc/src/main/kotlin/android-ui.library-conventions.gradle.kts b/buildSrc/src/main/kotlin/android-ui.library-conventions.gradle.kts index 5069c140..680409d1 100644 --- a/buildSrc/src/main/kotlin/android-ui.library-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/android-ui.library-conventions.gradle.kts @@ -1,6 +1,4 @@ -plugins { - `maven-publish` -} +plugins { `maven-publish` } publishing { repositories { diff --git a/docs/assets/tv-default-ui-live.png b/docs/assets/tv-default-ui-live.png new file mode 100644 index 00000000..27ad2ad4 Binary files /dev/null and b/docs/assets/tv-default-ui-live.png differ diff --git a/docs/assets/tv-default-ui.png b/docs/assets/tv-default-ui.png new file mode 100644 index 00000000..cc41eb20 Binary files /dev/null and b/docs/assets/tv-default-ui.png differ diff --git a/docs/assets/tv-demo-home.jpg b/docs/assets/tv-demo-home.jpg new file mode 100644 index 00000000..c54e937c Binary files /dev/null and b/docs/assets/tv-demo-home.jpg differ diff --git a/docs/assets/tv-language-menu.png b/docs/assets/tv-language-menu.png new file mode 100644 index 00000000..87f94e3c Binary files /dev/null and b/docs/assets/tv-language-menu.png differ diff --git a/docs/assets/tv-nitflex-ui.png b/docs/assets/tv-nitflex-ui.png new file mode 100644 index 00000000..aeb5a36a Binary files /dev/null and b/docs/assets/tv-nitflex-ui.png differ diff --git a/docs/assets/tv-settings-menu.png b/docs/assets/tv-settings-menu.png new file mode 100644 index 00000000..873cd2b6 Binary files /dev/null and b/docs/assets/tv-settings-menu.png differ diff --git a/docs/guides/android-tv/_category_.json b/docs/guides/android-tv/_category_.json new file mode 100644 index 00000000..6b9e092d --- /dev/null +++ b/docs/guides/android-tv/_category_.json @@ -0,0 +1,10 @@ +{ + "label": "Android TV", + "position": 4, + "collapsible": true, + "collapsed": true, + "description": "Build a player UI for Android TV with Compose for TV.", + "customProps": { + "icon": "📺" + } +} diff --git a/docs/guides/android-tv/custom-ui.md b/docs/guides/android-tv/custom-ui.md new file mode 100644 index 00000000..91bcbb85 --- /dev/null +++ b/docs/guides/android-tv/custom-ui.md @@ -0,0 +1,166 @@ +--- +title: Making a custom TV UI +sidebar_position: 2 +--- + +# Making a custom TV UI + +Use `UIController` to arrange playback controls for a TV remote. You can use the provided TV components or add your own Compose for TV components. + +## A basic player + +Start with a player that has a single Play button: + +```kotlin title="TvPlayer.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun BasicTvPlayer() { + UIController( + config = THEOplayerConfig.Builder().build(), + source = SourceDescription.Builder("https://example.com/stream.m3u8").build(), + centerChrome = { + PlayButton() + } + ) +} +``` + +`UIController` creates and manages the `THEOplayerView` from `config` and loads `source`. Its slots place content in the `centerOverlay`, `errorOverlay`, `topChrome`, `centerChrome`, and `bottomChrome` areas. Pass an existing view to the `UIController(theoplayerView = ...)` overload when you manage the view yourself. + +See the [`UIController` API reference](https://theoplayer.github.io/android-ui/api/android-ui-tv/com.theoplayer.android.ui.tv/-u-i-controller.html). + +## Add more controls + +Add a seek bar, time displays, a Live button, and a loading indicator: + +```kotlin title="TvPlayer.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvPlayerWithControls() { + UIController( + config = THEOplayerConfig.Builder().build(), + source = SourceDescription.Builder("https://example.com/stream.m3u8").build(), + centerOverlay = { + LoadingSpinner() + }, + errorOverlay = { + ErrorDisplay() + }, + centerChrome = { + PlayButton() + }, + bottomChrome = { + SeekBar() + Row(verticalAlignment = Alignment.CenterVertically) { + CurrentTimeDisplay() + Spacer(modifier = Modifier.weight(1f)) + DurationDisplay() + LiveButton() + } + } + ) +} +``` + +- `SeekBar` lets viewers seek with the D-pad. Holding Left or Right scrubs through the timeline. +- `CurrentTimeDisplay` shows the current time. Set `showRemaining = true` to show the remaining time. +- `DurationDisplay` shows the stream duration. +- `LiveButton` shows whether the player is at the live point and returns to it when selected. +- `LoadingSpinner` belongs in `centerOverlay`. It appears when playback is waiting for data. +- `ErrorDisplay` belongs in `errorOverlay`. It shows fatal playback errors. + +The TV UI does not include the mobile `MuteButton`, fullscreen, or picture-in-picture controls. + +## Add menus + +`LanguageMenuButton()` and `SettingsMenuButton()` open the built-in language and settings menus. Call them inside a `UIController` slot, where the `UIControllerScope` also provides the `MenuScope` functions: + +```kotlin title="TvPlayer.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvPlayerWithMenus() { + UIController( + config = THEOplayerConfig.Builder().build(), + source = SourceDescription.Builder("https://example.com/stream.m3u8").build(), + bottomChrome = { + Row { + LanguageMenuButton() + SettingsMenuButton() + } + } + ) +} +``` + +Open a specific built-in menu with `openMenu { PlaybackRateMenu() }`. For a custom menu, use `Menu(title = { ... }) { ... }`. Its content is a `ColumnScope`, so you can add TV Material `ListItem` rows or the built-in `QualityList`, `PlaybackRateList`, `AudioTrackList`, and `SubtitleTrackList` components. + +```kotlin title="CustomMenu.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun UIControllerScope.openCustomMenu() { + openMenu { + Menu(title = { Text("Playback options") }) { + ListItem( + selected = false, + onClick = { closeCurrentMenu() }, + headlineContent = { Text("Done") } + ) + } + } +} +``` + +Menus open as side panels over the video. Back closes the current menu before the player controls or app handle it. + +## Manage focus + +The TV `UIController` puts its controls in a focus group and restores the previous focus when controls reappear. On first entry, focus moves to the first available control. `DefaultUI` directs focus to Play when a live stream has no seek bar. + +In a custom layout, use `focusProperties` to choose where focus enters a group. This pattern directs focus to Play instead of the first button: + +```kotlin title="PlayControls.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun PlayControls() { + val fr = remember { FocusRequester() } + Row( + modifier = Modifier + .focusProperties { onEnter = { fr.requestFocus() } } + .focusGroup() + ) { + PlayButton(modifier = Modifier.focusRequester(fr)) + } +} +``` + +`SeekButton` provides a separate seek-back or seek-forward button. It is used by the demo's Nitflex UI, but not by `DefaultUI`. + +## Use the player's state + +Use `LocalTHEOplayerView.current?.player` with the experimental state holders to read player state in a TV control. For example, show the bottom controls only after playback has started: + +```kotlin title="TvPlayer.kt" +@OptIn(ExperimentalTvUiApi::class, ExperimentalPlayerStateApi::class) +@Composable +fun TvPlayerWithState() { + UIController( + config = THEOplayerConfig.Builder().build(), + source = SourceDescription.Builder("https://example.com/stream.m3u8").build(), + bottomChrome = { + val firstPlayState = + rememberFirstPlayState(LocalTHEOplayerView.current?.player) + if (firstPlayState.firstPlay) { + SeekBar() + } + } + ) +} +``` + +Unlike the mobile UI, the TV UI does not provide `Player.current`. Use state holders such as `rememberFirstPlayState`, `rememberPlayButtonState`, and `rememberSeekBarState` instead. + +## More examples + +The [`DefaultUI` source](https://github.com/THEOplayer/android-ui/blob/main/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DefaultUI.kt) shows how the built-in TV player arranges the controls. The demo app's [`TvNitflexUI`](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexUI.kt) and [`TvNitflexTheme`](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/java/com/theoplayer/android/ui/demo/tv/nitflex/TvNitflexTheme.kt) show a more customized player. + +![Nitflex TV player](../../assets/tv-nitflex-ui.png) diff --git a/docs/guides/android-tv/getting-started.md b/docs/guides/android-tv/getting-started.md new file mode 100644 index 00000000..91317c45 --- /dev/null +++ b/docs/guides/android-tv/getting-started.md @@ -0,0 +1,179 @@ +--- +title: Getting started on Android TV +sidebar_position: 1 +--- + +# Getting started on Android TV + +`android-ui-tv` is an experimental UI library for Android TV, built with Compose for TV and `androidx.tv:tv-material`. It depends on `android-ui-common` for shared state holders, theme colors, and key handling. It does not depend on `android-ui` or the standard Material 3 library. + +## Installation + +Add THEOplayer and the TV UI library to your app's module-level `build.gradle` file. Add TV Material if you use its components, such as `Surface`. + +```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" +} +``` + +The TV UI APIs are experimental. Add `@OptIn(ExperimentalTvUiApi::class)` to each composable or class that uses them. + +## Set up your app for TV + +Declare the TV feature, make touchscreen hardware optional, set a banner on the application, and add a `LEANBACK_LAUNCHER` intent filter to a TV activity. This follows the structure used by the [demo app manifest](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/AndroidManifest.xml). + +```xml title="AndroidManifest.xml" + + + + + + + + + + + + + +``` + +See Google's [Get started with TV apps](https://developer.android.com/training/tv/get-started/create) guide for more information. + +## Use the default UI + +Wrap `DefaultUI` in a TV Material `Surface` inside `THEOplayerTvTheme`: + +```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" + ) + } + } +} +``` + +`DefaultUI` does not set a content color. The `Surface` provides the correct colors for the text and icons. Without it, they can appear black against the black video. + +The UI shows the title at the top left. Time displays sit above a full-width seek bar. Below it is a row with the Live button on the left, Play/Pause in the center, and Language and Settings buttons on the right. The seek bar receives initial focus when it is available. + +![Default Android TV player](../../assets/tv-default-ui.png) + +See the [`DefaultUI` API reference](https://theoplayer.github.io/android-ui/api/android-ui-tv/com.theoplayer.android.ui.tv/-default-u-i.html). + +## Control playback with a remote + +When the controls are hidden, the player handles these keys: + +| Key | Action | +| --- | --- | +| Center or Enter | Show the controls and toggle play or pause | +| Left or Right | Show the controls and seek backward or forward by 10 seconds | +| Other keys | Show the controls | + +Media transport keys work while the controls are hidden or visible: + +| Key | Action | +| --- | --- | +| Play/Pause | Toggle playback | +| Play | Start playback | +| Pause | Pause playback | +| Fast-forward or Rewind | Seek forward or backward by 10 seconds | + +While the controls are visible, the D-pad moves focus between controls. Left and Right on the seek bar seek by 10 seconds. Holding either key scrubs through the timeline and pauses playback while scrubbing. + +Back closes an open menu first. When the controls are visible, Back hides them. When they are hidden, the player leaves Back for the app's own `BackHandler`. Controls hide after 5 seconds without interaction while playing by default. Set `PlayerAnimations.controlsHideDelay` in `THEOplayerTvTheme` to change this delay. + +## Live streams and ads + +Live streams without DVR have no seek bar, so focus moves directly to the Play button. + +![Live stream without a seek bar](../../assets/tv-default-ui-live.png) + +DVR streams show the remaining time. During an ad, the UI hides time displays, Live, Language, and Settings controls. A seek bar remains for seekable streams. + +## Language and settings menus + +Language and Settings open side panels over the video. Back closes the open menu. + +![Language menu](../../assets/tv-language-menu.png) + +![Settings menu](../../assets/tv-settings-menu.png) + +## Subtitles + +Subtitles move away from the top and bottom controls while they are visible. Moving subtitles up requires THEOplayer 9.5.0 or later. + +## Theming + +`THEOplayerTvTheme` accepts a TV Material color scheme, typography, `PlayerColors`, and `PlayerAnimations`. Its default color scheme is dark. There is no automatic light and dark theme switch. + +For example, customize the primary color with a TV Material dark color scheme: + +```kotlin title="TvTheme.kt" +@OptIn(ExperimentalTvUiApi::class) +@Composable +fun TvPlayerTheme(content: @Composable () -> Unit) { + THEOplayerTvTheme( + colorScheme = darkColorScheme(primary = Color(0xFF80CBC4)), + content = content + ) +} +``` + +## Use your own `THEOplayerView` + +Pass an existing view to `DefaultUI(theoplayerView = ...)` or `UIController(theoplayerView = ...)`. Use `rememberTHEOplayerView(config)` when the view should be owned by the composition. It follows the composition's lifecycle and destroys the view when it leaves the composition. + +If your activity creates the view, your activity owns its lifecycle. Forward `onPause`, `onResume`, and `onDestroy` to the view, as the demo's [`TvActivity`](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt) does: + +```kotlin title="TvActivity.kt" +class TvActivity : ComponentActivity() { + private lateinit var theoplayerView: THEOplayerView + + override fun onPause() { + super.onPause() + theoplayerView.onPause() + } + + override fun onResume() { + super.onResume() + theoplayerView.onResume() + } + + override fun onDestroy() { + super.onDestroy() + theoplayerView.onDestroy() + } +} +``` + +## Differences from mobile + +The TV UI has no Chromecast, fullscreen, picture-in-picture, or mute controls. The player is assumed to always fill the screen, and the remote controls the volume. + +## Try the demo app + +The demo app's [`TvActivity`](https://github.com/THEOplayer/android-ui/blob/main/app/src/main/java/com/theoplayer/android/ui/demo/tv/TvActivity.kt) opens a home screen with a stream carousel and theme chips. + +![Android TV demo home screen](../../assets/tv-demo-home.jpg) + +To run it, create a **Television (1080p)** Android Virtual Device, run the `app` module, and open the app from the TV launcher. See the [TV demo source](https://github.com/THEOplayer/android-ui/tree/main/app/src/main/java/com/theoplayer/android/ui/demo/tv) for the full example. diff --git a/docs/guides/chromecast.md b/docs/guides/chromecast.md index e79bee63..7853a998 100644 --- a/docs/guides/chromecast.md +++ b/docs/guides/chromecast.md @@ -103,3 +103,7 @@ you need to use theme based on `Theme.AppCompat` in your app: ``` + +## Android TV + +Chromecast is not supported by the experimental `android-ui-tv` library. diff --git a/docs/guides/custom-ui.md b/docs/guides/custom-ui.md index 37d8b7fc..0a1fb1d0 100644 --- a/docs/guides/custom-ui.md +++ b/docs/guides/custom-ui.md @@ -8,6 +8,10 @@ Although the default UI was designed to support a variety of usage scenarios, yo In this guide, you will build a custom player from scratch, starting from a basic player and gradually adding more features. Finally, we will look at some more complete examples of custom players, which you can use as reference for your own UI. +:::tip +Building for Android TV? See [Making a custom TV UI](./android-tv/custom-ui.md). +::: + ## A basic player We'll start by making the most basic player possible: one with just a play button. @@ -73,7 +77,7 @@ Additionally, we change the size and padding of the play button, to make it bigg You might have noticed that all buttons are visible immediately, even before the video starts playing for the first time. Most of the time however, you want to initially show only the play button, and then show the rest of the buttons after the video has started playing. To do this, you should check the player's current state and then adjust your player layout accordingly. -Within any of the composable lambdas you pass to `UIController`, you can use `Player.current` to access the current player. This `Player` object exposes properties for the player's current time, duration, paused and muted states, etc. See [`Player.kt`](https://github.com/THEOplayer/android-ui/blob/main/ui/src/main/java/com/theoplayer/android/ui/Player.kt) for the full list of properties. +Within any of the composable lambdas you pass to `UIController`, you can use `Player.current` to access the current player. This `Player` object exposes properties for the player's current time, duration, paused and muted states, etc. See [`Player.kt`](https://github.com/THEOplayer/android-ui/blob/main/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt) for the full list of properties. You can then perform any logic on these properties to adjust your player layout. For example, you can check the `firstPlay` property to see if the player has already started playing for the first time, and only show certain buttons if that property is `true`: @@ -128,3 +132,5 @@ It may sound odd to mention the default UI when we're talking about custom UIs. The default UI comes with all the bells and whistles that you'd expect from a fully-featured player: buttons to play or mute the video, a seek bar to seek through the video, menus to change the audio language and subtitles, and support for casting to Chromecast. Have a look at [`DefaultUI.kt`](https://github.com/THEOplayer/android-ui/blob/main/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt) to learn how the default layout is implemented, and perhaps copy some ideas for your own UI. + +For a TV player, see [Making a custom TV UI](./android-tv/custom-ui.md). diff --git a/docs/guides/localization.md b/docs/guides/localization.md index ec80ffca..d1d28078 100644 --- a/docs/guides/localization.md +++ b/docs/guides/localization.md @@ -12,13 +12,15 @@ by [providing alternative resources](https://developer.android.com/guide/topics/ in one or more languages. You can choose to [change the text of the default language](#changing-the-default-language) to target a single audience, or [add alternative languages](#add-alternative-languages) to target many audiences. +The resources are in `ui-common` and are shared by `android-ui` and `android-ui-tv`. + ## Changing the default language By default, the Open Video UI ships with English texts only. If your app targets a non-English speaking audience, you can override these texts with translations for another language. 1. Copy the English string resources - from [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/blob/main/ui/src/main/res/values/strings.xml) + from [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/blob/main/ui-common/src/main/res/values/strings.xml) to your app's resources for the default locale (i.e. `res/values/strings.xml`). 2. Change the resource values to the translated texts. For example, to change the title of the "Language" menu, you would change the contents @@ -44,7 +46,7 @@ If your app targets many audiences speaking different languages, you can add mul locale-specific resources. 1. Copy the English string resources - from [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/blob/main/ui/src/main/res/values/strings.xml) + from [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/blob/main/ui-common/src/main/res/values/strings.xml) to your app's resources for the default locale (i.e. `res/values/strings.xml`). 2. Add a new string resources file for a new locale. For example, for French, create the file: `res/values-fr/strings.xml`. @@ -62,8 +64,9 @@ locale-specific resources. Newer versions of the Open Video UI for Android may add new string resources that need to be translated. -When using custom translations in your app, we recommend pinning the `com.theoplayer.android-ui:android-ui` dependency -in your app's `build.gradle` to a specific version. Avoid using `+` in the dependency's version range. +If you use custom translations, pin the `com.theoplayer.android-ui:android-ui` dependency and, if used, the +`com.theoplayer.android-ui:android-ui-tv` dependency to specific versions in your app's `build.gradle`. Avoid using `+` +in the dependency version ranges. ```groovy title="build.gradle" dependencies { @@ -72,7 +75,7 @@ dependencies { ``` When you decide to upgrade Open Video UI to the latest version, make sure to also update your translations. -Check the history for [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/commits/main/ui/src/main/res/values/strings.xml) +Check the history for [`res/values/strings.xml`](https://github.com/THEOplayer/android-ui/commits/main/ui-common/src/main/res/values/strings.xml) to see whether any string resources were added or changed since the previous version. ## More information diff --git a/gradle.properties b/gradle.properties index a7841c32..cbcfa3f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,4 +31,4 @@ android.nonFinalResIds=true kotlin.code.style=official # The version of the THEOplayer Open Video UI for Android. -version=1.14.2 +version=1.15.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaf7f30a..2332f24c 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? @@ -19,18 +20,22 @@ 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" +ktfmt = "0.27.0" theoplayer = { prefer = "11.+", strictly = "[7.6.0, 12.0)" } 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" } 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" } @@ -46,6 +51,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" } @@ -54,6 +60,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" } @@ -70,3 +77,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc" } changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4c36ccb5..0724d72c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,9 +5,9 @@ pluginManagement { gradlePluginPortal() } } -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" -} + +plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { @@ -16,6 +16,13 @@ dependencyResolutionManagement { maven { url = uri("https://maven.theoplayer.com/releases") } } } + rootProject.name = "THEOplayer Open Video UI for Android" + include(":app") + include(":ui") + +include(":ui-common") + +include(":ui-tv") 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..5af6f4ee --- /dev/null +++ b/ui-common/build.gradle.kts @@ -0,0 +1,120 @@ +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) + alias(libs.plugins.ktfmt) + id("android-ui.library-conventions") +} + +ktfmt { kotlinLangStyle() } + +android { + namespace = "com.theoplayer.android.ui.common" + 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 } + 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") + optIn.add("com.theoplayer.android.ui.ExperimentalPlayerStateApi") + } +} + +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.animation) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.ui) + + testImplementation(libs.junit4) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + + compileOnly(libs.theoplayer.compile) + api(libs.theoplayer) + + dokkaPlugin(libs.dokka.plugin) +} + +dokka { + moduleName = "android-ui-common" + 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" + ) + 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-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..a7f51ec9 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AdState.kt @@ -0,0 +1,62 @@ +package com.theoplayer.android.ui + +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 com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * State holder for UI that depends on whether an ad is playing. + * + * Create an instance with [rememberAdState]. + */ +@ExperimentalPlayerStateApi +class AdState internal constructor(private val player: Player?) { + /** Whether an ad is currently playing. */ + var playingAd by mutableStateOf(false) + private set + + private fun update() { + playingAd = player?.ads?.isPlaying ?: false + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + coroutineScope { + player.ads?.let { ads -> + launch(start = CoroutineStart.UNDISPATCHED) { + ads.listen( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END, + ) { + update() + } + } + } + player.listen(PlayerEventTypes.SOURCECHANGE, PlayerEventTypes.ERROR) { update() } + } + } +} + +/** Creates an [AdState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberAdState(player: Player?): AdState { + val state = remember(player) { AdState(player) } + 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 new file mode 100644 index 00000000..092de27e --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/AudioTrackState.kt @@ -0,0 +1,71 @@ +package com.theoplayer.android.ui + +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 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?) { + /** 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 } + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.audioTracks.listen( + AudioTrackListEventTypes.ADDTRACK, + AudioTrackListEventTypes.REMOVETRACK, + AudioTrackListEventTypes.TRACKLISTCHANGE, + ) { + update() + } + } + + /** 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) } + 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 new file mode 100644 index 00000000..6fd0aa98 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/CastState.kt @@ -0,0 +1,98 @@ +package com.theoplayer.android.ui + +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.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.chromecast.ChromecastEventTypes +import kotlinx.coroutines.flow.collectLatest + +/** + * 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 var cast: Cast? by mutableStateOf(null) + + /** The current cast state of the player. */ + val castState: PlayerCastState + get() { + refreshCast() + return _castState + } + + private var _castState: PlayerCastState by mutableStateOf(PlayerCastState.UNAVAILABLE) + + /** The name of the connected Chromecast receiver, if any. */ + val castReceiverName: String? + get() { + refreshCast() + return _castReceiverName + } + + private var _castReceiverName: String? by mutableStateOf(null) + + /** Whether the player is casting. */ + val casting: Boolean + get() { + refreshCast() + return _casting + } + + private var _casting: Boolean by mutableStateOf(false) + + private fun update(cast: Cast?) { + _castState = cast?.chromecast?.state ?: PlayerCastState.UNAVAILABLE + _castReceiverName = cast?.chromecast?.receiverName + _casting = cast?.chromecast?.isCasting == true + } + + internal fun refreshCast() { + cast = theoplayerView?.cast + } + + internal suspend fun observe() { + refreshCast() + snapshotFlow { cast } + .collectLatest { cast -> + update(cast) + cast?.chromecast?.listen( + ChromecastEventTypes.STATECHANGE, + ChromecastEventTypes.ERROR, + ) { + update(cast) + } + } + } + + /** 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) } + LaunchedEffect(state) { state.observe() } + state.refreshCast() + return state +} 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..c687ac38 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt @@ -0,0 +1,83 @@ +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +/** Visibility state and callbacks for player controls. */ +@InternalUiApi +class ControlsVisibility( + /** + * Whether the controls are currently visible. This [State] instance is stable across + * recompositions. + */ + 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 currentFirstPlay by rememberUpdatedState(firstPlay) + val currentPaused by rememberUpdatedState(paused) + val currentForceVisible by rememberUpdatedState(forceVisible) + val visible = remember { + derivedStateOf { + when { + !isReady -> false + !currentFirstPlay || currentForceVisible -> true + forceControlsHidden -> false + else -> recentlyActive || currentPaused + } + } + } + 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/ErrorState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt new file mode 100644 index 00000000..c6c17ea1 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ErrorState.kt @@ -0,0 +1,66 @@ +package com.theoplayer.android.ui + +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 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 + +/** + * 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?, + initialError: THEOplayerException? = null, +) { + /** The fatal error the player has encountered, if any. */ + var error by mutableStateOf(initialError) + private set + + internal suspend fun observe() { + val player = player ?: return + player.listen(PlayerEventTypes.ERROR, PlayerEventTypes.SOURCECHANGE) { event -> + error = (event as? ErrorEvent)?.errorObject + } + } +} + +/** + * 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) } + LaunchedEffect(state) { state.observe() } + return state +} 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..65feef21 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/EventDispatcherUtil.kt @@ -0,0 +1,60 @@ +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 + +/** + * 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, + 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/ExperimentalPlayerStateApi.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ExperimentalPlayerStateApi.kt new file mode 100644 index 00000000..9302dca1 --- /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/FirstPlayState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt new file mode 100644 index 00000000..b263adf8 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FirstPlayState.kt @@ -0,0 +1,66 @@ +package com.theoplayer.android.ui + +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 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?) { + /** Whether playback has started for the first time. */ + var firstPlay by mutableStateOf(false) + private set + + private fun updatePlaying() { + if (player?.isPaused == false) { + firstPlay = true + } + } + + private fun updateFirstPlay() { + firstPlay = player?.let { !it.isPaused || (it.played?.length() ?: 0) > 0 } == true + } + + init { + updateFirstPlay() + } + + internal suspend fun observe() { + val player = player ?: return + 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 + } + updatePlaying() + } + } +} + +/** Creates a [FirstPlayState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberFirstPlayState(player: Player?): FirstPlayState { + val state = remember(player) { FirstPlayState(player) } + LaunchedEffect(state) { state.observe() } + return state +} 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 53% 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 index 81192e4a..ec78969e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -7,16 +7,27 @@ 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 java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.suspendCancellableCoroutine internal interface FullscreenHandler { val fullscreen: Boolean - var onFullscreenChangeListener: OnFullscreenChangeListener? + + fun addOnFullscreenChangeListener(listener: OnFullscreenChangeListener) + + fun removeOnFullscreenChangeListener(listener: OnFullscreenChangeListener) + fun requestFullscreen() + fun exitFullscreen() fun interface OnFullscreenChangeListener { @@ -24,10 +35,72 @@ internal interface FullscreenHandler { } } +/** + * 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 + 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. + * + * 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 = CopyOnWriteArrayList() + + 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 +114,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 @@ -68,13 +142,13 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler view, ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + ViewGroup.LayoutParams.MATCH_PARENT, + ), ) } fullscreen = true - onFullscreenChangeListener?.onFullscreenChange(fullscreen) + listeners.forEach { it.onFullscreenChange(fullscreen) } } override fun exitFullscreen() { @@ -82,6 +156,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,9 +181,10 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler previousViewIndex = 0 fullscreen = false - onFullscreenChangeListener?.onFullscreenChange(fullscreen) + listeners.forEach { it.onFullscreenChange(fullscreen) } } } -private suspend fun View.postAsync() = - suspendCoroutine { continuation -> post { continuation.resume(Unit) } } \ No newline at end of file +private suspend fun View.postAsync() = suspendCoroutine { continuation -> + post { continuation.resume(Unit) } +} 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..2829cc3b --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/FullscreenState.kt @@ -0,0 +1,60 @@ +package com.theoplayer.android.ui + +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 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 + + /** Whether the player is shown in fullscreen mode. */ + var fullscreen by mutableStateOf(false) + private set + + private fun update() { + fullscreen = handler?.fullscreen ?: false + } + + init { + update() + } + + internal suspend fun observe() { + val handler = handler ?: return + update() + handler.listen { update() } + } + + /** 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) } + LaunchedEffect(state) { state.observe() } + return state +} 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..3c7cfb36 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/InternalUiApi.kt @@ -0,0 +1,20 @@ +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-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..c4645f0d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LiveButtonState.kt @@ -0,0 +1,94 @@ +package com.theoplayer.android.ui + +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 com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for a "live" button, such as [LiveButton]. + * + * Create an instance with [rememberLiveButtonState]. + */ +@ExperimentalPlayerStateApi +class LiveButtonState +internal constructor( + private val player: Player?, + private val liveThreshold: Double, +) { + /** 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() = streamTypeOf(duration, seekable) + + 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() + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.DURATIONCHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } + + /** 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) } + 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 new file mode 100644 index 00000000..bd2506ea --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LoadingState.kt @@ -0,0 +1,68 @@ +package com.theoplayer.android.ui + +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 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?) { + /** 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 + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen( + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } +} + +/** Creates a [LoadingState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberLoadingState(player: Player?): LoadingState { + val state = remember(player) { LoadingState(player) } + LaunchedEffect(state) { state.observe() } + return state +} 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..cf19cde2 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/LocalTHEOplayerView.kt @@ -0,0 +1,7 @@ +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-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..c5d36e9a --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt @@ -0,0 +1,42 @@ +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]. + * + * This interface is implemented by `UIController`; implementing it yourself is not supported. + */ +@OptIn(ExperimentalSubclassOptIn::class) +@SubclassOptInRequired(InternalUiApi::class) +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 with `remember` is disposed — state remembered with `rememberSaveable` (such as scroll + * positions) is restored when the submenu closes. 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, returning to the previous menu (if any). */ + fun closeCurrentMenu() + + /** Close all open menus, including the current menu and all menus below it. */ + fun closeAllMenus() +} 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..5c3d53dd --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/MuteButtonState.kt @@ -0,0 +1,54 @@ +package com.theoplayer.android.ui + +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 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?) { + /** Whether the audio is muted. */ + var muted by mutableStateOf(false) + private set + + private fun update() { + muted = player?.isMuted ?: false + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen(PlayerEventTypes.VOLUMECHANGE) { update() } + } + + /** 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) } + 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 new file mode 100644 index 00000000..ace777c4 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PictureInPictureState.kt @@ -0,0 +1,95 @@ +package com.theoplayer.android.ui + +import android.app.Activity +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 com.theoplayer.android.api.THEOplayerView +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]. + * + * Create an instance with [rememberPictureInPictureState]. + */ +@ExperimentalPlayerStateApi +class PictureInPictureState internal constructor(private val theoplayerView: THEOplayerView?) { + 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 suspend fun observe() { + try { + val player = theoplayerView?.player ?: awaitCancellation() + update() + player.listen(PlayerEventTypes.PRESENTATIONMODECHANGE) { update() } + } finally { + 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) } + 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 new file mode 100644 index 00000000..58beea6d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayButtonState.kt @@ -0,0 +1,70 @@ +package com.theoplayer.android.ui + +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 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 internal constructor(private val player: Player?) { + /** 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 + } + + init { + updatePlaybackState() + } + + internal suspend fun observe() { + val player = player ?: return + updatePlaybackState() + player.listen( + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + updatePlaybackState() + } + } + + /** 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) } + 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 new file mode 100644 index 00000000..e4bf57a4 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackRateState.kt @@ -0,0 +1,51 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +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.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?) { + /** 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 + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen(PlayerEventTypes.RATECHANGE) { update() } + } + + /** 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) } + LaunchedEffect(state) { state.observe() } + return state +} 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..0403cf6c --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt @@ -0,0 +1,49 @@ +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/PlaybackTimeState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt new file mode 100644 index 00000000..8717ad83 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeState.kt @@ -0,0 +1,76 @@ +package com.theoplayer.android.ui + +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 com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player + +/** + * State holder for a playback time display, such as [CurrentTimeDisplay] or [DurationDisplay]. + * + * Create an instance with [rememberPlaybackTimeState]. + */ +@ExperimentalPlayerStateApi +class PlaybackTimeState internal constructor(private val player: Player?) { + /** 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() = streamTypeOf(rawDuration, seekable) + + 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 + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.DURATIONCHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } +} + +/** Creates a [PlaybackTimeState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberPlaybackTimeState(player: Player?): PlaybackTimeState { + val state = remember(player) { PlaybackTimeState(player) } + LaunchedEffect(state) { state.observe() } + return state +} 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 72% 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..65409386 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/Player.kt @@ -1,7 +1,6 @@ package com.theoplayer.android.ui import android.app.Activity -import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State @@ -39,11 +38,23 @@ 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.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 as VideoActiveQualityChangedEvent +import com.theoplayer.android.api.event.track.mediatrack.video.TargetQualityChangedEvent as VideoTargetQualityChangedEvent 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.pip.PiPType +import com.theoplayer.android.api.player.Player as THEOplayer import com.theoplayer.android.api.player.ReadyState import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality @@ -53,20 +64,13 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrackKind import com.theoplayer.android.api.player.track.texttrack.TextTrackMode import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.ui.util.supportsPictureInPictureMode -import com.theoplayer.android.api.event.track.mediatrack.audio.list.AddTrackEvent as AudioAddTrackEvent -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 as VideoActiveQualityChangedEvent -import com.theoplayer.android.api.event.track.mediatrack.video.TargetQualityChangedEvent as VideoTargetQualityChangedEvent -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.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.TrackListChangeEvent as TextTrackListChangeEvent -import com.theoplayer.android.api.player.Player as THEOplayer -internal val LocalPlayer = staticCompositionLocalOf { null } +/** + * The [Player] provided by the enclosing [UIController]. + * + * @suppress + */ +@InternalUiApi val LocalPlayer = staticCompositionLocalOf { null } /** * A player holding a [THEOplayer] instance that can be hosted by a [DefaultUI] or [UIController]. @@ -78,9 +82,9 @@ interface Player { /** * Returns the backing THEOplayer instance. * - * Note that [THEOplayer] properties are *not* backed by [State] objects, so composables - * will *not* be automatically subscribed to changes. Instead, use [THEOplayer.addEventListener], - * or use a corresponding [Player] property if available. + * Note that [THEOplayer] properties are *not* backed by [State] objects, so composables will + * *not* be automatically subscribed to changes. Instead, use [THEOplayer.addEventListener], or + * use a corresponding [Player] property if available. */ val player: THEOplayer? @@ -91,83 +95,55 @@ interface Player { */ val theoplayerView: THEOplayerView? - /** - * Returns or sets the player's source. - */ + /** Returns or sets the player's source. */ var source: SourceDescription? - /** - * Returns the raw [Cast] API of the backing THEOplayer instance. - */ + /** Returns the raw [Cast] API of the backing THEOplayer instance. */ val cast: Cast? - /** - * Returns the raw [Ads] API of the backing THEOplayer instance. - */ + /** Returns the raw [Ads] API of the backing THEOplayer instance. */ val ads: Ads? - /** - * Returns the current playback position of the media, in seconds. - */ + /** Returns the current playback position of the media, in seconds. */ val currentTime: Double - /** - * Returns the duration of the media, in seconds. - */ + /** Returns the duration of the media, in seconds. */ val duration: Double - /** - * Returns the ranges of the media to which the player can seek. - */ + /** Returns the ranges of the media to which the player can seek. */ val seekable: TimeRanges - /** - * Returns whether the player is paused. - */ + /** Returns whether the player is paused. */ val paused: Boolean - /** - * Returns whether playback has reached the end of the media. - */ + /** Returns whether playback has reached the end of the media. */ val ended: Boolean - /** - * Returns whether the player is seeking. - */ + /** Returns whether the player is seeking. */ val seeking: Boolean - /** - * Returns the [ReadyState] of the player. - */ + /** Returns the [ReadyState] of the player. */ val readyState: ReadyState /** * Returns or sets the audio volume of the player. * - * The player's volume is separate from the device's media volume. - * In general, it is recommended to let the user control the volume of the device instead, - * and leave the player's volume untouched. + * The player's volume is separate from the device's media volume. In general, it is recommended + * to let the user control the volume of the device instead, and leave the player's volume + * untouched. */ var volume: Double - /** - * Returns or sets whether the audio is muted. - */ + /** Returns or sets whether the audio is muted. */ var muted: Boolean - /** - * Returns or sets the playback rate of the player. - */ + /** Returns or sets the playback rate of the player. */ var playbackRate: Double - /** - * Returns the width of the active video rendition, in pixels. - */ + /** Returns the width of the active video rendition, in pixels. */ val videoWidth: Int - /** - * Returns the height of the active video rendition, in pixels. - */ + /** Returns the height of the active video rendition, in pixels. */ val videoHeight: Int /** @@ -177,191 +153,178 @@ interface Player { */ val firstPlay: Boolean - /** - * Returns any fatal error the player has encountered, if any. - */ + /** Returns any fatal error the player has encountered, if any. */ val error: THEOplayerException? - /** - * Returns or sets whether the player should be shown in fullscreen mode. - */ + /** Returns or sets whether the player should be shown in fullscreen mode. */ var fullscreen: Boolean - /** - * Returns whether the player is showing in picture-in-picture mode. - */ + /** Returns whether the player is showing in picture-in-picture mode. */ val pictureInPicture: Boolean - /** - * Returns whether the player supports entering picture-in-picture mode. - */ + /** Returns whether the player supports entering picture-in-picture mode. */ val pictureInPictureSupported: Boolean - /** - * Returns whether the player is currently waiting for more data to resume playback. - */ + /** Returns whether the player is currently waiting for more data to resume playback. */ val loading: Boolean - /** - * Returns whether the player is currently playing an ad. - */ + /** Returns whether the player is currently playing an ad. */ val playingAd: Boolean /** * Returns whether the player can seek to a different time. * - * Seeking may sometimes be prevented, for example because the player is [playing an ad][playingAd]. + * Seeking may sometimes be prevented, for example because the player is + * [playing an ad][playingAd]. */ val canSeek: Boolean /** * Returns the [StreamType] of the media. * - * This can be used to show or hide certain controls which are only relevant - * for certain stream types. + * This can be used to show or hide certain controls which are only relevant for certain stream + * types. */ val streamType: StreamType - /** - * Returns the currently active video quality. - */ + /** Returns the currently active video quality. */ val activeVideoQuality: VideoQuality? /** * Returns or sets the target video quality. * - * If `null`, then the player will choose a video quality based on its ABR algorithm. - * Otherwise, the player will only load from the given video quality. + * If `null`, then the player will choose a video quality based on its ABR algorithm. Otherwise, + * the player will only load from the given video quality. */ var targetVideoQuality: VideoQuality? - /** - * Returns all available video qualities. - */ + /** Returns all available video qualities. */ val videoQualities: List - /** - * Returns all available audio tracks. - */ + /** Returns all available audio tracks. */ val audioTracks: List> - /** - * Returns or sets the active audio track. - */ + /** Returns or sets the active audio track. */ var activeAudioTrack: MediaTrack? /** * Returns all available subtitle tracks. * - * A subtitle track is a [TextTrack] whose [kind][TextTrack.getKind] - * is [TextTrackKind.SUBTITLES] or [TextTrackKind.CAPTIONS]. + * A subtitle track is a [TextTrack] whose [kind][TextTrack.getKind] is + * [TextTrackKind.SUBTITLES] or [TextTrackKind.CAPTIONS]. */ val subtitleTracks: List - /** - * Returns or sets the active subtitle track. - */ + /** Returns or sets the active subtitle track. */ var activeSubtitleTrack: TextTrack? - /** - * Returns the casting state of the player. - */ + /** Returns the casting state of the player. */ val castState: PlayerCastState - /** - * Returns the name of the connected Chromecast receiver, if any. - */ + /** Returns the name of the connected Chromecast receiver, if any. */ val castReceiverName: String? - /** - * Starts or resumes playback. - */ + /** Starts or resumes playback. */ fun play() - /** - * Pauses playback. - */ + /** Pauses playback. */ fun pause() - /** - * Enter picture-in-picture mode. - */ + /** Enter picture-in-picture mode. */ fun enterPictureInPicture(pipType: PiPType) - /** - * Exit picture-in-picture mode. - */ + /** Exit picture-in-picture mode. */ fun exitPictureInPicture() - /** - * Contains properties to access the current [Player]. - */ + /** Contains properties to access the current [Player]. */ companion object { - /** - * Retrieves the current [Player] at the call site's position in the hierarchy. - */ + /** Retrieves the current [Player] at the call site's position in the hierarchy. */ val current: Player? - @Composable - get() = LocalPlayer.current + @Composable get() = LocalPlayer.current } } -/** - * The type of a stream. - */ +/** The type of a stream. */ enum class StreamType { - /** - * Video-on-demand: the stream has a known fixed duration. - */ + /** Video-on-demand: the stream has a known fixed duration. */ Vod, - /** - * Live: the stream has an unknown duration, and does not allow seeking to an earlier time. - */ + /** Live: the stream has an unknown duration, and does not allow seeking to an earlier time. */ Live, - /** - * DVR: the stream has an unknown duration, and allows seeking to an earlier time. - */ - Dvr + /** DVR: the stream has an unknown duration, and allows seeking to an earlier time. */ + Dvr, } -internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { +// 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]. + * + * @suppress + */ +@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) private set + override var duration by mutableDoubleStateOf(Double.NaN) private set + override var seekable by mutableStateOf(TimeRanges.empty()) private set + override var paused by mutableStateOf(true) private set + override var playingAd by mutableStateOf(false) private set + override var ended by mutableStateOf(false) private set + override var seeking by mutableStateOf(false) private set + override var readyState by mutableStateOf(ReadyState.HAVE_NOTHING) private set + override var videoWidth by mutableIntStateOf(0) private set + override var videoHeight by mutableIntStateOf(0) private set + override var firstPlay by mutableStateOf(false) private set + override var error by mutableStateOf(null) private set + override val canSeek by derivedStateOf { if (playingAd) { false } else { - seekable.isNotEmpty() || run { - // `player.seekable` is (incorrectly) empty while casting, see #35 - // Temporary fix: always allow seeking while casting. - castState == PlayerCastState.CONNECTED - } + seekable.isNotEmpty() || + run { + // 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 + } } } @@ -406,26 +369,28 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player EventListener { updateCurrentTimeAndPlaybackState() } private val resizeListener = EventListener { updateVideoWidthAndHeight() } private val adListener = EventListener> { updatePlayingAd() } - private val sourceChangeListener = EventListener { - _source = player?.source - error = null - firstPlay = false - updateCast(theoplayerView?.cast) - updateCurrentTimeAndPlaybackState() - updateDuration() - updateVideoWidthAndHeight() - updateActiveVideoTrack() - updatePlayingAd() - } - private val errorListener = EventListener { event -> - _source = player?.source - error = event.errorObject - updateCurrentTimeAndPlaybackState() - updateDuration() - updateVideoWidthAndHeight() - updateActiveVideoTrack() - updatePlayingAd() - } + private val sourceChangeListener = + EventListener { + _source = player?.source + error = null + firstPlay = false + updateCast(theoplayerView?.cast) + updateCurrentTimeAndPlaybackState() + updateDuration() + updateVideoWidthAndHeight() + updateActiveVideoTrack() + updatePlayingAd() + } + private val errorListener = + EventListener { event -> + _source = player?.source + error = event.errorObject + updateCurrentTimeAndPlaybackState() + updateDuration() + updateVideoWidthAndHeight() + updateActiveVideoTrack() + updatePlayingAd() + } override fun play() { paused = false @@ -453,6 +418,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player _volume = value player?.volume = value } + override var muted: Boolean get() = _muted set(value) { @@ -481,9 +447,7 @@ internal 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 @@ -505,10 +469,11 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } } - val fullscreenListener = - FullscreenHandler.OnFullscreenChangeListener { updateFullscreen() } + 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,31 +503,20 @@ 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 { !paused && !ended && (seeking || readyState.ordinal < ReadyState.HAVE_FUTURE_DATA.ordinal) } - 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 - } - } + override val streamType by derivedStateOf { streamTypeOf(duration, seekable) } private var activeVideoTrack: MediaTrack? = null override val videoQualities = mutableStateListOf() override var activeVideoQuality by mutableStateOf(null) private set + private var _targetVideoQuality by mutableStateOf(null) override var targetVideoQuality: VideoQuality? get() = _targetVideoQuality @@ -582,11 +536,11 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } oldActiveVideoTrack?.removeEventListener( VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT, - videoActiveQualityChangeListener + videoActiveQualityChangeListener, ) oldActiveVideoTrack?.removeEventListener( VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT, - videoTargetQualityChangeListener + videoTargetQualityChangeListener, ) activeVideoTrack = videoTrack videoQualities.clear() @@ -595,11 +549,11 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateTargetVideoQuality() videoTrack?.addEventListener( VideoTrackEventTypes.ACTIVEQUALITYCHANGEDEVENT, - videoActiveQualityChangeListener + videoActiveQualityChangeListener, ) videoTrack?.addEventListener( VideoTrackEventTypes.TARGETQUALITYCHANGEDEVENT, - videoTargetQualityChangeListener + videoTargetQualityChangeListener, ) } @@ -624,6 +578,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player override var audioTracks = mutableStateListOf>() private set + private var _activeAudioTrack by mutableStateOf?>(null) override var activeAudioTrack: MediaTrack? get() = _activeAudioTrack @@ -658,6 +613,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player override var subtitleTracks = mutableStateListOf() private set + private var _activeSubtitleTrack by mutableStateOf(null) override var activeSubtitleTrack: TextTrack? get() = _activeSubtitleTrack @@ -701,11 +657,13 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateCast(theoplayerView?.cast) return _cast } + override val castState: PlayerCastState get() { updateCast(theoplayerView?.cast) return _castState } + override val castReceiverName: String? get() { updateCast(theoplayerView?.cast) @@ -717,18 +675,18 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player _cast?.let { oldCast -> oldCast.chromecast.removeEventListener( ChromecastEventTypes.STATECHANGE, - chromecastStateChangeListener + chromecastStateChangeListener, ) oldCast.chromecast.removeEventListener( ChromecastEventTypes.ERROR, - chromecastErrorListener + chromecastErrorListener, ) } _cast = cast cast?.let { cast.chromecast.addEventListener( ChromecastEventTypes.STATECHANGE, - chromecastStateChangeListener + chromecastStateChangeListener, ) cast.chromecast.addEventListener(ChromecastEventTypes.ERROR, chromecastErrorListener) } @@ -770,50 +728,68 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player player?.addEventListener(PlayerEventTypes.RESIZE, resizeListener) player?.addEventListener( PlayerEventTypes.PRESENTATIONMODECHANGE, - presentationModeChangeListener + presentationModeChangeListener, ) player?.addEventListener(PlayerEventTypes.SOURCECHANGE, sourceChangeListener) player?.addEventListener(PlayerEventTypes.ERROR, errorListener) - player?.videoTracks?.addEventListener( - VideoTrackListEventTypes.ADDTRACK, - videoAddTrackListener - ) - player?.videoTracks?.addEventListener( - VideoTrackListEventTypes.REMOVETRACK, - videoRemoveTrackListener - ) - player?.videoTracks?.addEventListener( - VideoTrackListEventTypes.TRACKLISTCHANGE, - videoTrackListChangeListener - ) - player?.audioTracks?.addEventListener( - AudioTrackListEventTypes.ADDTRACK, - audioAddTrackListener - ) - player?.audioTracks?.addEventListener( - AudioTrackListEventTypes.REMOVETRACK, - audioRemoveTrackListener - ) - player?.audioTracks?.addEventListener( - AudioTrackListEventTypes.TRACKLISTCHANGE, - audioTrackListChangeListener - ) - player?.textTracks?.addEventListener( - TextTrackListEventTypes.ADDTRACK, - textAddTrackListener - ) - player?.textTracks?.addEventListener( - TextTrackListEventTypes.REMOVETRACK, - textRemoveTrackListener - ) - player?.textTracks?.addEventListener( - TextTrackListEventTypes.TRACKLISTCHANGE, - textTrackListChangeListener - ) + player + ?.videoTracks + ?.addEventListener( + VideoTrackListEventTypes.ADDTRACK, + videoAddTrackListener, + ) + player + ?.videoTracks + ?.addEventListener( + VideoTrackListEventTypes.REMOVETRACK, + videoRemoveTrackListener, + ) + player + ?.videoTracks + ?.addEventListener( + VideoTrackListEventTypes.TRACKLISTCHANGE, + videoTrackListChangeListener, + ) + player + ?.audioTracks + ?.addEventListener( + AudioTrackListEventTypes.ADDTRACK, + audioAddTrackListener, + ) + player + ?.audioTracks + ?.addEventListener( + AudioTrackListEventTypes.REMOVETRACK, + audioRemoveTrackListener, + ) + player + ?.audioTracks + ?.addEventListener( + AudioTrackListEventTypes.TRACKLISTCHANGE, + audioTrackListChangeListener, + ) + player + ?.textTracks + ?.addEventListener( + TextTrackListEventTypes.ADDTRACK, + textAddTrackListener, + ) + player + ?.textTracks + ?.addEventListener( + TextTrackListEventTypes.REMOVETRACK, + textRemoveTrackListener, + ) + player + ?.textTracks + ?.addEventListener( + TextTrackListEventTypes.TRACKLISTCHANGE, + textTrackListChangeListener, + ) 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() { @@ -835,58 +811,68 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player player?.removeEventListener(PlayerEventTypes.RESIZE, resizeListener) player?.removeEventListener( PlayerEventTypes.PRESENTATIONMODECHANGE, - presentationModeChangeListener + presentationModeChangeListener, ) player?.removeEventListener(PlayerEventTypes.SOURCECHANGE, sourceChangeListener) player?.removeEventListener(PlayerEventTypes.ERROR, errorListener) - player?.videoTracks?.removeEventListener( - VideoTrackListEventTypes.ADDTRACK, - videoAddTrackListener - ) - player?.videoTracks?.removeEventListener( - VideoTrackListEventTypes.REMOVETRACK, - videoRemoveTrackListener - ) - player?.videoTracks?.removeEventListener( - VideoTrackListEventTypes.TRACKLISTCHANGE, - videoTrackListChangeListener - ) - player?.audioTracks?.removeEventListener( - AudioTrackListEventTypes.ADDTRACK, - audioAddTrackListener - ) - player?.audioTracks?.removeEventListener( - AudioTrackListEventTypes.REMOVETRACK, - audioRemoveTrackListener - ) - player?.audioTracks?.removeEventListener( - AudioTrackListEventTypes.TRACKLISTCHANGE, - audioTrackListChangeListener - ) - player?.textTracks?.removeEventListener( - TextTrackListEventTypes.ADDTRACK, - textAddTrackListener - ) - player?.textTracks?.removeEventListener( - TextTrackListEventTypes.REMOVETRACK, - textRemoveTrackListener - ) - player?.textTracks?.removeEventListener( - TextTrackListEventTypes.TRACKLISTCHANGE, - textTrackListChangeListener - ) + player + ?.videoTracks + ?.removeEventListener( + VideoTrackListEventTypes.ADDTRACK, + videoAddTrackListener, + ) + player + ?.videoTracks + ?.removeEventListener( + VideoTrackListEventTypes.REMOVETRACK, + videoRemoveTrackListener, + ) + player + ?.videoTracks + ?.removeEventListener( + VideoTrackListEventTypes.TRACKLISTCHANGE, + videoTrackListChangeListener, + ) + player + ?.audioTracks + ?.removeEventListener( + AudioTrackListEventTypes.ADDTRACK, + audioAddTrackListener, + ) + player + ?.audioTracks + ?.removeEventListener( + AudioTrackListEventTypes.REMOVETRACK, + audioRemoveTrackListener, + ) + player + ?.audioTracks + ?.removeEventListener( + AudioTrackListEventTypes.TRACKLISTCHANGE, + audioTrackListChangeListener, + ) + player + ?.textTracks + ?.removeEventListener( + TextTrackListEventTypes.ADDTRACK, + textAddTrackListener, + ) + player + ?.textTracks + ?.removeEventListener( + TextTrackListEventTypes.REMOVETRACK, + textRemoveTrackListener, + ) + player + ?.textTracks + ?.removeEventListener( + TextTrackListEventTypes.TRACKLISTCHANGE, + textTrackListChangeListener, + ) ads?.removeEventListener(AdsEventTypes.AD_BREAK_BEGIN, adListener) ads?.removeEventListener(AdsEventTypes.AD_SKIP, adListener) ads?.removeEventListener(AdsEventTypes.AD_BREAK_END, adListener) updateCast(null) - fullscreenHandler?.onFullscreenChangeListener = null + fullscreenHandler?.removeOnFullscreenChangeListener(fullscreenListener) } } - -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/PlayerContainer.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt new file mode 100644 index 00000000..bd95d261 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerContainer.kt @@ -0,0 +1,77 @@ +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/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt new file mode 100644 index 00000000..447939db --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -0,0 +1,79 @@ +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 -> togglePlayerPlayPause(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY -> player?.play() + NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekPlayerBy(player, seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekPlayerBy(player, -seekOffset) + } +} + +/** Toggles playback between play and pause. */ +@InternalUiApi +fun togglePlayerPlayPause(player: Player?) { + if (player == null) return + if (player.isPaused) { + player.play() + } else { + player.pause() + } +} + +/** 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()) { + player.currentTime = (player.currentTime + seekOffset).coerceIn(0.0, duration) + } +} 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..d7907130 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -0,0 +1,141 @@ +package com.theoplayer.android.ui + +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 com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * State holder for a 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 internal constructor(private val player: Player?) { + + /** 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 update() { + currentTime = player?.currentTime ?: 0.0 + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() + duration = player?.duration ?: Double.NaN + playingAd = player?.ads?.isPlaying ?: false + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + coroutineScope { + player.ads?.let { ads -> + launch(start = CoroutineStart.UNDISPATCHED) { + ads.listen( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END, + ) { + update() + } + } + } + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.DURATIONCHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } + } + + /** + * 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() + } + } + + /** 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. + */ + 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) } + 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 new file mode 100644 index 00000000..880ac45d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekButtonState.kt @@ -0,0 +1,100 @@ +package com.theoplayer.android.ui + +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 com.theoplayer.android.api.event.ads.AdsEventTypes +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +/** + * State holder for a button that seeks forward or backward by a fixed offset, such as [SeekButton]. + * + * Create an instance with [rememberSeekButtonState]. + */ +@ExperimentalPlayerStateApi +class SeekButtonState +internal constructor( + private val player: Player?, + private val seekOffset: Int, +) { + + /** 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 + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + coroutineScope { + player.ads?.let { ads -> + launch(start = CoroutineStart.UNDISPATCHED) { + ads.listen( + AdsEventTypes.AD_BREAK_BEGIN, + AdsEventTypes.AD_SKIP, + AdsEventTypes.AD_BREAK_END, + ) { + update() + } + } + } + player.listen( + PlayerEventTypes.TIMEUPDATE, + PlayerEventTypes.PLAY, + PlayerEventTypes.PAUSE, + PlayerEventTypes.ENDED, + PlayerEventTypes.SEEKING, + PlayerEventTypes.SEEKED, + PlayerEventTypes.READYSTATECHANGE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } + } + + /** + * 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) } + LaunchedEffect(state) { state.observe() } + return state +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SharedAxisTransition.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SharedAxisTransition.kt new file mode 100644 index 00000000..ef6cdd1b --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SharedAxisTransition.kt @@ -0,0 +1,71 @@ +package com.theoplayer.android.ui + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +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.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +/** The horizontal distance the contents slide during a shared axis X transition. */ +internal val SharedAxisSlideDistance: Dp = 30.dp + +/** + * Material shared axis X transition: a fade-through with a short horizontal slide, as described in + * https://m3.material.io/styles/motion/transitions/transition-patterns + * + * Over [durationMillis], the incoming content slides in from [slideDistance] pixels (in the + * direction of [forward], mirrored in right-to-left layouts) while the outgoing content slides the + * opposite way. The outgoing content fades out over the first 35% of the transition and the + * incoming content fades in over the remaining 65%. The container's size animates on the same + * timing, clipped to its bounds. + * + * @param forward whether the navigation moves forward (a push) or backward (a pop) + * @param slideDistance the slide distance in pixels + * @param layoutDirection the current layout direction + * @param durationMillis the total duration of the transition + */ +internal fun sharedAxisX( + forward: Boolean, + slideDistance: Int, + layoutDirection: LayoutDirection, + durationMillis: Int, +): ContentTransform { + val fadeOutDuration = (durationMillis * 0.35f).toInt() + val fadeInDuration = durationMillis - fadeOutDuration + val ltr = layoutDirection == LayoutDirection.Ltr + // Forward: incoming starts at +slideDistance and moves to 0, outgoing moves + // from 0 to -slideDistance. Backward is mirrored, and both mirror again in RTL. + val incomingOffset = (if (forward) slideDistance else -slideDistance) * (if (ltr) 1 else -1) + val outgoingOffset = -incomingOffset + + val enter = + slideInHorizontally(animationSpec = tween(durationMillis, easing = FastOutSlowInEasing)) { + incomingOffset + } + + fadeIn( + tween(fadeInDuration, delayMillis = fadeOutDuration, easing = LinearOutSlowInEasing) + ) + val exit = + slideOutHorizontally(animationSpec = tween(durationMillis, easing = FastOutSlowInEasing)) { + outgoingOffset + } + fadeOut(tween(fadeOutDuration, easing = FastOutLinearInEasing)) + + val transform = enter togetherWith exit + return ContentTransform( + targetContentEnter = transform.targetContentEnter, + initialContentExit = transform.initialContentExit, + sizeTransform = + SizeTransform(clip = true) { _, _ -> + tween(durationMillis, easing = FastOutSlowInEasing) + }, + ) +} 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..fe358803 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SubtitleTrackState.kt @@ -0,0 +1,75 @@ +package com.theoplayer.android.ui + +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 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?) { + + /** 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 } + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.textTracks.listen( + TextTrackListEventTypes.ADDTRACK, + TextTrackListEventTypes.REMOVETRACK, + TextTrackListEventTypes.TRACKLISTCHANGE, + ) { + update() + } + } + + /** + * 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) } + LaunchedEffect(state) { state.observe() } + return state +} 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..9fc9a69e --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/THEOplayerViewLifecycle.kt @@ -0,0 +1,113 @@ +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/TextTrackFontFamily.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackFontFamily.kt new file mode 100644 index 00000000..8d8e087a --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackFontFamily.kt @@ -0,0 +1,27 @@ +package com.theoplayer.android.ui + +import android.graphics.Typeface + +/** + * A font family that can be selected in a text track style menu, such as [TextTrackStyleMenu]. + * + * Changing the font family requires THEOplayer 9.9.0 or higher. + */ +@ExperimentalPlayerStateApi +enum class TextTrackFontFamily(internal val familyName: String?) { + /** The default font family of the system or the player. */ + DEFAULT(null), + /** A serif font where every glyph has the same width. */ + MONOSPACE_SERIF("serif-monospace"), + /** A serif font where glyphs have varying widths. */ + PROPORTIONAL_SERIF("serif"), + /** A sans-serif font where every glyph has the same width. */ + MONOSPACE_SANS("monospace"), + /** A sans-serif font where glyphs have varying widths. */ + PROPORTIONAL_SANS("sans-serif"); + + // This cached instance is passed to setFont(), so that getFont() returns an equal typeface. + internal val typeface: Typeface? by lazy { + familyName?.let { Typeface.create(it, Typeface.NORMAL) } + } +} 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..685ebd87 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackMargins.kt @@ -0,0 +1,108 @@ +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/TextTrackStyleOption.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackStyleOption.kt new file mode 100644 index 00000000..35b4c5fc --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackStyleOption.kt @@ -0,0 +1,247 @@ +package com.theoplayer.android.ui + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle +import com.theoplayer.android.ui.common.R as CommonR +import java.text.NumberFormat + +/** + * An option in a text track style menu, such as [TextTrackStyleMenu]. + * + * Describes the selectable [values] of a single style property, and how to read, match, format and + * apply them on a [TextTrackStyleState]. + * + * @param T the type of the option's values + */ +@InternalUiApi +@ExperimentalPlayerStateApi +class TextTrackStyleOption +internal constructor( + /** The resource ID of the option's label. */ + @StringRes val label: Int, + /** The values that can be selected for this option. */ + val values: List, + private val current: (TextTrackStyleState) -> T, + private val matches: (T, T) -> Boolean, + private val apply: (TextTrackStyleState, T) -> Unit, + private val format: @Composable (T) -> String, + private val color: ((T) -> Color)? = null, + private val available: (TextTrackStyleState) -> Boolean = { true }, +) { + /** Whether this option can be shown for the given [state]. */ + fun isAvailable(state: TextTrackStyleState): Boolean = available(state) + + /** Whether [value] is the state's current value for this option. */ + fun isSelected(state: TextTrackStyleState, value: T): Boolean = matches(current(state), value) + + /** Applies [value] to the [state]. */ + fun select(state: TextTrackStyleState, value: T) = apply(state, value) + + /** Formats [value] for display. */ + @Composable fun formatValue(value: T): String = format(value) + + /** Formats the state's current value for this option. */ + @Composable fun formatCurrentValue(state: TextTrackStyleState): String = format(current(state)) + + /** The color to show next to [value], or `null` if this isn't a color option. */ + fun colorOf(value: T): Color? = color?.invoke(value) +} + +private val textTrackStyleColors = + listOf( + Color(0xFFFFFFFF) to CommonR.string.theoplayer_ui_color_white, + Color(0xFFFFFF00) to CommonR.string.theoplayer_ui_color_yellow, + Color(0xFF00FF00) to CommonR.string.theoplayer_ui_color_green, + Color(0xFF00FFFF) to CommonR.string.theoplayer_ui_color_cyan, + Color(0xFF0000FF) to CommonR.string.theoplayer_ui_color_blue, + Color(0xFFFF00FF) to CommonR.string.theoplayer_ui_color_magenta, + Color(0xFFFF0000) to CommonR.string.theoplayer_ui_color_red, + Color(0xFF000000) to CommonR.string.theoplayer_ui_color_black, + ) + +private val textTrackStyleFontSizes = listOf(50, 75, 100, 150, 200) +private val textTrackStyleFontOpacities = listOf(25, 50, 75, 100) +private val textTrackStyleBackgroundOpacities = listOf(0, 25, 50, 75, 100) + +private val textTrackStyleEdgeTypes = + listOf( + TextTrackStyle.EdgeType.EDGE_TYPE_NONE, + TextTrackStyle.EdgeType.EDGE_TYPE_DROP_SHADOW, + TextTrackStyle.EdgeType.EDGE_TYPE_RAISED, + TextTrackStyle.EdgeType.EDGE_TYPE_DEPRESSED, + TextTrackStyle.EdgeType.EDGE_TYPE_OUTLINE, + ) + +private fun Color.rgbEquals(other: Color): Boolean = + (toArgb() and 0x00FFFFFF) == (other.toArgb() and 0x00FFFFFF) + +@Composable +private fun formatTextTrackColor(color: Color): String { + return textTrackStyleColors + .firstOrNull { it.first.rgbEquals(color) } + ?.let { stringResource(it.second) } ?: stringResource(CommonR.string.theoplayer_ui_custom) +} + +@Composable +private fun formatTextTrackFontFamily(fontFamily: TextTrackFontFamily?): String { + return when (fontFamily) { + TextTrackFontFamily.DEFAULT -> + stringResource(CommonR.string.theoplayer_ui_font_family_default) + TextTrackFontFamily.MONOSPACE_SERIF -> + stringResource(CommonR.string.theoplayer_ui_font_family_monospace_serif) + TextTrackFontFamily.PROPORTIONAL_SERIF -> + stringResource(CommonR.string.theoplayer_ui_font_family_proportional_serif) + TextTrackFontFamily.MONOSPACE_SANS -> + stringResource(CommonR.string.theoplayer_ui_font_family_monospace_sans) + TextTrackFontFamily.PROPORTIONAL_SANS -> + stringResource(CommonR.string.theoplayer_ui_font_family_proportional_sans) + null -> stringResource(CommonR.string.theoplayer_ui_custom) + } +} + +@Composable +private fun formatTextTrackEdgeType(edgeType: TextTrackStyle.EdgeType): String { + return when (edgeType) { + TextTrackStyle.EdgeType.EDGE_TYPE_NONE -> + stringResource(CommonR.string.theoplayer_ui_edge_type_none) + TextTrackStyle.EdgeType.EDGE_TYPE_OUTLINE -> + stringResource(CommonR.string.theoplayer_ui_edge_type_uniform) + TextTrackStyle.EdgeType.EDGE_TYPE_DROP_SHADOW -> + stringResource(CommonR.string.theoplayer_ui_edge_type_drop_shadow) + TextTrackStyle.EdgeType.EDGE_TYPE_RAISED -> + stringResource(CommonR.string.theoplayer_ui_edge_type_raised) + TextTrackStyle.EdgeType.EDGE_TYPE_DEPRESSED -> + stringResource(CommonR.string.theoplayer_ui_edge_type_depressed) + } +} + +@Composable +private fun formatPercentage(percent: Int): String { + return NumberFormat.getPercentInstance().format(percent / 100.0) +} + +private val fontFamilyOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_font_family, + values = TextTrackFontFamily.entries, + current = { it.fontFamily }, + matches = { current, value -> current == value }, + apply = { state, value -> value?.let(state::selectFontFamily) }, + format = { formatTextTrackFontFamily(it) }, + available = { it.isFontFamilySupported }, + ) + +private val fontColorOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_font_color, + values = textTrackStyleColors.map { it.first }, + current = { it.fontColor }, + matches = { current, value -> current.rgbEquals(value) }, + apply = { state, value -> state.selectFontColor(value) }, + format = { formatTextTrackColor(it) }, + color = { it }, + ) + +private val fontSizeOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_font_size, + values = textTrackStyleFontSizes, + current = { it.fontSize }, + matches = { current, value -> current == value }, + apply = { state, value -> state.selectFontSize(value) }, + format = { formatPercentage(it) }, + ) + +private val fontOpacityOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_font_opacity, + values = textTrackStyleFontOpacities, + current = { it.fontOpacity }, + matches = { current, value -> current == value }, + apply = { state, value -> state.selectFontOpacity(value) }, + format = { formatPercentage(it) }, + ) + +private val edgeTypeOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_edge_type, + values = textTrackStyleEdgeTypes, + current = { it.edgeType }, + matches = { current, value -> current == value }, + apply = { state, value -> state.selectEdgeType(value) }, + format = { formatTextTrackEdgeType(it) }, + ) + +private val edgeColorOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_edge_color, + values = textTrackStyleColors.map { it.first }, + current = { it.edgeColor }, + matches = { current, value -> current.rgbEquals(value) }, + apply = { state, value -> state.selectEdgeColor(value) }, + format = { formatTextTrackColor(it) }, + color = { it }, + ) + +private val backgroundColorOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_background_color, + values = textTrackStyleColors.map { it.first }, + current = { it.backgroundColor }, + matches = { current, value -> current.rgbEquals(value) }, + apply = { state, value -> state.selectBackgroundColor(value) }, + format = { formatTextTrackColor(it) }, + color = { it }, + ) + +private val backgroundOpacityOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_background_opacity, + values = textTrackStyleBackgroundOpacities, + current = { it.backgroundOpacity }, + matches = { current, value -> current == value }, + apply = { state, value -> state.selectBackgroundOpacity(value) }, + format = { formatPercentage(it) }, + ) + +private val windowColorOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_window_color, + values = textTrackStyleColors.map { it.first }, + current = { it.windowColor }, + matches = { current, value -> current.rgbEquals(value) }, + apply = { state, value -> state.selectWindowColor(value) }, + format = { formatTextTrackColor(it) }, + color = { it }, + ) + +private val windowOpacityOption = + TextTrackStyleOption( + label = CommonR.string.theoplayer_ui_text_track_style_window_opacity, + values = textTrackStyleBackgroundOpacities, + current = { it.windowOpacity }, + matches = { current, value -> current == value }, + apply = { state, value -> state.selectWindowOpacity(value) }, + format = { formatPercentage(it) }, + ) + +/** The options of a text track style menu, in display order. */ +@InternalUiApi +@ExperimentalPlayerStateApi +val textTrackStyleOptions: List> = + listOf( + fontFamilyOption, + fontColorOption, + fontOpacityOption, + fontSizeOption, + backgroundColorOption, + backgroundOpacityOption, + windowColorOption, + windowOpacityOption, + edgeTypeOption, + edgeColorOption, + ) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackStyleState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackStyleState.kt new file mode 100644 index 00000000..72431621 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackStyleState.kt @@ -0,0 +1,227 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.texttrack.TextTrackStyle +import com.theoplayer.android.ui.util.fontCompat +import com.theoplayer.android.ui.util.isTextTrackStyleFontSupported +import com.theoplayer.android.ui.util.marginBottomCompat +import kotlin.math.roundToInt + +private val defaultFontColor = Color(0xFFFFFFFF) +private val defaultBackgroundColor = Color(0xFF000000) +private val defaultWindowColor = Color(0x00000000) +private val defaultEdgeColor = Color(0xFFFFFFFF) +private const val defaultFontSize = 100 +private val defaultEdgeType = TextTrackStyle.EdgeType.EDGE_TYPE_NONE + +/** + * State holder for the style of subtitles, such as [TextTrackStyleMenu]. + * + * Create an instance with [rememberTextTrackStyleState]. + */ +@ExperimentalPlayerStateApi +class TextTrackStyleState internal constructor(private val player: Player?) { + /** + * Whether the font family can be changed. + * + * Changing the font family requires THEOplayer 9.9.0 or higher. + */ + val isFontFamilySupported: Boolean + get() = player != null && isTextTrackStyleFontSupported + + /** + * The font family of the subtitles. + * + * [TextTrackFontFamily.DEFAULT] if no custom font is set, or `null` if a font not in + * [TextTrackFontFamily] is set. + */ + var fontFamily by mutableStateOf(TextTrackFontFamily.DEFAULT) + private set + + /** The color of the subtitle text, including its opacity. */ + var fontColor by mutableStateOf(defaultFontColor) + private set + + /** The color of the background directly behind the subtitle text, including its opacity. */ + var backgroundColor by mutableStateOf(defaultBackgroundColor) + private set + + /** The color of the window behind the subtitle text, including its opacity. */ + var windowColor by mutableStateOf(defaultWindowColor) + private set + + /** The color of the edge of the subtitle text, including its opacity. */ + var edgeColor by mutableStateOf(defaultEdgeColor) + private set + + /** The opacity of the subtitle text, as a percentage. */ + val fontOpacity: Int + get() = fontColor.opacityPercent + + /** The opacity of the background directly behind the subtitle text, as a percentage. */ + val backgroundOpacity: Int + get() = backgroundColor.opacityPercent + + /** The opacity of the window behind the subtitle text, as a percentage. */ + val windowOpacity: Int + get() = windowColor.opacityPercent + + /** The font size of the subtitles, as a percentage of the default font size. */ + var fontSize by mutableIntStateOf(defaultFontSize) + private set + + /** The edge style of the subtitle text. */ + var edgeType by mutableStateOf(defaultEdgeType) + private set + + private fun update() { + val style = player?.textTrackStyle + fontFamily = if (style != null) fontFamilyOf(style) else TextTrackFontFamily.DEFAULT + fontColor = style?.let { Color(it.fontColor) } ?: defaultFontColor + backgroundColor = style?.let { Color(it.backgroundColor) } ?: defaultBackgroundColor + windowColor = style?.let { Color(it.windowColor) } ?: defaultWindowColor + edgeColor = style?.let { Color(it.edgeColor) } ?: defaultEdgeColor + fontSize = style?.fontSize ?: defaultFontSize + edgeType = style?.edgeType ?: defaultEdgeType + } + + init { + update() + } + + /** + * Selects the font family of the subtitles. + * + * Does nothing if the font family can't be changed, see [isFontFamilySupported]. + */ + fun selectFontFamily(fontFamily: TextTrackFontFamily) { + val style = player?.textTrackStyle ?: return + if (!isTextTrackStyleFontSupported) return + style.fontCompat = fontFamily.typeface + this.fontFamily = fontFamily + } + + /** Selects the color of the subtitle text, keeping the current [font opacity][fontOpacity]. */ + fun selectFontColor(color: Color) { + selectColor(color.copy(alpha = fontColor.alpha), fontColorSetter) + } + + /** + * Selects the color of the background directly behind the subtitle text, keeping the current + * [background opacity][backgroundOpacity]. + */ + fun selectBackgroundColor(color: Color) { + selectColor(color.copy(alpha = backgroundColor.alpha), backgroundColorSetter) + } + + /** + * Selects the color of the window behind the subtitle text, keeping the current + * [window opacity][windowOpacity]. + */ + fun selectWindowColor(color: Color) { + selectColor(color.copy(alpha = windowColor.alpha), windowColorSetter) + } + + /** Selects the color of the edge of the subtitle text, keeping its current opacity. */ + fun selectEdgeColor(color: Color) { + selectColor(color.copy(alpha = edgeColor.alpha), edgeColorSetter) + } + + /** Selects the opacity of the subtitle text, keeping the current [font color][fontColor]. */ + fun selectFontOpacity(opacity: Int) { + selectColor(fontColor.withOpacity(opacity), fontColorSetter) + } + + /** + * Selects the opacity of the background directly behind the subtitle text, keeping the current + * [background color][backgroundColor]. + */ + fun selectBackgroundOpacity(opacity: Int) { + selectColor(backgroundColor.withOpacity(opacity), backgroundColorSetter) + } + + /** + * Selects the opacity of the window behind the subtitle text, keeping the current + * [window color][windowColor]. + */ + fun selectWindowOpacity(opacity: Int) { + selectColor(windowColor.withOpacity(opacity), windowColorSetter) + } + + /** Selects the font size of the subtitles, as a percentage of the default font size. */ + fun selectFontSize(fontSize: Int) { + val style = player?.textTrackStyle ?: return + style.fontSize = fontSize + this.fontSize = fontSize + } + + /** Selects the edge style of the subtitle text. */ + fun selectEdgeType(edgeType: TextTrackStyle.EdgeType) { + val style = player?.textTrackStyle ?: return + style.edgeType = edgeType + this.edgeType = edgeType + } + + /** + * Resets the style to the built-in defaults. + * + * The margins of the subtitle drawing box are preserved, since these are managed by the + * player's user interface. + */ + fun reset() { + val style = player?.textTrackStyle ?: return + val marginLeft = style.marginLeft + val marginRight = style.marginRight + val marginTop = style.marginTop + val marginBottom = style.marginBottomCompat + style.reset() + style.marginLeft = marginLeft + style.marginRight = marginRight + style.marginTop = marginTop + if (marginBottom != null) { + style.marginBottomCompat = marginBottom + } + update() + } + + private fun selectColor(color: Color, setter: ColorSetter) { + val style = player?.textTrackStyle ?: return + setter(style, color) + update() + } + + private fun fontFamilyOf(style: TextTrackStyle): TextTrackFontFamily? { + val font = style.fontCompat ?: return TextTrackFontFamily.DEFAULT + return TextTrackFontFamily.entries.firstOrNull { it.typeface == font } + } + + private val Color.opacityPercent: Int + get() = (alpha * 100).roundToInt() + + private fun Color.withOpacity(opacity: Int): Color = + Color((toArgb() and 0x00FFFFFF) or ((opacity * 255 / 100f).roundToInt() shl 24)) +} + +private typealias ColorSetter = (TextTrackStyle, Color) -> Unit + +private val fontColorSetter: ColorSetter = { style, color -> style.fontColor = color.toArgb() } +private val backgroundColorSetter: ColorSetter = { style, color -> + style.backgroundColor = color.toArgb() +} +private val windowColorSetter: ColorSetter = { style, color -> style.windowColor = color.toArgb() } +private val edgeColorSetter: ColorSetter = { style, color -> style.edgeColor = color.toArgb() } + +/** Creates a [TextTrackStyleState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberTextTrackStyleState(player: Player?): TextTrackStyleState { + return remember(player) { TextTrackStyleState(player) } +} 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..032920b1 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TextTrackUtils.kt @@ -0,0 +1,13 @@ +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 + } +} 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 62% 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 index 39fe28d8..2c234601 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TimeRanges.kt @@ -8,40 +8,33 @@ package com.theoplayer.android.ui data class TimeRanges(private val ranges: List>) : List> by ranges { - /** - * Returns the start of the first range, or `null` if [empty][isEmpty]. - */ + /** Returns the start of the first range, or `null` if [empty][isEmpty]. */ val firstStart: Double? get() = firstOrNull()?.start - /** - * Returns the end of the last range, or `null` if [empty][isEmpty]. - */ + /** Returns the end of the last range, or `null` if [empty][isEmpty]. */ val lastEnd: Double? get() = lastOrNull()?.endInclusive /** - * Returns the bounding time range from [firstStart] to [lastEnd], or `null` if [empty][isEmpty]. + * Returns the bounding time range from [firstStart] to [lastEnd], or `null` if + * [empty][isEmpty]. */ val bounds: ClosedFloatingPointRange? - get() = if (isEmpty()) { - null - } else { - first().start..last().endInclusive - } + get() = + if (isEmpty()) { + null + } else { + first().start..last().endInclusive + } companion object { private val _empty by lazy(mode = LazyThreadSafetyMode.NONE) { TimeRanges(listOf()) } - /** - * Returns an empty [TimeRanges]. - */ - @JvmStatic - fun empty(): TimeRanges = _empty + /** Returns an empty [TimeRanges]. */ + @JvmStatic fun empty(): TimeRanges = _empty - /** - * Converts a [com.theoplayer.android.api.timerange.TimeRanges] to a [TimeRanges]. - */ + /** Converts a [com.theoplayer.android.api.timerange.TimeRanges] to a [TimeRanges]. */ @JvmStatic fun fromTHEOplayer(ranges: com.theoplayer.android.api.timerange.TimeRanges): TimeRanges { return TimeRanges(ranges.map { range -> range.start..range.end }) 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..75f97e89 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt @@ -0,0 +1,97 @@ +package com.theoplayer.android.ui + +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.common.R as CommonR +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-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..ee4faad1 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt @@ -0,0 +1,331 @@ +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.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +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 androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import kotlin.math.roundToInt +import kotlin.time.DurationUnit + +/** + * Renders the shared player UI around the active controls, menu, or error state. + * + * @param menuContainer the container that wraps all menus (e.g. a sheet). All menus are rendered + * inside it, so opening a submenu does not trigger the outer content transition; the menu + * contents animate with a push/pop transition inside the container instead. + */ +@InternalUiApi +@Composable +fun UIControllerCore( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView?, + player: Player?, + scope: UIControllerScopeImpl, + uiState: State, + errorState: ErrorState, + controls: ControlsVisibility, + color: Color, + contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, + menuContainer: @Composable (menuContent: @Composable () -> Unit) -> Unit, + 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) + }, + ), + ) + + 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() } + + 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, + chromeBounds = chromeBounds, + controlsModifier = controlsModifier, + contentTransform = contentTransform, + menuContainer = menuContainer, + 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, + chromeBounds: ChromeBounds, + controlsModifier: Modifier, + contentTransform: AnimatedContentTransitionScope.() -> ContentTransform, + menuContainer: @Composable (menuContent: @Composable () -> Unit) -> Unit, + centerOverlay: (@Composable UIControllerScope.() -> Unit)?, + errorOverlay: (@Composable UIControllerScope.() -> Unit)?, + topChrome: (@Composable UIControllerScope.() -> Unit)?, + centerChrome: (@Composable UIControllerScope.() -> Unit)?, + bottomChrome: (@Composable UIControllerScope.() -> Unit)?, +) { + // Keep the saved state (scroll positions, focus) of menus that are still on + // the stack alive while a submenu is open, and forget closed menus. + val saveableStateHolder = rememberSaveableStateHolder() + val knownMenuIds = remember { mutableSetOf() } + LaunchedEffect(scope.menuIds) { + val menuIds = scope.menuIds + knownMenuIds.removeAll { id -> + (id !in menuIds).also { removed -> if (removed) saveableStateHolder.removeState(id) } + } + knownMenuIds.addAll(menuIds) + } + + CompositionLocalProvider( + LocalPlayer provides player, + LocalTHEOplayerView provides theoplayerView, + LocalUIControllerErrorState provides errorState, + ) { + AnimatedContent( + label = "ContentAnimation", + modifier = Modifier.fillMaxSize().background(background), + targetState = uiState, + transitionSpec = contentTransform, + contentKey = { state -> + if (state is UIState.Menu) { + // One stable content key while menus are open, so pushing a submenu + // does not animate the whole menu container. + MenusOpenContentKey + } else { + state + } + }, + ) { state -> + when (state) { + UIState.Error -> errorOverlay?.let { scope.it() } + is UIState.Menu -> menuContainer { InnerMenus(scope, saveableStateHolder) } + UIState.Controls -> + scope.PlayerControls( + controlsVisible = controls.visible.value, + animationsActive = controls.animationsActive && !adPlaying, + chromeBounds = chromeBounds, + 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, + chromeBounds: ChromeBounds, + 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().onGloballyPositioned { chromeBounds.player = it.boundsInRoot() } + ) { + 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 { chrome -> + Column( + modifier = + Modifier.onGloballyPositioned { + chromeBounds.topChrome = it.boundsInRoot() + } + ) { + chrome() + } + } + Spacer(modifier = Modifier.weight(1f)) + bottomChrome?.let { chrome -> + Column( + modifier = + Modifier.onGloballyPositioned { + chromeBounds.bottomChrome = it.boundsInRoot() + } + ) { + chrome() + } + } + } + } + } + } +} + +private object MenusOpenContentKey + +/** + * Renders the open menu stack, animating menu pushes and pops within the current + * [menuContainer][UIControllerCore]. + */ +@Composable +private fun InnerMenus( + scope: UIControllerScopeImpl, + saveableStateHolder: SaveableStateHolder, +) { + val layoutDirection = LocalLayoutDirection.current + val slideDistance = with(LocalDensity.current) { SharedAxisSlideDistance.toPx() }.roundToInt() + val durationMillis = + LocalPlayerAnimations.current.menuTransitionDuration.inWholeMilliseconds.toInt() + AnimatedContent( + label = "InnerMenusAnimation", + targetState = scope.currentMenu, + contentKey = { it?.id }, + transitionSpec = { + sharedAxisX( + forward = !scope.lastWasClosed, + slideDistance = slideDistance, + layoutDirection = layoutDirection, + durationMillis = durationMillis, + ) + }, + ) { menu -> + menu?.let { saveableStateHolder.SaveableStateProvider(it.id) { it.content(scope) } } + } +} + +/** 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/UIControllerScope.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt new file mode 100644 index 00000000..8004ae49 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerScope.kt @@ -0,0 +1,96 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.mutableStateListOf +import com.theoplayer.android.api.THEOplayerView + +/** + * Scope for the contents of a [UIController]. + * + * This interface is implemented by `UIController`; implementing it yourself is not supported. + */ +@OptIn(ExperimentalSubclassOptIn::class) +@SubclassOptInRequired(InternalUiApi::class) +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 +} + +/** + * An entry on the menu stack, pairing a [MenuContent] with a stable [id]. + * + * The [id] uniquely identifies the menu for as long as it is on the stack, so saved state (such as + * scroll positions) can be keyed to it. + */ +@InternalUiApi +class MenuEntry +internal constructor( + val id: Int, + val content: MenuContent, +) + +/** Default implementation of [UIControllerScope], managing the menu stack. */ +@InternalUiApi +class UIControllerScopeImpl( + override val theoplayerView: THEOplayerView?, + private val providedPlayer: Player?, +) : UIControllerScope { + private var menuStack = mutableStateListOf() + private var nextMenuId = 0 + + val currentMenu: MenuEntry? + get() = menuStack.lastOrNull() + + /** The ids of all menus currently on the stack, in stack order. */ + val menuIds: List + get() = menuStack.map { it.id } + + 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(MenuEntry(nextMenuId++, menu)) + lastWasClosed = false + } + + override fun closeCurrentMenu() { + menuStack.removeLastOrNull()?.also { lastWasClosed = true } + } + + override fun closeAllMenus() { + if (menuStack.isNotEmpty()) { + menuStack.clear() + lastWasClosed = true + } + } +} 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..c64f1db2 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt @@ -0,0 +1,67 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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: MenuEntry) : 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/VideoQualityState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt new file mode 100644 index 00000000..5b9dc9d0 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoQualityState.kt @@ -0,0 +1,99 @@ +package com.theoplayer.android.ui + +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 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]. + * + * Create an instance with [rememberVideoQualityState]. + */ +@ExperimentalPlayerStateApi +class VideoQualityState internal constructor(private val player: Player?) { + + /** 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 findActiveVideoTrack() = player?.videoTracks?.find { it.isEnabled } + + private fun setActiveVideoTrack(videoTrack: MediaTrack?) { + activeVideoTrack = videoTrack + videoQualities = videoTrack?.qualities?.toList() ?: listOf() + activeVideoQuality = videoTrack?.activeQuality + targetVideoQuality = videoTrack?.targetQuality + } + + init { + setActiveVideoTrack(findActiveVideoTrack()) + } + + 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 + } + } + + /** 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) } + 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 new file mode 100644 index 00000000..17f0f1e0 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/VideoSizeState.kt @@ -0,0 +1,57 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +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.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?) { + + /** 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 + } + + init { + update() + } + + internal suspend fun observe() { + val player = player ?: return + update() + player.listen( + PlayerEventTypes.RESIZE, + PlayerEventTypes.SOURCECHANGE, + PlayerEventTypes.ERROR, + ) { + update() + } + } +} + +/** Creates a [VideoSizeState] that's remembered across compositions. */ +@ExperimentalPlayerStateApi +@Composable +fun rememberVideoSizeState(player: Player?): VideoSizeState { + val state = remember(player) { VideoSizeState(player) } + LaunchedEffect(state) { state.observe() } + return state +} 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..91f7e07d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -0,0 +1,66 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi + +@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) + +@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/PlayerAnimations.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt new file mode 100644 index 00000000..023577ed --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt @@ -0,0 +1,29 @@ +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, + /** + * The animation duration when switching between menus, such as when opening a submenu or going + * back to the previous menu. + */ + val menuTransitionDuration: Duration = 300.milliseconds, +) + +/** Animation settings shared by player UI modules. */ +@InternalUiApi val LocalPlayerAnimations = staticCompositionLocalOf { PlayerAnimations() } 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..d225ff9d --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt @@ -0,0 +1,18 @@ +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/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 73% 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..8a0bf6ee 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,14 +2,14 @@ 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() -/** - * Checks whether a provided label is CEA-608 or CEA-708 formed. - */ +/** 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 } @@ -29,13 +29,12 @@ internal fun isLabelCeaFormatted(label: String?): Boolean { /** * Creates a text track label for CEA-608 and CEA-708 formats. * - * @return an optional string composed of a [channelNumber] and a prepended - * "CC" suffix, or `null` if the channel number is invalid. + * @return an optional string composed of a [channelNumber] and a prepended "CC" suffix, or `null` + * if the channel number is invalid. */ @CheckResult -internal fun getLabelForChannelNumber( - @IntRange(from = 0L, to = 63L) channelNumber: Int, -): String? { +@InternalUiApi +fun getLabelForChannelNumber(@IntRange(from = 0L, to = 63L) channelNumber: Int): String? { // CEA-608 only supports channel numbers in [1, 4], // while CEA-708 support service numbers in [1, 63]. if (channelNumber !in 1..63) { 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 new file mode 100644 index 00000000..bb43078e --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -0,0 +1,118 @@ +package com.theoplayer.android.ui.util + +import android.graphics.Typeface +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 + +/** Returns [TextTrack.getCaptionChannel], if available. */ +@InternalUiApi +val TextTrack.captionChannelCompat: Int? + get() { + // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. + return if (THEOplayerGlobalExt.version >= version1013) { + TheoPlayer1013Impl.getTextTrackCaptionChannel(this) + } 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) + } + } + +/** + * The font of the text track style. + * + * Returns `null` and ignores new values if [TextTrackStyle.setFont] isn't available. + * + * @see isTextTrackStyleFontSupported + */ +@InternalUiApi +var TextTrackStyle.fontCompat: Typeface? + get() { + // TextTrackStyle.getFont was added in THEOplayer 9.9.0. + return if (isTextTrackStyleFontSupported) { + TheoPlayer99Impl.getTextTrackStyleFont(this) + } else null + } + set(value) { + if (isTextTrackStyleFontSupported) { + TheoPlayer99Impl.setTextTrackStyleFont(this, value) + } + } + +/** + * Whether [TextTrackStyle.getFont] and [TextTrackStyle.setFont] are available. + * + * These were added in THEOplayer 9.9.0. + */ +@InternalUiApi +val isTextTrackStyleFontSupported: Boolean + get() = THEOplayerGlobalExt.version >= version99 + +private val version95 = Version(major = 9, minor = 5, patchAndPrerelease = "0") +private val version99 = Version(major = 9, minor = 9, 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 9.9.0 or higher. + * + * @see TheoPlayer1013Impl + */ +private class TheoPlayer99Impl private constructor() { + companion object { + @DoNotInline fun getTextTrackStyleFont(style: TextTrackStyle): Typeface? = style.getFont() + + @DoNotInline + fun setTextTrackStyleFont(style: TextTrackStyle, font: Typeface?) { + style.setFont(font) + } + } +} + +/** + * This class must be loaded **only** with THEOplayer 10.13.0 or higher. + * + * This uses the same pattern as AndroidX AppCompat, see e.g. + * [androidx.appcompat.app.AppCompatDelegate.Api33Impl] and the docs about + * [API-specific implementations](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/compat.md#delegating-to-api-specific-implementations-delegating-to-api-specific-implementations) + * and + * [static shims](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/platform_compat.md#static-shims-ex-viewcompat-static-shim). + */ +private class TheoPlayer1013Impl private constructor() { + companion object { + @DoNotInline fun getTextTrackCaptionChannel(track: TextTrack): Int? = track.captionChannel + } +} 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 72% 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..c3d9d999 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,15 +15,12 @@ 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] - */ +/** From [android.content.pm.ActivityInfo] */ private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000 -/** - * Check if the given activity supports [Activity.enterPictureInPictureMode]. - */ +/** Check if the given activity supports [Activity.enterPictureInPictureMode]. */ internal fun Activity.supportsPictureInPictureMode(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false @@ -33,31 +30,39 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { /** * Returns whether the activity is in (or transitioning to) picture-in-picture mode. + * + * @suppress */ +@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 var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) } if (activity is OnPictureInPictureModeChangedProvider) { DisposableEffect(activity) { - val observer = Consumer { info -> - pipMode = info.isInPictureInPictureMode - } + val observer = + Consumer { info -> + pipMode = info.isInPictureInPictureMode + } activity.addOnPictureInPictureModeChangedListener(observer) onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } } } var transitioningToPip by remember { mutableStateOf(false) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && activity is OnPictureInPictureUiStateChangedProvider) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && + activity is OnPictureInPictureUiStateChangedProvider + ) { DisposableEffect(activity) { - val observer = Consumer { info -> - transitioningToPip = info.isTransitioningToPip - } + val observer = + Consumer { info -> + transitioningToPip = info.isTransitioningToPip + } activity.addOnPictureInPictureUiStateChangedListener(observer) onDispose { activity.removeOnPictureInPictureUiStateChangedListener(observer) } } } return pipMode || transitioningToPip -} \ No newline at end of file +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt new file mode 100644 index 00000000..58695750 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -0,0 +1,74 @@ +package com.theoplayer.android.ui.util + +import androidx.annotation.CheckResult +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" + +/** + * Returns a name for the [Track.language] in the [Locale.Category.DISPLAY] locale that is + * appropriate for display to the user. If such conversion is not possible, for instance when + * [Track.language] is `null`, blank, or `"und"`, returns `null`. + */ +@get:CheckResult +@InternalUiApi +val Track.localizedLanguageName: String? + get() { + val languageCode = + this.language?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } ?: return null + val locale = Locale.forLanguageTag(languageCode) + val localisedLanguage = locale.getDisplayName(locale).orEmpty() + return localisedLanguage.takeUnless { it.isBlank() } + } + +/** Constructs a label for the given [MediaTrack] instance. */ +@InternalUiApi +fun constructLabel(track: MediaTrack<*>): String? { + return track.label?.takeUnless { it.isBlank() } ?: track.localizedLanguageName +} + +/** Constructs a label for the given [TextTrack] instance. */ +@InternalUiApi +fun constructLabel(track: TextTrack): String? { + val label = + track.label?.takeIf { + when { + // Ignore empty labels. + it.isBlank() -> false + // Ignore default label with just the language code. + it == track.language -> false + // Ignore default label with just the caption channel. + (track.type == TextTrackType.CEA608 && isLabelCeaFormatted(it)) -> false + else -> true + } + } + + label?.let { + return it + } + + track.localizedLanguageName?.let { + return it + } + + if (track.type == TextTrackType.CEA608) { + track.captionChannelCompat + ?.let { getLabelForChannelNumber(it) } + ?.let { + return it + } + + track.label + ?.takeUnless { it.isBlank() } + ?.let { + return it + } + } + + return null +} 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 76% 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..4e517a86 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,24 +1,18 @@ 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( - /** - * The major version. - */ +/** A [semver](https://semver.org/) version. */ +@InternalUiApi +data class Version( + /** The major version. */ val major: Int, - /** - * The minor version. - */ + /** The minor version. */ val minor: Int, - /** - * The patch (and prerelease) version. - */ + /** The patch (and prerelease) version. */ val patchAndPrerelease: String, ) : Comparable { override fun toString() = buildString { @@ -45,7 +39,7 @@ internal data class Version( return Version( major = major.toInt(), minor = minor.toInt(), - patchAndPrerelease = patchAndPrerelease + patchAndPrerelease = patchAndPrerelease, ) } catch (e: IllegalArgumentException) { throw IllegalArgumentException("Invalid version", e) @@ -54,9 +48,8 @@ internal data class Version( } } -internal object THEOplayerGlobalExt { - /** - * Returns the version of THEOplayer, as a [Version]. - */ +@InternalUiApi +object THEOplayerGlobalExt { + /** Returns the version of THEOplayer, as a [Version]. */ val version: Version by lazy { Version.parse(THEOplayerGlobal.getVersion()) } } 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-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 @@ + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui-common/src/main/res/values/strings.xml similarity index 59% rename from ui/src/main/res/values/strings.xml rename to ui-common/src/main/res/values/strings.xml index 18e947cd..adbc763c 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui-common/src/main/res/values/strings.xml @@ -59,6 +59,72 @@ Off + + Subtitle options + + Reset + + Font family + + Font color + + Font opacity + + Font size + + Background color + + Background opacity + + Window color + + Window opacity + + Character edge style + + Edge color + + Custom + + + Default + + Monospace Serif + + Proportional Serif + + Monospace Sans + + Proportional Sans + + + White + + Yellow + + Green + + Cyan + + Blue + + Magenta + + Red + + Black + + + None + + Drop shadow + + Raised + + Depressed + + Uniform + Normal