# omni_player **Repository Path**: sirli369/omni_player ## Basic Information - **Project Name**: omni_player - **Description**: OmniPlayer 是一个跨平台 Flutter 媒体播放器插件,支持 iOS 和 Android。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-19 - **Last Updated**: 2026-03-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # OmniPlayer 播放器插件说明文档 ## 概述 OmniPlayer 是一个跨平台 Flutter 媒体播放器插件,支持 iOS 和 Android。 | 平台 | 底层引擎 | 视频渲染 | |------|----------|----------| | iOS | MobileVLCKit | Metal PlatformView(无像素拷贝) | | Android | ExoPlayer (Media3) | Flutter Texture API | > 仅支持 iOS 和 Android,不支持 macOS / Windows。 **支持格式**:MP4、MKV、MOV、AVI、MP3、AAC、FLAC、OGG、HLS(.m3u8)、DASH、RTSP 等 VLC/ExoPlayer 支持的所有格式。 --- ## 安装 在 `pubspec.yaml` 中引入插件: ```yaml dependencies: omni_player: path: ../omni_player # 或 pub.dev 地址 ``` ### iOS 配置 `ios/Runner/Info.plist` 添加: ```xml UIBackgroundModes audio NSAppTransportSecurity NSAllowsArbitraryLoads ``` ### Android 配置 `android/app/src/main/AndroidManifest.xml` 确保有以下权限(Service 声明由插件自动注册): ```xml ``` --- ## 快速开始 ### 1. 初始化 ```dart import 'package:omni_player/omni_player.dart'; final player = OmniPlayer.instance; @override void initState() { super.initState(); _initPlayer(); } Future _initPlayer() async { await player.initialize( // 可选:启用磁盘缓存(点播场景推荐开启) cacheConfig: const CacheConfig( enabled: true, maxSize: 500 * 1024 * 1024, // 500 MB ), ); } ``` ### 2. 播放媒体 ```dart await player.open( MediaItem( url: 'https://example.com/video.mp4', title: '我的视频', artist: '作者', album: '专辑', coverUrl: 'https://example.com/cover.jpg', isVideo: true, ), ); ``` ### 3. 显示视频画面 ```dart VideoWidget( player: player, fit: BoxFit.contain, backgroundColor: Colors.black, ) ``` ### 4. 释放资源 ```dart @override void dispose() { player.dispose(); super.dispose(); } ``` --- ## API 文档 ### OmniPlayer(单例) ```dart OmniPlayer.instance // 获取全局单例 ``` #### 初始化 / 释放 | 方法 | 说明 | |------|------| | `initialize({CacheConfig? cacheConfig})` | 启动播放器,订阅事件流(必须在使用前调用);传入 `cacheConfig` 可启用磁盘缓存 | | `dispose()` | 释放所有原生资源,关闭事件流,刷写未落盘的缓存元数据 | #### 播放控制 | 方法 | 说明 | |------|------| | `open(MediaItem, {autoPlay})` | 打开媒体,`autoPlay` 默认 `true` | | `play()` | 播放 | | `pause()` | 暂停 | | `stop()` | 停止并清除媒体 | | `seek(Duration)` | 跳转到指定位置 | | `setVolume(double)` | 设置音量,范围 `0.0 ~ 1.0` | | `setSpeed(double)` | 设置倍速,例如 `1.5`、`2.0` | | `setLooping(bool)` | 是否循环播放 | | `setPositionUpdateInterval(Duration)` | 设置进度回调频率,默认 500ms | #### 缓存操作 | 方法 | 说明 | |------|------| | `getCacheSize()` | 返回当前缓存占用字节数 `Future`,未启用时返回 `0` | | `getCacheSizeString()` | 返回格式化字符串 `Future`,如 `"123.4 MB"`,未启用时返回 `"0 B"` | | `clearCache()` | 清空所有缓存文件 | | `clearCacheItem(String url)` | 清除指定 URL 的缓存 | | `cacheManager` | 底层 `CacheManager?`,可访问更多进阶配置 | #### 状态属性(同步读取当前值) | 属性 | 类型 | 说明 | |------|------|------| | `state` | `PlayerState` | 当前播放状态 | | `position` | `Duration` | 当前播放位置 | | `duration` | `Duration` | 媒体总时长 | | `buffered` | `double` | 缓冲进度 `0.0 ~ 1.0` | | `textureId` | `int?` | Android Texture ID(iOS 为 null) | | `videoSize` | `VideoSize?` | 视频分辨率 | | `error` | `String?` | 最近一次错误信息 | #### 事件流(Stream) | 流 | 类型 | 说明 | |----|------|------| | `stateStream` | `Stream` | 播放状态变化 | | `positionStream` | `Stream` | 播放进度变化 | | `durationStream` | `Stream` | 时长更新(首次解析后触发) | | `bufferedStream` | `Stream` | 缓冲进度变化 | | `textureIdStream` | `Stream` | Texture ID 变化(仅 Android) | | `videoSizeStream` | `Stream` | 视频分辨率变化 | | `errorStream` | `Stream` | 播放错误信息 | | `previousTrackStream` | `Stream` | 用户点击通知栏/锁屏「上一首」 | | `nextTrackStream` | `Stream` | 用户点击通知栏/锁屏「下一首」 | --- ### MediaItem ```dart MediaItem({ required String url, // 媒体地址(HTTP/HTTPS/本地路径) required String title, // 标题(显示在通知栏/锁屏) String? artist, // 艺术家 String? album, // 专辑 String? coverUrl, // 封面图 URL(显示在通知栏/锁屏) bool isVideo = false, // true = 视频;false = 纯音频 Map? headers, // 自定义 HTTP 请求头 }) ``` --- ### PlayerState 枚举 ```dart enum PlayerState { idle, // 空闲,未加载任何媒体 loading, // 加载/缓冲中 playing, // 播放中 paused, // 已暂停 stopped, // 已停止(调用 stop() 后) completed, // 播放完成(非循环模式到达末尾) error, // 发生错误 } ``` --- ### CacheConfig ```dart CacheConfig({ bool enabled = true, // 是否启用缓存 int maxSize = 500 * 1024 * 1024, // 最大磁盘空间(字节),默认 500 MB,超出时 LRU 自动淘汰 String? customDirectory, // 自定义缓存目录,null = 系统 Cache 目录/omni_player_cache }) ``` > **缓存策略**:仅对普通 HTTP/HTTPS 点播 URL 生效,HLS(`.m3u8`)、DASH(`.mpd`)、RTSP、RTMP 等流媒体协议自动跳过缓存。 --- ### VideoWidget ```dart VideoWidget({ required OmniPlayer player, BoxFit fit = BoxFit.contain, // 视频填充模式 Color backgroundColor = Colors.black, // 背景色 }) ``` > iOS 使用 Metal PlatformView 渲染,不支持 Flutter 截图(GPU 内容无法被 `renderRepaintBoundary` 捕获)。 --- ## 完整示例 ### 示例一:播放视频 ```dart import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:omni_player/omni_player.dart'; class VideoPlayerPage extends StatefulWidget { const VideoPlayerPage({super.key}); @override State createState() => _VideoPlayerPageState(); } class _VideoPlayerPageState extends State { final _player = OmniPlayer.instance; PlayerState _state = PlayerState.idle; Duration _position = Duration.zero; Duration _duration = Duration.zero; double _buffered = 0.0; final List _subs = []; @override void initState() { super.initState(); _initPlayer(); } Future _initPlayer() async { await _player.initialize( cacheConfig: const CacheConfig( enabled: true, maxSize: 500 * 1024 * 1024, ), ); _subs.addAll([ _player.stateStream.listen((s) => setState(() => _state = s)), _player.positionStream.listen((p) => setState(() => _position = p)), _player.durationStream.listen((d) => setState(() => _duration = d)), _player.bufferedStream.listen((b) => setState(() => _buffered = b)), // 监听锁屏/通知栏上下集按钮 _player.previousTrackStream.listen((_) => _playPrevious()), _player.nextTrackStream.listen((_) => _playNext()), ]); await _player.open( const MediaItem( url: 'https://example.com/video.mp4', title: '示例视频', artist: '演示', coverUrl: 'https://example.com/cover.jpg', isVideo: true, ), ); } void _playPrevious() { /* 切换到上一个媒体 */ } void _playNext() { /* 切换到下一个媒体 */ } String _fmt(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '${d.inHours > 0 ? '${d.inHours}:' : ''}$m:$s'; } @override Widget build(BuildContext context) { // iOS 不发 textureId,只要 isVideo=true 就直接显示 VideoWidget final bool isIOS = defaultTargetPlatform == TargetPlatform.iOS; final bool hasVideo = isIOS || _player.textureId != null; return Scaffold( backgroundColor: Colors.black, body: Column( children: [ // 视频画面 AspectRatio( aspectRatio: _player.videoSize?.aspectRatio ?? 16 / 9, child: hasVideo ? VideoWidget(player: _player) : const Center(child: CircularProgressIndicator()), ), // 进度条(secondaryTrackValue 显示缓冲进度) Slider( value: _duration.inMilliseconds > 0 ? _position.inMilliseconds / _duration.inMilliseconds : 0.0, secondaryTrackValue: _buffered, onChanged: (v) => _player.seek( Duration(milliseconds: (v * _duration.inMilliseconds).toInt()), ), ), // 时间显示 Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_fmt(_position), style: const TextStyle(color: Colors.white)), Text(_fmt(_duration), style: const TextStyle(color: Colors.white)), ], ), ), // 控制按钮 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.replay_10, color: Colors.white), onPressed: () => _player.seek(_position - const Duration(seconds: 10)), ), IconButton( icon: Icon( _state == PlayerState.playing ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 40, ), onPressed: _state == PlayerState.playing ? _player.pause : _player.play, ), IconButton( icon: const Icon(Icons.forward_10, color: Colors.white), onPressed: () => _player.seek(_position + const Duration(seconds: 10)), ), ], ), ], ), ); } @override void dispose() { for (final sub in _subs) sub.cancel(); _player.dispose(); super.dispose(); } } ``` --- ### 示例二:播放音频(后台 + 锁屏控制) ```dart Future playAudio() async { final player = OmniPlayer.instance; await player.initialize(); // 监听上下首(锁屏耳机线控 / 通知栏按钮) player.previousTrackStream.listen((_) => playPrev()); player.nextTrackStream.listen((_) => playNext()); await player.open( const MediaItem( url: 'https://example.com/music.mp3', title: '轻音乐', artist: 'Artist Name', album: 'Album Name', coverUrl: 'https://example.com/cover.jpg', isVideo: false, // 纯音频,不创建视频渲染资源 ), ); } ``` --- ### 示例三:自定义 HTTP 请求头(带鉴权的私有流) ```dart await player.open( MediaItem( url: 'https://private-cdn.example.com/stream.m3u8', title: '私有直播', isVideo: true, headers: { 'Authorization': 'Bearer YOUR_TOKEN', 'Referer': 'https://example.com', }, ), ); ``` --- ### 示例四:进度回调频率控制 ```dart // 歌词同步等高精度场景,设置 100ms await player.setPositionUpdateInterval(const Duration(milliseconds: 100)); // 普通场景节省性能,设置 1000ms await player.setPositionUpdateInterval(const Duration(seconds: 1)); ``` --- ### 示例五:循环播放 + 倍速 ```dart await player.setLooping(true); await player.setSpeed(1.5); await player.open( const MediaItem( url: 'https://example.com/lesson.mp4', title: '教学视频', isVideo: true, ), ); ``` --- ### 示例六:播放列表 ```dart class PlaylistPage extends StatefulWidget { const PlaylistPage({super.key}); @override State createState() => _PlaylistPageState(); } class _PlaylistPageState extends State { final _player = OmniPlayer.instance; final _playlist = const [ MediaItem(url: 'https://example.com/ep1.mp4', title: '第 1 集', isVideo: true), MediaItem(url: 'https://example.com/ep2.mp4', title: '第 2 集', isVideo: true), MediaItem(url: 'https://example.com/ep3.mp4', title: '第 3 集', isVideo: true), ]; int _currentIndex = 0; final List _subs = []; @override void initState() { super.initState(); _initPlayer(); } Future _initPlayer() async { await _player.initialize( cacheConfig: const CacheConfig(enabled: true), ); _subs.addAll([ // 播放完成后自动播放下一集 _player.stateStream.listen((s) { if (s == PlayerState.completed) _playNext(); }), // 通知栏/锁屏上下集按钮 _player.previousTrackStream.listen((_) => _playPrev()), _player.nextTrackStream.listen((_) => _playNext()), ]); _playAt(0); } Future _playAt(int index) async { setState(() => _currentIndex = index); await _player.open(_playlist[index]); } void _playPrev() { if (_currentIndex > 0) _playAt(_currentIndex - 1); } void _playNext() { if (_currentIndex < _playlist.length - 1) _playAt(_currentIndex + 1); } @override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ AspectRatio( aspectRatio: 16 / 9, child: VideoWidget(player: _player), ), // 播放列表 Expanded( child: ListView.builder( itemCount: _playlist.length, itemBuilder: (_, i) => ListTile( title: Text(_playlist[i].title), selected: i == _currentIndex, onTap: () => _playAt(i), ), ), ), ], ), ); } @override void dispose() { for (final sub in _subs) sub.cancel(); _player.dispose(); super.dispose(); } } ``` --- ### 示例七:缓存管理 ```dart // 启用缓存(初始化时配置一次即可) await player.initialize( cacheConfig: const CacheConfig( enabled: true, maxSize: 300 * 1024 * 1024, // 300 MB ), ); // ── 查看缓存占用 ────────────────────────────── final sizeStr = await player.getCacheSizeString(); print('当前缓存:$sizeStr'); // 例如 "87.3 MB" // ── 显示在设置页 UI ─────────────────────────── Text('缓存占用:$sizeStr') // ── 清空所有缓存 ────────────────────────────── await player.clearCache(); // ── 清除单个视频缓存 ────────────────────────── await player.clearCacheItem('https://example.com/video.mp4'); // ── 动态调整最大空间 ────────────────────────── await player.cacheManager?.setMaxSize(200 * 1024 * 1024); // 改为 200 MB ``` --- ## 注意事项 ### iOS - 真机运行需要有效的开发者证书和 Provisioning Profile - 视频使用 Metal PlatformView 渲染,**不支持截图**(`renderRepaintBoundary` 无法捕获 GPU 内容) - 后台音频需在 `Info.plist` 配置 `UIBackgroundModes: [audio]` - 锁屏控制(上下首 / 播放暂停 / 进度拖拽)通过 `MPRemoteCommandCenter` 实现,无需额外配置 ### Android - 通知栏媒体控制(⏮ 播放/暂停 ⏭)由 Media3 `MediaSessionService` 自动管理 - 点击通知栏上下集按钮会触发 `previousTrackStream` / `nextTrackStream`,**不会自动切换曲目**,需业务层在监听器里调用 `player.open()` 实现 - 封面图通过 Glide 异步加载,加载完成后自动刷新通知栏(仅 HTTPS URL 有效) - Android 12+ 需要应用处于前台或拥有 `FOREGROUND_SERVICE` 权限才能持续显示通知 ### 通用 - `OmniPlayer.instance` 是全局单例,`dispose()` 后若需再次使用,需重新调用 `initialize()` - `VideoWidget` 必须与对应的 `OmniPlayer` 实例搭配使用 - `isVideo: false` 时不创建视频纹理 / PlatformView,节省内存和 GPU 资源 - 同时只能播放一个媒体;调用 `open()` 会自动停止当前媒体 - `seek()` 使用精确模式,跳转到指定时间点而非最近关键帧;对于关键帧间隔大的视频,精确 seek 需要从上一关键帧解码到目标帧,耗时略长属正常现象 ### 缓存 - 缓存仅对 HTTP/HTTPS 点播 URL 生效;HLS(`.m3u8`)、DASH(`.mpd`)、RTSP、RTMP 自动跳过 - 首次播放仍走远端网络,**后台下载完成后**再次播放才会命中缓存 - 缓存文件存放在系统 Cache 目录,系统存储不足时 **可能被 OS 自动清理**,属正常行为 - 调用 `clearCache()` 或 `clearCacheItem()` 后,下次播放该资源将重新下载并缓存 - `dispose()` 时会自动将未落盘的元数据刷写到磁盘,无需手动调用 --- ## 常见问题 **Q:iOS 视频黑屏?** A:确认 `isVideo: true` 且界面中使用了 `VideoWidget`。iOS 必须用 `VideoWidget`(PlatformView)渲染,普通 `Container` 无效。 **Q:Android 通知栏没有封面?** A:确认 `MediaItem.coverUrl` 是可公开访问的 HTTPS 图片 URL,不支持本地路径。 **Q:播放状态一直是 loading?** A:网络流媒体首次缓冲耗时较长属正常。本地文件若一直 loading,请检查文件路径和格式是否受支持。 **Q:上下首按钮点了没反应?** A:`previousTrackStream` / `nextTrackStream` 只负责将事件通知到 Flutter 层,具体的切换逻辑需要业务代码自行实现(在监听器中调用 `player.open()`)。 **Q:iOS 缓冲进度和播放进度一样?** A:小文件在快速网络下会瞬间下载完毕,缓冲进度会直接跳到 100%,这是正常行为。 **Q:后台音频暂停了?** A:iOS 需要在 `Info.plist` 添加 `UIBackgroundModes: [audio]`。Android 需确保 `PlayerService` 处于前台(插件已自动处理)。 **Q:缓存命中了但视频没有播放?** A:确认 `initialize()` 传入了 `CacheConfig(enabled: true)`,未传则缓存不会启用。Android 端首次冷启动需等待 Service 绑定完成(`initialize()` 返回后才可调用 `open()`,已在插件内部保证)。 **Q:第二次播放仍然走网络,缓存没有生效?** A:首次播放会在后台静默下载,下载完成前关闭 App 会导致缓存未写入。再次打开 App 播放一遍后即可正常缓存。 **Q:如何知道某个视频是否已缓存?** A:使用底层 API 检测:`await player.cacheManager?.isCached(url) ?? false`。 **Q:缓存文件存在哪里?** A:默认存放在系统应用缓存目录的 `omni_player_cache` 子目录下。Android 路径约为 `/data/data/{包名}/cache/omni_player_cache/`,iOS 约为 `Library/Caches/omni_player_cache/`。