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
+162
View File
@@ -0,0 +1,162 @@
# media3_engine
`smplayer` 项目本地 Flutter plugin。**仅 Android**,包名 `com.smplayer.media3_engine`
不发布到 pub.dev。Dart 侧通过约定好的 MethodChannel / EventChannel 与 native 通讯。
引擎实现位于主项目:
- Dart`lib/core/infra/player_engine/media3_player_engine.dart`
- Kotlin`plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/`
## 版本锁定
| 组件 | 版本 |
|------|------|
| androidx.media | `1.10.1` |
| FFmpeg | `release/6.0` |
| Android NDK | `r27d` LTS (27.3.13750724) |
| Android `minSdk` | 24 |
| Android `compileSdk` | 36 |
| AGP / Kotlin / JDK | 8.11.1 / 2.2.20 / 17 |
| ABI | **arm64-v8a only**(包体最小化) |
| License | LGPL(不开 `--enable-gpl` / `--enable-nonfree` |
升 Media3 / FFmpeg 版本前,先核对 `androidx/media @ <tag>/libraries/decoder_ffmpeg/README.md`
推荐的 NDK 与 FFmpeg branch,再调整 `scripts/build_media3_ffmpeg.sh` 默认值。
## 通道协议
### MethodChannel `smplayer/media3_engine`
| 方法 | 参数 | 返回 |
|------|------|------|
| `create` | | `{playerId:int, textureId:int, ffmpegAvailable:bool, ffmpegVersion:string}` |
| `open` | `playerId, url, headers, startMs, play, generation` | 首次 `STATE_READY``PlaybackException` 后 complete |
| `play` / `pause` | `playerId` | |
| `stop` | `playerId, generation` | |
| `seek` | `playerId, positionMs` | |
| `setRate` | `playerId, rate` (float) | |
| `setVolume` | `playerId, volume` (0..200) | |
| `setAudioTrack` | `playerId, id` (`audio:<g>:<t>` / `auto` / `no`) | |
| `setSubtitleTrack` | `playerId, id` (`text:<g>:<t>` / `auto` / `no`) | |
| `setSubtitleStyle` | `playerId, fontSize, bgOpacity, position` | (首版 no-opcue 由 Dart 渲染) |
| `getProperty` | `playerId, name` | `string``ffmpegAvailable` / `ffmpegVersion` / `audioRenderer` / `playbackState` |
| `dispose` | `playerId` | |
### EventChannel `smplayer/media3_engine/events`
所有事件载荷形如 `{playerId, generation, type, ...}`Dart 侧按 `playerId` 过滤、
`generation` 丢弃过期事件。
| `type` | payload |
|--------|---------|
| `position` | `{positionMs, bufferMs}` |
| `duration` | `{durationMs}` |
| `playing` | `{playing}` |
| `completed` | `{}` |
| `videoSize` | `{width, height}` |
| `tracks` | `{audio:[{id,index,title,language}], subtitle:[…]}` |
| `subtitle` | `{lines:[string]}` |
| `volume` | `{volume}`(保留,目前 native 不主动 emit |
| `error` | `{code, message}` |
Generation token 语义:Dart 端每次 `open / stop / dispose` ++ generationnative
回调先比对此值,过期事件丢弃。这保证了 cancel / reopen 时不会有迟到的 STATE_READY
污染新 session 的状态。
## FFmpeg 软解 codec 列表
`scripts/build_media3_ffmpeg.sh``ENABLED_DECODERS` 默认:
| 解码器名(FFmpeg) | 实际格式 |
|-------------------|----------|
| `dca` | DTS / DTS-HD / DTS-HD MA |
| `ac3` | AC3 |
| `eac3` | E-AC3 (Dolby Digital Plus) |
| `truehd` | Dolby TrueHD(含 Atmos 元数据,但 Atmos 渲染依赖宿主 audio sink |
| `opus` | Opus |
| `vorbis` | Vorbis |
| `flac` | FLAC |
视频解码不在范围——交给平台 MediaCodec 硬解。容器解析与音频帧 parser 由 ExoPlayer
自身处理,FFmpeg 扩展只补 decoder(无需 `--enable-parser` / `--enable-demuxer`)。
## 编译 AAR
> **前置硬约束**`android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar` 是
> Android 构建的硬依赖。仓库默认不提交该 AAR 二进制;缺文件时 plugin 的
> `preBuild` 任务 `verifyMedia3FfmpegAar` 会 fail-fast 抛错并给出本节的恢复
> 指令。CI / Clean checkout 都必须先编出 AAR 才能 `flutter build apk`。
### 前置
- macOS / Linux hostApple Silicon 建议通过 Docker `linux-x86_64` 容器编译以规避
Media3 README 未文档化的 darwin-arm64 行为)
- Android NDK r27d (LTS):从 https://developer.android.com/ndk/downloads 安装;Media3 1.10.1 README 实测版本是 r26br27 ABI 兼容、社区已验证可编
- `perl`, `make`, `yasm`, `git`, `python3`, `shasum`macOS 自带;Linux 需手装 yasm
- JDK 17 + Android SDK(命令行工具足够,Android Studio 非必需)
### 步骤
```sh
# 1. 设置 NDK 路径(或用 --ndk-path 参数)
export ANDROID_NDK_HOME=/path/to/android-ndk-r27d
# 2. 跑脚本(首次约 30-60 分钟,取决于 host CPU / IO
./scripts/build_media3_ffmpeg.sh
# 3. 产物
# android/app/libs/media3-decoder-ffmpeg-1.10.1-arm64.aar
# android/app/libs/CHECKSUMS.txt 自动追加 / 替换该 AAR 的 SHA-256
#
# 4. 核对 SHA-256 → git add 上述两个文件 → flutter build apk --debug 验证加载
```
### 重新编译 / 升级版本
```sh
# 完全清空工作区重新拉取(覆盖 androidx/media + FFmpeg 缓存)
./scripts/build_media3_ffmpeg.sh --clean
# 升 Media3 tag
./scripts/build_media3_ffmpeg.sh --clean --media3-tag 1.11.0
# 升 FFmpeg branch
./scripts/build_media3_ffmpeg.sh --clean --ffmpeg-branch release/6.1
```
脚本会在 `libraries/decoder_ffmpeg/src/main/jni/build_ffmpeg.sh` 上幂等地打一次
sed patch,把上游默认的 4 ABI 限制到 arm64-v8a only。`--clean` 会清空 patch 与缓存。
### Docker 复现编译(推荐 Apple Silicon
```sh
docker run --rm -it \
-v "$(pwd)":/work \
-e ANDROID_NDK_HOME=/opt/android-ndk-r27d \
-w /work \
cimg/android:2024.05.1-ndk \
scripts/build_media3_ffmpeg.sh
```
镜像版本以 r27d NDK 实际可用性为准;上面只是一个起点示意。
## 已知限制
| 限制 | 说明 / 解决路径 |
|------|----------------|
| **位图字幕**PGS / VobSub | onCues 检出后 emit 空文本 + `error: 'subtitle_bitmap_unsupported'`;建议改外挂 SRT/ASS 或切回 fvp |
| **音量 boost**100 < volume ≤ 200 | ExoPlayer `volume` 上限为 1.0;本插件把 100..200 段静默截断到 1.0。Dart 端 `state.volume` 保留原值(UI 一致),但物理音量不会更大 |
| **HDR / DV** | 路由层显式排除(`resolvePlayerEngineKind` 在 DV/ProRes 下回退 fvp);media3 视频走 MediaCodecDV 元数据未被特殊处理 |
| **iOS / 桌面** | plugin 仅声明 Android 平台,构造层 `Platform.isAndroid` 守卫;UI 层桌面隐藏 media3 选项 |
| **PiP / MediaSession / Cast** | 不实现(范围外);播放在前台 Activity 内完成 |
| **DRM / Widevine** | 不实现(范围外) |
| **FFmpeg AAR 体积** | 约 12-15 MB / arm64-v8arelease APK 增量约 ≤ 30 MB |
## 平台守卫四层
1. **枚举层**`PlayerEngineOverride.media3` / `PlayerEngineKind.media3` 在所有平台编译通过(inert
2. **路由层**`resolvePlayerEngineKind` 在 desktop 把 media3 降级为 mpv
3. **构造层**`Media3PlayerEngine` 构造函数 `assert(Platform.isAndroid)`,否则抛 `UnsupportedError`
4. **UI 层**`PlayerEngineRow.visibleOptions` 过滤 + `coerceValue` 持久化值降级
任一层失守,其它层仍能阻止 native crash。
@@ -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
}
}
@@ -0,0 +1,3 @@
library;
+20
View File
@@ -0,0 +1,20 @@
name: media3_engine
description: Android-only Flutter plugin bridging Media3 ExoPlayer + FFmpeg audio
decoder extension. 仅供主项目本地 path 依赖,不发布到 pub.dev。
version: 0.0.1
publish_to: 'none'
environment:
sdk: ^3.11.5
flutter: '>=3.41.0'
dependencies:
flutter:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: com.smplayer.media3_engine
pluginClass: Media3EnginePlugin