niuma_player 0.5.0
niuma_player: ^0.5.0 copied to clipboard
Flutter video player SDK with unified API across iOS / Android / Web, plus automatic ExoPlayer to IJK fallback on Android.
niuma_player #
Headless Flutter 视频播放内核——iOS / Android / Web 三端统一 controller API,附带纯逻辑播放编排(多线路自动 failover / 切换失败回滚 / retry policy / source middleware)以及手势、全屏的 headless controller。
本包不提供播放器控件皮肤。 接入方用无样式渲染面
NiumaPlayerView渲染画面 + 监听controller.value(ValueNotifier<NiumaPlayerValue>)自己拼控件,或让 AI 按需生成。曾经的整套参考皮(一体化播放器壳、原子控件、控件条、全屏页、弹幕 / 广告 / 缩略图 / 投屏 UI、短视频、主题,共 88 文件)保留在 git 历史里——需要时用git log/git show捞,或喂给 AI 当参考。
目录 #
设计哲学:headless 内核 #
niuma_player 只做一件事:把视频在三端可靠地播起来,并把播放状态以统一的 NiumaPlayerValue 暴露出来。它不替你决定 UI 长什么样。
- 播放引擎、状态机、错误分类、多线路 failover、retry、source middleware 都在核里,是稳定的 semver 契约。
- 画面渲染走一个无样式的
NiumaPlayerView(一块视频纹理 /<video>表面)。 - 所有控件——播放按钮、进度条、全屏、缩略图预览——由接入方自己写:监听
controller.value拿 phase / position / duration / size / error,调controller.play()/pause()/seekTo()等驱动。
这样换来的好处:
- SDK 体积小、依赖少,不把一套别人的视觉强加给你。
- UI 完全是你自家的设计系统,不用跟 SDK 的主题打架。
- 复杂控件可以让 AI 按你的 design token 生成,或从 git 历史里的参考皮捞现成实现改造。
需要参考实现? 整套老的参考皮(bili 风长视频壳 / 抖音风短视频 / 弹幕引擎 + overlay / DLNA + AirPlay 投屏协议与 UI / 缩略图取帧 / 广告调度 / 主题)都在 git 历史里。git log --all -- 'example/lib/niuma_ui/**' 找到那个 commit,git show <sha>:example/lib/niuma_ui/... 取文件即可。
平台兼容 #
| 平台 | 后端引擎 | HLS | PiP |
|---|---|---|---|
| iOS | AVPlayer (video_player) |
✅ 原生 | ✅(反射 AVPlayer 接 AVPictureInPictureController) |
| Android | ExoPlayer(video_player 官方维护,硬解,默认)↔ IJK(软解兜底,GSY 官方产物:bilibili ijkplayer 0.8.8 + FFmpeg 4.3 全量,h264/h265) |
✅ 原生 | ✅(业务侧 1 行回调接入) |
| Web | <video> + 按需 hls.js |
✅(Safari 原生 / Chrome·Firefox·Edge 走 hls.js 懒注入) | ⚠️ 浏览器无可靠程序化 API |
Web 端基于 package:web,wasm-ready(可随 flutter build web --wasm 编译)。
安装 #
dependencies:
niuma_player: ^0.5.0 # pub.dev
flutter pub get
Dart 侧外部依赖只有 5 个:video_player(iOS AVPlayer / Android ExoPlayer 主路径)、web(Web 后端)、plugin_platform_interface、meta、clock;另有 Flutter SDK 自带的 flutter_web_plugins。Android 原生只引入 GSY ijkplayer(MavenCentral,minSdk 21)作软解兜底——ExoPlayer 来自官方 video_player,随它升级,SDK 不再自维护。
最小用法 #
下面这段就是 example/lib/minimal_player/minimal_player.dart 的核心:NiumaPlayerView 渲染画面,ValueListenableBuilder<NiumaPlayerValue> 监听状态自己拼播放/暂停 + 进度条 + 时间。
import 'package:flutter/material.dart';
import 'package:niuma_player/niuma_player.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({super.key});
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
late final NiumaPlayerController _c;
@override
void initState() {
super.initState();
_c = NiumaPlayerController.dataSource(
NiumaDataSource.network('https://example.com/video.mp4'),
)..initialize();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
AspectRatio(aspectRatio: 16 / 9, child: NiumaPlayerView(_c)),
ValueListenableBuilder<NiumaPlayerValue>(
valueListenable: _c,
builder: (context, value, _) {
final maxMs = value.duration.inMilliseconds;
return Row(
children: [
IconButton(
icon: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: value.isPlaying ? _c.pause : _c.play,
),
Expanded(
child: Slider(
value: value.position.inMilliseconds
.clamp(0, maxMs)
.toDouble(),
max: maxMs > 0 ? maxMs.toDouble() : 1,
onChanged: maxMs > 0
? (v) =>
_c.seekTo(Duration(milliseconds: v.round()))
: null,
),
),
Text('${formatVideoTime(value.position)} / '
'${formatVideoTime(value.duration)}'),
],
);
},
),
],
);
}
}
完整可运行版本见 example/lib/minimal_player/minimal_player.dart。更详细的接入步骤见 doc/getting-started.md,全部公开符号见 doc/api-reference.md。
核心 API 速查 #
NiumaPlayerController #
// 单 source 便捷构造:
final c = NiumaPlayerController.dataSource(
NiumaDataSource.network('https://example.com/video.mp4'),
);
// 多线路 / 自定义 policy 构造:
final c = NiumaPlayerController(
source, // NiumaMediaSource
middlewares: const [], // SourceMiddleware 流水线
retryPolicy: const RetryPolicy.smart(),
options: const NiumaPlayerOptions(),
);
await c.initialize();
c.play();
c.pause();
c.seekTo(const Duration(seconds: 30));
c.setPlaybackSpeed(1.5);
c.setVolume(0.8);
await c.load(newSource); // 原地换源:复用当前 backend(web 复用同一 <video>,
// 保住 iOS Safari 有声激活);不支持换源的 backend 自动重建
c.setWebNativeControls(true); // web-only:开底层 <video> 浏览器原生控件兜底
await c.dispose();
c.value; // 当前 NiumaPlayerValue(NiumaPlayerController extends ValueNotifier)
c.events; // Stream<NiumaPlayerEvent>
c.activeLineId; // 当前线路 id
NiumaPlayerOptions 关键项(Android) #
NiumaPlayerOptions(
forceIjkOnAndroid: false, // true = 强制 IJK 软解(默认 video_player/ExoPlayer 硬解)
useAndroidPlatformView: false, // true = SurfaceView 原生渲染(vp 主路径映射 platformView,IJK 走 Hybrid Composition)
manageScreenWakelock: true, // 播放中自动保持屏幕常亮
)
- 内核选择完全显式:默认官方
video_player(ExoPlayer 硬解);初始化失败当次会话内自动用 IJK 软解兜底重试一次(不落盘)。双内核都失败抛EngineFallbackFailure(同时携带两段原始错误)。 - 媒体能力探测:
await NiumaCapabilities.supportsHevc()—— Android 查硬解、iOS 恒 true、web 探 MSE/canPlayType。用于向服务端协商 H265 源(协议字段业务自定,SDK 不自动加请求头)。
NiumaPlayerValue(监听这个拼 UI) #
value.phase; // PlayerPhase: idle/opening/ready/playing/paused/buffering/ended/error
value.position; // 当前位置 Duration
value.duration; // 总时长 Duration
value.bufferedPosition; // 已缓冲位置
value.size; // 视频自然分辨率 Size
value.playbackSpeed; // double
value.error; // PlayerError?(phase==error 时非 null)
value.isInPictureInPicture; // 是否在 PiP
value.isPictureInPictureSupported; // 设备是否支持 PiP
value.isPlaying; // phase == playing
value.initialized; // 已 init 且不在 opening
value.hasError; // phase == error
多线路 + failover #
final source = NiumaMediaSource.lines(
lines: [
MediaLine(id: 'cdn1', label: '线路 1', priority: 0,
source: NiumaDataSource.network('https://cdn1/v.m3u8')),
MediaLine(id: 'cdn2', label: '线路 2', priority: 1,
source: NiumaDataSource.network('https://cdn2/v.m3u8')),
],
defaultLineId: 'cdn1',
);
await c.switchLine('cdn2'); // 业务主动切换;失败时按 options 静默回滚
NiumaPlayerOptions 默认 autoFailoverOnInitialError: true(默认线路 init 失败 → 按 priority 升序自动尝试下一条)与 rollbackOnSwitchFailure: true(主动切换失败 → 回滚原线路保留 position)。
全屏 / 手势 headless controller #
// 全屏:朝向锁定 + system UI 切换的纯编排,UI 由你自己摆
final fs = NiumaFullscreenController(...);
// 手势:把拖动几何量映射成播放意图 + HUD 反馈状态
final gesture = NiumaGestureController(...);
gesture.feedback; // ValueListenable<GestureFeedbackState?>,监听它渲染你自家 HUD
Web 全屏按浏览器分流(核给 webFullscreenMode 让你判定,不必碰 DOM):
switch (webFullscreenMode) {
case NiumaWebFullscreenMode.nativeVideoElement: // iOS Safari:只能 <video> 系统全屏
controller.enterNativeFullscreen();
case NiumaWebFullscreenMode.browserElement: // Chrome 等:画布真全屏 + 保留控件
requestBrowserFullscreen(); // 对 documentElement 真全屏
Navigator.of(context).push(/* 你的全屏页:video + 控件铺满 */);
case NiumaWebFullscreenMode.notWeb: // io:直接 push 全屏页
Navigator.of(context).push(/* ... */);
}
退出 / ESC 同步:exitBrowserFullscreen() / onBrowserFullscreenChange(cb)。
Web 单 <video> 在 inline / 全屏路由间搬迁的协调契约:NiumaFullscreenScope + enterWebFullscreenRoute() / exitWebFullscreenRoute() / webFullscreenRouteCountListenable。
事件流 #
c.events.listen((e) {
if (e is BackendSelected) { /* iOS / Android / Web 选定 */ }
if (e is FallbackTriggered) { /* Android video_player → IJK */ }
if (e is LineSwitching) {}
if (e is LineSwitched) {}
if (e is LineSwitchFailed) { /* 即使已回滚也会 emit,供业务上报 */ }
if (e is PipModeChanged) {}
});
平台原生接入 #
iOS #
ios/Runner/Info.plist:
<!-- 允许 HTTP 视频源(HTTPS 不需要) -->
<key>NSAppTransportSecurity</key>
<dict><key>NSAllowsArbitraryLoads</key><true/></dict>
<!-- PiP 后台音频继续播 -->
<key>UIBackgroundModes</key>
<array><string>audio</string></array>
PiP 不需要额外原生代码——核内部反射 video_player_avfoundation 拿 AVPlayer 接 AVPictureInPictureController。
Android #
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
<activity
android:supportsPictureInPicture="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode">
MainActivity.kt——必须接 PiP 回调,否则核收不到进/退 PiP 事件:
import android.content.res.Configuration
import cn.niuma.niuma_player.NiumaPlayerPlugin
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() {
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration?,
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
NiumaPlayerPlugin.reportPipModeChanged(isInPictureInPictureMode)
}
}
Web #
web/index.html <head> 内(PWA 全屏覆盖 notch / home indicator):
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#000000">
HLS 在 Chrome / Firefox / Edge 走核内置的 vendored hls.js(assets/hls/hls.min.js),仅当播放 HLS 源且非 Safari 时运行时懒注入 <script>;纯 mp4 页面不加载任何额外 JS。Safari 走浏览器原生 HLS。
Web 平台已知限制 #
- PiP:浏览器没有可靠的程序化 API,核在 web 上不提供该能力(自家 UI 应隐藏对应入口)。
SystemChrome.setPreferredOrientations:web 平台 no-op,无法程序化锁方向。竖屏 viewport 放横屏视频时,建议自家 UI 浮一个"旋转屏幕"提示。- iOS Safari
<video>.volumesetter 只读:核同步设muted解决"按钮静音不生效"。 - iOS Safari
videoWidth/Height滞后:onLoadedMetadata时尺寸可能仍是 0,核在onPlaying/onTimeUpdate时 retry 同步value.size。 - iOS Safari 有声自动播 = 单
<video>复用 + 用户手势激活:WebKit 要求带声音播放在用户手势同步栈内,且激活绑在同一个<video>元素上。feed 这类滑动自动播场景应复用一个 controller(controller.load(newSource)换源、不每条新建),用户首次点击时 unmute 建立激活,之后换源持续有声;每条新建<video>会丢激活 → 只能静音(详见example/lib/feed_demo/)。 - iOS Safari Flutter 控件被
<video>吞:叠在<video>platform-view 上的 Flutter 控件,落在视频像素区的点击收不到。把关键按钮放视频画面外(如 AppBar),或用controller.setWebNativeControls(true)开浏览器原生控件兜底。
文档 #
doc/getting-started.md— 接入步骤doc/api-reference.md— 公开符号速查CHANGELOG.md— 版本变更CONTRIBUTING.md— 贡献指南
许可证 #
Apache 2.0 — 见 LICENSE。