Initial commit
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
// Media3 Engine plugin — Android-only ExoPlayer + FFmpeg audio decoder bridge.
|
||||
//
|
||||
// 依赖:
|
||||
// - Maven Central: media3-exoplayer / media3-datasource / media3-decoder 1.10.1
|
||||
// - 本地 AAR : media3-decoder-ffmpeg-1.10.1-arm64.aar
|
||||
// 由 fileTree 从主 app 的 android/app/libs/ 加载。AAR 由
|
||||
// scripts/build_media3_ffmpeg.sh 自编译产出,CHECKSUMS.txt
|
||||
// 记录 SHA-256。
|
||||
//
|
||||
// AAR 缺失时 preBuild 任务会 fail-fast 抛出带恢复指引的错误(详见 README)。
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("kotlin-android")
|
||||
}
|
||||
|
||||
group = "com.smplayer.media3_engine"
|
||||
version = "0.0.1"
|
||||
|
||||
android {
|
||||
namespace = "com.smplayer.media3_engine"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
ndk {
|
||||
// 与主 app 保持一致:仅 arm64-v8a。其它 ABI 在 FFmpeg AAR 中也不存在,
|
||||
// 即便 ABI filter 漏了也只是无声的浪费(不会 crash)。
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = false
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
// 自编译 FFmpeg audio decoder extension(arm64-v8a only,LGPL 配置)。AAR 由
|
||||
// scripts/build_media3_ffmpeg.sh 产出,放在 android/app/libs/。
|
||||
// 这里只声明 compileOnly:AGP 8 起禁止 library module 直接 implementation 本地
|
||||
// .aar("Direct local .aar file dependencies are not supported when building an
|
||||
// AAR"),实际把 AAR 打入最终 APK 的责任落到主 app/build.gradle.kts 的
|
||||
// `implementation(fileTree("libs"))`。本 plugin 编译期只需要 FfmpegAudioRenderer
|
||||
// 等符号的可见性。
|
||||
val media3Version = "1.10.1"
|
||||
val ffmpegAarDir = file("../../../android/app/libs")
|
||||
val ffmpegAarName = "media3-decoder-ffmpeg-$media3Version-arm64.aar"
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-datasource:$media3Version")
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
compileOnly(fileTree(mapOf("dir" to ffmpegAarDir, "include" to listOf(ffmpegAarName))))
|
||||
}
|
||||
|
||||
// AAR 缺失 / 篡改 fail-fast:在 preBuild 之前校验文件存在性 + SHA-256 与
|
||||
// `android/app/libs/CHECKSUMS.txt` 一致。CI/Clean checkout 友好。
|
||||
val verifyMedia3FfmpegAar by tasks.registering {
|
||||
group = "verification"
|
||||
description = "Verify media3 FFmpeg decoder AAR exists and matches CHECKSUMS.txt."
|
||||
val checksumsFile = ffmpegAarDir.resolve("CHECKSUMS.txt")
|
||||
val aarFile = ffmpegAarDir.resolve(ffmpegAarName)
|
||||
inputs.file(aarFile).withPropertyName("aar").withPathSensitivity(org.gradle.api.tasks.PathSensitivity.NONE)
|
||||
inputs.file(checksumsFile).withPropertyName("checksums").withPathSensitivity(org.gradle.api.tasks.PathSensitivity.NONE)
|
||||
doLast {
|
||||
if (!aarFile.isFile) {
|
||||
throw GradleException(
|
||||
"""
|
||||
|Media3 FFmpeg decoder AAR is missing:
|
||||
| expected: ${aarFile.absolutePath}
|
||||
|
|
||||
|This AAR is self-built (LGPL config, arm64-v8a,
|
||||
|codecs: dca/ac3/eac3/truehd/opus/vorbis/flac).
|
||||
|To produce it, run from the repo root:
|
||||
|
|
||||
| scripts/build_media3_ffmpeg.sh
|
||||
|
|
||||
|Prerequisites: NDK r26b, JDK 17, ~30-60min wall-clock. See
|
||||
|plugins/media3_engine/README.md for full instructions and Docker recipe.
|
||||
|
|
||||
|If you only build the desktop (Windows/macOS) target, this Android
|
||||
|module is not on the dependency path and you can ignore this error.
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
if (!checksumsFile.isFile) {
|
||||
throw GradleException(
|
||||
"Media3 AAR checksum manifest is missing: ${checksumsFile.absolutePath}\n" +
|
||||
"Restore CHECKSUMS.txt from git or rerun scripts/build_media3_ffmpeg.sh."
|
||||
)
|
||||
}
|
||||
// CHECKSUMS.txt 行格式:`<sha256> <filename>`,可有 # 开头注释 / 空行
|
||||
val expectedSha = checksumsFile.readLines()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") && it.endsWith(ffmpegAarName) }
|
||||
?.substringBefore(' ')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
if (expectedSha.isNullOrBlank() || !expectedSha.matches(Regex("^[0-9a-f]{64}$"))) {
|
||||
throw GradleException(
|
||||
"Media3 AAR checksum entry missing or malformed for $ffmpegAarName in " +
|
||||
"${checksumsFile.absolutePath}. Expected a line: '<sha256> $ffmpegAarName'."
|
||||
)
|
||||
}
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
aarFile.inputStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n <= 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
val actualSha = md.digest().joinToString("") { "%02x".format(it) }
|
||||
if (actualSha != expectedSha) {
|
||||
throw GradleException(
|
||||
"""
|
||||
|Media3 FFmpeg decoder AAR checksum mismatch — refusing to build.
|
||||
| file : ${aarFile.absolutePath}
|
||||
| expected : $expectedSha (from CHECKSUMS.txt)
|
||||
| actual : $actualSha
|
||||
|
|
||||
|The AAR has been replaced/corrupted since CHECKSUMS.txt was written.
|
||||
|If you intentionally rebuilt it, scripts/build_media3_ffmpeg.sh will
|
||||
|refresh CHECKSUMS.txt automatically; otherwise restore the file from git.
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
logger.lifecycle("[verifyMedia3FfmpegAar] OK — sha256 $actualSha")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(verifyMedia3FfmpegAar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.smplayer.media3_engine
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.FrameLayout
|
||||
import kotlin.math.abs
|
||||
|
||||
internal class Media3AspectRatioFrameLayout(context: Context) : FrameLayout(context) {
|
||||
private var videoAspectRatio: Float = 0f
|
||||
private var resizeMode: Int = RESIZE_FIT
|
||||
|
||||
fun setAspectRatio(aspectRatio: Float) {
|
||||
if (videoAspectRatio == aspectRatio) return
|
||||
videoAspectRatio = aspectRatio
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
fun setResizeMode(mode: Int) {
|
||||
if (resizeMode == mode) return
|
||||
resizeMode = mode
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
if (videoAspectRatio <= 0f || resizeMode == RESIZE_FILL) return
|
||||
|
||||
var width = measuredWidth
|
||||
var height = measuredHeight
|
||||
if (width <= 0 || height <= 0) return
|
||||
|
||||
val viewAspectRatio = width.toFloat() / height
|
||||
val aspectDeformation = videoAspectRatio / viewAspectRatio - 1f
|
||||
if (abs(aspectDeformation) <= MAX_ASPECT_RATIO_DEFORMATION_FRACTION) return
|
||||
|
||||
when (resizeMode) {
|
||||
RESIZE_ZOOM -> {
|
||||
if (aspectDeformation > 0f) {
|
||||
width = (height * videoAspectRatio).toInt()
|
||||
} else {
|
||||
height = (width / videoAspectRatio).toInt()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (aspectDeformation > 0f) {
|
||||
height = (width / videoAspectRatio).toInt()
|
||||
} else {
|
||||
width = (height * videoAspectRatio).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
)
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RESIZE_FIT = 0
|
||||
const val RESIZE_FILL = 3
|
||||
const val RESIZE_ZOOM = 4
|
||||
private const val MAX_ASPECT_RATIO_DEFORMATION_FRACTION = 0.01f
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.smplayer.media3_engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.platform.PlatformViewRegistry
|
||||
|
||||
|
||||
class Media3EnginePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler {
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private lateinit var applicationContext: Context
|
||||
private lateinit var binaryMessenger: BinaryMessenger
|
||||
private lateinit var platformViewRegistry: PlatformViewRegistry
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val players = mutableMapOf<Int, Media3PlayerInstance>()
|
||||
private var nextPlayerId = 1
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
binaryMessenger = binding.binaryMessenger
|
||||
platformViewRegistry = binding.platformViewRegistry
|
||||
|
||||
methodChannel = MethodChannel(binaryMessenger, CHANNEL_METHOD)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
|
||||
eventChannel = EventChannel(binaryMessenger, CHANNEL_EVENTS)
|
||||
eventChannel.setStreamHandler(this)
|
||||
|
||||
|
||||
platformViewRegistry.registerViewFactory(
|
||||
VIEW_TYPE,
|
||||
Media3PlatformViewFactory { id -> players[id] },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
|
||||
for (player in players.values) {
|
||||
try {
|
||||
player.dispose()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "dispose during detach failed", t)
|
||||
}
|
||||
}
|
||||
players.clear()
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
|
||||
|
||||
for (player in players.values) {
|
||||
try {
|
||||
player.replayKeyState()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "replayKeyState failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
|
||||
|
||||
internal fun emit(event: Map<String, Any?>) {
|
||||
val sink = eventSink ?: return
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
sink.success(event)
|
||||
} else {
|
||||
mainHandler.post { eventSink?.success(event) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
try {
|
||||
when (call.method) {
|
||||
"create" -> handleCreate(result)
|
||||
else -> dispatchToPlayer(call, result)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.e(TAG, "method ${call.method} failed", t)
|
||||
result.error("media3_error", t.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreate(result: MethodChannel.Result) {
|
||||
val id = nextPlayerId++
|
||||
val instance = Media3PlayerInstance(
|
||||
id = id,
|
||||
context = applicationContext,
|
||||
mainHandler = mainHandler,
|
||||
emit = ::emit,
|
||||
)
|
||||
players[id] = instance
|
||||
val info = instance.create()
|
||||
result.success(info)
|
||||
}
|
||||
|
||||
private fun dispatchToPlayer(call: MethodCall, result: MethodChannel.Result) {
|
||||
val playerId = (call.argument<Number>("playerId"))?.toInt()
|
||||
?: run {
|
||||
result.error("missing_player_id", "method ${call.method}", null)
|
||||
return
|
||||
}
|
||||
val player = players[playerId]
|
||||
?: run {
|
||||
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
when (call.method) {
|
||||
"open" -> player.open(call, result)
|
||||
"play" -> { player.play(); result.success(null) }
|
||||
"pause" -> { player.pause(); result.success(null) }
|
||||
"stop" -> {
|
||||
val gen = (call.argument<Number>("generation"))?.toInt() ?: -1
|
||||
player.stop(gen)
|
||||
result.success(null)
|
||||
}
|
||||
"seek" -> {
|
||||
val ms = (call.argument<Number>("positionMs"))?.toLong() ?: 0L
|
||||
player.seek(ms)
|
||||
result.success(null)
|
||||
}
|
||||
"setRate" -> {
|
||||
val rate = (call.argument<Number>("rate"))?.toFloat() ?: 1f
|
||||
player.setRate(rate)
|
||||
result.success(null)
|
||||
}
|
||||
"setVolume" -> {
|
||||
val vol = (call.argument<Number>("volume"))?.toFloat() ?: 100f
|
||||
player.setVolume(vol)
|
||||
result.success(null)
|
||||
}
|
||||
"setResizeMode" -> {
|
||||
val mode = call.argument<String>("mode") ?: "fit"
|
||||
player.setResizeMode(mapResizeMode(mode))
|
||||
result.success(null)
|
||||
}
|
||||
"replayKeyState" -> {
|
||||
player.replayKeyState()
|
||||
result.success(null)
|
||||
}
|
||||
"setAudioTrack" -> {
|
||||
val id = call.argument<String>("id") ?: "auto"
|
||||
player.setAudioTrack(id)
|
||||
result.success(null)
|
||||
}
|
||||
"setSubtitleTrack" -> {
|
||||
val id = call.argument<String>("id") ?: "no"
|
||||
player.setSubtitleTrack(id)
|
||||
result.success(null)
|
||||
}
|
||||
"setSubtitleStyle" -> {
|
||||
|
||||
result.success(null)
|
||||
}
|
||||
"getProperty" -> {
|
||||
val name = call.argument<String>("name") ?: ""
|
||||
result.success(player.getProperty(name))
|
||||
}
|
||||
"dispose" -> {
|
||||
player.dispose()
|
||||
players.remove(playerId)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapResizeMode(mode: String): Int {
|
||||
return when (mode) {
|
||||
"fill" -> Media3AspectRatioFrameLayout.RESIZE_FILL
|
||||
"zoom" -> Media3AspectRatioFrameLayout.RESIZE_ZOOM
|
||||
else -> Media3AspectRatioFrameLayout.RESIZE_FIT
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Media3EnginePlugin"
|
||||
private const val CHANNEL_METHOD = "smplayer/media3_engine"
|
||||
private const val CHANNEL_EVENTS = "smplayer/media3_engine/events"
|
||||
private const val VIEW_TYPE = "smplayer/media3_surface"
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.smplayer.media3_engine
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import io.flutter.plugin.platform.PlatformView
|
||||
import io.flutter.plugin.platform.PlatformViewFactory
|
||||
|
||||
|
||||
internal class Media3PlatformView(
|
||||
context: Context,
|
||||
private val playerId: Int,
|
||||
private val getPlayer: (Int) -> Media3PlayerInstance?,
|
||||
) : PlatformView, SurfaceHolder.Callback {
|
||||
|
||||
private val rootView: FrameLayout = FrameLayout(context).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
}
|
||||
private val aspectLayout: Media3AspectRatioFrameLayout = Media3AspectRatioFrameLayout(context)
|
||||
private val surfaceView: SurfaceView = SurfaceView(context)
|
||||
private val subtitleView: SubtitleView = SubtitleView(context)
|
||||
|
||||
|
||||
private val shutterView: View = View(context).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
}
|
||||
|
||||
init {
|
||||
aspectLayout.addView(
|
||||
surfaceView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
aspectLayout.addView(
|
||||
subtitleView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
rootView.addView(
|
||||
aspectLayout,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
Gravity.CENTER,
|
||||
),
|
||||
)
|
||||
rootView.addView(
|
||||
shutterView,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
surfaceView.holder.addCallback(this)
|
||||
getPlayer(playerId)?.attachView(aspectLayout)
|
||||
getPlayer(playerId)?.attachSubtitleView(subtitleView)
|
||||
getPlayer(playerId)?.attachShutter(shutterView)
|
||||
}
|
||||
|
||||
override fun getView(): View = rootView
|
||||
|
||||
override fun dispose() {
|
||||
surfaceView.holder.removeCallback(this)
|
||||
getPlayer(playerId)?.detachShutter(shutterView)
|
||||
getPlayer(playerId)?.detachSubtitleView(subtitleView)
|
||||
getPlayer(playerId)?.detachView(aspectLayout)
|
||||
getPlayer(playerId)?.detachSurface()
|
||||
}
|
||||
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
android.util.Log.i(TAG, "surfaceCreated playerId=$playerId valid=${holder.surface.isValid}")
|
||||
getPlayer(playerId)?.attachSurface(holder.surface)
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
android.util.Log.i(TAG, "surfaceChanged playerId=$playerId ${width}x$height format=$format")
|
||||
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
android.util.Log.i(TAG, "surfaceDestroyed playerId=$playerId")
|
||||
getPlayer(playerId)?.detachSurface()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Media3PlatformView"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal class Media3PlatformViewFactory(
|
||||
private val getPlayer: (Int) -> Media3PlayerInstance?,
|
||||
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
|
||||
|
||||
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
|
||||
val params = args as? Map<*, *>
|
||||
val playerId = (params?.get("playerId") as? Number)?.toInt() ?: -1
|
||||
return Media3PlatformView(context, playerId, getPlayer)
|
||||
}
|
||||
}
|
||||
+756
@@ -0,0 +1,756 @@
|
||||
package com.smplayer.media3_engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
internal class Media3PlayerInstance(
|
||||
val id: Int,
|
||||
private val context: Context,
|
||||
private val mainHandler: Handler,
|
||||
private val emit: (Map<String, Any?>) -> Unit,
|
||||
) {
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
|
||||
|
||||
private var surface: Surface? = null
|
||||
private var attachedView: Media3AspectRatioFrameLayout? = null
|
||||
private var listener: Player.Listener? = null
|
||||
private var positionRunnable: Runnable? = null
|
||||
private var pendingOpenListener: Player.Listener? = null
|
||||
private var pendingOpenResult: MethodChannel.Result? = null
|
||||
private var pendingOpenGen: Int = -1
|
||||
private var pendingSurfaceTimeout: Runnable? = null
|
||||
private var currentResizeMode: Int = Media3AspectRatioFrameLayout.RESIZE_FIT
|
||||
private var lastAspectRatio: Float = 0f
|
||||
|
||||
|
||||
private var attachedSubtitleView: SubtitleView? = null
|
||||
private var lastCueGroup: CueGroup? = null
|
||||
|
||||
|
||||
private var attachedShutterView: View? = null
|
||||
private var firstFrameRendered: Boolean = false
|
||||
|
||||
|
||||
private var generation: Int = 0
|
||||
private var disposed: Boolean = false
|
||||
private var ffmpegAvailable: Boolean = false
|
||||
private var ffmpegVersion: String? = null
|
||||
|
||||
@Volatile
|
||||
private var totalRxBytes: Long = 0L
|
||||
|
||||
|
||||
private var pendingPrepareGen: Int = -1
|
||||
|
||||
|
||||
private var lastDurationMs: Long = 0L
|
||||
private var lastVideoWidth: Int = 0
|
||||
private var lastVideoHeight: Int = 0
|
||||
private var lastTracksPayload: Map<String, Any?>? = null
|
||||
private var lastPlaying: Boolean = false
|
||||
private var lastPositionMs: Long = 0L
|
||||
|
||||
|
||||
fun create(): Map<String, Any?> {
|
||||
ffmpegAvailable = probeFfmpegAvailability()
|
||||
ffmpegVersion = probeFfmpegVersion()
|
||||
|
||||
val renderersFactory = DefaultRenderersFactory(context)
|
||||
.setExtensionRendererMode(
|
||||
DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
)
|
||||
|
||||
|
||||
.setEnableDecoderFallback(true)
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
15_000,
|
||||
50_000,
|
||||
500,
|
||||
2_000,
|
||||
)
|
||||
.build()
|
||||
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLoadControl(loadControl)
|
||||
.build()
|
||||
exo.playWhenReady = false
|
||||
|
||||
|
||||
exo.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
initializationDurationMs: Long,
|
||||
) {
|
||||
android.util.Log.i(TAG, "video decoder initialized: $decoderName")
|
||||
}
|
||||
})
|
||||
player = exo
|
||||
|
||||
|
||||
return mapOf(
|
||||
"playerId" to id,
|
||||
"ffmpegAvailable" to ffmpegAvailable,
|
||||
"ffmpegVersion" to (ffmpegVersion ?: ""),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun attachSurface(s: Surface) {
|
||||
surface = s
|
||||
player?.setVideoSurface(s)
|
||||
|
||||
|
||||
val pendingGen = pendingPrepareGen
|
||||
if (pendingGen >= 0 && pendingGen == generation) {
|
||||
pendingPrepareGen = -1
|
||||
clearPendingSurfaceTimeout()
|
||||
android.util.Log.i(TAG, "deferred prepare: surface arrived, executing prepare (gen=$pendingGen)")
|
||||
player?.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
fun attachView(view: Media3AspectRatioFrameLayout) {
|
||||
attachedView = view
|
||||
view.setResizeMode(currentResizeMode)
|
||||
view.setAspectRatio(lastAspectRatio)
|
||||
}
|
||||
|
||||
fun detachView(view: Media3AspectRatioFrameLayout) {
|
||||
if (attachedView === view) attachedView = null
|
||||
}
|
||||
|
||||
|
||||
fun detachSurface() {
|
||||
surface = null
|
||||
try {
|
||||
player?.clearVideoSurface()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "clearVideoSurface failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
fun attachSubtitleView(view: SubtitleView) {
|
||||
attachedSubtitleView = view
|
||||
lastCueGroup?.let { group ->
|
||||
val hasBitmap = group.cues.any { it.bitmap != null }
|
||||
if (hasBitmap) {
|
||||
view.setCues(group.cues)
|
||||
} else {
|
||||
view.setCues(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun detachSubtitleView(view: SubtitleView) {
|
||||
if (attachedSubtitleView === view) attachedSubtitleView = null
|
||||
}
|
||||
|
||||
|
||||
fun attachShutter(view: View) {
|
||||
attachedShutterView = view
|
||||
view.visibility = if (firstFrameRendered) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
|
||||
fun detachShutter(view: View) {
|
||||
if (attachedShutterView === view) attachedShutterView = null
|
||||
}
|
||||
|
||||
|
||||
private fun attachListener(exo: ExoPlayer, openGen: Int) {
|
||||
val lst = object : Player.Listener {
|
||||
private fun expired(): Boolean {
|
||||
if (disposed || openGen != generation) {
|
||||
try { exo.removeListener(this) } catch (_: Throwable) {}
|
||||
if (listener === this) listener = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (expired()) return
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
val dur = exo.duration
|
||||
if (dur != C.TIME_UNSET) {
|
||||
lastDurationMs = dur
|
||||
emitTyped("duration", mapOf("durationMs" to dur), openGen)
|
||||
}
|
||||
}
|
||||
Player.STATE_ENDED -> emitTyped("completed", emptyMap(), openGen)
|
||||
Player.STATE_BUFFERING, Player.STATE_IDLE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (expired()) return
|
||||
lastPlaying = isPlaying
|
||||
emitTyped("playing", mapOf("playing" to isPlaying), openGen)
|
||||
if (isPlaying) startPositionTimer(openGen) else stopPositionTimer()
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (expired()) return
|
||||
val w = videoSize.width
|
||||
val h = videoSize.height
|
||||
if (w <= 0 || h <= 0) return
|
||||
val pixelWidthHeightRatio = videoSize.pixelWidthHeightRatio
|
||||
lastAspectRatio = if (pixelWidthHeightRatio > 0f) {
|
||||
w * pixelWidthHeightRatio / h
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
attachedView?.setAspectRatio(lastAspectRatio)
|
||||
lastVideoWidth = w
|
||||
lastVideoHeight = h
|
||||
|
||||
emitTyped("videoSize", mapOf("width" to w, "height" to h), openGen)
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
if (expired()) return
|
||||
android.util.Log.i(TAG, "rendered first frame (gen=$openGen)")
|
||||
firstFrameRendered = true
|
||||
attachedShutterView?.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
if (expired()) return
|
||||
val payload = serializeTracks(tracks)
|
||||
lastTracksPayload = payload
|
||||
emitTyped("tracks", payload, openGen)
|
||||
}
|
||||
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
if (expired()) return
|
||||
lastCueGroup = cueGroup
|
||||
val cues = cueGroup.cues
|
||||
var bitmapDetected = false
|
||||
for (cue in cues) {
|
||||
if (cue.bitmap != null) {
|
||||
bitmapDetected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (bitmapDetected) {
|
||||
|
||||
|
||||
attachedSubtitleView?.setCues(cues)
|
||||
emitTyped("subtitle", mapOf("lines" to emptyList<String>()), openGen)
|
||||
return
|
||||
}
|
||||
|
||||
attachedSubtitleView?.setCues(emptyList())
|
||||
val lines = ArrayList<String>(cues.size)
|
||||
for (cue in cues) {
|
||||
val text = cue.text?.toString()
|
||||
if (!text.isNullOrEmpty()) lines.add(text)
|
||||
}
|
||||
emitTyped("subtitle", mapOf("lines" to lines), openGen)
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
if (expired()) return
|
||||
emitTyped(
|
||||
"error",
|
||||
mapOf(
|
||||
"code" to (error.errorCodeName ?: "playback_error"),
|
||||
"message" to (error.message ?: ""),
|
||||
),
|
||||
openGen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listener?.let { try { exo.removeListener(it) } catch (_: Throwable) {} }
|
||||
listener = lst
|
||||
exo.addListener(lst)
|
||||
}
|
||||
|
||||
|
||||
fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||
val exo = player ?: run {
|
||||
result.error("not_created", "player not created", null)
|
||||
return
|
||||
}
|
||||
val url = call.argument<String>("url") ?: run {
|
||||
result.error("missing_url", "open without url", null)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val scheme = try { android.net.Uri.parse(url).scheme?.lowercase() } catch (_: Throwable) { null }
|
||||
if (scheme != "http" && scheme != "https" && scheme != "file") {
|
||||
result.error(
|
||||
"invalid_url_scheme",
|
||||
"media3 only accepts http/https/file urls; got: $scheme",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
val headersRaw = call.argument<Map<String, String>>("headers") ?: emptyMap()
|
||||
val startMs = (call.argument<Number>("startMs"))?.toLong() ?: 0L
|
||||
val play = call.argument<Boolean>("play") ?: true
|
||||
|
||||
cancelPendingOpen("open_cancelled", "open superseded before completion")
|
||||
|
||||
|
||||
val dartGen = (call.argument<Number>("generation"))?.toInt() ?: -1
|
||||
generation = max(generation + 1, dartGen)
|
||||
val openGen = generation
|
||||
|
||||
|
||||
lastDurationMs = 0L
|
||||
lastVideoWidth = 0
|
||||
lastVideoHeight = 0
|
||||
lastAspectRatio = 0f
|
||||
attachedView?.setAspectRatio(0f)
|
||||
lastTracksPayload = null
|
||||
lastPositionMs = 0L
|
||||
lastPlaying = false
|
||||
lastCueGroup = null
|
||||
attachedSubtitleView?.setCues(emptyList())
|
||||
|
||||
|
||||
firstFrameRendered = false
|
||||
attachedShutterView?.visibility = View.VISIBLE
|
||||
|
||||
|
||||
val once = object : Player.Listener {
|
||||
private var settled = false
|
||||
|
||||
fun settle(success: Boolean, error: PlaybackException?) {
|
||||
if (settled) return
|
||||
if (pendingOpenListener !== this) {
|
||||
settled = true
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
try { exo.removeListener(this) } catch (_: Throwable) {}
|
||||
pendingOpenListener = null
|
||||
pendingOpenResult = null
|
||||
pendingOpenGen = -1
|
||||
pendingPrepareGen = -1
|
||||
clearPendingSurfaceTimeout()
|
||||
if (openGen != generation) {
|
||||
result.error("open_cancelled", "open cancelled before completion", null)
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
result.error("open_cancelled", "player disposed before open completed", null)
|
||||
return
|
||||
}
|
||||
if (success) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.error(
|
||||
"open_failed",
|
||||
error?.message ?: "playback error",
|
||||
error?.errorCodeName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) settle(true, null)
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
settle(false, error)
|
||||
}
|
||||
}
|
||||
exo.addListener(once)
|
||||
pendingOpenListener = once
|
||||
pendingOpenResult = result
|
||||
pendingOpenGen = openGen
|
||||
|
||||
|
||||
attachListener(exo, openGen)
|
||||
|
||||
|
||||
exo.stop()
|
||||
exo.clearMediaItems()
|
||||
|
||||
totalRxBytes = 0L
|
||||
val transferListener = object : TransferListener {
|
||||
override fun onTransferInitializing(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {}
|
||||
override fun onTransferStart(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {}
|
||||
override fun onBytesTransferred(
|
||||
source: DataSource,
|
||||
dataSpec: DataSpec,
|
||||
isNetwork: Boolean,
|
||||
bytesTransferred: Int,
|
||||
) {
|
||||
if (isNetwork) totalRxBytes += bytesTransferred.toLong()
|
||||
}
|
||||
override fun onTransferEnd(source: DataSource, dataSpec: DataSpec, isNetwork: Boolean) {}
|
||||
}
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
.setDefaultRequestProperties(sanitizeHeaders(headersRaw))
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||
.setTransferListener(transferListener)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
exo.setMediaSource(mediaSourceFactory.createMediaSource(MediaItem.fromUri(url)))
|
||||
|
||||
if (startMs > 0L) exo.seekTo(startMs)
|
||||
exo.playWhenReady = play
|
||||
|
||||
|
||||
if (surface != null) {
|
||||
pendingPrepareGen = -1
|
||||
clearPendingSurfaceTimeout()
|
||||
exo.prepare()
|
||||
} else {
|
||||
android.util.Log.i(TAG, "open: surface not ready, deferring prepare (gen=$openGen)")
|
||||
pendingPrepareGen = openGen
|
||||
scheduleSurfaceTimeout(openGen)
|
||||
}
|
||||
|
||||
|
||||
if (exo.playerError != null) {
|
||||
once.settle(false, exo.playerError)
|
||||
} else if (exo.playbackState == Player.STATE_READY) {
|
||||
once.settle(true, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun play() = player?.let { it.playWhenReady = true }
|
||||
fun pause() = player?.let { it.playWhenReady = false }
|
||||
|
||||
fun stop(generationFromDart: Int) {
|
||||
|
||||
if (generationFromDart >= 0) generation = max(generation, generationFromDart)
|
||||
cancelPendingOpen("open_cancelled", "open cancelled by stop")
|
||||
player?.stop()
|
||||
stopPositionTimer()
|
||||
lastCueGroup = null
|
||||
attachedSubtitleView?.setCues(emptyList())
|
||||
}
|
||||
|
||||
fun seek(positionMs: Long) {
|
||||
player?.seekTo(positionMs)
|
||||
lastPositionMs = positionMs
|
||||
emitTyped("position", mapOf("positionMs" to positionMs), generation)
|
||||
}
|
||||
|
||||
fun setRate(rate: Float) {
|
||||
player?.let { it.setPlaybackSpeed(max(0.1f, rate)) }
|
||||
}
|
||||
|
||||
fun setVolume(volume0to200: Float) {
|
||||
val mapped = min(1f, max(0f, volume0to200 / 100f))
|
||||
player?.volume = mapped
|
||||
|
||||
}
|
||||
|
||||
fun setResizeMode(mode: Int) {
|
||||
currentResizeMode = mode
|
||||
attachedView?.setResizeMode(mode)
|
||||
}
|
||||
|
||||
|
||||
fun setAudioTrack(id: String) {
|
||||
applyTrackOverride(C.TRACK_TYPE_AUDIO, id)
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(id: String) {
|
||||
applyTrackOverride(C.TRACK_TYPE_TEXT, id)
|
||||
}
|
||||
|
||||
private fun applyTrackOverride(trackType: @C.TrackType Int, id: String) {
|
||||
val exo = player ?: return
|
||||
val builder = exo.trackSelectionParameters.buildUpon()
|
||||
.clearOverridesOfType(trackType)
|
||||
.setTrackTypeDisabled(trackType, false)
|
||||
when (id) {
|
||||
"auto" -> {}
|
||||
"no" -> builder.setTrackTypeDisabled(trackType, true)
|
||||
else -> {
|
||||
|
||||
val parts = id.split(":")
|
||||
if (parts.size != 3) return
|
||||
val groupIdx = parts[1].toIntOrNull() ?: return
|
||||
val trackIdx = parts[2].toIntOrNull() ?: return
|
||||
val tracks = exo.currentTracks
|
||||
val typedGroups = tracks.groups.filter { it.type == trackType }
|
||||
val target = typedGroups.getOrNull(groupIdx) ?: return
|
||||
if (trackIdx !in 0 until target.length) return
|
||||
builder.addOverride(
|
||||
TrackSelectionOverride(target.mediaTrackGroup, trackIdx)
|
||||
)
|
||||
}
|
||||
}
|
||||
exo.trackSelectionParameters = builder.build()
|
||||
}
|
||||
|
||||
fun getProperty(name: String): Any {
|
||||
return when (name) {
|
||||
"ffmpegAvailable" -> if (ffmpegAvailable) "true" else "false"
|
||||
"ffmpegVersion" -> ffmpegVersion.orEmpty()
|
||||
"audioRenderer" -> currentAudioRendererName()
|
||||
"playbackState" -> playbackStateName()
|
||||
"totalRxBytes" -> totalRxBytes
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentAudioRendererName(): String {
|
||||
val exo = player ?: return ""
|
||||
return try {
|
||||
for (i in 0 until exo.rendererCount) {
|
||||
if (exo.getRendererType(i) == C.TRACK_TYPE_AUDIO) {
|
||||
|
||||
val renderer = exo.javaClass.getMethod("getRenderer", Int::class.java)
|
||||
.invoke(exo, i)
|
||||
if (renderer != null) return renderer.javaClass.simpleName
|
||||
}
|
||||
}
|
||||
""
|
||||
} catch (t: Throwable) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun playbackStateName(): String {
|
||||
val state = player?.playbackState ?: return ""
|
||||
return when (state) {
|
||||
Player.STATE_IDLE -> "idle"
|
||||
Player.STATE_BUFFERING -> "buffering"
|
||||
Player.STATE_READY -> "ready"
|
||||
Player.STATE_ENDED -> "ended"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
cancelPendingOpen("open_cancelled", "player disposed before open completed")
|
||||
disposed = true
|
||||
++generation
|
||||
stopPositionTimer()
|
||||
try {
|
||||
listener?.let { player?.removeListener(it) }
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "removeListener failed", t)
|
||||
}
|
||||
listener = null
|
||||
try {
|
||||
player?.clearVideoSurface()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "clearVideoSurface failed", t)
|
||||
}
|
||||
try {
|
||||
player?.release()
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "player.release failed", t)
|
||||
}
|
||||
player = null
|
||||
|
||||
surface = null
|
||||
attachedView = null
|
||||
attachedSubtitleView = null
|
||||
}
|
||||
|
||||
|
||||
fun replayKeyState() {
|
||||
if (disposed) return
|
||||
val gen = generation
|
||||
if (lastDurationMs > 0) emitTyped("duration", mapOf("durationMs" to lastDurationMs), gen)
|
||||
if (lastVideoWidth > 0 && lastVideoHeight > 0) {
|
||||
emitTyped(
|
||||
"videoSize",
|
||||
mapOf("width" to lastVideoWidth, "height" to lastVideoHeight),
|
||||
gen,
|
||||
)
|
||||
}
|
||||
lastTracksPayload?.let { emitTyped("tracks", it, gen) }
|
||||
emitTyped("playing", mapOf("playing" to lastPlaying), gen)
|
||||
emitTyped("position", mapOf("positionMs" to lastPositionMs), gen)
|
||||
}
|
||||
|
||||
|
||||
private fun emitTyped(type: String, payload: Map<String, Any?>, gen: Int) {
|
||||
val merged = HashMap<String, Any?>(payload.size + 3)
|
||||
merged["playerId"] = id
|
||||
merged["generation"] = gen
|
||||
merged["type"] = type
|
||||
merged.putAll(payload)
|
||||
emit(merged)
|
||||
}
|
||||
|
||||
|
||||
private fun sanitizeHeaders(headers: Map<String, String>): Map<String, String> {
|
||||
val sanitized = HashMap<String, String>(headers.size)
|
||||
for ((k, v) in headers) {
|
||||
if (k.contains('\n') || k.contains('\r')) continue
|
||||
if (v.contains('\n') || v.contains('\r')) continue
|
||||
sanitized[k] = v
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
private fun startPositionTimer(openGen: Int) {
|
||||
stopPositionTimer()
|
||||
val r = object : Runnable {
|
||||
override fun run() {
|
||||
if (disposed || openGen != generation) return
|
||||
val exo = player ?: return
|
||||
val pos = exo.currentPosition
|
||||
val buf = exo.bufferedPosition
|
||||
if (pos != lastPositionMs) {
|
||||
lastPositionMs = pos
|
||||
emitTyped(
|
||||
"position",
|
||||
mapOf("positionMs" to pos, "bufferMs" to buf),
|
||||
openGen,
|
||||
)
|
||||
}
|
||||
mainHandler.postDelayed(this, POSITION_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
positionRunnable = r
|
||||
mainHandler.postDelayed(r, POSITION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun stopPositionTimer() {
|
||||
positionRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
positionRunnable = null
|
||||
}
|
||||
|
||||
private fun scheduleSurfaceTimeout(openGen: Int) {
|
||||
clearPendingSurfaceTimeout()
|
||||
val r = Runnable {
|
||||
if (disposed || openGen != generation || pendingPrepareGen != openGen) return@Runnable
|
||||
android.util.Log.w(TAG, "deferred prepare timed out waiting for surface (gen=$openGen)")
|
||||
cancelPendingOpen("surface_timeout", "surface not attached within timeout")
|
||||
}
|
||||
pendingSurfaceTimeout = r
|
||||
mainHandler.postDelayed(r, SURFACE_ATTACH_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun clearPendingSurfaceTimeout() {
|
||||
pendingSurfaceTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
pendingSurfaceTimeout = null
|
||||
}
|
||||
|
||||
private fun cancelPendingOpen(code: String, message: String) {
|
||||
clearPendingSurfaceTimeout()
|
||||
pendingPrepareGen = -1
|
||||
val once = pendingOpenListener
|
||||
val result = pendingOpenResult
|
||||
pendingOpenListener = null
|
||||
pendingOpenResult = null
|
||||
pendingOpenGen = -1
|
||||
if (once != null) {
|
||||
try { player?.removeListener(once) } catch (_: Throwable) {}
|
||||
}
|
||||
if (result != null) {
|
||||
try {
|
||||
result.error(code, message, null)
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w(TAG, "complete pending open failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun serializeTracks(tracks: Tracks): Map<String, Any?> {
|
||||
val audio = ArrayList<Map<String, Any?>>()
|
||||
val subtitle = ArrayList<Map<String, Any?>>()
|
||||
var audioGroupIdx = 0
|
||||
var textGroupIdx = 0
|
||||
for (group in tracks.groups) {
|
||||
when (group.type) {
|
||||
C.TRACK_TYPE_AUDIO -> {
|
||||
val gi = audioGroupIdx++
|
||||
for (ti in 0 until group.length) {
|
||||
if (!group.isTrackSupported(ti)) continue
|
||||
val fmt = group.getTrackFormat(ti)
|
||||
audio.add(
|
||||
mapOf(
|
||||
"id" to "audio:$gi:$ti",
|
||||
"index" to ti,
|
||||
"title" to (fmt.label ?: ""),
|
||||
"language" to (fmt.language ?: ""),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
C.TRACK_TYPE_TEXT -> {
|
||||
val gi = textGroupIdx++
|
||||
for (ti in 0 until group.length) {
|
||||
if (!group.isTrackSupported(ti)) continue
|
||||
val fmt = group.getTrackFormat(ti)
|
||||
subtitle.add(
|
||||
mapOf(
|
||||
"id" to "text:$gi:$ti",
|
||||
"index" to ti,
|
||||
"title" to (fmt.label ?: ""),
|
||||
"language" to (fmt.language ?: ""),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapOf("audio" to audio, "subtitle" to subtitle)
|
||||
}
|
||||
|
||||
private fun probeFfmpegAvailability(): Boolean {
|
||||
|
||||
return try {
|
||||
val cls = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
|
||||
cls.getMethod("isAvailable").invoke(null) as? Boolean ?: false
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun probeFfmpegVersion(): String? {
|
||||
return try {
|
||||
val cls = Class.forName("androidx.media3.decoder.ffmpeg.FfmpegLibrary")
|
||||
cls.getMethod("getVersion").invoke(null) as? String
|
||||
} catch (t: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Media3PlayerInstance"
|
||||
private const val POSITION_INTERVAL_MS = 500L
|
||||
private const val SURFACE_ATTACH_TIMEOUT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user