diff --git a/CHANGELOG.md b/CHANGELOG.md index 9840501e..f8d022cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ * 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) * 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) * 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124)) -* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125)) +* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125), [#126](https://github.com/THEOplayer/android-ui/pull/126)) ## [1.14.2] (2026-10-13) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt new file mode 100644 index 00000000..b15de631 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt @@ -0,0 +1,111 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.ui.common.R as CommonR +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.ui.util.constructLabel +import java.text.DecimalFormat + +/** + * Returns a human-readable label for the given media track or text track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: Track): String { + return remember(track.id, track.uid) { + when (track) { + is TextTrack -> constructLabel(track) + is MediaTrack<*> -> constructLabel(track) + else -> null + } + } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * Returns a human-readable label for the given media track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: MediaTrack<*>): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * Returns a human-readable label for the given text track. + */ +@InternalUiApi +@Composable +fun formatTrackName(track: TextTrack): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) +} + +/** + * The default playback rates shown in playback rate lists. + */ +@InternalUiApi +val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) + +/** + * Formats a playback rate for display. + */ +@InternalUiApi +@Composable +fun formatPlaybackRate(rate: Double): String { + return if (rate == 1.0) { + stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) + } else { + val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) + val decimalFormat = remember(format) { DecimalFormat(format) } + decimalFormat.format(rate) + } +} + +/** + * Formats a bandwidth for display. + */ +@InternalUiApi +@Composable +fun formatBandwidth(bandwidth: Long): String { + val format = stringResource( + if (bandwidth >= 1e7) { + CommonR.string.theoplayer_ui_bandwidth_format_10mbps + } else if (bandwidth >= 1e6) { + CommonR.string.theoplayer_ui_bandwidth_format_1mbps + } else { + CommonR.string.theoplayer_ui_bandwidth_format_kbps + } + ) + val decimalFormat = remember(format) { DecimalFormat(format) } + return decimalFormat.format(bandwidth / (if (bandwidth >= 1e6) 1e6 else 1e3)) +} + +/** + * Formats the active or target video quality for display. + */ +@InternalUiApi +@Composable +fun formatActiveQualityLabel( + targetVideoQuality: VideoQuality?, + activeVideoQuality: VideoQuality? +): String { + return if (targetVideoQuality != null) { + stringResource( + CommonR.string.theoplayer_ui_quality_with_height, + targetVideoQuality.height + ) + } else if (activeVideoQuality != null) { + stringResource( + CommonR.string.theoplayer_ui_quality_automatic_with_height, + activeVideoQuality.height + ) + } else { + stringResource(CommonR.string.theoplayer_ui_quality_automatic) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt similarity index 88% rename from ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index a6fd6857..2e65ec79 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -2,6 +2,7 @@ package com.theoplayer.android.ui.util import androidx.annotation.CheckResult import androidx.annotation.IntRange +import com.theoplayer.android.ui.InternalUiApi private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() @@ -9,7 +10,8 @@ private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() * Checks whether a provided label is CEA-608 or CEA-708 formed. */ @CheckResult -internal fun isLabelCeaFormatted(label: String?): Boolean { +@InternalUiApi +fun isLabelCeaFormatted(label: String?): Boolean { if (label.isNullOrEmpty()) { return false } @@ -33,7 +35,8 @@ internal fun isLabelCeaFormatted(label: String?): Boolean { * "CC" suffix, or `null` if the channel number is invalid. */ @CheckResult -internal fun getLabelForChannelNumber( +@InternalUiApi +fun getLabelForChannelNumber( @IntRange(from = 0L, to = 63L) channelNumber: Int, ): String? { // CEA-608 only supports channel numbers in [1, 4], diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt similarity index 92% rename from ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt index 164ed51b..4a38ad6f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -2,11 +2,13 @@ package com.theoplayer.android.ui.util import androidx.annotation.DoNotInline import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.ui.InternalUiApi /** * Returns [TextTrack.getCaptionChannel], if available. */ -internal val TextTrack.captionChannelCompat: Int? +@InternalUiApi +val TextTrack.captionChannelCompat: Int? get() { // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. return if (THEOplayerGlobalExt.version >= version1013) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt similarity index 89% rename from ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index b2bed135..f9af8e5e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -5,6 +5,7 @@ import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import com.theoplayer.android.ui.InternalUiApi import java.util.Locale private const val LANGUAGE_UNDEFINED = "und" @@ -18,7 +19,8 @@ private const val LANGUAGE_UNDEFINED = "und" * returns `null`. */ @get:CheckResult -internal val Track.localizedLanguageName: String? +@InternalUiApi +val Track.localizedLanguageName: String? get() { val languageCode = this.language ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } @@ -31,7 +33,8 @@ internal val Track.localizedLanguageName: String? /** * Constructs a label for the given [MediaTrack] instance. */ -internal fun constructLabel(track: MediaTrack<*>): String? { +@InternalUiApi +fun constructLabel(track: MediaTrack<*>): String? { return track.label?.takeUnless { it.isBlank() } ?: track.localizedLanguageName } @@ -39,7 +42,8 @@ internal fun constructLabel(track: MediaTrack<*>): String? { /** * Constructs a label for the given [TextTrack] instance. */ -internal fun constructLabel(track: TextTrack): String? { +@InternalUiApi +fun constructLabel(track: TextTrack): String? { val label = track.label?.takeIf { when { // Ignore empty labels. diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt similarity index 92% rename from ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt rename to ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index d654e984..1bdeccd3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,13 +1,15 @@ package com.theoplayer.android.ui.util import com.theoplayer.android.api.THEOplayerGlobal +import com.theoplayer.android.ui.InternalUiApi private const val VERSION_DELIMITER = '.' /** * A [semver](https://semver.org/) version. */ -internal data class Version( +@InternalUiApi +data class Version( /** * The major version. */ @@ -54,7 +56,8 @@ internal data class Version( } } -internal object THEOplayerGlobalExt { +@InternalUiApi +object THEOplayerGlobalExt { /** * Returns the version of THEOplayer, as a [Version]. */ diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt new file mode 100644 index 00000000..f8b4ca12 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt @@ -0,0 +1,94 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberAudioTrackState + +/** + * A list of audio tracks, from which the user can choose an active audio track. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when an audio track in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun AudioTrackList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + AudioTrackList( + state = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of audio tracks, from which the user can choose an active audio track. + * + * @param state the [AudioTrackState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when an audio track in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun AudioTrackList( + state: AudioTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.audioTracks.indexOf(state.activeAudioTrack).coerceAtLeast(0) + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) + ) { + items( + count = state.audioTracks.size, + key = { state.audioTracks[it].uid } + ) { index -> + val audioTrack = state.audioTracks[index] + val isActive = state.activeAudioTrack == audioTrack + ListItem( + selected = isActive, + onClick = { + state.select(audioTrack) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { Text(text = formatTrackName(audioTrack)) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (index == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt new file mode 100644 index 00000000..58676119 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenu.kt @@ -0,0 +1,154 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Audiotrack +import androidx.compose.material.icons.rounded.ClosedCaption +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberAudioTrackState +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A menu to change the spoken language and subtitles of the stream. + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.LanguageMenu() { + LanguageMenu( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A menu to change the spoken language and subtitles of the stream. + * + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param audioTrackState the [AudioTrackState] to read + * @param subtitleTrackState the [SubtitleTrackState] to read + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenu( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_language)) } + ) { + if (showAudioTracks(audioTrackState)) { + MenuSubmenuItem( + onClick = { openMenu { AudioTrackMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) + }, + leadingContent = { + Icon(Icons.Rounded.Audiotrack, contentDescription = null) + }, + supportingContent = { + Text( + text = audioTrackState.activeAudioTrack?.let { formatTrackName(it) } + ?: stringResource(CommonR.string.theoplayer_ui_audio_none) + ) + } + ) + } + if (showSubtitleTracks(subtitleTrackState)) { + MenuSubmenuItem( + onClick = { openMenu { SubtitleMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) + }, + leadingContent = { + Icon(Icons.Rounded.ClosedCaption, contentDescription = null) + }, + supportingContent = { + Text( + text = subtitleTrackState.activeSubtitleTrack?.let { + formatTrackName(it) + } ?: stringResource(CommonR.string.theoplayer_ui_subtitles_off) + ) + } + ) + } + } +} + +internal fun showAudioTracks(audioTrackState: AudioTrackState): Boolean { + return audioTrackState.audioTracks.size >= 2 +} + +internal fun showSubtitleTracks(subtitleTrackState: SubtitleTrackState): Boolean { + return subtitleTrackState.subtitleTracks.isNotEmpty() +} + +/** + * A menu to change the spoken language of the stream. + * + * @see AudioTrackList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.AudioTrackMenu() { + AudioTrackMenu(state = rememberAudioTrackState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the spoken language of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [AudioTrackState] to read and control + * @see AudioTrackList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.AudioTrackMenu(state: AudioTrackState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_audio)) } + ) { + AudioTrackList(state, onClick = { closeCurrentMenu() }) + } +} + +/** + * A menu to change the subtitles of the stream. + * + * @see SubtitleTrackList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SubtitleMenu() { + SubtitleMenu(state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the subtitles of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [SubtitleTrackState] to read and control + * @see SubtitleTrackList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SubtitleMenu(state: SubtitleTrackState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_subtitles)) } + ) { + SubtitleTrackList(state, onClick = { closeCurrentMenu() }) + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt new file mode 100644 index 00000000..3eef4d4e --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/LanguageMenuButton.kt @@ -0,0 +1,96 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.AudioTrackState +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.rememberAudioTrackState +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A button that opens the [language menu][LanguageMenu]. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.LanguageMenuButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) + ) + } +) { + LanguageMenuButton( + audioTrackState = rememberAudioTrackState(LocalTHEOplayerView.current?.player), + subtitleTrackState = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +/** + * A button that opens the [language menu][LanguageMenu]. + * + * The states are only used to decide whether the button is shown. The opened menu remembers its + * own states. + * + * @param audioTrackState the [AudioTrackState] to read + * @param subtitleTrackState the [SubtitleTrackState] to read + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.LanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + painter = painterResource(id = CommonR.drawable.language), + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_language) + ) + } +) { + if (!showLanguageMenuButton(audioTrackState, subtitleTrackState)) { + return + } + + IconButton( + modifier = modifier, + onClick = { openMenu { LanguageMenu() } } + ) { + Box(Modifier.padding(contentPadding)) { + content() + } + } +} + +internal fun showLanguageMenuButton( + audioTrackState: AudioTrackState, + subtitleTrackState: SubtitleTrackState +): Boolean { + return showAudioTracks(audioTrackState) || showSubtitleTracks(subtitleTrackState) +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt new file mode 100644 index 00000000..17184ab3 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/Menu.kt @@ -0,0 +1,173 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ClosedCaption +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text +import com.theoplayer.android.ui.MenuContent +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme + +/** + * A side menu displayed at the end edge of the player. + * + * @param modifier the [Modifier] to be applied to this menu + * @param title the title + * @param content the menu content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.Menu( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterEnd + ) { + Surface( + modifier = modifier.fillMaxHeight().width(menuWidth), + shape = RoundedCornerShape( + topStart = menuCornerRadius, + bottomStart = menuCornerRadius + ), + colors = SurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { + Box( + modifier = Modifier + .padding( + start = menuTitlePadding, + top = menuTitlePadding, + end = menuTitlePadding, + bottom = menuTitleBottomSpacing + ) + ) { + title() + } + } + Column( + modifier = Modifier + .padding(horizontal = menuContentPadding) + .focusRequester(focusRequester) + .focusGroup(), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) + ) { + content() + } + } + } + } +} + +/** A menu row that opens a nested menu. */ +@Composable +internal fun MenuSubmenuItem( + onClick: () -> Unit, + headlineContent: @Composable () -> Unit, + supportingContent: @Composable () -> Unit, + leadingContent: @Composable BoxScope.() -> Unit +) { + ListItem( + selected = false, + onClick = onClick, + colors = menuItemColors(), + headlineContent = headlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } + ) +} + +@Composable +internal fun menuItemColors() = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant +) + +internal val menuItemVerticalSpacing = 8.dp + +// Leaves room for the focused item's scale so lazy lists don't clip it. +internal val menuItemFocusOverflowPadding = 4.dp + +private val menuWidth = 320.dp +private val menuCornerRadius = 16.dp +private val menuTitlePadding = 24.dp +private val menuTitleBottomSpacing = 16.dp +private val menuContentPadding = 12.dp + +@OptIn(ExperimentalTvUiApi::class) +@Preview( + showBackground = true, + backgroundColor = 0xFF000000, + device = "spec:width=960dp,height=540dp" +) +@Composable +private fun MenuPreview() { + THEOplayerTvTheme { + PreviewMenuScope.Menu( + title = { Text("Language") } + ) { + ListItem( + selected = true, + onClick = {}, + colors = menuItemColors(), + headlineContent = { Text("English") }, + trailingContent = { Icon(Icons.Rounded.Check, contentDescription = null) } + ) + ListItem( + selected = false, + onClick = {}, + colors = menuItemColors(), + headlineContent = { Text("Subtitles") }, + supportingContent = { Text("Off") }, + leadingContent = { + Icon(Icons.Rounded.ClosedCaption, contentDescription = null) + }, + trailingContent = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) } + ) + } + } +} + +private object PreviewMenuScope : MenuScope { + override fun openMenu(menu: MenuContent) = Unit + + override fun closeCurrentMenu() = Unit +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt new file mode 100644 index 00000000..85237747 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/PlaybackRateList.kt @@ -0,0 +1,100 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.PlaybackRateState +import com.theoplayer.android.ui.defaultPlaybackRates +import com.theoplayer.android.ui.formatPlaybackRate +import com.theoplayer.android.ui.rememberPlaybackRateState + +/** + * A list of playback rates, from which the user can choose a desired playback rate. + * + * @param modifier the [Modifier] to be applied to this list + * @param playbackRates the list of possible playback rates + * @param onClick called when a playback rate in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun PlaybackRateList( + modifier: Modifier = Modifier, + playbackRates: List = defaultPlaybackRates, + onClick: (() -> Unit)? = null +) { + PlaybackRateList( + state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player), + modifier = modifier, + playbackRates = playbackRates, + onClick = onClick + ) +} + +/** + * A list of playback rates, from which the user can choose a desired playback rate. + * + * @param state the [PlaybackRateState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param playbackRates the list of possible playback rates + * @param onClick called when a playback rate in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun PlaybackRateList( + state: PlaybackRateState, + modifier: Modifier = Modifier, + playbackRates: List = defaultPlaybackRates, + onClick: (() -> Unit)? = null +) { + val selectedIndex = playbackRates.indexOf(state.playbackRate).coerceAtLeast(0) + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) + ) { + items( + items = playbackRates, + key = { it } + ) { playbackRate -> + val isActive = state.playbackRate == playbackRate + ListItem( + selected = isActive, + onClick = { + state.select(playbackRate) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { Text(text = formatPlaybackRate(playbackRate)) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (playbackRates.indexOf(playbackRate) == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt new file mode 100644 index 00000000..57e531f5 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/QualityList.kt @@ -0,0 +1,132 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.VideoQualityState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatBandwidth +import com.theoplayer.android.ui.rememberVideoQualityState + +/** + * A list of video qualities, from which the user can choose a target video quality. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a video quality in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun QualityList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + QualityList( + state = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of video qualities, from which the user can choose a target video quality. + * + * @param state the [VideoQualityState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a video quality in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun QualityList( + state: VideoQualityState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.targetVideoQuality + ?.let { state.videoQualities.indexOf(it) + 1 } + ?: 0 + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) + ) { + item(key = null) { + val isActive = state.targetVideoQuality == null + ListItem( + selected = isActive, + onClick = { + state.select(null) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_quality_automatic)) + }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (selectedIndex == 0) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + items( + count = state.videoQualities.size, + key = { state.videoQualities[it].uid } + ) { index -> + val quality = state.videoQualities[index] + val isActive = state.targetVideoQuality == quality + ListItem( + selected = isActive, + onClick = { + state.select(quality) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { + Text( + text = stringResource( + CommonR.string.theoplayer_ui_quality_with_height, + quality.height + ) + ) + }, + supportingContent = { + Text(text = formatBandwidth(quality.bandwidth)) + }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (index + 1 == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt new file mode 100644 index 00000000..78a5b530 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenu.kt @@ -0,0 +1,146 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.HighQuality +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.PlaybackRateState +import com.theoplayer.android.ui.VideoQualityState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatActiveQualityLabel +import com.theoplayer.android.ui.formatPlaybackRate +import com.theoplayer.android.ui.rememberPlaybackRateState +import com.theoplayer.android.ui.rememberVideoQualityState + +/** + * A menu to change the settings of the player, such as video quality or playback rate. + * + * @see QualityMenu + * @see PlaybackRateMenu + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SettingsMenu() { + SettingsMenu( + videoQualityState = rememberVideoQualityState(LocalTHEOplayerView.current?.player), + playbackRateState = rememberPlaybackRateState(LocalTHEOplayerView.current?.player) + ) +} + +/** + * A menu to change the settings of the player, such as video quality or playback rate. + * + * Remember the states inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param videoQualityState the [VideoQualityState] to read + * @param playbackRateState the [PlaybackRateState] to read + * @see QualityMenu + * @see PlaybackRateMenu + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.SettingsMenu( + videoQualityState: VideoQualityState, + playbackRateState: PlaybackRateState +) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_settings)) } + ) { + MenuSubmenuItem( + onClick = { openMenu { QualityMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) + }, + leadingContent = { + Icon(Icons.Rounded.HighQuality, contentDescription = null) + }, + supportingContent = { + Text( + text = formatActiveQualityLabel( + videoQualityState.targetVideoQuality, + videoQualityState.activeVideoQuality + ) + ) + } + ) + MenuSubmenuItem( + onClick = { openMenu { PlaybackRateMenu() } }, + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) + }, + leadingContent = { + Icon(Icons.Rounded.Speed, contentDescription = null) + }, + supportingContent = { + Text(text = formatPlaybackRate(playbackRateState.playbackRate)) + } + ) + } +} + +/** + * A menu to change the video quality of the stream. + * + * @see QualityList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.QualityMenu() { + QualityMenu(state = rememberVideoQualityState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the video quality of the stream. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [VideoQualityState] to read and control + * @see QualityList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.QualityMenu(state: VideoQualityState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_quality)) } + ) { + QualityList(state, onClick = { closeCurrentMenu() }) + } +} + +/** + * A menu to change the playback rate of the player. + * + * @see PlaybackRateList + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.PlaybackRateMenu() { + PlaybackRateMenu(state = rememberPlaybackRateState(LocalTHEOplayerView.current?.player)) +} + +/** + * A menu to change the playback rate of the player. + * + * Remember the state inside the `openMenu` block that opens this menu, see [MenuScope.openMenu]. + * + * @param state the [PlaybackRateState] to read and control + * @see PlaybackRateList + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { + Menu( + title = { Text(text = stringResource(CommonR.string.theoplayer_ui_menu_playback_rate)) } + ) { + PlaybackRateList(state, onClick = { closeCurrentMenu() }) + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt new file mode 100644 index 00000000..fd930fbf --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SettingsMenuButton.kt @@ -0,0 +1,44 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import com.theoplayer.android.ui.MenuScope +import com.theoplayer.android.ui.common.R as CommonR + +/** + * A button that opens the [settings menu][SettingsMenu]. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container and content + * @param content the button content + */ +@ExperimentalTvUiApi +@Composable +fun MenuScope.SettingsMenuButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: @Composable () -> Unit = { + Icon( + Icons.Rounded.Settings, + contentDescription = stringResource(CommonR.string.theoplayer_ui_menu_settings) + ) + } +) { + IconButton( + modifier = modifier, + onClick = { openMenu { SettingsMenu() } } + ) { + Box(Modifier.padding(contentPadding)) { + content() + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt new file mode 100644 index 00000000..4655546a --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SubtitleTrackList.kt @@ -0,0 +1,122 @@ +package com.theoplayer.android.ui.tv + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.LocalTHEOplayerView +import com.theoplayer.android.ui.SubtitleTrackState +import com.theoplayer.android.ui.common.R as CommonR +import com.theoplayer.android.ui.formatTrackName +import com.theoplayer.android.ui.rememberSubtitleTrackState + +/** + * A list of subtitle tracks, from which the user can choose an active subtitle track. + * + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a subtitle track in the list is clicked + */ +@ExperimentalTvUiApi +@Composable +fun SubtitleTrackList( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + SubtitleTrackList( + state = rememberSubtitleTrackState(LocalTHEOplayerView.current?.player), + modifier = modifier, + onClick = onClick + ) +} + +/** + * A list of subtitle tracks, from which the user can choose an active subtitle track. + * + * @param state the [SubtitleTrackState] to read and control + * @param modifier the [Modifier] to be applied to this list + * @param onClick called when a subtitle track in the list is clicked + */ +@ExperimentalTvUiApi +@ExperimentalPlayerStateApi +@Composable +fun SubtitleTrackList( + state: SubtitleTrackState, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + val selectedIndex = state.activeSubtitleTrack + ?.let { state.subtitleTracks.indexOf(it) + 1 } + ?: 0 + val selectedRequester = remember { FocusRequester() } + + LazyColumn( + modifier = modifier.focusRestorer(selectedRequester), + state = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex), + contentPadding = PaddingValues(vertical = menuItemFocusOverflowPadding), + verticalArrangement = Arrangement.spacedBy(menuItemVerticalSpacing) + ) { + item(key = null) { + val isActive = state.activeSubtitleTrack == null + ListItem( + selected = isActive, + onClick = { + state.select(null) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { + Text(text = stringResource(CommonR.string.theoplayer_ui_subtitles_off)) + }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (selectedIndex == 0) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + items( + count = state.subtitleTracks.size, + key = { state.subtitleTracks[it].uid } + ) { index -> + val subtitleTrack = state.subtitleTracks[index] + val isActive = state.activeSubtitleTrack == subtitleTrack + ListItem( + selected = isActive, + onClick = { + state.select(subtitleTrack) + onClick?.invoke() + }, + colors = menuItemColors(), + headlineContent = { Text(text = formatTrackName(subtitleTrack)) }, + trailingContent = if (isActive) { + { Icon(Icons.Rounded.Check, contentDescription = null) } + } else { + null + }, + modifier = if (index + 1 == selectedIndex) { + Modifier.focusRequester(selectedRequester) + } else { + Modifier + } + ) + } + } +} diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt index 10c0e71e..85b6daf7 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -8,6 +8,8 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.focusGroup import androidx.compose.runtime.Composable @@ -24,6 +26,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import com.theoplayer.android.api.THEOplayerView import com.theoplayer.android.api.player.Player import com.theoplayer.android.ui.ControlsVisibility @@ -115,7 +119,7 @@ fun UIController( errorState = errorState, controls = controls, color = color, - contentTransform = tvContentTransform(), + contentTransform = tvContentTransform(LocalLayoutDirection.current), controlsModifier = Modifier .focusRequester(focusRequester) .focusRestorer() @@ -151,13 +155,23 @@ private fun rememberControlsFocus( } /** - * Returns fade-only transitions for TV content. + * Returns transitions for TV menus. */ -private fun tvContentTransform(): AnimatedContentTransitionScope.() -> ContentTransform = { +private fun tvContentTransform( + layoutDirection: LayoutDirection +): AnimatedContentTransitionScope.() -> ContentTransform = { when { targetState is UIState.Error -> EnterTransition.None togetherWith ExitTransition.None - targetState is UIState.Menu || initialState is UIState.Menu -> + initialState == UIState.Controls && targetState is UIState.Menu -> + slideInHorizontally { + if (layoutDirection == LayoutDirection.Ltr) it else -it + } + fadeIn() togetherWith fadeOut() + initialState is UIState.Menu && targetState == UIState.Controls -> + fadeIn() togetherWith slideOutHorizontally { + if (layoutDirection == LayoutDirection.Ltr) it else -it + } + fadeOut() + initialState is UIState.Menu && targetState is UIState.Menu -> fadeIn() togetherWith fadeOut() else -> EnterTransition.None togetherWith ExitTransition.None diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt index 5ea45761..7d01316f 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -21,9 +21,12 @@ import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import com.theoplayer.android.api.THEOplayerView @@ -32,6 +35,13 @@ import com.theoplayer.android.api.error.THEOplayerException import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PlayerEventTypes import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.mediatrack.MediaTrackList +import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality +import com.theoplayer.android.api.player.track.mediatrack.quality.QualityList +import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackList import com.theoplayer.android.api.timerange.TimeRanges import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.UIControllerScope @@ -96,6 +106,184 @@ class UIControllerTest { verify(exactly = 1) { player.currentTime = 30.0 } } + @Test + fun languageMenuButtonIsHiddenWithoutAlternativeTracks() { + val onlyTrack = MenuAudioTrack(uid = 1, label = "English", enabled = true) + setControllerAndShowControls(mockMenuPlayer(audioTracks = listOf(onlyTrack.track))) { + LanguageMenuButton(modifier = Modifier.testTag("language-menu-button")) + } + assertTagDoesNotExist("language-menu-button") + } + + @Test + fun languageMenuButtonIsShownWithAlternativeAudioTracks() { + val onlyTrack = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val secondTrack = MenuAudioTrack(uid = 2, label = "French", enabled = false) + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(onlyTrack.track, secondTrack.track)) + ) { + LanguageMenuButton(modifier = Modifier.testTag("language-menu-button")) + } + waitForTag("language-menu-button") + } + + @Test + fun languageMenuShowsActiveTrackAndFocusesFirstRow() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = false) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + + waitForText("Language") + assertTextExists("Language") + waitForText("English") + assertTextExists("English") + composeRule.onNodeWithText("Audio").assertIsFocused() + } + + @Test + fun audioTrackMenuFocusesActiveTrackAndSelectionReturnsToLanguageMenu() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = false) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = true) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + waitForText("Audio") + composeRule.onNodeWithText("Audio").assertIsFocused() + pressCenter() + + waitForText("French") + composeRule.onNodeWithText("French").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_UP) + composeRule.onNodeWithText("English").assertIsFocused() + pressCenter() + waitForText("Language") + assertTextExists("Language") + assertTextExists("English") + assertTrue(english.enabled) + } + + @Test + fun backReturnsFromSubmenuAndRestoresFocusToControls() { + val english = MenuAudioTrack(uid = 1, label = "English", enabled = true) + val french = MenuAudioTrack(uid = 2, label = "French", enabled = false) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls( + mockMenuPlayer(audioTracks = listOf(english.track, french.track)) + ) { + LanguageMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("language-menu-button") + ) + } + waitForTag("language-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + pressCenter() + waitForText("Audio") + composeRule.onNodeWithText("Audio").assertIsFocused() + pressCenter() + waitForText("English") + assertTextExists("English") + + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + waitForText("Language") + assertTextExists("Language") + composeRule.onNodeWithText("Audio").assertIsFocused() + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + composeRule.waitForIdle() + + assertTagExists("language-menu-button") + composeRule.onNodeWithTag("language-menu-button").assertIsFocused() + } + + @Test + fun settingsMenuShowsCurrentValuesAndSelectsPlaybackRate() { + val player = mockMenuPlayer(playbackRate = 1.25) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls(player) { + SettingsMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("settings-menu-button") + ) + } + waitForTag("settings-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("settings-menu-button").assertIsFocused() + pressCenter() + + waitForText("Automatic") + assertTextExists("Automatic") + assertTextExists("1.25x") + composeRule.onNodeWithText("Quality").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("Playback speed").assertIsFocused() + waitForText("Playback speed") + pressCenter() + waitForText("1.5x") + composeRule.onNodeWithText("1.25x").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("1.5x").assertIsFocused() + pressCenter() + + verify(exactly = 1) { player.setPlaybackRate(1.5) } + } + + @Test + fun qualityListSelectsTargetQuality() { + val quality720 = menuVideoQuality(uid = 10, height = 720, bandwidth = 2_500_000) + val quality1080 = menuVideoQuality(uid = 11, height = 1080, bandwidth = 5_000_000) + val videoTrack = MenuVideoTrack(listOf(quality720, quality1080)) + val player = mockMenuPlayer(videoTracks = listOf(videoTrack.track)) + val buttonFocusRequester = FocusRequester() + setControllerAndShowControls(player) { + SettingsMenuButton( + modifier = Modifier + .focusRequester(buttonFocusRequester) + .testTag("settings-menu-button") + ) + } + waitForTag("settings-menu-button") + composeRule.runOnIdle { buttonFocusRequester.requestFocus() } + composeRule.onNodeWithTag("settings-menu-button").assertIsFocused() + pressCenter() + waitForText("Quality") + composeRule.onNodeWithText("Quality").assertIsFocused() + pressCenter() + waitForText("720p") + composeRule.onNodeWithText("Automatic").assertIsFocused() + pressDpad(KeyEvent.KEYCODE_DPAD_DOWN) + composeRule.onNodeWithText("720p").assertIsFocused() + pressCenter() + + assertEquals(quality720, videoTrack.targetQuality) + } + @Test fun hiddenDpadCenterPausesAndShowsControls() { val player = mockPlayingPlayer() @@ -494,6 +682,12 @@ class UIControllerTest { } } + private fun waitForText(text: String) { + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + } + private fun assertTagExists(tag: String) { assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()) } @@ -502,6 +696,10 @@ class UIControllerTest { assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()) } + private fun assertTextExists(text: String) { + assertTrue(composeRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()) + } + private fun focusableOverlay( focusRequester: FocusRequester, onKeyEvent: (ComposeKeyEvent) -> Boolean = { false } @@ -539,6 +737,56 @@ class UIControllerTest { return player } + private fun setControllerAndShowControls( + player: Player, + bottomChrome: @Composable UIControllerScope.() -> Unit + ) { + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = bottomChrome + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitForIdle() + } + + private fun mockMenuPlayer( + audioTracks: List> = emptyList(), + videoTracks: List> = emptyList(), + playbackRate: Double = 1.0 + ): Player { + val player = mockPlayingPlayer() + every { player.getAudioTracks() } returns mockAudioTrackList(audioTracks) + every { player.getTextTracks() } returns mockTextTrackList(emptyList()) + every { player.getVideoTracks() } returns mockVideoTrackList(videoTracks) + every { player.getPlaybackRate() } returns playbackRate + return player + } + + private fun pressCenter() { + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + composeRule.waitForIdle() + } + + private fun pressDpad(keyCode: Int) { + assertTrue( + composeRule.activity.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + ) + composeRule.activity.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + composeRule.waitForIdle() + } + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, keyCode) ) @@ -547,3 +795,61 @@ class UIControllerTest { const val CONTROLS_AUTO_HIDE_DURATION_MILLIS = 5_000L } } + +private class MenuAudioTrack( + val uid: Int, + val label: String, + var enabled: Boolean +) { + val track: MediaTrack = mockk(relaxed = true) { + every { this@mockk.uid } returns this@MenuAudioTrack.uid + every { this@mockk.label } returns this@MenuAudioTrack.label + every { isEnabled } answers { this@MenuAudioTrack.enabled } + every { setEnabled(any()) } answers { this@MenuAudioTrack.enabled = firstArg() } + } +} + +private class MenuVideoTrack( + private val qualities: List +) { + var targetQuality: VideoQuality? = null + + val track: MediaTrack = mockk(relaxed = true) { + every { this@mockk.uid } returns 100 + every { isEnabled } returns true + every { getActiveQuality() } returns qualities.firstOrNull() + every { getTargetQuality() } answers { this@MenuVideoTrack.targetQuality } + every { setTargetQuality(any()) } answers { + this@MenuVideoTrack.targetQuality = firstArg() + } + every { getQualities() } answers { + mockk>(relaxed = true) { + every { iterator() } answers { + this@MenuVideoTrack.qualities.toMutableList().listIterator() + } + } + } + } +} + +private fun menuVideoQuality(uid: Int, height: Int, bandwidth: Long): VideoQuality = mockk { + every { this@mockk.uid } returns uid + every { this@mockk.height } returns height + every { this@mockk.bandwidth } returns bandwidth +} + +private fun mockAudioTrackList( + tracks: List> +): MediaTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} + +private fun mockVideoTrackList( + tracks: List> +): MediaTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} + +private fun mockTextTrackList(tracks: List): TextTrackList = mockk(relaxed = true) { + every { iterator() } answers { tracks.toMutableList().listIterator() } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt index 2b00abd1..98cde93b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,13 +1,9 @@ package com.theoplayer.android.ui -import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.texttrack.TextTrack -import com.theoplayer.android.ui.util.constructLabel /** * Return the given time in a human-readable format, such as `"0:10"` or `"01:23:45"`. @@ -29,15 +25,10 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * * @param track the media track or text track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: Track): String { - return remember(track.id, track.uid) { - when (track) { - is TextTrack -> constructLabel(track) - is MediaTrack<*> -> constructLabel(track) - else -> null - } - } ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } /** @@ -45,10 +36,10 @@ fun formatTrackLabel(track: Track): String { * * @param track the media track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: MediaTrack<*>): String { - return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } /** @@ -56,8 +47,8 @@ fun formatTrackLabel(track: MediaTrack<*>): String { * * @param track the text track */ +@OptIn(InternalUiApi::class) @Composable fun formatTrackLabel(track: TextTrack): String { - return remember(track.id, track.uid) { constructLabel(track) } - ?: stringResource(CommonR.string.theoplayer_ui_track_unknown) + return formatTrackName(track) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt index 9a21bfd4..ba5c7fd8 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PlaybackRateList.kt @@ -1,6 +1,5 @@ package com.theoplayer.android.ui -import com.theoplayer.android.ui.common.R as CommonR import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable @@ -9,16 +8,8 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import java.text.DecimalFormat - -/** - * The default playback rates shown in a [PlaybackRateList]. - */ -private val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) /** * A list of playback rates, from which the user can choose a desired playback rate. @@ -27,6 +18,7 @@ private val defaultPlaybackRates = listOf(0.25, 0.5, 1.0, 1.25, 1.5, 2.0) * @param playbackRates the list of possible playback rates * @param onClick called when a playback rate in the list is clicked */ +@OptIn(InternalUiApi::class) @Composable fun PlaybackRateList( modifier: Modifier = Modifier, @@ -50,6 +42,7 @@ fun PlaybackRateList( * @param onClick called when a playback rate in the list is clicked */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun PlaybackRateList( state: PlaybackRateState, @@ -86,15 +79,4 @@ fun PlaybackRateList( ) } } -} - -@Composable -internal fun formatPlaybackRate(rate: Double): String { - return if (rate == 1.0) { - stringResource(CommonR.string.theoplayer_ui_playback_rate_normal, rate) - } else { - val format = stringResource(CommonR.string.theoplayer_ui_playback_rate_format) - val decimalFormat = remember(format) { DecimalFormat(format) } - decimalFormat.format(rate) - } } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt index 77898c73..323c4523 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/QualityList.kt @@ -9,11 +9,9 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import java.text.DecimalFormat /** * A list of video qualities, from which the user can choose a target video quality. @@ -41,6 +39,7 @@ fun QualityList( * @param onClick called when a video quality in the list is clicked */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun QualityList( state: VideoQualityState, @@ -106,19 +105,4 @@ fun QualityList( ) } } -} - -@Composable -internal fun formatBandwidth(bandwidth: Long): String { - val format = stringResource( - if (bandwidth >= 1e7) { - CommonR.string.theoplayer_ui_bandwidth_format_10mbps - } else if (bandwidth >= 1e6) { - CommonR.string.theoplayer_ui_bandwidth_format_1mbps - } else { - CommonR.string.theoplayer_ui_bandwidth_format_kbps - } - ) - val decimalFormat = remember(format) { DecimalFormat(format) } - return decimalFormat.format(bandwidth / (if (bandwidth >= 1e6) 1e6 else 1e3)) } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt index 74beede0..45704a9f 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenu.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.theoplayer.android.api.player.track.mediatrack.quality.VideoQuality /** * A [Menu] to change the settings of the player, such as the video quality or the playback rate. @@ -42,6 +41,7 @@ fun MenuScope.SettingsMenu() { * @see PlaybackRateMenu */ @ExperimentalPlayerStateApi +@OptIn(InternalUiApi::class) @Composable fun MenuScope.SettingsMenu( videoQualityState: VideoQualityState, @@ -171,23 +171,3 @@ fun MenuScope.PlaybackRateMenu(state: PlaybackRateState) { PlaybackRateList(state, onClick = { closeCurrentMenu() }) } } - -@Composable -internal fun formatActiveQualityLabel( - targetVideoQuality: VideoQuality?, - activeVideoQuality: VideoQuality? -): String { - return if (targetVideoQuality != null) { - stringResource( - CommonR.string.theoplayer_ui_quality_with_height, - targetVideoQuality.height - ) - } else if (activeVideoQuality != null) { - stringResource( - CommonR.string.theoplayer_ui_quality_automatic_with_height, - activeVideoQuality.height - ) - } else { - stringResource(CommonR.string.theoplayer_ui_quality_automatic) - } -}