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.
This commit is contained in:
edde746
2026-05-01 17:50:59 +02:00
parent e78f7cee5e
commit 036d99f11f
10 changed files with 370 additions and 70 deletions
+57 -34
View File
@@ -3,6 +3,7 @@
#include <cstring>
#include <new>
#include <vector>
#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<size_t>(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<uint8_t> scratch;
try {
scratch.resize(static_cast<size_t>(payload_length));
} catch (...) {
return CONVERT_FAILED;
}
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte*>(buf));
env->GetByteArrayRegion(payload, payload_offset, payload_length, reinterpret_cast<jbyte*>(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<size_t>(len));
const auto rpu_len = static_cast<size_t>(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<size_t>(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<uint8_t>(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<jsize>(out->len));
if (result != nullptr) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len), reinterpret_cast<const jbyte*>(out->data));
const auto writable = static_cast<size_t>(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<jsize>(out->len), reinterpret_cast<const jbyte*>(out->data));
const bool write_failed = env->ExceptionCheck();
const auto written = static_cast<jint>(out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return result;
return write_failed ? CONVERT_FAILED : written;
#endif
}
@@ -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
@@ -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
@@ -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)
)
}
)
@@ -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<String>("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<String>("name")
val value = call.argument<String>("value")
@@ -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();
@@ -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<VideoSettingsSheet> {
bool _autoPlayNextEpisode = true;
bool _audioPassthrough = false;
bool _audioNormalization = false;
String _dvConversionMode = 'auto';
@override
void initState() {
@@ -148,6 +149,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
Future<void> _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<VideoSettingsSheet> {
_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<VideoSettingsSheet> {
await widget.player.setProperty('af', newValue ? 'loudnorm=I=-14:TP=-3:LRA=4' : '');
}
Future<void> _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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
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<VideoSettingsSheet> {
);
}
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<double>(
stream: widget.player.streams.rate,
@@ -854,6 +921,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildAudioDeviceView();
case _SettingsView.shader:
return _buildShaderView();
case _SettingsView.dvConversion:
return _buildDvConversionView();
}
}(),
);
@@ -58,7 +58,7 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
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<PlayerPerformanceOverlay> {
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', [
@@ -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';
@@ -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,