omni_player 0.0.1
omni_player: ^0.0.1 copied to clipboard
Flutter媒体播放器插件,在Android/iOS上支持视频和音频播放和后台播放。支持MKV、MP4、HLS等.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:omni_player/omni_player.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OmniPlayer Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.deepPurple,
useMaterial3: true,
),
home: const PlayerPage(),
);
}
}
class PlayerPage extends StatefulWidget {
const PlayerPage({super.key});
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
late final OmniPlayer _player;
final _urlController = TextEditingController();
final _titleController = TextEditingController(text: '我的视频');
PlayerState _state = PlayerState.idle;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
double _buffered = 0.0;
double _volume = 1.0;
double _speed = 1.0;
bool _looping = false;
bool _isVideo = true;
VideoSize? _videoSize;
String? _errorMsg;
int _positionIntervalMs = 500;
final List<StreamSubscription> _subs = [];
// 内置测试地址
static const _presets = [
('MKV 视频', 'https://www.iandevlin.com/html5test/webvtt/upc-tobymansfieldday.mkv', true),
('MP4 视频', 'https://wch-sh.oss-cn-shanghai.aliyuncs.com/DEEPENG/resource/3000/L5%20%E6%95%85%E4%BA%8B/002.My%20First%20Things-%E6%88%91%E7%9A%84%E7%AC%AC%E4%B8%80%E6%89%B9%E7%89%A9%E5%93%81/062e7145786e077f83178f6d3b3f3be7.mp4', true),
('HLS 直播', 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8', true),
('MP3 音频', 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', false),
];
@override
void initState() {
super.initState();
_player = OmniPlayer.instance;
_initPlayer();
}
Future<void> _initPlayer() async {
await _player.initialize();
_subs.addAll([
_player.stateStream.listen((s) => setState(() {
_state = s;
if (s != PlayerState.error) _errorMsg = null;
})),
_player.positionStream.listen((p) => setState(() => _position = p)),
_player.durationStream.listen((d) => setState(() => _duration = d)),
_player.bufferedStream.listen((b) => setState(() => _buffered = b)),
_player.videoSizeStream.listen((s) => setState(() => _videoSize = s)),
_player.errorStream.listen((e) => setState(() => _errorMsg = e)),
_player.previousTrackStream.listen((_) => _showSnack('⏮ 上一首(通知栏/锁屏触发)')),
_player.nextTrackStream.listen((_) => _showSnack('⏭ 下一首(通知栏/锁屏触发)')),
]);
}
// ── 播放 ──────────────────────────────────────────────
Future<void> _play() async {
final url = _urlController.text.trim();
if (url.isEmpty) {
_showSnack('请输入视频/音频地址');
return;
}
setState(() {
_errorMsg = null;
_position = Duration.zero;
_duration = Duration.zero;
_buffered = 0.0;
_videoSize = null;
});
await _player.open(
MediaItem(
url: url,
title: _titleController.text.trim().isEmpty ? url : _titleController.text.trim(),
isVideo: _isVideo,
coverUrl:"https://wch-sh.oss-cn-shanghai.aliyuncs.com/DEEPENG/resource/3000/L5%20%E6%95%85%E4%BA%8B/002.My%20First%20Things-%E6%88%91%E7%9A%84%E7%AC%AC%E4%B8%80%E6%89%B9%E7%89%A9%E5%93%81/062e7145786e077f83178f6d3b3f3be7.jpg",
artist:"DeepEng",
album:"DeepEng",
),
);
}
String _fmt(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
void _showSnack(String msg) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2)));
}
// ── 状态颜色/图标 ──────────────────────────────────────
Color get _stateColor {
switch (_state) {
case PlayerState.playing:
return Colors.green;
case PlayerState.loading:
return Colors.orange;
case PlayerState.error:
return Colors.red;
case PlayerState.completed:
return Colors.blue;
default:
return Colors.grey;
}
}
String get _stateLabel {
switch (_state) {
case PlayerState.idle: return '空闲';
case PlayerState.loading: return '加载中...';
case PlayerState.playing: return '播放中';
case PlayerState.paused: return '已暂停';
case PlayerState.stopped: return '已停止';
case PlayerState.completed: return '播放完成';
case PlayerState.error: return '错误';
}
}
@override
Widget build(BuildContext context) {
// iOS 使用 PlatformView,不发 textureId,只要 _isVideo 为 true 就显示 VideoWidget
// Android/macOS/Windows 使用 Texture,需要等 textureId 到来
final bool isIOS = defaultTargetPlatform == TargetPlatform.iOS;
final hasVideo = _isVideo && (isIOS || _player.textureId != null);
return Scaffold(
appBar: AppBar(
title: const Text('OmniPlayer Demo'),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 视频区域 ──────────────────────────────────
_buildVideoArea(hasVideo),
const SizedBox(height: 12),
// ── 状态栏 ────────────────────────────────────
_buildStatusBar(),
const SizedBox(height: 16),
// ── 输入框 ────────────────────────────────────
_buildUrlInput(),
const SizedBox(height: 8),
// ── 快速选择 ──────────────────────────────────
_buildPresetChips(),
const SizedBox(height: 12),
// ── 播放类型切换 ──────────────────────────────
_buildMediaTypeSwitch(),
const SizedBox(height: 12),
// ── 进度条 ────────────────────────────────────
_buildProgressBar(),
const SizedBox(height: 8),
// ── 主控制按钮 ────────────────────────────────
_buildMainControls(),
const SizedBox(height: 16),
// ── 音量 / 速度 ───────────────────────────────
_buildVolumeRow(),
const SizedBox(height: 8),
_buildSpeedRow(),
const SizedBox(height: 8),
// ── 循环 ──────────────────────────────────────
SwitchListTile(
title: const Text('循环播放'),
value: _looping,
onChanged: (v) {
setState(() => _looping = v);
_player.setLooping(v);
},
),
// ── 进度回调频率 ───────────────────────────────
_buildIntervalRow(),
// ── 错误信息 ──────────────────────────────────
if (_errorMsg != null)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(_errorMsg!,
style: const TextStyle(color: Colors.red)),
),
],
),
),
],
),
),
);
}
Widget _buildVideoArea(bool hasVideo) {
return AspectRatio(
aspectRatio: _videoSize != null
? (_videoSize!.width / _videoSize!.height)
: 16 / 9,
child: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: hasVideo
? VideoWidget(player: _player)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isVideo ? Icons.videocam_off : Icons.music_note,
size: 64,
color: Colors.white30,
),
if (_videoSize != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${_videoSize!.width} × ${_videoSize!.height}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
),
),
),
);
}
Widget _buildStatusBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _stateColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _stateColor.withOpacity(0.3)),
),
child: Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(_stateLabel,
style: TextStyle(color: _stateColor, fontWeight: FontWeight.w500)),
if (_state == PlayerState.loading) ...[
const SizedBox(width: 8),
SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(
strokeWidth: 2, color: _stateColor,
),
),
],
const Spacer(),
if (_videoSize != null)
Text(
'${_videoSize!.width}×${_videoSize!.height}',
style: TextStyle(color: _stateColor, fontSize: 12),
),
],
),
);
}
Widget _buildUrlInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: '标题(可选)',
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.title),
),
),
const SizedBox(height: 8),
TextField(
controller: _urlController,
decoration: InputDecoration(
labelText: '视频 / 音频地址',
hintText: 'https://example.com/video.mkv',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.link),
suffixIcon: _urlController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => setState(() => _urlController.clear()),
)
: null,
),
onChanged: (_) => setState(() {}),
onSubmitted: (_) => _play(),
keyboardType: TextInputType.url,
),
],
);
}
Widget _buildPresetChips() {
return Wrap(
spacing: 8,
runSpacing: 4,
children: _presets.map((preset) {
final (label, url, isVideo) = preset;
return ActionChip(
label: Text(label, style: const TextStyle(fontSize: 12)),
avatar: Icon(
isVideo ? Icons.videocam : Icons.audiotrack,
size: 16,
),
onPressed: () {
setState(() {
_urlController.text = url;
_titleController.text = label;
_isVideo = isVideo;
});
},
);
}).toList(),
);
}
Widget _buildMediaTypeSwitch() {
return Row(
children: [
const Text('类型:'),
const SizedBox(width: 8),
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('视频'), icon: Icon(Icons.videocam)),
ButtonSegment(value: false, label: Text('音频'), icon: Icon(Icons.audiotrack)),
],
selected: {_isVideo},
onSelectionChanged: (s) => setState(() => _isVideo = s.first),
),
],
);
}
Widget _buildProgressBar() {
final total = _duration.inMilliseconds;
final current = _position.inMilliseconds.clamp(0, total > 0 ? total : 1);
return Column(
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: total > 0 ? current / total : 0.0,
secondaryTrackValue: _buffered.clamp(0.0, 1.0),
onChanged: total > 0
? (v) {
_player.seek(Duration(milliseconds: (v * total).toInt()));
}
: null,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(_position), style: const TextStyle(fontSize: 12)),
Text(_fmt(_duration), style: const TextStyle(fontSize: 12)),
],
),
),
],
);
}
Widget _buildMainControls() {
final isPlaying = _state == PlayerState.playing;
final canControl = _state != PlayerState.idle && _state != PlayerState.error;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 停止
IconButton.outlined(
icon: const Icon(Icons.stop),
iconSize: 28,
onPressed: canControl ? _player.stop : null,
),
const SizedBox(width: 12),
// 后退 10s
IconButton.outlined(
icon: const Icon(Icons.replay_10),
iconSize: 28,
onPressed: canControl
? () => _player.seek(_position - const Duration(seconds: 10))
: null,
),
const SizedBox(width: 12),
// 播放 / 暂停(主按钮)
FilledButton.icon(
onPressed: _urlController.text.trim().isNotEmpty
? () {
if (_state == PlayerState.idle ||
_state == PlayerState.stopped ||
_state == PlayerState.completed ||
_state == PlayerState.error) {
_play();
} else if (isPlaying) {
_player.pause();
} else {
_player.play();
}
}
: null,
icon: Icon(
(_state == PlayerState.idle ||
_state == PlayerState.stopped ||
_state == PlayerState.completed)
? Icons.play_arrow
: isPlaying
? Icons.pause
: Icons.play_arrow,
size: 32,
),
label: Text(
(_state == PlayerState.idle ||
_state == PlayerState.stopped ||
_state == PlayerState.completed)
? '播放'
: isPlaying
? '暂停'
: '继续',
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
const SizedBox(width: 12),
// 前进 10s
IconButton.outlined(
icon: const Icon(Icons.forward_10),
iconSize: 28,
onPressed: canControl
? () => _player.seek(_position + const Duration(seconds: 10))
: null,
),
],
);
}
Widget _buildVolumeRow() {
return Row(
children: [
const Icon(Icons.volume_down, size: 20),
Expanded(
child: Slider(
value: _volume,
onChanged: (v) {
setState(() => _volume = v);
_player.setVolume(v);
},
divisions: 20,
label: '${(_volume * 100).round()}%',
),
),
const Icon(Icons.volume_up, size: 20),
const SizedBox(width: 8),
Text('${(_volume * 100).round()}%',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
);
}
Widget _buildSpeedRow() {
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
const Text('倍速:', style: TextStyle(fontSize: 14)),
...speeds.map((s) => ChoiceChip(
label: Text('${s}x', style: const TextStyle(fontSize: 12)),
selected: _speed == s,
onSelected: (_) {
setState(() => _speed = s);
_player.setSpeed(s);
},
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)),
],
);
}
Widget _buildIntervalRow() {
const intervals = [100, 250, 500, 1000];
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
const Text('进度回调:', style: TextStyle(fontSize: 14)),
...intervals.map((ms) => ChoiceChip(
label: Text('${ms}ms', style: const TextStyle(fontSize: 12)),
selected: _positionIntervalMs == ms,
onSelected: (_) {
setState(() => _positionIntervalMs = ms);
_player.setPositionUpdateInterval(Duration(milliseconds: ms));
},
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)),
],
);
}
@override
void dispose() {
for (final sub in _subs) sub.cancel();
_urlController.dispose();
_titleController.dispose();
_player.dispose();
super.dispose();
}
}