refactor(android): harden and share player integration
This commit is contained in:
@@ -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 ->
|
||||
|
||||
+95
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-99
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
-88
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user