From 036d99f11f64d637dc2a1a249927ce8e95626809 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 1 May 2026 17:50:59 +0200 Subject: [PATCH] perf(android): optimize DV conversion hot path Remove avoidable per-RPU allocations and copies during DV fallback conversion, and add debug controls to force conversion modes while profiling playback. --- android/app/src/main/cpp/dovi_bridge.cpp | 91 ++++++++++------- .../com/edde746/plezy/exoplayer/DoviBridge.kt | 38 +++++-- .../exoplayer/DoviConvertingTrackOutput.kt | 99 ++++++++++++++----- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 64 +++++++++++- .../plezy/exoplayer/ExoPlayerPlugin.kt | 21 ++++ lib/mpv/player/platform/player_android.dart | 7 ++ .../sheets/video_settings_sheet.dart | 71 ++++++++++++- .../performance_overlay.dart | 5 +- .../performance_stats.dart | 39 ++++++++ .../performance_stats_service.dart | 5 + 10 files changed, 370 insertions(+), 70 deletions(-) diff --git a/android/app/src/main/cpp/dovi_bridge.cpp b/android/app/src/main/cpp/dovi_bridge.cpp index c485d95e..3a6d6c2f 100644 --- a/android/app/src/main/cpp/dovi_bridge.cpp +++ b/android/app/src/main/cpp/dovi_bridge.cpp @@ -3,6 +3,7 @@ #include #include +#include #define TAG "DoviBridge" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) @@ -13,70 +14,85 @@ #endif static const char* BRIDGE_VERSION = "1.0.0"; +static constexpr jint CONVERT_FAILED = -1; +static constexpr jint DESTINATION_TOO_SMALL = -2; +static constexpr jint MAX_RPU_INPUT_SIZE = 8192; +static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384; -extern "C" JNIEXPORT jbyteArray JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81( - JNIEnv* env, jclass, jbyteArray payload, jint mode) { +extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81( + JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output, + jint output_offset, jint output_capacity, jint mode) { #if !DOVI_REAL_LINKED - return nullptr; + return CONVERT_FAILED; #else - if (payload == nullptr) return nullptr; + if (payload == nullptr || output == nullptr) return CONVERT_FAILED; + if (payload_offset < 0 || payload_length <= 0 || output_offset < 0 || output_capacity < 0) { + return CONVERT_FAILED; + } - jsize len = env->GetArrayLength(payload); - if (len <= 0) return nullptr; + const jsize payload_array_len = env->GetArrayLength(payload); + const jsize output_array_len = env->GetArrayLength(output); + if (payload_offset > payload_array_len || payload_length > payload_array_len - payload_offset) { + return CONVERT_FAILED; + } + if (output_offset > output_array_len) return DESTINATION_TOO_SMALL; + + const jsize logical_output_len = output_capacity < output_array_len ? output_capacity : output_array_len; + if (output_offset > logical_output_len) return DESTINATION_TOO_SMALL; // Valid RPU NALs are typically <2 KiB; reject unreasonable sizes - if (len > 8192) { - LOGW("RPU payload too large (%d bytes), skipping", len); - return nullptr; + if (payload_length > MAX_RPU_INPUT_SIZE) { + LOGW("RPU payload too large (%d bytes), skipping", payload_length); + return CONVERT_FAILED; } // Copy to native heap so libdovi never touches JVM heap memory. - // GetByteArrayElements on ART may return a direct heap pointer; any - // out-of-bounds access by libdovi would corrupt adjacent JVM objects. - auto* buf = new (std::nothrow) uint8_t[static_cast(len)]; - if (buf == nullptr) return nullptr; + // Do not use GetPrimitiveArrayCritical here: it can block concurrent GC + // compaction during sustained playback. A thread-local scratch buffer avoids + // per-frame heap churn while keeping libdovi away from JVM heap memory. + thread_local std::vector scratch; + try { + scratch.resize(static_cast(payload_length)); + } catch (...) { + return CONVERT_FAILED; + } - env->GetByteArrayRegion(payload, 0, len, reinterpret_cast(buf)); + env->GetByteArrayRegion(payload, payload_offset, payload_length, reinterpret_cast(scratch.data())); if (env->ExceptionCheck()) { - delete[] buf; - return nullptr; + return CONVERT_FAILED; } // Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu - DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(buf, static_cast(len)); + const auto rpu_len = static_cast(payload_length); + DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len); if (rpu == nullptr) { - delete[] buf; - return nullptr; + return CONVERT_FAILED; } const char* err = dovi_rpu_get_error(rpu); if (err != nullptr) { // Fallback: try dovi_parse_rpu (raw RPU without NAL framing) dovi_rpu_free(rpu); - rpu = dovi_parse_rpu(buf, static_cast(len)); + rpu = dovi_parse_rpu(scratch.data(), rpu_len); if (rpu == nullptr) { - delete[] buf; - return nullptr; + return CONVERT_FAILED; } err = dovi_rpu_get_error(rpu); if (err != nullptr) { LOGW("RPU parse failed: %s", err); dovi_rpu_free(rpu); - delete[] buf; - return nullptr; + return CONVERT_FAILED; } } - delete[] buf; - // Convert to target profile (mode 2 = P8.1 with no-op curves) int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast(mode)); if (ret != 0) { err = dovi_rpu_get_error(rpu); LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown"); dovi_rpu_free(rpu); - return nullptr; + return CONVERT_FAILED; } // Write back as UNSPEC62 NAL @@ -86,25 +102,32 @@ extern "C" JNIEXPORT jbyteArray JNICALL Java_com_edde746_plezy_exoplayer_DoviBri LOGW("RPU write failed: %s", err ? err : "unknown"); if (out != nullptr) dovi_data_free(out); dovi_rpu_free(rpu); - return nullptr; + return CONVERT_FAILED; } - if (out->len > 16384) { + if (out->len > MAX_RPU_OUTPUT_SIZE) { LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len); dovi_data_free(out); dovi_rpu_free(rpu); - return nullptr; + return CONVERT_FAILED; } - jbyteArray result = env->NewByteArray(static_cast(out->len)); - if (result != nullptr) { - env->SetByteArrayRegion(result, 0, static_cast(out->len), reinterpret_cast(out->data)); + const auto writable = static_cast(logical_output_len - output_offset); + if (out->len > writable) { + dovi_data_free(out); + dovi_rpu_free(rpu); + return DESTINATION_TOO_SMALL; } + env->SetByteArrayRegion( + output, output_offset, static_cast(out->len), reinterpret_cast(out->data)); + const bool write_failed = env->ExceptionCheck(); + const auto written = static_cast(out->len); + dovi_data_free(out); dovi_rpu_free(rpu); - return result; + return write_failed ? CONVERT_FAILED : written; #endif } 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 b2f3cf55..c144c4e4 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 @@ -10,6 +10,9 @@ enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP } object DoviBridge { private const val TAG = "DoviBridge" + const val CONVERT_FAILED = -1 + const val DESTINATION_TOO_SMALL = -2 + private val nativeLoaded: Boolean by lazy { try { System.loadLibrary("dovi_bridge") @@ -20,8 +23,11 @@ object DoviBridge { } } - fun isAvailable(): Boolean = nativeLoaded && - runCatching { nativeIsConversionPathReady() }.getOrDefault(false) + private val conversionPathReady: Boolean by lazy { + nativeLoaded && runCatching { nativeIsConversionPathReady() }.getOrDefault(false) + } + + fun isAvailable(): Boolean = conversionPathReady private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean { try { @@ -63,11 +69,21 @@ object DoviBridge { else -> DvConversionMode.HEVC_STRIP } - fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? { - if (!isAvailable() || payload.isEmpty()) return null - return runCatching { nativeConvertDv7RpuToDv81(payload, mode) } + fun convertRpuNalu( + payload: ByteArray, + payloadOffset: Int, + payloadLength: Int, + output: ByteArray, + outputOffset: Int, + outputCapacity: Int, + mode: Int = 2 + ): Int { + if (!conversionPathReady || payloadLength <= 0) return CONVERT_FAILED + return runCatching { + nativeConvertDv7RpuToDv81(payload, payloadOffset, payloadLength, output, outputOffset, outputCapacity, mode) + } .onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") } - .getOrNull() + .getOrDefault(CONVERT_FAILED) } fun getVersion(): String? { @@ -76,7 +92,15 @@ object DoviBridge { } @JvmStatic - private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray? + private external fun nativeConvertDv7RpuToDv81( + payload: ByteArray, + payloadOffset: Int, + payloadLength: Int, + output: ByteArray, + outputOffset: Int, + outputCapacity: Int, + mode: Int + ): Int @JvmStatic private external fun nativeIsConversionPathReady(): Boolean 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 7903553f..f16229ad 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 @@ -37,7 +37,7 @@ class DoviConvertingTrackOutput( private const val NAL_TYPE_UNSPEC63 = 63 private const val LIBDOVI_MODE_TO_81 = 2 private const val INITIAL_BUFFER_SIZE = 256 * 1024 - private const val READ_CHUNK = 64 * 1024 + private const val MAX_CONVERTED_RPU_SIZE = 16 * 1024 private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1) } @@ -47,18 +47,28 @@ class DoviConvertingTrackOutput( private set var convertedRpuCount = 0L private set + var rpuConversionFailureCount = 0L + private set + var rpuOutputTooSmallCount = 0L + private set + val averageRpuConversionTimeUs: Long + get() = if (rpuConversionCallCount > 0) totalRpuConversionTimeUs / rpuConversionCallCount else 0L + val averageSampleProcessingTimeUs: Long + get() = if (sampleCount > 0) totalSampleProcessingTimeUs / sampleCount else 0L // Reusable buffers — grown as needed, never shrunk private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE) private var sampleLen = 0 private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE) private var outputLen = 0 - private var readBuf = ByteArray(READ_CHUNK) private val outputParsable = ParsableByteArray() private var buffering = false // Sample counter for periodic logging private var sampleCount = 0L + private var rpuConversionCallCount = 0L + private var totalRpuConversionTimeUs = 0L + private var totalSampleProcessingTimeUs = 0L override fun format(format: Format) { if (!conversionActive) { @@ -133,11 +143,9 @@ class DoviConvertingTrackOutput( } buffering = true - if (readBuf.size < length) readBuf = ByteArray(length) - val bytesRead = input.read(readBuf, 0, length) + ensureSampleCapacity(sampleLen + length) + val bytesRead = input.read(sampleBuf, sampleLen, length) if (bytesRead > 0) { - ensureSampleCapacity(sampleLen + bytesRead) - System.arraycopy(readBuf, 0, sampleBuf, sampleLen, bytesRead) sampleLen += bytesRead } return bytesRead @@ -173,6 +181,7 @@ class DoviConvertingTrackOutput( val outLen: Int val outBuf: ByteArray + val processStartNs = System.nanoTime() val success = try { processNalUnits(srcLen) true @@ -187,6 +196,9 @@ class DoviConvertingTrackOutput( outLen = srcLen outBuf = sampleBuf } + if (success) { + recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L) + } // Skip empty samples (all NALs were DV layers) — don't confuse the decoder if (outLen == 0) return @@ -299,17 +311,12 @@ class DoviConvertingTrackOutput( outputLen += nalLen kept++ } else if (action == NalAction.CONVERT) { - val converted = DoviBridge.convertRpuNalu( - sampleBuf.copyOfRange(nalStart, nalStart + nalLen), - LIBDOVI_MODE_TO_81 - ) - if (converted != null) { - normalizeLayerId(converted, 0) - ensureOutputCapacity(outputLen + 4 + converted.size) + val convertedLen = convertRpuIntoOutput(nalStart, nalLen, outputLen + 4) + if (convertedLen >= 0) { System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4) outputLen += 4 - System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) - outputLen += converted.size + normalizeLayerId(outputBuf, outputLen) + outputLen += convertedLen convertedRpuCount++ kept++ } else { @@ -372,17 +379,12 @@ class DoviConvertingTrackOutput( outputLen += nalLen kept++ } else if (action == NalAction.CONVERT) { - val converted = DoviBridge.convertRpuNalu( - sampleBuf.copyOfRange(nalStart, nalStart + nalLen), - LIBDOVI_MODE_TO_81 - ) - if (converted != null) { - normalizeLayerId(converted, 0) - ensureOutputCapacity(outputLen + 4 + converted.size) - writeInt32BE(outputBuf, outputLen, converted.size) + val convertedLen = convertRpuIntoOutput(nalStart, nalLen, outputLen + 4) + if (convertedLen >= 0) { + writeInt32BE(outputBuf, outputLen, convertedLen) outputLen += 4 - System.arraycopy(converted, 0, outputBuf, outputLen, converted.size) - outputLen += converted.size + normalizeLayerId(outputBuf, outputLen) + outputLen += convertedLen convertedRpuCount++ kept++ } else { @@ -409,6 +411,53 @@ class DoviConvertingTrackOutput( private enum class NalAction { KEEP, STRIP, CONVERT } + private fun convertRpuIntoOutput(nalStart: Int, nalLen: Int, outputOffset: Int): Int { + ensureOutputCapacity(outputOffset + MAX_CONVERTED_RPU_SIZE) + + var retriedAfterResize = false + while (true) { + val startNs = System.nanoTime() + val written = DoviBridge.convertRpuNalu( + payload = sampleBuf, + payloadOffset = nalStart, + payloadLength = nalLen, + output = outputBuf, + outputOffset = outputOffset, + outputCapacity = outputBuf.size, + mode = LIBDOVI_MODE_TO_81 + ) + totalRpuConversionTimeUs += (System.nanoTime() - startNs) / 1_000L + rpuConversionCallCount++ + + if (written >= 0) return written + + if (written == DoviBridge.DESTINATION_TOO_SMALL) { + rpuOutputTooSmallCount++ + if (!retriedAfterResize) { + val doubled = if (outputBuf.size <= Int.MAX_VALUE / 2) outputBuf.size * 2 else Int.MAX_VALUE + ensureOutputCapacity(maxOf(doubled, outputOffset + MAX_CONVERTED_RPU_SIZE)) + retriedAfterResize = true + continue + } + } + + rpuConversionFailureCount++ + return written + } + } + + private fun recordSampleProcessing(elapsedUs: Long) { + totalSampleProcessingTimeUs += elapsedUs + if (sampleCount <= 3 || (sampleCount > 0 && sampleCount % 500 == 0L)) { + Log.d( + TAG, + "Perf: avgSample=${averageSampleProcessingTimeUs}us, " + + "avgRpu=${averageRpuConversionTimeUs}us, converted=$convertedRpuCount, " + + "rpuFailures=$rpuConversionFailureCount, rpuTooSmall=$rpuOutputTooSmallCount" + ) + } + } + /** Classify a NAL at sampleBuf[offset..offset+len) without copying. */ private fun processNalInline(offset: Int, len: Int): NalAction { if (len < 2) return NalAction.KEEP 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 cfa08ed7..21e9bc9b 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 @@ -248,12 +248,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // DV conversion state private var dvMode: DvConversionMode = DvConversionMode.DISABLED + private var debugDvModeOverride: DvConversionMode? = null private var dv7RetryAttempted = false @Volatile private var activeDoviMkvWrapper: DoviExtractorWrapper? = null @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null + private fun getConfiguredDvMode(): DvConversionMode = debugDvModeOverride ?: DoviBridge.getConversionMode() + fun initialize(bufferSizeBytes: Int? = null, tunnelingEnabled: Boolean = true): Boolean { if (isInitialized) { Log.d(TAG, "Already initialized") @@ -261,7 +264,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } tunnelingUserEnabled = tunnelingEnabled - this.dvMode = DoviBridge.getConversionMode() + this.dvMode = getConfiguredDvMode() Log.i( TAG, "DV conversion: mode=$dvMode, bridge=${DoviBridge.isAvailable()}, " + @@ -824,6 +827,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { */ 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 val uri = currentMediaUri ?: return false @@ -1420,6 +1424,57 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { subtitleDelayUs.set((seconds * 1_000_000).toLong()) } + fun setDebugDvConversionMode(mode: String): Boolean { + val override = when (mode.trim().lowercase()) { + "auto" -> null + "disabled", "native" -> DvConversionMode.DISABLED + "dv81", "p8", "p7_to_p8", "p7-to-p8" -> DvConversionMode.DV81 + "hevc", "hevc_strip", "p7_to_hevc", "p7-to-hevc" -> DvConversionMode.HEVC_STRIP + else -> return false + } + + debugDvModeOverride = override + dvMode = getConfiguredDvMode() + dv7RetryAttempted = override != null + activeDoviMkvWrapper = null + activeDoviMp4Wrapper = null + + val debugMode = override?.name ?: "AUTO" + emitLog("info", "dv-debug", "Debug DV conversion mode set to $debugMode (active=$dvMode)") + reloadCurrentMediaForDvMode() + return true + } + + private fun reloadCurrentMediaForDvMode() { + val player = exoPlayer ?: return + val uri = currentMediaUri ?: return + if (currentMediaIsLive) return + + val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs) + val savedPlayWhenReady = player.playWhenReady + pendingStartPositionMs = savedPosition + pendingPlayWhenReady = savedPlayWhenReady + decoderInitName = null + audioDecoderInitName = null + firstFrameRendered = false + stopFrameWatchdog() + cancelDecoderHangCheck() + + trackSelector?.let { selector -> + selector.parameters = selector.buildUponParameters() + .setTunnelingEnabled(tunnelingUserEnabled) + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + .build() + } + + val mediaItem = buildMediaItem(uri) + player.setMediaItem(mediaItem, savedPosition) + player.prepare() + player.playWhenReady = savedPlayWhenReady + emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms") + } + fun play() { pendingPlayWhenReady = null exoPlayer?.play() @@ -1784,8 +1839,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { arrayOf( "dvConversionActive" to (dovi?.conversionActive == true), "dvConversionMode" to dvMode.name, + "dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"), "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), - "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L) + "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), + "dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L), + "dvRpuOutputTooSmall" to (dovi?.rpuOutputTooSmallCount ?: 0L), + "dvAvgRpuConversionUs" to (dovi?.averageRpuConversionTimeUs ?: 0L), + "dvAvgSampleProcessingUs" to (dovi?.averageSampleProcessingTimeUs ?: 0L) ) } ) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index bf43c7be..0a3418b8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -148,6 +148,7 @@ class ExoPlayerPlugin : } "setSubtitleStyle" -> handleSetSubtitleStyle(call, result) "setBoxFitMode" -> handleSetBoxFitMode(call, result) + "setDvConversionMode" -> handleSetDvConversionMode(call, result) "observeProperty" -> handleObserveProperty(call, result) "setMpvProperty" -> handleSetMpvProperty(call, result) "setLogLevel" -> { @@ -545,6 +546,26 @@ class ExoPlayerPlugin : } ?: result.success(null) } + private fun handleSetDvConversionMode(call: MethodCall, result: MethodChannel.Result) { + val mode = call.argument("mode") + if (mode == null) { + result.error("INVALID_ARGS", "Missing 'mode'", null) + return + } + if (usingMpvFallback) { + result.success(false) + return + } + activity?.runOnUiThread { + val handled = playerCore?.setDebugDvConversionMode(mode) == true + if (handled) { + result.success(true) + } else { + result.error("INVALID_ARGS", "Invalid DV conversion mode: $mode", null) + } + } ?: result.error("NO_ACTIVITY", "Activity not available", null) + } + private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) { val name = call.argument("name") val value = call.argument("value") diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 5b80186d..a908b666 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -191,6 +191,9 @@ class PlayerAndroid extends PlayerBase { case 'tunneled-playback': _tunnelingEnabled = value != 'no'; break; + case 'dv-conversion-mode': + await invoke('setDvConversionMode', {'mode': value}); + break; case 'sub-visibility': if (value == 'no') { // Store current subtitle track and disable @@ -237,6 +240,10 @@ class PlayerAndroid extends PlayerBase { return (state.duration.inMilliseconds / 1000.0).toString(); case 'seekable': return state.seekable ? 'yes' : 'no'; + case 'dv-conversion-mode': + final stats = await getStats(); + final mode = stats['dvConversionDebugMode']; + return mode?.toString().toLowerCase(); // Video frame rate - query from ExoPlayer stats case 'container-fps': final fpsStats = await getStats(); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 0723a14f..6d412279 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -30,7 +30,7 @@ import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; import 'base_video_control_sheet.dart'; -enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice, shader } +enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice, shader, dvConversion } /// Reusable menu item widget for settings sheet class _SettingsMenuItem extends StatelessWidget { @@ -137,6 +137,7 @@ class _VideoSettingsSheetState extends State { bool _autoPlayNextEpisode = true; bool _audioPassthrough = false; bool _audioNormalization = false; + String _dvConversionMode = 'auto'; @override void initState() { @@ -148,6 +149,9 @@ class _VideoSettingsSheetState extends State { Future _loadSettings() async { final settings = await SettingsService.getInstance(); + final dvConversionMode = kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer' + ? await widget.player.getProperty('dv-conversion-mode') + : null; if (!mounted) return; setState(() { _enableHDR = settings.read(SettingsService.enableHDR); @@ -155,6 +159,7 @@ class _VideoSettingsSheetState extends State { _autoPlayNextEpisode = settings.read(SettingsService.autoPlayNextEpisode); _audioPassthrough = settings.read(SettingsService.audioPassthrough); _audioNormalization = settings.read(SettingsService.audioNormalization); + _dvConversionMode = _normalizeDvConversionMode(dvConversionMode); }); } @@ -212,6 +217,15 @@ class _VideoSettingsSheetState extends State { await widget.player.setProperty('af', newValue ? 'loudnorm=I=-14:TP=-3:LRA=4' : ''); } + Future _setDebugDvConversionMode(String mode) async { + await widget.player.setProperty('dv-conversion-mode', mode); + if (!mounted) return; + setState(() { + _dvConversionMode = mode; + }); + OverlaySheetController.of(context).close(); + } + void _navigateTo(_SettingsView view) { // Sync views open as a compact top bar instead of a sub-view if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) { @@ -297,6 +311,8 @@ class _VideoSettingsSheetState extends State { return t.videoSettings.audioOutput; case _SettingsView.shader: return t.shaders.title; + case _SettingsView.dvConversion: + return 'DV Conversion Mode'; } } @@ -316,9 +332,29 @@ class _VideoSettingsSheetState extends State { return Symbols.speaker_rounded; case _SettingsView.shader: return Symbols.auto_fix_high_rounded; + case _SettingsView.dvConversion: + return Symbols.hdr_strong_rounded; } } + String _normalizeDvConversionMode(String? mode) { + return switch (mode?.trim().toLowerCase()) { + 'disabled' || 'native' => 'disabled', + 'dv81' || 'p8' || 'p7_to_p8' || 'p7-to-p8' => 'dv81', + 'hevc' || 'hevc_strip' || 'p7_to_hevc' || 'p7-to-hevc' => 'hevc_strip', + _ => 'auto', + }; + } + + String _formatDvConversionMode(String mode) { + return switch (_normalizeDvConversionMode(mode)) { + 'disabled' => 'Native / Disabled', + 'dv81' => 'P7 → P8.1', + 'hevc_strip' => 'P7 → HEVC', + _ => 'Auto', + }; + } + String _formatSleepTimer(SleepTimerService sleepTimer) { if (!sleepTimer.isActive) return 'Off'; final remaining = sleepTimer.remainingTime; @@ -512,6 +548,15 @@ class _VideoSettingsSheetState extends State { onTap: _togglePerformanceOverlay, ), + if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer') + _SettingsMenuItem( + icon: Symbols.hdr_strong_rounded, + title: 'DV Conversion Mode', + valueText: _formatDvConversionMode(_dvConversionMode), + isHighlighted: _dvConversionMode != 'auto', + onTap: () => _navigateTo(_SettingsView.dvConversion), + ), + // Debug: Trigger MPV Fallback (Android ExoPlayer only) if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer') FocusableListTile( @@ -539,6 +584,28 @@ class _VideoSettingsSheetState extends State { ); } + Widget _buildDvConversionView() { + const modes = [ + (value: 'auto', title: 'Auto', subtitle: 'Use device capability detection and normal fallback behavior'), + (value: 'disabled', title: 'Native / Disabled', subtitle: 'Force native DV7 and suppress DV conversion retry'), + (value: 'dv81', title: 'P7 → P8.1', subtitle: 'Force inline RPU conversion to Dolby Vision profile 8.1'), + (value: 'hevc_strip', title: 'P7 → HEVC', subtitle: 'Strip Dolby Vision RPU/EL layers and present plain HEVC'), + ]; + final primary = Theme.of(context).colorScheme.primary; + + return ListView( + children: [ + for (final mode in modes) + FocusableListTile( + title: Text(mode.title, style: TextStyle(color: _dvConversionMode == mode.value ? primary : null)), + subtitle: Text(mode.subtitle, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)), + trailing: _dvConversionMode == mode.value ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, + onTap: () => _setDebugDvConversionMode(mode.value), + ), + ], + ); + } + Widget _buildSpeedView() { return StreamBuilder( stream: widget.player.streams.rate, @@ -854,6 +921,8 @@ class _VideoSettingsSheetState extends State { return _buildAudioDeviceView(); case _SettingsView.shader: return _buildShaderView(); + case _SettingsView.dvConversion: + return _buildDvConversionView(); } }(), ); diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart index dd6cae6a..11316504 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -58,7 +58,7 @@ class _PlayerPerformanceOverlayState extends State { if (!isMpv) _metric('Tunneling', _stats.tunneledPlaybackFormatted), if (_stats.aspectName != null && _stats.aspectName!.isNotEmpty) _metric('Aspect', _stats.aspectName!), if (_stats.rotate != null && _stats.rotate != 0) _metric('Rotation', _stats.rotateFormatted), - if (_stats.dvConversionActive) _metric('DV', _stats.dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC'), + if (_stats.dvConversionActive) _metric('DV', _stats.dvConversionFormatted), ]), _buildSection(Symbols.volume_up_rounded, 'Audio', [ if (_stats.audioCodec != null) _metric('Codec', _stats.audioCodec!), @@ -81,6 +81,9 @@ class _PlayerPerformanceOverlayState extends State { if (isMpv) _metric('Display FPS', _stats.displayFpsFormatted), if (isMpv) _metric('A/V Sync', _stats.avsyncFormatted), _metric('Dropped', _stats.droppedFramesFormatted), + if (_stats.dvConversionActive) _metric('DV RPUs', _stats.dvRpuCountFormatted), + if (_stats.dvConversionActive) _metric('DV RPU Avg', _stats.dvAvgRpuConversionFormatted), + if (_stats.dvConversionActive) _metric('DV Sample Avg', _stats.dvAvgSampleProcessingFormatted), ]), if (_stats.hasHdrMetadata) _buildSection(Symbols.hdr_on_rounded, 'HDR', [ diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index 7dd65e2d..1d24c50e 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -56,6 +56,11 @@ class PerformanceStats { // DV conversion final bool dvConversionActive; final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED" + final int? dvConvertedRpus; + final int? dvRpuConversionFailures; + final int? dvRpuOutputTooSmall; + final int? dvAvgRpuConversionUs; + final int? dvAvgSampleProcessingUs; // App metrics final int? appMemoryBytes; @@ -98,6 +103,11 @@ class PerformanceStats { this.cacheDuration, this.dvConversionActive = false, this.dvConversionMode = '', + this.dvConvertedRpus, + this.dvRpuConversionFailures, + this.dvRpuOutputTooSmall, + this.dvAvgRpuConversionUs, + this.dvAvgSampleProcessingUs, this.appMemoryBytes, this.uiFps, }); @@ -140,6 +150,11 @@ class PerformanceStats { cacheDuration = null, dvConversionActive = false, dvConversionMode = '', + dvConvertedRpus = null, + dvRpuConversionFailures = null, + dvRpuOutputTooSmall = null, + dvAvgRpuConversionUs = null, + dvAvgSampleProcessingUs = null, appMemoryBytes = null, uiFps = null; @@ -255,6 +270,30 @@ class PerformanceStats { /// Format tunneled playback status with reason. String get tunneledPlaybackFormatted => tunnelingStatus ?? (tunneledPlayback ? 'Active' : 'Off'); + /// Format DV conversion mode for display. + String get dvConversionFormatted => dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC'; + + /// Format DV RPU conversion totals. + String get dvRpuCountFormatted { + final converted = dvConvertedRpus ?? 0; + final failures = dvRpuConversionFailures ?? 0; + return failures > 0 ? '$converted ($failures failed)' : converted.toString(); + } + + /// Format DV conversion timing in microseconds. + String get dvAvgRpuConversionFormatted { + final us = dvAvgRpuConversionUs; + if (us == null || us <= 0) return 'N/A'; + return '${us}us'; + } + + /// Format DV sample processing timing in microseconds. + String get dvAvgSampleProcessingFormatted { + final us = dvAvgSampleProcessingUs; + if (us == null || us <= 0) return 'N/A'; + return '${us}us'; + } + /// Format app memory usage in MB. String get appMemoryFormatted { if (appMemoryBytes == null) return 'N/A'; diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 94668afa..bab88548 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -208,6 +208,11 @@ class PerformanceStatsService { // DV conversion dvConversionActive: statsMap['dvConversionActive'] == true, dvConversionMode: statsMap['dvConversionMode'] as String? ?? '', + dvConvertedRpus: (statsMap['dvConvertedRpus'] as num?)?.toInt(), + dvRpuConversionFailures: (statsMap['dvRpuConversionFailures'] as num?)?.toInt(), + dvRpuOutputTooSmall: (statsMap['dvRpuOutputTooSmall'] as num?)?.toInt(), + dvAvgRpuConversionUs: (statsMap['dvAvgRpuConversionUs'] as num?)?.toInt(), + dvAvgSampleProcessingUs: (statsMap['dvAvgSampleProcessingUs'] as num?)?.toInt(), // App metrics appMemoryBytes: appMemory, uiFps: _currentUiFps,