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) 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/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/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..65cc7d12 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/Color.kt @@ -0,0 +1,126 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.ui.graphics.Color +import com.theoplayer.android.ui.InternalUiApi + +@InternalUiApi +val md_theme_light_primary = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_primaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_light_onPrimaryContainer = Color(0xFF251A00) +@InternalUiApi +val md_theme_light_secondary = Color(0xFF6A5D3F) +@InternalUiApi +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_secondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_light_onSecondaryContainer = Color(0xFF241A04) +@InternalUiApi +val md_theme_light_tertiary = Color(0xFF496548) +@InternalUiApi +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_tertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_light_onTertiaryContainer = Color(0xFF06210A) +@InternalUiApi +val md_theme_light_error = Color(0xFFBA1A1A) +@InternalUiApi +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_light_onError = Color(0xFFFFFFFF) +@InternalUiApi +val md_theme_light_onErrorContainer = Color(0xFF410002) +@InternalUiApi +val md_theme_light_background = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onBackground = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surface = Color(0xFFFFFBFF) +@InternalUiApi +val md_theme_light_onSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_light_surfaceVariant = Color(0xFFECE1CF) +@InternalUiApi +val md_theme_light_onSurfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_light_outline = Color(0xFF7E7667) +@InternalUiApi +val md_theme_light_inverseOnSurface = Color(0xFFF7F0E7) +@InternalUiApi +val md_theme_light_inverseSurface = Color(0xFF33302A) +@InternalUiApi +val md_theme_light_inversePrimary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_light_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_light_surfaceTint = Color(0xFF775A00) +@InternalUiApi +val md_theme_light_outlineVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_light_scrim = Color(0xFF000000) + +@InternalUiApi +val md_theme_dark_primary = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_onPrimary = Color(0xFF3F2E00) +@InternalUiApi +val md_theme_dark_primaryContainer = Color(0xFF5A4300) +@InternalUiApi +val md_theme_dark_onPrimaryContainer = Color(0xFFFFDF98) +@InternalUiApi +val md_theme_dark_secondary = Color(0xFFD7C5A0) +@InternalUiApi +val md_theme_dark_onSecondary = Color(0xFF3A2F15) +@InternalUiApi +val md_theme_dark_secondaryContainer = Color(0xFF52452A) +@InternalUiApi +val md_theme_dark_onSecondaryContainer = Color(0xFFF4E0BB) +@InternalUiApi +val md_theme_dark_tertiary = Color(0xFFAFCFAB) +@InternalUiApi +val md_theme_dark_onTertiary = Color(0xFF1C361D) +@InternalUiApi +val md_theme_dark_tertiaryContainer = Color(0xFF324D32) +@InternalUiApi +val md_theme_dark_onTertiaryContainer = Color(0xFFCBEBC6) +@InternalUiApi +val md_theme_dark_error = Color(0xFFFFB4AB) +@InternalUiApi +val md_theme_dark_errorContainer = Color(0xFF93000A) +@InternalUiApi +val md_theme_dark_onError = Color(0xFF690005) +@InternalUiApi +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +@InternalUiApi +val md_theme_dark_background = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onBackground = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_onSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_surfaceVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_onSurfaceVariant = Color(0xFFD0C5B4) +@InternalUiApi +val md_theme_dark_outline = Color(0xFF999080) +@InternalUiApi +val md_theme_dark_inverseOnSurface = Color(0xFF1E1B16) +@InternalUiApi +val md_theme_dark_inverseSurface = Color(0xFFE9E1D9) +@InternalUiApi +val md_theme_dark_inversePrimary = Color(0xFF775A00) +@InternalUiApi +val md_theme_dark_shadow = Color(0xFF000000) +@InternalUiApi +val md_theme_dark_surfaceTint = Color(0xFFFFC50F) +@InternalUiApi +val md_theme_dark_outlineVariant = Color(0xFF4D4639) +@InternalUiApi +val md_theme_dark_scrim = Color(0xFF000000) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/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/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-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) 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 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/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/CurrentTimeDisplay.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt new file mode 100644 index 00000000..6f3ac44d --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/CurrentTimeDisplay.kt @@ -0,0 +1,84 @@ +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 + +/** + * 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) +@Composable +fun CurrentTimeDisplay( + modifier: Modifier = Modifier, + showRemaining: Boolean = false, + showDuration: Boolean = false +) { + CurrentTimeDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier, + showRemaining = showRemaining, + showDuration = showDuration + ) +} + +/** + * A text display that shows the player's current time. + * + * @param state the [PlaybackTimeState] to read + * @param modifier the [Modifier] to be applied to this text + * @param showRemaining if set, shows the remaining time of the stream as a negative value + * (e.g. "-01:23") + * @param showDuration if set, also shows the duration of the stream + * (e.g. "01:23 / 10:00") + */ +@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..ff5ab0b0 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/DurationDisplay.kt @@ -0,0 +1,40 @@ +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 + +/** + * 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) +@Composable +fun DurationDisplay(modifier: Modifier = Modifier) { + DurationDisplay( + state = rememberPlaybackTimeState(LocalTHEOplayerView.current?.player), + modifier = modifier + ) +} + +/** + * 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 +@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..23d7214c --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ErrorDisplay.kt @@ -0,0 +1,83 @@ +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 + +/** + * 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) +@Composable +fun ErrorDisplay(modifier: Modifier = Modifier) { + ErrorDisplay( + state = LocalUIControllerErrorState.current + ?: rememberErrorState(LocalTHEOplayerView.current?.player, Player.current?.error), + modifier = modifier + ) +} + +/** + * A display for a fatal error, if the player encounters one. + * + * The error is only captured from the player's `error` event, so create the [ErrorState] + * *before* an error can happen: at the top of your UI instead of inside the error overlay + * that is only shown once there's an error. + * + * @param state the [ErrorState] to read + * @param modifier the [Modifier] to be applied to this display + */ +@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..252b238d --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LiveButton.kt @@ -0,0 +1,128 @@ +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 + +/** + * 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) +@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 + ) +} + +/** + * 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 +@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..f787c914 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LoadingSpinner.kt @@ -0,0 +1,90 @@ +package com.theoplayer.android.ui.tv + +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.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 +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 com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import kotlin.time.Duration +import kotlin.time.DurationUnit + +/** + * 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) +@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 + ) +} + +/** + * 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 +@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 +) { + 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) + } +} + +@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-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..80ab1444 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlayButton.kt @@ -0,0 +1,123 @@ +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 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) +@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 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 +@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..fd6004a1 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -0,0 +1,95 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.SeekBarState +import com.theoplayer.android.ui.rememberSeekBarState + +/** + * 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) +@Composable +fun SeekBar( + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors(), + seekOffset: Int = 10 +) { + SeekBar( + state = rememberSeekBarState(LocalTHEOplayerView.current?.player), + modifier = modifier, + colors = colors, + seekOffset = seekOffset + ) +} + +/** + * 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 +@Composable +fun SeekBar( + state: SeekBarState, + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors(), + seekOffset: Int = 10 +) { + val interactionSource = remember { MutableInteractionSource() } + val sliderState = rememberSyncedSliderState(state.value, state.valueRange) + Slider( + state = sliderState, + onValueChange = { value -> + if (sliderState.isDragging) { + state.onValueChange(value) + } else { + state.seekTo(value) + } + }, + 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, + enabled = state.enabled + ) + } + }, + track = { sliderState -> + 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 new file mode 100644 index 00000000..1309096e --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Slider.kt @@ -0,0 +1,479 @@ +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. + * + * @param value the initial slider value, constrained to [valueRange] + * @param valueRange the range of values available to the 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. + * + * 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 +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, + enabled = enabled + ) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) + } +) { + 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] 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 +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, + enabled = enabled + ) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(sliderState = sliderState, colors = colors, enabled = enabled) + } +) { + 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 in enabled and disabled states. + */ +@ExperimentalTvUiApi +@Immutable +class SliderColors( + /** Thumb color when enabled. */ + val thumbColor: Color, + /** Active track color when enabled. */ + val activeTrackColor: 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. + */ +@ExperimentalTvUiApi +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), + 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, + 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(), + enabled: Boolean = true + ) { + 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(enabled), + radius = radius.toPx() + ) + } + } + + /** + * 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(), + 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(enabled), + start = Offset(0f, centerY), + end = end, + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + drawLine( + color = colors.activeTrackColor(enabled), + 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/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-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/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 +} 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) ) 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/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() + } +} 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), 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. *