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 8cafb000..64423770 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 @@ -23,55 +23,30 @@ object DoviBridge { fun isAvailable(): Boolean = nativeLoaded && runCatching { nativeIsConversionPathReady() }.getOrDefault(false) - /** - * Check if the device has a hardware decoder that supports Dolby Vision Profile 7. - * Queries MediaCodecList for decoders supporting video/dolby-vision with - * DolbyVisionProfileDvheDtr (profile 7). - */ - val deviceSupportsDvProfile7: Boolean by lazy { + private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean { try { + if (Build.VERSION.SDK_INT < minApi) return false val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - val supported = codecList.codecInfos.any { info -> + return codecList.codecInfos.any { info -> !info.isEncoder && info.supportedTypes.any { type -> type.equals("video/dolby-vision", ignoreCase = true) && - info.getCapabilitiesForType(type).profileLevels.any { pl -> - pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr - } + info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile } } } - Log.i(TAG, "Device DV Profile 7 support: $supported") - supported } catch (e: Exception) { - Log.w(TAG, "Failed to query DV7 support", e) - false + Log.w(TAG, "Failed to query DV profile $profile support", e) + return false } } - /** - * Check if the device has a hardware decoder that supports Dolby Vision Profile 8 - * (DvheSt). DolbyVisionProfileDvheSt constant requires API 27+. - */ + val deviceSupportsDvProfile7: Boolean by lazy { + deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr) + .also { Log.i(TAG, "Device DV Profile 7 support: $it") } + } + val deviceSupportsDvProfile8: Boolean by lazy { - try { - if (Build.VERSION.SDK_INT < 27) { - Log.i(TAG, "API < 27, cannot check DV Profile 8 support") - return@lazy false - } - val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS) - val supported = codecList.codecInfos.any { info -> - !info.isEncoder && info.supportedTypes.any { type -> - type.equals("video/dolby-vision", ignoreCase = true) && - info.getCapabilitiesForType(type).profileLevels.any { pl -> - pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt - } - } - } - Log.i(TAG, "Device DV Profile 8 support: $supported") - supported - } catch (e: Exception) { - Log.w(TAG, "Failed to query DV8 support", e) - false - } + deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27) + .also { Log.i(TAG, "Device DV Profile 8 support: $it") } } fun getConversionMode(): DvConversionMode = when { 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 5cb2be14..2dbcc6a8 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 @@ -165,14 +165,42 @@ class DoviConvertingTrackOutput( // Sample counter for periodic logging private var sampleCount = 0L + /** + * Process a single NAL: convert RPU (DV81), strip DV layers, or keep. + * Returns processed NAL data to write, or null if stripped. + */ + private fun processNal(nalData: ByteArray): ByteArray? { + if (nalData.size < 2) return nalData + val nalType = (nalData[0].toInt() ushr 1) and 0x3F + val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or + ((nalData[1].toInt() ushr 3) and 0x1F) + return when { + nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> { + val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81) + if (converted != null) { + normalizeLayerId(converted) + convertedRpuCount++ + converted + } else { + strippedNalCount++ + null + } + } + nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> { + strippedNalCount++ + null + } + else -> { + normalizeLayerId(nalData) + nalData + } + } + } + /** * Process NAL units in the sample data. Auto-detects format: * - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor * - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor - * - * Strips UNSPEC62 RPU NALs, UNSPEC63 EL NALs, and any NAL with nuh_layer_id > 0. - * Normalizes nuh_layer_id to 0 on all retained NALs. - * Output uses the same format as input. */ private fun processNalUnits(data: ByteArray): ByteArray { if (data.size < 4) return data @@ -202,12 +230,10 @@ class DoviConvertingTrackOutput( while (i < data.size - 2) { if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) { if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) { - // 4-byte start code: 00 00 00 01 positions.add(Pair(i + 4, 4)) i += 4 continue } else if (data[i + 2] == 1.toByte()) { - // 3-byte start code: 00 00 01 positions.add(Pair(i + 3, 3)) i += 3 continue @@ -227,58 +253,25 @@ class DoviConvertingTrackOutput( val startCodes = findAnnexBStartCodes(data) if (startCodes.isEmpty()) { sampleCount++ - return data // No start codes found, pass through + return data } for (idx in startCodes.indices) { val nalStart = startCodes[idx].first val nalEnd = if (idx + 1 < startCodes.size) { - // NAL ends where next start code begins (subtract its start code length area) - // Find the start of the next start code pattern startCodes[idx + 1].first - startCodes[idx + 1].second } else { data.size } - if (nalEnd <= nalStart) continue - val nalData = data.copyOfRange(nalStart, nalEnd) - if (nalData.size >= 2) { - val nalType = (nalData[0].toInt() ushr 1) and 0x3F - val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or - ((nalData[1].toInt() ushr 3) and 0x1F) - - when { - nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> { - // Convert RPU NAL via libdovi instead of stripping - val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81) - if (converted != null) { - normalizeLayerId(converted) - output.write(ANNEX_B_START_CODE) - output.write(converted) - convertedRpuCount++ - kept++ - } else { - strippedNalCount++ - stripped++ - } - } - nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> { - strippedNalCount++ - stripped++ - } - else -> { - normalizeLayerId(nalData) - // Write with 4-byte start code (consistent output) - output.write(ANNEX_B_START_CODE) - output.write(nalData) - kept++ - } - } - } else { + val result = processNal(data.copyOfRange(nalStart, nalEnd)) + if (result != null) { output.write(ANNEX_B_START_CODE) - output.write(nalData) + output.write(result) kept++ + } else { + stripped++ } } @@ -287,7 +280,6 @@ class DoviConvertingTrackOutput( Log.d(TAG, "Sample #$sampleCount (AnnexB): ${data.size}B -> ${output.size()}B, " + "kept=$kept stripped=$stripped NALs") } - return output.toByteArray() } @@ -311,41 +303,12 @@ class DoviConvertingTrackOutput( break } - val nalStart = pos + 4 - val nalData = data.copyOfRange(nalStart, nalStart + nalLen) - - if (nalData.size >= 2) { - val nalType = (nalData[0].toInt() ushr 1) and 0x3F - val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or - ((nalData[1].toInt() ushr 3) and 0x1F) - - when { - nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> { - // Convert RPU NAL via libdovi instead of stripping - val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81) - if (converted != null) { - normalizeLayerId(converted) - writeLengthPrefixedNal(output, converted) - convertedRpuCount++ - kept++ - } else { - strippedNalCount++ - stripped++ - } - } - nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> { - strippedNalCount++ - stripped++ - } - else -> { - normalizeLayerId(nalData) - writeLengthPrefixedNal(output, nalData) - kept++ - } - } - } else { - writeLengthPrefixedNal(output, nalData) + val result = processNal(data.copyOfRange(pos + 4, pos + 4 + nalLen)) + if (result != null) { + writeLengthPrefixedNal(output, result) kept++ + } else { + stripped++ } pos += 4 + nalLen @@ -356,7 +319,6 @@ class DoviConvertingTrackOutput( Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${data.size}B -> ${output.size()}B, " + "kept=$kept stripped=$stripped NALs") } - return output.toByteArray() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt index ce13c775..482a4f4c 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt @@ -8,13 +8,34 @@ import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput +/** + * ExtractorOutput wrapper that intercepts video track creation + * to insert DoviConvertingTrackOutput for DV processing. + * Shared by DoviExtractorWrapper (MP4) and DoviMatroskaExtractor (MKV). + */ +class DoviExtractorOutputWrapper( + private val delegate: ExtractorOutput, + private val dvMode: DvConversionMode, + private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit, +) : ExtractorOutput { + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + if (type == C.TRACK_TYPE_VIDEO) { + val wrapper = DoviConvertingTrackOutput(original, dvMode) + onVideoTrackWrapped(wrapper) + return wrapper + } + return original + } + + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) +} + /** * Extractor decorator for Mp4/FragmentedMp4 containers. * Wraps the video TrackOutput with DoviConvertingTrackOutput to perform * DV Profile 7 → 8.1 conversion via inline NAL processing. - * - * For MP4, RPU (UNSPEC62) and EL (UNSPEC63) NALs are interleaved in sample data, - * so no BlockAdditions handling is needed. */ class DoviExtractorWrapper( private val delegate: Extractor, @@ -27,20 +48,7 @@ class DoviExtractorWrapper( override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) override fun init(output: ExtractorOutput) { - delegate.init(object : ExtractorOutput { - override fun track(id: Int, type: Int): TrackOutput { - val original = output.track(id, type) - if (type == C.TRACK_TYPE_VIDEO) { - val wrapper = DoviConvertingTrackOutput(original, dvMode) - doviTrackOutput = wrapper - return wrapper - } - return original - } - - override fun endTracks() = output.endTracks() - override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap) - }) + delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it }) } override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt index 3f3acdb7..6ad1864a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviMatroskaExtractor.kt @@ -1,10 +1,8 @@ package com.edde746.plezy.exoplayer import android.util.Log -import androidx.media3.common.C import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.text.SubtitleParser import io.github.peerless2012.ass.media.AssHandler @@ -141,33 +139,11 @@ class DoviMatroskaExtractor( val field = extractorOutputField ?: return val output = field.get(this) as? ExtractorOutput ?: return if (output is DoviExtractorOutputWrapper) return - field.set(this, DoviExtractorOutputWrapper(output, this)) + field.set(this, DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it }) } private fun clearAttachment() { currentAttachmentName = null currentAttachmentMime = null } - - /** - * ExtractorOutput wrapper that intercepts video track creation - * to insert DoviConvertingTrackOutput for DV processing. - */ - class DoviExtractorOutputWrapper( - private val delegate: ExtractorOutput, - private val extractor: DoviMatroskaExtractor, - ) : ExtractorOutput { - override fun track(id: Int, type: Int): TrackOutput { - val original = delegate.track(id, type) - if (type == C.TRACK_TYPE_VIDEO) { - val wrapper = DoviConvertingTrackOutput(original, extractor.dvMode) - extractor.doviTrackOutput = wrapper - return wrapper - } - return original - } - - override fun endTracks() = delegate.endTracks() - override fun seekMap(seekMap: androidx.media3.extractor.SeekMap) = delegate.seekMap(seekMap) - } } 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 7e84ef8a..455d22a5 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 @@ -1452,13 +1452,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "isPlaying" to player.isPlaying, "playbackState" to player.playbackState, // DV conversion (query extractor's track output, which is set during extraction) - "dvConversionActive" to ((activeDoviMkvExtractor?.doviTrackOutput?.conversionActive - ?: activeDoviMp4Wrapper?.doviTrackOutput?.conversionActive) == true), - "dvConversionMode" to dvMode.name, - "dvStrippedNals" to ((activeDoviMkvExtractor?.doviTrackOutput?.strippedNalCount - ?: activeDoviMp4Wrapper?.doviTrackOutput?.strippedNalCount) ?: 0L), - "dvConvertedRpus" to ((activeDoviMkvExtractor?.doviTrackOutput?.convertedRpuCount - ?: activeDoviMp4Wrapper?.doviTrackOutput?.convertedRpuCount) ?: 0L), + *(activeDoviMkvExtractor?.doviTrackOutput + ?: activeDoviMp4Wrapper?.doviTrackOutput).let { dovi -> + arrayOf( + "dvConversionActive" to (dovi?.conversionActive == true), + "dvConversionMode" to dvMode.name, + "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), + "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), + ) + }, ) }