OmniPlayer
OmniPlayer 是一个面向 Flutter 的跨平台媒体播放器插件,基于 VLC 引擎封装 iOS 与 Android 播放能力,支持视频、音频、后台播放、锁屏/通知栏控制、精确 seek、倍速、循环播放与可选磁盘缓存。
特性
- 跨平台播放:支持 iOS 与 Android,底层分别使用 MobileVLCKit / libVLC。
- 音视频一体:支持视频、纯音频、后台音频、锁屏/通知栏媒体控制。
- 广泛格式:支持 MP4、MOV、MKV、MP3、AAC、FLAC、WAV、HLS、DASH、RTSP 等 VLC 可播放格式。
- 高频交互优化:插件内部处理 seek、暂停、恢复播放、状态乱序和进度回退问题。
- 播放控制完整:支持播放、暂停、停止、seek、音量、倍速、循环播放、进度回调频率控制。
- 可选磁盘缓存:普通 HTTP/HTTPS 点播资源可后台缓存,再次播放直接命中本地文件。
平台实现
| 平台 | 播放引擎 | 视频渲染 | 后台/控制 |
|---|---|---|---|
| iOS | MobileVLCKit | VLC drawable + UiKitView |
MPRemoteCommandCenter |
| Android | libVLC | Flutter Texture |
Foreground Service + MediaSessionCompat |
当前仅支持 iOS 与 Android,不支持 macOS、Windows、Web。
安装
flutter pub add omni_player
或手动添加到 pubspec.yaml:
dependencies:
omni_player: ^0.1.23
平台配置
iOS
在 ios/Runner/Info.plist 按需添加:
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
UIBackgroundModes用于后台音频播放。NSAppTransportSecurity仅在需要播放 HTTP 明文资源时添加。- 真机运行需要有效的开发者证书和 Provisioning Profile。
Android
在 android/app/src/main/AndroidManifest.xml 确保有网络权限:
<uses-permission android:name="android.permission.INTERNET" />
插件会自动注册前台播放服务与媒体控制相关组件。
快速开始
初始化
import 'package:omni_player/omni_player.dart';
final player = OmniPlayer.instance;
Future<void> initPlayer() async {
await player.initialize(
cacheConfig: const CacheConfig(
enabled: true,
maxSize: 500 * 1024 * 1024,
),
);
}
播放媒体
await player.open(
const MediaItem(
url: 'https://example.com/video.mp4',
title: '示例视频',
artist: 'OmniPlayer',
coverUrl: 'https://example.com/cover.jpg',
isVideo: true,
),
);
显示视频
AspectRatio(
aspectRatio: player.videoSize?.aspectRatio ?? 16 / 9,
child: VideoWidget(
player: player,
fit: BoxFit.contain,
),
)
释放资源
@override
void dispose() {
player.dispose();
super.dispose();
}
OmniPlayer.instance 是全局单例。调用 dispose() 后如需再次使用,需要重新获取 OmniPlayer.instance 并调用 initialize()。
完整示例
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:omni_player/omni_player.dart';
class VideoPlayerPage extends StatefulWidget {
const VideoPlayerPage({super.key});
@override
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
}
class _VideoPlayerPageState extends State<VideoPlayerPage> {
final _player = OmniPlayer.instance;
final _subs = <StreamSubscription>[];
PlayerState _state = PlayerState.idle;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
double _buffered = 0.0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
await _player.initialize(
cacheConfig: const CacheConfig(enabled: true),
);
_subs.addAll([
_player.stateStream.listen((value) => setState(() => _state = value)),
_player.positionStream.listen((value) => setState(() => _position = value)),
_player.durationStream.listen((value) => setState(() => _duration = value)),
_player.bufferedStream.listen((value) => setState(() => _buffered = value)),
]);
await _player.open(
const MediaItem(
url: 'https://example.com/video.mp4',
title: '示例视频',
isVideo: true,
),
);
}
@override
Widget build(BuildContext context) {
final durationMs = _duration.inMilliseconds;
final progress = durationMs > 0
? (_position.inMilliseconds / durationMs).clamp(0.0, 1.0)
: 0.0;
return Scaffold(
backgroundColor: Colors.black,
body: Column(
children: [
AspectRatio(
aspectRatio: _player.videoSize?.aspectRatio ?? 16 / 9,
child: VideoWidget(player: _player),
),
Slider(
value: progress,
secondaryTrackValue: _buffered.clamp(0.0, 1.0),
onChanged: durationMs <= 0
? null
: (value) => _player.seek(
Duration(milliseconds: (value * durationMs).round()),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
color: Colors.white,
icon: const Icon(Icons.replay_10),
onPressed: () => _player.seek(_position - const Duration(seconds: 10)),
),
IconButton(
color: Colors.white,
iconSize: 42,
icon: Icon(_state == PlayerState.playing ? Icons.pause : Icons.play_arrow),
onPressed: _state == PlayerState.playing ? _player.pause : _player.play,
),
IconButton(
color: Colors.white,
icon: const Icon(Icons.forward_10),
onPressed: () => _player.seek(_position + const Duration(seconds: 10)),
),
],
),
],
),
);
}
@override
void dispose() {
for (final sub in _subs) {
sub.cancel();
}
_player.dispose();
super.dispose();
}
}
常用场景
播放音频并响应锁屏/通知栏上下首
final player = OmniPlayer.instance;
await player.initialize();
player.previousTrackStream.listen((_) => playPrevious());
player.nextTrackStream.listen((_) => playNext());
await player.open(
const MediaItem(
url: 'https://example.com/audio.mp3',
title: '示例音频',
artist: 'Artist',
album: 'Album',
coverUrl: 'https://example.com/cover.jpg',
isVideo: false,
),
);
自定义 HTTP 请求头
await player.open(
const MediaItem(
url: 'https://private.example.com/video.m3u8',
title: '鉴权视频',
isVideo: true,
headers: {
'Authorization': 'Bearer token',
'Referer': 'https://example.com',
},
),
);
循环播放与倍速
await player.setLooping(true);
await player.setSpeed(1.5);
await player.open(
const MediaItem(
url: 'https://example.com/lesson.mp4',
title: '课程视频',
isVideo: true,
),
);
调整进度回调频率
await player.setPositionUpdateInterval(const Duration(milliseconds: 100));
普通播放建议保持默认 500ms;歌词同步、精细进度条等场景可降低间隔。
缓存管理
await player.initialize(
cacheConfig: const CacheConfig(
enabled: true,
maxSize: 300 * 1024 * 1024,
),
);
final size = await player.getCacheSizeString();
final cached = await player.cacheManager?.isCached('https://example.com/video.mp4') ?? false;
await player.clearCacheItem('https://example.com/video.mp4');
await player.clearCache();
API 参考
OmniPlayer
生命周期
| 方法 | 说明 |
|---|---|
initialize({CacheConfig? cacheConfig}) |
初始化播放器并订阅原生事件;使用前必须调用。 |
dispose() |
释放原生资源、事件流与缓存管理器。 |
播放控制
| 方法 | 说明 |
|---|---|
open(MediaItem item, {bool autoPlay = true}) |
打开媒体;默认自动播放。 |
play() |
播放或恢复播放。 |
pause() |
暂停播放。 |
stop() |
停止播放并清除当前媒体。 |
seek(Duration position) |
跳转到指定时间点。 |
setVolume(double volume) |
设置音量,范围 0.0 ~ 1.0。 |
setSpeed(double speed) |
设置倍速,例如 0.75、1.0、1.5、2.0。 |
setLooping(bool looping) |
设置是否循环播放。 |
setPositionUpdateInterval(Duration interval) |
设置进度回调频率,默认 500ms。 |
同步属性
| 属性 | 类型 | 说明 |
|---|---|---|
state |
PlayerState |
当前播放状态。 |
position |
Duration |
当前播放进度。 |
duration |
Duration |
媒体总时长。 |
buffered |
double |
缓冲进度,范围 0.0 ~ 1.0。 |
looping |
bool |
当前是否循环播放。 |
textureId |
int? |
Android Texture ID;iOS 通常为 null。 |
videoSize |
VideoSize? |
视频尺寸。 |
error |
String? |
最近一次错误信息。 |
cacheManager |
CacheManager? |
缓存管理器;未启用缓存时为 null。 |
事件流
| Stream | 类型 | 说明 |
|---|---|---|
stateStream |
Stream<PlayerState> |
播放状态变化。 |
positionStream |
Stream<Duration> |
播放进度变化。 |
durationStream |
Stream<Duration> |
媒体总时长变化。 |
bufferedStream |
Stream<double> |
缓冲进度变化。 |
textureIdStream |
Stream<int?> |
Android Texture ID 变化。 |
videoSizeStream |
Stream<VideoSize> |
视频尺寸变化。 |
errorStream |
Stream<String> |
播放错误。 |
previousTrackStream |
Stream<void> |
通知栏/锁屏/耳机线控上一首事件。 |
nextTrackStream |
Stream<void> |
通知栏/锁屏/耳机线控下一首事件。 |
MediaItem
| 字段 | 类型 | 说明 |
|---|---|---|
url |
String |
媒体地址,支持 HTTP/HTTPS、本地文件、HLS、DASH、RTSP 等 VLC 支持的地址。 |
title |
String |
媒体标题,用于通知栏/锁屏显示。 |
artist |
String? |
艺术家。 |
album |
String? |
专辑。 |
coverUrl |
String? |
封面图地址。 |
isVideo |
bool |
是否为视频;默认 false。 |
headers |
Map<String, String>? |
自定义 HTTP 请求头。 |
PlayerState
| 状态 | 说明 |
|---|---|
idle |
空闲,未加载媒体。 |
loading |
加载或缓冲中。 |
playing |
播放中。 |
paused |
已暂停。 |
stopped |
已停止。 |
completed |
播放完成。 |
error |
播放错误。 |
CacheConfig
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled |
bool |
true |
是否启用缓存。 |
maxSize |
int |
500 * 1024 * 1024 |
最大缓存空间,单位字节。 |
customDirectory |
String? |
null |
自定义缓存目录;为空时使用系统缓存目录下的 omni_player_cache。 |
VideoWidget
VideoWidget({
required OmniPlayer player,
BoxFit fit = BoxFit.contain,
Color backgroundColor = Colors.black,
})
- iOS 使用
UiKitView承载 VLC drawable。 - Android 使用 Flutter
Texture显示 VLC 输出。 fit仅影响 Android Texture 的缩放方式;iOS 由原生 drawable 承载。backgroundColor默认为黑色。
缓存策略
- 仅普通 HTTP/HTTPS 点播资源参与缓存。
- HLS(
.m3u8)、DASH(.mpd)、RTSP、RTMP 等流媒体协议会自动跳过缓存。 - 首次播放仍走远端,同时后台下载;下载完成后再次播放才会命中本地缓存。
- 缓存存放在系统 Cache 目录,系统存储不足时可能被 OS 自动清理。
dispose()会刷写缓存元数据;一般无需手动处理。
使用建议
initialize()完成后再调用open()。- 同一时间只播放一个媒体;再次
open()会停止当前媒体。 - 视频场景必须设置
MediaItem.isVideo: true并在界面中放置VideoWidget。 - 纯音频请设置
isVideo: false,可减少纹理、PlatformView 和 GPU 资源占用。 - 播放列表切换建议监听
completed或nextTrackStream后由业务层调用open()。 seek()是精确跳转,关键帧间隔较大的视频可能需要更长时间完成解码。
常见问题
iOS 视频黑屏
确认 MediaItem.isVideo 为 true,并且页面中使用了 VideoWidget。iOS 视频由 VLC drawable 直接渲染,普通 Flutter 容器无法显示画面。
Android 视频黑屏
确认已经调用 initialize() 与 open(),并且 MediaItem.isVideo 为 true。Android 通过 Texture 渲染,VideoWidget 会自动监听 textureIdStream。
播放状态一直是 loading
网络资源首次缓冲可能较慢。本地文件一直 loading 时,请检查文件路径是否存在、URL 是否正确、格式是否被 VLC 支持。
上一首/下一首没有自动切换
previousTrackStream 和 nextTrackStream 只发出事件,不会自动切换媒体。业务层需要在监听器中自行调用 player.open()。
缓存命中但没有走本地文件
确认初始化时传入了 CacheConfig(enabled: true)。首次播放只会后台下载,缓存完成后下次播放才会命中。
缓冲进度看起来等于播放进度
本地文件、小文件或网络较快时,缓冲可能很快到 100%。部分流媒体协议无法精确提供完整缓冲窗口,插件会尽量估算。
iOS 无法截图视频画面
iOS 使用 VLC drawable PlatformView 渲染,GPU 内容无法被 Flutter RenderRepaintBoundary 捕获,这是平台限制。
License
详见 LICENSE。