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.751.01.52.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 资源占用。
  • 播放列表切换建议监听 completednextTrackStream 后由业务层调用 open()
  • seek() 是精确跳转,关键帧间隔较大的视频可能需要更长时间完成解码。

常见问题

iOS 视频黑屏

确认 MediaItem.isVideotrue,并且页面中使用了 VideoWidget。iOS 视频由 VLC drawable 直接渲染,普通 Flutter 容器无法显示画面。

Android 视频黑屏

确认已经调用 initialize()open(),并且 MediaItem.isVideotrue。Android 通过 Texture 渲染,VideoWidget 会自动监听 textureIdStream

播放状态一直是 loading

网络资源首次缓冲可能较慢。本地文件一直 loading 时,请检查文件路径是否存在、URL 是否正确、格式是否被 VLC 支持。

上一首/下一首没有自动切换

previousTrackStreamnextTrackStream 只发出事件,不会自动切换媒体。业务层需要在监听器中自行调用 player.open()

缓存命中但没有走本地文件

确认初始化时传入了 CacheConfig(enabled: true)。首次播放只会后台下载,缓存完成后下次播放才会命中。

缓冲进度看起来等于播放进度

本地文件、小文件或网络较快时,缓冲可能很快到 100%。部分流媒体协议无法精确提供完整缓冲窗口,插件会尽量估算。

iOS 无法截图视频画面

iOS 使用 VLC drawable PlatformView 渲染,GPU 内容无法被 Flutter RenderRepaintBoundary 捕获,这是平台限制。

License

详见 LICENSE

Libraries

omni_player