From 1a056d58c15cfcee182fe88baa111cac96fd3c60 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 11:27:17 +0000 Subject: [PATCH 01/25] 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 e1932a3c..f73e0a2e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ activity-compose = "1.10.1" 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" @@ -35,6 +36,7 @@ androidx-compose-ui-toolingPreview = { group = "androidx.compose.ui", name = "ui androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" } androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } playServices-castFramework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServices-castFramework" } gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" } dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref = "dokka" } 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 55f3620352fba585fdd0b5b2fc1bdcfdf701b474 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 12:29:27 +0000 Subject: [PATCH 02/25] 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 c1112058c3f45174e465a393ebaa295a3adbd283 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 12:48:20 +0000 Subject: [PATCH 03/25] 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 9c78eaf94cfe2c2fa7c2c79005fe123deab74514 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:17:56 +0000 Subject: [PATCH 04/25] 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 9cddc51c66fc328bacdb21e512f4807a38501afe Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:43:06 +0000 Subject: [PATCH 05/25] 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 41b48c375a441c199d0c40e434c061875ece0b5a Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:44:35 +0000 Subject: [PATCH 06/25] 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 b8b3d7dede53bef40e861cbff593c81bf547ac30 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 13:46:44 +0000 Subject: [PATCH 07/25] 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 2aea86a5d4aef0195c18a39966256f0dc23e8847 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 15:38:24 +0000 Subject: [PATCH 08/25] 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 04d0ef44564401effdc86610a468629664004712 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:33:19 +0100 Subject: [PATCH 09/25] 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 db459c5b987e64671cd2c9114bb6f79916821404 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:34:34 +0100 Subject: [PATCH 10/25] 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 9e0d8c1209acc380abf39d7b07a02b1b70e6ebf3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:39:02 +0100 Subject: [PATCH 11/25] 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 5f33a3391fa9ea131f7487fce82d4e20725a36cd Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 16 Mar 2026 18:41:00 +0100 Subject: [PATCH 12/25] 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 933465de4fb09921eacd9e50230fb6cb080a3568 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Mon, 16 Mar 2026 18:47:51 +0000 Subject: [PATCH 13/25] 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 b7c75ef8b5dd22f6314fc2d7167914f0299982c0 Mon Sep 17 00:00:00 2001 From: Alex Dadukin Date: Tue, 17 Mar 2026 11:03:07 +0000 Subject: [PATCH 14/25] 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 f73e0a2e..230bf242 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)" } [libraries] androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" } 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 124e19fdd9ede9cf932bfac9deb1df3783979361 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:44:58 +0100 Subject: [PATCH 15/25] 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 597050f30763570ba9caf85457116f7adc7a23f9 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:46:09 +0100 Subject: [PATCH 16/25] 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 cc6717eea684761e7e1d77411835e82058113cfe Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:54:47 +0100 Subject: [PATCH 17/25] 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 0de6af2671ab5fb51b7c11b15228bd098efad47a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:55:46 +0100 Subject: [PATCH 18/25] 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 8f77f2c205095b99e918ce03232ecd56cb465805 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 15:58:29 +0100 Subject: [PATCH 19/25] 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 c76d6acafaa01f6d5d013191d07113073c5e9e9d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 16:01:26 +0100 Subject: [PATCH 20/25] 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 6e1bd6481423b8476fa579e0bb11f5d003b6b55d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:15:54 +0100 Subject: [PATCH 21/25] 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 3800c218c13546dcf9a19ec9c1fa343e2a13e307 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:17:08 +0100 Subject: [PATCH 22/25] 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 d8c6bfd8e35514269d914ea7790d2a62800d0c06 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Tue, 17 Mar 2026 17:26:25 +0100 Subject: [PATCH 23/25] 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 8c3901bcdb7e088f08367ea328d0b22264fe7dac Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 18 Mar 2026 11:32:58 +0100 Subject: [PATCH 24/25] 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 a89ddbe257f152b409b4e217d772b494b6f1793b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 18 Mar 2026 13:14:59 +0100 Subject: [PATCH 25/25] 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) }