跳转到正文
YGJoon 技术博客

Flutter 与音频视频播放桥接

发布于:
·5.5k 字 · 约 14 分钟·作者:
以音乐视频应用为引子:本地与在线媒体播放、播放列表管理、后台播放与音频焦点,MP4/HLS 等格式兼容和双端差异一次讲透
Flutter 与音频视频播放桥接文章封面
目录

引言:播放器是“周边坑”最多的功能

这个标题不是噱头,是我做完两个播放器项目后的真实感受:flutter_aj_player(视频播放器插件)和 flutter_music_163(音乐 App,见文末实战节)。播放本身——调个 initialize()、按个播放键——反而是最简单的;真正的坑全在周边:全屏转屏、手势调亮度音量、后台保活、音频焦点、锁屏控件。这篇就按“选型→环境→音频→视频→后台与焦点”的顺序,把这些周边一次讲透。

先说选型:音频 just_audio,视频 video_player

音频格式:MP3/AAC 闭眼用,FLAC 看平台脸色

音频格式这块,移动端其实没多少选择困难:MP3 和 AAC 两端通吃,随便用;M4A 是苹果系的容器,iOS 上最省心;FLAC 无损是好,但 Android 各版本支持程度不一,真要做无损先拿目标机型实测;OGG、WAV 知道有这回事就行,生产环境很少见。

功能上,播放暂停快进快退音量这些是基本盘;变速播放、均衡器是加分项;真正决定体验的是后台播放和音频会话管理——这两块我单独拆了一章讲,因为坑最多。

视频格式:MP4 和 HLS 是主流,MKV 别指望

视频比音频麻烦一个数量级。我的经验:点播用 MP4(H.264 编码最稳),直播/长视频用 HLS(自适应码率,弱网友好);MOV 是苹果系的,Android 上能播但别主动选;MKV、AVI、WebM 在移动端原生播放器支持得七零八落,真要播就得上 FFmpeg 那种重型方案——除非产品非它不可,否则直接跟产品说“转码成 MP4”。

功能上,全屏、倍速、字幕、清晰度切换、画中画(见小窗播放那篇)都是用户默认你会有的,video_player 只给了最底层的播放能力,UI 层全得自己包——我在 flutter_aj_player 里就是这么干的。

我为什么选 just_audio 而不是 audioplayers

  1. video_player——视频播放的官方插件,底层 ExoPlayer/AVPlayer,稳。但它只管“播”,控制 UI、转屏、手势全得自己写。
  2. just_audio——我的音频默认选择。选它不选 audioplayers 的原因很实在:just_audio 配 audio_session,后台播放、来电暂停、耳机拔出暂停、锁屏控件这套是完整打通的(flutter_music_163 里验证过)。
  3. audioplayers——API 更简单,播个提示音、短音效很合适;但要做正经音乐 App,它的后台和焦点处理不如 just_audio 省心。
  4. 自定义平台通道——除非你要做插件(比如我的 flutter_aj_player),否则别碰。

一句话:短音效用 audioplayers,正经播放器音频用 just_audio + audio_session,视频用 video_player 自己包 UI。

环境和权限:后台播放那几行配置最容易漏

依赖:音频视频这几件套

dependencies:
  flutter:
    sdk: flutter
  video_player: ^2.8.1
  just_audio: ^0.9.36
  audio_session: ^0.1.16
  rxdart: ^0.27.7
  permission_handler: ^11.0.1

manifest 和 Info.plist:后台模式别漏勾

Android 权限配置(android/app/src/main/AndroidManifest.xml)

<!-- 网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- 存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="28" />

<!-- Android 13+ 需要这些权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- 后台播放权限 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<!-- 音频焦点 -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

iOS 权限配置(ios/Runner/Info.plist)

<!-- 后台播放权限 -->
<key>UIBackgroundModes</key>
<array>
    <string>audio</string>
</array>

<!-- 网络权限 -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

权限申请集中管,存储权限按 Android 版本分两套

import 'package:permission_handler/permission_handler.dart';

class MediaPermissionManager {
  static Future<bool> requestAudioPermissions() async {
    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;
      final sdkInt = androidInfo.version.sdkInt;

      if (sdkInt >= 33) {
        // Android 13+
        final audio = await Permission.audio.request();
        return audio.isGranted;
      } else {
        // Android 12及以下
        final storage = await Permission.storage.request();
        return storage.isGranted;
      }
    } else {
      // iOS不需要特殊权限
      return true;
    }
  }

  static Future<bool> requestVideoPermissions() async {
    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;
      final sdkInt = androidInfo.version.sdkInt;

      if (sdkInt >= 33) {
        // Android 13+
        final video = await Permission.videos.request();
        return video.isGranted;
      } else {
        // Android 12及以下
        final storage = await Permission.storage.request();
        return storage.isGranted;
      }
    } else {
      // iOS不需要特殊权限
      return true;
    }
  }

  static Future<bool> checkAudioPermissions() async {
    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;
      final sdkInt = androidInfo.version.sdkInt;

      if (sdkInt >= 33) {
        return await Permission.audio.isGranted;
      } else {
        return await Permission.storage.isGranted;
      }
    } else {
      return true;
    }
  }

  static Future<bool> checkVideoPermissions() async {
    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;
      final sdkInt = androidInfo.version.sdkInt;

      if (sdkInt >= 33) {
        return await Permission.videos.isGranted;
      } else {
        return await Permission.storage.isGranted;
      }
    } else {
      return true;
    }
  }

  static Future<void> openSettings() async {
    await openAppSettings();
  }
}

音频:播放器管理器的写法

AudioPlayer 封装:播放列表、切歌、seek 一次到位

import 'package:just_audio/just_audio.dart';
import 'package:audio_session/audio_session.dart';

class AudioPlayerManager {
  static final AudioPlayerManager _instance = AudioPlayerManager._internal();
  factory AudioPlayerManager() => _instance;
  AudioPlayerManager._internal();

  final AudioPlayer _player = AudioPlayer();
  final List<AudioSource> _playlist = [];
  int _currentIndex = 0;
  bool _isInitialized = false;

  AudioPlayer get player => _player;
  List<AudioSource> get playlist => List.unmodifiable(_playlist);
  int get currentIndex => _currentIndex;
  bool get isInitialized => _isInitialized;

  Stream<PlayerState> get playerStateStream => _player.playerStateStream;
  Stream<Duration?> get positionStream => _player.positionStream;
  Stream<Duration?> get durationStream => _player.durationStream;
  Stream<SequenceState?> get sequenceStateStream => _player.sequenceStateStream;

  Future<void> initialize() async {
    try {
      // 配置音频会话
      final session = await AudioSession.instance;
      await session.configure(const AudioSessionConfiguration.speech());

      // 设置播放器属性
      await _player.setLoopMode(LoopMode.off);
      await _player.setShuffleModeEnabled(false);

      _isInitialized = true;
    } catch (e) {
      throw AudioException('音频播放器初始化失败:${e.toString()}');
    }
  }

  Future<void> loadPlaylist(List<AudioSource> sources) async {
    try {
      _playlist.clear();
      _playlist.addAll(sources);
      _currentIndex = 0;

      await _player.setAudioSource(
        ConcatenatingAudioSource(children: _playlist),
        initialIndex: _currentIndex,
      );
    } catch (e) {
      throw AudioException('加载播放列表失败:${e.toString()}');
    }
  }

  Future<void> loadSingleAudio(AudioSource source) async {
    try {
      _playlist.clear();
      _playlist.add(source);
      _currentIndex = 0;

      await _player.setAudioSource(source);
    } catch (e) {
      throw AudioException('加载音频失败:${e.toString()}');
    }
  }

  Future<void> play() async {
    try {
      await _player.play();
    } catch (e) {
      throw AudioException('播放失败:${e.toString()}');
    }
  }

  Future<void> pause() async {
    try {
      await _player.pause();
    } catch (e) {
      throw AudioException('暂停失败:${e.toString()}');
    }
  }

  Future<void> stop() async {
    try {
      await _player.stop();
    } catch (e) {
      throw AudioException('停止失败:${e.toString()}');
    }
  }

  Future<void> seek(Duration position) async {
    try {
      await _player.seek(position);
    } catch (e) {
      throw AudioException('跳转失败:${e.toString()}');
    }
  }

  Future<void> next() async {
    try {
      await _player.seekToNext();
      _currentIndex = (_currentIndex + 1) % _playlist.length;
    } catch (e) {
      throw AudioException('下一首失败:${e.toString()}');
    }
  }

  Future<void> previous() async {
    try {
      await _player.seekToPrevious();
      _currentIndex = (_currentIndex - 1 + _playlist.length) % _playlist.length;
    } catch (e) {
      throw AudioException('上一首失败:${e.toString()}');
    }
  }

  Future<void> setVolume(double volume) async {
    try {
      await _player.setVolume(volume.clamp(0.0, 1.0));
    } catch (e) {
      throw AudioException('设置音量失败:${e.toString()}');
    }
  }

  Future<void> setSpeed(double speed) async {
    try {
      await _player.setSpeed(speed.clamp(0.5, 2.0));
    } catch (e) {
      throw AudioException('设置播放速度失败:${e.toString()}');
    }
  }

  Future<void> setLoopMode(LoopMode mode) async {
    try {
      await _player.setLoopMode(mode);
    } catch (e) {
      throw AudioException('设置循环模式失败:${e.toString()}');
    }
  }

  Future<void> setShuffleModeEnabled(bool enabled) async {
    try {
      await _player.setShuffleModeEnabled(enabled);
    } catch (e) {
      throw AudioException('设置随机播放失败:${e.toString()}');
    }
  }

  Future<void> addToPlaylist(AudioSource source) async {
    try {
      _playlist.add(source);
      await _player.setAudioSource(
        ConcatenatingAudioSource(children: _playlist),
        initialIndex: _currentIndex,
      );
    } catch (e) {
      throw AudioException('添加到播放列表失败:${e.toString()}');
    }
  }

  Future<void> removeFromPlaylist(int index) async {
    try {
      if (index < 0 || index >= _playlist.length) {
        throw AudioException('索引超出范围');
      }

      _playlist.removeAt(index);

      if (index < _currentIndex) {
        _currentIndex--;
      } else if (index == _currentIndex && _currentIndex >= _playlist.length) {
        _currentIndex = _playlist.length - 1;
      }

      await _player.setAudioSource(
        ConcatenatingAudioSource(children: _playlist),
        initialIndex: _currentIndex,
      );
    } catch (e) {
      throw AudioException('从播放列表移除失败:${e.toString()}');
    }
  }

  Future<void> clearPlaylist() async {
    try {
      _playlist.clear();
      _currentIndex = 0;
      await _player.setAudioSource(null);
    } catch (e) {
      throw AudioException('清空播放列表失败:${e.toString()}');
    }
  }

  Future<void> playAtIndex(int index) async {
    try {
      if (index < 0 || index >= _playlist.length) {
        throw AudioException('索引超出范围');
      }

      _currentIndex = index;
      await _player.seek(Duration.zero, index: index);
      await _player.play();
    } catch (e) {
      throw AudioException('播放指定索引失败:${e.toString()}');
    }
  }

  AudioSource? getCurrentAudio() {
    if (_currentIndex >= 0 && _currentIndex < _playlist.length) {
      return _playlist[_currentIndex];
    }
    return null;
  }

  Future<AudioMetadata?> getCurrentMetadata() async {
    final currentAudio = getCurrentAudio();
    if (currentAudio == null) return null;

    try {
      final sequenceState = await _player.sequenceState.first;
      final currentSource = sequenceState?.currentSource;

      if (currentSource != null) {
        return AudioMetadata(
          title: currentSource.tag.title ?? '未知标题',
          artist: currentSource.tag.artist ?? '未知艺术家',
          album: currentSource.tag.album ?? '未知专辑',
          duration: currentSource.tag.duration ?? Duration.zero,
          artwork: currentSource.tag.artwork,
        );
      }

      return null;
    } catch (e) {
      return null;
    }
  }

  Future<void> dispose() async {
    try {
      await _player.dispose();
      _isInitialized = false;
    } catch (e) {
      throw AudioException('释放播放器失败:${e.toString()}');
    }
  }
}

class AudioException implements Exception {
  final String message;
  final String? code;

  AudioException(this.message, {this.code});

  @override
  String toString() {
    return 'AudioException: $message${code != null ? ' (Code: $code)' : ''}';
  }
}

class AudioMetadata {
  final String title;
  final String artist;
  final String album;
  final Duration duration;
  final Uint8List? artwork;

  AudioMetadata({
    required this.title,
    required this.artist,
    required this.album,
    required this.duration,
    this.artwork,
  });

  String get formattedDuration {
    final minutes = duration.inMinutes;
    final seconds = duration.inSeconds % 60;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
  }
}

音频 UI:进度条和播放按钮,状态驱动 UI

class AudioPlayerPage extends StatefulWidget {
  @override
  _AudioPlayerPageState createState() => _AudioPlayerPageState();
}

class _AudioPlayerPageState extends State<AudioPlayerPage> {
  final AudioPlayerManager _audioManager = AudioPlayerManager();
  PlayerState? _playerState;
  Duration? _position;
  Duration? _duration;
  AudioMetadata? _metadata;
  double _volume = 1.0;
  double _speed = 1.0;
  LoopMode _loopMode = LoopMode.off;
  bool _isShuffleModeEnabled = false;

  @override
  void initState() {
    super.initState();
    _initializeAudioPlayer();
  }

  @override
  void dispose() {
    _audioManager.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('音频播放器'),
        actions: [
          IconButton(
            icon: Icon(Icons.playlist_play),
            onPressed: _showPlaylist,
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            _buildAlbumArt(),
            SizedBox(height: 24),
            _buildTrackInfo(),
            SizedBox(height: 24),
            _buildProgressBar(),
            SizedBox(height: 24),
            _buildPlaybackControls(),
            SizedBox(height: 24),
            _buildAdvancedControls(),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _loadSampleAudio,
        child: Icon(Icons.add),
      ),
    );
  }

  Widget _buildAlbumArt() {
    return Container(
      width: 200,
      height: 200,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        color: Colors.grey[300],
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.2),
            blurRadius: 10,
            offset: Offset(0, 4),
          ),
        ],
      ),
      child: _metadata?.artwork != null
          ? ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Image.memory(
                _metadata!.artwork!,
                fit: BoxFit.cover,
              ),
            )
          : Icon(
              Icons.music_note,
              size: 80,
              color: Colors.grey[600],
            ),
    );
  }

  Widget _buildTrackInfo() {
    return Column(
      children: [
        Text(
          _metadata?.title ?? '未加载音频',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
          textAlign: TextAlign.center,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
        SizedBox(height: 8),
        Text(
          '${_metadata?.artist ?? '未知艺术家'} • ${_metadata?.album ?? '未知专辑'}',
          style: TextStyle(
            fontSize: 16,
            color: Colors.grey[600],
          ),
          textAlign: TextAlign.center,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
      ],
    );
  }

  Widget _buildProgressBar() {
    return Column(
      children: [
        SliderTheme(
          data: SliderTheme.of(context).copyWith(
            thumbShape: RoundSliderThumbShape(enabledThumbRadius: 8),
            trackHeight: 4,
          ),
          child: Slider(
            value: _position?.inMilliseconds.toDouble() ?? 0.0,
            max: _duration?.inMilliseconds.toDouble() ?? 0.0,
            onChanged: (value) {
              _audioManager.seek(Duration(milliseconds: value.toInt()));
            },
          ),
        ),
        SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(_formatDuration(_position)),
            Text(_formatDuration(_duration)),
          ],
        ),
      ],
    );
  }

  Widget _buildPlaybackControls() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        IconButton(
          icon: Icon(Icons.shuffle),
          onPressed: _toggleShuffleMode,
          color: _isShuffleModeEnabled ? Theme.of(context).primaryColor : null,
        ),
        IconButton(
          icon: Icon(Icons.skip_previous),
          onPressed: _audioManager.previous,
          iconSize: 40,
        ),
        Container(
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: Theme.of(context).primaryColor,
          ),
          child: IconButton(
            icon: Icon(
              _playerState?.playing == true ? Icons.pause : Icons.play_arrow,
              color: Colors.white,
            ),
            onPressed: _togglePlayback,
            iconSize: 40,
          ),
        ),
        IconButton(
          icon: Icon(Icons.skip_next),
          onPressed: _audioManager.next,
          iconSize: 40,
        ),
        IconButton(
          icon: Icon(_getLoopModeIcon()),
          onPressed: _toggleLoopMode,
          color: _loopMode != LoopMode.off ? Theme.of(context).primaryColor : null,
        ),
      ],
    );
  }

  Widget _buildAdvancedControls() {
    return Column(
      children: [
        Row(
          children: [
            Icon(Icons.volume_up),
            SizedBox(width: 12),
            Expanded(
              child: Slider(
                value: _volume,
                onChanged: (value) {
                  setState(() {
                    _volume = value;
                  });
                  _audioManager.setVolume(value);
                },
              ),
            ),
            Text('${(_volume * 100).toInt()}%'),
          ],
        ),
        SizedBox(height: 16),
        Row(
          children: [
            Icon(Icons.speed),
            SizedBox(width: 12),
            Expanded(
              child: Slider(
                value: _speed,
                min: 0.5,
                max: 2.0,
                divisions: 3,
                onChanged: (value) {
                  setState(() {
                    _speed = value;
                  });
                  _audioManager.setSpeed(value);
                },
              ),
            ),
            Text('${_speed}x'),
          ],
        ),
      ],
    );
  }

  IconData _getLoopModeIcon() {
    switch (_loopMode) {
      case LoopMode.off:
        return Icons.loop;
      case LoopMode.one:
        return Icons.repeat_one;
      case LoopMode.all:
        return Icons.repeat;
    }
  }

  String _formatDuration(Duration? duration) {
    if (duration == null) return '00:00';

    final minutes = duration.inMinutes;
    final seconds = duration.inSeconds % 60;
    return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
  }

  Future<void> _initializeAudioPlayer() async {
    try {
      await _audioManager.initialize();

      // 监听播放状态
      _audioManager.playerStateStream.listen((state) {
        setState(() {
          _playerState = state;
        });
      });

      // 监听播放位置
      _audioManager.positionStream.listen((position) {
        setState(() {
          _position = position;
        });
      });

      // 监听音频时长
      _audioManager.durationStream.listen((duration) {
        setState(() {
          _duration = duration;
        });
      });

      // 监听当前播放的音频
      _audioManager.sequenceStateStream.listen((_) async {
        final metadata = await _audioManager.getCurrentMetadata();
        setState(() {
          _metadata = metadata;
        });
      });
    } catch (e) {
      _showErrorDialog('初始化失败', e.toString());
    }
  }

  void _togglePlayback() async {
    try {
      if (_playerState?.playing == true) {
        await _audioManager.pause();
      } else {
        await _audioManager.play();
      }
    } catch (e) {
      _showErrorDialog('播放失败', e.toString());
    }
  }

  void _toggleLoopMode() async {
    LoopMode nextMode;
    switch (_loopMode) {
      case LoopMode.off:
        nextMode = LoopMode.one;
        break;
      case LoopMode.one:
        nextMode = LoopMode.all;
        break;
      case LoopMode.all:
        nextMode = LoopMode.off;
        break;
    }

    try {
      await _audioManager.setLoopMode(nextMode);
      setState(() {
        _loopMode = nextMode;
      });
    } catch (e) {
      _showErrorDialog('设置循环模式失败', e.toString());
    }
  }

  void _toggleShuffleMode() async {
    try {
      final enabled = !_isShuffleModeEnabled;
      await _audioManager.setShuffleModeEnabled(enabled);
      setState(() {
        _isShuffleModeEnabled = enabled;
      });
    } catch (e) {
      _showErrorDialog('设置随机播放失败', e.toString());
    }
  }

  Future<void> _loadSampleAudio() async {
    try {
      final hasPermission = await MediaPermissionManager.checkAudioPermissions();
      if (!hasPermission) {
        final granted = await MediaPermissionManager.requestAudioPermissions();
        if (!granted) {
          _showPermissionDialog();
          return;
        }
      }

      // 加载示例音频
      final audioSource = AudioSource.uri(
        Uri.parse('https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'),
        tag: MediaItem(
          id: 'sample_1',
          title: '示例音频',
          artist: 'SoundHelix',
          album: '示例专辑',
        ),
      );

      await _audioManager.loadSingleAudio(audioSource);
    } catch (e) {
      _showErrorDialog('加载音频失败', e.toString());
    }
  }

  void _showPlaylist() {
    showModalBottomSheet(
      context: context,
      builder: (context) => PlaylistPage(audioManager: _audioManager),
    );
  }

  void _showErrorDialog(String title, String message) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('确定'),
          ),
        ],
      ),
    );
  }

  void _showPermissionDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('需要权限'),
        content: Text('应用需要音频权限来播放媒体文件'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              MediaPermissionManager.openSettings();
            },
            child: Text('去设置'),
          ),
        ],
      ),
    );
  }
}

视频:controller 初始化只是第一步

VideoPlayerController:初始化、监听、释放一个不能少

import 'package:video_player/video_player.dart';

class VideoPlayerManager {
  VideoPlayerController? _controller;
  bool _isInitialized = false;
  bool _isPlaying = false;
  Duration _position = Duration.zero;
  Duration _duration = Duration.zero;
  double _volume = 1.0;
  double _playbackSpeed = 1.0;

  VideoPlayerController? get controller => _controller;
  bool get isInitialized => _isInitialized;
  bool get isPlaying => _isPlaying;
  Duration get position => _position;
  Duration get duration => _duration;
  double get volume => _volume;
  double get playbackSpeed => _playbackSpeed;

  Future<void> initialize(String dataSource) async {
    try {
      _controller = VideoPlayerController.network(dataSource);

      await _controller!.initialize();

      // 监听播放状态
      _controller!.addListener(_onControllerUpdate);

      _isInitialized = true;
    } catch (e) {
      throw VideoException('视频播放器初始化失败:${e.toString()}');
    }
  }

  Future<void> initializeFromFile(File file) async {
    try {
      _controller = VideoPlayerController.file(file);

      await _controller!.initialize();

      _controller!.addListener(_onControllerUpdate);

      _isInitialized = true;
    } catch (e) {
      throw VideoException('视频播放器初始化失败:${e.toString()}');
    }
  }

  void _onControllerUpdate() {
    if (_controller == null) return;

    setState(() {
      _isPlaying = _controller!.value.isPlaying;
      _position = _controller!.value.position;
      _duration = _controller!.value.duration ?? Duration.zero;
      _volume = _controller!.value.volume;
      _playbackSpeed = _controller!.value.playbackSpeed;
    });
  }

  void setState(VoidCallback fn) {
    fn();
    // 这里可以添加状态更新通知
  }

  Future<void> play() async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.play();
    } catch (e) {
      throw VideoException('播放失败:${e.toString()}');
    }
  }

  Future<void> pause() async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.pause();
    } catch (e) {
      throw VideoException('暂停失败:${e.toString()}');
    }
  }

  Future<void> stop() async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.pause();
      await _controller!.seekTo(Duration.zero);
    } catch (e) {
      throw VideoException('停止失败:${e.toString()}');
    }
  }

  Future<void> seekTo(Duration position) async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.seekTo(position);
    } catch (e) {
      throw VideoException('跳转失败:${e.toString()}');
    }
  }

  Future<void> setVolume(double volume) async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.setVolume(volume.clamp(0.0, 1.0));
    } catch (e) {
      throw VideoException('设置音量失败:${e.toString()}');
    }
  }

  Future<void> setPlaybackSpeed(double speed) async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.setPlaybackSpeed(speed.clamp(0.25, 2.0));
    } catch (e) {
      throw VideoException('设置播放速度失败:${e.toString()}');
    }
  }

  Future<void> setLooping(bool looping) async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.setLooping(looping);
    } catch (e) {
      throw VideoException('设置循环播放失败:${e.toString()}');
    }
  }

  Future<void> enterFullscreen() async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.enterFullscreen();
    } catch (e) {
      throw VideoException('进入全屏失败:${e.toString()}');
    }
  }

  Future<void> exitFullscreen() async {
    if (_controller == null || !_isInitialized) {
      throw VideoException('播放器未初始化');
    }

    try {
      await _controller!.exitFullscreen();
    } catch (e) {
      throw VideoException('退出全屏失败:${e.toString()}');
    }
  }

  Future<void> dispose() async {
    try {
      await _controller?.dispose();
      _controller = null;
      _isInitialized = false;
    } catch (e) {
      throw VideoException('释放播放器失败:${e.toString()}');
    }
  }

  String get formattedPosition {
    return _formatDuration(_position);
  }

  String get formattedDuration {
    return _formatDuration(_duration);
  }

  double get progress {
    if (_duration.inMilliseconds == 0) return 0.0;
    return _position.inMilliseconds / _duration.inMilliseconds;
  }

  String _formatDuration(Duration duration) {
    final hours = duration.inHours;
    final minutes = duration.inMinutes % 60;
    final seconds = duration.inSeconds % 60;

    if (hours > 0) {
      return '${hours.toString().padLeft(2, '0')}:'
             '${minutes.toString().padLeft(2, '0')}:'
             '${seconds.toString().padLeft(2, '0')}';
    } else {
      return '${minutes.toString().padLeft(2, '0')}:'
             '${seconds.toString().padLeft(2, '0')}';
    }
  }
}

class VideoException implements Exception {
  final String message;
  final String? code;

  VideoException(this.message, {this.code});

  @override
  String toString() {
    return 'VideoException: $message${code != null ? ' (Code: $code)' : ''}';
  }
}

视频 UI:手势和控制层,我直接用的现成插件

class VideoPlayerPage extends StatefulWidget {
  final String? videoUrl;
  final File? videoFile;

  const VideoPlayerPage({
    this.videoUrl,
    this.videoFile,
  });

  @override
  _VideoPlayerPageState createState() => _VideoPlayerPageState();
}

class _VideoPlayerPageState extends State<VideoPlayerPage> {
  final VideoPlayerManager _videoManager = VideoPlayerManager();
  bool _showControls = true;
  Timer? _hideControlsTimer;

  @override
  void initState() {
    super.initState();
    _initializeVideoPlayer();
  }

  @override
  void dispose() {
    _hideControlsTimer?.cancel();
    _videoManager.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: GestureDetector(
        onTap: _toggleControls,
        child: Stack(
          children: [
            // 视频播放器
            Center(
              child: _buildVideoPlayer(),
            ),

            // 控制面板
            if (_showControls)
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                child: Container(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter,
                      colors: [
                        Colors.black.withOpacity(0.7),
                        Colors.transparent,
                        Colors.transparent,
                        Colors.black.withOpacity(0.7),
                      ],
                      stops: [0.0, 0.2, 0.8, 1.0],
                    ),
                  ),
                  child: Column(
                    children: [
                      _buildTopControls(),
                      Spacer(),
                      _buildBottomControls(),
                    ],
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildVideoPlayer() {
    if (!_videoManager.isInitialized) {
      return Container(
        width: double.infinity,
        height: double.infinity,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              CircularProgressIndicator(color: Colors.white),
              SizedBox(height: 16),
              Text(
                '正在加载视频...',
                style: TextStyle(color: Colors.white),
              ),
            ],
          ),
        ),
      );
    }

    return AspectRatio(
      aspectRatio: _videoManager.controller!.value.aspectRatio,
      child: VideoPlayer(_videoManager.controller!),
    );
  }

  Widget _buildTopControls() {
    return SafeArea(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          children: [
            IconButton(
              icon: Icon(Icons.arrow_back, color: Colors.white),
              onPressed: () => Navigator.pop(context),
            ),
            Spacer(),
            IconButton(
              icon: Icon(Icons.fullscreen, color: Colors.white),
              onPressed: _toggleFullscreen,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildBottomControls() {
    return SafeArea(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            // 进度条
            Row(
              children: [
                Text(
                  _videoManager.formattedPosition,
                  style: TextStyle(color: Colors.white, fontSize: 12),
                ),
                SizedBox(width: 12),
                Expanded(
                  child: SliderTheme(
                    data: SliderTheme.of(context).copyWith(
                      thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6),
                      trackHeight: 3,
                      thumbColor: Colors.white,
                      activeTrackColor: Colors.white,
                      inactiveTrackColor: Colors.white.withOpacity(0.3),
                    ),
                    child: Slider(
                      value: _videoManager.progress,
                      onChanged: (value) {
                        final position = Duration(
                          milliseconds: (value * _videoManager.duration.inMilliseconds).toInt(),
                        );
                        _videoManager.seekTo(position);
                      },
                    ),
                  ),
                ),
                SizedBox(width: 12),
                Text(
                  _videoManager.formattedDuration,
                  style: TextStyle(color: Colors.white, fontSize: 12),
                ),
              ],
            ),
            SizedBox(height: 16),
            // 播放控制
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                IconButton(
                  icon: Icon(Icons.replay_10, color: Colors.white),
                  onPressed: _rewind10Seconds,
                ),
                IconButton(
                  icon: Icon(
                    _videoManager.isPlaying ? Icons.pause : Icons.play_arrow,
                    color: Colors.white,
                    size: 40,
                  ),
                  onPressed: _togglePlayback,
                ),
                IconButton(
                  icon: Icon(Icons.forward_10, color: Colors.white),
                  onPressed: _forward10Seconds,
                ),
              ],
            ),
            SizedBox(height: 16),
            // 高级控制
            Row(
              children: [
                Icon(Icons.volume_up, color: Colors.white, size: 20),
                SizedBox(width: 8),
                Expanded(
                  child: SliderTheme(
                    data: SliderTheme.of(context).copyWith(
                      thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4),
                      trackHeight: 2,
                      thumbColor: Colors.white,
                      activeTrackColor: Colors.white,
                      inactiveTrackColor: Colors.white.withOpacity(0.3),
                    ),
                    child: Slider(
                      value: _videoManager.volume,
                      onChanged: (value) {
                        _videoManager.setVolume(value);
                      },
                    ),
                  ),
                ),
                SizedBox(width: 8),
                PopupMenuButton<String>(
                  icon: Icon(Icons.speed, color: Colors.white),
                  onSelected: _setPlaybackSpeed,
                  itemBuilder: (context) => [
                    PopupMenuItem(
                      value: '0.5',
                      child: Text('0.5x'),
                    ),
                    PopupMenuItem(
                      value: '0.75',
                      child: Text('0.75x'),
                    ),
                    PopupMenuItem(
                      value: '1.0',
                      child: Text('1.0x'),
                    ),
                    PopupMenuItem(
                      value: '1.25',
                      child: Text('1.25x'),
                    ),
                    PopupMenuItem(
                      value: '1.5',
                      child: Text('1.5x'),
                    ),
                    PopupMenuItem(
                      value: '2.0',
                      child: Text('2.0x'),
                    ),
                  ],
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _initializeVideoPlayer() async {
    try {
      if (widget.videoUrl != null) {
        await _videoManager.initialize(widget.videoUrl!);
      } else if (widget.videoFile != null) {
        await _videoManager.initializeFromFile(widget.videoFile!);
      } else {
        throw VideoException('未提供视频源');
      }

      setState(() {});

      // 开始播放
      await _videoManager.play();
    } catch (e) {
      _showErrorDialog('初始化失败', e.toString());
    }
  }

  void _toggleControls() {
    setState(() {
      _showControls = !_showControls;
    });

    if (_showControls) {
      _hideControlsTimer?.cancel();
      _hideControlsTimer = Timer(Duration(seconds: 3), () {
        if (mounted) {
          setState(() {
            _showControls = false;
          });
        }
      });
    }
  }

  void _togglePlayback() async {
    try {
      if (_videoManager.isPlaying) {
        await _videoManager.pause();
      } else {
        await _videoManager.play();
      }
    } catch (e) {
      _showErrorDialog('播放失败', e.toString());
    }
  }

  void _rewind10Seconds() async {
    final newPosition = _videoManager.position - Duration(seconds: 10);
    final clampedPosition = newPosition < Duration.zero
        ? Duration.zero
        : newPosition;
    await _videoManager.seekTo(clampedPosition);
  }

  void _forward10Seconds() async {
    final newPosition = _videoManager.position + Duration(seconds: 10);
    final clampedPosition = newPosition > _videoManager.duration
        ? _videoManager.duration
        : newPosition;
    await _videoManager.seekTo(clampedPosition);
  }

  void _setPlaybackSpeed(String speed) async {
    await _videoManager.setPlaybackSpeed(double.parse(speed));
  }

  Future<void> _toggleFullscreen() async {
    try {
      if (_videoManager.controller?.value.isFullScreen == true) {
        await _videoManager.exitFullscreen();
      } else {
        await _videoManager.enterFullscreen();
      }
    } catch (e) {
      _showErrorDialog('全屏切换失败', e.toString());
    }
  }

  void _showErrorDialog(String title, String message) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('确定'),
          ),
        ],
      ),
    );
  }
}

后台播放和音频焦点:被用户骂过才知道重要

后台播放:服务保活和通知栏控制

import 'package:audio_service/audio_service.dart';

class BackgroundAudioService {
  static Future<void> initialize() async {
    final audioServiceHandler = AudioServiceHandler();

    await AudioService.init(
      builder: () => audioServiceHandler,
      config: AudioServiceConfig(
        androidNotificationChannelId: 'com.example.audio_service.audio',
        androidNotificationChannelName: 'Audio Service',
        androidNotificationOngoing: true,
        androidStopForegroundOnPause: true,
        androidNotificationIcon: 'mipmap/ic_launcher',
        androidEnableQueue: true,
        androidNotificationColor: 0xFF2196F3,
        notificationColor: Color(0xFF2196F3),
        artDownscaleWidth: 600,
        artDownscaleHeight: 600,
      ),
    );
  }
}

class AudioServiceHandler extends BaseAudioHandler with QueueHandler {
  final AudioPlayerManager _audioManager = AudioPlayerManager();

  AudioServiceHandler() {
    _listenToPlaybackEvents();
  }

  @override
  Future<void> play() async {
    if (queue.value.isNotEmpty) {
      final mediaItem = queue.value[mediaItem.value?.index ?? 0];
      await _audioManager.playAtIndex(mediaItem.index ?? 0);
      mediaItem.add(mediaItem);
    }
  }

  @override
  Future<void> pause() async {
    await _audioManager.pause();
  }

  @override
  Future<void> stop() async {
    await _audioManager.stop();
    await super.stop();
  }

  @override
  Future<void> seek(Duration position) async {
    await _audioManager.seek(position);
  }

  @override
  Future<void> skipToNext() async {
    await _audioManager.next();
    final currentIndex = _audioManager.currentIndex;
    if (currentIndex < queue.value.length) {
      mediaItem.add(queue.value[currentIndex]);
    }
  }

  @override
  Future<void> skipToPrevious() async {
    await _audioManager.previous();
    final currentIndex = _audioManager.currentIndex;
    if (currentIndex < queue.value.length) {
      mediaItem.add(queue.value[currentIndex]);
    }
  }

  @override
  Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
    LoopMode loopMode;
    switch (repeatMode) {
      case AudioServiceRepeatMode.none:
        loopMode = LoopMode.off;
        break;
      case AudioServiceRepeatMode.one:
        loopMode = LoopMode.one;
        break;
      case AudioServiceRepeatMode.all:
      case AudioServiceRepeatMode.group:
        loopMode = LoopMode.all;
        break;
    }

    await _audioManager.setLoopMode(loopMode);
    repeatMode.add(repeatMode);
  }

  @override
  Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
    final enabled = shuffleMode == AudioServiceShuffleMode.all;
    await _audioManager.setShuffleModeEnabled(enabled);
    shuffleMode.add(shuffleMode);
  }

  void _listenToPlaybackEvents() {
    _audioManager.playerStateStream.listen((state) {
      playbackState.add(_mapPlayerState(state));
    });

    _audioManager.positionStream.listen((position) {
      playbackState.add(playbackState.value.copyWith(
        updatePosition: position,
      ));
    });

    _audioManager.sequenceStateStream.listen((sequenceState) async {
      if (sequenceState?.currentSource != null) {
        final metadata = await _audioManager.getCurrentMetadata();
        if (metadata != null) {
          final mediaItem = MediaItem(
            id: sequenceState!.currentIndex.toString(),
            title: metadata.title,
            artist: metadata.artist,
            album: metadata.album,
            duration: metadata.duration,
            artUri: metadata.artwork != null
                ? Uri.parse('data:image/jpeg;base64,${base64Encode(metadata.artwork!)}')
                : null,
          );

          queue.add([mediaItem]);
          mediaItem.add(mediaItem);
        }
      }
    });
  }

  PlaybackState _mapPlayerState(PlayerState playerState) {
    return PlaybackState(
      controls: [
        MediaControl.skipToPrevious,
        MediaControl.playPause,
        MediaControl.skipToNext,
      ],
      systemActions: const {
        MediaAction.seek,
        MediaAction.seekForward,
        MediaAction.seekBackward,
      },
      androidCompactActionIndices: const [0, 1, 2],
      processingState: const {
        ProcessingState.idle: AudioProcessingState.idle,
        ProcessingState.loading: AudioProcessingState.loading,
        ProcessingState.buffering: AudioProcessingState.buffering,
        ProcessingState.ready: AudioProcessingState.ready,
        ProcessingState.completed: AudioProcessingState.completed,
      }[playerState.processingState] ?? AudioProcessingState.idle,
      playing: playerState.playing,
      updatePosition: playerState.position,
      bufferedPosition: playerState.bufferedPosition,
      speed: playerState.speed,
      updateTime: DateTime.now(),
    );
  }
}

音频焦点:来电暂停、耳机拔出、锁屏控件(iOS 那几行配置别漏)

import 'package:audio_session/audio_session.dart';

class AudioFocusManager {
  static AudioSession? _session;
  static bool _hasFocus = false;

  static Future<void> initialize() async {
    try {
      _session = await AudioSession.instance;
      await _session!.configure(const AudioSessionConfiguration.music());

      // 监听音频焦点变化
      _session!.interruptionEventStream.listen(_handleInterruption);

      // 监听音频路由变化
      _session!.becomingNoisyEventStream.listen(_handleBecomingNoisy);
    } catch (e) {
      throw AudioException('音频焦点初始化失败:${e.toString()}');
    }
  }

  static Future<void> requestFocus() async {
    try {
      if (_session != null) {
        await _session!.setActive(true);
        _hasFocus = true;
      }
    } catch (e) {
      throw AudioException('请求音频焦点失败:${e.toString()}');
    }
  }

  static Future<void> releaseFocus() async {
    try {
      if (_session != null) {
        await _session!.setActive(false);
        _hasFocus = false;
      }
    } catch (e) {
      throw AudioException('释放音频焦点失败:${e.toString()}');
    }
  }

  static bool get hasFocus => _hasFocus;

  static void _handleInterruption(InterruptionEvent event) {
    switch (event.begin) {
      case true:
        // 音频被中断
        _hasFocus = false;
        if (event.primary) {
          // 其他应用开始播放音频
          // 暂停当前播放
          AudioPlayerManager().pause();
        }
        break;
      case false:
        // 中断结束
        if (event.primary) {
          // 其他应用停止播放音频
          // 请求音频焦点并恢复播放
          requestFocus().then((_) {
            AudioPlayerManager().play();
          });
        }
        break;
    }
  }

  static void _handleBecomingNoisy() {
    // 耳机拔出等事件
    AudioPlayerManager().pause();
  }
}

实战案例:我的两个播放器项目

上面是个虚构故事,说说我真实做过的两个:

flutter_aj_player——一个视频播放器插件。注意它是插件(plugin)不是普通 App:目录里有 android/、ios/、linux/、macos/、windows/ 五个平台目录,依赖里有 plugin_platform_interface。做播放器插件和用 video_player 完全是两回事:你要自己处理各平台的原生播放器实例管理。我在这个项目里还加了 auto_orientation(全屏自动转屏)和 brightness_volume(手势调亮度音量)——这两个是播放器体验的核心,但教程里很少提。

音乐播放应用——一个音乐 App,音频方案是 just_audio + audio_session。选 just_audio 而不是 audioplayers 的原因是:just_audio 的 AudioSession 对后台播放和系统音频焦点(来电暂停、耳机拔出暂停)的处理更完整。audio_session: 配置那几行看着不起眼,但不配的话,iOS 上锁屏界面不会显示播放控件——这是我被用户反馈后才补上的。

两个项目给我的最大教训:播放器的坑 90% 不在播放,在周边——转屏、亮度手势、后台保活、音频焦点、锁屏控件。只讲 VideoPlayerController.initialize() 的教程,离”能用的播放器”还差得远。

写在最后

播放器这块,我做完两个项目(flutter_aj_player、flutter_music_163)最大的体会就一句:坑 90% 不在播放,在周边。选型上,音频我站 just_audio + audio_session,视频用 video_player 自己包 UI;转屏和亮度音量手势别手写,直接上 auto_orientation 和 brightness_volume 这类插件;audio_session 那几行配置看着不起眼,不配的话 iOS 锁屏控件直接不显示——这条我是被用户反馈打脸后才补上的。只讲 initialize() 的教程,离“能用的播放器”还差得远。


分享这篇文章:


相关文章

上一篇
Flutter 与相机和相册桥接
下一篇
Flutter 与传感器桥接