diff --git a/android/app/src/main/cpp/dovi_bridge.cpp b/android/app/src/main/cpp/dovi_bridge.cpp index 3a6d6c2f..71a6c194 100644 --- a/android/app/src/main/cpp/dovi_bridge.cpp +++ b/android/app/src/main/cpp/dovi_bridge.cpp @@ -86,7 +86,7 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na } } - // Convert to target profile (mode 2 = P8.1 with no-op curves) + // Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op. int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast(mode)); if (ret != 0) { err = dovi_rpu_get_error(rpu); diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt index c144c4e4..98a7de2f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviBridge.kt @@ -1,18 +1,35 @@ package com.edde746.plezy.exoplayer +import android.content.Context import android.media.MediaCodecInfo import android.media.MediaCodecList import android.os.Build import android.util.Log +import android.view.Display +import android.view.WindowManager enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP } object DoviBridge { private const val TAG = "DoviBridge" + private val DOLBY_VISION_MIME_TYPES = setOf( + "video/dolby-vision", + "video/hevcdv", + "video/dv_hevc" + ) + const val CONVERT_FAILED = -1 const val DESTINATION_TOO_SMALL = -2 + private data class DvProfileLevel(val profile: Int, val level: Int) + + private data class DvDecoderCapability( + val name: String, + val mimeType: String, + val profileLevels: List + ) + private val nativeLoaded: Boolean by lazy { try { System.loadLibrary("dovi_bridge") @@ -29,46 +46,207 @@ object DoviBridge { fun isAvailable(): Boolean = conversionPathReady - private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean { + private val dolbyVisionDecoders: List by lazy { try { - if (Build.VERSION.SDK_INT < minApi) return false val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - return codecList.codecInfos.any { info -> - !info.isEncoder && - info.supportedTypes.any { type -> - type.equals("video/dolby-vision", ignoreCase = true) && - info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile } + codecList.codecInfos.flatMap { info -> + if (info.isEncoder) { + emptyList() + } else { + info.supportedTypes.mapNotNull { type -> + if (!DOLBY_VISION_MIME_TYPES.contains(type.lowercase())) return@mapNotNull null + val capabilities = runCatching { info.getCapabilitiesForType(type) } + .onFailure { Log.w(TAG, "Failed to query ${info.name} capabilities for $type", it) } + .getOrNull() + ?: return@mapNotNull null + DvDecoderCapability( + name = info.name, + mimeType = type, + profileLevels = capabilities.profileLevels.map { DvProfileLevel(it.profile, it.level) } + ) } + } } } catch (e: Exception) { - Log.w(TAG, "Failed to query DV profile $profile support", e) - return false + Log.w(TAG, "Failed to query native Dolby Vision decoder support", e) + emptyList() + } + } + + val hasNativeDolbyVisionDecoder: Boolean by lazy { + dolbyVisionDecoders.isNotEmpty().also { + Log.i(TAG, "Native Dolby Vision decoder available: $it; decoders=${describeDolbyVisionDecoders()}") + } + } + + private fun deviceAdvertisesDvProfile(profile: Int, minApi: Int = 0): Boolean { + if (Build.VERSION.SDK_INT < minApi) return false + return dolbyVisionDecoders.any { decoder -> + decoder.profileLevels.any { it.profile == profile } } } val deviceSupportsDvProfile7: Boolean by lazy { - deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr) - .also { Log.i(TAG, "Device DV Profile 7 support: $it") } + deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb) + .also { Log.i(TAG, "Device advertises exact DV Profile 7 (DvheDtb): $it") } } val deviceSupportsDvProfile8: Boolean by lazy { - deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27) - .also { Log.i(TAG, "Device DV Profile 8 support: $it") } + deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27) + .also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") } } - fun getConversionMode(): DvConversionMode = when { - !isAvailable() -> DvConversionMode.DISABLED - deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure - deviceSupportsDvProfile8 -> DvConversionMode.DV81 - else -> DvConversionMode.HEVC_STRIP + fun displaySupportsDolbyVision(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})") + return false + } + + val display = getCurrentDisplay(context) + if (display == null) { + Log.i(TAG, "Display Dolby Vision support: false (no active display)") + return false + } + + val hdrTypes = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + display.mode.supportedHdrTypes + } else { + @Suppress("DEPRECATION") + val legacyHdrTypes = display.hdrCapabilities.supportedHdrTypes + legacyHdrTypes + } + }.getOrElse { error -> + Log.w(TAG, "Display Dolby Vision support: false (failed to query HDR types)", error) + return false + } + val supported = hdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION) + Log.i(TAG, "Display Dolby Vision support: $supported; hdrTypes=${describeHdrTypes(hdrTypes)}") + return supported + } + + fun logSupportSummary(context: Context) { + Log.i( + TAG, + "DV support summary: bridgeReady=${isAvailable()}, displayDV=${displaySupportsDolbyVision(context)}, " + + "nativeDecoder=$hasNativeDolbyVisionDecoder, advertisedP7=$deviceSupportsDvProfile7, " + + "advertisedP8=$deviceSupportsDvProfile8, decoders=${describeDolbyVisionDecoders()}" + ) + } + + fun getConversionMode(context: Context): DvConversionMode { + val bridgeReady = isAvailable() + val displayDv = displaySupportsDolbyVision(context) + val nativeDecoder = hasNativeDolbyVisionDecoder + val advertisedP7 = deviceSupportsDvProfile7 + val advertisedP8 = deviceSupportsDvProfile8 + + val mode = when { + !displayDv -> DvConversionMode.HEVC_STRIP + advertisedP7 -> DvConversionMode.DISABLED + advertisedP8 && bridgeReady -> DvConversionMode.DV81 + else -> DvConversionMode.HEVC_STRIP + } + val reason = when { + !displayDv -> "active display does not support Dolby Vision; stripping DV metadata for HEVC fallback" + advertisedP7 -> "active display supports Dolby Vision and decoder advertises exact Profile 7; trying native DV first" + advertisedP8 && bridgeReady -> "active display supports Dolby Vision and decoder advertises Profile 8; converting to Profile 8.1" + !bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback" + else -> "Dolby Vision output path is unavailable; stripping DV metadata for HEVC fallback" + } + Log.i( + TAG, + "AUTO DV decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " + + "displayDV=$displayDv, nativeDecoder=$nativeDecoder, advertisedP7=$advertisedP7, advertisedP8=$advertisedP8" + ) + return mode } /** Get the fallback mode when native DV7 decoding fails. */ - fun getDv7FallbackMode(): DvConversionMode = when { - deviceSupportsDvProfile8 -> DvConversionMode.DV81 - else -> DvConversionMode.HEVC_STRIP + fun getDv7FallbackMode(context: Context): DvConversionMode { + val bridgeReady = isAvailable() + val displayDv = displaySupportsDolbyVision(context) + val advertisedP8 = deviceSupportsDvProfile8 + val mode = if (displayDv && advertisedP8 && bridgeReady) DvConversionMode.DV81 else DvConversionMode.HEVC_STRIP + val reason = when { + mode == DvConversionMode.DV81 -> "display supports Dolby Vision and decoder advertises Profile 8" + !bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback" + else -> "Dolby Vision output or Profile 8 support is unavailable" + } + Log.i( + TAG, + "DV7 fallback decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " + + "displayDV=$displayDv, advertisedP8=$advertisedP8" + ) + return mode } + private fun getCurrentDisplay(context: Context): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.display + } else { + @Suppress("DEPRECATION") + (context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay + } + + private fun describeDolbyVisionDecoders(): String { + if (dolbyVisionDecoders.isEmpty()) return "none" + return dolbyVisionDecoders.joinToString { decoder -> + val profiles = if (decoder.profileLevels.isEmpty()) { + "none" + } else { + decoder.profileLevels.joinToString(prefix = "[", postfix = "]") { + "${describeDvProfile(it.profile)}/${describeDvLevel(it.level)}" + } + } + "${decoder.name}(${decoder.mimeType}, profiles=$profiles)" + } + } + + private fun describeHdrTypes(hdrTypes: IntArray): String { + if (hdrTypes.isEmpty()) return "none" + return hdrTypes.joinToString(prefix = "[", postfix = "]") { type -> + when (type) { + Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DOLBY_VISION" + Display.HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10" + Display.HdrCapabilities.HDR_TYPE_HLG -> "HLG" + Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10_PLUS" + else -> "unknown(${hex(type)})" + } + } + } + + private fun describeDvProfile(profile: Int): String = when (profile) { + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPer -> "P0/DvavPer" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPen -> "P1/DvavPen" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDer -> "P2/DvheDer" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDen -> "P3/DvheDen" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr -> "P4/DvheDtr" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn -> "P5/DvheStn" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDth -> "P6/DvheDth" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb -> "P7/DvheDtb" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt -> "P8/DvheSt" + MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavSe -> "P9/DvavSe" + else -> "unknown(${hex(profile)})" + } + + private fun describeDvLevel(level: Int): String = when (level) { + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd24 -> "L1/Hd24" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd30 -> "L2/Hd30" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd24 -> "L3/Fhd24" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd30 -> "L4/Fhd30" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd60 -> "L5/Fhd60" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd24 -> "L6/Uhd24" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd30 -> "L7/Uhd30" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd48 -> "L8/Uhd48" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd60 -> "L9/Uhd60" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd120 -> "L10/Uhd120" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k30 -> "L11/8k30" + MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k60 -> "L12/8k60" + else -> "unknown(${hex(level)})" + } + + private fun hex(value: Int): String = "0x${value.toString(16)}" + fun convertRpuNalu( payload: ByteArray, payloadOffset: Int, diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt index b2e06a7f..8c01743c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt @@ -11,9 +11,10 @@ import androidx.media3.extractor.TrackOutput /** * TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode: * - * - DV81: Convert RPU NALs via libdovi to Profile 8.1, present as video/dolby-vision - * with dvhe.08.XX codec string. Preserves dynamic tone mapping metadata. - * - HEVC_STRIP: Strip all DV enhancement layers, present as plain video/hevc. + * - DV81: Convert RPU NALs via libdovi mode 2 to Profile 8.1, present as + * video/dolby-vision with dvhe.08.XX codec string. Conversion failures drop + * that RPU instead of forwarding Profile 7 metadata into a Profile 8.1 stream. + * - HEVC_STRIP: Strip DV RPU/EL NALs, present as plain video/hevc. * * Two modes of NAL framing (auto-detected): * - Annex B (MKV path): MatroskaExtractor outputs 00 00 00 01 start codes @@ -22,8 +23,7 @@ import androidx.media3.extractor.TrackOutput * NAL processing: * - Type 62 (UNSPEC62): DV RPU → convert (DV81) or strip (HEVC_STRIP) * - Type 63 (UNSPEC63): DV Enhancement Layer → strip - * - nuh_layer_id > 0: Enhancement layer NAL → strip - * - All retained NALs: normalize nuh_layer_id to 0 + * - All other NALs: pass through unchanged, matching Kodi's compatibility path * * All buffers are reused across samples to minimize GC pressure on the hot path. */ @@ -47,6 +47,10 @@ class DoviConvertingTrackOutput( private set var strippedNalCount = 0L private set + var strippedRpuNalCount = 0L + private set + var strippedElNalCount = 0L + private set var strippedInitNalCount = 0L private set var convertedRpuCount = 0L @@ -79,11 +83,17 @@ class DoviConvertingTrackOutput( override fun format(format: Format) { if (!conversionActive) { val codecs = format.codecs - if (codecs != null && codecs.startsWith("dvhe.07")) { + if (isDvProfile7Codec(codecs)) { + val codecString = codecs ?: "" conversionActive = true - logInfo("DV Profile 7 detected ($codecs), mode=$dvMode") + logInfo("DV Profile 7 detected ($codecString), mode=$dvMode") + when (dvMode) { + DvConversionMode.DV81 -> logInfo("DV81: Kodi-style libdovi conversion (convert RPU type 62, drop EL type 63)") + DvConversionMode.HEVC_STRIP -> logInfo("HEVC_STRIP: Kodi-compatible strip (drop RPU type 62 and EL type 63 only)") + else -> Unit + } logInfo( - "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " + + "Original format: mime=${format.sampleMimeType}, codecs=$codecString, " + "initData=${format.initializationData.size} entries " + "(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})" ) @@ -92,7 +102,7 @@ class DoviConvertingTrackOutput( val newFormat = when (dvMode) { DvConversionMode.DV81 -> { // Parse DV level from codec string: "dvhe.07.06" → 6 - val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6 + val level = codecString.split('.').getOrNull(2)?.toIntOrNull() ?: 6 val newCodecs = "dvhe.08.%02d".format(level) val dvConfigRecord = buildDv81ConfigRecord(level) logInfo("DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B") @@ -360,7 +370,6 @@ class DoviConvertingTrackOutput( System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) outputLen += 4 System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) - normalizeLayerId(outputBuf, outputLen) outputLen += nalLen kept++ } else if (action == NalAction.CONVERT) { @@ -368,16 +377,15 @@ class DoviConvertingTrackOutput( if (convertedLen >= 0) { System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) outputLen += 4 - normalizeLayerId(outputBuf, outputLen) outputLen += convertedLen convertedRpuCount++ kept++ } else { - strippedNalCount++ + recordStrippedNal(action) stripped++ } } else { - strippedNalCount++ + recordStrippedNal(action) stripped++ } } @@ -428,7 +436,6 @@ class DoviConvertingTrackOutput( writeInt32BE(outputBuf, outputLen, nalLen) outputLen += 4 System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen) - normalizeLayerId(outputBuf, outputLen) outputLen += nalLen kept++ } else if (action == NalAction.CONVERT) { @@ -436,16 +443,15 @@ class DoviConvertingTrackOutput( if (convertedLen >= 0) { writeInt32BE(outputBuf, outputLen, convertedLen) outputLen += 4 - normalizeLayerId(outputBuf, outputLen) outputLen += convertedLen convertedRpuCount++ kept++ } else { - strippedNalCount++ + recordStrippedNal(action) stripped++ } } else { - strippedNalCount++ + recordStrippedNal(action) stripped++ } @@ -461,7 +467,17 @@ class DoviConvertingTrackOutput( } } - private enum class NalAction { KEEP, STRIP, CONVERT } + private enum class NalAction { KEEP, STRIP_RPU, STRIP_EL, CONVERT } + + private fun recordStrippedNal(action: NalAction) { + strippedNalCount++ + // CONVERT reaches here only after conversion fails; raw P7 RPUs are not safe in P8.1 output. + when (action) { + NalAction.STRIP_RPU, NalAction.CONVERT -> strippedRpuNalCount++ + NalAction.STRIP_EL -> strippedElNalCount++ + NalAction.KEEP -> Unit + } + } private fun convertRpuIntoOutput(nalStart: Int, nalLen: Int, outputOffset: Int): Int { ensureOutputCapacity(outputOffset + MAX_CONVERTED_RPU_SIZE) @@ -565,7 +581,6 @@ class DoviConvertingTrackOutput( System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) outputLen += 4 System.arraycopy(data, nalStart, outputBuf, outputLen, nalLen) - normalizeLayerId(outputBuf, outputLen) outputLen += nalLen kept++ } @@ -617,25 +632,22 @@ class DoviConvertingTrackOutput( /** Classify a NAL at sampleBuf[offset..offset+len) without copying. */ private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81) + private fun isDvProfile7Codec(codecs: String?): Boolean { + val normalized = codecs?.lowercase() ?: return false + return normalized.startsWith("dvhe.07") || normalized.startsWith("dvh1.07") + } + private fun classifyNal(data: ByteArray, offset: Int, len: Int, convertRpu: Boolean): NalAction { if (len < 2) return NalAction.KEEP val nalType = (data[offset].toInt() ushr 1) and 0x3F - val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or - ((data[offset + 1].toInt() ushr 3) and 0x1F) return when { nalType == NAL_TYPE_UNSPEC62 && convertRpu -> NalAction.CONVERT - nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP + nalType == NAL_TYPE_UNSPEC62 -> NalAction.STRIP_RPU + nalType == NAL_TYPE_UNSPEC63 -> NalAction.STRIP_EL else -> NalAction.KEEP } } - private fun normalizeLayerId(data: ByteArray, offset: Int) { - if (data.size - offset >= 2) { - data[offset] = (data[offset].toInt() and 0xFE).toByte() - data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte() - } - } - private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) { buf[offset] = ((value ushr 24) and 0xFF).toByte() buf[offset + 1] = ((value ushr 16) and 0xFF).toByte() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 38f6de75..a7c6aa65 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -300,12 +300,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var dvMode: DvConversionMode = DvConversionMode.DISABLED private var debugDvModeOverride: DvConversionMode? = null private var dv7RetryAttempted = false + private var currentVideoFormat: Format? = null + private var loggedNativeDvSelectionKey: String? = null + private var loggedNativeDvFirstFrame = false @Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null - private fun getConfiguredDvMode(): DvConversionMode = debugDvModeOverride ?: DoviBridge.getConversionMode() + private fun getConfiguredDvMode(): DvConversionMode = debugDvModeOverride ?: DoviBridge.getConversionMode(activity) fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean { if (isInitialized) { @@ -315,10 +318,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { tunnelingUserEnabled = tunnelingEnabled this.dvMode = getConfiguredDvMode() + DoviBridge.logSupportSummary(activity) Log.i( TAG, - "DV conversion: mode=$dvMode, bridge=${DoviBridge.isAvailable()}, " + - "deviceDV7=${DoviBridge.deviceSupportsDvProfile7}, deviceDV8=${DoviBridge.deviceSupportsDvProfile8}" + "DV conversion: mode=$dvMode, override=${debugDvModeOverride?.name ?: "AUTO"}" ) disposing = false @@ -889,11 +892,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } if (videoGroup != null) { val vf = videoGroup.mediaTrackGroup.getFormat(0) + currentVideoFormat = vf val hdr = vf.colorInfo?.let { ci -> val transfer = ci.colorTransfer if (transfer != null && transfer != 0) " HDR(transfer=$transfer)" else "" } ?: "" emitLog("info", "tracks", "Video: ${vf.codecs} ${vf.width}x${vf.height}$hdr") + logNativeDvSelectionIfNeeded(vf) + } else { + currentVideoFormat = null } if (audioGroup != null) { val af = audioGroup.mediaTrackGroup.getFormat(0) @@ -967,22 +974,85 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) } + private fun isDvProfile7Format(format: Format): Boolean { + val codecs = format.codecs?.lowercase() ?: return false + return codecs.startsWith("dvhe.07") || codecs.startsWith("dvh1.07") + } + + private fun findDv7VideoFormat(): Format? { + currentVideoFormat?.takeIf { isDvProfile7Format(it) }?.let { return it } + val tracks = exoPlayer?.currentTracks ?: return null + for (group in tracks.groups) { + if (group.type != C.TRACK_TYPE_VIDEO) continue + for (i in 0 until group.mediaTrackGroup.length) { + val format = group.mediaTrackGroup.getFormat(i) + if (isDvProfile7Format(format)) return format + } + } + return null + } + + private fun describeVideoFormat(format: Format?): String { + if (format == null) return "none" + return "mime=${format.sampleMimeType}, codecs=${format.codecs}, size=${format.width}x${format.height}" + } + + private fun logNativeDvSelectionIfNeeded(format: Format) { + if (dvMode != DvConversionMode.DISABLED || !isDvProfile7Format(format)) return + val key = "${format.sampleMimeType}|${format.codecs}|${format.width}x${format.height}" + if (loggedNativeDvSelectionKey == key) return + loggedNativeDvSelectionKey = key + emitLog( + "info", + "dv-native", + "Selected DV Profile 7 for native playback: ${describeVideoFormat(format)}, " + + "displayDV=${DoviBridge.displaySupportsDolbyVision(activity)}, " + + "nativeDecoder=${DoviBridge.hasNativeDolbyVisionDecoder}, " + + "advertisedP7=${DoviBridge.deviceSupportsDvProfile7}, advertisedP8=${DoviBridge.deviceSupportsDvProfile8}" + ) + } + + private fun logNativeDvFirstFrameIfNeeded() { + if (loggedNativeDvFirstFrame || dvMode != DvConversionMode.DISABLED) return + val format = currentVideoFormat ?: return + if (!isDvProfile7Format(format)) return + loggedNativeDvFirstFrame = true + emitLog( + "info", + "dv-native", + "Native DV Profile 7 playback confirmed: ${describeVideoFormat(format)}, decoder=${decoderInitName ?: "unknown"}" + ) + } + /** - * When native DV7 decoding fails (device falsely advertises DV7 support), - * upgrade to DV7→8.1 conversion or HEVC strip and reload the media. - * Returns true if retry was initiated. + * When native DV7 decoding fails, upgrade to DV7→8.1 conversion or HEVC strip + * and reload the media. Returns true if retry was initiated. */ private fun retryWithDvConversion(reason: String): Boolean { if (dv7RetryAttempted) return false - if (debugDvModeOverride == DvConversionMode.DISABLED) return false - if (dvMode != DvConversionMode.DISABLED) return false - if (!DoviBridge.isAvailable()) return false + if (debugDvModeOverride == DvConversionMode.DISABLED) { + emitLog("debug", "dv-fallback", "Skipping DV conversion retry for $reason: native/disabled mode is forced") + return false + } + if (dvMode != DvConversionMode.DISABLED) { + emitLog("debug", "dv-fallback", "Skipping DV conversion retry for $reason: conversion already active ($dvMode)") + return false + } if (currentMediaUri == null) return false + val dv7Format = findDv7VideoFormat() + if (dv7Format == null) { + emitLog( + "debug", + "dv-fallback", + "Skipping DV conversion retry for $reason: current video is not DV Profile 7 (${describeVideoFormat(currentVideoFormat)})" + ) + return false + } dv7RetryAttempted = true - val newMode = DoviBridge.getDv7FallbackMode() + val newMode = DoviBridge.getDv7FallbackMode(activity) dvMode = newMode - Log.i(TAG, "Native DV7 playback failed ($reason), retrying with $newMode") + Log.i(TAG, "Native DV7 playback failed ($reason, ${describeVideoFormat(dv7Format)}), retrying with $newMode") emitLog("info", "dv-fallback", "DV7 native failed ($reason), retrying as $newMode") return reloadCurrentMediaForDvMode() @@ -1938,6 +2008,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { firstFrameRendered = true cancelDecoderHangCheck() emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") + logNativeDvFirstFrameIfNeeded() // STATE_READY fires when the player has enough buffered to start, but // the first frame may not be on screen yet (decoder init + keyframe // decode). The MPV-parity `playback-restart` event consumers (Dart @@ -2078,6 +2149,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { decoderInitName = null audioDecoderInitName = null + currentVideoFormat = null + loggedNativeDvSelectionKey = null + loggedNativeDvFirstFrame = false loggedDecodedTrueHdTunnelingGuard = false updateAudioDecoderPolicy("open") currentMediaUri = uri @@ -2209,6 +2283,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { detectedFrameRate = -1f fpsTimestampCount = 0 firstFrameRendered = false + currentVideoFormat = null + loggedNativeDvSelectionKey = null + loggedNativeDvFirstFrame = false activeDoviMkvWrapper = null activeDoviMp4Wrapper = null stopFrameWatchdog() @@ -2606,6 +2683,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"), "dvStrippedInitNals" to (dovi?.strippedInitNalCount ?: 0L), "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), + "dvStrippedRpuNals" to (dovi?.strippedRpuNalCount ?: 0L), + "dvStrippedElNals" to (dovi?.strippedElNalCount ?: 0L), "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), "dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L), "dvRpuOutputTooSmall" to (dovi?.rpuOutputTooSmallCount ?: 0L),