Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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 extensionarm64-v8a onlyLGPL 配置)。AAR 由
// scripts/build_media3_ffmpeg.sh 产出,放在 android/app/libs/。
// 这里只声明 compileOnlyAGP 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" />
@@ -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
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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
}
}