From 91d3c1855128f2ac0c52e203d9a1e34359f93620 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 11:27:17 +0000 Subject: [PATCH 01/37] OPTI-1528: extract language localisation logic into a separate file, add unit tests --- gradle/libs.versions.toml | 2 + ui/build.gradle.kts | 2 + .../java/com/theoplayer/android/ui/Helper.kt | 11 +-- .../com/theoplayer/android/ui/TrackExts.kt | 32 ++++++++ .../theoplayer/android/ui/ExampleUnitTest.kt | 17 ---- .../theoplayer/android/ui/TrackExtsTest.kt | 82 +++++++++++++++++++ 6 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt delete mode 100644 ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt create mode 100644 ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 945a6a75..22062b92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ activity-compose = "1.13.0" appcompat = "1.7.1" compose-bom = "2025.08.01" junit4 = "4.13.2" +mockk = "1.14.9" playServices-castFramework = "21.5.0" ui-test-junit4 = "1.9.0" # ...not in BOM for some reason? androidx-junit = "1.3.0" @@ -46,6 +47,7 @@ dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref = dokka-plugin = { group = "org.jetbrains.dokka", name = "android-documentation-plugin", version.ref = "dokka" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" } theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" } theoplayer-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index af840ffc..918d0db7 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -78,7 +78,9 @@ dependencies { implementation(libs.androidx.compose.ui.toolingPreview) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.iconsExtended) + testImplementation(libs.junit4) + testImplementation(libs.mockk) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso) androidTestImplementation(libs.androidx.compose.ui.testJunit4) 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 cef88a0c..32e14aa3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -68,14 +68,9 @@ fun formatTrackLabel(track: Track): String { if (!label.isNullOrEmpty()) { return label } - val languageCode = track.language - if (!languageCode.isNullOrEmpty()) { - val locale = Locale.forLanguageTag(languageCode) - val languageName = locale.getDisplayName(locale) - if (languageName.isNotEmpty()) { - return languageName - } - return languageCode + val localisedLanguage = track.localisedLanguage + if (localisedLanguage != null) { + return localisedLanguage } return stringResource(R.string.theoplayer_ui_track_unknown) } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt new file mode 100644 index 00000000..4056070c --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt @@ -0,0 +1,32 @@ +package com.theoplayer.android.ui + +import androidx.annotation.CheckResult +import com.theoplayer.android.api.player.track.Track +import java.util.Locale + +private const val LANGUAGE_UNDEFINED = "und" + +/** + * Returns a name for the [Track.language] in the + * [Locale.Category.DISPLAY] locale that is appropriate + * for display to the user. + * If such conversion is not possible, for instance + * when [Track.language] is `null`, blank, or `"und"`, + * returns `null`. + */ +@get:CheckResult +internal val Track.localisedLanguage: String? + get() { + val languageCode = this.language + if (languageCode.isNullOrBlank() || languageCode == LANGUAGE_UNDEFINED) { + return null + } + + val localisedLanguage = + Locale.forLanguageTag(languageCode).displayLanguage + if (localisedLanguage.isNullOrBlank()) { + return null + } + + return localisedLanguage + } diff --git a/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt b/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt deleted file mode 100644 index b281c162..00000000 --- a/ui/src/test/java/com/theoplayer/android/ui/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.theoplayer.android.ui - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt new file mode 100644 index 00000000..62d1b96c --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt @@ -0,0 +1,82 @@ +package com.theoplayer.android.ui + +import com.theoplayer.android.api.player.track.Track +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import java.util.Locale + +@RunWith(Enclosed::class) +class TrackExtsTest { + + @RunWith(JUnit4::class) + class LocalisedLanguageTest { + + private val track = mockk() + private val locale = mockk() + + @Before + fun setUp() { + mockkStatic(Locale::class) + } + + @Test + fun `GIVEN language is null THEN localised language is also null`() { + every { track.language } returns null + assertNull(track.localisedLanguage) + } + + @Test + fun `GIVEN language is und THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_UNDEFINED + assertNull(track.localisedLanguage) + } + + @Test + fun `GIVEN language is blank THEN localised language is null`() { + every { track.language } returns TEST_BLANK_STRING + assertNull(track.localisedLanguage) + } + + @Test + fun `GIVEN locale returns null as displayLanguage THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.displayLanguage } returns null + + assertNull(track.localisedLanguage) + } + + @Test + fun `GIVEN locale returns a blank string as displayLanguage THEN localised language is null`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.displayLanguage } returns TEST_BLANK_STRING + + assertNull(track.localisedLanguage) + } + + @Test + fun `GIVEN locale returns a valid display name THEN returns localised name`() { + every { track.language } returns LANGUAGE_CODE_ENGLISH + every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale + every { locale.displayLanguage } returns LOCALISED_ENGLISH_CODE_NAME + + assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localisedLanguage) + } + + private companion object { + const val LANGUAGE_CODE_UNDEFINED = "und" + const val LANGUAGE_CODE_ENGLISH = "en" + const val LOCALISED_ENGLISH_CODE_NAME = "English" + const val TEST_BLANK_STRING = " " + } + } +} From 39120bbc2667ecb3b23085dc74a19ac1463108da Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 12:29:27 +0000 Subject: [PATCH 02/37] OPTI-1528: add util to conditionally check player version --- .../java/com/theoplayer/android/ui/Helper.kt | 2 +- .../android/ui/{ => util}/TrackExts.kt | 2 +- .../theoplayer/android/ui/util/VersionUtil.kt | 26 ++++++ .../android/ui/{ => util}/TrackExtsTest.kt | 19 ++-- .../android/ui/util/VersionUtilTest.kt | 90 +++++++++++++++++++ 5 files changed, 127 insertions(+), 12 deletions(-) rename ui/src/main/java/com/theoplayer/android/ui/{ => util}/TrackExts.kt (95%) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt rename ui/src/test/java/com/theoplayer/android/ui/{ => util}/TrackExtsTest.kt (84%) create mode 100644 ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt 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 32e14aa3..d85aefea 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -3,7 +3,7 @@ package com.theoplayer.android.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.theoplayer.android.api.player.track.Track -import java.util.Locale +import com.theoplayer.android.ui.util.localisedLanguage import kotlin.math.absoluteValue /** diff --git a/ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt similarity index 95% rename from ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt rename to ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 4056070c..b4d62540 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -1,4 +1,4 @@ -package com.theoplayer.android.ui +package com.theoplayer.android.ui.util import androidx.annotation.CheckResult import com.theoplayer.android.api.player.track.Track diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt new file mode 100644 index 00000000..97eb1b5e --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -0,0 +1,26 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.THEOplayerGlobal + +private const val VERSION_DELIMITER = "." + +/** + * Performs a player version check and executes an appropriate action: + * if the major version is equal or above to the [desiredMajorVersion] + * then [actionIfEqualOrAbove] is triggered, otherwise [actionIfBelow]. + */ +internal inline fun runForPlayerWith( + desiredMajorVersion: Int, + actionIfEqualOrAbove: () -> T, + actionIfBelow: () -> T, +): T { + val version: String? = THEOplayerGlobal.getVersion() + val versionSplits = version?.split(VERSION_DELIMITER) + val majorVersionNumber = versionSplits?.getOrNull(0)?.toIntOrNull() + + return if (majorVersionNumber == null || majorVersionNumber < desiredMajorVersion) { + actionIfBelow() + } else { + actionIfEqualOrAbove() + } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt similarity index 84% rename from ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt rename to ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index 62d1b96c..280d81f2 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -1,11 +1,10 @@ -package com.theoplayer.android.ui +package com.theoplayer.android.ui.util import com.theoplayer.android.api.player.track.Track import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull +import org.junit.Assert import org.junit.Before import org.junit.Test import org.junit.experimental.runners.Enclosed @@ -30,19 +29,19 @@ class TrackExtsTest { @Test fun `GIVEN language is null THEN localised language is also null`() { every { track.language } returns null - assertNull(track.localisedLanguage) + Assert.assertNull(track.localisedLanguage) } @Test fun `GIVEN language is und THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_UNDEFINED - assertNull(track.localisedLanguage) + Assert.assertNull(track.localisedLanguage) } @Test fun `GIVEN language is blank THEN localised language is null`() { every { track.language } returns TEST_BLANK_STRING - assertNull(track.localisedLanguage) + Assert.assertNull(track.localisedLanguage) } @Test @@ -51,7 +50,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns null - assertNull(track.localisedLanguage) + Assert.assertNull(track.localisedLanguage) } @Test @@ -60,7 +59,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns TEST_BLANK_STRING - assertNull(track.localisedLanguage) + Assert.assertNull(track.localisedLanguage) } @Test @@ -69,7 +68,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns LOCALISED_ENGLISH_CODE_NAME - assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localisedLanguage) + Assert.assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localisedLanguage) } private companion object { @@ -79,4 +78,4 @@ class TrackExtsTest { const val TEST_BLANK_STRING = " " } } -} +} \ No newline at end of file diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt new file mode 100644 index 00000000..dfe1876d --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -0,0 +1,90 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.THEOplayerGlobal +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(Enclosed::class) +class VersionUtilTest { + + @RunWith(JUnit4::class) + class RunForPlayerWithTest { + + private val actionAbove = mockk<() -> Unit>() + private val actionBelow = mockk<() -> Unit>() + + @Before + fun setUp() { + mockkStatic(THEOplayerGlobal::class) + + every { actionAbove.invoke() } returns Unit + every { actionBelow.invoke() } returns Unit + } + + @Test + fun `WHEN THEOplayerGlobal version is null THEN executes action for version below`() { + every { THEOplayerGlobal.getVersion() } returns null + + runForPlayerWith( + desiredMajorVersion = 2, + actionIfEqualOrAbove = actionAbove, + actionIfBelow = actionBelow, + ) + + verify { actionBelow() } + } + + @Test + fun `WHEN THEOplayerGlobal version is invalid THEN executes action for version below`() { + every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_INVALID + + runForPlayerWith( + desiredMajorVersion = 2, + actionIfEqualOrAbove = actionAbove, + actionIfBelow = actionBelow, + ) + + verify { actionBelow() } + } + + @Test + fun `WHEN THEOplayerGlobal version is valid and old THEN executes action for version below`() { + every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_OLD + + runForPlayerWith( + desiredMajorVersion = 2, + actionIfEqualOrAbove = actionAbove, + actionIfBelow = actionBelow, + ) + + verify { actionBelow() } + } + + @Test + fun `WHEN THEOplayerGlobal version is valid and new THEN executes action for version above`() { + every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_NEW + + runForPlayerWith( + desiredMajorVersion = 2, + actionIfEqualOrAbove = actionAbove, + actionIfBelow = actionBelow, + ) + + verify { actionAbove() } + } + + private companion object { + const val TEST_PLAYER_VERSION_INVALID = "invalid version" + const val TEST_PLAYER_VERSION_NEW = "2.3.1" + const val TEST_PLAYER_VERSION_OLD = "1.1.5" + } + } + +} From c06453b05ecc572214b815d421d3ab7b53611345 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 12:48:20 +0000 Subject: [PATCH 03/37] OPTI-1528: add CEA label formatting util --- .../com/theoplayer/android/ui/util/CeaUtil.kt | 20 +++++ .../theoplayer/android/ui/util/CeaUtilTest.kt | 88 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt create mode 100644 ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt new file mode 100644 index 00000000..36d91c32 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -0,0 +1,20 @@ +package com.theoplayer.android.ui.util + +import androidx.annotation.IntRange + +/** + * Creates a text track label for CEA-608 and CEA-708 formats. + * + * @return an optional string composed of a [channelNumber] and a prepended + * "CC" suffix, or `null` if the channel number is invalid. + */ +internal fun getLabelForChannelNumber( + @IntRange(from = 0L, to = 63L) channelNumber: Int?, +): String? { + // CEA-608 only supports channel numbers in [1, 4], + // while CEA-708 support service numbers in [1, 63]. + if (channelNumber !in 1..63) { + return null + } + return "CC${channelNumber}" +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt new file mode 100644 index 00000000..f5c58d75 --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt @@ -0,0 +1,88 @@ +package com.theoplayer.android.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Enclosed::class) +class CeaUtilTest { + + @RunWith(Parameterized::class) + class GetLabelForChannelNumberTest( + private val args: Args, + ) { + + @Test + fun `WHEN provided with a channel number THEN returns an expected label`() { + assertEquals( + args.expectedLabel, + getLabelForChannelNumber(args.channelNumber), + ) + } + + data class Args( + val channelNumber: Int?, + val expectedLabel: String?, + ) + + private companion object { + @JvmStatic + @Parameterized.Parameters + fun data() = arrayOf( + // Boundary checks. + Args( + channelNumber = null, + expectedLabel = null, + ), + Args( + channelNumber = -1, + expectedLabel = null, + ), + Args( + channelNumber = -100, + expectedLabel = null, + ), + Args( + channelNumber = 100, + expectedLabel = null, + ), + Args( + channelNumber = 64, + expectedLabel = null, + ), + Args( + channelNumber = 0, + expectedLabel = null, + ), + + // Regular checks. + Args( + channelNumber = 1, + expectedLabel = "CC1", + ), + Args( + channelNumber = 2, + expectedLabel = "CC2", + ), + Args( + channelNumber = 3, + expectedLabel = "CC3", + ), + Args( + channelNumber = 4, + expectedLabel = "CC4", + ), + Args( + channelNumber = 22, + expectedLabel = "CC22", + ), + Args( + channelNumber = 63, + expectedLabel = "CC63", + ), + ) + } + } +} From 290530255cfc9e33e302cc61e2cbe63ace5b9c94 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:17:56 +0000 Subject: [PATCH 04/37] OPTI-1528: add a CEA formatting checker with unit tests --- .../com/theoplayer/android/ui/util/CeaUtil.kt | 20 ++++ .../theoplayer/android/ui/util/CeaUtilTest.kt | 93 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index 36d91c32..2dc85896 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -2,6 +2,26 @@ package com.theoplayer.android.ui.util import androidx.annotation.IntRange +private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() + +/** + * Checks whether a provided label is CEA-608 or CEA-708 formed. + */ +internal fun isLabelCeaFormatted(label: String): Boolean { + val matchResult = CEA_FORMATTING_REGEX.find(label) + val groupValues = matchResult?.groupValues + if (matchResult == null || + groupValues == null || + // There is one group we want to match with the channel number. + groupValues.size != 2) { + return false + } + + val rawChannelNumber = groupValues[1] + val channelNumber = rawChannelNumber.toIntOrNull() + return !rawChannelNumber.startsWith("0") && channelNumber in 1..63 +} + /** * Creates a text track label for CEA-608 and CEA-708 formats. * diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt index f5c58d75..dac2eb96 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt @@ -9,6 +9,99 @@ import org.junit.runners.Parameterized @RunWith(Enclosed::class) class CeaUtilTest { + @RunWith(Parameterized::class) + class IsLabelCeaFormattedTest( + private val args: Args, + ) { + + @Test + fun `WHEN provided with a label THEN returns whether CEA formatted`() { + assertEquals( + args.expectedIsCeaFormatted, + isLabelCeaFormatted(args.label), + ) + } + + data class Args( + val label: String, + val expectedIsCeaFormatted: Boolean, + ) + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // False. + Args( + label = "", + expectedIsCeaFormatted = false, + ), + Args( + label = "abc", + expectedIsCeaFormatted = false, + ), + Args( + label = "Some label", + expectedIsCeaFormatted = false, + ), + Args( + label = "Text with cc1 inlined", + expectedIsCeaFormatted = false, + ), + Args( + label = "cC1", + expectedIsCeaFormatted = false, + ), + Args( + label = "Cc1", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC0", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC01", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC64", + expectedIsCeaFormatted = false, + ), + Args( + label = "CC128", + expectedIsCeaFormatted = false, + ), + + // True. + Args( + label = "CC1", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC2", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC3", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC4", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC22", + expectedIsCeaFormatted = true, + ), + Args( + label = "CC63", + expectedIsCeaFormatted = true, + ), + ) + } + } + @RunWith(Parameterized::class) class GetLabelForChannelNumberTest( private val args: Args, From 4c9b896cf0f7c7bf9282c4ac8c1cb7564bb4f8f9 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:43:06 +0000 Subject: [PATCH 05/37] OPTI-1528: substitute label with channelNumber for CEA tracks --- .../theoplayer/android/ui/AudioTrackList.kt | 2 +- .../java/com/theoplayer/android/ui/Helper.kt | 46 ++++++++++++++++--- .../com/theoplayer/android/ui/LanguageMenu.kt | 4 +- .../android/ui/SubtitleTrackList.kt | 2 +- .../com/theoplayer/android/ui/util/CeaUtil.kt | 6 ++- .../theoplayer/android/ui/util/CeaUtilTest.kt | 6 ++- 6 files changed, 54 insertions(+), 12 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt index a430fbf5..855fcb89 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt @@ -29,7 +29,7 @@ fun AudioTrackList( ) { val audioTrack = audioTracks[it] ListItem( - headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, + headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, leadingContent = { RadioButton( selected = (activeAudioTrack == audioTrack), 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 d85aefea..09fdae46 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,9 +1,16 @@ package com.theoplayer.android.ui +import android.content.res.Resources import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalResources import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import com.theoplayer.android.ui.util.getLabelForChannelNumber +import com.theoplayer.android.ui.util.isLabelCeaFormatted import com.theoplayer.android.ui.util.localisedLanguage +import com.theoplayer.android.ui.util.runForPlayerWith import kotlin.math.absoluteValue /** @@ -63,14 +70,41 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * @param track the media track or text track */ @Composable -fun formatTrackLabel(track: Track): String { - val label = track.label +fun rememberTrackLabel( + track: Track, + resources: Resources = LocalResources.current, +): String = remember(key1 = track.id, key2 = track.uid) { + val label: String? = runForPlayerWith( + // With 11 release, the player will no longer + // prefix text tracks with "CC" for CEA-608 and CEA-708, + // if [Track.label] is `null`. + desiredMajorVersion = 11, + actionIfEqualOrAbove = { track.label }, + actionIfBelow = { + if ((track is TextTrack) && isLabelCeaFormatted(track.label)) { + // If we are below 11th major release + // and the label is CEA-formatted we + // can safely assume it was the last resort + // option to produce a meaningful label, given + // we cannot localize the language code in the player. + null + } else { + track.label + } + }, + ) if (!label.isNullOrEmpty()) { - return label + return@remember label } val localisedLanguage = track.localisedLanguage if (localisedLanguage != null) { - return localisedLanguage + return@remember localisedLanguage } - return stringResource(R.string.theoplayer_ui_track_unknown) + if ((track is TextTrack) && track.type == TextTrackType.CEA608) { + val channelNumberLabel = getLabelForChannelNumber(track.channelNumber) + if (channelNumberLabel != null) { + return@remember channelNumberLabel + } + } + return@remember resources.getString(R.string.theoplayer_ui_track_unknown) } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index c3824d49..93d60b76 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -86,7 +86,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeAudioTrack?.let { formatTrackLabel(it) } + text = player?.activeAudioTrack?.let { rememberTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_audio_none ), @@ -115,7 +115,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( + text = player?.activeSubtitleTrack?.let { rememberTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index 8e70f194..7fbfdfd7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -45,7 +45,7 @@ fun SubtitleTrackList( ) { val audioTrack = subtitleTracks[it] ListItem( - headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, + headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, leadingContent = { RadioButton( selected = (activeSubtitleTrack == audioTrack), diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index 2dc85896..6a0c5cc7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -7,7 +7,11 @@ private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() /** * Checks whether a provided label is CEA-608 or CEA-708 formed. */ -internal fun isLabelCeaFormatted(label: String): Boolean { +internal fun isLabelCeaFormatted(label: String?): Boolean { + if (label == null) { + return false + } + val matchResult = CEA_FORMATTING_REGEX.find(label) val groupValues = matchResult?.groupValues if (matchResult == null || diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt index dac2eb96..8e0a7f3e 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt @@ -23,7 +23,7 @@ class CeaUtilTest { } data class Args( - val label: String, + val label: String?, val expectedIsCeaFormatted: Boolean, ) @@ -32,6 +32,10 @@ class CeaUtilTest { @Parameterized.Parameters(name = "{0}") fun data() = arrayOf( // False. + Args( + label = null, + expectedIsCeaFormatted = false, + ), Args( label = "", expectedIsCeaFormatted = false, From 8f724f7468745fde80de330c7dba1b3dbbdb4ed0 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:44:35 +0000 Subject: [PATCH 06/37] OPTI-1528: add a DASH stream example with CEA text tracks --- .../main/java/com/theoplayer/android/ui/demo/Streams.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt index aaf0cce5..838b0ed7 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt @@ -41,7 +41,14 @@ val streams by lazy { TypedSource.Builder("https://livesim.dashif.org/livesim/testpic_2s/Manifest.mpd") .build() ).build() - ) + ), + Stream( + title = "Test card (with CEA tracks)", + source = SourceDescription.Builder( + TypedSource.Builder("https://livesim2.dashif.org/vod/testpic_2s/cea608.mpd") + .build() + ).build() + ), ) } From 96b4176001372c49e46a77269d4d5a1d617a445e Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:46:44 +0000 Subject: [PATCH 07/37] OPTI-1528: check whether the label is null or blank --- ui/src/main/java/com/theoplayer/android/ui/Helper.kt | 2 +- ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 09fdae46..0a9dce33 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -93,7 +93,7 @@ fun rememberTrackLabel( } }, ) - if (!label.isNullOrEmpty()) { + if (!label.isNullOrBlank()) { return@remember label } val localisedLanguage = track.localisedLanguage diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index 6a0c5cc7..923e426b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui.util +import androidx.annotation.CheckResult import androidx.annotation.IntRange private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() @@ -7,6 +8,7 @@ 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 { if (label == null) { return false @@ -32,6 +34,7 @@ internal fun isLabelCeaFormatted(label: String?): Boolean { * @return an optional string composed of a [channelNumber] and a prepended * "CC" suffix, or `null` if the channel number is invalid. */ +@CheckResult internal fun getLabelForChannelNumber( @IntRange(from = 0L, to = 63L) channelNumber: Int?, ): String? { From db63be85436ba837e818733f3d5897761e3c23ac Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 15:38:24 +0000 Subject: [PATCH 08/37] OPTI-1528: address the review feedback --- .../java/com/theoplayer/android/ui/Helper.kt | 41 +---- .../com/theoplayer/android/ui/util/CeaUtil.kt | 14 +- .../theoplayer/android/ui/util/TrackExts.kt | 76 ++++++++- .../theoplayer/android/ui/util/VersionUtil.kt | 30 ++-- .../theoplayer/android/ui/util/CeaUtilTest.kt | 8 +- .../android/ui/util/TrackExtsTest.kt | 144 +++++++++++++++++- .../android/ui/util/VersionUtilTest.kt | 126 +++++++-------- 7 files changed, 290 insertions(+), 149 deletions(-) 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 0a9dce33..c29e7081 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -5,12 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalResources import com.theoplayer.android.api.player.track.Track -import com.theoplayer.android.api.player.track.texttrack.TextTrack -import com.theoplayer.android.api.player.track.texttrack.TextTrackType -import com.theoplayer.android.ui.util.getLabelForChannelNumber -import com.theoplayer.android.ui.util.isLabelCeaFormatted -import com.theoplayer.android.ui.util.localisedLanguage -import com.theoplayer.android.ui.util.runForPlayerWith +import com.theoplayer.android.ui.util.constructLabel import kotlin.math.absoluteValue /** @@ -74,37 +69,5 @@ fun rememberTrackLabel( track: Track, resources: Resources = LocalResources.current, ): String = remember(key1 = track.id, key2 = track.uid) { - val label: String? = runForPlayerWith( - // With 11 release, the player will no longer - // prefix text tracks with "CC" for CEA-608 and CEA-708, - // if [Track.label] is `null`. - desiredMajorVersion = 11, - actionIfEqualOrAbove = { track.label }, - actionIfBelow = { - if ((track is TextTrack) && isLabelCeaFormatted(track.label)) { - // If we are below 11th major release - // and the label is CEA-formatted we - // can safely assume it was the last resort - // option to produce a meaningful label, given - // we cannot localize the language code in the player. - null - } else { - track.label - } - }, - ) - if (!label.isNullOrBlank()) { - return@remember label - } - val localisedLanguage = track.localisedLanguage - if (localisedLanguage != null) { - return@remember localisedLanguage - } - if ((track is TextTrack) && track.type == TextTrackType.CEA608) { - val channelNumberLabel = getLabelForChannelNumber(track.channelNumber) - if (channelNumberLabel != null) { - return@remember channelNumberLabel - } - } - return@remember resources.getString(R.string.theoplayer_ui_track_unknown) + constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt index 923e426b..a6fd6857 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/CeaUtil.kt @@ -10,16 +10,14 @@ private val CEA_FORMATTING_REGEX = "^CC(\\d+)$".toRegex() */ @CheckResult internal fun isLabelCeaFormatted(label: String?): Boolean { - if (label == null) { + if (label.isNullOrEmpty()) { return false } - val matchResult = CEA_FORMATTING_REGEX.find(label) - val groupValues = matchResult?.groupValues - if (matchResult == null || - groupValues == null || - // There is one group we want to match with the channel number. - groupValues.size != 2) { + val matchResult = CEA_FORMATTING_REGEX.find(label) ?: return false + val groupValues = matchResult.groupValues + // There is one group we want to match with the channel number. + if (groupValues.size != 2) { return false } @@ -36,7 +34,7 @@ internal fun isLabelCeaFormatted(label: String?): Boolean { */ @CheckResult internal fun getLabelForChannelNumber( - @IntRange(from = 0L, to = 63L) channelNumber: Int?, + @IntRange(from = 0L, to = 63L) channelNumber: Int, ): String? { // CEA-608 only supports channel numbers in [1, 4], // while CEA-708 support service numbers in [1, 63]. diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index b4d62540..4c65930e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -1,7 +1,11 @@ package com.theoplayer.android.ui.util import androidx.annotation.CheckResult +import com.theoplayer.android.api.THEOplayerGlobal import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import com.theoplayer.android.ui.R import java.util.Locale private const val LANGUAGE_UNDEFINED = "und" @@ -15,7 +19,7 @@ private const val LANGUAGE_UNDEFINED = "und" * returns `null`. */ @get:CheckResult -internal val Track.localisedLanguage: String? +internal val Track.localizedLanguage: String? get() { val languageCode = this.language if (languageCode.isNullOrBlank() || languageCode == LANGUAGE_UNDEFINED) { @@ -30,3 +34,73 @@ internal val Track.localisedLanguage: String? return localisedLanguage } + +/** + * Constructs a label for the given [Track] instance. + * The method works slightly different for different player version. + * + * On version 10 and below the logic checks the following and condition + * and the first not `null` entry from the list: + * 1. Track label if is not a language code + * or a CEA-prefixed string. + * 2. Track language display name + * 3. Track channel number if a text CEA-608 track + * 4. Track label if was either a language code or a CEA-prefixed string + * + * If none of the above is satisfied, returns `null`. + * + * On version 11 and later the logic has slightly changed as + * the player no longer constructs the [Track.getLabel] internally: + * 1. Track label + * 2. Track language display name + * 3. Track channel number + */ +internal fun constructLabel( + track: Track, +): String? { + val playerVersion = getPlayerMajorVersion(THEOplayerGlobal.getVersion()) + + val label: String? = if( + playerVersion != null && + playerVersion < 11 && + (track is TextTrack) && + ( + isLabelCeaFormatted(track.label) || + (track.label != null && track.language == track.label) + )) { + // If we are below 11th major release + // and the label is CEA-formatted we + // can safely assume it was the last resort + // option to produce a meaningful label, given + // we cannot localize the language code in the player. + null + } else { + // With 11 release, the player will no longer + // prefix text tracks with "CC" for CEA-608 and CEA-708, + // if [Track.label] is `null`. + track.label + } + + if (!label.isNullOrBlank()) { + return label + } + + val localisedLanguage = track.localizedLanguage + if (localisedLanguage != null) { + return localisedLanguage + } + + if ((track is TextTrack) && + track.channelNumber != null && + track.type == TextTrackType.CEA608) { + val channelNumberLabel = getLabelForChannelNumber(track.channelNumber) + if (channelNumberLabel != null) { + return channelNumberLabel + } + if (!track.label.isNullOrBlank()) { + return track.label + } + } + + return null +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 97eb1b5e..2a7d625a 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,26 +1,18 @@ package com.theoplayer.android.ui.util -import com.theoplayer.android.api.THEOplayerGlobal - -private const val VERSION_DELIMITER = "." +private const val DEFAULT_VERSION_DELIMITER = "." /** - * Performs a player version check and executes an appropriate action: - * if the major version is equal or above to the [desiredMajorVersion] - * then [actionIfEqualOrAbove] is triggered, otherwise [actionIfBelow]. + * Extracts a major version number from + * a semver-formatted string. */ -internal inline fun runForPlayerWith( - desiredMajorVersion: Int, - actionIfEqualOrAbove: () -> T, - actionIfBelow: () -> T, -): T { - val version: String? = THEOplayerGlobal.getVersion() - val versionSplits = version?.split(VERSION_DELIMITER) - val majorVersionNumber = versionSplits?.getOrNull(0)?.toIntOrNull() - - return if (majorVersionNumber == null || majorVersionNumber < desiredMajorVersion) { - actionIfBelow() - } else { - actionIfEqualOrAbove() +internal fun getPlayerMajorVersion(version: String): Int? { + val versionSplits = version.split( + DEFAULT_VERSION_DELIMITER, + limit = 3, + ) + if (versionSplits.size != 3) { + return null } + return versionSplits.getOrNull(0)?.toIntOrNull() } diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt index 8e0a7f3e..0f95e156 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/CeaUtilTest.kt @@ -120,19 +120,15 @@ class CeaUtilTest { } data class Args( - val channelNumber: Int?, + val channelNumber: Int, val expectedLabel: String?, ) private companion object { @JvmStatic - @Parameterized.Parameters + @Parameterized.Parameters(name = "{0}") fun data() = arrayOf( // Boundary checks. - Args( - channelNumber = null, - expectedLabel = null, - ), Args( channelNumber = -1, expectedLabel = null, diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index 280d81f2..d183285c 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -1,15 +1,26 @@ package com.theoplayer.android.ui.util +import com.theoplayer.android.api.THEOplayerGlobal +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.EventType +import com.theoplayer.android.api.event.track.TrackEvent import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrackMode +import com.theoplayer.android.api.player.track.texttrack.TextTrackReadyState +import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import com.theoplayer.android.api.player.track.texttrack.cue.TextTrackCueList import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.experimental.runners.Enclosed import org.junit.runner.RunWith import org.junit.runners.JUnit4 +import org.junit.runners.Parameterized import java.util.Locale @RunWith(Enclosed::class) @@ -29,19 +40,19 @@ class TrackExtsTest { @Test fun `GIVEN language is null THEN localised language is also null`() { every { track.language } returns null - Assert.assertNull(track.localisedLanguage) + Assert.assertNull(track.localizedLanguage) } @Test fun `GIVEN language is und THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_UNDEFINED - Assert.assertNull(track.localisedLanguage) + Assert.assertNull(track.localizedLanguage) } @Test fun `GIVEN language is blank THEN localised language is null`() { every { track.language } returns TEST_BLANK_STRING - Assert.assertNull(track.localisedLanguage) + Assert.assertNull(track.localizedLanguage) } @Test @@ -50,7 +61,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns null - Assert.assertNull(track.localisedLanguage) + Assert.assertNull(track.localizedLanguage) } @Test @@ -59,7 +70,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns TEST_BLANK_STRING - Assert.assertNull(track.localisedLanguage) + Assert.assertNull(track.localizedLanguage) } @Test @@ -68,7 +79,7 @@ class TrackExtsTest { every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale every { locale.displayLanguage } returns LOCALISED_ENGLISH_CODE_NAME - Assert.assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localisedLanguage) + Assert.assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localizedLanguage) } private companion object { @@ -78,4 +89,123 @@ class TrackExtsTest { const val TEST_BLANK_STRING = " " } } -} \ No newline at end of file + + @RunWith(Parameterized::class) + class ConstructLabelTest( + private val args: Args, + ) { + + private val track = mockk() + + @Before + fun setUp() { + mockkStatic(THEOplayerGlobal::class) + every { THEOplayerGlobal.getVersion() } returns args.playerVersion + + every { track.type } returns TextTrackType.CEA608 + every { track.label } returns args.label + every { track.language } returns args.language +// every { track.channelNumber } returns args.channelNumber + + mockkStatic(Track::localizedLanguage) + every { any().localizedLanguage } returns args.localizedLanguageName + } + + @Test + fun `WHEN a valid track provided THEN returns a correct label`() { + assertEquals( + args.expectedLabel, + constructLabel(track), + ) + } + + data class Args( + val label: String?, + val language: String?, + val localizedLanguageName: String?, + val channelNumber: String?, + val playerVersion: String, + val expectedLabel: String?, + ) + + private companion object { + + const val TEST_PLAYER_VERSION_10 = "10.1.1" + const val TEST_PLAYER_VERSION_11 = "11.0.10" + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // Boundary checks. + Args( + label = null, + language = null, + localizedLanguageName = null, + channelNumber = null, + playerVersion = "", + expectedLabel = null, + ), + + // v10 checks. + Args( + label = "Hello world", + language = null, + localizedLanguageName = null, + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "Hello world", + ), + Args( + label = null, + language = "en", + localizedLanguageName = "English", + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + Args( + label = "en", + language = "en", + localizedLanguageName = "English", + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + Args( + label = "en", + language = null, + localizedLanguageName = null, + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "en", + ), + Args( + label = "CC1", + language = "en", + localizedLanguageName = "English", + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "English", + ), + + // v11 checks. + Args( + label = "Hello world", + language = null, + localizedLanguageName = null, + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "Hello world", + ), + Args( + label = "en", + language = "en", + localizedLanguageName = "English", + channelNumber = null, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "en", + ), + ) + } + } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index dfe1876d..fb88c3f3 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -1,89 +1,77 @@ package com.theoplayer.android.ui.util -import com.theoplayer.android.api.THEOplayerGlobal -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.verify -import org.junit.Before +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.experimental.runners.Enclosed import org.junit.runner.RunWith -import org.junit.runners.JUnit4 +import org.junit.runners.Parameterized @RunWith(Enclosed::class) class VersionUtilTest { - @RunWith(JUnit4::class) - class RunForPlayerWithTest { - - private val actionAbove = mockk<() -> Unit>() - private val actionBelow = mockk<() -> Unit>() - - @Before - fun setUp() { - mockkStatic(THEOplayerGlobal::class) - - every { actionAbove.invoke() } returns Unit - every { actionBelow.invoke() } returns Unit - } + @RunWith(Parameterized::class) + class RunForPlayerWithTest( + private val args: Args, + ) { @Test - fun `WHEN THEOplayerGlobal version is null THEN executes action for version below`() { - every { THEOplayerGlobal.getVersion() } returns null - - runForPlayerWith( - desiredMajorVersion = 2, - actionIfEqualOrAbove = actionAbove, - actionIfBelow = actionBelow, + fun `WHEN a version string provided THEN returns a correct major version`() { + assertEquals( + args.expectedMajorVersion, + getPlayerMajorVersion(args.version), ) - - verify { actionBelow() } } - @Test - fun `WHEN THEOplayerGlobal version is invalid THEN executes action for version below`() { - every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_INVALID - - runForPlayerWith( - desiredMajorVersion = 2, - actionIfEqualOrAbove = actionAbove, - actionIfBelow = actionBelow, - ) + data class Args( + val version: String, + val expectedMajorVersion: Int?, + ) - verify { actionBelow() } - } - - @Test - fun `WHEN THEOplayerGlobal version is valid and old THEN executes action for version below`() { - every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_OLD - - runForPlayerWith( - desiredMajorVersion = 2, - actionIfEqualOrAbove = actionAbove, - actionIfBelow = actionBelow, - ) - - verify { actionBelow() } - } - - @Test - fun `WHEN THEOplayerGlobal version is valid and new THEN executes action for version above`() { - every { THEOplayerGlobal.getVersion() } returns TEST_PLAYER_VERSION_NEW + private companion object { - runForPlayerWith( - desiredMajorVersion = 2, - actionIfEqualOrAbove = actionAbove, - actionIfBelow = actionBelow, + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + // Boundary checks. + Args( + version = "", + expectedMajorVersion = null, + ), + Args( + version = "not a version string", + expectedMajorVersion = null, + ), + Args( + version = "1.00", + expectedMajorVersion = null, + ), + + // Regular checks. + Args( + version = "11.0.0", + expectedMajorVersion = 11, + ), + Args( + version = "1.2.3", + expectedMajorVersion = 1, + ), + Args( + version = "9.8.7", + expectedMajorVersion = 9, + ), + Args( + version = "1.1.0-beta01", + expectedMajorVersion = 1, + ), + Args( + version = "2.1.0-beta.1.0", + expectedMajorVersion = 2, + ), + Args( + version = "16.8.2+01", + expectedMajorVersion = 16, + ), ) - - verify { actionAbove() } - } - - private companion object { - const val TEST_PLAYER_VERSION_INVALID = "invalid version" - const val TEST_PLAYER_VERSION_NEW = "2.3.1" - const val TEST_PLAYER_VERSION_OLD = "1.1.5" } } From ec229de8eec82d98a000e847da49f1d9cfeb1a6a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:33:19 +0100 Subject: [PATCH 09/37] Read `Track.channelNumber` using reflection --- .../theoplayer/android/ui/util/TrackExts.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 4c65930e..e2e3d1c1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -5,7 +5,7 @@ import com.theoplayer.android.api.THEOplayerGlobal import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType -import com.theoplayer.android.ui.R +import java.lang.reflect.Method import java.util.Locale private const val LANGUAGE_UNDEFINED = "und" @@ -90,10 +90,8 @@ internal fun constructLabel( return localisedLanguage } - if ((track is TextTrack) && - track.channelNumber != null && - track.type == TextTrackType.CEA608) { - val channelNumberLabel = getLabelForChannelNumber(track.channelNumber) + if ((track is TextTrack) && track.type == TextTrackType.CEA608) { + val channelNumberLabel = track.channelNumberCompat?.let { getLabelForChannelNumber(it) } if (channelNumberLabel != null) { return channelNumberLabel } @@ -104,3 +102,19 @@ internal fun constructLabel( return null } + +/** + * Returns [TextTrack.channelNumber], if available. + */ +private val TextTrack.channelNumberCompat: Int? + get() = textTrackChannelNumberGetter?.invoke(this) as? Int + +private val textTrackChannelNumberGetter: Method? by lazy { + try { + TextTrack::class.java.getDeclaredMethod("getChannelNumber").also { + check(it.returnType == Int::class.java) + } + } catch (_: Throwable) { + null + } +} From 8f5d777dbf1ef8dd793aff43e85e87f3127b0a49 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:34:34 +0100 Subject: [PATCH 10/37] Fix indentation --- .../main/java/com/theoplayer/android/ui/util/TrackExts.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index e2e3d1c1..3f6d2d78 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -60,14 +60,12 @@ internal fun constructLabel( ): String? { val playerVersion = getPlayerMajorVersion(THEOplayerGlobal.getVersion()) - val label: String? = if( + val label: String? = if ( playerVersion != null && playerVersion < 11 && (track is TextTrack) && - ( - isLabelCeaFormatted(track.label) || - (track.label != null && track.language == track.label) - )) { + (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) + ) { // If we are below 11th major release // and the label is CEA-formatted we // can safely assume it was the last resort From b6a03dccc0a0624747610e718b1052a4ffcbaf73 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:39:02 +0100 Subject: [PATCH 11/37] Simplify --- .../theoplayer/android/ui/util/TrackExts.kt | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 3f6d2d78..f3c52281 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -22,17 +22,10 @@ private const val LANGUAGE_UNDEFINED = "und" internal val Track.localizedLanguage: String? get() { val languageCode = this.language - if (languageCode.isNullOrBlank() || languageCode == LANGUAGE_UNDEFINED) { - return null - } - - val localisedLanguage = - Locale.forLanguageTag(languageCode).displayLanguage - if (localisedLanguage.isNullOrBlank()) { - return null - } - - return localisedLanguage + ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } + ?: return null + val localisedLanguage = Locale.forLanguageTag(languageCode).displayLanguage + return localisedLanguage.takeUnless { it.isBlank() } } /** @@ -79,23 +72,17 @@ internal fun constructLabel( track.label } - if (!label.isNullOrBlank()) { - return label - } + if (!label.isNullOrBlank()) return label - val localisedLanguage = track.localizedLanguage - if (localisedLanguage != null) { - return localisedLanguage - } + track.localizedLanguage?.let { return it } if ((track is TextTrack) && track.type == TextTrackType.CEA608) { - val channelNumberLabel = track.channelNumberCompat?.let { getLabelForChannelNumber(it) } - if (channelNumberLabel != null) { - return channelNumberLabel - } - if (!track.label.isNullOrBlank()) { - return track.label - } + track.channelNumberCompat + ?.let { getLabelForChannelNumber(it) } + ?.let { return it } + track.label + ?.takeUnless { it.isBlank() } + ?.let { return it } } return null From 6a74318cf8106217547b2a2ced120fe48cc05247 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:41:00 +0100 Subject: [PATCH 12/37] Tweak checks --- ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index f3c52281..61c7e972 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -51,12 +51,11 @@ internal val Track.localizedLanguage: String? internal fun constructLabel( track: Track, ): String? { - val playerVersion = getPlayerMajorVersion(THEOplayerGlobal.getVersion()) + val playerVersion = getPlayerMajorVersion(THEOplayerGlobal.getVersion()) ?: 0 val label: String? = if ( - playerVersion != null && - playerVersion < 11 && (track is TextTrack) && + playerVersion < 11 && (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) ) { // If we are below 11th major release From 5b9fe038aad6e296a36c6905767ced21cd612c7c Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 18:47:51 +0000 Subject: [PATCH 13/37] OPTI-1528: return locale full display name in its own locale --- ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 61c7e972..b37e35f1 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -24,7 +24,8 @@ internal val Track.localizedLanguage: String? val languageCode = this.language ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } ?: return null - val localisedLanguage = Locale.forLanguageTag(languageCode).displayLanguage + val locale = Locale.forLanguageTag(languageCode) + val localisedLanguage = locale.getDisplayName(locale) return localisedLanguage.takeUnless { it.isBlank() } } From 9940a86486b683a74bb871de754a624a7c20fd87 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Tue, 17 Mar 2026 11:03:07 +0000 Subject: [PATCH 14/37] OPTI-1528: fix reflection call --- gradle/libs.versions.toml | 2 +- .../theoplayer/android/ui/util/TrackExts.kt | 37 +++++---- .../android/ui/util/TrackExtsTest.kt | 79 ++++++++++++------- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22062b92..dd74eb41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ androidx-junit = "1.3.0" androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" dokka = "2.0.0" -theoplayer = { prefer="10.11.0", strictly = "[7.6.0, 11.0)" } +theoplayer = { prefer="10.13.0", strictly = "[7.6.0, 11.0)" } theoplayer-min = { strictly = "7.6.0" } core = "1.18.0" core-pip = "1.0.0-alpha02" diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index b37e35f1..77631852 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -19,14 +19,14 @@ private const val LANGUAGE_UNDEFINED = "und" * returns `null`. */ @get:CheckResult -internal val Track.localizedLanguage: String? +internal val Track.localizedLanguageName: String? get() { val languageCode = this.language ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } ?: return null val locale = Locale.forLanguageTag(languageCode) - val localisedLanguage = locale.getDisplayName(locale) - return localisedLanguage.takeUnless { it.isBlank() } + val localisedLanguage: String? = locale.getDisplayName(locale) + return localisedLanguage?.takeUnless { it.isBlank() } } /** @@ -38,7 +38,7 @@ internal val Track.localizedLanguage: String? * 1. Track label if is not a language code * or a CEA-prefixed string. * 2. Track language display name - * 3. Track channel number if a text CEA-608 track + * 3. Track caption channel if a text CEA-608 track * 4. Track label if was either a language code or a CEA-prefixed string * * If none of the above is satisfied, returns `null`. @@ -47,7 +47,7 @@ internal val Track.localizedLanguage: String? * the player no longer constructs the [Track.getLabel] internally: * 1. Track label * 2. Track language display name - * 3. Track channel number + * 3. Track caption channel */ internal fun constructLabel( track: Track, @@ -72,14 +72,17 @@ internal fun constructLabel( track.label } - if (!label.isNullOrBlank()) return label + if (!label.isNullOrBlank()) { + return label + } - track.localizedLanguage?.let { return it } + track.localizedLanguageName?.let { return it } if ((track is TextTrack) && track.type == TextTrackType.CEA608) { - track.channelNumberCompat + track.captionChannelCompat ?.let { getLabelForChannelNumber(it) } ?.let { return it } + track.label ?.takeUnless { it.isBlank() } ?.let { return it } @@ -89,17 +92,21 @@ internal fun constructLabel( } /** - * Returns [TextTrack.channelNumber], if available. + * Returns [TextTrack.getCaptionChannel], if available. */ -private val TextTrack.channelNumberCompat: Int? - get() = textTrackChannelNumberGetter?.invoke(this) as? Int +private val TextTrack.captionChannelCompat: Int? + get() = textTrackCaptionChannelGetter?.invoke(this) as? Int -private val textTrackChannelNumberGetter: Method? by lazy { +private val textTrackCaptionChannelGetter: Method? by lazy { try { - TextTrack::class.java.getDeclaredMethod("getChannelNumber").also { - check(it.returnType == Int::class.java) + TextTrack::class.java.getMethod("getCaptionChannel").also { + check(it.returnType.kotlin == Int::class) } - } catch (_: Throwable) { + } catch (_: NoSuchMethodException) { + null + } catch (_: SecurityException) { + null + } catch (_: IllegalStateException) { null } } diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index d183285c..1d580ccf 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -1,18 +1,14 @@ package com.theoplayer.android.ui.util import com.theoplayer.android.api.THEOplayerGlobal -import com.theoplayer.android.api.event.EventListener -import com.theoplayer.android.api.event.EventType -import com.theoplayer.android.api.event.track.TrackEvent import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.texttrack.TextTrack -import com.theoplayer.android.api.player.track.texttrack.TextTrackMode -import com.theoplayer.android.api.player.track.texttrack.TextTrackReadyState import com.theoplayer.android.api.player.track.texttrack.TextTrackType -import com.theoplayer.android.api.player.track.texttrack.cue.TextTrackCueList +import io.mockk.clearStaticMockk import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic +import org.junit.After import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Before @@ -27,7 +23,7 @@ import java.util.Locale class TrackExtsTest { @RunWith(JUnit4::class) - class LocalisedLanguageTest { + class LocalisedLanguageNameTest { private val track = mockk() private val locale = mockk() @@ -37,49 +33,54 @@ class TrackExtsTest { mockkStatic(Locale::class) } + @After + fun tearDown() { + clearStaticMockk(Locale::class) + } + @Test fun `GIVEN language is null THEN localised language is also null`() { every { track.language } returns null - Assert.assertNull(track.localizedLanguage) + Assert.assertNull(track.localizedLanguageName) } @Test fun `GIVEN language is und THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_UNDEFINED - Assert.assertNull(track.localizedLanguage) + Assert.assertNull(track.localizedLanguageName) } @Test fun `GIVEN language is blank THEN localised language is null`() { every { track.language } returns TEST_BLANK_STRING - Assert.assertNull(track.localizedLanguage) + Assert.assertNull(track.localizedLanguageName) } @Test fun `GIVEN locale returns null as displayLanguage THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_ENGLISH every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale - every { locale.displayLanguage } returns null + every { locale.getDisplayName(any()) } returns null - Assert.assertNull(track.localizedLanguage) + Assert.assertNull(track.localizedLanguageName) } @Test fun `GIVEN locale returns a blank string as displayLanguage THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_ENGLISH every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale - every { locale.displayLanguage } returns TEST_BLANK_STRING + every { locale.getDisplayName(any()) } returns TEST_BLANK_STRING - Assert.assertNull(track.localizedLanguage) + Assert.assertNull(track.localizedLanguageName) } @Test fun `GIVEN locale returns a valid display name THEN returns localised name`() { every { track.language } returns LANGUAGE_CODE_ENGLISH every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale - every { locale.displayLanguage } returns LOCALISED_ENGLISH_CODE_NAME + every { locale.getDisplayName(any()) } returns LOCALISED_ENGLISH_CODE_NAME - Assert.assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localizedLanguage) + assertEquals(LOCALISED_ENGLISH_CODE_NAME, track.localizedLanguageName) } private companion object { @@ -105,10 +106,16 @@ class TrackExtsTest { every { track.type } returns TextTrackType.CEA608 every { track.label } returns args.label every { track.language } returns args.language -// every { track.channelNumber } returns args.channelNumber + every { track.captionChannel } returns args.captionChannel + + mockkStatic(Track::localizedLanguageName) + every { any().localizedLanguageName } returns args.localizedLanguageName + } - mockkStatic(Track::localizedLanguage) - every { any().localizedLanguage } returns args.localizedLanguageName + @After + fun tearDown() { + clearStaticMockk(THEOplayerGlobal::class) + clearStaticMockk(Track::localizedLanguageName) } @Test @@ -123,7 +130,7 @@ class TrackExtsTest { val label: String?, val language: String?, val localizedLanguageName: String?, - val channelNumber: String?, + val captionChannel: Int?, val playerVersion: String, val expectedLabel: String?, ) @@ -141,7 +148,7 @@ class TrackExtsTest { label = null, language = null, localizedLanguageName = null, - channelNumber = null, + captionChannel = null, playerVersion = "", expectedLabel = null, ), @@ -151,7 +158,7 @@ class TrackExtsTest { label = "Hello world", language = null, localizedLanguageName = null, - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10, expectedLabel = "Hello world", ), @@ -159,7 +166,7 @@ class TrackExtsTest { label = null, language = "en", localizedLanguageName = "English", - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10, expectedLabel = "English", ), @@ -167,7 +174,7 @@ class TrackExtsTest { label = "en", language = "en", localizedLanguageName = "English", - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10, expectedLabel = "English", ), @@ -175,7 +182,7 @@ class TrackExtsTest { label = "en", language = null, localizedLanguageName = null, - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10, expectedLabel = "en", ), @@ -183,17 +190,25 @@ class TrackExtsTest { label = "CC1", language = "en", localizedLanguageName = "English", - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10, expectedLabel = "English", ), + Args( + label = null, + language = null, + localizedLanguageName = null, + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10, + expectedLabel = "CC1", + ), // v11 checks. Args( label = "Hello world", language = null, localizedLanguageName = null, - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_11, expectedLabel = "Hello world", ), @@ -201,10 +216,18 @@ class TrackExtsTest { label = "en", language = "en", localizedLanguageName = "English", - channelNumber = null, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_11, expectedLabel = "en", ), + Args( + label = null, + language = null, + localizedLanguageName = null, + captionChannel = 4, + playerVersion = TEST_PLAYER_VERSION_11, + expectedLabel = "CC4", + ), ) } } From e35cec6b06f622323009f58e970e679336bd66bc Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:44:58 +0100 Subject: [PATCH 15/37] Add `Version` class --- .../theoplayer/android/ui/util/TrackExts.kt | 4 +- .../theoplayer/android/ui/util/VersionUtil.kt | 48 ++++++++++++++----- .../android/ui/util/VersionUtilTest.kt | 4 +- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 77631852..f9aa3712 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -52,11 +52,11 @@ internal val Track.localizedLanguageName: String? internal fun constructLabel( track: Track, ): String? { - val playerVersion = getPlayerMajorVersion(THEOplayerGlobal.getVersion()) ?: 0 + val playerVersion = Version.parse(THEOplayerGlobal.getVersion()) ?: Version.ZERO val label: String? = if ( (track is TextTrack) && - playerVersion < 11 && + playerVersion.major < 11 && (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) ) { // If we are below 11th major release diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 2a7d625a..a3dfbe18 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,18 +1,44 @@ package com.theoplayer.android.ui.util -private const val DEFAULT_VERSION_DELIMITER = "." +private const val VERSION_DELIMITER = '.' /** - * Extracts a major version number from - * a semver-formatted string. + * A [semver](https://semver.org/) version. */ -internal fun getPlayerMajorVersion(version: String): Int? { - val versionSplits = version.split( - DEFAULT_VERSION_DELIMITER, - limit = 3, - ) - if (versionSplits.size != 3) { - return null +internal data class Version( + /** + * The major version. + */ + val major: Int, + /** + * The minor version. + */ + val minor: Int, + /** + * The patch (and prerelease) version. + */ + val patch: String, +) { + override fun toString() = buildString { + append(major) + append(VERSION_DELIMITER) + append(minor) + append(VERSION_DELIMITER) + append(patch) + } + + companion object { + val ZERO = Version(major = 0, minor = 0, patch = "0") + + fun parse(version: String): Version? { + val versionParts = version.split(VERSION_DELIMITER, limit = 3) + if (versionParts.size != 3) return null + val (major, minor, patch) = versionParts + return Version( + major = major.toIntOrNull() ?: return null, + minor = minor.toIntOrNull() ?: return null, + patch = patch + ) + } } - return versionSplits.getOrNull(0)?.toIntOrNull() } diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index fb88c3f3..27bbccb6 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -10,7 +10,7 @@ import org.junit.runners.Parameterized class VersionUtilTest { @RunWith(Parameterized::class) - class RunForPlayerWithTest( + class ParseVersionTest( private val args: Args, ) { @@ -18,7 +18,7 @@ class VersionUtilTest { fun `WHEN a version string provided THEN returns a correct major version`() { assertEquals( args.expectedMajorVersion, - getPlayerMajorVersion(args.version), + Version.parse(args.version)?.major, ) } From e61f50fda347778efa002b600330cb4a00efd104 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:46:09 +0100 Subject: [PATCH 16/37] Cache parsed THEOplayer version --- .../java/com/theoplayer/android/ui/Helper.kt | 20 +++++++++++++++++++ .../theoplayer/android/ui/util/TrackExts.kt | 5 +---- .../theoplayer/android/ui/util/VersionUtil.kt | 11 ++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) 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 c29e7081..25fd1c69 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -70,4 +70,24 @@ fun rememberTrackLabel( resources: Resources = LocalResources.current, ): String = remember(key1 = track.id, key2 = track.uid) { constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) +} + +/** + * Memoize the most recent call. + */ +internal inline fun memoizeLast(crossinline transform: (P) -> R): (P) -> R { + return object : (P) -> R { + private var lastCall: Pair? = null + + override fun invoke(input: P): R { + val lastCall = this.lastCall + return if (lastCall != null && lastCall.first == input) { + lastCall.second + } else { + transform(input).also { output -> + this.lastCall = input to output + } + } + } + } } \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index f9aa3712..f88ed703 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -1,7 +1,6 @@ package com.theoplayer.android.ui.util import androidx.annotation.CheckResult -import com.theoplayer.android.api.THEOplayerGlobal import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType @@ -52,11 +51,9 @@ internal val Track.localizedLanguageName: String? internal fun constructLabel( track: Track, ): String? { - val playerVersion = Version.parse(THEOplayerGlobal.getVersion()) ?: Version.ZERO - val label: String? = if ( (track is TextTrack) && - playerVersion.major < 11 && + theoplayerVersion.major < 11 && (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) ) { // If we are below 11th major release diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index a3dfbe18..8a9bc675 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,5 +1,8 @@ package com.theoplayer.android.ui.util +import com.theoplayer.android.api.THEOplayerGlobal +import com.theoplayer.android.ui.memoizeLast + private const val VERSION_DELIMITER = '.' /** @@ -42,3 +45,11 @@ internal data class Version( } } } + +private val getCachedTheoplayerVersion = memoizeLast(Version::parse) + +/** + * Returns the major version of THEOplayer. + */ +internal val theoplayerVersion: Version + get() = getCachedTheoplayerVersion(THEOplayerGlobal.getVersion()) ?: Version.ZERO From ba3da70bca5b2b8abb175a84a2445a6aa4460851 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:54:47 +0100 Subject: [PATCH 17/37] Throw if player version cannot be parsed --- .../theoplayer/android/ui/util/VersionUtil.kt | 26 +++++------ .../android/ui/util/TrackExtsTest.kt | 2 +- .../android/ui/util/VersionUtilTest.kt | 43 +++++++++++-------- 3 files changed, 41 insertions(+), 30 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 8a9bc675..15baa574 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -31,17 +31,19 @@ internal data class Version( } companion object { - val ZERO = Version(major = 0, minor = 0, patch = "0") - - fun parse(version: String): Version? { - val versionParts = version.split(VERSION_DELIMITER, limit = 3) - if (versionParts.size != 3) return null - val (major, minor, patch) = versionParts - return Version( - major = major.toIntOrNull() ?: return null, - minor = minor.toIntOrNull() ?: return null, - patch = patch - ) + fun parse(version: String): Version { + try { + val versionParts = version.split(VERSION_DELIMITER, limit = 3) + require(versionParts.size == 3) + val (major, minor, patch) = versionParts + return Version( + major = major.toInt(), + minor = minor.toInt(), + patch = patch + ) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid version", e) + } } } } @@ -52,4 +54,4 @@ private val getCachedTheoplayerVersion = memoizeLast(Version::parse) * Returns the major version of THEOplayer. */ internal val theoplayerVersion: Version - get() = getCachedTheoplayerVersion(THEOplayerGlobal.getVersion()) ?: Version.ZERO + get() = getCachedTheoplayerVersion(THEOplayerGlobal.getVersion()) diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index 1d580ccf..236ca2e3 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -149,7 +149,7 @@ class TrackExtsTest { language = null, localizedLanguageName = null, captionChannel = null, - playerVersion = "", + playerVersion = "0.0.0", expectedLabel = null, ), diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index 27bbccb6..12624aac 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -1,6 +1,7 @@ package com.theoplayer.android.ui.util import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows import org.junit.Test import org.junit.experimental.runners.Enclosed import org.junit.runner.RunWith @@ -18,13 +19,13 @@ class VersionUtilTest { fun `WHEN a version string provided THEN returns a correct major version`() { assertEquals( args.expectedMajorVersion, - Version.parse(args.version)?.major, + Version.parse(args.version).major, ) } data class Args( val version: String, - val expectedMajorVersion: Int?, + val expectedMajorVersion: Int, ) private companion object { @@ -32,21 +33,6 @@ class VersionUtilTest { @JvmStatic @Parameterized.Parameters(name = "{0}") fun data() = arrayOf( - // Boundary checks. - Args( - version = "", - expectedMajorVersion = null, - ), - Args( - version = "not a version string", - expectedMajorVersion = null, - ), - Args( - version = "1.00", - expectedMajorVersion = null, - ), - - // Regular checks. Args( version = "11.0.0", expectedMajorVersion = 11, @@ -75,4 +61,27 @@ class VersionUtilTest { } } + @RunWith(Parameterized::class) + class InvalidVersionTest( + private val version: String + ) { + + @Test + fun `WHEN an invalid version string provided THEN throws an error`() { + assertThrows(IllegalArgumentException::class.java) { + Version.parse(version) + } + } + + private companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data() = arrayOf( + "", + "not a version string", + "1.00" + ) + } + } + } From 1dc74662a9817e8ed032cc6075f1800ef3fc67af Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:55:46 +0100 Subject: [PATCH 18/37] Rename --- .../java/com/theoplayer/android/ui/util/VersionUtil.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 15baa574..92a7ebe4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -20,14 +20,14 @@ internal data class Version( /** * The patch (and prerelease) version. */ - val patch: String, + val patchAndPrerelease: String, ) { override fun toString() = buildString { append(major) append(VERSION_DELIMITER) append(minor) append(VERSION_DELIMITER) - append(patch) + append(patchAndPrerelease) } companion object { @@ -35,11 +35,11 @@ internal data class Version( try { val versionParts = version.split(VERSION_DELIMITER, limit = 3) require(versionParts.size == 3) - val (major, minor, patch) = versionParts + val (major, minor, patchAndPrerelease) = versionParts return Version( major = major.toInt(), minor = minor.toInt(), - patch = patch + patchAndPrerelease = patchAndPrerelease ) } catch (e: IllegalArgumentException) { throw IllegalArgumentException("Invalid version", e) From 1fdfcfc1df2f8ca0712eab62e7cf4c28aa0edba2 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:58:29 +0100 Subject: [PATCH 19/37] Test the whole version --- .../android/ui/util/VersionUtilTest.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index 12624aac..4141ba68 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -8,7 +8,7 @@ import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Enclosed::class) -class VersionUtilTest { +internal class VersionUtilTest { @RunWith(Parameterized::class) class ParseVersionTest( @@ -18,14 +18,14 @@ class VersionUtilTest { @Test fun `WHEN a version string provided THEN returns a correct major version`() { assertEquals( - args.expectedMajorVersion, - Version.parse(args.version).major, + args.expected, + Version.parse(args.version), ) } data class Args( val version: String, - val expectedMajorVersion: Int, + val expected: Version, ) private companion object { @@ -35,27 +35,27 @@ class VersionUtilTest { fun data() = arrayOf( Args( version = "11.0.0", - expectedMajorVersion = 11, + expected = Version(major = 11, minor = 0, patchAndPrerelease = "0"), ), Args( version = "1.2.3", - expectedMajorVersion = 1, + expected = Version(major = 1, minor = 2, patchAndPrerelease = "3"), ), Args( version = "9.8.7", - expectedMajorVersion = 9, + expected = Version(major = 9, minor = 8, patchAndPrerelease = "7"), ), Args( version = "1.1.0-beta01", - expectedMajorVersion = 1, + expected = Version(major = 1, minor = 1, patchAndPrerelease = "0-beta01"), ), Args( version = "2.1.0-beta.1.0", - expectedMajorVersion = 2, + expected = Version(major = 2, minor = 1, patchAndPrerelease = "0-beta.1.0"), ), Args( version = "16.8.2+01", - expectedMajorVersion = 16, + expected = Version(major = 16, minor = 8, patchAndPrerelease = "2+01"), ), ) } From 63bba7876bbfc4ce6482d0ab02d3253ab5df790b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 16:01:26 +0100 Subject: [PATCH 20/37] Improve test titles --- .../java/com/theoplayer/android/ui/util/VersionUtilTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index 4141ba68..b402abeb 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -26,7 +26,9 @@ internal class VersionUtilTest { data class Args( val version: String, val expected: Version, - ) + ) { + override fun toString(): String = version + } private companion object { From 650244704cb893bebf5cd813be7095e22617c5d5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:15:54 +0100 Subject: [PATCH 21/37] Make `Version` comparable --- .../theoplayer/android/ui/util/VersionUtil.kt | 9 +++++- .../android/ui/util/VersionUtilTest.kt | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 92a7ebe4..8c562c6e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -21,7 +21,7 @@ internal data class Version( * The patch (and prerelease) version. */ val patchAndPrerelease: String, -) { +) : Comparable { override fun toString() = buildString { append(major) append(VERSION_DELIMITER) @@ -30,6 +30,13 @@ internal data class Version( append(patchAndPrerelease) } + override fun compareTo(other: Version): Int { + return compareBy { it.major } + .thenBy { it.minor } + .thenBy { it.patchAndPrerelease } + .compare(this, other) + } + companion object { fun parse(version: String): Version { try { diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt index b402abeb..621fdcd6 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/VersionUtilTest.kt @@ -86,4 +86,34 @@ internal class VersionUtilTest { } } + class CompareVersionTest { + @Test + fun `WHEN left major is less than right major THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("4.0.0"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("4.0.0"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("11.0.0"), -1) + } + + @Test + fun `WHEN majors are equal and left minor is less than right major THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.1.0"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.10.0"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.6.0"), -1) + } + + @Test + fun `WHEN majors and minors are equal and left patch is less than right patch THEN left is less than right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.0.1"), -1) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.9.10"), -1) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.5.4"), -1) + } + + @Test + fun `WHEN majors, minors and patches are equal THEN left is equal to right`() { + assertEquals(Version.parse("3.0.0") compareTo Version.parse("3.0.0"), 0) + assertEquals(Version.parse("3.9.1") compareTo Version.parse("3.9.1"), 0) + assertEquals(Version.parse("10.5.3") compareTo Version.parse("10.5.3"), 0) + } + } + } From 785cb6df3f9339dfea3b1e64050fd57bee445c14 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:17:08 +0100 Subject: [PATCH 22/37] Move to `Compat` --- .../com/theoplayer/android/ui/util/Compat.kt | 24 +++++++++++++++++++ .../theoplayer/android/ui/util/TrackExts.kt | 21 ---------------- 2 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt new file mode 100644 index 00000000..cf240e76 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -0,0 +1,24 @@ +package com.theoplayer.android.ui.util + +import com.theoplayer.android.api.player.track.texttrack.TextTrack +import java.lang.reflect.Method + +/** + * Returns [TextTrack.getCaptionChannel], if available. + */ +internal val TextTrack.captionChannelCompat: Int? + get() = textTrackCaptionChannelGetter?.invoke(this) as? Int + +private val textTrackCaptionChannelGetter: Method? by lazy { + try { + TextTrack::class.java.getMethod("getCaptionChannel").also { + check(it.returnType.kotlin == Int::class) + } + } catch (_: NoSuchMethodException) { + null + } catch (_: SecurityException) { + null + } catch (_: IllegalStateException) { + null + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index f88ed703..a1cb19cb 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -4,7 +4,6 @@ import androidx.annotation.CheckResult import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType -import java.lang.reflect.Method import java.util.Locale private const val LANGUAGE_UNDEFINED = "und" @@ -87,23 +86,3 @@ internal fun constructLabel( return null } - -/** - * Returns [TextTrack.getCaptionChannel], if available. - */ -private val TextTrack.captionChannelCompat: Int? - get() = textTrackCaptionChannelGetter?.invoke(this) as? Int - -private val textTrackCaptionChannelGetter: Method? by lazy { - try { - TextTrack::class.java.getMethod("getCaptionChannel").also { - check(it.returnType.kotlin == Int::class) - } - } catch (_: NoSuchMethodException) { - null - } catch (_: SecurityException) { - null - } catch (_: IllegalStateException) { - null - } -} From 834a6089b7e1d36c4d063acbb3965c809cfb73f8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:26:25 +0100 Subject: [PATCH 23/37] Use helper class instead of reflection --- .../com/theoplayer/android/ui/util/Compat.kt | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt index cf240e76..98235502 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -1,24 +1,30 @@ package com.theoplayer.android.ui.util +import androidx.annotation.DoNotInline import com.theoplayer.android.api.player.track.texttrack.TextTrack -import java.lang.reflect.Method /** * Returns [TextTrack.getCaptionChannel], if available. */ internal val TextTrack.captionChannelCompat: Int? - get() = textTrackCaptionChannelGetter?.invoke(this) as? Int + get() { + // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. + return if (theoplayerVersion >= version1013) { + TheoPlayer1013Impl.getTextTrackCaptionChannel(this) + } else null + } + +private val version1013 = Version(major = 10, minor = 13, patchAndPrerelease = "0") -private val textTrackCaptionChannelGetter: Method? by lazy { - try { - TextTrack::class.java.getMethod("getCaptionChannel").also { - check(it.returnType.kotlin == Int::class) - } - } catch (_: NoSuchMethodException) { - null - } catch (_: SecurityException) { - null - } catch (_: IllegalStateException) { - null +/** + * This class must be loaded **only** with THEOplayer 10.13.0 or higher. + * + * This uses the same pattern as AndroidX AppCompat, + * see e.g. [androidx.appcompat.app.AppCompatDelegate.Api33Impl] + */ +private class TheoPlayer1013Impl private constructor() { + companion object { + @DoNotInline + fun getTextTrackCaptionChannel(track: TextTrack): Int? = track.captionChannel } } From 1fa3e797be70fbe69519946b24e0f31c16456e6a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 18 Mar 2026 11:32:58 +0100 Subject: [PATCH 24/37] Link to AndroidX docs --- ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt index 98235502..cb6d0a75 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -21,6 +21,8 @@ private val version1013 = Version(major = 10, minor = 13, patchAndPrerelease = " * * This uses the same pattern as AndroidX AppCompat, * see e.g. [androidx.appcompat.app.AppCompatDelegate.Api33Impl] + * 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) + * and [static shims](https://github.com/androidx/androidx/blob/androidx-main/docs/api_guidelines/platform_compat.md#static-shims-ex-viewcompat-static-shim). */ private class TheoPlayer1013Impl private constructor() { companion object { From 9316f4e130093265186f0ccfdf8a7fcfb7481e84 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 18 Mar 2026 13:14:59 +0100 Subject: [PATCH 25/37] Simplify mocking THEOplayer version --- .../java/com/theoplayer/android/ui/Helper.kt | 20 ------------------- .../com/theoplayer/android/ui/util/Compat.kt | 2 +- .../theoplayer/android/ui/util/TrackExts.kt | 2 +- .../theoplayer/android/ui/util/VersionUtil.kt | 14 ++++++------- .../android/ui/util/TrackExtsTest.kt | 9 +++++---- 5 files changed, 13 insertions(+), 34 deletions(-) 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 25fd1c69..16e8c557 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -71,23 +71,3 @@ fun rememberTrackLabel( ): String = remember(key1 = track.id, key2 = track.uid) { constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) } - -/** - * Memoize the most recent call. - */ -internal inline fun memoizeLast(crossinline transform: (P) -> R): (P) -> R { - return object : (P) -> R { - private var lastCall: Pair? = null - - override fun invoke(input: P): R { - val lastCall = this.lastCall - return if (lastCall != null && lastCall.first == input) { - lastCall.second - } else { - transform(input).also { output -> - this.lastCall = input to output - } - } - } - } -} \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt index cb6d0a75..164ed51b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/Compat.kt @@ -9,7 +9,7 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrack internal val TextTrack.captionChannelCompat: Int? get() { // TextTrack.getCaptionChannel was added in THEOplayer 10.13.0. - return if (theoplayerVersion >= version1013) { + return if (THEOplayerGlobalExt.version >= version1013) { TheoPlayer1013Impl.getTextTrackCaptionChannel(this) } else null } diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index a1cb19cb..54bb6c93 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -52,7 +52,7 @@ internal fun constructLabel( ): String? { val label: String? = if ( (track is TextTrack) && - theoplayerVersion.major < 11 && + THEOplayerGlobalExt.version.major < 11 && (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) ) { // If we are below 11th major release diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt index 8c562c6e..d654e984 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/VersionUtil.kt @@ -1,7 +1,6 @@ package com.theoplayer.android.ui.util import com.theoplayer.android.api.THEOplayerGlobal -import com.theoplayer.android.ui.memoizeLast private const val VERSION_DELIMITER = '.' @@ -55,10 +54,9 @@ internal data class Version( } } -private val getCachedTheoplayerVersion = memoizeLast(Version::parse) - -/** - * Returns the major version of THEOplayer. - */ -internal val theoplayerVersion: Version - get() = getCachedTheoplayerVersion(THEOplayerGlobal.getVersion()) +internal object THEOplayerGlobalExt { + /** + * Returns the version of THEOplayer, as a [Version]. + */ + val version: Version by lazy { Version.parse(THEOplayerGlobal.getVersion()) } +} diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index 236ca2e3..a4befdaf 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -1,12 +1,13 @@ package com.theoplayer.android.ui.util -import com.theoplayer.android.api.THEOplayerGlobal import com.theoplayer.android.api.player.track.Track import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackType +import io.mockk.clearMocks import io.mockk.clearStaticMockk import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import org.junit.After import org.junit.Assert @@ -100,8 +101,8 @@ class TrackExtsTest { @Before fun setUp() { - mockkStatic(THEOplayerGlobal::class) - every { THEOplayerGlobal.getVersion() } returns args.playerVersion + mockkObject(THEOplayerGlobalExt) + every { THEOplayerGlobalExt.version } returns Version.parse(args.playerVersion) every { track.type } returns TextTrackType.CEA608 every { track.label } returns args.label @@ -114,7 +115,7 @@ class TrackExtsTest { @After fun tearDown() { - clearStaticMockk(THEOplayerGlobal::class) + clearMocks(THEOplayerGlobalExt) clearStaticMockk(Track::localizedLanguageName) } From c07dbb2e96306280637bf74590c941d29c099a3d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 13:43:01 +0100 Subject: [PATCH 26/37] Require THEOplayer 10.13.0 for compiling --- gradle/libs.versions.toml | 2 ++ ui/build.gradle.kts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd74eb41..935679d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" dokka = "2.0.0" theoplayer = { prefer="10.13.0", strictly = "[7.6.0, 11.0)" } +theoplayer-compile = { prefer = "10.+", strictly = "[10.13.0, 11.0)" } theoplayer-min = { strictly = "7.6.0" } core = "1.18.0" core-pip = "1.0.0-alpha02" @@ -54,6 +55,7 @@ theoplayer-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "int theoplayer-min = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer-min" } theoplayer-min-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer-min" } theoplayer-min-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer-min" } +theoplayer-compile = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer-compile" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 918d0db7..96fdcf2a 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -87,7 +87,9 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.testManifest) + compileOnly(libs.theoplayer.compile) api(libs.theoplayer) + testImplementation(libs.theoplayer) dokkaPlugin(libs.dokka.plugin) } From 88121b455d12ceb642f4eeefab9f1a6628a26e00 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 13:43:24 +0100 Subject: [PATCH 27/37] Prefer latest THEOplayer 10.x version --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 935679d3..08400876 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ androidx-junit = "1.3.0" androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" dokka = "2.0.0" -theoplayer = { prefer="10.13.0", strictly = "[7.6.0, 11.0)" } +theoplayer = { prefer = "10.+", strictly = "[7.6.0, 11.0)" } theoplayer-compile = { prefer = "10.+", strictly = "[10.13.0, 11.0)" } theoplayer-min = { strictly = "7.6.0" } core = "1.18.0" From 52e5efc0dbbae570f8868df7bf37335e7be7580f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 13:47:39 +0100 Subject: [PATCH 28/37] Don't test with null `Locale.getDisplayName()` --- .../java/com/theoplayer/android/ui/util/TrackExts.kt | 4 ++-- .../java/com/theoplayer/android/ui/util/TrackExtsTest.kt | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 54bb6c93..33f1557b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -23,8 +23,8 @@ internal val Track.localizedLanguageName: String? ?.takeUnless { it.isBlank() || it == LANGUAGE_UNDEFINED } ?: return null val locale = Locale.forLanguageTag(languageCode) - val localisedLanguage: String? = locale.getDisplayName(locale) - return localisedLanguage?.takeUnless { it.isBlank() } + val localisedLanguage = locale.getDisplayName(locale).orEmpty() + return localisedLanguage.takeUnless { it.isBlank() } } /** diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index a4befdaf..d9ff2a08 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -57,15 +57,6 @@ class TrackExtsTest { Assert.assertNull(track.localizedLanguageName) } - @Test - fun `GIVEN locale returns null as displayLanguage THEN localised language is null`() { - every { track.language } returns LANGUAGE_CODE_ENGLISH - every { Locale.forLanguageTag(eq(LANGUAGE_CODE_ENGLISH)) } returns locale - every { locale.getDisplayName(any()) } returns null - - Assert.assertNull(track.localizedLanguageName) - } - @Test fun `GIVEN locale returns a blank string as displayLanguage THEN localised language is null`() { every { track.language } returns LANGUAGE_CODE_ENGLISH From f601bcb0a7ae2b89785026cd54535c564a6d901b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 13:57:09 +0100 Subject: [PATCH 29/37] Rename --- .../android/ui/util/TrackExtsTest.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index d9ff2a08..a753fc57 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -129,8 +129,8 @@ class TrackExtsTest { private companion object { - const val TEST_PLAYER_VERSION_10 = "10.1.1" - const val TEST_PLAYER_VERSION_11 = "11.0.10" + const val TEST_PLAYER_VERSION_10_0 = "10.0.0" + const val TEST_PLAYER_VERSION_11_0 = "11.0.0" @JvmStatic @Parameterized.Parameters(name = "{0}") @@ -145,13 +145,13 @@ class TrackExtsTest { expectedLabel = null, ), - // v10 checks. + // v10.0 checks. Args( label = "Hello world", language = null, localizedLanguageName = null, captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "Hello world", ), Args( @@ -159,7 +159,7 @@ class TrackExtsTest { language = "en", localizedLanguageName = "English", captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "English", ), Args( @@ -167,7 +167,7 @@ class TrackExtsTest { language = "en", localizedLanguageName = "English", captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "English", ), Args( @@ -175,7 +175,7 @@ class TrackExtsTest { language = null, localizedLanguageName = null, captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "en", ), Args( @@ -183,7 +183,7 @@ class TrackExtsTest { language = "en", localizedLanguageName = "English", captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "English", ), Args( @@ -191,17 +191,17 @@ class TrackExtsTest { language = null, localizedLanguageName = null, captionChannel = 1, - playerVersion = TEST_PLAYER_VERSION_10, + playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "CC1", ), - // v11 checks. + // v11.0 checks. Args( label = "Hello world", language = null, localizedLanguageName = null, captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_11, + playerVersion = TEST_PLAYER_VERSION_11_0, expectedLabel = "Hello world", ), Args( @@ -209,7 +209,7 @@ class TrackExtsTest { language = "en", localizedLanguageName = "English", captionChannel = null, - playerVersion = TEST_PLAYER_VERSION_11, + playerVersion = TEST_PLAYER_VERSION_11_0, expectedLabel = "en", ), Args( @@ -217,7 +217,7 @@ class TrackExtsTest { language = null, localizedLanguageName = null, captionChannel = 4, - playerVersion = TEST_PLAYER_VERSION_11, + playerVersion = TEST_PLAYER_VERSION_11_0, expectedLabel = "CC4", ), ) From 475dd6e4ac66848e06f25f840a9cf3b1201ac546 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:05:12 +0100 Subject: [PATCH 30/37] Fix tests --- .../android/ui/util/TrackExtsTest.kt | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt index a753fc57..88d99617 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/util/TrackExtsTest.kt @@ -130,6 +130,7 @@ class TrackExtsTest { private companion object { const val TEST_PLAYER_VERSION_10_0 = "10.0.0" + const val TEST_PLAYER_VERSION_10_13 = "10.13.0" const val TEST_PLAYER_VERSION_11_0 = "11.0.0" @JvmStatic @@ -146,6 +147,8 @@ class TrackExtsTest { ), // v10.0 checks. + // - Track.captionChannel is never set + // - Track.label can be "CC1" or "CC2" Args( label = "Hello world", language = null, @@ -187,20 +190,74 @@ class TrackExtsTest { expectedLabel = "English", ), Args( - label = null, + label = "CC1", language = null, localizedLanguageName = null, - captionChannel = 1, + captionChannel = null, playerVersion = TEST_PLAYER_VERSION_10_0, expectedLabel = "CC1", ), + // v10.13 checks. + // - Track.captionChannel is always set + // - Track.label can be "CC1" or "CC2" + Args( + label = "Hello world", + language = null, + localizedLanguageName = null, + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "Hello world", + ), + Args( + label = "CC1", + language = "en", + localizedLanguageName = "English", + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "English", + ), + Args( + label = "en", + language = "en", + localizedLanguageName = "English", + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "English", + ), + Args( + label = "en", + language = null, + localizedLanguageName = null, + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "en", + ), + Args( + label = "CC1", + language = "en", + localizedLanguageName = "English", + captionChannel = 1, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "English", + ), + Args( + label = "CC4", + language = null, + localizedLanguageName = null, + captionChannel = 4, + playerVersion = TEST_PLAYER_VERSION_10_13, + expectedLabel = "CC4", + ), + // v11.0 checks. + // - Track.captionChannel is always set + // - Track.label must not be "CC1" or "CC2" Args( label = "Hello world", language = null, localizedLanguageName = null, - captionChannel = null, + captionChannel = 1, playerVersion = TEST_PLAYER_VERSION_11_0, expectedLabel = "Hello world", ), @@ -208,7 +265,7 @@ class TrackExtsTest { label = "en", language = "en", localizedLanguageName = "English", - captionChannel = null, + captionChannel = 1, playerVersion = TEST_PLAYER_VERSION_11_0, expectedLabel = "en", ), From 38a83be57ce941da1e9ed255a3482ab98cab9654 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:12:26 +0100 Subject: [PATCH 31/37] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab01b185..afcd5ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ > - 🏠 Internal > - 💅 Polish +## Unreleased + +* 🐛 The language menu now shows CEA-608/708 closed caption tracks with their localized language name (if available) instead of their channel number (e.g. "CC1"). ([#84](https://github.com/THEOplayer/android-ui/pull/84)) + ## v1.13.3 (2026-03-23) * 🐛 Changed the minimum supported THEOplayer version to 7.6.0. ([#85](https://github.com/THEOplayer/android-ui/pull/85)) From 2a818cf65f5e643cd5de1e2de5866fa1732e22a3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:15:05 +0100 Subject: [PATCH 32/37] Tweak --- ui/src/main/java/com/theoplayer/android/ui/Helper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 16e8c557..7099a821 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -68,6 +68,6 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals fun rememberTrackLabel( track: Track, resources: Resources = LocalResources.current, -): String = remember(key1 = track.id, key2 = track.uid) { +): String = remember(track.id, track.uid) { constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) } From d575f556109cc6eb58ff42e91f03339184d1d664 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:22:29 +0100 Subject: [PATCH 33/37] Add overloads for MediaTrack and TextTrack --- .../java/com/theoplayer/android/ui/Helper.kt | 33 +++++++++++++++++++ .../theoplayer/android/ui/util/TrackExts.kt | 22 +++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) 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 7099a821..03b49032 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalResources import com.theoplayer.android.api.player.track.Track +import com.theoplayer.android.api.player.track.mediatrack.MediaTrack +import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.ui.util.constructLabel import kotlin.math.absoluteValue @@ -68,6 +70,37 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals fun rememberTrackLabel( track: Track, resources: Resources = LocalResources.current, +): String = remember(track.id, track.uid) { + val label = when (track) { + is TextTrack -> constructLabel(track) + is MediaTrack<*> -> constructLabel(track) + else -> null + } + label ?: resources.getString(R.string.theoplayer_ui_track_unknown) +} + +/** + * Return a human-readable label for the given media track. + * + * @param track the media track + */ +@Composable +fun rememberTrackLabel( + track: MediaTrack<*>, + resources: Resources = LocalResources.current, +): String = remember(track.id, track.uid) { + constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) +} + +/** + * Return a human-readable label for the given text track. + * + * @param track the text track + */ +@Composable +fun rememberTrackLabel( + track: TextTrack, + resources: Resources = LocalResources.current, ): String = remember(track.id, track.uid) { constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt index 33f1557b..be4d80d0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/TrackExts.kt @@ -2,6 +2,7 @@ package com.theoplayer.android.ui.util import androidx.annotation.CheckResult 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 java.util.Locale @@ -28,7 +29,19 @@ internal val Track.localizedLanguageName: String? } /** - * Constructs a label for the given [Track] instance. + * Constructs a label for the given [MediaTrack] instance. + * + * This returns the first non-empty entry from the list: + * 1. Track label + * 2. Track language display name + */ +internal fun constructLabel(track: MediaTrack<*>): String? { + return track.label?.takeUnless { it.isBlank() } + ?: track.localizedLanguageName +} + +/** + * Constructs a label for the given [TextTrack] instance. * The method works slightly different for different player version. * * On version 10 and below the logic checks the following and condition @@ -47,11 +60,8 @@ internal val Track.localizedLanguageName: String? * 2. Track language display name * 3. Track caption channel */ -internal fun constructLabel( - track: Track, -): String? { +internal fun constructLabel(track: TextTrack): String? { val label: String? = if ( - (track is TextTrack) && THEOplayerGlobalExt.version.major < 11 && (isLabelCeaFormatted(track.label) || (track.label != null && track.language == track.label)) ) { @@ -74,7 +84,7 @@ internal fun constructLabel( track.localizedLanguageName?.let { return it } - if ((track is TextTrack) && track.type == TextTrackType.CEA608) { + if (track.type == TextTrackType.CEA608) { track.captionChannelCompat ?.let { getLabelForChannelNumber(it) } ?.let { return it } From d08ac128bd754d45dbaa027e298a0a962be242c6 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:31:01 +0100 Subject: [PATCH 34/37] Rename --- .../java/com/theoplayer/android/ui/SubtitleTrackList.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index 7fbfdfd7..9d3bb55d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -43,17 +43,17 @@ fun SubtitleTrackList( count = subtitleTracks.size, key = { subtitleTracks[it].uid } ) { - val audioTrack = subtitleTracks[it] + val subtitleTrack = subtitleTracks[it] ListItem( - headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, + headlineContent = { Text(text = rememberTrackLabel(subtitleTrack)) }, leadingContent = { RadioButton( - selected = (activeSubtitleTrack == audioTrack), + selected = (activeSubtitleTrack == subtitleTrack), onClick = null ) }, modifier = Modifier.clickable(onClick = { - player?.activeSubtitleTrack = audioTrack + player?.activeSubtitleTrack = subtitleTrack onClick?.let { it() } }) ) From 010c53f9735ffda6b4619048ab958e8d0a21a304 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:39:35 +0100 Subject: [PATCH 35/37] Simplify --- .../java/com/theoplayer/android/ui/Helper.kt | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) 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 03b49032..d09f729e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -1,9 +1,8 @@ package com.theoplayer.android.ui -import android.content.res.Resources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalResources +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 @@ -67,16 +66,14 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * @param track the media track or text track */ @Composable -fun rememberTrackLabel( - track: Track, - resources: Resources = LocalResources.current, -): String = remember(track.id, track.uid) { - val label = when (track) { - is TextTrack -> constructLabel(track) - is MediaTrack<*> -> constructLabel(track) - else -> null - } - label ?: resources.getString(R.string.theoplayer_ui_track_unknown) +fun rememberTrackLabel(track: Track): String { + return remember(track.id, track.uid) { + when (track) { + is TextTrack -> constructLabel(track) + is MediaTrack<*> -> constructLabel(track) + else -> null + } + } ?: stringResource(R.string.theoplayer_ui_track_unknown) } /** @@ -85,11 +82,9 @@ fun rememberTrackLabel( * @param track the media track */ @Composable -fun rememberTrackLabel( - track: MediaTrack<*>, - resources: Resources = LocalResources.current, -): String = remember(track.id, track.uid) { - constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) +fun rememberTrackLabel(track: MediaTrack<*>): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(R.string.theoplayer_ui_track_unknown) } /** @@ -98,9 +93,7 @@ fun rememberTrackLabel( * @param track the text track */ @Composable -fun rememberTrackLabel( - track: TextTrack, - resources: Resources = LocalResources.current, -): String = remember(track.id, track.uid) { - constructLabel(track) ?: resources.getString(R.string.theoplayer_ui_track_unknown) +fun rememberTrackLabel(track: TextTrack): String { + return remember(track.id, track.uid) { constructLabel(track) } + ?: stringResource(R.string.theoplayer_ui_track_unknown) } From ba3170e26dde5ec39144467d4666b005bd189566 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:43:15 +0100 Subject: [PATCH 36/37] Rename back to formatTrackLabel --- .../main/java/com/theoplayer/android/ui/AudioTrackList.kt | 2 +- ui/src/main/java/com/theoplayer/android/ui/Helper.kt | 6 +++--- ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt | 4 ++-- .../java/com/theoplayer/android/ui/SubtitleTrackList.kt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt index 855fcb89..a430fbf5 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/AudioTrackList.kt @@ -29,7 +29,7 @@ fun AudioTrackList( ) { val audioTrack = audioTracks[it] ListItem( - headlineContent = { Text(text = rememberTrackLabel(audioTrack)) }, + headlineContent = { Text(text = formatTrackLabel(audioTrack)) }, leadingContent = { RadioButton( selected = (activeAudioTrack == audioTrack), 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 d09f729e..aa6a4852 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Helper.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Helper.kt @@ -66,7 +66,7 @@ fun formatTime(time: Double, guide: Double = 0.0, preferNegative: Boolean = fals * @param track the media track or text track */ @Composable -fun rememberTrackLabel(track: Track): String { +fun formatTrackLabel(track: Track): String { return remember(track.id, track.uid) { when (track) { is TextTrack -> constructLabel(track) @@ -82,7 +82,7 @@ fun rememberTrackLabel(track: Track): String { * @param track the media track */ @Composable -fun rememberTrackLabel(track: MediaTrack<*>): String { +fun formatTrackLabel(track: MediaTrack<*>): String { return remember(track.id, track.uid) { constructLabel(track) } ?: stringResource(R.string.theoplayer_ui_track_unknown) } @@ -93,7 +93,7 @@ fun rememberTrackLabel(track: MediaTrack<*>): String { * @param track the text track */ @Composable -fun rememberTrackLabel(track: TextTrack): String { +fun formatTrackLabel(track: TextTrack): String { return remember(track.id, track.uid) { constructLabel(track) } ?: stringResource(R.string.theoplayer_ui_track_unknown) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt index 93d60b76..c3824d49 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/LanguageMenu.kt @@ -86,7 +86,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeAudioTrack?.let { rememberTrackLabel(it) } + text = player?.activeAudioTrack?.let { formatTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_audio_none ), @@ -115,7 +115,7 @@ fun MenuScope.LanguageMenuCompact() { ) { Text( modifier = Modifier.weight(1f), - text = player?.activeSubtitleTrack?.let { rememberTrackLabel(it) } ?: stringResource( + text = player?.activeSubtitleTrack?.let { formatTrackLabel(it) } ?: stringResource( R.string.theoplayer_ui_subtitles_off ), textAlign = TextAlign.Center diff --git a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt index 9d3bb55d..c29b3a99 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SubtitleTrackList.kt @@ -45,7 +45,7 @@ fun SubtitleTrackList( ) { val subtitleTrack = subtitleTracks[it] ListItem( - headlineContent = { Text(text = rememberTrackLabel(subtitleTrack)) }, + headlineContent = { Text(text = formatTrackLabel(subtitleTrack)) }, leadingContent = { RadioButton( selected = (activeSubtitleTrack == subtitleTrack), From 2c389a22d2abba45abd3e68cf5ac4b7058b44615 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 27 Mar 2026 14:45:14 +0100 Subject: [PATCH 37/37] Tweak changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afcd5ac8..118a2e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## Unreleased -* 🐛 The language menu now shows CEA-608/708 closed caption tracks with their localized language name (if available) instead of their channel number (e.g. "CC1"). ([#84](https://github.com/THEOplayer/android-ui/pull/84)) +* 🐛 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)) ## v1.13.3 (2026-03-23)