From b676ef6c563fabd37cdc7edf7055462709e9b308 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:40:46 +0200 Subject: [PATCH] refactor(android): harden and share player integration --- .../edde746/plezy/ExternalPlayerChannel.kt | 194 +++++++++++ .../kotlin/com/edde746/plezy/MainActivity.kt | 177 +--------- .../BufferedTransformingTrackOutput.kt | 95 ++++++ .../exoplayer/DoviConvertingTrackOutput.kt | 142 +++----- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 210 +++--------- .../plezy/exoplayer/ExoPlayerPlugin.kt | 303 +++++++++--------- .../plezy/exoplayer/SubtitleCueLayout.kt | 77 +++++ .../exoplayer/ZlibInflatingTrackOutput.kt | 109 ++----- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 50 +-- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 41 +-- .../edde746/plezy/shared/MediaCodecQuery.kt | 45 +++ .../plezy/shared/MpvContentUriResolver.kt | 69 ++++ .../plezy/shared/PlayerChannelBinding.kt | 70 ++++ .../edde746/plezy/shared/PlayerSurfaceHost.kt | 47 +++ .../plezy/ExternalPlayerChannelTest.kt | 66 ++++ .../BufferedTransformingTrackOutputTest.kt | 100 ++++++ .../ExoPlayerInitializationCleanupTest.kt | 51 +++ .../plezy/exoplayer/ExoPlayerPluginTest.kt | 90 ++++++ .../plezy/exoplayer/SubtitleCueLayoutTest.kt | 49 +++ .../edde746/plezy/mpv/MpvPlayerPluginTest.kt | 33 ++ .../plezy/shared/MediaCodecQueryTest.kt | 34 ++ .../plezy/shared/MpvContentUriResolverTest.kt | 51 +++ .../plezy/shared/PlayerSurfaceHostTest.kt | 44 +++ 23 files changed, 1380 insertions(+), 767 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayout.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/MpvContentUriResolver.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerSurfaceHost.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayoutTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/shared/MpvContentUriResolverTest.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/shared/PlayerSurfaceHostTest.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt b/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt new file mode 100644 index 00000000..efb509bf --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/ExternalPlayerChannel.kt @@ -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 { + 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): 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("filePath") + val packageNames = call.argument>("packages") + ?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) } + ?: emptyList() + val title = call.argument("title")?.trim()?.takeIf(String::isNotEmpty) + val startPositionMs = call.argument("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(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) } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 4a0f5e8c..f056fdf1 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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 { - 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): 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("filePath") - val packageNames = call.argument>("packages") - ?.mapNotNull { (it as? String)?.trim()?.takeIf { value -> value.isNotEmpty() } } - ?: emptyList() - val title = call.argument("title")?.trim()?.takeIf { it.isNotEmpty() } - val startPositionMs = call.argument("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(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 -> diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt new file mode 100644 index 00000000..4eb86361 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutput.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt index 8c01743c..05ddd360 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt @@ -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)) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index a8029116..517853fd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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(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(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) { 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): List { - 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): List { - 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 } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 0419c664..7f56bcba 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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() - 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() - 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?, + startPositionMs: Long, + hasStartPosition: Boolean, + autoPlay: Boolean, + externalSubtitles: List>? + ) { + val startSeconds = startPositionMs / 1000.0 + val options = mutableListOf() + 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? = null): Map { - val event = mutableMapOf( - "type" to "event", - "name" to name - ) - data?.let { event["data"] = it } - return event + channels.emitProperty(propId, value) } override fun onEvent(name: String, data: Map?) { - 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?, positionMs: Long, externalSubtitles: List>?, - 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() - 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() + 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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayout.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayout.kt new file mode 100644 index 00000000..564d6d2b --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayout.kt @@ -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, positionPercent: Int, fontSize: Float): List = 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): List { + 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, positionPercent: Int, fontSize: Float): List { + 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) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt index e6778edd..b662358d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 599ef29f..55fc218f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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(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(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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index f7941701..f7b07bb3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -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() 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?) { - val event = mutableMapOf( - "type" to "event", - "name" to name - ) - data?.let { event["data"] = it } - eventSink?.success(event) + channels.emitEvent(name, data) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt new file mode 100644 index 00000000..3e4bc03a --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt @@ -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.") + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/MpvContentUriResolver.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/MpvContentUriResolver.kt new file mode 100644 index 00000000..899f07c2 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/MpvContentUriResolver.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt new file mode 100644 index 00000000..9833955a --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerChannelBinding.kt @@ -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? = null) { + val event = mutableMapOf( + "type" to "event", + "name" to name + ) + data?.let { event["data"] = it } + runOnMain { eventSink?.success(event) } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerSurfaceHost.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerSurfaceHost.kt new file mode 100644 index 00000000..a6faec56 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerSurfaceHost.kt @@ -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(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 + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt new file mode 100644 index 00000000..3ee03bf5 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/ExternalPlayerChannelTest.kt @@ -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 + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt new file mode 100644 index 00000000..c215088a --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt @@ -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 + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt new file mode 100644 index 00000000..95b25e68 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerInitializationCleanupTest.kt @@ -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(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) +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt new file mode 100644 index 00000000..d47bb6e7 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt @@ -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 + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayoutTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayoutTest.kt new file mode 100644 index 00000000..97c5bd39 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/SubtitleCueLayoutTest.kt @@ -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()) + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt index 6473b8b1..d96b2916 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt @@ -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 + 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 } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt new file mode 100644 index 00000000..3296a361 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt @@ -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)) + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/shared/MpvContentUriResolverTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/shared/MpvContentUriResolverTest.kt new file mode 100644 index 00000000..ff01d4bb --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/shared/MpvContentUriResolverTest.kt @@ -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()) + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/shared/PlayerSurfaceHostTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/shared/PlayerSurfaceHostTest.kt new file mode 100644 index 00000000..3e85fa0a --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/shared/PlayerSurfaceHostTest.kt @@ -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) + } +}