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:
@@ -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
|
||||
|
||||
+74
-25
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user