omni_player 0.1.0 copy "omni_player: ^0.1.0" to clipboard
omni_player: ^0.1.0 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 使用 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();
  }
}
2
likes
0
points
458
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter媒体播放器插件,在Android/iOS上支持视频和音频播放和后台播放。支持MKV、MP4、HLS等.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on omni_player

Packages that implement omni_player