Skip to content

Commit 0eed012

Browse files
Merge pull request #84 from THEOplayer/bugfix/OPTI-1528-add-cc-label-if-language-or-label-unknown
OPTI-1528: handle `CC` prefix for `CEA` text tracks on Player API below `11`th release
2 parents 7d381de + 2c389a2 commit 0eed012

14 files changed

Lines changed: 878 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
> - 🏠 Internal
1010
> - 💅 Polish
1111
12+
## Unreleased
13+
14+
* 🐛 The language menu now prefers to show CEA-608/708 closed caption tracks with their localized language name (if available) instead of their language code (e.g. "en") or channel number (e.g. "CC1"). ([#84](https://github.com/THEOplayer/android-ui/pull/84))
15+
1216
## v1.13.3 (2026-03-23)
1317

1418
* 🐛 Changed the minimum supported THEOplayer version to 7.6.0. ([#85](https://github.com/THEOplayer/android-ui/pull/85))

app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ val streams by lazy {
4141
TypedSource.Builder("https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd")
4242
.build()
4343
).build()
44-
)
44+
),
45+
Stream(
46+
title = "Test card (with CEA tracks)",
47+
source = SourceDescription.Builder(
48+
TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd")
49+
.build()
50+
).build()
51+
),
4552
)
4653
}
4754

gradle/libs.versions.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ activity-compose = "1.13.0"
1010
appcompat = "1.7.1"
1111
compose-bom = "2025.08.01"
1212
junit4 = "4.13.2"
13+
mockk = "1.14.9"
1314
playServices-castFramework = "21.5.0"
1415
ui-test-junit4 = "1.9.0" # ...not in BOM for some reason?
1516
androidx-junit = "1.3.0"
1617
androidx-espresso = "3.7.0"
1718
androidx-mediarouter = "1.8.1"
1819
dokka = "2.0.0"
19-
theoplayer = { prefer="10.11.0", strictly = "[7.6.0, 11.0)" }
20+
theoplayer = { prefer = "10.+", strictly = "[7.6.0, 11.0)" }
21+
theoplayer-compile = { prefer = "10.+", strictly = "[10.13.0, 11.0)" }
2022
theoplayer-min = { strictly = "7.6.0" }
2123
core = "1.18.0"
2224
core-pip = "1.0.0-alpha02"
@@ -46,12 +48,14 @@ dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref =
4648
dokka-plugin = { group = "org.jetbrains.dokka", name = "android-documentation-plugin", version.ref = "dokka" }
4749
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
4850
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
51+
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
4952
theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" }
5053
theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" }
5154
theoplayer-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer" }
5255
theoplayer-min = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer-min" }
5356
theoplayer-min-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer-min" }
5457
theoplayer-min-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer-min" }
58+
theoplayer-compile = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer-compile" }
5559

5660
[plugins]
5761
android-application = { id = "com.android.application", version.ref = "gradle" }

ui/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,18 @@ dependencies {
7878
implementation(libs.androidx.compose.ui.toolingPreview)
7979
implementation(libs.androidx.compose.material3)
8080
implementation(libs.androidx.compose.material.iconsExtended)
81+
8182
testImplementation(libs.junit4)
83+
testImplementation(libs.mockk)
8284
androidTestImplementation(libs.androidx.junit)
8385
androidTestImplementation(libs.androidx.espresso)
8486
androidTestImplementation(libs.androidx.compose.ui.testJunit4)
8587
debugImplementation(libs.androidx.compose.ui.tooling)
8688
debugImplementation(libs.androidx.compose.ui.testManifest)
8789

90+
compileOnly(libs.theoplayer.compile)
8891
api(libs.theoplayer)
92+
testImplementation(libs.theoplayer)
8993

9094
dokkaPlugin(libs.dokka.plugin)
9195
}

ui/src/main/java/com/theoplayer/android/ui/Helper.kt

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.theoplayer.android.ui
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.remember
45
import androidx.compose.ui.res.stringResource
56
import com.theoplayer.android.api.player.track.Track
6-
import java.util.Locale
7+
import com.theoplayer.android.api.player.track.mediatrack.MediaTrack
8+
import com.theoplayer.android.api.player.track.texttrack.TextTrack
9+
import com.theoplayer.android.ui.util.constructLabel
710
import kotlin.math.absoluteValue
811

912
/**
@@ -64,18 +67,33 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals
6467
*/
6568
@Composable
6669
fun formatTrackLabel(track: Track): String {
67-
val label = track.label
68-
if (!label.isNullOrEmpty()) {
69-
return label
70-
}
71-
val languageCode = track.language
72-
if (!languageCode.isNullOrEmpty()) {
73-
val locale = Locale.forLanguageTag(languageCode)
74-
val languageName = locale.getDisplayName(locale)
75-
if (languageName.isNotEmpty()) {
76-
return languageName
70+
return remember(track.id, track.uid) {
71+
when (track) {
72+
is TextTrack -> constructLabel(track)
73+
is MediaTrack<*> -> constructLabel(track)
74+
else -> null
7775
}
78-
return languageCode
79-
}
80-
return stringResource(R.string.theoplayer_ui_track_unknown)
81-
}
76+
} ?: stringResource(R.string.theoplayer_ui_track_unknown)
77+
}
78+
79+
/**
80+
* Return a human-readable label for the given media track.
81+
*
82+
* @param track the media track
83+
*/
84+
@Composable
85+
fun formatTrackLabel(track: MediaTrack<*>): String {
86+
return remember(track.id, track.uid) { constructLabel(track) }
87+
?: stringResource(R.string.theoplayer_ui_track_unknown)
88+
}
89+
90+
/**
91+
* Return a human-readable label for the given text track.
92+
*
93+
* @param track the text track
94+
*/
95+
@Composable
96+
fun formatTrackLabel(track: TextTrack): String {
97+
return remember(track.id, track.uid) { constructLabel(track) }
98+
?: stringResource(R.string.theoplayer_ui_track_unknown)
99+
}

ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,17 @@ fun SubtitleTrackList(
4343
count = subtitleTracks.size,
4444
key = { subtitleTracks[it].uid }
4545
) {
46-
val audioTrack = subtitleTracks[it]
46+
val subtitleTrack = subtitleTracks[it]
4747
ListItem(
48-
headlineContent = { Text(text = formatTrackLabel(audioTrack)) },
48+
headlineContent = { Text(text = formatTrackLabel(subtitleTrack)) },
4949
leadingContent = {
5050
RadioButton(
51-
selected = (activeSubtitleTrack == audioTrack),
51+
selected = (activeSubtitleTrack == subtitleTrack),
5252
onClick = null
5353
)
5454
},
5555
modifier = Modifier.clickable(onClick = {
56-
player?.activeSubtitleTrack = audioTrack
56+
player?.activeSubtitleTrack = subtitleTrack
5757
onClick?.let { it() }
5858
})
5959
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.theoplayer.android.ui.util
2+
3+
import androidx.annotation.CheckResult
4+
import androidx.annotation.IntRange
5+
6+
private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex()
7+
8+
/**
9+
* Checks whether a provided label is CEA-608 or CEA-708 formed.
10+
*/
11+
@CheckResult
12+
internal fun isLabelCeaFormatted(label: String?): Boolean {
13+
if (label.isNullOrEmpty()) {
14+
return false
15+
}
16+
17+
val matchResult = CEA_FORMATTING_REGEX.find(label) ?: return false
18+
val groupValues = matchResult.groupValues
19+
// There is one group we want to match with the channel number.
20+
if (groupValues.size != 2) {
21+
return false
22+
}
23+
24+
val rawChannelNumber = groupValues[1]
25+
val channelNumber = rawChannelNumber.toIntOrNull()
26+
return !rawChannelNumber.startsWith("0") && channelNumber in 1..63
27+
}
28+
29+
/**
30+
* Creates a text track label for CEA-608 and CEA-708 formats.
31+
*
32+
* @return an optional string composed of a [channelNumber] and a prepended
33+
* "CC" suffix, or `null` if the channel number is invalid.
34+
*/
35+
@CheckResult
36+
internal fun getLabelForChannelNumber(
37+
@IntRange(from = 0L, to = 63L) channelNumber: Int,
38+
): String? {
39+
// CEA-608 only supports channel numbers in [1, 4],
40+
// while CEA-708 support service numbers in [1, 63].
41+
if (channelNumber !in 1..63) {
42+
return null
43+
}
44+
return "CC${channelNumber}"
45+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.theoplayer.android.ui.util
2+
3+
import androidx.annotation.DoNotInline
4+
import com.theoplayer.android.api.player.track.texttrack.TextTrack
5+
6+
/**
7+
* Returns [TextTrack.getCaptionChannel], if available.
8+
*/
9+
internal val TextTrack.captionChannelCompat: Int?
10+
get() {
11+
// TextTrack.getCaptionChannel was added in THEOplayer 10.13.0.
12+
return if (THEOplayerGlobalExt.version >= version1013) {
13+
TheoPlayer1013Impl.getTextTrackCaptionChannel(this)
14+
} else null
15+
}
16+
17+
private val version1013 = Version(major = 10, minor = 13, patchAndPrerelease = "0")
18+
19+
/**
20+
* This class must be loaded **only** with THEOplayer 10.13.0 or higher.
21+
*
22+
* This uses the same pattern as AndroidX AppCompat,
23+
* see e.g. [androidx.appcompat.app.AppCompatDelegate.Api33Impl]
24+
* and the docs about [API-specific implementations](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/compat.md#delegating-to-api-specific-implementations-delegating-to-api-specific-implementations)
25+
* and [static shims](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/platform_compat.md#static-shims-ex-viewcompat-static-shim).
26+
*/
27+
private class TheoPlayer1013Impl private constructor() {
28+
companion object {
29+
@DoNotInline
30+
fun getTextTrackCaptionChannel(track: TextTrack): Int? = track.captionChannel
31+
}
32+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.theoplayer.android.ui.util
2+
3+
import androidx.annotation.CheckResult
4+
import com.theoplayer.android.api.player.track.Track
5+
import com.theoplayer.android.api.player.track.mediatrack.MediaTrack
6+
import com.theoplayer.android.api.player.track.texttrack.TextTrack
7+
import com.theoplayer.android.api.player.track.texttrack.TextTrackType
8+
import java.util.Locale
9+
10+
private const val LANGUAGE_UNDEFINED = "und"
11+
12+
/**
13+
* Returns a name for the [Track.language] in the
14+
* [Locale.Category.DISPLAY] locale that is appropriate
15+
* for display to the user.
16+
* If such conversion is not possible, for instance
17+
* when [Track.language] is `null`, blank, or `"und"`,
18+
* returns `null`.
19+
*/
20+
@get:CheckResult
21+
internal val Track.localizedLanguageName: String?
22+
get() {
23+
val languageCode = this.language
24+
?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED }
25+
?: return null
26+
val locale = Locale.forLanguageTag(languageCode)
27+
val localisedLanguage = locale.getDisplayName(locale).orEmpty()
28+
return localisedLanguage.takeUnless { it.isBlank() }
29+
}
30+
31+
/**
32+
* Constructs a label for the given [MediaTrack] instance.
33+
*
34+
* This returns the first non-empty entry from the list:
35+
* 1. Track label
36+
* 2. Track language display name
37+
*/
38+
internal fun constructLabel(track: MediaTrack<*>): String? {
39+
return track.label?.takeUnless { it.isBlank() }
40+
?: track.localizedLanguageName
41+
}
42+
43+
/**
44+
* Constructs a label for the given [TextTrack] instance.
45+
* The method works slightly different for different player version.
46+
*
47+
* On version 10 and below the logic checks the following and condition
48+
* and the first not `null` entry from the list:
49+
* 1. Track label if is not a language code
50+
* or a CEA-prefixed string.
51+
* 2. Track language display name
52+
* 3. Track caption channel if a text CEA-608 track
53+
* 4. Track label if was either a language code or a CEA-prefixed string
54+
*
55+
* If none of the above is satisfied, returns `null`.
56+
*
57+
* On version 11 and later the logic has slightly changed as
58+
* the player no longer constructs the [Track.getLabel] internally:
59+
* 1. Track label
60+
* 2. Track language display name
61+
* 3. Track caption channel
62+
*/
63+
internal fun constructLabel(track: TextTrack): String? {
64+
val label: String? = if (
65+
THEOplayerGlobalExt.version.major < 11 &&
66+
(isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label))
67+
) {
68+
// If we are below 11th major release
69+
// and the label is CEA-formatted we
70+
// can safely assume it was the last resort
71+
// option to produce a meaningful label, given
72+
// we cannot localize the language code in the player.
73+
null
74+
} else {
75+
// With 11 release, the player will no longer
76+
// prefix text tracks with "CC" for CEA-608 and CEA-708,
77+
// if [Track.label] is `null`.
78+
track.label
79+
}
80+
81+
if (!label.isNullOrBlank()) {
82+
return label
83+
}
84+
85+
track.localizedLanguageName?.let { return it }
86+
87+
if (track.type == TextTrackType.CEA608) {
88+
track.captionChannelCompat
89+
?.let { getLabelForChannelNumber(it) }
90+
?.let { return it }
91+
92+
track.label
93+
?.takeUnless { it.isBlank() }
94+
?.let { return it }
95+
}
96+
97+
return null
98+
}

0 commit comments

Comments
 (0)