diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt index f4b1f0b4f..8c28655f1 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt @@ -1,22 +1,20 @@ package org.jellyfin.androidtv.util.profile -import android.content.Context -import android.media.MediaCodecInfo import android.media.MediaCodecInfo.CodecProfileLevel import android.media.MediaCodecList import android.media.MediaFormat import android.os.Build import android.util.Size -import androidx.core.content.ContextCompat import androidx.media3.common.MimeTypes -import timber.log.Timber +import org.jellyfin.androidtv.util.profile.codec.AvcCodecCapabilities +import org.jellyfin.androidtv.util.profile.codec.MediaCodecQuery class MediaCodecCapabilitiesTest( - private val context: Context, - private val softwareCodecsEnabled: Boolean + private val softwareCodecsEnabled: Boolean, ) { - private val display by lazy { ContextCompat.getDisplayOrDefault(context) } private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) } + private val codecQuery by lazy { MediaCodecQuery(mediaCodecList, softwareCodecsEnabled) } + private val avc by lazy { AvcCodecCapabilities(codecQuery) } // Map common Dolby Vision Profiles to their corresponding CodecProfileLevel constant private object DolbyVisionProfiles { @@ -61,27 +59,6 @@ class MediaCodecCapabilitiesTest( } } - // AVC levels as reported by ffprobe are multiplied by 10, e.g. level 4.1 is 41. Level 1b is set to 9 - private val avcLevels = listOf( - CodecProfileLevel.AVCLevel1b to 9, - CodecProfileLevel.AVCLevel1 to 10, - CodecProfileLevel.AVCLevel11 to 11, - CodecProfileLevel.AVCLevel12 to 12, - CodecProfileLevel.AVCLevel13 to 13, - CodecProfileLevel.AVCLevel2 to 20, - CodecProfileLevel.AVCLevel21 to 21, - CodecProfileLevel.AVCLevel22 to 22, - CodecProfileLevel.AVCLevel3 to 30, - CodecProfileLevel.AVCLevel31 to 31, - CodecProfileLevel.AVCLevel32 to 32, - CodecProfileLevel.AVCLevel4 to 40, - CodecProfileLevel.AVCLevel41 to 41, - CodecProfileLevel.AVCLevel42 to 42, - CodecProfileLevel.AVCLevel5 to 50, - CodecProfileLevel.AVCLevel51 to 51, - CodecProfileLevel.AVCLevel52 to 52, - ) - // HEVC levels as reported by ffprobe are multiplied by 30, e.g. level 4.1 is 123 private val hevcLevels = listOf( CodecProfileLevel.HEVCMainTierLevel1 to 30, @@ -99,60 +76,44 @@ class MediaCodecCapabilitiesTest( CodecProfileLevel.HEVCMainTierLevel62 to 186, ) - fun supportsAV1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1) + fun supportsAV1(): Boolean = codecQuery.hasCodecForMime(MimeTypes.VIDEO_AV1) - fun supportsAV1Main10(): Boolean = hasDecoder( + fun supportsAV1Main10(): Boolean = codecQuery.hasDecoder( MimeTypes.VIDEO_AV1, AV1ProfileLevel.ProfileMain10, AV1ProfileLevel.Level5 ) fun supportsAV1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - hasDecoder( + codecQuery.hasDecoder( MimeTypes.VIDEO_DOLBY_VISION, AV1ProfileLevel.DolbyVisionProfile10, CodecProfileLevel.DolbyVisionLevelHd24 ) - fun supportsAV1HDR10(): Boolean = hasDecoder( + fun supportsAV1HDR10(): Boolean = codecQuery.hasDecoder( MimeTypes.VIDEO_AV1, AV1ProfileLevel.ProfileMain10HDR10, AV1ProfileLevel.Level5 ) - fun supportsAV1HDR10Plus(): Boolean = hasDecoder( + fun supportsAV1HDR10Plus(): Boolean = codecQuery.hasDecoder( MimeTypes.VIDEO_AV1, AV1ProfileLevel.ProfileMain10HDR10Plus, AV1ProfileLevel.Level5 ) - fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC) + fun supportsAVC(): Boolean = avc.supportsAvc() - fun supportsAVCHigh10(): Boolean = hasDecoder( - MediaFormat.MIMETYPE_VIDEO_AVC, - CodecProfileLevel.AVCProfileHigh10, - CodecProfileLevel.AVCLevel4 - ) + fun supportsAVCHigh10(): Boolean = avc.supportsAvcHigh10() - fun getAVCMainLevel(): Int = getAVCLevel( - CodecProfileLevel.AVCProfileMain - ) + fun getAVCMainLevel(): Int = avc.getMainLevel() - fun getAVCHigh10Level(): Int = getAVCLevel( - CodecProfileLevel.AVCProfileHigh10 - ) + fun getAVCHigh10Level(): Int = avc.getHigh10Level() - private fun getAVCLevel(profile: Int): Int { - val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile) + fun supportsHevc(): Boolean = codecQuery.hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC) - return avcLevels.asReversed().find { item -> - level >= item.first - }?.second ?: 0 - } - - fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC) - - fun supportsHevcMain10(): Boolean = hasDecoder( + fun supportsHevcMain10(): Boolean = codecQuery.hasDecoder( MediaFormat.MIMETYPE_VIDEO_HEVC, CodecProfileLevel.HEVCProfileMain10, CodecProfileLevel.HEVCMainTierLevel4 @@ -160,27 +121,27 @@ class MediaCodecCapabilitiesTest( // Can safely assume Dolby Vision decoders support single-layer HEVC profiles fun supportsHevcDolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION) + codecQuery.hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION) // Checks for Dolby Vision Profile 7 (Enhancement Layer) and multi-instance HEVC support fun supportsHevcDolbyVisionEL(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - hasDecoder( + codecQuery.hasDecoder( MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, DolbyVisionProfiles.Profile7, CodecProfileLevel.DolbyVisionLevelHd24 ) && - supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) + codecQuery.supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) fun supportsHevcHDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - hasDecoder( + codecQuery.hasDecoder( MediaFormat.MIMETYPE_VIDEO_HEVC, CodecProfileLevel.HEVCProfileMain10HDR10, CodecProfileLevel.HEVCMainTierLevel4 ) fun supportsHevcHDR10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - hasDecoder( + codecQuery.hasDecoder( MediaFormat.MIMETYPE_VIDEO_HEVC, CodecProfileLevel.HEVCProfileMain10HDR10Plus, CodecProfileLevel.HEVCMainTierLevel4 @@ -195,127 +156,14 @@ class MediaCodecCapabilitiesTest( ) private fun getHevcLevel(profile: Int): Int { - val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile) + val level = codecQuery.getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile) return hevcLevels.asReversed().find { item -> level >= item.first }?.second ?: 0 } - fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1) + fun supportsVc1(): Boolean = codecQuery.hasCodecForMime(MimeTypes.VIDEO_VC1) - private fun getDecoderLevel(mime: String, profile: Int): Int { - var maxLevel = 0 - - for (info in mediaCodecList.codecInfos) { - if (info.isEncoder) continue - if (!softwareCodecsEnabled && info.isSoftwareCodec) continue - - try { - val capabilities = info.getCapabilitiesForType(mime) - for (profileLevel in capabilities.profileLevels) { - if (profileLevel.profile == profile) { - maxLevel = maxOf(maxLevel, profileLevel.level) - } - } - } catch (_: IllegalArgumentException) { - // Decoder not supported - ignore - } - } - - return maxLevel - } - - private fun hasDecoder(mime: String, profile: Int, level: Int): Boolean { - for (info in mediaCodecList.codecInfos) { - if (info.isEncoder) continue - if (!softwareCodecsEnabled && info.isSoftwareCodec) continue - - try { - val capabilities = info.getCapabilitiesForType(mime) - for (profileLevel in capabilities.profileLevels) { - if (profileLevel.profile != profile) continue - - // H.263 levels are not completely ordered: - // Level45 support only implies Level10 support - if (mime.equals(MediaFormat.MIMETYPE_VIDEO_H263, ignoreCase = true)) { - if (profileLevel.level != level && profileLevel.level == CodecProfileLevel.H263Level45 && level > CodecProfileLevel.H263Level10) { - continue - } - } - - if (profileLevel.level >= level) return true - } - } catch (_: IllegalArgumentException) { - // Decoder not supported - ignore - } - } - - return false - } - - private fun hasCodecForMime(mime: String): Boolean { - for (info in mediaCodecList.codecInfos) { - if (info.isEncoder) continue - if (!softwareCodecsEnabled && info.isSoftwareCodec) continue - - if (info.supportedTypes.any { it.equals(mime, ignoreCase = true) }) { - Timber.i("found codec %s for mime %s", info.name, mime) - return true - } - } - - return false - } - - private fun supportsMultiInstance(mime: String): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false - - for (info in mediaCodecList.codecInfos) { - if (info.isEncoder) continue - if (!softwareCodecsEnabled && info.isSoftwareCodec) continue - - try { - val types = info.getSupportedTypes() - if (!types.contains(mime)) continue - - val capabilities = info.getCapabilitiesForType(mime) - if (capabilities.maxSupportedInstances > 1) return true - } catch (_: IllegalArgumentException) { - // Decoder not supported - ignore - } - } - - return false - } - - fun getMaxResolution(mime: String): Size { - var maxWidth = 0 - var maxHeight = 0 - - for (info in mediaCodecList.codecInfos) { - if (info.isEncoder) continue - if (!softwareCodecsEnabled && info.isSoftwareCodec) continue - - try { - val capabilities = info.getCapabilitiesForType(mime) - val videoCapabilities = capabilities.videoCapabilities ?: continue - val supportedWidth = videoCapabilities.supportedWidths?.upper ?: continue - val supportedHeight = videoCapabilities.supportedHeights?.upper ?: continue - - maxWidth = maxOf(maxWidth, supportedWidth) - maxHeight = maxOf(maxHeight, supportedHeight) - - } catch (_: IllegalArgumentException) { - // Decoder not supported - ignore - } - } - - Timber.d("Computed max resolution for %s: %dx%d", mime, maxWidth, maxHeight) - - return Size(maxWidth, maxHeight) - } - - private val MediaCodecInfo.isSoftwareCodec: Boolean - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isSoftwareOnly + fun getMaxResolution(mime: String): Size = codecQuery.getMaxResolution(mime) } diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/AvcCodecCapabilities.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/AvcCodecCapabilities.kt new file mode 100644 index 000000000..8024a8216 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/AvcCodecCapabilities.kt @@ -0,0 +1,56 @@ +package org.jellyfin.androidtv.util.profile.codec + +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaFormat + +/** + * AVC (H.264) codec capability detection backed by a [MediaCodecQuery]. + */ +class AvcCodecCapabilities( + private val query: MediaCodecQuery, +) { + companion object { + // AVC levels as reported by ffprobe are multiplied by 10, e.g. level 4.1 is 41. Level 1b is set to 9 + internal val LEVEL_MAP: List> = listOf( + CodecProfileLevel.AVCLevel1b to 9, + CodecProfileLevel.AVCLevel1 to 10, + CodecProfileLevel.AVCLevel11 to 11, + CodecProfileLevel.AVCLevel12 to 12, + CodecProfileLevel.AVCLevel13 to 13, + CodecProfileLevel.AVCLevel2 to 20, + CodecProfileLevel.AVCLevel21 to 21, + CodecProfileLevel.AVCLevel22 to 22, + CodecProfileLevel.AVCLevel3 to 30, + CodecProfileLevel.AVCLevel31 to 31, + CodecProfileLevel.AVCLevel32 to 32, + CodecProfileLevel.AVCLevel4 to 40, + CodecProfileLevel.AVCLevel41 to 41, + CodecProfileLevel.AVCLevel42 to 42, + CodecProfileLevel.AVCLevel5 to 50, + CodecProfileLevel.AVCLevel51 to 51, + CodecProfileLevel.AVCLevel52 to 52, + ) + + private const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC + } + + fun supportsAvc(): Boolean = query.hasCodecForMime(MIME) + + fun supportsAvcHigh10(): Boolean = query.hasDecoder( + MIME, + CodecProfileLevel.AVCProfileHigh10, + CodecProfileLevel.AVCLevel4, + ) + + fun getMainLevel(): Int = getLevel(CodecProfileLevel.AVCProfileMain) + + fun getHigh10Level(): Int = getLevel(CodecProfileLevel.AVCProfileHigh10) + + private fun getLevel(profile: Int): Int { + val level = query.getDecoderLevel(MIME, profile) + + return LEVEL_MAP.asReversed().find { (codecLevel, _) -> + level >= codecLevel + }?.second ?: 0 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/MediaCodecQuery.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/MediaCodecQuery.kt new file mode 100644 index 000000000..9091b0988 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/codec/MediaCodecQuery.kt @@ -0,0 +1,109 @@ +package org.jellyfin.androidtv.util.profile.codec + +import android.media.MediaCodecInfo +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import android.util.Size +import timber.log.Timber + +/** + * Queries device codec capabilities from Android's [MediaCodecList]. + */ +class MediaCodecQuery( + private val mediaCodecList: MediaCodecList, + private val softwareCodecsEnabled: Boolean, +) { + private val MediaCodecInfo.isSoftwareCodec: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isSoftwareOnly + + private fun decoderInfos(): Sequence = + mediaCodecList.codecInfos.asSequence() + .filter { !it.isEncoder } + .filter { softwareCodecsEnabled || !it.isSoftwareCodec } + + fun hasCodecForMime(mime: String): Boolean { + val info = decoderInfos().firstOrNull { info -> + info.supportedTypes.any { it.equals(mime, ignoreCase = true) } + } ?: return false + + Timber.i("found codec %s for mime %s", info.name, mime) + return true + } + + fun hasDecoder(mime: String, profile: Int, level: Int): Boolean = + decoderInfos().any { info -> supportsProfileLevel(info, mime, profile, level) } + + fun getDecoderLevel(mime: String, profile: Int): Int { + var maxLevel = 0 + + for (info in decoderInfos()) { + val capabilities = getCapabilitiesOrNull(info, mime) ?: continue + for (profileLevel in capabilities.profileLevels) { + if (profileLevel.profile == profile) { + maxLevel = maxOf(maxLevel, profileLevel.level) + } + } + } + + return maxLevel + } + + fun getMaxResolution(mime: String): Size { + val resolutions = decoderInfos() + .mapNotNull { info -> getCapabilitiesOrNull(info, mime)?.videoCapabilities } + .mapNotNull { vc -> + val w = vc.supportedWidths?.upper ?: return@mapNotNull null + val h = vc.supportedHeights?.upper ?: return@mapNotNull null + w to h + } + + val maxWidth = resolutions.maxOfOrNull { it.first } ?: 0 + val maxHeight = resolutions.maxOfOrNull { it.second } ?: 0 + + Timber.d("Computed max resolution for %s: %dx%d", mime, maxWidth, maxHeight) + return Size(maxWidth, maxHeight) + } + + fun supportsMultiInstance(mime: String): Boolean = + decoderInfos().any { info -> hasMultipleInstances(info, mime) } + + private fun supportsProfileLevel(info: MediaCodecInfo, mime: String, profile: Int, level: Int): Boolean { + val capabilities = getCapabilitiesOrNull(info, mime) ?: return false + + return capabilities.profileLevels.any { profileLevel -> + profileLevel.profile == profile && meetsLevelRequirement(mime, profileLevel.level, level) + } + } + + private fun meetsLevelRequirement(mime: String, decoderLevel: Int, requiredLevel: Int): Boolean { + // H.263 levels are not completely ordered: + // Level45 support only implies Level10 support + if (mime.equals(MediaFormat.MIMETYPE_VIDEO_H263, ignoreCase = true)) { + if (decoderLevel != requiredLevel && + decoderLevel == CodecProfileLevel.H263Level45 && + requiredLevel > CodecProfileLevel.H263Level10 + ) { + return false + } + } + + return decoderLevel >= requiredLevel + } + + private fun hasMultipleInstances(info: MediaCodecInfo, mime: String): Boolean { + if (!info.supportedTypes.contains(mime)) return false + val capabilities = getCapabilitiesOrNull(info, mime) ?: return false + return capabilities.maxSupportedInstances > 1 + } + + private fun getCapabilitiesOrNull( + info: MediaCodecInfo, + mime: String, + ): MediaCodecInfo.CodecCapabilities? = try { + info.getCapabilitiesForType(mime) + } catch (_: IllegalArgumentException) { + null + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt index 7b289ecf6..6fa68d9bf 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt @@ -79,7 +79,7 @@ fun createDeviceProfile( userPreferences: UserPreferences, serverVersion: ServerVersion, ) = createDeviceProfile( - mediaTest = MediaCodecCapabilitiesTest(context, userPreferences[UserPreferences.softwareCodecsEnabled]), + mediaTest = MediaCodecCapabilitiesTest(userPreferences[UserPreferences.softwareCodecsEnabled]), maxBitrate = userPreferences.getMaxBitrate(), isAC3Enabled = userPreferences[UserPreferences.ac3Enabled], downMixAudio = userPreferences[UserPreferences.audioBehaviour] == AudioBehavior.DOWNMIX_TO_STEREO, diff --git a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt index f97db98f8..bc79f54cf 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfileReport.kt @@ -181,7 +181,7 @@ fun createDeviceProfileReport( } appendDetails("Codec HDR Support") { - val mediaTest = MediaCodecCapabilitiesTest(context, userPreferences[UserPreferences.softwareCodecsEnabled]) + val mediaTest = MediaCodecCapabilitiesTest(userPreferences[UserPreferences.softwareCodecsEnabled]) val codecHDRSupport = buildMap> { if (mediaTest.supportsAV1()) { diff --git a/app/src/test/kotlin/util/profile/codec/AvcCodecCapabilitiesTest.kt b/app/src/test/kotlin/util/profile/codec/AvcCodecCapabilitiesTest.kt new file mode 100644 index 000000000..457833adb --- /dev/null +++ b/app/src/test/kotlin/util/profile/codec/AvcCodecCapabilitiesTest.kt @@ -0,0 +1,164 @@ +package org.jellyfin.androidtv.util.profile.codec + +import android.media.MediaCodecInfo.CodecProfileLevel +import android.media.MediaFormat +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class AvcCodecCapabilitiesTest : FunSpec({ + val mime = MediaFormat.MIMETYPE_VIDEO_AVC + + test("supportsAvc returns true when device has AVC decoder") { + val query = mockk { + every { hasCodecForMime(mime) } returns true + } + AvcCodecCapabilities(query).supportsAvc() shouldBe true + } + + test("supportsAvc returns false when device has no AVC decoder") { + val query = mockk { + every { hasCodecForMime(mime) } returns false + } + AvcCodecCapabilities(query).supportsAvc() shouldBe false + } + + test("supportsAvcHigh10 returns true when device supports High10 at Level 4") { + val query = mockk { + every { hasDecoder(mime, CodecProfileLevel.AVCProfileHigh10, CodecProfileLevel.AVCLevel4) } returns true + } + AvcCodecCapabilities(query).supportsAvcHigh10() shouldBe true + } + + test("supportsAvcHigh10 returns false when device lacks High10 support") { + val query = mockk { + every { hasDecoder(mime, CodecProfileLevel.AVCProfileHigh10, CodecProfileLevel.AVCLevel4) } returns false + } + AvcCodecCapabilities(query).supportsAvcHigh10() shouldBe false + } + + test("getMainLevel returns mapped level for device with Main Level 4.1") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel41 + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 41 + } + + test("getMainLevel returns mapped level for device with Main Level 5.1") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel51 + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 51 + } + + test("getMainLevel returns mapped level for device with Main Level 5.2") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel52 + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 52 + } + + test("getMainLevel returns 0 when device reports no AVC Main support") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns 0 + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 0 + } + + test("getMainLevel maps level between known values to the lower known level") { + val query = mockk { + // Return a value between AVCLevel4 (2048) and AVCLevel41 (4096) + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns 3000 + } + // 3000 >= AVCLevel4 (2048) but < AVCLevel41 (4096), so should map to 40 + AvcCodecCapabilities(query).getMainLevel() shouldBe 40 + } + + test("getMainLevel maps Level 1b correctly") { + val query = mockk { + // AVCLevel1b = 2, AVCLevel1 = 1 + // In reversed search, AVCLevel1 (1) appears before AVCLevel1b (2) + // so level 2 >= 1 matches AVCLevel1 first, returning 10 + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel1b + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 10 + } + + test("getMainLevel maps Level 1 correctly") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel1 + } + AvcCodecCapabilities(query).getMainLevel() shouldBe 10 + } + + test("getHigh10Level returns mapped level for device with High10 Level 5.1") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileHigh10) } returns CodecProfileLevel.AVCLevel51 + } + AvcCodecCapabilities(query).getHigh10Level() shouldBe 51 + } + + test("getHigh10Level returns 0 when device has no High10 support") { + val query = mockk { + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileHigh10) } returns 0 + } + AvcCodecCapabilities(query).getHigh10Level() shouldBe 0 + } + + test("LEVEL_MAP contains all expected AVC levels in ascending order") { + val expectedFfprobeLevels = listOf(9, 10, 11, 12, 13, 20, 21, 22, 30, 31, 32, 40, 41, 42, 50, 51, 52) + AvcCodecCapabilities.LEVEL_MAP.map { it.second } shouldBe expectedFfprobeLevels + } + + test("LEVEL_MAP ffprobe level values are in ascending order") { + val ffprobeLevels = AvcCodecCapabilities.LEVEL_MAP.map { it.second } + ffprobeLevels shouldBe ffprobeLevels.sorted() + } + + // Simulated device profiles + test("Shield TV profile: Main Level 5.1 + High10 Level 5.1") { + val query = mockk { + every { hasCodecForMime(mime) } returns true + every { hasDecoder(mime, CodecProfileLevel.AVCProfileHigh10, CodecProfileLevel.AVCLevel4) } returns true + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel51 + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileHigh10) } returns CodecProfileLevel.AVCLevel51 + } + val avc = AvcCodecCapabilities(query) + + avc.supportsAvc() shouldBe true + avc.supportsAvcHigh10() shouldBe true + avc.getMainLevel() shouldBe 51 + avc.getHigh10Level() shouldBe 51 + } + + test("Budget Fire Stick profile: Main Level 4.1 only") { + val query = mockk { + every { hasCodecForMime(mime) } returns true + every { hasDecoder(mime, CodecProfileLevel.AVCProfileHigh10, CodecProfileLevel.AVCLevel4) } returns false + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns CodecProfileLevel.AVCLevel41 + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileHigh10) } returns 0 + } + val avc = AvcCodecCapabilities(query) + + avc.supportsAvc() shouldBe true + avc.supportsAvcHigh10() shouldBe false + avc.getMainLevel() shouldBe 41 + avc.getHigh10Level() shouldBe 0 + } + + test("No AVC support at all") { + val query = mockk { + every { hasCodecForMime(mime) } returns false + every { hasDecoder(mime, CodecProfileLevel.AVCProfileHigh10, CodecProfileLevel.AVCLevel4) } returns false + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileMain) } returns 0 + every { getDecoderLevel(mime, CodecProfileLevel.AVCProfileHigh10) } returns 0 + } + val avc = AvcCodecCapabilities(query) + + avc.supportsAvc() shouldBe false + avc.supportsAvcHigh10() shouldBe false + avc.getMainLevel() shouldBe 0 + avc.getHigh10Level() shouldBe 0 + } +})