refactor(android): harden and share player integration

This commit is contained in:
edde746
2026-07-12 17:31:15 +02:00
parent a9493f4f0e
commit b676ef6c56
23 changed files with 1380 additions and 767 deletions
@@ -0,0 +1,194 @@
package com.edde746.plezy
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.content.FileProvider
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
internal class ExternalPlayerChannel(private val activity: Activity) {
companion object {
private const val CHANNEL = "com.plezy/external_player"
const val REQUEST_CODE = 7461
private const val API_MX_RETURN_RESULT = "return_result"
private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW"
private const val API_MX_RESULT_POSITION = "position"
private const val API_MX_RESULT_DURATION = "duration"
private const val API_MX_RESULT_END_BY = "end_by"
private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion"
private const val API_MX_TITLE = "title"
private const val API_MX_FILENAME = "filename"
private const val API_MX_SECURE_URI = "secure_uri"
private const val API_VLC_RESULT_POSITION = "extra_position"
private const val API_VLC_RESULT_DURATION = "extra_duration"
private const val API_VIMU_TITLE = "forcename"
private const val API_VIMU_SEEK_POSITION = "startfrom"
private const val API_VIMU_RESUME = "forceresume"
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
private const val API_VIMU_RESULT_ERROR = 4
private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1
private val positionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
private val durationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION)
}
private var pendingResult: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, CHANNEL).setMethodCallHandler(::onMethodCall)
}
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST_CODE) return false
val result = pendingResult
pendingResult = null
if (result == null) {
android.util.Log.w("ExternalPlayerChannel", "Result received without a pending channel call")
} else {
result.success(buildResult(resultCode, data))
}
return true
}
fun dispose() {
pendingResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
pendingResult = null
}
internal fun buildResult(resultCode: Int, data: Intent?): Map<String, Any?> {
val extras = data?.extras
val endPosition = firstNumberExtra(extras, positionExtras)
val duration = firstNumberExtra(extras, durationExtras)
val action = data?.action
val playbackCompleted = when (action) {
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
else -> false
}
val playbackError = action == API_VIMU_RESULT_ID && resultCode == API_VIMU_RESULT_ERROR
return mapOf(
"launched" to true,
"resultCode" to resultCode,
"resultOk" to (resultCode == Activity.RESULT_OK),
"action" to action,
"positionMs" to endPosition,
"durationMs" to duration,
"playbackCompleted" to playbackCompleted,
"playbackError" to playbackError
)
}
private fun firstNumberExtra(extras: Bundle?, keys: Array<String>): Long? {
if (extras == null) return null
for (key in keys) {
@Suppress("DEPRECATION")
val value = extras.get(key)
when (value) {
is Number -> return value.toLong()
is String -> value.toLongOrNull()?.let { return it }
}
}
return null
}
@Suppress("UNCHECKED_CAST")
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "openVideo") {
result.notImplemented()
return
}
val filePath = call.argument<String>("filePath")
val packageNames = call.argument<List<Any?>>("packages")
?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) }
?: emptyList()
val title = call.argument<String>("title")?.trim()?.takeIf(String::isNotEmpty)
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return
}
if (pendingResult != null) {
result.error("ALREADY_ACTIVE", "An external player is already active", null)
return
}
try {
val source = resolveSource(filePath)
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
for (packageName in targetPackages) {
try {
pendingResult = result
activity.startActivityForResult(
buildIntent(source, packageName, startPositionMs, title),
REQUEST_CODE
)
return
} catch (_: ActivityNotFoundException) {
pendingResult = null
}
}
val message = if (packageNames.isEmpty()) {
"No app found for video"
} else {
"No app found for packages: ${packageNames.joinToString(", ")}"
}
result.error("APP_NOT_FOUND", message, null)
} catch (error: Exception) {
pendingResult = null
result.error("LAUNCH_FAILED", error.message ?: error.javaClass.simpleName, null)
}
}
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
private fun resolveSource(filePath: String): Source {
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
val uri = Uri.parse(filePath)
return Source(uri, grantRead = false, fileName = uri.lastPathSegment)
}
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
return Source(uri, grantRead = true, fileName = uri.lastPathSegment)
}
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
val file = File(path)
val uri = FileProvider.getUriForFile(activity, "com.edde746.plezy.fileprovider", file)
return Source(uri, grantRead = true, fileName = file.name)
}
private fun buildIntent(
source: Source,
packageName: String?,
startPositionMs: Long,
title: String?
): Intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(source.uri, "video/*")
if (source.grantRead) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
packageName?.let(::setPackage)
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
if (startPosition > 0) {
putExtra(API_MX_RESULT_POSITION, startPosition)
putExtra(API_VIMU_SEEK_POSITION, startPosition)
}
putExtra(API_MX_RETURN_RESULT, true)
putExtra(API_MX_SECURE_URI, true)
putExtra(API_VIMU_RESUME, false)
title?.let {
putExtra(API_MX_TITLE, it)
putExtra(API_VIMU_TITLE, it)
}
source.fileName?.let { putExtra(API_MX_FILENAME, it) }
}
}
@@ -1,16 +1,13 @@
package com.edde746.plezy
import android.app.Activity
import android.app.ActivityManager
import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
@@ -24,7 +21,6 @@ import android.view.WindowInsets
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
@@ -38,7 +34,6 @@ import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.MethodChannel
import java.io.File
import kotlin.math.roundToInt
class MainActivity : FlutterActivity() {
@@ -46,40 +41,15 @@ class MainActivity : FlutterActivity() {
companion object {
private const val TAG = "MainActivity"
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
private const val EXTERNAL_PLAYER_REQUEST_CODE = 7461
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
private const val LOW_MEM_THRESHOLD_BYTES = 2252L shl 20
// External player result APIs used by Jellyfin Android TV.
private const val API_MX_RETURN_RESULT = "return_result"
private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW"
private const val API_MX_RESULT_POSITION = "position"
private const val API_MX_RESULT_DURATION = "duration"
private const val API_MX_RESULT_END_BY = "end_by"
private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion"
private const val API_MX_TITLE = "title"
private const val API_MX_FILENAME = "filename"
private const val API_MX_SECURE_URI = "secure_uri"
private const val API_VLC_RESULT_POSITION = "extra_position"
private const val API_VLC_RESULT_DURATION = "extra_duration"
private const val API_VIMU_TITLE = "forcename"
private const val API_VIMU_SEEK_POSITION = "startfrom"
private const val API_VIMU_RESUME = "forceresume"
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
private const val API_VIMU_RESULT_ERROR = 4
private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1
private val externalPlayerPositionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
private val externalPlayerDurationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION)
var usingSkia = false
}
private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "com.plezy/theme"
private val DEVICE_CHANNEL = "com.plezy/device"
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
@@ -87,11 +57,11 @@ class MainActivity : FlutterActivity() {
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private var watchNextPlugin: WatchNextPlugin? = null
private var nativeTextInputFocused = false
private var pendingExternalPlayerResult: MethodChannel.Result? = null
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
private var flutterSurfaceReconnectPending = false
private var activityStarted = false
private val externalPlayerChannel = ExternalPlayerChannel(this)
private inline fun logTextInputDiag(message: () -> String) {
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
@@ -325,63 +295,13 @@ class MainActivity : FlutterActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == EXTERNAL_PLAYER_REQUEST_CODE) {
val pendingResult = pendingExternalPlayerResult
pendingExternalPlayerResult = null
if (pendingResult == null) {
Log.w(TAG, "External player result received without a pending channel result")
} else {
pendingResult.success(buildExternalPlayerResult(resultCode, data))
}
return
if (!externalPlayerChannel.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
super.onActivityResult(requestCode, resultCode, data)
}
private fun buildExternalPlayerResult(resultCode: Int, data: Intent?): Map<String, Any?> {
val extras = data?.extras
val endPosition = firstNumberExtra(extras, externalPlayerPositionExtras)
val duration = firstNumberExtra(extras, externalPlayerDurationExtras)
val action = data?.action
val playbackCompleted = when (action) {
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
else -> false
}
val playbackError = when (action) {
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_ERROR
else -> false
}
return mapOf(
"launched" to true,
"resultCode" to resultCode,
"resultOk" to (resultCode == Activity.RESULT_OK),
"action" to action,
"positionMs" to endPosition,
"durationMs" to duration,
"playbackCompleted" to playbackCompleted,
"playbackError" to playbackError
)
}
private fun firstNumberExtra(extras: Bundle?, keys: Array<String>): Long? {
if (extras == null) return null
for (key in keys) {
@Suppress("DEPRECATION")
val value = extras.get(key)
when (value) {
is Number -> return value.toLong()
is String -> value.toLongOrNull()?.let { return it }
}
}
return null
}
override fun onDestroy() {
pendingExternalPlayerResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
pendingExternalPlayerResult = null
externalPlayerChannel.dispose()
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -541,94 +461,7 @@ class MainActivity : FlutterActivity() {
}
}
// External player: open local video files with proper content:// URIs
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"openVideo" -> {
val filePath = call.argument<String>("filePath")
val packageNames = call.argument<List<Any?>>("packages")
?.mapNotNull { (it as? String)?.trim()?.takeIf { value -> value.isNotEmpty() } }
?: emptyList()
val title = call.argument<String>("title")?.trim()?.takeIf { it.isNotEmpty() }
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
if (filePath == null) {
result.error("INVALID_ARGUMENT", "filePath is required", null)
return@setMethodCallHandler
}
if (pendingExternalPlayerResult != null) {
result.error("ALREADY_ACTIVE", "An external player is already active", null)
return@setMethodCallHandler
}
try {
val uri: Uri
val grantRead: Boolean
val fileName: String?
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
uri = Uri.parse(filePath)
grantRead = false
fileName = uri.lastPathSegment
} else if (filePath.startsWith("content://")) {
uri = Uri.parse(filePath)
grantRead = true
fileName = uri.lastPathSegment
} else {
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
fileName = File(path).name
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
grantRead = true
}
fun buildIntent(packageName: String?): Intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
if (grantRead) {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
packageName?.let { setPackage(it) }
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
if (startPosition > 0) {
putExtra(API_MX_RESULT_POSITION, startPosition)
putExtra(API_VIMU_SEEK_POSITION, startPosition)
}
putExtra(API_MX_RETURN_RESULT, true)
putExtra(API_MX_SECURE_URI, true)
putExtra(API_VIMU_RESUME, false)
title?.let {
putExtra(API_MX_TITLE, it)
putExtra(API_VIMU_TITLE, it)
}
fileName?.let { putExtra(API_MX_FILENAME, it) }
}
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
for (packageName in targetPackages) {
try {
pendingExternalPlayerResult = result
startActivityForResult(buildIntent(packageName), EXTERNAL_PLAYER_REQUEST_CODE)
return@setMethodCallHandler
} catch (e: ActivityNotFoundException) {
pendingExternalPlayerResult = null
}
}
pendingExternalPlayerResult = null
val message = if (packageNames.isEmpty()) {
"No app found for video"
} else {
"No app found for packages: ${packageNames.joinToString(", ")}"
}
result.error("APP_NOT_FOUND", message, null)
} catch (e: Exception) {
pendingExternalPlayerResult = null
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
}
}
else -> result.notImplemented()
}
}
externalPlayerChannel.attach(flutterEngine.dartExecutor.binaryMessenger)
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
@@ -0,0 +1,95 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.EOFException
/** Shared whole-sample buffering protocol for TrackOutput transforms. */
abstract class BufferedTransformingTrackOutput(
protected val delegate: TrackOutput,
initialBufferSize: Int,
initialReadBufferSize: Int = initialBufferSize
) : TrackOutput {
protected var inputBuffer = ByteArray(initialBufferSize)
private set
private var inputLength = 0
private var buffering = false
private var readBuffer = ByteArray(initialReadBufferSize)
private val outputParsable = ParsableByteArray()
protected abstract val transformEnabled: Boolean
protected abstract val transformedBuffer: ByteArray
/** Returns transformed length, or a negative value to drop the sample. */
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
open override fun format(format: Format) = delegate.format(format)
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!transformEnabled) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
buffering = true
if (readBuffer.size < length) readBuffer = ByteArray(length)
val bytesRead = input.read(readBuffer, 0, length)
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!transformEnabled) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureInputCapacity(inputLength + length)
data.readBytes(inputBuffer, inputLength, length)
inputLength += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!transformEnabled || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val sourceLength = inputLength
inputLength = 0
val transformedLength = transformSample(sourceLength, flags)
if (transformedLength < 0) return
outputParsable.reset(transformedBuffer, transformedLength)
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
}
private fun appendInput(source: ByteArray, length: Int) {
ensureInputCapacity(inputLength + length)
System.arraycopy(source, 0, inputBuffer, inputLength, length)
inputLength += length
}
private fun ensureInputCapacity(needed: Int) {
if (inputBuffer.size < needed) {
inputBuffer = inputBuffer.copyOf(maxOf(needed, inputBuffer.size * 2))
}
}
}
@@ -2,10 +2,8 @@ package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
/**
@@ -28,10 +26,10 @@ import androidx.media3.extractor.TrackOutput
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : TrackOutput {
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE) {
companion object {
private const val TAG = "DoviConvertTrack"
@@ -65,12 +63,8 @@ class DoviConvertingTrackOutput(
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 val outputParsable = ParsableByteArray()
private var buffering = false
// Sample counter for periodic logging
private var sampleCount = 0L
@@ -79,6 +73,7 @@ class DoviConvertingTrackOutput(
private var totalSampleProcessingTimeUs = 0L
private var loggedSupplementalWrapper = false
private var loggedEncryptedSupplementalPassthrough = false
private var outputIsProcessed = false
override fun format(format: Format) {
if (!conversionActive) {
@@ -135,55 +130,15 @@ class DoviConvertingTrackOutput(
delegate.format(format)
}
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!conversionActive) {
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
override val transformEnabled: Boolean
get() = conversionActive
buffering = true
ensureSampleCapacity(sampleLen + length)
val bytesRead = input.read(sampleBuf, sampleLen, length)
if (bytesRead > 0) {
sampleLen += bytesRead
}
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!conversionActive) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureSampleCapacity(sampleLen + length)
data.readBytes(sampleBuf, sampleLen, length)
sampleLen += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!conversionActive || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val srcLen = sampleLen
sampleLen = 0
override val transformedBuffer: ByteArray
get() = if (outputIsProcessed) outputBuf else inputBuffer
override fun transformSample(inputLength: Int, flags: Int): Int {
val processStartNs = System.nanoTime()
val success = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
outputIsProcessed = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
loggedEncryptedSupplementalPassthrough = true
logWarn("Encrypted supplemental sample encountered, passing raw sample")
@@ -191,25 +146,20 @@ class DoviConvertingTrackOutput(
false
} else {
try {
processSampleData(flags, srcLen)
processSampleData(flags, inputLength)
true
} catch (e: Exception) {
logError("NAL processing failed, passing raw sample", e)
false
}
}
val outLen = if (success) outputLen else srcLen
val outBuf = if (success) outputBuf else sampleBuf
if (success) {
val transformedLength = if (outputIsProcessed) outputLen else inputLength
if (outputIsProcessed) {
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (outLen == 0) return
outputParsable.reset(outBuf, outLen)
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder.
return if (transformedLength == 0) -1 else transformedLength
}
/**
@@ -231,7 +181,7 @@ class DoviConvertingTrackOutput(
return
}
val mainSampleLen = readInt32BE(sampleBuf, 0)
val mainSampleLen = readInt32BE(inputBuffer, 0)
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
copyRawSample(dataLen)
@@ -243,7 +193,7 @@ class DoviConvertingTrackOutput(
loggedSupplementalWrapper = true
logDebug(
"Supplemental wrapper detected: total=${dataLen}B, main=${mainSampleLen}B, " +
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(sampleBuf, 4, 8)}"
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(inputBuffer, 4, 8)}"
)
}
@@ -259,7 +209,7 @@ class DoviConvertingTrackOutput(
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
writeInt32BE(outputBuf, 0, processedMainLen)
if (supplementalLen > 0) {
System.arraycopy(sampleBuf, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
System.arraycopy(inputBuffer, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
}
outputLen = 4 + processedMainLen + supplementalLen
@@ -282,7 +232,7 @@ class DoviConvertingTrackOutput(
outputLen = 0
if (dataLen < 4) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
return
}
@@ -290,22 +240,22 @@ class DoviConvertingTrackOutput(
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (
dataLen >= 4 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 0.toByte() &&
sampleBuf[dataOffset + 3] == 1.toByte()
inputBuffer[dataOffset] == 0.toByte() &&
inputBuffer[dataOffset + 1] == 0.toByte() &&
inputBuffer[dataOffset + 2] == 0.toByte() &&
inputBuffer[dataOffset + 3] == 1.toByte()
) ||
(
dataLen >= 3 &&
sampleBuf[dataOffset] == 0.toByte() &&
sampleBuf[dataOffset + 1] == 0.toByte() &&
sampleBuf[dataOffset + 2] == 1.toByte()
inputBuffer[dataOffset] == 0.toByte() &&
inputBuffer[dataOffset + 1] == 0.toByte() &&
inputBuffer[dataOffset + 2] == 1.toByte()
)
if (sampleCount == 0L) {
logDebug(
"NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${formatBytes(sampleBuf, dataOffset, 8)}"
"first bytes: ${formatBytes(inputBuffer, dataOffset, 8)}"
)
}
@@ -323,11 +273,11 @@ class DoviConvertingTrackOutput(
var scEnd = -1
var i = dataOffset
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
scEnd = i + 4
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
} else if (inputBuffer[i + 2] == 1.toByte()) {
scEnd = i + 3
break
}
@@ -337,7 +287,7 @@ class DoviConvertingTrackOutput(
if (scEnd < 0) {
// No start codes found — pass through
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
outputLen = dataLen
sampleCount++
return
@@ -350,11 +300,11 @@ class DoviConvertingTrackOutput(
var nalEnd = dataEnd
i = nalStart
while (i < dataEnd - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
nalEnd = i
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
} else if (inputBuffer[i + 2] == 1.toByte()) {
nalEnd = i
break
}
@@ -369,7 +319,7 @@ class DoviConvertingTrackOutput(
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
@@ -392,7 +342,7 @@ class DoviConvertingTrackOutput(
// Advance past the next start code
if (nalEnd >= dataEnd) break
nalStart = if (nalEnd + 3 < dataEnd && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
nalStart = if (nalEnd + 3 < dataEnd && inputBuffer[nalEnd + 2] == 0.toByte() && inputBuffer[nalEnd + 3] == 1.toByte()) {
nalEnd + 4
} else {
nalEnd + 3
@@ -417,10 +367,10 @@ class DoviConvertingTrackOutput(
var stripped = 0
while (pos + 4 <= dataEnd) {
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
(sampleBuf[pos + 3].toInt() and 0xFF)
val nalLen = ((inputBuffer[pos].toInt() and 0xFF) shl 24) or
((inputBuffer[pos + 1].toInt() and 0xFF) shl 16) or
((inputBuffer[pos + 2].toInt() and 0xFF) shl 8) or
(inputBuffer[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > dataEnd) {
if (sampleCount < 5) {
@@ -435,7 +385,7 @@ class DoviConvertingTrackOutput(
ensureOutputCapacity(outputLen + 4 + nalLen)
writeInt32BE(outputBuf, outputLen, nalLen)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
@@ -486,7 +436,7 @@ class DoviConvertingTrackOutput(
while (true) {
val startNs = System.nanoTime()
val written = DoviBridge.convertRpuNalu(
payload = sampleBuf,
payload = inputBuffer,
payloadOffset = nalStart,
payloadLength = nalLen,
output = outputBuf,
@@ -630,7 +580,7 @@ class DoviConvertingTrackOutput(
}
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(inputBuffer, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun isDvProfile7Codec(codecs: String?): Boolean {
val normalized = codecs?.lowercase() ?: return false
@@ -662,7 +612,7 @@ class DoviConvertingTrackOutput(
private fun copyRawSample(dataLen: Int) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
System.arraycopy(inputBuffer, 0, outputBuf, 0, dataLen)
outputLen = dataLen
}
@@ -672,12 +622,6 @@ class DoviConvertingTrackOutput(
return (offset until end).joinToString(" ") { "%02X".format(data[it]) }
}
private fun ensureSampleCapacity(needed: Int) {
if (sampleBuf.size < needed) {
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
}
}
private fun ensureOutputCapacity(needed: Int) {
if (outputBuf.size < needed) {
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
@@ -9,6 +9,8 @@ import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.net.Uri
import android.os.Build
import android.os.Handler
@@ -68,6 +70,8 @@ import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerSurfaceHost
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong
import org.chromium.net.CronetEngine
@@ -390,13 +394,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (disposing || !isInitialized) return@post
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post
// Fires on every layout pass via OnGlobalLayoutListener; skip when the
// container is already at the front to avoid recursing the view tree
// and re-writing compositionOrder each time.
if (contentView.getChildAt(contentView.childCount - 1) === container) return@post
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
}
}
@@ -458,15 +456,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video (clips overflow for ZOOM crop mode)
surfaceContainer = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
clipChildren = true
}
// Create FrameLayout container for video (clips overflow for ZOOM crop mode).
surfaceContainer = PlayerSurfaceHost.createContainer(activity, clipChildren = true)
// AspectRatioFrameLayout drives FIT/ZOOM/FILL via Media3's resizeMode.
// Centered inside the container; in ZOOM mode it measures larger than
@@ -481,17 +472,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
// Create SurfaceView for video rendering (fills the ARFL)
surfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(surfaceCallback)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Create SurfaceView for video rendering (fills the ARFL).
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, surfaceCallback)
videoAspectContainer!!.addView(surfaceView)
surfaceContainer!!.addView(videoAspectContainer)
@@ -521,19 +503,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
surfaceContainer!!.addView(subtitleView)
Log.d(TAG, "SubtitleViews created and added to surfaceContainer")
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and configure z-order. compositionOrder maps directly to
// SurfaceView mSubLayer on API 36+: negative values are hole-punched behind
// the parent canvas, non-negative are composited above. Media3's
// CanvasSubtitleOutput renders SRT/VTT/SDH text on the parent canvas, so the
// video and libass surfaces must be negative for non-ASS subs to be visible.
// Stack (back → front): video (-2) → libass overlay (-1) → parent canvas
// (CanvasSubtitleOutput) → Flutter UI (+1). Pre-36 falls back to legacy buckets.
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
}
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
ensureFlutterOverlayOnTop()
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
@@ -862,6 +832,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
try {
dispose()
} catch (cleanupError: Exception) {
Log.e(TAG, "Failed to clean up partial initialization", cleanupError)
}
return false
}
}
@@ -972,78 +947,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun renderSubtitleCues(cues: List<Cue>) {
val textCues = cues.filter { it.bitmap == null }
val bitmapCues = cues.filter { it.bitmap != null }
val stacked = stackUnpositionedCues(textCues)
val outgoing = applySubtitlePosition(stacked)
val outgoing = SubtitleCueLayout.layout(textCues, subtitlePositionPercent, subtitleFontSize)
subtitleView?.setCues(outgoing)
bitmapSubtitleView?.setCues(bitmapCues)
}
// SRT carries no per-cue positioning, so SubripParser emits cues with
// lineType = TYPE_UNSET. SubtitlePainter then renders every such cue at the
// same default bottom-anchored position, causing visible overlap when more
// than one is active. Reassign line numbers from the bottom up so concurrent
// unpositioned cues stack instead. Workaround for
// https://github.com/androidx/media/issues/2237; can be removed once
// https://github.com/androidx/media/pull/3151 lands and we upgrade Media3.
private fun stackUnpositionedCues(cues: List<Cue>): List<Cue> {
if (cues.size < 2) return cues
val toStack = cues.indices.filter {
cues[it].lineType == Cue.TYPE_UNSET && cues[it].text != null
}
if (toStack.size < 2) return cues
val rebuilt = cues.toMutableList()
var nextRow = -1
// Reverse so the last cue in the group lands on row -1 (bottom).
for (idx in toStack.reversed()) {
val cue = cues[idx]
rebuilt[idx] = cue.buildUpon()
.setLine(nextRow.toFloat(), Cue.LINE_TYPE_NUMBER)
.setLineAnchor(Cue.ANCHOR_TYPE_END)
.build()
val rowsConsumed = (cue.text?.toString()?.count { it == '\n' } ?: 0) + 1
nextRow -= rowsConsumed
}
return rebuilt
}
private fun applySubtitlePosition(cues: List<Cue>): List<Cue> {
val clampedPosition = subtitlePositionPercent.coerceIn(0, 100)
if (clampedPosition == 100 || cues.isEmpty()) return cues
val baseLine = clampedPosition / 100f
val rowHeight = (subtitleFontSize / 720f * 1.2f).coerceAtLeast(0.01f)
var changed = false
val rebuilt = cues.map { cue ->
if (!usesDefaultVerticalPlacement(cue)) return@map cue
val rowOffset = if (cue.lineType == Cue.LINE_TYPE_NUMBER && cue.line < 0f) {
(-cue.line - 1f).coerceAtLeast(0f)
} else {
0f
}
val line = if (clampedPosition == 0) {
(rowOffset * rowHeight).coerceAtMost(1f)
} else {
(baseLine - rowOffset * rowHeight).coerceIn(0f, 1f)
}
val lineAnchor = if (clampedPosition == 0) Cue.ANCHOR_TYPE_START else Cue.ANCHOR_TYPE_END
changed = true
cue.buildUpon()
.setLine(line, Cue.LINE_TYPE_FRACTION)
.setLineAnchor(lineAnchor)
.build()
}
return if (changed) rebuilt else cues
}
private fun usesDefaultVerticalPlacement(cue: Cue): Boolean {
if (cue.text == null || cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET) return false
return cue.line == Cue.DIMEN_UNSET || (cue.lineType == Cue.LINE_TYPE_NUMBER && cue.line < 0f)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged: $isPlaying")
if (isPlaying) pendingPlayWhenReady = null
@@ -2233,28 +2141,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (shouldForceAppAudioDecoder(mimeType)) return false
hwAudioDecoderCache[mimeType]?.let { return it }
val result = try {
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
var found = false
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mimeType, ignoreCase = true)) {
val name = info.name
if (!name.startsWith("OMX.google.") &&
!name.startsWith("c2.android.") &&
!name.contains(".sw.") &&
!name.startsWith("c2.ffmpeg.")
) {
Log.d(TAG, "Found hardware audio decoder for $mimeType: $name")
found = true
break
}
}
}
if (found) break
val decoder = MediaCodecQuery.findHardwareDecoder(mimeType)
if (decoder == null) {
Log.d(TAG, "No hardware audio decoder for $mimeType — app decoder may handle it")
false
} else {
Log.d(TAG, "Found hardware audio decoder for $mimeType: ${decoder.name}")
true
}
if (!found) Log.d(TAG, "No hardware audio decoder for $mimeType — app decoder may handle it")
found
} catch (e: Exception) {
Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}")
false
@@ -2266,33 +2160,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean {
tunneledPlaybackCache[mimeType]?.let { return it }
val result = try {
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
var supported = false
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mimeType, ignoreCase = true)) {
val name = info.name
if (name.startsWith("OMX.google.") ||
name.startsWith("c2.android.") ||
name.contains(".sw.") ||
name.startsWith("c2.ffmpeg.")
) {
continue // Skip software decoders
}
val caps = info.getCapabilitiesForType(type)
if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) {
Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType")
supported = true
break
} else {
Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType")
}
}
}
if (supported) break
val decoder = MediaCodecQuery.findHardwareDecoder(mimeType) { info, type ->
info.getCapabilitiesForType(type).isFeatureSupported(
MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback
)
}
if (decoder != null) {
Log.d(TAG, "Hardware video decoder ${decoder.name} supports tunneled playback for $mimeType")
true
} else {
Log.d(TAG, "No hardware video decoder supports tunneled playback for $mimeType")
false
}
supported
} catch (e: Exception) {
Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}")
false
@@ -3691,25 +3570,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (videoFormat == null) return null
val mimeType = videoFormat.sampleMimeType ?: return null
// Check available decoders for this mime type
try {
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS)
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mimeType, ignoreCase = true)) {
// Return the first hardware decoder found, or software if none
val name = info.name
if (!name.startsWith("OMX.google.") && !name.contains(".sw.")) {
return name // Hardware decoder
}
}
}
}
// Fallback - assume software if no HW decoder found
return "Software"
return try {
MediaCodecQuery.findHardwareDecoder(mimeType, MediaCodecList.ALL_CODECS)?.name ?: "Software"
} catch (e: Exception) {
return null
null
}
}
@@ -2,14 +2,12 @@ package com.edde746.plezy.exoplayer
import android.app.Activity
import android.app.ActivityManager
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.shared.MpvContentUriResolver
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -27,12 +25,10 @@ class ExoPlayerPlugin :
companion object {
private const val TAG = "ExoPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/exo_player"
private const val EVENT_CHANNEL = "com.plezy/exo_player/events"
}
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private val channels = PlayerChannelBinding(METHOD_CHANNEL, this, this, TAG)
private val mainHandler get() = channels.mainHandler
private var playerCore: ExoPlayerCore? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
@@ -46,7 +42,6 @@ class ExoPlayerPlugin :
private data class ObservedProperty(val id: Int, val format: String)
private val observedProperties = LinkedHashMap<String, ObservedProperty>()
private val mainHandler = Handler(Looper.getMainLooper())
private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0
@@ -64,19 +59,11 @@ class ExoPlayerPlugin :
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
channels.attach(binding)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
channels.detach()
}
// ActivityAware
@@ -119,13 +106,11 @@ class ExoPlayerPlugin :
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
channels.listen(events)
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
channels.cancel()
}
// MethodChannel.MethodCallHandler
@@ -224,28 +209,34 @@ class ExoPlayerPlugin :
}
try {
playerCore = ExoPlayerCore(currentActivity).apply {
val core = ExoPlayerCore(currentActivity).apply {
delegate = this@ExoPlayerPlugin
this.debugLoggingEnabled = this@ExoPlayerPlugin.debugLoggingEnabled
}
val success = playerCore?.initialize(
playerCore = core
val success = core.initialize(
bufferSizeBytes = bufferSizeBytes,
tunnelingEnabled = tunnelingEnabled,
audioPassthroughEnabled = audioPassthroughEnabled
) ?: false
if (success && playerCore?.setDebugDvConversionMode(dvConversionMode) != true) {
)
if (!success) {
if (playerCore === core) playerCore = null
result.success(false)
return@runOnUiThread
}
if (core.setDebugDvConversionMode(dvConversionMode) != true) {
Log.w(TAG, "Invalid DV conversion mode during initialize: $dvConversionMode")
}
// Seed from this device's persisted calibration, falling back to the Dart perf-tier proxy.
playerCore?.seedAssVideoLatencyFrames(assVideoLatencyFrames)
core.seedAssVideoLatencyFrames(assVideoLatencyFrames)
core.setVisible(false)
// Start hidden
playerCore?.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
Log.d(TAG, "Initialized: true")
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
playerCore?.dispose()
playerCore = null
result.error("INIT_FAILED", e.message, null)
}
}
@@ -281,6 +272,11 @@ class ExoPlayerPlugin :
result.error("INVALID_ARGS", "Missing 'uri'", null)
return
}
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
return
}
val externalSubtitleSnapshot = externalSubtitles?.map { it.toMap() }
currentExternalSubtitles = externalSubtitleSnapshot
@@ -290,32 +286,54 @@ class ExoPlayerPlugin :
// potential ExoPlayer→MPV fallback.
if (usingMpvFallback) {
pendingMpvProperties.clear()
val generation = sessionGeneration
MpvContentUriResolver.resolve(uri, currentActivity.contentResolver, mainHandler) { source ->
if (generation != sessionGeneration || activity !== currentActivity || !usingMpvFallback) {
source.closeIfUnused()
result.success(null)
return@resolve
}
loadMpvMedia(
source.value,
headers,
startPositionMs,
hasStartPosition,
autoPlay,
externalSubtitleSnapshot
)
result.success(null)
}
return
}
activity?.runOnUiThread {
if (usingMpvFallback) {
// MPV: Build loadfile command with options
val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>()
options.add(if (hasStartPosition && startPositionMs > 0L) "start=$startSeconds" else "start=none")
if (!autoPlay) options.add("pause=yes")
options.add("sid=no")
options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitleSnapshot)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) { success ->
if (success && autoPlay) {
mpvCore?.setProperty("pause", "no")
}
}
} else {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitleSnapshot)
}
currentActivity.runOnUiThread {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitleSnapshot)
result.success(null)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
}
private fun loadMpvMedia(
uri: String,
headers: Map<String, String>?,
startPositionMs: Long,
hasStartPosition: Boolean,
autoPlay: Boolean,
externalSubtitles: List<Map<String, Any?>>?
) {
val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>()
options.add(if (hasStartPosition && startPositionMs > 0L) "start=$startSeconds" else "start=none")
if (!autoPlay) options.add("pause=yes")
options.add("sid=no")
options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
if (success && autoPlay) {
mpvCore?.setProperty("pause", "no")
}
}
}
private fun handlePlay(result: MethodChannel.Result) {
@@ -730,8 +748,15 @@ class ExoPlayerPlugin :
private fun handleGetStats(result: MethodChannel.Result) {
if (usingMpvFallback) {
Thread {
val stats = getMpvStats()
activity?.runOnUiThread { result.success(stats) }
val stats = try {
getMpvStats()
} catch (error: Throwable) {
Log.w(TAG, "Failed to collect mpv fallback stats", error)
mapOf("playerType" to "mpv")
}
// Platform-channel replies must return to Android's platform thread.
// Do not depend on an Activity: it may detach while this work runs.
mainHandler.post { result.success(stats) }
}.start()
} else {
activity?.runOnUiThread {
@@ -764,47 +789,15 @@ class ExoPlayerPlugin :
override fun onPropertyChange(name: String, value: Any?) {
val propId = observedProperties[name]?.id ?: return
mainHandler.post { eventSink?.success(listOf(propId, value)) }
}
private fun eventPayload(name: String, data: Map<String, Any>? = null): Map<String, Any> {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
return event
channels.emitProperty(propId, value)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = eventPayload(name, data)
mainHandler.post { eventSink?.success(event) }
channels.emitEvent(name, data)
}
private fun notifyBackendSwitched() {
mainHandler.post { eventSink?.success(eventPayload("backend-switched")) }
}
/**
* Opens a content:// URI via ContentResolver and returns the raw FD number,
* or null if the URI is not a content:// scheme or opening fails.
* The returned FD is detached so MPV can own and close it via fdclose://.
*/
private fun openContentFd(
uriString: String,
resolver: ContentResolver? = activity?.contentResolver
): Int? {
if (!uriString.startsWith("content://")) return null
return try {
val uri = Uri.parse(uriString)
val pfd = resolver?.openFileDescriptor(uri, "r") ?: return null
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
fd
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
null
}
channels.emitEvent("backend-switched")
}
private fun appendExternalSubtitleOptions(
@@ -848,79 +841,71 @@ class ExoPlayerPlugin :
headers: Map<String, String>?,
positionMs: Long,
externalSubtitles: List<Map<String, Any?>>?,
playWhenReady: Boolean
playWhenReady: Boolean,
generation: Int
) {
// Snapshot Dart-registered state on main thread before clearing
// Snapshot Dart-registered state on main thread before clearing.
val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
val observedProps = observedProperties.toList()
// Compute content FD on main thread (needs contentResolver)
val mpvUri = openContentFd(uri, act.contentResolver)
?.let { "fdclose://$it" } ?: uri
// Buffer size for closure
val bufferSize = configuredBufferSizeBytes
if (mpvCore !== core) {
core.dispose()
fallbackInProgress = false
return
}
// Configure basic MPV properties for Plex playback
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
// Apply pending MPV properties from Dart
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
}
// Re-observe exactly what Dart registered via observeProperty, so the
// event stream keeps flowing for every property the Dart side consumes.
for ((propName, observed) in observedProps) {
core.observeProperty(propName, observed.format)
}
// Show the MPV surface (internally posts to UI)
core.setVisible(true)
// Load media at the same position
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none")
if (!playWhenReady) options.add("pause=yes")
options.add("sid=no")
options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
notifyBackendSwitched()
core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
Thread {
val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
core.setProperty("tone-mapping", "reinhard")
core.setProperty("tone-mapping-param", "0.7")
core.setProperty("tone-mapping-mode", "luma")
MpvContentUriResolver.resolve(uri, act.contentResolver, mainHandler) { source ->
if (generation != sessionGeneration || mpvCore !== core || activity !== act || !usingMpvFallback) {
source.closeIfUnused()
core.dispose()
return@resolve
}
}.start()
// Request audio focus
core.requestAudioFocus()
// Configure basic MPV properties for Plex playback.
core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack")
Log.i(TAG, "Successfully switched to MPV fallback")
if (bufferSize != null && bufferSize > 0) {
core.setProperty("demuxer-max-bytes", bufferSize.toString())
}
for ((propName, propValue) in pendingProps) {
core.setProperty(propName, propValue)
}
// Re-observe exactly what Dart registered via observeProperty, so the
// event stream keeps flowing for every property the Dart side consumes.
for ((propName, observed) in observedProps) {
core.observeProperty(propName, observed.format)
}
core.setVisible(true)
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none")
if (!playWhenReady) options.add("pause=yes")
options.add("sid=no")
options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",")
notifyBackendSwitched()
core.command(arrayOf("loadfile", source.value, "replace", "-1", optionsStr))
// On GPUs without compute shaders, MPV can't do dynamic peak detection
// and spline tone-mapping produces dim/washed-out results with extreme
// static HDR peak metadata. Use reinhard which handles this better.
Thread {
val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") {
Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard")
core.setProperty("tone-mapping", "reinhard")
core.setProperty("tone-mapping-param", "0.7")
core.setProperty("tone-mapping-mode", "luma")
}
}.start()
core.requestAudioFocus()
Log.i(TAG, "Successfully switched to MPV fallback")
}
}
override fun onFormatUnsupported(
@@ -962,7 +947,7 @@ class ExoPlayerPlugin :
val generation = sessionGeneration
Handler(Looper.getMainLooper()).post {
mainHandler.post {
if (generation != sessionGeneration) {
fallbackInProgress = false
return@post
@@ -1002,7 +987,7 @@ class ExoPlayerPlugin :
usingMpvFallback = true
fallbackInProgress = false
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady)
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady, generation)
}
} catch (e: Exception) {
fallbackInProgress = false
@@ -0,0 +1,77 @@
package com.edde746.plezy.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi
@OptIn(UnstableApi::class)
internal object SubtitleCueLayout {
fun layout(cues: List<Cue>, positionPercent: Int, fontSize: Float): List<Cue> = applyPosition(stackUnpositioned(cues), positionPercent, fontSize)
// SRT carries no per-cue positioning, so SubripParser emits cues with
// lineType = TYPE_UNSET. SubtitlePainter then renders every such cue at the
// same default bottom-anchored position, causing visible overlap when more
// than one is active. Reassign line numbers from the bottom up so concurrent
// unpositioned cues stack instead. Workaround for
// https://github.com/androidx/media/issues/2237; can be removed once
// https://github.com/androidx/media/pull/3151 lands and we upgrade Media3.
private fun stackUnpositioned(cues: List<Cue>): List<Cue> {
if (cues.size < 2) return cues
val toStack = cues.indices.filter {
cues[it].lineType == Cue.TYPE_UNSET && cues[it].text != null
}
if (toStack.size < 2) return cues
val rebuilt = cues.toMutableList()
var nextRow = -1
// Reverse so the last cue in the group lands on row -1 (bottom).
for (idx in toStack.reversed()) {
val cue = cues[idx]
rebuilt[idx] = cue.buildUpon()
.setLine(nextRow.toFloat(), Cue.LINE_TYPE_NUMBER)
.setLineAnchor(Cue.ANCHOR_TYPE_END)
.build()
val rowsConsumed = (cue.text?.toString()?.count { it == '\n' } ?: 0) + 1
nextRow -= rowsConsumed
}
return rebuilt
}
private fun applyPosition(cues: List<Cue>, positionPercent: Int, fontSize: Float): List<Cue> {
val clampedPosition = positionPercent.coerceIn(0, 100)
if (clampedPosition == 100 || cues.isEmpty()) return cues
val baseLine = clampedPosition / 100f
val rowHeight = (fontSize / 720f * 1.2f).coerceAtLeast(0.01f)
var changed = false
val rebuilt = cues.map { cue ->
if (!usesDefaultVerticalPlacement(cue)) return@map cue
val rowOffset = if (cue.lineType == Cue.LINE_TYPE_NUMBER && cue.line < 0f) {
(-cue.line - 1f).coerceAtLeast(0f)
} else {
0f
}
val line = if (clampedPosition == 0) {
(rowOffset * rowHeight).coerceAtMost(1f)
} else {
(baseLine - rowOffset * rowHeight).coerceIn(0f, 1f)
}
val lineAnchor = if (clampedPosition == 0) Cue.ANCHOR_TYPE_START else Cue.ANCHOR_TYPE_END
changed = true
cue.buildUpon()
.setLine(line, Cue.LINE_TYPE_FRACTION)
.setLineAnchor(lineAnchor)
.build()
}
return if (changed) rebuilt else cues
}
private fun usesDefaultVerticalPlacement(cue: Cue): Boolean {
if (cue.text == null || cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET) return false
return cue.line == Cue.DIMEN_UNSET || (cue.lineType == Cue.LINE_TYPE_NUMBER && cue.line < 0f)
}
}
@@ -1,9 +1,6 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.util.zip.DataFormatException
import java.util.zip.Inflater
@@ -16,8 +13,8 @@ import java.util.zip.Inflater
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class ZlibInflatingTrackOutput(
private val delegate: TrackOutput
) : TrackOutput {
delegate: TrackOutput
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE, INFLATE_CHUNK) {
companion object {
private const val TAG = "ZlibTrackOutput"
@@ -28,94 +25,30 @@ class ZlibInflatingTrackOutput(
var active = false
private val inflater = Inflater()
// Reusable buffers — grown as needed, never shrunk
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var compressedLen = 0
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var readBuf = ByteArray(INFLATE_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
override fun format(format: Format) = delegate.format(format)
override val transformEnabled: Boolean
get() = active
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
override val transformedBuffer: ByteArray
get() = inflateBuf
buffering = true
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
return bytesRead
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
if (!active) {
delegate.sampleData(data, length, sampleDataPart)
return
}
buffering = true
ensureCompressedCapacity(compressedLen + length)
data.readBytes(compressedBuf, compressedLen, length)
compressedLen += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
if (!active || !buffering) {
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
return
}
buffering = false
val srcLen = compressedLen
compressedLen = 0
val inflatedLen = try {
inflater.reset()
inflater.setInput(compressedBuf, 0, srcLen)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
// Fall back to raw compressed data
ensureInflateCapacity(srcLen)
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
srcLen
}
outputParsable.reset(inflateBuf, inflatedLen)
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
}
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
ensureCompressedCapacity(compressedLen + length)
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
compressedLen += length
}
private fun ensureCompressedCapacity(needed: Int) {
if (compressedBuf.size < needed) {
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
override fun transformSample(inputLength: Int, flags: Int): Int = try {
inflater.reset()
inflater.setInput(inputBuffer, 0, inputLength)
var written = 0
while (!inflater.finished()) {
if (written == inflateBuf.size) growInflateBuf()
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
if (count == 0 && !inflater.finished()) break
written += count
}
written
} catch (e: DataFormatException) {
Log.e(TAG, "Zlib inflate failed (${inputLength}B), passing raw", e)
ensureInflateCapacity(inputLength)
System.arraycopy(inputBuffer, 0, inflateBuf, 0, inputLength)
inputLength
}
private fun ensureInflateCapacity(needed: Int) {
@@ -2,7 +2,6 @@ package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader
@@ -16,9 +15,9 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost
import dev.jdtech.mpv.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
@@ -124,14 +123,7 @@ class MpvPlayerCore(
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (disposing || !isInitialized) return@post
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post
if (contentView.getChildAt(contentView.childCount - 1) == container) {
flutterOverlayApplied = true
return@post
}
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
}
}
@@ -231,42 +223,12 @@ class MpvPlayerCore(
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
}
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Add SurfaceView to container
surfaceContainer = PlayerSurfaceHost.createContainer(activity)
surfaceView = PlayerSurfaceHost.createVideoSurface(activity, this@MpvPlayerCore)
surfaceContainer!!.addView(surfaceView)
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface.
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
val contentView = PlayerSurfaceHost.attachToContent(activity, surfaceContainer!!)
flutterOverlayApplied = PlayerSurfaceHost.ensureFlutterOverlayOnTop(contentView, surfaceContainer)
ensureFlutterOverlayOnTop()
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
ensureFlutterOverlayOnTop()
@@ -3,10 +3,9 @@ package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import com.edde746.plezy.shared.PlayerChannelBinding
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -36,9 +35,7 @@ open class MpvPlayerPlugin(
private val tag = if (audioOnly) "MpvAudioPlayerPlugin" else "MpvPlayerPlugin"
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
private val channels = PlayerChannelBinding(channelBase, this, this, tag)
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
@@ -46,12 +43,8 @@ open class MpvPlayerPlugin(
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
private val mainHandler = Handler(Looper.getMainLooper())
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
private fun runOnMain(block: () -> Unit) = channels.runOnMain(block)
// Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead
@@ -65,26 +58,17 @@ open class MpvPlayerPlugin(
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(this)
Log.d(tag, "Attached to engine")
channels.attach(binding)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
channels.detach()
if (audioOnly) {
// The audio core is not activity-bound; engine detach is its terminal
// native lifecycle event (mirrors the video core's activity detach).
disposeCoreForTeardown()
}
applicationContext = null
Log.d(tag, "Detached from engine")
}
private fun disposeCoreForTeardown() {
@@ -130,13 +114,11 @@ open class MpvPlayerPlugin(
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(tag, "Event stream connected")
channels.listen(events)
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(tag, "Event stream disconnected")
channels.cancel()
}
// MethodChannel.MethodCallHandler
@@ -496,16 +478,11 @@ open class MpvPlayerPlugin(
override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return
eventSink?.success(listOf(propId, value))
channels.emitProperty(propId, value)
}
override fun onEvent(name: String, data: Map<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
channels.emitEvent(name, data)
}
}
@@ -0,0 +1,45 @@
package com.edde746.plezy.shared
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import java.util.Locale
/** Canonical decoder lookup and hardware classification for native playback. */
internal object MediaCodecQuery {
fun findHardwareDecoder(
mimeType: String,
codecKind: Int = MediaCodecList.REGULAR_CODECS,
predicate: (MediaCodecInfo, String) -> Boolean = { _, _ -> true }
): MediaCodecInfo? {
for (info in MediaCodecList(codecKind).codecInfos) {
if (info.isEncoder || !isHardwareAccelerated(info)) continue
for (type in info.supportedTypes) {
if (type.equals(mimeType, ignoreCase = true) && predicate(info, type)) {
return info
}
}
}
return null
}
fun isHardwareAccelerated(info: MediaCodecInfo): Boolean {
// API 29 added the manufacturer-provided classification. Older releases
// expose only component names, so retain the legacy software-name fallback.
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
!isSoftwareCodecName(info.name)
}
}
internal fun isSoftwareCodecName(name: String): Boolean {
val normalized = name.lowercase(Locale.ROOT)
return normalized.startsWith("omx.google.") ||
normalized.startsWith("omx.ffmpeg.") ||
normalized.startsWith("c2.android.") ||
normalized.startsWith("c2.google.") ||
normalized.startsWith("c2.ffmpeg.") ||
normalized.contains(".sw.")
}
}
@@ -0,0 +1,69 @@
package com.edde746.plezy.shared
import android.content.ContentResolver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
internal data class ResolvedMpvUri(
val value: String,
private val ownedFd: Int? = null
) {
fun closeIfUnused() {
val fd = ownedFd ?: return
try {
ParcelFileDescriptor.adoptFd(fd).close()
} catch (error: Exception) {
Log.w("MpvContentUriResolver", "Failed to close unused content FD $fd", error)
}
}
}
internal object MpvContentUriResolver {
private const val TAG = "MpvContentUriResolver"
fun resolve(
uriString: String,
resolver: ContentResolver,
mainHandler: Handler,
onResolved: (ResolvedMpvUri) -> Unit
) {
resolve(uriString, mainHandler, opener = {
val descriptor = resolver.openFileDescriptor(Uri.parse(uriString), "r") ?: return@resolve null
descriptor.detachFd()
}, onResolved = onResolved)
}
internal fun resolve(
uriString: String,
mainHandler: Handler,
opener: () -> Int?,
onResolved: (ResolvedMpvUri) -> Unit
) {
if (!uriString.startsWith("content://")) {
onResolved(ResolvedMpvUri(uriString))
return
}
Thread {
check(Looper.myLooper() != Looper.getMainLooper()) {
"Content file descriptors must not be opened on the main thread"
}
val fd = try {
opener()
} catch (error: Exception) {
Log.e(TAG, "Failed to open content FD for $uriString", error)
null
}
val resolved = if (fd == null) {
ResolvedMpvUri(uriString)
} else {
Log.d(TAG, "Opened content FD $fd for $uriString")
ResolvedMpvUri("fdclose://$fd", fd)
}
mainHandler.post { onResolved(resolved) }
}.start()
}
}
@@ -0,0 +1,70 @@
package com.edde746.plezy.shared
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/** Owns the identical MethodChannel/EventChannel lifecycle for native players. */
internal class PlayerChannelBinding(
private val channelBase: String,
private val methodCallHandler: MethodChannel.MethodCallHandler,
private val streamHandler: EventChannel.StreamHandler,
private val logTag: String
) {
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private var eventSink: EventChannel.EventSink? = null
val mainHandler = Handler(Looper.getMainLooper())
fun attach(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(methodCallHandler)
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(streamHandler)
Log.d(logTag, "Attached to engine")
}
fun detach() {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
eventSink = null
Log.d(logTag, "Detached from engine")
}
fun listen(events: EventChannel.EventSink?) {
eventSink = events
Log.d(logTag, "Event stream connected")
}
fun cancel() {
eventSink = null
Log.d(logTag, "Event stream disconnected")
}
fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
fun emitProperty(id: Int, value: Any?) {
runOnMain { eventSink?.success(listOf(id, value)) }
}
fun emitEvent(name: String, data: Map<String, Any>? = null) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
runOnMain { eventSink?.success(event) }
}
}
@@ -0,0 +1,47 @@
package com.edde746.plezy.shared
import android.app.Activity
import android.graphics.Color
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
/** Shared Android view scaffold beneath the ExoPlayer and mpv cores. */
internal object PlayerSurfaceHost {
fun createContainer(activity: Activity, clipChildren: Boolean = false): FrameLayout = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
this.clipChildren = clipChildren
}
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(callback)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
fun attachToContent(activity: Activity, container: FrameLayout): ViewGroup {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(container, 0)
ensureFlutterOverlayOnTop(contentView, container)
return contentView
}
fun ensureFlutterOverlayOnTop(contentView: ViewGroup, surfaceContainer: ViewGroup?): Boolean {
val flutterContainer = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return false
if (contentView.getChildAt(contentView.childCount - 1) !== flutterContainer) {
FlutterOverlayHelper.configureFlutterZOrder(contentView, flutterContainer, compositionOrder = 1)
}
return true
}
}
@@ -0,0 +1,66 @@
package com.edde746.plezy
import android.app.Activity
import android.content.Intent
import io.flutter.plugin.common.MethodChannel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ExternalPlayerChannelTest {
@Test
fun mxPlayerResultPreservesPositionDurationAndCompletion() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
val channel = ExternalPlayerChannel(activity)
val data = Intent("com.mxtech.intent.result.VIEW")
.putExtra("position", 123)
.putExtra("duration", "456")
.putExtra("end_by", "playback_completion")
val result = channel.buildResult(Activity.RESULT_OK, data)
assertEquals(123L, result["positionMs"])
assertEquals(456L, result["durationMs"])
assertEquals(true, result["playbackCompleted"])
assertEquals(false, result["playbackError"])
}
@Test
fun activityDestroyCompletesPendingChannelCall() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
val channel = ExternalPlayerChannel(activity)
val result = RecordingResult()
channel.javaClass.getDeclaredField("pendingResult").apply {
isAccessible = true
set(channel, result)
}
channel.dispose()
assertTrue(result.completed)
assertEquals("ACTIVITY_DESTROYED", result.errorCode)
}
private class RecordingResult : MethodChannel.Result {
var completed = false
var errorCode: String? = null
override fun success(result: Any?) {
completed = true
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed = true
this.errorCode = errorCode
}
override fun notImplemented() {
completed = true
}
}
}
@@ -0,0 +1,100 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.io.EOFException
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class BufferedTransformingTrackOutputTest {
@Test
fun activeTransformBuffersChunksAndEmitsOneNormalizedSample() {
val delegate = RecordingTrackOutput()
val output = IncrementingTrackOutput(delegate)
output.sampleData(ParsableByteArray(byteArrayOf(1, 2)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleData(ParsableByteArray(byteArrayOf(3)), 1, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(42, C.BUFFER_FLAG_KEY_FRAME, 3, 7, null)
assertArrayEquals(byteArrayOf(2, 3, 4), delegate.bytes.toByteArray())
assertEquals(42, delegate.timeUs)
assertEquals(3, delegate.sampleSize)
assertEquals(0, delegate.sampleOffset)
}
@Test
fun activeTransformHonorsDataReaderEndOfInputContract() {
val output = IncrementingTrackOutput(RecordingTrackOutput())
val exhausted = DataReader { _, _, _ -> C.RESULT_END_OF_INPUT }
assertThrows(EOFException::class.java) {
output.sampleData(exhausted, 1, false, TrackOutput.SAMPLE_DATA_PART_MAIN)
}
assertEquals(
C.RESULT_END_OF_INPUT,
output.sampleData(exhausted, 1, true, TrackOutput.SAMPLE_DATA_PART_MAIN)
)
}
private class IncrementingTrackOutput(delegate: TrackOutput) : BufferedTransformingTrackOutput(delegate, initialBufferSize = 2) {
private var transformed = ByteArray(2)
override val transformEnabled = true
override val transformedBuffer: ByteArray
get() = transformed
override fun transformSample(inputLength: Int, flags: Int): Int {
if (transformed.size < inputLength) transformed = ByteArray(inputLength)
for (index in 0 until inputLength) {
transformed[index] = (inputBuffer[index] + 1).toByte()
}
return inputLength
}
}
private class RecordingTrackOutput : TrackOutput {
val bytes = ByteArrayOutputStream()
var timeUs = C.TIME_UNSET
var sampleSize = -1
var sampleOffset = -1
override fun format(format: Format) = Unit
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
val buffer = ByteArray(length)
val read = input.read(buffer, 0, length)
if (read > 0) bytes.write(buffer, 0, read)
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
val buffer = ByteArray(length)
data.readBytes(buffer, 0, length)
bytes.write(buffer)
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
this.timeUs = timeUs
sampleSize = size
sampleOffset = offset
}
}
}
@@ -0,0 +1,51 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.os.Looper
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class ExoPlayerInitializationCleanupTest {
@Test
fun disposeRemovesPartiallyAttachedViewAndLayoutListener() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val content = activity.findViewById<ViewGroup>(android.R.id.content)
val container = FrameLayout(activity)
content.addView(container)
var layoutCallbacks = 0
val listener = ViewTreeObserver.OnGlobalLayoutListener { layoutCallbacks++ }
content.viewTreeObserver.addOnGlobalLayoutListener(listener)
val core = ExoPlayerCore(activity)
core.setPrivateField("surfaceContainer", container)
core.setPrivateField("overlayLayoutListener", listener)
core.dispose()
content.viewTreeObserver.dispatchOnGlobalLayout()
shadowOf(Looper.getMainLooper()).idle()
assertEquals(0, layoutCallbacks)
assertNull(container.parent)
assertNull(core.getPrivateField("overlayLayoutListener"))
}
private fun Any.setPrivateField(name: String, value: Any?) {
javaClass.getDeclaredField(name).apply {
isAccessible = true
set(this@setPrivateField, value)
}
}
private fun Any.getPrivateField(name: String): Any? = javaClass.getDeclaredField(name).apply { isAccessible = true }.get(this)
}
@@ -0,0 +1,90 @@
package com.edde746.plezy.exoplayer
import android.os.Looper
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class ExoPlayerPluginTest {
@Test
fun fallbackGetStatsCompletesAfterActivityDetach() {
val plugin = ExoPlayerPlugin()
plugin.javaClass.getDeclaredField("usingMpvFallback").apply {
isAccessible = true
setBoolean(plugin, true)
}
val result = RecordingResult()
plugin.onMethodCall(MethodCall("getStats", null), result)
var completed = false
repeat(100) {
shadowOf(Looper.getMainLooper()).idle()
if (result.completed.await(10, TimeUnit.MILLISECONDS)) {
completed = true
return@repeat
}
}
assertTrue("fallback getStats never completed", completed)
assertEquals(mapOf("playerType" to "mpv"), result.successValue)
}
@Test
fun eventCallbacksKeepTheSharedPlayerEnvelope() {
val plugin = ExoPlayerPlugin()
val sink = RecordingEventSink()
plugin.onListen(null, sink)
plugin.onEvent("ready", mapOf("position" to 42))
assertEquals(
mapOf(
"type" to "event",
"name" to "ready",
"data" to mapOf("position" to 42)
),
sink.successValue
)
}
private class RecordingResult : MethodChannel.Result {
val completed = CountDownLatch(1)
var successValue: Any? = null
override fun success(result: Any?) {
successValue = result
completed.countDown()
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed.countDown()
}
override fun notImplemented() {
completed.countDown()
}
}
private class RecordingEventSink : EventChannel.EventSink {
var successValue: Any? = null
override fun success(event: Any?) {
successValue = event
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit
override fun endOfStream() = Unit
}
}
@@ -0,0 +1,49 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.text.Cue
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class SubtitleCueLayoutTest {
@Test
fun concurrentUnpositionedCuesStackFromBottom() {
val first = Cue.Builder().setText("first").build()
val second = Cue.Builder().setText("second").build()
val laidOut = SubtitleCueLayout.layout(listOf(first, second), positionPercent = 100, fontSize = 36f)
assertEquals(Cue.LINE_TYPE_NUMBER, laidOut[0].lineType)
assertEquals(-2f, laidOut[0].line)
assertEquals(Cue.ANCHOR_TYPE_END, laidOut[0].lineAnchor)
assertEquals(-1f, laidOut[1].line)
}
@Test
fun userPositionPreservesStackSpacing() {
val first = Cue.Builder().setText("first").build()
val second = Cue.Builder().setText("second").build()
val laidOut = SubtitleCueLayout.layout(listOf(first, second), positionPercent = 50, fontSize = 36f)
assertEquals(Cue.LINE_TYPE_FRACTION, laidOut[0].lineType)
assertEquals(0.44f, laidOut[0].line, 0.0001f)
assertEquals(0.5f, laidOut[1].line, 0.0001f)
assertEquals(Cue.ANCHOR_TYPE_END, laidOut[0].lineAnchor)
assertEquals(Cue.ANCHOR_TYPE_END, laidOut[1].lineAnchor)
}
@Test
fun explicitCuePositionIsNotOverridden() {
val positioned = Cue.Builder()
.setText("positioned")
.setLine(0.25f, Cue.LINE_TYPE_FRACTION)
.setLineAnchor(Cue.ANCHOR_TYPE_START)
.build()
val laidOut = SubtitleCueLayout.layout(listOf(positioned), positionPercent = 50, fontSize = 36f)
assertSame(positioned, laidOut.single())
}
}
@@ -9,6 +9,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -29,6 +30,35 @@ class MpvPlayerPluginTest {
assertNull(result.successValue)
}
@Test
fun disposeCompletesEveryPendingInitialization() {
val plugin = MpvPlayerPlugin()
val first = RecordingResult()
val second = RecordingResult()
@Suppress("UNCHECKED_CAST")
val pending = plugin.javaClass.getDeclaredField("pendingInitResults").apply {
isAccessible = true
}.get(plugin) as MutableList<MethodChannel.Result>
pending += first
pending += second
plugin.javaClass.getDeclaredField("isInitializing").apply {
isAccessible = true
setBoolean(plugin, true)
}
val dispose = RecordingResult()
plugin.onMethodCall(MethodCall("dispose", null), dispose)
assertEquals(false, first.successValue)
assertEquals(false, second.successValue)
assertNull(dispose.successValue)
assertTrue(first.completed)
assertTrue(second.completed)
assertTrue(dispose.completed)
assertEquals(0, pending.size)
}
@Test
fun setLogLevelReportsUnsupported() {
val result = RecordingResult()
@@ -98,12 +128,15 @@ class MpvPlayerPluginTest {
private class RecordingResult : MethodChannel.Result {
var successValue: Any? = null
var errorCode: String? = null
var completed: Boolean = false
override fun success(result: Any?) {
completed = true
successValue = result
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
completed = true
this.errorCode = errorCode
}
@@ -0,0 +1,34 @@
package com.edde746.plezy.shared
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaCodecQueryTest {
@Test
fun recognizesKnownPlatformAndFfmpegSoftwareCodecNames() {
listOf(
"OMX.google.h264.decoder",
"OMX.FFMPEG.VIDEO.DECODER",
"c2.android.avc.decoder",
"c2.google.av1.decoder",
"c2.ffmpeg.vp9.decoder",
"vendor.video.sw.decoder"
).forEach { name ->
assertTrue("expected software codec: $name", MediaCodecQuery.isSoftwareCodecName(name))
}
}
@Test
fun doesNotMisclassifyVendorHardwareCodecNames() {
listOf(
"OMX.qcom.video.decoder.avc",
"OMX.MTK.VIDEO.DECODER.HEVC",
"c2.qti.avc.decoder",
"c2.exynos.hevc.decoder"
).forEach { name ->
assertFalse("expected hardware codec: $name", MediaCodecQuery.isSoftwareCodecName(name))
}
}
}
@@ -0,0 +1,51 @@
package com.edde746.plezy.shared
import android.os.Handler
import android.os.Looper
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class MpvContentUriResolverTest {
@Test
fun contentDescriptorOpensOffMainAndCompletesOnMain() {
val mainLooper = Looper.getMainLooper()
val openedOnMain = AtomicBoolean(true)
val completedOnMain = AtomicBoolean(false)
val completed = CountDownLatch(1)
MpvContentUriResolver.resolve(
uriString = "content://downloads/video.mkv",
mainHandler = Handler(mainLooper),
opener = {
openedOnMain.set(Looper.myLooper() == mainLooper)
null
},
onResolved = {
completedOnMain.set(Looper.myLooper() == mainLooper)
completed.countDown()
}
)
var didComplete = false
repeat(100) {
shadowOf(mainLooper).idle()
if (completed.await(10, TimeUnit.MILLISECONDS)) {
didComplete = true
return@repeat
}
}
assertTrue("content URI resolution never completed", didComplete)
assertFalse("content descriptor opened on the main thread", openedOnMain.get())
assertTrue("resolved URI callback did not return on the main thread", completedOnMain.get())
}
}
@@ -0,0 +1,44 @@
package com.edde746.plezy.shared
import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.SurfaceHolder
import android.view.ViewGroup
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PlayerSurfaceHostTest {
@Test
fun scaffoldCreatesFullScreenBlackVideoLayerBehindFlutterContent() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
}
val container = PlayerSurfaceHost.createContainer(activity, clipChildren = true)
val surface = PlayerSurfaceHost.createVideoSurface(activity, callback)
container.addView(surface)
val content = PlayerSurfaceHost.attachToContent(activity, container)
assertSame(content, container.parent)
assertEquals(0, content.indexOfChild(container))
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, container.layoutParams.width)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, container.layoutParams.height)
assertEquals(Color.BLACK, (container.background as ColorDrawable).color)
assertTrue(container.clipChildren)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
}
}