From d58b2e96fc2aeecf8408ee6401a4e7b16e353253 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:06 +0000 Subject: [PATCH 01/11] Move shared resources and playback time formatting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../android/ui/PlaybackTimeFormat.kt | 51 ++++++++++++++ .../com/theoplayer/android/ui/theme/Color.kt | 68 +++++++++++++++++++ .../src/main/res/drawable/language.xml | 0 .../src/main/res/drawable/pause.xml | 0 .../src/main/res/drawable/play.xml | 0 .../src/main/res/values/strings.xml | 0 .../theoplayer/android/ui/ChromecastButton.kt | 13 ++-- .../android/ui/ChromecastDisplay.kt | 13 ++-- .../android/ui/CurrentTimeDisplay.kt | 9 +-- .../com/theoplayer/android/ui/ErrorDisplay.kt | 3 +- .../theoplayer/android/ui/FullscreenButton.kt | 9 +-- .../java/com/theoplayer/android/ui/Helper.kt | 46 ++----------- .../com/theoplayer/android/ui/LanguageMenu.kt | 21 +++--- .../android/ui/LanguageMenuButton.kt | 9 +-- .../com/theoplayer/android/ui/LiveButton.kt | 9 +-- .../java/com/theoplayer/android/ui/Menu.kt | 3 +- .../com/theoplayer/android/ui/MuteButton.kt | 9 +-- .../android/ui/PictureInPictureButton.kt | 9 +-- .../com/theoplayer/android/ui/PlayButton.kt | 21 +++--- .../theoplayer/android/ui/PlaybackRateList.kt | 5 +- .../com/theoplayer/android/ui/QualityList.kt | 11 +-- .../com/theoplayer/android/ui/SeekButton.kt | 5 +- .../com/theoplayer/android/ui/SettingsMenu.kt | 19 +++--- .../android/ui/SettingsMenuButton.kt | 3 +- .../android/ui/SubtitleTrackList.kt | 3 +- 25 files changed, 220 insertions(+), 119 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt rename {ui => ui-common}/src/main/res/drawable/language.xml (100%) rename {ui => ui-common}/src/main/res/drawable/pause.xml (100%) rename {ui => ui-common}/src/main/res/drawable/play.xml (100%) rename {ui => ui-common}/src/main/res/values/strings.xml (100%) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt new file mode 100644 index 00000000..dfb38bb0 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlaybackTimeFormat.kt @@ -0,0 +1,51 @@ +package com.theoplayer.android.ui + +import kotlin.math.absoluteValue + +/** + * Formats a playback time as a human-readable string. + */ +@InternalUiApi +fun formatPlaybackTime( + time: Double, + guide: Double = 0.0, + preferNegative: Boolean = false +): String { + val isNegative = time < 0 || (preferNegative && time == 0.0) + val absoluteTime = time.absoluteValue.toInt() + + val guideMinutes = ((guide / 60) % 60).toInt() + val guideHours = (guide / 3600).toInt() + + val result = StringBuilder() + if (isNegative) { + result.append('-') + } + if (time.isNaN() || time.isInfinite()) { + if (guideHours > 0) { + result.append("--:") + } + result.append("--:--") + } else { + val seconds = absoluteTime % 60 + val minutes = (absoluteTime / 60) % 60 + val hours = absoluteTime / 3600 + // Check if we need to show hours. + val showHours = hours > 0 || guideHours > 0 + if (showHours) { + result.append(hours) + result.append(':') + } + // If hours are showing, we may need to add a leading zero to minutes. + // Always show at least one digit of minutes. + val showMinutesLeadingZero = (showHours || guideMinutes >= 0) + if (showMinutesLeadingZero) { + result.append(minutes.toString().padStart(2, '0')) + } else { + result.append(minutes) + } + result.append(':') + result.append(seconds.toString().padStart(2, '0')) + } + return result.toString() +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt new file mode 100644 index 00000000..9c05554a --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -0,0 +1,68 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.ui.graphics.Color + +internal val md_theme_light_primary = Color(0xFF775A00) +internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) +internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) +internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) +internal val md_theme_light_secondary = Color(0xFF6A5D3F) +internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) +internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) +internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) +internal val md_theme_light_tertiary = Color(0xFF496548) +internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) +internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) +internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) +internal val md_theme_light_error = Color(0xFFBA1A1A) +internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) +internal val md_theme_light_onError = Color(0xFFFFFFFF) +internal val md_theme_light_onErrorContainer = Color(0xFF410002) +internal val md_theme_light_background = Color(0xFFFFFBFF) +internal val md_theme_light_onBackground = Color(0xFF1E1B16) +internal val md_theme_light_surface = Color(0xFFFFFBFF) +internal val md_theme_light_onSurface = Color(0xFF1E1B16) +internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) +internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) +internal val md_theme_light_outline = Color(0xFF7E7667) +internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) +internal val md_theme_light_inverseSurface = Color(0xFF33302A) +internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) +internal val md_theme_light_shadow = Color(0xFF000000) +internal val md_theme_light_surfaceTint = Color(0xFF775A00) +internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) +internal val md_theme_light_scrim = Color(0xFF000000) + +internal val md_theme_dark_primary = Color(0xFFFFC50F) +internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) +internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) +internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) +internal val md_theme_dark_secondary = Color(0xFFD7C5A0) +internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) +internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) +internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) +internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) +internal val md_theme_dark_onTertiary = Color(0xFF1C361D) +internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) +internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) +internal val md_theme_dark_error = Color(0xFFFFB4AB) +internal val md_theme_dark_errorContainer = Color(0xFF93000A) +internal val md_theme_dark_onError = Color(0xFF690005) +internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +internal val md_theme_dark_background = Color(0xFF1E1B16) +internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) +internal val md_theme_dark_surface = Color(0xFF1E1B16) +internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) +internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) +internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) +internal val md_theme_dark_outline = Color(0xFF999080) +internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) +internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) +internal val md_theme_dark_inversePrimary = Color(0xFF775A00) +internal val md_theme_dark_shadow = Color(0xFF000000) +internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) +internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) +internal val md_theme_dark_scrim = Color(0xFF000000) + + +private val seed = Color(0xFFFFC50F) diff --git a/ui/src/main/res/drawable/language.xml b/ui-common/src/main/res/drawable/language.xml similarity index 100% rename from ui/src/main/res/drawable/language.xml rename to ui-common/src/main/res/drawable/language.xml diff --git a/ui/src/main/res/drawable/pause.xml b/ui-common/src/main/res/drawable/pause.xml similarity index 100% rename from ui/src/main/res/drawable/pause.xml rename to ui-common/src/main/res/drawable/pause.xml diff --git a/ui/src/main/res/drawable/play.xml b/ui-common/src/main/res/drawable/play.xml similarity index 100% rename from ui/src/main/res/drawable/play.xml rename to ui-common/src/main/res/drawable/play.xml diff --git a/ui/src/main/res/values/strings.xml b/ui-common/src/main/res/values/strings.xml similarity index 100% rename from ui/src/main/res/values/strings.xml rename to ui-common/src/main/res/values/strings.xml diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt index 00908bd1..c2e629c7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Cast @@ -32,19 +33,19 @@ fun ChromecastButton( availableIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_start) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_start) ) }, connectingIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) }, connectedIcon: @Composable () -> Unit = { Icon( Icons.Rounded.CastConnected, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) } ) { @@ -81,19 +82,19 @@ fun ChromecastButton( availableIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_start) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_start) ) }, connectingIcon: @Composable () -> Unit = { Icon( Icons.Rounded.Cast, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) }, connectedIcon: @Composable () -> Unit = { Icon( Icons.Rounded.CastConnected, - contentDescription = stringResource(R.string.theoplayer_ui_btn_chromecast_stop) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_chromecast_stop) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt index 6ff83f8a..bb73e5b2 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ChromecastDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -104,11 +105,11 @@ fun ChromecastDisplayCompact( Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver) + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver) ) } } @@ -160,21 +161,21 @@ fun ChromecastDisplayExpanded( Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_first_line, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_first_line, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_first_line), + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_first_line), style = MaterialTheme.typography.bodyLarge ) Text( text = state.castReceiverName?.let { stringResource( - R.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_second_line, + CommonR.string.theoplayer_ui_chromecast_playing_on_receiver_expanded_second_line, it ) } - ?: stringResource(R.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_second_line), + ?: stringResource(CommonR.string.theoplayer_ui_chromecast_playing_on_unknown_receiver_expanded_second_line), style = MaterialTheme.typography.headlineSmall ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt index d798c68d..37c39dd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/CurrentTimeDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -58,9 +59,9 @@ fun CurrentTimeDisplay( val text = if (showDuration) { stringResource( if (showRemaining) { - R.string.theoplayer_ui_current_time_remaining_with_duration + CommonR.string.theoplayer_ui_current_time_remaining_with_duration } else { - R.string.theoplayer_ui_current_time_with_duration + CommonR.string.theoplayer_ui_current_time_with_duration }, formatTime(time, duration, preferNegative = showRemaining), formatTime(duration) @@ -68,9 +69,9 @@ fun CurrentTimeDisplay( } else { stringResource( if (showRemaining) { - R.string.theoplayer_ui_current_time_remaining + CommonR.string.theoplayer_ui_current_time_remaining } else { - R.string.theoplayer_ui_current_time + CommonR.string.theoplayer_ui_current_time }, formatTime(time, duration, preferNegative = showRemaining), ) diff --git a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt index 28394ed7..81ed05d7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/ErrorDisplay.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -72,7 +73,7 @@ private fun ErrorDisplayContent(error: THEOplayerException, modifier: Modifier) contentAlignment = Alignment.CenterStart ) { Text( - text = stringResource(R.string.theoplayer_ui_error_title), + text = stringResource(CommonR.string.theoplayer_ui_error_title), style = MaterialTheme.typography.headlineMedium ) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt index a28f1f06..ca446b5b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/FullscreenButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Fullscreen @@ -26,13 +27,13 @@ fun FullscreenButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_enter) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_enter) ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.FullscreenExit, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_exit) ) } ) { @@ -64,13 +65,13 @@ fun FullscreenButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_enter) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_enter) ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.FullscreenExit, - contentDescription = stringResource(R.string.theoplayer_ui_btn_fullscreen_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_fullscreen_exit) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt index aa6a4852..2b00abd1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource @@ -7,7 +8,6 @@ import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.ui.util.constructLabel -import kotlin.math.absoluteValue /** * Return the given time in a human-readable format, such as `"0:10"` or `"01:23:45"`. @@ -21,43 +21,7 @@ import kotlin.math.absoluteValue * for example because it represents the time remaining in the video. */ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = false): String { - val isNegative = time < 0 || (preferNegative && time == 0.0) - val absoluteTime = time.absoluteValue.toInt() - - val guideMinutes = ((guide / 60) % 60).toInt() - val guideHours = (guide / 3600).toInt() - - val result = StringBuilder() - if (isNegative) { - result.append('-') - } - if (time.isNaN() || time.isInfinite()) { - if (guideHours > 0) { - result.append("--:") - } - result.append("--:--") - } else { - val seconds = absoluteTime % 60 - val minutes = (absoluteTime / 60) % 60 - val hours = absoluteTime / 3600 - // Check if we need to show hours. - val showHours = hours > 0 || guideHours > 0 - if (showHours) { - result.append(hours) - result.append(':') - } - // If hours are showing, we may need to add a leading zero to minutes. - // Always show at least one digit of minutes. - val showMinutesLeadingZero = (showHours || guideMinutes >= 0) - if (showMinutesLeadingZero) { - result.append(minutes.toString().padStart(2, '0')) - } else { - result.append(minutes) - } - result.append(':') - result.append(seconds.toString().padStart(2, '0')) - } - return result.toString() + return formatPlaybackTime(time, guide, preferNegative) } /** @@ -73,7 +37,7 @@ fun formatTrackLabel(track: Track): String { is MediaTrack<*> -> constructLabel(track) else -> null } - } ?: stringResource(R.string.theoplayer_ui_track_unknown) + } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } /** @@ -84,7 +48,7 @@ fun formatTrackLabel(track: Track): String { @Composable fun formatTrackLabel(track: MediaTrack<*>): String { return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(R.string.theoplayer_ui_track_unknown) + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } /** @@ -95,5 +59,5 @@ fun formatTrackLabel(track: MediaTrack<*>): String { @Composable fun formatTrackLabel(track: TextTrack): String { return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(R.string.theoplayer_ui_track_unknown) + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index a65d8a03..bf7fef07 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,11 +51,11 @@ fun MenuScope.LanguageMenu( subtitleTrackState: SubtitleTrackState ) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_language)) }, + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_language)) }, backIcon = { Icon( Icons.Rounded.Close, - contentDescription = stringResource(R.string.theoplayer_ui_btn_menu_close) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_menu_close) ) }, ) { @@ -122,7 +123,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_audio) + text = stringResource(CommonR.string.theoplayer_ui_menu_audio) ) TextButton( modifier = Modifier @@ -134,7 +135,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier.weight(1f), text = audioTrackState.activeAudioTrack?.let { formatTrackLabel(it) } ?: stringResource( - R.string.theoplayer_ui_audio_none + CommonR.string.theoplayer_ui_audio_none ), textAlign = TextAlign.Center ) @@ -151,7 +152,7 @@ fun MenuScope.LanguageMenuCompact( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_subtitles) + text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles) ) TextButton( modifier = Modifier @@ -162,7 +163,7 @@ fun MenuScope.LanguageMenuCompact( Text( modifier = Modifier.weight(1f), text = subtitleTrackState.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( - R.string.theoplayer_ui_subtitles_off + CommonR.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center ) @@ -217,7 +218,7 @@ fun MenuScope.LanguageMenuExpanded( modifier = Modifier .fillMaxWidth(1f) .padding(0.dp, 8.dp), - text = stringResource(R.string.theoplayer_ui_menu_audio), + text = stringResource(CommonR.string.theoplayer_ui_menu_audio), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) @@ -230,7 +231,7 @@ fun MenuScope.LanguageMenuExpanded( modifier = Modifier .fillMaxWidth(1f) .padding(0.dp, 8.dp), - text = stringResource(R.string.theoplayer_ui_menu_subtitles), + text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium ) @@ -264,7 +265,7 @@ fun MenuScope.AudioTrackMenu() { @Composable fun MenuScope.AudioTrackMenu(state: AudioTrackState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_audio)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) } ) { AudioTrackList(state, onClick = { closeCurrentMenu() }) } @@ -294,7 +295,7 @@ fun MenuScope.SubtitleMenu() { @Composable fun MenuScope.SubtitleMenu(state: SubtitleTrackState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_subtitles)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) } ) { SubtitleTrackList(state, onClick = { closeCurrentMenu() }) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt index 312c0192..f3b0ed60 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenuButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -21,8 +22,8 @@ fun MenuScope.LanguageMenuButton( contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.language), - contentDescription = stringResource(R.string.theoplayer_ui_menu_language) + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) ) } ) { @@ -57,8 +58,8 @@ fun MenuScope.LanguageMenuButton( contentPadding: PaddingValues = PaddingValues(0.dp), content: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.language), - contentDescription = stringResource(R.string.theoplayer_ui_menu_language) + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt index 289ceedc..03c7dc50 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LiveButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -58,7 +59,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) }, dvr: @Composable RowScope. () -> Unit = { @@ -76,7 +77,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) } ) { @@ -127,7 +128,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) }, dvr: @Composable RowScope. () -> Unit = { @@ -145,7 +146,7 @@ fun LiveButton( ) Text( modifier = Modifier.align(Alignment.CenterVertically), - text = stringResource(R.string.theoplayer_ui_btn_live) + text = stringResource(CommonR.string.theoplayer_ui_btn_live) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt index fd2e412a..cc28088f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Menu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Menu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -34,7 +35,7 @@ fun MenuScope.Menu( backIcon: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.theoplayer_ui_btn_back) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_back) ) }, content: @Composable () -> Unit, diff --git a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt index 8232c740..749c0c3b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/MuteButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.VolumeOff @@ -26,13 +27,13 @@ fun MuteButton( mute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeUp, - contentDescription = stringResource(R.string.theoplayer_ui_btn_mute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_mute) ) }, unmute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = stringResource(R.string.theoplayer_ui_btn_unmute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_unmute) ) } ) { @@ -64,13 +65,13 @@ fun MuteButton( mute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeUp, - contentDescription = stringResource(R.string.theoplayer_ui_btn_mute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_mute) ) }, unmute: @Composable () -> Unit = { Icon( Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = stringResource(R.string.theoplayer_ui_btn_unmute) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_unmute) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt index 9c24524e..df831828 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Fullscreen @@ -31,13 +32,13 @@ fun PictureInPictureButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.PictureInPictureAlt, - contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_enter), ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_exit) ) } ) { @@ -74,13 +75,13 @@ fun PictureInPictureButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.PictureInPictureAlt, - contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_enter), ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + contentDescription = stringResource(CommonR.string.theoplayer_ui_pip_exit) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt index c039b516..1f42de81 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlayButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Replay @@ -28,23 +29,23 @@ fun PlayButton( contentPadding: PaddingValues = PaddingValues(0.dp), play: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.play), + painter = painterResource(id = CommonR.drawable.play), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_play) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_play) ) }, pause: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.pause), + painter = painterResource(id = CommonR.drawable.pause), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_pause) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_pause) ) }, replay: @Composable () -> Unit = { Icon( Icons.Rounded.Replay, modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_replay) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_replay) ) } ) { @@ -80,23 +81,23 @@ fun PlayButton( contentPadding: PaddingValues = PaddingValues(0.dp), play: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.play), + painter = painterResource(id = CommonR.drawable.play), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_play) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_play) ) }, pause: @Composable () -> Unit = { Icon( - painter = painterResource(id = R.drawable.pause), + painter = painterResource(id = CommonR.drawable.pause), modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_pause) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_pause) ) }, replay: @Composable () -> Unit = { Icon( Icons.Rounded.Replay, modifier = iconModifier, - contentDescription = stringResource(id = R.string.theoplayer_ui_btn_replay) + contentDescription = stringResource(id = CommonR.string.theoplayer_ui_btn_replay) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index c2369ff1..9a21bfd4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -90,9 +91,9 @@ fun PlaybackRateList( @Composable internal fun formatPlaybackRate(rate: Double): String { return if (rate == 1.0) { - stringResource(R.string.theoplayer_ui_playback_rate_normal, rate) + stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) } else { - val format = stringResource(R.string.theoplayer_ui_playback_rate_format) + val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) val decimalFormat = remember(format) { DecimalFormat(format) } decimalFormat.format(rate) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 2c390cb6..77898c73 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -56,7 +57,7 @@ fun QualityList( ) { item(key = null) { ListItem( - headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_quality_automatic)) }, + headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_quality_automatic)) }, leadingContent = { RadioButton( selected = (state.targetVideoQuality == null), @@ -82,7 +83,7 @@ fun QualityList( headlineContent = { Text( text = stringResource( - R.string.theoplayer_ui_quality_with_height, + CommonR.string.theoplayer_ui_quality_with_height, quality.height ) ) @@ -111,11 +112,11 @@ fun QualityList( internal fun formatBandwidth(bandwidth: Long): String { val format = stringResource( if (bandwidth >= 1e7) { - R.string.theoplayer_ui_bandwidth_format_10mbps + CommonR.string.theoplayer_ui_bandwidth_format_10mbps } else if (bandwidth >= 1e6) { - R.string.theoplayer_ui_bandwidth_format_1mbps + CommonR.string.theoplayer_ui_bandwidth_format_1mbps } else { - R.string.theoplayer_ui_bandwidth_format_kbps + CommonR.string.theoplayer_ui_bandwidth_format_kbps } ) val decimalFormat = remember(format) { DecimalFormat(format) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index e986916d..9069ec98 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.theoplayer.android.ui.common.R as CommonR import kotlin.math.absoluteValue /** @@ -52,9 +53,9 @@ fun SeekButton( .scale(scaleX = if (seekOffset >= 0) -1f else 1f, scaleY = 1f), contentDescription = pluralStringResource( if (seekOffset >= 0) { - R.plurals.theoplayer_ui_btn_seek_forward + CommonR.plurals.theoplayer_ui_btn_seek_forward } else { - R.plurals.theoplayer_ui_btn_seek_backward + CommonR.plurals.theoplayer_ui_btn_seek_backward }, seekOffset.absoluteValue, seekOffset.absoluteValue diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 1c3aada3..74beede0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -47,11 +48,11 @@ fun MenuScope.SettingsMenu( playbackRateState: PlaybackRateState ) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_settings)) }, + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_settings)) }, backIcon = { Icon( Icons.Rounded.Close, - contentDescription = stringResource(R.string.theoplayer_ui_btn_menu_close) + contentDescription = stringResource(CommonR.string.theoplayer_ui_btn_menu_close) ) }, ) { @@ -61,7 +62,7 @@ fun MenuScope.SettingsMenu( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_quality) + text = stringResource(CommonR.string.theoplayer_ui_menu_quality) ) TextButton( modifier = Modifier @@ -88,7 +89,7 @@ fun MenuScope.SettingsMenu( modifier = Modifier .weight(1f) .alignByBaseline(), - text = stringResource(R.string.theoplayer_ui_menu_playback_rate) + text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate) ) TextButton( modifier = Modifier @@ -135,7 +136,7 @@ fun MenuScope.QualityMenu() { @Composable fun MenuScope.QualityMenu(state: VideoQualityState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_quality)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) } ) { QualityList(state, onClick = { closeCurrentMenu() }) } @@ -165,7 +166,7 @@ fun MenuScope.PlaybackRateMenu() { @Composable fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { Menu( - title = { Text(text = stringResource(R.string.theoplayer_ui_menu_playback_rate)) } + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) } ) { PlaybackRateList(state, onClick = { closeCurrentMenu() }) } @@ -178,15 +179,15 @@ internal fun formatActiveQualityLabel( ): String { return if (targetVideoQuality != null) { stringResource( - R.string.theoplayer_ui_quality_with_height, + CommonR.string.theoplayer_ui_quality_with_height, targetVideoQuality.height ) } else if (activeVideoQuality != null) { stringResource( - R.string.theoplayer_ui_quality_automatic_with_height, + CommonR.string.theoplayer_ui_quality_automatic_with_height, activeVideoQuality.height ) } else { - stringResource(R.string.theoplayer_ui_quality_automatic) + stringResource(CommonR.string.theoplayer_ui_quality_automatic) } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt index 7a10fd04..48594cd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Settings @@ -23,7 +24,7 @@ fun MenuScope.SettingsMenuButton( content: @Composable () -> Unit = { Icon( Icons.Rounded.Settings, - contentDescription = stringResource(R.string.theoplayer_ui_menu_settings) + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_settings) ) } ) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index a9d49d07..981cfed1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -54,7 +55,7 @@ fun SubtitleTrackList( ) { item(key = null) { ListItem( - headlineContent = { Text(text = stringResource(R.string.theoplayer_ui_subtitles_off)) }, + headlineContent = { Text(text = stringResource(CommonR.string.theoplayer_ui_subtitles_off)) }, leadingContent = { RadioButton( selected = (state.activeSubtitleTrack == null), From 381e4ddf9586974b6ebffc046fad9361f62b55a4 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:37 +0000 Subject: [PATCH 02/11] Share THEOplayer palette and player colors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/theme/Color.kt | 184 ++++++++++++------ .../android/ui/theme/PlayerColors.kt | 27 +++ .../theoplayer/android/ui/tv/theme/Theme.kt | 76 +++++++- .../com/theoplayer/android/ui/theme/Color.kt | 68 ------- .../com/theoplayer/android/ui/theme/Theme.kt | 21 -- 5 files changed, 221 insertions(+), 155 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt delete mode 100644 ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt index 9c05554a..65cc7d12 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -1,68 +1,126 @@ package com.theoplayer.android.ui.theme import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi -internal val md_theme_light_primary = Color(0xFF775A00) -internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) -internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) -internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) -internal val md_theme_light_secondary = Color(0xFF6A5D3F) -internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) -internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) -internal val md_theme_light_tertiary = Color(0xFF496548) -internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) -internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) -internal val md_theme_light_error = Color(0xFFBA1A1A) -internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) -internal val md_theme_light_onError = Color(0xFFFFFFFF) -internal val md_theme_light_onErrorContainer = Color(0xFF410002) -internal val md_theme_light_background = Color(0xFFFFFBFF) -internal val md_theme_light_onBackground = Color(0xFF1E1B16) -internal val md_theme_light_surface = Color(0xFFFFFBFF) -internal val md_theme_light_onSurface = Color(0xFF1E1B16) -internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) -internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) -internal val md_theme_light_outline = Color(0xFF7E7667) -internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) -internal val md_theme_light_inverseSurface = Color(0xFF33302A) -internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) -internal val md_theme_light_shadow = Color(0xFF000000) -internal val md_theme_light_surfaceTint = Color(0xFF775A00) -internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) -internal val md_theme_light_scrim = Color(0xFF000000) +@InternalUiApi +val md_theme_light_primary = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_primaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_light_onPrimaryContainer = Color(0xFF251A00) +@InternalUiApi +val md_theme_light_secondary = Color(0xFF6A5D3F) +@InternalUiApi +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_light_onSecondaryContainer = Color(0xFF241A04) +@InternalUiApi +val md_theme_light_tertiary = Color(0xFF496548) +@InternalUiApi +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_light_onTertiaryContainer = Color(0xFF06210A) +@InternalUiApi +val md_theme_light_error = Color(0xFFBA1A1A) +@InternalUiApi +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_light_onError = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_onErrorContainer = Color(0xFF410002) +@InternalUiApi +val md_theme_light_background = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onBackground = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surface = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surfaceVariant = Color(0xFFECE1CF) +@InternalUiApi +val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_light_outline = Color(0xFF7E7667) +@InternalUiApi +val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) +@InternalUiApi +val md_theme_light_inverseSurface = Color(0xFF33302A) +@InternalUiApi +val md_theme_light_inversePrimary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_light_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_light_surfaceTint = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_outlineVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_light_scrim = Color(0xFF000000) -internal val md_theme_dark_primary = Color(0xFFFFC50F) -internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) -internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) -internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) -internal val md_theme_dark_secondary = Color(0xFFD7C5A0) -internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) -internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) -internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) -internal val md_theme_dark_onTertiary = Color(0xFF1C361D) -internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) -internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_dark_error = Color(0xFFFFB4AB) -internal val md_theme_dark_errorContainer = Color(0xFF93000A) -internal val md_theme_dark_onError = Color(0xFF690005) -internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -internal val md_theme_dark_background = Color(0xFF1E1B16) -internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) -internal val md_theme_dark_surface = Color(0xFF1E1B16) -internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) -internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) -internal val md_theme_dark_outline = Color(0xFF999080) -internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) -internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_inversePrimary = Color(0xFF775A00) -internal val md_theme_dark_shadow = Color(0xFF000000) -internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) -internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) -internal val md_theme_dark_scrim = Color(0xFF000000) - - -private val seed = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_primary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_onPrimary = Color(0xFF3F2E00) +@InternalUiApi +val md_theme_dark_primaryContainer = Color(0xFF5A4300) +@InternalUiApi +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_dark_secondary = Color(0xFFD7C5A0) +@InternalUiApi +val md_theme_dark_onSecondary = Color(0xFF3A2F15) +@InternalUiApi +val md_theme_dark_secondaryContainer = Color(0xFF52452A) +@InternalUiApi +val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_dark_tertiary = Color(0xFFAFCFAB) +@InternalUiApi +val md_theme_dark_onTertiary = Color(0xFF1C361D) +@InternalUiApi +val md_theme_dark_tertiaryContainer = Color(0xFF324D32) +@InternalUiApi +val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_dark_error = Color(0xFFFFB4AB) +@InternalUiApi +val md_theme_dark_errorContainer = Color(0xFF93000A) +@InternalUiApi +val md_theme_dark_onError = Color(0xFF690005) +@InternalUiApi +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_dark_background = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onBackground = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_dark_outline = Color(0xFF999080) +@InternalUiApi +val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_inversePrimary = Color(0xFF775A00) +@InternalUiApi +val md_theme_dark_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_dark_surfaceTint = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_outlineVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt new file mode 100644 index 00000000..467ff558 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerColors.kt @@ -0,0 +1,27 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi + +/** + * Additional colors used by THEOplayer UI components. + */ +@Immutable +data class PlayerColors( + /** + * The color of the circle icon of a live button when playing at the live point. + */ + val liveButtonLive: Color = Color.Red, + /** + * The color of the circle icon of a live button when playing behind the live point. + */ + val liveButtonDvr: Color = Color.Gray +) + +/** + * Player component colors shared by UI modules. + */ +@InternalUiApi +val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt index 347fe822..ee8e43cc 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt @@ -6,25 +6,95 @@ import androidx.tv.material3.ColorScheme import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Typography import androidx.tv.material3.darkColorScheme +import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.tv.ExperimentalTvUiApi import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.theme.LocalPlayerColors import com.theoplayer.android.ui.theme.PlayerAnimations +import com.theoplayer.android.ui.theme.PlayerColors +import com.theoplayer.android.ui.theme.md_theme_dark_background +import com.theoplayer.android.ui.theme.md_theme_dark_error +import com.theoplayer.android.ui.theme.md_theme_dark_errorContainer +import com.theoplayer.android.ui.theme.md_theme_dark_inverseOnSurface +import com.theoplayer.android.ui.theme.md_theme_dark_inversePrimary +import com.theoplayer.android.ui.theme.md_theme_dark_inverseSurface +import com.theoplayer.android.ui.theme.md_theme_dark_onBackground +import com.theoplayer.android.ui.theme.md_theme_dark_onError +import com.theoplayer.android.ui.theme.md_theme_dark_onErrorContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onPrimary +import com.theoplayer.android.ui.theme.md_theme_dark_onPrimaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onSecondary +import com.theoplayer.android.ui.theme.md_theme_dark_onSecondaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_onSurface +import com.theoplayer.android.ui.theme.md_theme_dark_onSurfaceVariant +import com.theoplayer.android.ui.theme.md_theme_dark_onTertiary +import com.theoplayer.android.ui.theme.md_theme_dark_onTertiaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_outline +import com.theoplayer.android.ui.theme.md_theme_dark_outlineVariant +import com.theoplayer.android.ui.theme.md_theme_dark_primary +import com.theoplayer.android.ui.theme.md_theme_dark_primaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_scrim +import com.theoplayer.android.ui.theme.md_theme_dark_secondary +import com.theoplayer.android.ui.theme.md_theme_dark_secondaryContainer +import com.theoplayer.android.ui.theme.md_theme_dark_surface +import com.theoplayer.android.ui.theme.md_theme_dark_surfaceVariant +import com.theoplayer.android.ui.theme.md_theme_dark_tertiary +import com.theoplayer.android.ui.theme.md_theme_dark_tertiaryContainer import kotlin.time.Duration.Companion.seconds +private val THEOplayerDarkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + inversePrimary = md_theme_dark_inversePrimary, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + inverseSurface = md_theme_dark_inverseSurface, + inverseOnSurface = md_theme_dark_inverseOnSurface, + error = md_theme_dark_error, + onError = md_theme_dark_onError, + errorContainer = md_theme_dark_errorContainer, + onErrorContainer = md_theme_dark_onErrorContainer, + border = md_theme_dark_outline, + borderVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim +) + /** - * Provides TV Material theme values and animations to a player UI. + * Provides THEOplayer's TV color scheme, player colors and animations to a player UI. * + * @param colorScheme TV Material colors for this hierarchy + * @param typography TV Material typography for this hierarchy + * @param playerColors additional colors used by THEOplayer UI components * @param playerAnimations animation settings for the TV player UI */ @ExperimentalTvUiApi +@OptIn(InternalUiApi::class) @Composable fun THEOplayerTvTheme( - colorScheme: ColorScheme = darkColorScheme(), + colorScheme: ColorScheme = THEOplayerDarkColorScheme, typography: Typography = MaterialTheme.typography, + playerColors: PlayerColors = PlayerColors(), playerAnimations: PlayerAnimations = PlayerAnimations(controlsHideDelay = 5.seconds), content: @Composable () -> Unit ) { - CompositionLocalProvider(LocalPlayerAnimations provides playerAnimations) { + CompositionLocalProvider( + LocalPlayerColors provides playerColors, + LocalPlayerAnimations provides playerAnimations + ) { MaterialTheme( colorScheme = colorScheme, typography = typography, diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt deleted file mode 100644 index 9c05554a..00000000 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Color.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.theoplayer.android.ui.theme - -import androidx.compose.ui.graphics.Color - -internal val md_theme_light_primary = Color(0xFF775A00) -internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) -internal val md_theme_light_primaryContainer = Color(0xFFFFDF98) -internal val md_theme_light_onPrimaryContainer = Color(0xFF251A00) -internal val md_theme_light_secondary = Color(0xFF6A5D3F) -internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) -internal val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_light_onSecondaryContainer = Color(0xFF241A04) -internal val md_theme_light_tertiary = Color(0xFF496548) -internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) -internal val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_light_onTertiaryContainer = Color(0xFF06210A) -internal val md_theme_light_error = Color(0xFFBA1A1A) -internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) -internal val md_theme_light_onError = Color(0xFFFFFFFF) -internal val md_theme_light_onErrorContainer = Color(0xFF410002) -internal val md_theme_light_background = Color(0xFFFFFBFF) -internal val md_theme_light_onBackground = Color(0xFF1E1B16) -internal val md_theme_light_surface = Color(0xFFFFFBFF) -internal val md_theme_light_onSurface = Color(0xFF1E1B16) -internal val md_theme_light_surfaceVariant = Color(0xFFECE1CF) -internal val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) -internal val md_theme_light_outline = Color(0xFF7E7667) -internal val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) -internal val md_theme_light_inverseSurface = Color(0xFF33302A) -internal val md_theme_light_inversePrimary = Color(0xFFFFC50F) -internal val md_theme_light_shadow = Color(0xFF000000) -internal val md_theme_light_surfaceTint = Color(0xFF775A00) -internal val md_theme_light_outlineVariant = Color(0xFFD0C5B4) -internal val md_theme_light_scrim = Color(0xFF000000) - -internal val md_theme_dark_primary = Color(0xFFFFC50F) -internal val md_theme_dark_onPrimary = Color(0xFF3F2E00) -internal val md_theme_dark_primaryContainer = Color(0xFF5A4300) -internal val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) -internal val md_theme_dark_secondary = Color(0xFFD7C5A0) -internal val md_theme_dark_onSecondary = Color(0xFF3A2F15) -internal val md_theme_dark_secondaryContainer = Color(0xFF52452A) -internal val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) -internal val md_theme_dark_tertiary = Color(0xFFAFCFAB) -internal val md_theme_dark_onTertiary = Color(0xFF1C361D) -internal val md_theme_dark_tertiaryContainer = Color(0xFF324D32) -internal val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) -internal val md_theme_dark_error = Color(0xFFFFB4AB) -internal val md_theme_dark_errorContainer = Color(0xFF93000A) -internal val md_theme_dark_onError = Color(0xFF690005) -internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) -internal val md_theme_dark_background = Color(0xFF1E1B16) -internal val md_theme_dark_onBackground = Color(0xFFE9E1D9) -internal val md_theme_dark_surface = Color(0xFF1E1B16) -internal val md_theme_dark_onSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_surfaceVariant = Color(0xFF4D4639) -internal val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) -internal val md_theme_dark_outline = Color(0xFF999080) -internal val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) -internal val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) -internal val md_theme_dark_inversePrimary = Color(0xFF775A00) -internal val md_theme_dark_shadow = Color(0xFF000000) -internal val md_theme_dark_surfaceTint = Color(0xFFFFC50F) -internal val md_theme_dark_outlineVariant = Color(0xFF4D4639) -internal val md_theme_dark_scrim = Color(0xFF000000) - - -private val seed = Color(0xFFFFC50F) diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt index 94d0e668..dea694d0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt @@ -9,10 +9,6 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color -import com.theoplayer.android.ui.LiveButton private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -79,23 +75,6 @@ private val DarkColors = darkColorScheme( scrim = md_theme_dark_scrim, ) -/** - * Additional colors used by THEOplayer UI components. - */ -@Immutable -data class PlayerColors( - /** - * The color of the circle icon of a [LiveButton] when playing at the live point. - */ - val liveButtonLive: Color = Color.Red, - /** - * The color of the circle icon of a [LiveButton] when playing behind the live point. - */ - val liveButtonDvr: Color = Color.Gray -) - -private val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } - /** * Provides theme colors, shapes and fonts to be used by the player. * From 3be08d125db0944089bde2912573f085406ca750 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 16:47:48 +0000 Subject: [PATCH 03/11] Add direct seek operation to SeekBarState Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/SeekBarState.kt | 8 +++++++ .../theoplayer/android/ui/SeekBarStateTest.kt | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt index 45e76b87..18f9cf24 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -116,6 +116,14 @@ class SeekBarState internal constructor(private val player: Player?) { } } + /** + * Seeks to a position within [valueRange] without changing playback state. + */ + fun seekTo(value: Float) { + if (!enabled) return + player?.currentTime = seekableRange.start + value.toDouble() + } + /** * Called when the user stops dragging the seek bar. Resumes playback if the player was * playing before the drag started. diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt index 3059b206..681de2fd 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt @@ -76,6 +76,30 @@ class SeekBarStateTest { verify(exactly = 1) { mock.player.play() } } + @Test + fun `seekTo seeks without changing playback state`() { + val mock = MockPlayer(paused = false, currentTime = 50.0) + val state = SeekBarState(mock.player) + + state.seekTo(60f) + + verify { mock.player.currentTime = 60.0 } + verify(exactly = 0) { mock.player.pause() } + verify(exactly = 0) { mock.player.play() } + } + + @Test + fun `seekTo does nothing when disabled or player is missing`() { + val mock = MockPlayer(seekableRanges = emptyList()) + + SeekBarState(mock.player).seekTo(60f) + SeekBarState(null).seekTo(60f) + + verify(exactly = 0) { mock.player.currentTime = any() } + verify(exactly = 0) { mock.player.pause() } + verify(exactly = 0) { mock.player.play() } + } + @Test fun `scrubbing a paused player does not resume it`() = runTest { val mock = MockPlayer(paused = true, currentTime = 10.0) From e608298517ec5b690963225e211cffa838b99780 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:01:16 +0000 Subject: [PATCH 04/11] Add Android TV playback controls and tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/build.gradle.kts | 1 + .../android/ui/tv/CurrentTimeDisplay.kt | 71 ++++++ .../android/ui/tv/DurationDisplay.kt | 35 +++ .../theoplayer/android/ui/tv/ErrorDisplay.kt | 74 ++++++ .../theoplayer/android/ui/tv/LiveButton.kt | 103 ++++++++ .../android/ui/tv/LoadingSpinner.kt | 106 +++++++++ .../theoplayer/android/ui/tv/PlayButton.kt | 106 +++++++++ .../com/theoplayer/android/ui/tv/SeekBar.kt | 225 ++++++++++++++++++ .../android/ui/tv/PlayButtonTest.kt | 118 +++++++++ .../theoplayer/android/ui/tv/SeekBarTest.kt | 213 +++++++++++++++++ .../theoplayer/android/ui/tv/TestPlayer.kt | 129 ++++++++++ .../android/ui/tv/UIControllerTest.kt | 24 +- 12 files changed, 1189 insertions(+), 16 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts index cf65d3ad..18cc1c76 100644 --- a/ui-tv/build.gradle.kts +++ b/ui-tv/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { implementation(libs.androidx.tv.material) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.ui) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.ui.toolingPreview) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt new file mode 100644 index 00000000..8294d826 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt @@ -0,0 +1,71 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackTimeState +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.formatPlaybackTime +import com.theoplayer.android.ui.rememberPlaybackTimeState + +/** + * Displays the player's current time. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun CurrentTimeDisplay( + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false +) { + CurrentTimeDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier, + showRemaining = showRemaining, + showDuration = showDuration + ) +} + +/** + * Displays playback time from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun CurrentTimeDisplay( + state: PlaybackTimeState, + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false +) { + val currentTime = state.currentTime + val duration = state.duration + val time = if (showRemaining) -(duration - currentTime) else currentTime + val text = if (showDuration) { + stringResource( + if (showRemaining) { + R.string.theoplayer_ui_current_time_remaining_with_duration + } else { + R.string.theoplayer_ui_current_time_with_duration + }, + formatPlaybackTime(time, duration, preferNegative = showRemaining), + formatPlaybackTime(duration) + ) + } else { + stringResource( + if (showRemaining) { + R.string.theoplayer_ui_current_time_remaining + } else { + R.string.theoplayer_ui_current_time + }, + formatPlaybackTime(time, duration, preferNegative = showRemaining) + ) + } + Text(modifier = modifier, text = text) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt new file mode 100644 index 00000000..164dff4c --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt @@ -0,0 +1,35 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackTimeState +import com.theoplayer.android.ui.formatPlaybackTime +import com.theoplayer.android.ui.rememberPlaybackTimeState + +/** + * Displays the player's duration. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun DurationDisplay(modifier: Modifier = Modifier) { + DurationDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier + ) +} + +/** + * Displays the duration from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun DurationDisplay(state: PlaybackTimeState, modifier: Modifier = Modifier) { + Text(modifier = modifier, text = formatPlaybackTime(state.duration)) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt new file mode 100644 index 00000000..06ad8e11 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt @@ -0,0 +1,74 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.ui.ErrorState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.LocalUIControllerErrorState +import com.theoplayer.android.ui.Player +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberErrorState + +/** + * Displays a fatal player error, if one occurs. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun ErrorDisplay(modifier: Modifier = Modifier) { + ErrorDisplay( + state = LocalUIControllerErrorState.current + ?: rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), + modifier = modifier + ) +} + +/** + * Displays the error held by [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun ErrorDisplay(state: ErrorState, modifier: Modifier = Modifier) { + state.error?.let { ErrorDisplayContent(it, modifier) } +} + +@Composable +private fun ErrorDisplayContent(error: THEOplayerException, modifier: Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + Icons.Rounded.Error, + modifier = Modifier.size(48.dp), + contentDescription = null + ) + Column { + Box( + modifier = Modifier.defaultMinSize(minHeight = 48.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = stringResource(R.string.theoplayer_ui_error_title), + style = MaterialTheme.typography.headlineMedium + ) + } + Text(text = "${error.message}") + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt new file mode 100644 index 00000000..75bb6900 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt @@ -0,0 +1,103 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonColors +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LiveButtonState +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberLiveButtonState +import com.theoplayer.android.ui.theme.LocalPlayerColors + +/** + * Shows live status and seeks to the live point when clicked. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun LiveButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + colors: ButtonColors = ButtonDefaults.colors(), + liveThreshold: Double = 10.0, + live: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonLive) + }, + dvr: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonDvr) + } +) { + LiveButton( + state = rememberLiveButtonState(LocalTHEOplayerView.current?.player, liveThreshold), + modifier = modifier, + contentPadding = contentPadding, + colors = colors, + live = live, + dvr = dvr + ) +} + +/** + * Shows live status from [state] and seeks to the live point when clicked. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun LiveButton( + state: LiveButtonState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + colors: ButtonColors = ButtonDefaults.colors(), + live: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonLive) + }, + dvr: @Composable RowScope.() -> Unit = { + LiveButtonContent(LocalPlayerColors.current.liveButtonDvr) + } +) { + if (state.visible) { + Button( + onClick = state::onClick, + modifier = modifier, + contentPadding = contentPadding, + colors = colors + ) { + if (state.isLive) live() else dvr() + } + } +} + +@Composable +private fun RowScope.LiveButtonContent(color: Color) { + Icon( + Icons.Rounded.Circle, + modifier = Modifier + .size(12.dp) + .align(Alignment.CenterVertically), + tint = color, + contentDescription = null + ) + Spacer(Modifier.width(4.dp)) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResource(R.string.theoplayer_ui_btn_live) + ) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt new file mode 100644 index 00000000..9e89fa0a --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -0,0 +1,106 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.LoadingState +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.rememberLoadingState +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import kotlin.time.Duration +import kotlinx.coroutines.delay as delayMillis + +/** + * Shows a spinner while the player is loading. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun LoadingSpinner( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp, + delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay +) { + LoadingSpinner( + state = rememberLoadingState(LocalTHEOplayerView.current?.player), + modifier = modifier, + color = color, + strokeWidth = strokeWidth, + delay = delay + ) +} + +/** + * Shows a spinner while [state] is loading. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) +@Composable +fun LoadingSpinner( + state: LoadingState, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + strokeWidth: Dp = 4.dp, + delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay +) { + val inspectionMode = LocalInspectionMode.current + var delayElapsed by remember(state, delay) { mutableStateOf(false) } + LaunchedEffect(state.loading, delay) { + delayElapsed = false + if (state.loading && !inspectionMode) { + delayMillis(delay.inWholeMilliseconds.coerceAtLeast(0)) + delayElapsed = true + } + } + + if (state.loading && (inspectionMode || delayElapsed)) { + val transition = rememberInfiniteTransition(label = "loading-spinner") + val rotation by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1_000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + Canvas(modifier = modifier.size(40.dp)) { + val stroke = strokeWidth.toPx() + val inset = stroke / 2 + drawArc( + color = color, + startAngle = rotation - 90f, + sweepAngle = 270f, + useCenter = false, + topLeft = Offset(inset, inset), + size = Size(size.width - stroke, size.height - stroke), + style = Stroke(width = stroke, cap = StrokeCap.Round) + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt new file mode 100644 index 00000000..edc27511 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt @@ -0,0 +1,106 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlayButtonState +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.rememberPlayButtonState + +/** + * A button that toggles playback. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun PlayButton( + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + play: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.play), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_play) + ) + }, + pause: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.pause), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_pause) + ) + }, + replay: @Composable () -> Unit = { + Icon( + Icons.Rounded.Replay, + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_replay) + ) + } +) { + PlayButton( + state = rememberPlayButtonState(LocalTHEOplayerView.current?.player), + modifier = modifier, + iconModifier = iconModifier, + contentPadding = contentPadding, + play = play, + pause = pause, + replay = replay + ) +} + +/** + * A button that toggles playback using [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun PlayButton( + state: PlayButtonState, + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + play: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.play), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_play) + ) + }, + pause: @Composable () -> Unit = { + Icon( + painter = painterResource(R.drawable.pause), + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_pause) + ) + }, + replay: @Composable () -> Unit = { + Icon( + Icons.Rounded.Replay, + modifier = iconModifier, + contentDescription = stringResource(R.string.theoplayer_ui_btn_replay) + ) + } +) { + IconButton(onClick = state::onClick, modifier = modifier) { + Box(Modifier.padding(contentPadding)) { + when { + !state.paused -> pause() + state.ended -> replay() + else -> play() + } + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt new file mode 100644 index 00000000..6b19cbaa --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -0,0 +1,225 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.SeekBarState +import com.theoplayer.android.ui.rememberSeekBarState +import kotlin.math.max + +/** + * Displays the player's seek bar. + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +fun SeekBar( + modifier: Modifier = Modifier, + colors: SeekBarColors = SeekBarDefaults.colors(), + seekOffset: Int = 10 +) { + SeekBar( + state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + modifier = modifier, + colors = colors, + seekOffset = seekOffset + ) +} + +/** + * Displays the seek bar from [state]. + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun SeekBar( + state: SeekBarState, + modifier: Modifier = Modifier, + colors: SeekBarColors = SeekBarDefaults.colors(), + seekOffset: Int = 10 +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val thumbRadius by animateDpAsState( + targetValue = if (focused) 8.dp else 4.dp, + label = "seekbar-thumb-radius" + ) + val range = state.valueRange + + Box( + modifier = modifier + .fillMaxWidth() + .height(16.dp) + .semantics { + progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) + setProgress { value -> + state.seekTo(value.coerceIn(range)) + true + } + } + .seekBarKeyEvents(state, seekOffset) + .focusable(interactionSource = interactionSource) + ) { + Canvas(Modifier.fillMaxSize()) { + drawSeekBar(state, colors, thumbRadius) + } + } +} + +@Composable +private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Modifier { + var isScrubbing by remember(state) { mutableStateOf(false) } + var scrubValue by remember(state) { mutableStateOf(0f) } + val range = state.valueRange + + fun finishScrubbing() { + if (isScrubbing) { + state.onValueChangeFinished() + isScrubbing = false + } + } + + return onFocusChanged { + if (!it.isFocused) finishScrubbing() + }.onKeyEvent { event -> + val direction = when (event.key) { + Key.DirectionLeft -> -1 + Key.DirectionRight -> 1 + else -> return@onKeyEvent false + } + if (!state.enabled && !isScrubbing) return@onKeyEvent false + + when (event.type) { + KeyEventType.KeyDown -> { + if (event.nativeKeyEvent.repeatCount == 0) { + state.seekTo((state.value + direction * seekOffset).coerceIn(range)) + } else { + if (!isScrubbing) { + scrubValue = state.value + isScrubbing = true + } + val step = (range.endInclusive - range.start) * 0.01f + scrubValue = (scrubValue + direction * step).coerceIn(range) + state.onValueChange(scrubValue) + } + true + } + + KeyEventType.KeyUp -> { + finishScrubbing() + true + } + + else -> false + } + } +} + +private fun DrawScope.drawSeekBar( + state: SeekBarState, + colors: SeekBarColors, + thumbRadius: Dp +) { + val range = state.valueRange + val edge = 8.dp.toPx() + val start = edge + val end = max(start, size.width - edge) + val width = end - start + val valueWidth = (range.endInclusive - range.start).coerceAtLeast(0f) + val fraction = if (valueWidth == 0f) { + 0f + } else { + ((state.value - range.start) / valueWidth).coerceIn(0f, 1f) + } + val thumbX = start + width * fraction + val centerY = size.height / 2 + val trackWidth = 4.dp.toPx() + + drawLine( + color = colors.inactiveTrackColor, + start = Offset(start, centerY), + end = Offset(end, centerY), + strokeWidth = trackWidth, + cap = StrokeCap.Round + ) + drawLine( + color = colors.activeTrackColor, + start = Offset(start, centerY), + end = Offset(thumbX, centerY), + strokeWidth = trackWidth, + cap = StrokeCap.Round + ) + if (state.showThumb) { + drawCircle( + color = colors.thumbColor, + radius = thumbRadius.toPx(), + center = Offset(thumbX, centerY) + ) + } +} + +/** + * Colors used to draw a TV seek bar. + */ +@ExperimentalTvUiApi +@Immutable +class SeekBarColors( + /** Thumb color. */ + val thumbColor: Color, + /** Active track color. */ + val activeTrackColor: Color, + /** Inactive track color. */ + val inactiveTrackColor: Color +) + +/** + * Creates colors for a TV seek bar. + */ +@ExperimentalTvUiApi +object SeekBarDefaults { + /** + * Creates a seek bar color set from the current TV theme. + */ + @Composable + fun colors( + thumbColor: Color = MaterialTheme.colorScheme.primary, + activeTrackColor: Color = MaterialTheme.colorScheme.primary, + inactiveTrackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + ): SeekBarColors = SeekBarColors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = inactiveTrackColor + ) +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt new file mode 100644 index 00000000..7e7eff2a --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/PlayButtonTest.kt @@ -0,0 +1,118 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.player.EndedEvent +import com.theoplayer.android.api.event.player.PlayEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.common.R +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class PlayButtonTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun pausedPlayerShowsPlayAndClickStartsPlayback() { + val player = TestPlayer(paused = true) + val focusRequester = FocusRequester() + setPlayButton(player, Modifier.focusRequester(focusRequester)) + focusPlayButton(focusRequester) + val playDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_play) + + composeRule.onNodeWithContentDescription(playDescription).assertExists() + pressCenter() + + verify(exactly = 1) { player.player.play() } + } + + @Test + fun playingPlayerShowsPauseAndClickPausesPlayback() { + val player = TestPlayer(paused = true) + val focusRequester = FocusRequester() + setPlayButton(player, Modifier.focusRequester(focusRequester)) + focusPlayButton(focusRequester) + player.paused = false + dispatch(player, PlayerEventTypes.PLAY, mockk()) + val pauseDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_pause) + + composeRule.onNodeWithContentDescription(pauseDescription).assertExists() + pressCenter() + + verify(exactly = 1) { player.player.pause() } + } + + @Test + fun endedPlayerShowsReplay() { + val player = TestPlayer(paused = true) + setPlayButton(player) + player.ended = true + dispatch(player, PlayerEventTypes.ENDED, mockk()) + + val replayDescription = composeRule.activity.getString(R.string.theoplayer_ui_btn_replay) + composeRule.onNodeWithContentDescription(replayDescription).assertExists() + } + + private fun setPlayButton(player: TestPlayer, modifier: Modifier = Modifier) { + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player.player) } + CompositionLocalProvider(LocalTHEOplayerView provides view) { + THEOplayerTvTheme { + PlayButton(modifier = modifier.testTag("play-button")) + } + } + } + composeRule.waitForIdle() + } + + private fun focusPlayButton(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.onNodeWithTag("play-button").assertIsFocused() + } + + private fun pressCenter() { + assertTrue( + dispatchKeyEvent( + composeRule.activity, + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + assertTrue( + dispatchKeyEvent( + composeRule.activity, + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + } + + private fun dispatch(player: TestPlayer, eventType: EventType<*>, event: Event<*>) { + composeRule.runOnIdle { + player.dispatch(eventType, event) + } + composeRule.waitForIdle() + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt new file mode 100644 index 00000000..df010280 --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt @@ -0,0 +1,213 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.event.player.TimeUpdateEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class SeekBarTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun seekBarIsFocusable() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester).testTag("seek-bar")) + + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.onNodeWithTag("seek-bar").assertIsFocused() + } + + @Test + fun singleRightPressSeeksWithoutPausing() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(60.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.pause() } + } + + @Test + fun singleLeftPressClampsToSeekableStart() { + val player = TestPlayer(currentTime = 5.0) + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + + assertEquals(0.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.pause() } + } + + @Test + fun heldRightPressScrubsAndResumesPlayback() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + val positions = mutableListOf(player.currentTime) + for (repeat in 1..3) { + assertTrue( + dispatchKey( + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.ACTION_DOWN, + repeat = repeat + ) + ) + positions += player.currentTime + } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertTrue(positions.zipWithNext().all { (previous, next) -> next > previous }) + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun heldScrubResumesPlaybackWhenSeekBarBecomesDisabled() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + + player.seekableRanges = emptyList() + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun heldScrubResumesPlaybackWhenFocusLeavesSeekBar() { + val player = TestPlayer() + val focusRequester = FocusRequester() + val otherFocusRequester = FocusRequester() + setSeekBar( + player, + Modifier.focusRequester(focusRequester), + otherFocusRequester + ) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + composeRule.runOnIdle { + player.dispatch(PlayerEventTypes.TIMEUPDATE, mockk()) + } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + + composeRule.runOnIdle { otherFocusRequester.requestFocus() } + composeRule.waitForIdle() + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + } + + @Test + fun disabledSeekBarDoesNotConsumeOrSeek() { + val player = TestPlayer(seekableRanges = emptyList()) + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertEquals(50.0, player.currentTime, 0.0) + verify(exactly = 0) { player.player.currentTime = any() } + } + + private fun setSeekBar( + player: TestPlayer, + modifier: Modifier = Modifier, + otherFocusRequester: FocusRequester? = null + ) { + composeRule.setContent { + val view = remember { PlayerBackedTHEOplayerView(composeRule.activity, player.player) } + CompositionLocalProvider(LocalTHEOplayerView provides view) { + THEOplayerTvTheme { + Column { + SeekBar(modifier = modifier) + if (otherFocusRequester != null) { + Box( + Modifier + .size(16.dp) + .focusRequester(otherFocusRequester) + .focusable() + ) + } + } + } + } + } + composeRule.waitForIdle() + } + + private fun requestFocus(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey( + keyCode: Int, + action: Int, + repeat: Int = 0 + ): Boolean = dispatchKeyEvent( + composeRule.activity, + KeyEvent(1_000L, eventTime++, action, keyCode, repeat) + ) + + private var eventTime = 1_001L +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt new file mode 100644 index 00000000..77e3cccb --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/TestPlayer.kt @@ -0,0 +1,129 @@ +package com.theoplayer.android.ui.tv + +import android.app.Activity +import android.content.Context +import android.view.KeyEvent +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.event.Event +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.player.PlayerEvent +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.ReadyState +import com.theoplayer.android.api.timerange.TimeRange +import com.theoplayer.android.api.timerange.TimeRanges +import io.mockk.every +import io.mockk.mockk + +internal class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: Player +) : THEOplayerView(context) { + override fun getPlayer(): Player = nativePlayer +} + +internal class PlayerEventListeners { + private val listeners = mutableListOf, EventListener<*>>>() + + fun add(type: EventType<*>, listener: EventListener<*>) { + listeners.add(type to listener) + } + + fun remove(type: EventType<*>, listener: EventListener<*>) { + listeners.remove(type to listener) + } + + fun hasListeners(type: EventType<*>): Boolean = listeners.any { it.first == type } + + fun dispatch(type: EventType<*>, event: Event<*>) { + listeners.toList().forEach { (eventType, listener) -> + if (eventType == type) { + @Suppress("UNCHECKED_CAST") + (listener as EventListener>).handleEvent(event) + } + } + } +} + +internal class TestPlayer( + var currentTime: Double = 50.0, + var paused: Boolean = false, + var ended: Boolean = false, + var seekableRanges: List> = listOf(0.0 to 100.0), + private val duration: Double = 100.0 +) { + val eventListeners = PlayerEventListeners() + private val adEventListeners = PlayerEventListeners() + private val ads = mockk { + every { isPlaying } returns false + every { + addEventListener(any>>(), any()) + } answers { + adEventListeners.add(firstArg(), secondArg()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + adEventListeners.remove(firstArg(), secondArg()) + Unit + } + } + val player: Player = mockk { + every { isPaused } answers { this@TestPlayer.paused } + every { isEnded } answers { this@TestPlayer.ended } + every { getCurrentTime() } answers { this@TestPlayer.currentTime } + every { getDuration() } answers { this@TestPlayer.duration } + every { getSeekable() } answers { timeRanges(this@TestPlayer.seekableRanges) } + every { getAds() } returns this@TestPlayer.ads + every { getReadyState() } returns ReadyState.HAVE_FUTURE_DATA + every { isSeeking } returns false + every { setCurrentTime(any()) } answers { + this@TestPlayer.currentTime = firstArg() + } + every { play() } answers { + this@TestPlayer.paused = false + Unit + } + every { pause() } answers { + this@TestPlayer.paused = true + Unit + } + every { + addEventListener(any>>(), any()) + } answers { + eventListeners.add(firstArg(), secondArg()) + Unit + } + every { + removeEventListener(any>>(), any()) + } answers { + eventListeners.remove(firstArg(), secondArg()) + Unit + } + } + + fun dispatch(type: EventType<*>, event: Event<*>) { + eventListeners.dispatch(type, event) + } +} + +internal fun dispatchKeyEvent(activity: Activity, event: KeyEvent): Boolean = + activity.dispatchKeyEvent(event) + +private fun timeRanges(ranges: List>): TimeRanges = mockk { + every { iterator() } answers { + ranges.map { (start, end) -> + timeRange(start, end) + }.toMutableList().listIterator() + } +} + +private fun timeRange(start: Double, end: Double): TimeRange { + val range = mockk() + every { range.start } returns start + every { range.end } returns end + return range +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt index d8269f3f..5ea45761 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -1,6 +1,5 @@ package com.theoplayer.android.ui.tv -import android.content.Context import android.view.KeyEvent import android.view.ViewGroup import androidx.activity.ComponentActivity @@ -30,7 +29,6 @@ import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.error.THEOplayerException -import com.theoplayer.android.api.event.EventListener import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player @@ -40,7 +38,6 @@ import com.theoplayer.android.ui.UIControllerScope import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -342,12 +339,12 @@ class UIControllerTest { } composeRule.activity.onBackPressedDispatcher.addCallback(callback) val player = mockNotPlayedPlayer() - val errorListener = slot>() - val errorListeners = mutableListOf>() + val errorListeners = PlayerEventListeners() every { - player.addEventListener(PlayerEventTypes.ERROR, capture(errorListener)) + player.addEventListener(PlayerEventTypes.ERROR, any()) } answers { - errorListeners += errorListener.captured + errorListeners.add(firstArg(), secondArg()) + Unit } setController( player = player, @@ -356,12 +353,14 @@ class UIControllerTest { } ) composeRule.mainClock.advanceTimeBy(100) - composeRule.waitUntil(10_000) { errorListeners.isNotEmpty() } + composeRule.waitUntil(10_000) { + errorListeners.hasListeners(PlayerEventTypes.ERROR) + } composeRule.waitForIdle() val errorEvent = mockk() every { errorEvent.errorObject } returns mockk() composeRule.runOnIdle { - errorListeners.forEach { it.handleEvent(errorEvent) } + errorListeners.dispatch(PlayerEventTypes.ERROR, errorEvent) } waitForTag("error") @@ -540,13 +539,6 @@ class UIControllerTest { return player } - private class PlayerBackedTHEOplayerView( - context: Context, - private val nativePlayer: Player - ) : THEOplayerView(context) { - override fun getPlayer(): Player = nativePlayer - } - private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, keyCode) ) From fbf991020a9d84fa8bfbe52d6fada5327695e85e Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:08:05 +0000 Subject: [PATCH 05/11] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1fd2d6..9840501e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) * 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125)) ## [1.14.2] (2026-10-13) From fd8a2a2f3bf4c06c4d0c428cdfb8a8e7f882b70d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:14:31 +0000 Subject: [PATCH 06/11] Explain seek bar key repeat handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt index 6b19cbaa..f64fdf1e 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -123,6 +123,9 @@ private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Mod when (event.type) { KeyEventType.KeyDown -> { + // The first key down is a single press: skip by seekOffset and keep the play state. + // Holding the key auto-repeats key down events (repeatCount > 0): scrub instead, + // pausing playback until the key is released. if (event.nativeKeyEvent.repeatCount == 0) { state.seekTo((state.value + direction * seekOffset).coerceIn(range)) } else { From 2138f849ded3fe39d135a0a1a73701ebd2ff8335 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:28:11 +0000 Subject: [PATCH 07/11] Extract Slider from SeekBar --- .../com/theoplayer/android/ui/tv/SeekBar.kt | 206 ++------- .../com/theoplayer/android/ui/tv/Slider.kt | 391 ++++++++++++++++++ .../theoplayer/android/ui/tv/SliderTest.kt | 288 +++++++++++++ 3 files changed, 703 insertions(+), 182 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt index f64fdf1e..def51243 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -1,43 +1,13 @@ package com.theoplayer.android.ui.tv -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.semantics.progressBarRangeInfo -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.setProgress -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.SeekBarState import com.theoplayer.android.ui.rememberSeekBarState -import kotlin.math.max /** * Displays the player's seek bar. @@ -47,7 +17,7 @@ import kotlin.math.max @Composable fun SeekBar( modifier: Modifier = Modifier, - colors: SeekBarColors = SeekBarDefaults.colors(), + colors: SliderColors = SliderDefaults.colors(), seekOffset: Int = 10 ) { SeekBar( @@ -67,162 +37,34 @@ fun SeekBar( fun SeekBar( state: SeekBarState, modifier: Modifier = Modifier, - colors: SeekBarColors = SeekBarDefaults.colors(), + colors: SliderColors = SliderDefaults.colors(), seekOffset: Int = 10 ) { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - val thumbRadius by animateDpAsState( - targetValue = if (focused) 8.dp else 4.dp, - label = "seekbar-thumb-radius" - ) - val range = state.valueRange - - Box( - modifier = modifier - .fillMaxWidth() - .height(16.dp) - .semantics { - progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) - setProgress { value -> - state.seekTo(value.coerceIn(range)) - true - } - } - .seekBarKeyEvents(state, seekOffset) - .focusable(interactionSource = interactionSource) - ) { - Canvas(Modifier.fillMaxSize()) { - drawSeekBar(state, colors, thumbRadius) - } - } -} - -@Composable -private fun Modifier.seekBarKeyEvents(state: SeekBarState, seekOffset: Int): Modifier { - var isScrubbing by remember(state) { mutableStateOf(false) } - var scrubValue by remember(state) { mutableStateOf(0f) } - val range = state.valueRange - - fun finishScrubbing() { - if (isScrubbing) { - state.onValueChangeFinished() - isScrubbing = false - } - } - - return onFocusChanged { - if (!it.isFocused) finishScrubbing() - }.onKeyEvent { event -> - val direction = when (event.key) { - Key.DirectionLeft -> -1 - Key.DirectionRight -> 1 - else -> return@onKeyEvent false - } - if (!state.enabled && !isScrubbing) return@onKeyEvent false - - when (event.type) { - KeyEventType.KeyDown -> { - // The first key down is a single press: skip by seekOffset and keep the play state. - // Holding the key auto-repeats key down events (repeatCount > 0): scrub instead, - // pausing playback until the key is released. - if (event.nativeKeyEvent.repeatCount == 0) { - state.seekTo((state.value + direction * seekOffset).coerceIn(range)) - } else { - if (!isScrubbing) { - scrubValue = state.value - isScrubbing = true - } - val step = (range.endInclusive - range.start) * 0.01f - scrubValue = (scrubValue + direction * step).coerceIn(range) - state.onValueChange(scrubValue) - } - true + val sliderState = rememberSyncedSliderState(state.value, state.valueRange) + Slider( + state = sliderState, + onValueChange = { value -> + if (sliderState.isDragging) { + state.onValueChange(value) + } else { + state.seekTo(value) } - - KeyEventType.KeyUp -> { - finishScrubbing() - true + }, + modifier = modifier, + enabled = state.enabled, + keyStep = seekOffset.toFloat(), + keyRepeatStep = (state.valueRange.endInclusive - state.valueRange.start) / 100f, + onValueChangeFinished = state::onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = { + if (state.showThumb) { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) } - - else -> false + }, + track = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) } - } -} - -private fun DrawScope.drawSeekBar( - state: SeekBarState, - colors: SeekBarColors, - thumbRadius: Dp -) { - val range = state.valueRange - val edge = 8.dp.toPx() - val start = edge - val end = max(start, size.width - edge) - val width = end - start - val valueWidth = (range.endInclusive - range.start).coerceAtLeast(0f) - val fraction = if (valueWidth == 0f) { - 0f - } else { - ((state.value - range.start) / valueWidth).coerceIn(0f, 1f) - } - val thumbX = start + width * fraction - val centerY = size.height / 2 - val trackWidth = 4.dp.toPx() - - drawLine( - color = colors.inactiveTrackColor, - start = Offset(start, centerY), - end = Offset(end, centerY), - strokeWidth = trackWidth, - cap = StrokeCap.Round - ) - drawLine( - color = colors.activeTrackColor, - start = Offset(start, centerY), - end = Offset(thumbX, centerY), - strokeWidth = trackWidth, - cap = StrokeCap.Round - ) - if (state.showThumb) { - drawCircle( - color = colors.thumbColor, - radius = thumbRadius.toPx(), - center = Offset(thumbX, centerY) - ) - } -} - -/** - * Colors used to draw a TV seek bar. - */ -@ExperimentalTvUiApi -@Immutable -class SeekBarColors( - /** Thumb color. */ - val thumbColor: Color, - /** Active track color. */ - val activeTrackColor: Color, - /** Inactive track color. */ - val inactiveTrackColor: Color -) - -/** - * Creates colors for a TV seek bar. - */ -@ExperimentalTvUiApi -object SeekBarDefaults { - /** - * Creates a seek bar color set from the current TV theme. - */ - @Composable - fun colors( - thumbColor: Color = MaterialTheme.colorScheme.primary, - activeTrackColor: Color = MaterialTheme.colorScheme.primary, - inactiveTrackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) - ): SeekBarColors = SeekBarColors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor ) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt new file mode 100644 index 00000000..a9f58c48 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt @@ -0,0 +1,391 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import kotlin.math.roundToInt + +private val ThumbSize = 16.dp +private const val TrackLayoutId = "track" +private const val ThumbLayoutId = "thumb" + +/** + * Holds the value and range for a TV slider. + */ +@ExperimentalTvUiApi +@Stable +class SliderState( + value: Float = 0f, + valueRange: ClosedFloatingPointRange = 0f..1f +) { + private var valueState by mutableFloatStateOf(value.coerceIn(valueRange)) + + /** The current slider value, constrained to [valueRange]. */ + var value: Float + get() = valueState + set(value) { + valueState = value.coerceIn(valueRange) + } + + /** The range of values available to the slider. */ + var valueRange by mutableStateOf(valueRange) + + /** The current value's fraction within [valueRange]. */ + val coercedValueAsFraction: Float + get() { + val range = valueRange + val width = range.endInclusive - range.start + if (width == 0f) return 0f + return ((value.coerceIn(range) - range.start) / width).coerceIn(0f, 1f) + } + + /** Whether the user is holding Left or Right to scrub. */ + var isDragging by mutableStateOf(false) + internal set +} + +/** + * Displays a focusable slider controlled by D-pad keys. + */ +@ExperimentalTvUiApi +@Composable +fun Slider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + keyStep: Float = (valueRange.endInclusive - valueRange.start) / 100f, + keyRepeatStep: Float = keyStep, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) + } +) { + val state = rememberSyncedSliderState(value, valueRange) + Slider( + state = state, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + keyStep = keyStep, + keyRepeatStep = keyRepeatStep, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = thumb, + track = track + ) +} + +/** + * Displays a slider backed by [state]. + */ +@ExperimentalTvUiApi +@Composable +fun Slider( + state: SliderState, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + keyStep: Float = (state.valueRange.endInclusive - state.valueRange.start) / 100f, + keyRepeatStep: Float = keyStep, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors) + } +) { + val range = state.valueRange + val sliderModifier = modifier + .fillMaxWidth() + .height(ThumbSize) + .semantics { + progressBarRangeInfo = ProgressBarRangeInfo(state.value, range) + setProgress { value -> + if (!enabled) { + false + } else { + state.value = value + onValueChange(state.value) + onValueChangeFinished?.invoke() + true + } + } + if (!enabled) disabled() + } + .sliderKeyEvents( + state = state, + enabled = enabled, + keyStep = keyStep, + keyRepeatStep = keyRepeatStep, + onValueChange = onValueChange, + onValueChangeFinished = onValueChangeFinished + ) + .focusable(interactionSource = interactionSource) + + SliderLayout( + state = state, + modifier = sliderModifier, + thumb = thumb, + track = track + ) +} + +/** + * Creates a remembered slider state synchronized with external values. + */ +@Composable +internal fun rememberSyncedSliderState( + value: Float, + valueRange: ClosedFloatingPointRange +): SliderState { + val state = remember { SliderState(value, valueRange) } + state.valueRange = valueRange + if (!state.isDragging) state.value = value + return state +} + +/** + * Colors used to draw a TV slider. + */ +@ExperimentalTvUiApi +@Immutable +class SliderColors( + /** Thumb color. */ + val thumbColor: Color, + /** Active track color. */ + val activeTrackColor: Color, + /** Inactive track color. */ + val inactiveTrackColor: Color +) + +/** + * Creates slider colors and default slider content. + */ +@ExperimentalTvUiApi +object SliderDefaults { + /** + * Creates a slider color set from the current TV theme. + */ + @Composable + fun colors( + thumbColor: Color = MaterialTheme.colorScheme.primary, + activeTrackColor: Color = MaterialTheme.colorScheme.primary, + inactiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + ): SliderColors = SliderColors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = inactiveTrackColor + ) + + /** + * Displays a slider thumb that grows when focused. + */ + @Composable + fun Thumb( + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + colors: SliderColors = colors() + ) { + val focused by interactionSource.collectIsFocusedAsState() + val radius by animateDpAsState( + targetValue = if (focused) 8.dp else 4.dp, + label = "slider-thumb-radius" + ) + + Canvas(modifier.size(ThumbSize)) { + drawCircle( + color = colors.thumbColor, + radius = radius.toPx() + ) + } + } + + /** + * Displays a slider track with active and inactive sections. + */ + @Composable + fun Track( + sliderState: SliderState, + modifier: Modifier = Modifier, + colors: SliderColors = colors() + ) { + Canvas(modifier.fillMaxWidth().height(4.dp)) { + val centerY = size.height / 2 + val end = Offset(size.width, centerY) + val strokeWidth = 4.dp.toPx() + drawLine( + color = colors.inactiveTrackColor, + start = Offset(0f, centerY), + end = end, + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + drawLine( + color = colors.activeTrackColor, + start = Offset(0f, centerY), + end = Offset(size.width * sliderState.coercedValueAsFraction, centerY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } + } +} + +@Composable +private fun Modifier.sliderKeyEvents( + state: SliderState, + enabled: Boolean, + keyStep: Float, + keyRepeatStep: Float, + onValueChange: (Float) -> Unit, + onValueChangeFinished: (() -> Unit)? +): Modifier { + val currentOnValueChange by rememberUpdatedState(onValueChange) + val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished) + var isKeyPressed by remember(state) { mutableStateOf(false) } + + fun finishDragging() { + if (state.isDragging) { + state.isDragging = false + currentOnValueChangeFinished?.invoke() + } + } + + LaunchedEffect(enabled) { + if (!enabled) finishDragging() + } + + return onFocusChanged { + if (!it.isFocused) { + isKeyPressed = false + finishDragging() + } + }.onKeyEvent { event -> + val direction = when (event.key) { + Key.DirectionLeft -> -1 + Key.DirectionRight -> 1 + else -> return@onKeyEvent false + } + + when (event.type) { + KeyEventType.KeyDown -> { + if (!enabled) return@onKeyEvent isKeyPressed + isKeyPressed = true + // The first key down is a single step; repeats indicate a held key, which scrubs. + // Accumulate repeats from SliderState because external values may update later. + if (event.nativeKeyEvent.repeatCount == 0) { + state.value += direction * keyStep + } else { + state.isDragging = true + state.value += direction * keyRepeatStep + } + currentOnValueChange(state.value) + true + } + + KeyEventType.KeyUp -> { + if (!isKeyPressed) return@onKeyEvent enabled + isKeyPressed = false + if (state.isDragging) { + finishDragging() + } else if (enabled) { + currentOnValueChangeFinished?.invoke() + } + true + } + + else -> false + } + } +} + +@Composable +private fun SliderLayout( + state: SliderState, + modifier: Modifier, + thumb: @Composable (SliderState) -> Unit, + track: @Composable (SliderState) -> Unit +) { + Layout( + modifier = modifier, + content = { + Box(Modifier.layoutId(TrackLayoutId)) { track(state) } + Box(Modifier.layoutId(ThumbLayoutId)) { thumb(state) } + } + ) { measurables, constraints -> + val thumbSize = ThumbSize.roundToPx() + val width = constraints.maxWidth + val height = thumbSize.coerceIn(constraints.minHeight, constraints.maxHeight) + val trackWidth = (width - thumbSize).coerceAtLeast(0) + val track = measurables.first { it.layoutId == TrackLayoutId }.measure( + Constraints( + minWidth = trackWidth, + maxWidth = trackWidth, + maxHeight = height + ) + ) + val thumb = measurables.first { it.layoutId == ThumbLayoutId }.measure( + Constraints(maxWidth = width, maxHeight = height) + ) + val thumbCenterX = thumbSize / 2 + (trackWidth * state.coercedValueAsFraction).roundToInt() + + layout(width, height) { + track.place( + x = thumbSize / 2, + y = (height - track.height) / 2 + ) + thumb.place( + x = thumbCenterX - thumb.width / 2, + y = (height - thumb.height) / 2 + ) + } + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt new file mode 100644 index 00000000..3adece8b --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SliderTest.kt @@ -0,0 +1,288 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class) +class SliderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun singlePressChangesValueAndFinishesOnce() { + val focusRequester = FocusRequester() + val changes = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = 0.2f, + onValueChange = changes::add, + modifier = modifier, + keyStep = 0.1f, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(1, changes.size) + assertEquals(0.3f, changes.single(), 0.0001f) + assertEquals(1, finished.size) + } + + @Test + fun heldKeyAccumulatesRepeatStepsOnSliderState() { + val focusRequester = FocusRequester() + val state = SliderState(value = 0.2f) + val changes = mutableListOf() + val draggingDuringChanges = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + state = state, + onValueChange = { + changes += it + draggingDuringChanges += state.isDragging + }, + modifier = modifier, + keyStep = 0.3f, + keyRepeatStep = 0.1f, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 2)) + + assertEquals(3, changes.size) + assertEquals(0.5f, changes[0], 0.0001f) + assertEquals(0.6f, changes[1], 0.0001f) + assertEquals(0.7f, changes[2], 0.0001f) + assertEquals(listOf(false, true, true), draggingDuringChanges) + assertTrue(state.isDragging) + assertEquals(0, finished.size) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + } + + @Test + fun valueClampsAtBothRangeEnds() { + val focusRequester = FocusRequester() + val value = mutableFloatStateOf(0f) + val changes = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = value.floatValue, + onValueChange = changes::add, + modifier = modifier, + keyStep = 0.1f + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + composeRule.runOnIdle { value.floatValue = 1f } + composeRule.waitForIdle() + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertEquals(2, changes.size) + assertEquals(0f, changes[0], 0f) + assertEquals(1f, changes[1], 0f) + } + + @Test + fun disabledSliderDoesNotConsumeKeysOrCallCallbacks() { + val focusRequester = FocusRequester() + val changes = mutableListOf() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + value = 0.5f, + onValueChange = changes::add, + modifier = modifier, + enabled = false, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_DOWN)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN)) + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + + assertTrue(changes.isEmpty()) + assertTrue(finished.isEmpty()) + } + + @Test + fun losingFocusDuringDragFinishesOnce() { + val focusRequester = FocusRequester() + val otherFocusRequester = FocusRequester() + val state = SliderState() + val finished = mutableListOf() + setSlider( + modifier = Modifier.focusRequester(focusRequester), + otherFocusRequester = otherFocusRequester + ) { modifier -> + Slider( + state = state, + onValueChange = {}, + modifier = modifier, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + composeRule.runOnIdle { otherFocusRequester.requestFocus() } + composeRule.waitForIdle() + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + } + + @Test + fun disablingDuringDragFinishesOnce() { + val focusRequester = FocusRequester() + val enabled = mutableStateOf(true) + val state = SliderState() + val finished = mutableListOf() + setSlider(Modifier.focusRequester(focusRequester)) { modifier -> + Slider( + state = state, + onValueChange = {}, + modifier = modifier, + enabled = enabled.value, + onValueChangeFinished = { finished += Unit } + ) + } + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(state.isDragging) + composeRule.runOnIdle { enabled.value = false } + composeRule.waitForIdle() + + assertFalse(state.isDragging) + assertEquals(1, finished.size) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.ACTION_UP)) + assertEquals(1, finished.size) + } + + @Test + fun valueOverloadFollowsExternalValueWhenNotDragging() { + val value = mutableFloatStateOf(0.2f) + setSlider { modifier -> + Slider( + value = value.floatValue, + onValueChange = {}, + modifier = modifier, + thumb = { state -> + Box( + Modifier + .size(1.dp) + .semantics { contentDescription = "slider-value-${state.value}" } + ) + } + ) + } + + composeRule.onNodeWithContentDescription("slider-value-0.2").assertExists() + composeRule.runOnIdle { value.floatValue = 0.75f } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription("slider-value-0.75").assertExists() + } + + @Test + fun sliderIsFocusable() { + val focusRequester = FocusRequester() + setSlider(Modifier.focusRequester(focusRequester).testTag("slider")) { modifier -> + Slider(value = 0.5f, onValueChange = {}, modifier = modifier) + } + + requestFocus(focusRequester) + + composeRule.onNodeWithTag("slider").assertIsFocused() + } + + private fun setSlider( + modifier: Modifier = Modifier, + otherFocusRequester: FocusRequester? = null, + content: @Composable (Modifier) -> Unit + ) { + composeRule.setContent { + THEOplayerTvTheme { + Column { + content(modifier) + if (otherFocusRequester != null) { + Box( + Modifier + .size(16.dp) + .focusRequester(otherFocusRequester) + .focusable() + ) + } + } + } + } + composeRule.waitForIdle() + } + + private fun requestFocus(focusRequester: FocusRequester) { + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey( + keyCode: Int, + action: Int, + repeat: Int = 0 + ): Boolean = dispatchKeyEvent( + composeRule.activity, + KeyEvent(1_000L, eventTime++, action, keyCode, repeat) + ) + + private var eventTime = 1_001L +} From 4d140abc988aade980c63d3820a10fde5f69a954 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:40:32 +0000 Subject: [PATCH 08/11] Port Material3's indeterminate spinner to LoadingSpinner --- .../ui/tv/CircularProgressIndicator.kt | 222 ++++++++++++++++++ .../android/ui/tv/LoadingSpinner.kt | 83 +++---- 2 files changed, 250 insertions(+), 55 deletions(-) create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt new file mode 100644 index 00000000..63976d2c --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CircularProgressIndicator.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.theoplayer.android.ui.tv + +// Adapted from androidx.compose.material3.ProgressIndicator: +// https://android.googlesource.com/platform/frameworks/support/+/080d2b3e5326ba80392d93442c4a51a02dc22650/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ProgressIndicator.kt + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.min + +/** + * [Material Design determinate circular progress + * indicator](https://m3.material.io/components/progress-indicators/overview) + * + * Progress indicators express an unspecified wait time or display the duration of a process. + * + * ![Circular progress indicator + * image](https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Flqdiyyvh-1P-progress-indicator-configurations.png?alt=media) + * + * @sample androidx.compose.material3.samples.IndeterminateCircularProgressIndicatorSample + * @param modifier the [Modifier] to be applied to this progress indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param trackColor color of the track behind the indicator, visible when the progress has not + * reached the area of the overall indicator yet + * @param strokeCap stroke cap to use for the ends of this progress indicator + * @param gapSize size of the gap between the progress indicator and the track + */ +@Composable +internal fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color, + strokeWidth: Dp, + trackColor: Color = Color.Transparent, + strokeCap: StrokeCap = StrokeCap.Round, + gapSize: Dp = CircularIndicatorTrackGapSize, +) { + val stroke = with(LocalDensity.current) { Stroke(width = strokeWidth.toPx(), cap = strokeCap) } + + val infiniteTransition = rememberInfiniteTransition() + // A global rotation that does a 360 degrees rotation in 6 seconds. + val globalRotation = + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = CircularGlobalRotationDegreesTarget, + animationSpec = circularIndeterminateGlobalRotationAnimationSpec, + ) + + // An additional rotation that moves by 90 degrees in 500ms and then rest for 1 second. + val additionalRotation = + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = CircularAdditionalRotationDegreesTarget, + animationSpec = circularIndeterminateRotationAnimationSpec, + ) + + // Indicator progress animation that will be changing the progress up and down as the indicator + // rotates. + val progressAnimation = + infiniteTransition.animateFloat( + initialValue = CircularIndeterminateMinProgress, + targetValue = CircularIndeterminateMaxProgress, + animationSpec = circularIndeterminateProgressAnimationSpec, + ) + + Canvas(modifier.progressSemantics().size(CircularIndicatorDiameter)) { + val sweep = progressAnimation.value * 360f + val adjustedGapSize = + if (strokeCap == StrokeCap.Butt || size.height > size.width) { + gapSize + } else { + gapSize + strokeWidth + } + val gapSizeSweep = (adjustedGapSize.value / (PI * size.width.toDp().value).toFloat()) * 360f + + rotate(globalRotation.value + additionalRotation.value) { + drawCircularIndicator( + sweep + min(sweep, gapSizeSweep), + 360f - sweep - min(sweep, gapSizeSweep) * 2, + trackColor, + stroke, + ) + drawDeterminateCircularIndicator(startAngle = 0f, sweep, color, stroke) + } + } +} + +private fun DrawScope.drawCircularIndicator( + startAngle: Float, + sweep: Float, + color: Color, + stroke: Stroke, +) { + // To draw this circle we need a rect with edges that line up with the midpoint of the stroke. + // To do this we need to remove half the stroke width from the total diameter for both sides. + val diameterOffset = stroke.width / 2 + val arcDimen = size.width - 2 * diameterOffset + drawArc( + color = color, + startAngle = startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(diameterOffset, diameterOffset), + size = Size(arcDimen, arcDimen), + style = stroke, + ) +} + +private fun DrawScope.drawDeterminateCircularIndicator( + startAngle: Float, + sweep: Float, + color: Color, + stroke: Stroke, +) = drawCircularIndicator(startAngle, sweep, color, stroke) + +/** A global animation spec for indeterminate circular progress indicator. */ +private val circularIndeterminateGlobalRotationAnimationSpec + get() = + infiniteRepeatable( + animation = tween(CircularAnimationProgressDuration, easing = LinearEasing) + ) + +/** + * An animation spec for indeterminate circular progress indicators that infinitely rotates a 360 + * degrees. + */ +private val circularIndeterminateRotationAnimationSpec + get() = + infiniteRepeatable( + animation = + keyframes { + durationMillis = CircularAnimationProgressDuration // 6000ms + 90f at + CircularAnimationAdditionalRotationDuration using + CircularIndeterminateRotationEasing // 300ms + 90f at CircularAnimationAdditionalRotationDelay // hold till 1500ms + 180f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay // 1800ms + 180f at CircularAnimationAdditionalRotationDelay * 2 // hold till 3000ms + 270f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay * 2 // 3300ms + 270f at CircularAnimationAdditionalRotationDelay * 3 // hold till 4500ms + 360f at + CircularAnimationAdditionalRotationDuration + + CircularAnimationAdditionalRotationDelay * 3 // 4800ms + 360f at CircularAnimationProgressDuration // hold till 6000ms + } + ) + +/** An animation spec for indeterminate circular progress indicators progress motion. */ +private val circularIndeterminateProgressAnimationSpec + get() = + infiniteRepeatable( + animation = + keyframes { + durationMillis = CircularAnimationProgressDuration // 6000ms + CircularIndeterminateMaxProgress at + CircularAnimationProgressDuration / 2 using + CircularProgressEasing // 3000ms + CircularIndeterminateMinProgress at CircularAnimationProgressDuration + } + ) + +// CircularProgressIndicator Material specs +// Diameter of the indicator circle +/*@VisibleForTesting*/ +private val CircularIndicatorDiameter = 40.dp // CircularProgressIndicatorTokens.Size +private val CircularIndicatorTrackGapSize = 4.dp // CircularProgressIndicatorTokens.TrackActiveSpace + +// Indeterminate circular indicator transition specs + +// The indeterminate circular indicator easing constants for its motion +private val CircularProgressEasing = + CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) // MotionTokens.EasingStandardCubicBezier +private val CircularIndeterminateRotationEasing = + CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) // MotionTokens.EasingEmphasizedDecelerateCubicBezier +private const val CircularIndeterminateMinProgress = 0.1f +private const val CircularIndeterminateMaxProgress = 0.87f + +private const val CircularAnimationProgressDuration = 6000 +private const val CircularAnimationAdditionalRotationDelay = 1500 +private const val CircularAnimationAdditionalRotationDuration = 300 +private const val CircularAdditionalRotationDegreesTarget = 360f +private const val CircularGlobalRotationDegreesTarget = 1080f diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt index 9e89fa0a..7596aaa6 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -1,26 +1,12 @@ package com.theoplayer.android.ui.tv -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.size +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -31,10 +17,16 @@ import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.rememberLoadingState import com.theoplayer.android.ui.theme.LocalPlayerAnimations import kotlin.time.Duration -import kotlinx.coroutines.delay as delayMillis +import kotlin.time.DurationUnit /** - * Shows a spinner while the player is loading. + * An indicator that shows whether the player is currently waiting for more data to resume playback. + * + * @param modifier the [Modifier] to be applied to this indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param delay delay for the progress indicator to become visible after the player starts buffering. + * In general, it's less jarring to *not* show the indicator for very short buffering periods. */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -55,7 +47,14 @@ fun LoadingSpinner( } /** - * Shows a spinner while [state] is loading. + * An indicator that shows whether the player is currently waiting for more data to resume playback. + * + * @param state the [LoadingState] to read + * @param modifier the [Modifier] to be applied to this indicator + * @param color color of this progress indicator + * @param strokeWidth stroke width of this progress indicator + * @param delay delay for the progress indicator to become visible after the player starts buffering. + * In general, it's less jarring to *not* show the indicator for very short buffering periods. */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi @@ -68,39 +67,13 @@ fun LoadingSpinner( strokeWidth: Dp = 4.dp, delay: Duration = LocalPlayerAnimations.current.loadingSpinnerDelay ) { - val inspectionMode = LocalInspectionMode.current - var delayElapsed by remember(state, delay) { mutableStateOf(false) } - LaunchedEffect(state.loading, delay) { - delayElapsed = false - if (state.loading && !inspectionMode) { - delayMillis(delay.inWholeMilliseconds.coerceAtLeast(0)) - delayElapsed = true - } - } - - if (state.loading && (inspectionMode || delayElapsed)) { - val transition = rememberInfiniteTransition(label = "loading-spinner") - val rotation by transition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1_000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "rotation" - ) - Canvas(modifier = modifier.size(40.dp)) { - val stroke = strokeWidth.toPx() - val inset = stroke / 2 - drawArc( - color = color, - startAngle = rotation - 90f, - sweepAngle = 270f, - useCenter = false, - topLeft = Offset(inset, inset), - size = Size(size.width - stroke, size.height - stroke), - style = Stroke(width = stroke, cap = StrokeCap.Round) - ) - } + AnimatedVisibility( + visible = state.loading, + // Show the loading spinner only after a small delay + enter = fadeIn(snap(delayMillis = delay.toInt(DurationUnit.MILLISECONDS))), + // Hide the loading spinner immediately when loaded + exit = ExitTransition.None + ) { + CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } From 6c49666cfe27d0671ed27a93477bb88ad8728eef Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:44:13 +0000 Subject: [PATCH 09/11] Add disabled slider colors and document TV controls --- .../android/ui/tv/CurrentTimeDisplay.kt | 17 ++- .../android/ui/tv/DurationDisplay.kt | 9 +- .../theoplayer/android/ui/tv/ErrorDisplay.kt | 13 +- .../theoplayer/android/ui/tv/LiveButton.kt | 29 +++- .../theoplayer/android/ui/tv/PlayButton.kt | 21 ++- .../com/theoplayer/android/ui/tv/SeekBar.kt | 33 ++++- .../com/theoplayer/android/ui/tv/Slider.kt | 124 +++++++++++++++--- 7 files changed, 214 insertions(+), 32 deletions(-) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt index 8294d826..6f3ac44d 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt @@ -13,7 +13,13 @@ import com.theoplayer.android.ui.formatPlaybackTime import com.theoplayer.android.ui.rememberPlaybackTimeState /** - * Displays the player's current time. + * A text display that shows the player's current time. + * + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -32,7 +38,14 @@ fun CurrentTimeDisplay( } /** - * Displays playback time from [state]. + * A text display that shows the player's current time. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt index 164dff4c..ff5ab0b0 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt @@ -11,7 +11,9 @@ import com.theoplayer.android.ui.formatPlaybackTime import com.theoplayer.android.ui.rememberPlaybackTimeState /** - * Displays the player's duration. + * A text display that shows the player's duration. + * + * @param modifier the [Modifier] to be applied to this text */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -24,7 +26,10 @@ fun DurationDisplay(modifier: Modifier = Modifier) { } /** - * Displays the duration from [state]. + * A text display that shows the player's duration. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt index 06ad8e11..23d7214c 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt @@ -27,7 +27,9 @@ import com.theoplayer.android.ui.common.R import com.theoplayer.android.ui.rememberErrorState /** - * Displays a fatal player error, if one occurs. + * A display for a fatal error, if the player encounters one. + * + * @param modifier the [Modifier] to be applied to this display */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -41,7 +43,14 @@ fun ErrorDisplay(modifier: Modifier = Modifier) { } /** - * Displays the error held by [state]. + * A display for a fatal error, if the player encounters one. + * + * The error is only captured from the player's `error` event, so create the [ErrorState] + * *before* an error can happen: at the top of your UI instead of inside the error overlay + * that is only shown once there's an error. + * + * @param state the [ErrorState] to read + * @param modifier the [Modifier] to be applied to this display */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt index 75bb6900..252b238d 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt @@ -27,7 +27,20 @@ import com.theoplayer.android.ui.rememberLiveButtonState import com.theoplayer.android.ui.theme.LocalPlayerColors /** - * Shows live status and seeks to the live point when clicked. + * A button that shows whether the player is currently playing at the live point, + * and seeks to the live point when clicked. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param colors [ButtonColors] that resolve the colors for this button in different states. See + * [ButtonDefaults.colors]. + * @param liveThreshold the maximum distance (in seconds) from the live point that the player's + * current time can be for it to still be considered "at the live point" + * @param live button content when the player is playing at the live point, + * typically a red circle icon followed by the text "LIVE" + * @param dvr button content when the player is playing behind the live point, + * typically a gray circle icon followed by the text "LIVE" */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) @@ -55,7 +68,19 @@ fun LiveButton( } /** - * Shows live status from [state] and seeks to the live point when clicked. + * A button that shows whether the player is currently playing at the live point, + * and seeks to the live point when clicked. + * + * @param state the [LiveButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param colors [ButtonColors] that resolve the colors for this button in different states. See + * [ButtonDefaults.colors]. + * @param live button content when the player is playing at the live point, + * typically a red circle icon followed by the text "LIVE" + * @param dvr button content when the player is playing behind the live point, + * typically a gray circle icon followed by the text "LIVE" */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt index edc27511..80ab1444 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt @@ -19,7 +19,15 @@ import com.theoplayer.android.ui.common.R import com.theoplayer.android.ui.rememberPlayButtonState /** - * A button that toggles playback. + * A button that toggles whether the player is playing or paused. + * + * @param modifier the [Modifier] to be applied to this button + * @param iconModifier the [Modifier] to be applied to the [Icon]s inside this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param play button content when the player is paused but not ended, typically a "play" icon + * @param pause button content when the player is playing, typically a "pause" icon + * @param replay button content when the player is paused and ended, typically a "replay" icon */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class) @@ -62,7 +70,16 @@ fun PlayButton( } /** - * A button that toggles playback using [state]. + * A button that toggles whether the player is playing or paused. + * + * @param state the [PlayButtonState] to read and control + * @param modifier the [Modifier] to be applied to this button + * @param iconModifier the [Modifier] to be applied to the [Icon]s inside this button + * @param contentPadding the spacing values to apply internally between the container and the + * content + * @param play button content when the player is paused but not ended, typically a "play" icon + * @param pause button content when the player is playing, typically a "pause" icon + * @param replay button content when the player is paused and ended, typically a "replay" icon */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt index def51243..fd6004a1 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -10,7 +10,15 @@ import com.theoplayer.android.ui.SeekBarState import com.theoplayer.android.ui.rememberSeekBarState /** - * Displays the player's seek bar. + * A seek bar showing the player's current time and allowing D-pad seeking. + * + * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the + * timeline; playback pauses while scrubbing. + * + * @param modifier the [Modifier] to be applied to this seek bar + * @param colors [SliderColors] that resolve the seek bar colors for enabled and disabled states. + * See [SliderDefaults.colors]. + * @param seekOffset the number of seconds to seek on a single Left or Right press */ @ExperimentalTvUiApi @OptIn(ExperimentalPlayerStateApi::class) @@ -29,7 +37,16 @@ fun SeekBar( } /** - * Displays the seek bar from [state]. + * A seek bar showing the current time from [state] and allowing D-pad seeking. + * + * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the + * timeline; playback pauses while scrubbing. + * + * @param state the [SeekBarState] to read and control + * @param modifier the [Modifier] to be applied to this seek bar + * @param colors [SliderColors] that resolve the seek bar colors for enabled and disabled states. + * See [SliderDefaults.colors]. + * @param seekOffset the number of seconds to seek on a single Left or Right press */ @ExperimentalTvUiApi @ExperimentalPlayerStateApi @@ -60,11 +77,19 @@ fun SeekBar( interactionSource = interactionSource, thumb = { if (state.showThumb) { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = state.enabled + ) } }, track = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track( + sliderState = sliderState, + colors = colors, + enabled = state.enabled + ) } ) } diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt index a9f58c48..1309096e 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt @@ -47,6 +47,9 @@ private const val ThumbLayoutId = "thumb" /** * Holds the value and range for a TV slider. + * + * @param value the initial slider value, constrained to [valueRange] + * @param valueRange the range of values available to the slider */ @ExperimentalTvUiApi @Stable @@ -82,6 +85,22 @@ class SliderState( /** * Displays a focusable slider controlled by D-pad keys. + * + * A single Left or Right press changes the value by [keyStep]. Holding a key changes it by + * [keyRepeatStep] for each repeat. + * + * @param value the current slider value, constrained to [valueRange] + * @param onValueChange called with each value change + * @param modifier the [Modifier] to be applied to this slider + * @param enabled whether the slider responds to user input + * @param valueRange the range of values available to the slider + * @param keyStep the value change for a single Left or Right press + * @param keyRepeatStep the value change for each key repeat while Left or Right is held + * @param onValueChangeFinished called on key-up, when focus is lost, or when the slider is disabled + * @param colors the colors used for enabled and disabled states. See [SliderDefaults.colors]. + * @param interactionSource the [MutableInteractionSource] used to observe focus interactions + * @param thumb the thumb slot, which receives the current [SliderState] + * @param track the track slot, which receives the current [SliderState] */ @ExperimentalTvUiApi @Composable @@ -97,10 +116,14 @@ fun Slider( colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) }, track: @Composable (SliderState) -> Unit = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) } ) { val state = rememberSyncedSliderState(value, valueRange) @@ -120,7 +143,22 @@ fun Slider( } /** - * Displays a slider backed by [state]. + * Displays a slider backed by [state] and controlled by D-pad keys. + * + * A single Left or Right press changes the value by [keyStep]. Holding a key changes it by + * [keyRepeatStep] for each repeat. + * + * @param state the [SliderState] that holds the slider value and range + * @param onValueChange called with each value change + * @param modifier the [Modifier] to be applied to this slider + * @param enabled whether the slider responds to user input + * @param keyStep the value change for a single Left or Right press + * @param keyRepeatStep the value change for each key repeat while Left or Right is held + * @param onValueChangeFinished called on key-up, when focus is lost, or when the slider is disabled + * @param colors the colors used for enabled and disabled states. See [SliderDefaults.colors]. + * @param interactionSource the [MutableInteractionSource] used to observe focus interactions + * @param thumb the thumb slot, which receives the current [SliderState] + * @param track the track slot, which receives the current [SliderState] */ @ExperimentalTvUiApi @Composable @@ -135,10 +173,14 @@ fun Slider( colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumb: @Composable (SliderState) -> Unit = { - SliderDefaults.Thumb(interactionSource = interactionSource, colors = colors) + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) }, track: @Composable (SliderState) -> Unit = { sliderState -> - SliderDefaults.Track(sliderState = sliderState, colors = colors) + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) } ) { val range = state.valueRange @@ -192,18 +234,36 @@ internal fun rememberSyncedSliderState( } /** - * Colors used to draw a TV slider. + * Colors used to draw a TV slider in enabled and disabled states. */ @ExperimentalTvUiApi @Immutable class SliderColors( - /** Thumb color. */ + /** Thumb color when enabled. */ val thumbColor: Color, - /** Active track color. */ + /** Active track color when enabled. */ val activeTrackColor: Color, - /** Inactive track color. */ - val inactiveTrackColor: Color -) + /** Inactive track color when enabled. */ + val inactiveTrackColor: Color, + /** Thumb color when disabled. */ + val disabledThumbColor: Color, + /** Active track color when disabled. */ + val disabledActiveTrackColor: Color, + /** Inactive track color when disabled. */ + val disabledInactiveTrackColor: Color +) { + /** Returns the thumb color for [enabled]. */ + internal fun thumbColor(enabled: Boolean): Color = + if (enabled) thumbColor else disabledThumbColor + + /** Returns the active track color for [enabled]. */ + internal fun activeTrackColor(enabled: Boolean): Color = + if (enabled) activeTrackColor else disabledActiveTrackColor + + /** Returns the inactive track color for [enabled]. */ + internal fun inactiveTrackColor(enabled: Boolean): Color = + if (enabled) inactiveTrackColor else disabledInactiveTrackColor +} /** * Creates slider colors and default slider content. @@ -212,27 +272,49 @@ class SliderColors( object SliderDefaults { /** * Creates a slider color set from the current TV theme. + * + * @param thumbColor thumb color when enabled + * @param activeTrackColor active track color when enabled + * @param inactiveTrackColor inactive track color when enabled + * @param disabledThumbColor thumb color when disabled + * @param disabledActiveTrackColor active track color when disabled + * @param disabledInactiveTrackColor inactive track color when disabled */ @Composable fun colors( thumbColor: Color = MaterialTheme.colorScheme.primary, activeTrackColor: Color = MaterialTheme.colorScheme.primary, inactiveTrackColor: Color = - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.24f), + disabledThumbColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledActiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledInactiveTrackColor: Color = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) ): SliderColors = SliderColors( thumbColor = thumbColor, activeTrackColor = activeTrackColor, - inactiveTrackColor = inactiveTrackColor + inactiveTrackColor = inactiveTrackColor, + disabledThumbColor = disabledThumbColor, + disabledActiveTrackColor = disabledActiveTrackColor, + disabledInactiveTrackColor = disabledInactiveTrackColor ) /** * Displays a slider thumb that grows when focused. + * + * @param interactionSource the interaction source used to observe focus + * @param modifier the [Modifier] to be applied to the thumb + * @param colors the colors used for the thumb in enabled and disabled states + * @param enabled whether the slider is enabled */ @Composable fun Thumb( interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, - colors: SliderColors = colors() + colors: SliderColors = colors(), + enabled: Boolean = true ) { val focused by interactionSource.collectIsFocusedAsState() val radius by animateDpAsState( @@ -242,7 +324,7 @@ object SliderDefaults { Canvas(modifier.size(ThumbSize)) { drawCircle( - color = colors.thumbColor, + color = colors.thumbColor(enabled), radius = radius.toPx() ) } @@ -250,26 +332,32 @@ object SliderDefaults { /** * Displays a slider track with active and inactive sections. + * + * @param sliderState the state used to determine the active track length + * @param modifier the [Modifier] to be applied to the track + * @param colors the colors used for the track in enabled and disabled states + * @param enabled whether the slider is enabled */ @Composable fun Track( sliderState: SliderState, modifier: Modifier = Modifier, - colors: SliderColors = colors() + colors: SliderColors = colors(), + enabled: Boolean = true ) { Canvas(modifier.fillMaxWidth().height(4.dp)) { val centerY = size.height / 2 val end = Offset(size.width, centerY) val strokeWidth = 4.dp.toPx() drawLine( - color = colors.inactiveTrackColor, + color = colors.inactiveTrackColor(enabled), start = Offset(0f, centerY), end = end, strokeWidth = strokeWidth, cap = StrokeCap.Round ) drawLine( - color = colors.activeTrackColor, + color = colors.activeTrackColor(enabled), start = Offset(0f, centerY), end = Offset(size.width * sliderState.coercedValueAsFraction, centerY), strokeWidth = strokeWidth, From db8ff89e57d265f377c91726c00959f5b0b204eb Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 17:48:09 +0000 Subject: [PATCH 10/11] Add CircularProgressIndicator preview --- .../com/theoplayer/android/ui/tv/LoadingSpinner.kt | 11 +++++++++++ .../java/com/theoplayer/android/ui/LoadingSpinner.kt | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt index 7596aaa6..f787c914 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme @@ -16,6 +17,7 @@ import com.theoplayer.android.ui.LoadingState import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.rememberLoadingState import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme import kotlin.time.Duration import kotlin.time.DurationUnit @@ -77,3 +79,12 @@ fun LoadingSpinner( CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } + +@OptIn(ExperimentalTvUiApi::class) +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun CircularProgressIndicatorPreview() { + THEOplayerTvTheme { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary, strokeWidth = 4.dp) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt index 330477a0..add24ee4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LoadingSpinner.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.theoplayer.android.ui.theme.THEOplayerTheme import kotlin.time.Duration @@ -68,3 +69,11 @@ fun LoadingSpinner( CircularProgressIndicator(modifier = modifier, color = color, strokeWidth = strokeWidth) } } + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun CircularProgressIndicatorPreview() { + THEOplayerTheme(useDarkTheme = true) { + CircularProgressIndicator() + } +} From e84a46e1fa9647160282af0ae4fb16a80eff71cc Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 18:04:26 +0000 Subject: [PATCH 11/11] Ignore ui-tv build output Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui-tv/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 ui-tv/.gitignore diff --git a/ui-tv/.gitignore b/ui-tv/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/ui-tv/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file