引言:播放器是“周边坑”最多的功能
这个标题不是噱头,是我做完两个播放器项目后的真实感受: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
- video_player——视频播放的官方插件,底层 ExoPlayer/AVPlayer,稳。但它只管“播”,控制 UI、转屏、手势全得自己写。
- just_audio——我的音频默认选择。选它不选
audioplayers的原因很实在:just_audio配audio_session,后台播放、来电暂停、耳机拔出暂停、锁屏控件这套是完整打通的(flutter_music_163 里验证过)。 - audioplayers——API 更简单,播个提示音、短音效很合适;但要做正经音乐 App,它的后台和焦点处理不如 just_audio 省心。
- 自定义平台通道——除非你要做插件(比如我的 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() 的教程,离“能用的播放器”还差得远。
