Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
111 changes: 111 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/TrackFormat.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ package com.theoplayer.android.ui.util

import androidx.annotation.CheckResult
import androidx.annotation.IntRange
import com.theoplayer.android.ui.InternalUiApi

private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex()

/**
* Checks whether a provided label is CEA-608 or CEA-708 formed.
*/
@CheckResult
internal fun isLabelCeaFormatted(label: String?): Boolean {
@InternalUiApi
fun isLabelCeaFormatted(label: String?): Boolean {
if (label.isNullOrEmpty()) {
return false
}
Expand All @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }
Expand All @@ -31,15 +33,17 @@ 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
}

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -54,7 +56,8 @@ internal data class Version(
}
}

internal object THEOplayerGlobalExt {
@InternalUiApi
object THEOplayerGlobalExt {
/**
* Returns the version of THEOplayer, as a [Version].
*/
Expand Down
94 changes: 94 additions & 0 deletions ui-tv/src/main/java/com/theoplayer/android/ui/tv/AudioTrackList.kt
Original file line number Diff line number Diff line change
@@ -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
}
)
}
}
}
Loading
Loading