# 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-op，cue 由 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` ++ generation，native
回调先比对此值，过期事件丢弃。这保证了 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 host（Apple Silicon 建议通过 Docker `linux-x86_64` 容器编译以规避
  Media3 README 未文档化的 darwin-arm64 行为）
- Android NDK r27d (LTS)：从 https://developer.android.com/ndk/downloads 安装；Media3 1.10.1 README 实测版本是 r26b，r27 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 视频走 MediaCodec，DV 元数据未被特殊处理 |
| **iOS / 桌面** | plugin 仅声明 Android 平台，构造层 `Platform.isAndroid` 守卫；UI 层桌面隐藏 media3 选项 |
| **PiP / MediaSession / Cast** | 不实现（范围外）；播放在前台 Activity 内完成 |
| **DRM / Widevine** | 不实现（范围外） |
| **FFmpeg AAR 体积** | 约 12-15 MB / arm64-v8a；release 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。
