Commit b617e011 by ethanlamzs

优化播放失败后

1 parent f8375082
...@@ -20,6 +20,27 @@ class PlayerService { ...@@ -20,6 +20,27 @@ class PlayerService {
/// 当前播放文件的时长 /// 当前播放文件的时长
int? _playDuration; int? _playDuration;
/// 播放代次,每次发起新播放自增,用于识别陈旧的 whenFinished 回调
int _playSeq = 0;
/// 轻量串行锁:串行化 playAudio / stopAudio,防止并发调用时播放器重建逻辑交错
Future<void> _playLock = Future.value();
/// 远程音频下载超时
static const Duration _downloadTimeout = Duration(seconds: 10);
/// HttpClient 建连超时
static const Duration _connectTimeout = Duration(seconds: 10);
/// startPlayer / openPlayer 兜底超时(防插件内部 Future 永久悬挂死锁)
static const Duration _startTimeout = Duration(seconds: 10);
/// 常规操作(暂停/恢复/进度/倍速/停止/关闭)死锁防护超时
static const Duration _opTimeout = Duration(seconds: 3);
/// 当前播放的远程音频对应的本地临时文件
String? _localAudioPath;
Function? _sendResponse; Function? _sendResponse;
set sendResponse(Function value) { set sendResponse(Function value) {
...@@ -29,6 +50,19 @@ class PlayerService { ...@@ -29,6 +50,19 @@ class PlayerService {
Future<bool> _initPlayer() async { Future<bool> _initPlayer() async {
// 打开播放器 // 打开播放器
try { try {
// 幂等清理:防止旧订阅泄漏、旧实例未关闭(异常重建场景会重复进入本方法)
await _playerSubscription?.cancel();
_playerSubscription = null;
try {
if (_player != null) {
// 死锁防护:旧实例内部锁可能已被悬挂的 startPlayer 持有,超时后放弃(接受泄漏)
await _withTimeout(_player!.closePlayer(), _opTimeout);
}
} catch (e) {
print(e);
}
_player = null;
// 针对iOS, 需要控制不使用听筒进行播放 // 针对iOS, 需要控制不使用听筒进行播放
if (Platform.isIOS) { if (Platform.isIOS) {
var audioSession = await AudioSession.instance; var audioSession = await AudioSession.instance;
...@@ -38,10 +72,10 @@ class PlayerService { ...@@ -38,10 +72,10 @@ class PlayerService {
final player = FlutterSoundPlayer(); final player = FlutterSoundPlayer();
_player = (await player.openPlayer())!; _player = (await _withTimeout(player.openPlayer(), _startTimeout))!;
// 播放进度回调 // 播放进度回调
_player!.setSubscriptionDuration(Duration(seconds: 1)); await _withTimeout(_player!.setSubscriptionDuration(Duration(seconds: 1)), _opTimeout);
_playerSubscription = _player!.onProgress!.listen((event) { _playerSubscription = _player!.onProgress!.listen((event) {
_playDuration = event.duration.inSeconds; _playDuration = event.duration.inSeconds;
var playPosition = event.position.inSeconds; var playPosition = event.position.inSeconds;
...@@ -63,8 +97,248 @@ class PlayerService { ...@@ -63,8 +97,248 @@ class PlayerService {
} }
} }
/// 轻量串行锁:串行化 playAudio / stopAudio,防止并发调用时播放器重建逻辑交错
Future<T> _withLock<T>(Future<T> Function() action) {
final result = _playLock.then((_) => action());
// 链尾吞掉异常,保证后续排队操作不被跳过;异常仍通过 result 传给调用方
_playLock = result.then((_) {}, onError: (_) {});
return result;
}
/// 对底层插件调用加超时兜底:插件内部锁可能被悬挂的 startPlayer 永久持有,
/// 超时后放弃等待并走重建流程(底层 future 迟到完成/出错不会成为未捕获异常)
Future<T> _withTimeout<T>(Future<T> future, Duration timeout) {
future.ignore();
return future.timeout(timeout);
}
/// 是否为需要自行下载的远程音频地址。
/// 插件 iOS 远程分支存在"下载错误被吞、startPlayer 永久悬挂无异常"的缺陷,
/// 远程音频一律先下载到本地临时文件再播放
bool _isRemoteUrl(String url) {
final uri = Uri.tryParse(url);
return uri != null && (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// 从 URL 提取安全的文件扩展名(白名单校验,缺省空串)
String _extFromUrl(String url) {
final path = Uri.tryParse(url)?.path ?? '';
final idx = path.lastIndexOf('.');
if (idx < 0) {
return '';
}
final ext = path.substring(idx).toLowerCase();
final ok = RegExp(r'^\.[a-z0-9]+$').hasMatch(ext) && ext.length <= 10;
return ok ? ext : '';
}
/// 通过文件头魔数识别音频真实格式,返回扩展名(无法识别返回空串)。
/// 实测存在 URL/Content-Type 标注 mp3 但实际内容是 WAV 的服务端,
/// 错误的扩展名会误导 AVAudioPlayer 选择错误的解析器导致播放失败
Future<String> _sniffAudioExt(File file) async {
try {
final head = await file.openRead(0, 16).first;
String str(int start, int end) => String.fromCharCodes(head.skip(start).take(end - start));
if (head.length >= 12 && str(0, 4) == 'RIFF' && str(8, 12) == 'WAVE') {
return 'wav';
}
if (head.length >= 3 && str(0, 3) == 'ID3') {
return 'mp3';
}
if (head.length >= 4 && str(0, 4) == 'OggS') {
return 'ogg';
}
if (head.length >= 4 && str(0, 4) == 'fLaC') {
return 'flac';
}
if (head.length >= 4 && str(0, 4) == 'caff') {
return 'caf';
}
if (head.length >= 8 && str(4, 8) == 'ftyp') {
return 'm4a';
}
// ADTS AAC 帧头(FF F1 / FF F9)
if (head.length >= 2 && head[0] == 0xFF && (head[1] & 0xF6) == 0xF0) {
return 'aac';
}
// MPEG 音频帧同步(FF FB / FF F3 等)
if (head.length >= 2 && head[0] == 0xFF && (head[1] & 0xE0) == 0xE0) {
return 'mp3';
}
} catch (e) {
print(e);
}
return '';
}
/// best-effort 删除文件,永不抛异常
Future<void> _deleteFileQuietly(String? path) async {
if (path == null || path.isEmpty) {
return;
}
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
print(e);
}
}
/// 下载远程音频到本地临时文件;失败抛异常(此时播放器实例未被触碰,完全健康)
Future<String> _downloadToTemp(String url) async {
final client = HttpClient()
..connectionTimeout = _connectTimeout
..autoUncompress = true;
File? tempFile;
try {
final request = await client.getUrl(Uri.parse(url));
// 带 上浏览器 UA:部分 CDN/防盗链会拒绝非浏览器 UA(dart 默认 UA 会被返回错误页或空内容)
request.headers.set(
HttpHeaders.userAgentHeader,
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148');
final response = await request.close().timeout(_downloadTimeout);
final contentType = response.headers.value(HttpHeaders.contentTypeHeader) ?? '';
print('音频下载: HTTP ${response.statusCode}, type=$contentType, contentLength=${response.contentLength}');
if (response.statusCode != 200) {
throw Exception('HTTP ${response.statusCode}');
}
// 服务器返回了文本页面(常见于防盗链/鉴权失败),并非音频数据
if (contentType.contains('text/') || contentType.contains('application/json')) {
throw Exception('服务器返回非音频内容: $contentType');
}
tempFile = File(
'${Directory.systemTemp.path}/appframe_audio_${DateTime.now().millisecondsSinceEpoch}${_extFromUrl(url)}');
await response.pipe(tempFile.openWrite()).timeout(_downloadTimeout);
final size = await tempFile.length();
print('音频下载完成: $size bytes -> ${tempFile.path}');
if (size == 0) {
throw Exception('文件内容为空');
}
// 关键修复:按文件头魔数识别真实格式并修正扩展名,
// AVAudioPlayer 依赖扩展名选择解析器(URL 标 mp3 实为 WAV 时会解析失败)
final sniffedExt = await _sniffAudioExt(tempFile);
final curExt = _extFromUrl(tempFile.path);
if (sniffedExt.isNotEmpty && curExt != '.$sniffedExt') {
final newPath =
'${Directory.systemTemp.path}/appframe_audio_${DateTime.now().millisecondsSinceEpoch}.$sniffedExt';
tempFile = await tempFile.rename(newPath);
print('格式识别: 实际内容为 $sniffedExt(原扩展名 "$curExt"),已重命名 -> $newPath');
}
return tempFile.path;
} catch (e) {
// 清理下载半成品
try {
await tempFile?.delete();
} catch (e2) {
print(e2);
}
throw Exception('音频下载失败: $e');
} finally {
// force 关闭:超时场景真正掐断连接,取消后台下载
client.close(force: true);
}
}
/// 单次启动播放(设置速度 + 启动 + 回填时长),供首次尝试与重建后重试复用
Future<void> _startOnce(FlutterSoundPlayer player, String url, double playRate, String playId, int seq) async {
// 播放速度
await _withTimeout(player.setSpeed(playRate), _opTimeout);
final duration = await _withTimeout(
player.startPlayer(
fromURI: url,
whenFinished: _buildWhenFinished(player, playId, seq),
),
_startTimeout,
);
// startPlayer 会返回音轨总时长,立即回填,避免超短音频结束时取不到时长
if (duration != null) {
_playDuration = duration.inSeconds;
}
}
/// 构造播放结束回调:带 playId + 播放代次快照,防止旧播放晚到的结束回调误杀新播放
void Function() _buildWhenFinished(FlutterSoundPlayer player, String playId, int seq) {
return () async {
// 陈旧回调防御:播放指令已切换到新内容,放弃本次结束处理
if (playId != _playId || seq != _playSeq) {
return;
}
// 使用闭包捕获的局部实例停止,避免误操作到重建后的新实例
try {
await _withTimeout(player.stopPlayer(), _opTimeout);
} catch (e) {
// 注意:插件可能抛出 String 类型错误,必须用裸 catch
print(e);
}
// 补发一下全部进度
var h5Cmd = {
'unique': '',
'cmd': 'audioProgress',
'data': {'playId': _playId, 'duration': _playDuration, 'currentTime': _playDuration},
'errMsg': '',
};
_sendResponse!(h5Cmd);
// 播放结束后,发送消息给客户端
h5Cmd = {
'unique': '',
'cmd': 'audioEnd',
'data': {'playId': _playId},
'errMsg': '',
};
_sendResponse!(h5Cmd);
};
}
/// 尽力重建播放器(销毁旧实例 + 重新初始化),永不抛异常
Future<bool> _tryRebuildPlayer() async {
try {
await _initPlayer();
return (_playerIsInit ?? false) && _player != null;
} catch (e) {
print('重建播放器失败: $e');
return false;
}
}
/// 复位到干净的未初始化状态,保证下次播放重新走初始化流程,永不抛异常
Future<void> _resetToUninitialized() async {
try {
await _playerSubscription?.cancel();
} catch (e) {
print(e);
}
_playerSubscription = null;
try {
if (_player != null) {
// 死锁防护:超时后放弃旧实例(接受泄漏)
await _withTimeout(_player!.closePlayer(), _opTimeout);
}
} catch (e) {
print(e);
}
_player = null;
_playerIsInit = false;
_playId = '';
_playDuration = null;
// 清理本地临时音频文件
await _deleteFileQuietly(_localAudioPath);
_localAudioPath = null;
}
Future<bool> playAudio(String url, int seek, String playId, double playRate) async { Future<bool> playAudio(String url, int seek, String playId, double playRate) async {
if (!(_playerIsInit ?? false)) { return _withLock(() async {
// 增强判断:状态标志与实例不一致(异常后可能出现的脏状态)时也重新初始化
if (!(_playerIsInit ?? false) || _player == null) {
final initResult = await _initPlayer(); final initResult = await _initPlayer();
if (!initResult) { if (!initResult) {
return false; return false;
...@@ -96,40 +370,75 @@ class PlayerService { ...@@ -96,40 +370,75 @@ class PlayerService {
// debugPrint('当前音频路由-------------------: ${currentRoute.outputs}'); // debugPrint('当前音频路由-------------------: ${currentRoute.outputs}');
// } // }
// 远程音频先自行下载到本地临时文件再播放:
// 绕开插件 iOS 远程分支"下载错误被吞、startPlayer 永久悬挂且无异常"的缺陷
//(即线上 WAVEAudioFile::OpenFromDataSource Failed 后无法再播放的根因)。
// 放在 _playId/_playSeq 更新之前,下载失败时零状态污染,旧播放继续且语义正确。
String playUrl = url;
String? downloadedPath;
if (_isRemoteUrl(url)) {
downloadedPath = await _downloadToTemp(url);
playUrl = downloadedPath;
}
// 换曲清理目标:上一个已无人引用的临时文件
final previousLocalPath = _localAudioPath;
_playId = playId; _playId = playId;
// 播放速度 // 播放代次自增,用于识别陈旧的 whenFinished 回调
_player!.setSpeed(playRate); _playSeq += 1;
// 重置上一首的时长,避免污染本次播放的进度上报
_playDuration = null;
await _player!.startPlayer( final player = _player!;
fromURI: url, try {
whenFinished: () async { await _startOnce(player, playUrl, playRate, playId, _playSeq);
await _player!.stopPlayer(); } catch (e) {
// 底层启动失败(如 iOS 报 WAVEAudioFile::OpenFromDataSource Failed)后,
// 播放器实例可能已损坏,这里销毁重建后重试一次,保证不影响后续新播放
print('startPlayer 失败,尝试重建播放器恢复: $e');
// 下载文件诊断:辅助排查下载内容不是音频/被防盗链篡改的情况
// (HTML 页面头 3c 21 44 4f、gzip 头 1f 8b、MP3 头 ff fb / 49 44 33、WAV 头 52 49 46 46)
if (downloadedPath != null) {
try {
final file = File(downloadedPath);
final size = await file.length();
final head = await file.openRead(0, 16).first;
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
print('下载文件诊断: size=$size bytes, 前16字节=$hex');
} catch (e3) {
print(e3);
}
}
final recovered = await _tryRebuildPlayer();
if (!recovered) {
await _resetToUninitialized();
// 清理本次下载的文件(可能损坏)
await _deleteFileQuietly(downloadedPath);
rethrow;
}
try {
// 重试继续使用同一本地文件,不重复下载
await _startOnce(_player!, playUrl, playRate, playId, _playSeq);
} catch (e2) {
print('重建播放器后重试仍失败: $e2');
await _resetToUninitialized();
await _deleteFileQuietly(downloadedPath);
// 抛出原始异常,保持对外错误内容不变
throw e;
}
}
// 补发一下全部进度 // 新播放已成功启动(旧播放已被 startPlayer 内部停止,旧文件无人引用),清理并登记
var h5Cmd = { await _deleteFileQuietly(previousLocalPath);
'unique': '', _localAudioPath = downloadedPath;
'cmd': 'audioProgress',
'data': {'playId': _playId, 'duration': _playDuration, 'currentTime': _playDuration},
'errMsg': '',
};
_sendResponse!(h5Cmd);
// 播放结束后,发送消息给客户端
h5Cmd = {
'unique': '',
'cmd': 'audioEnd',
'data': {'playId': _playId},
'errMsg': '',
};
_sendResponse!(h5Cmd);
},
);
if (seek != 0) { if (seek != 0) {
await seekAudio(seek); await seekAudio(seek);
} }
return true; return true;
});
} }
Future<bool> pauseAudio() async { Future<bool> pauseAudio() async {
...@@ -137,11 +446,19 @@ class PlayerService { ...@@ -137,11 +446,19 @@ class PlayerService {
throw Exception("播放器未初始化"); throw Exception("播放器未初始化");
} }
if (_player == null) {
throw Exception("播放器未初始化");
}
if (!_player!.isPlaying) { if (!_player!.isPlaying) {
throw Exception("播放器状态错误"); throw Exception("播放器状态错误");
} }
await _player!.pausePlayer(); try {
await _withTimeout(_player!.pausePlayer(), _opTimeout);
} on TimeoutException catch (e) {
throw Exception('音频暂停超时: $e');
}
return true; return true;
} }
...@@ -150,17 +467,29 @@ class PlayerService { ...@@ -150,17 +467,29 @@ class PlayerService {
throw Exception("播放器未初始化"); throw Exception("播放器未初始化");
} }
if (_player == null) {
throw Exception("播放器未初始化");
}
if (!_player!.isPaused) { if (!_player!.isPaused) {
throw Exception("播放器状态错误"); throw Exception("播放器状态错误");
} }
if (playRate != null) { if (playRate != null) {
_player!.setSpeed(playRate); try {
await _withTimeout(_player!.setSpeed(playRate), _opTimeout);
} on TimeoutException catch (e) {
throw Exception('设置播放速度超时: $e');
}
// 设置速度之后,会自动恢复播放 // 设置速度之后,会自动恢复播放
return true; return true;
} }
await _player!.resumePlayer(); try {
await _withTimeout(_player!.resumePlayer(), _opTimeout);
} on TimeoutException catch (e) {
throw Exception('音频恢复超时: $e');
}
return true; return true;
} }
...@@ -169,7 +498,15 @@ class PlayerService { ...@@ -169,7 +498,15 @@ class PlayerService {
throw Exception("播放器未初始化"); throw Exception("播放器未初始化");
} }
await _player!.seekToPlayer(Duration(seconds: seek)); if (_player == null) {
throw Exception("播放器未初始化");
}
try {
await _withTimeout(_player!.seekToPlayer(Duration(seconds: seek)), _opTimeout);
} on TimeoutException catch (e) {
throw Exception('音频进度跳转超时: $e');
}
return true; return true;
} }
...@@ -178,30 +515,59 @@ class PlayerService { ...@@ -178,30 +515,59 @@ class PlayerService {
throw Exception("播放器未初始化"); throw Exception("播放器未初始化");
} }
if (_player == null) {
throw Exception("播放器未初始化");
}
// await pauseAudio(); // await pauseAudio();
// await resumeAudio(playRate); // await resumeAudio(playRate);
_player!.setSpeed(playRate); try {
await _withTimeout(_player!.setSpeed(playRate), _opTimeout);
} on TimeoutException catch (e) {
throw Exception('设置播放速度超时: $e');
}
return true; return true;
} }
Future<bool> stopAudio() async { Future<bool> stopAudio() async {
return _withLock(() async {
if (!(_playerIsInit ?? false)) { if (!(_playerIsInit ?? false)) {
throw Exception("播放器未初始化"); throw Exception("播放器未初始化");
} }
if (_player == null) {
throw Exception("播放器未初始化");
}
if (!_player!.isPlaying && !_player!.isPaused) { if (!_player!.isPlaying && !_player!.isPaused) {
throw Exception("播放器状态错误"); throw Exception("播放器状态错误");
} }
await _player!.stopPlayer(); try {
await _withTimeout(_player!.stopPlayer(), _opTimeout);
} on TimeoutException catch (e) {
// 超时死锁防护:底层实例可能已损坏,尽力重建,保证不影响下次新播放
print('stopPlayer 超时,尝试重建播放器: $e');
await _tryRebuildPlayer();
throw Exception('音频停止超时: $e');
} catch (e) {
// 停止失败时底层实例可能已损坏,尽力重建,保证不影响下次新播放
print('stopPlayer 异常,尝试重建播放器: $e');
await _tryRebuildPlayer();
rethrow;
}
return true; return true;
});
} }
Future<bool> clearAudio() async { Future<bool> clearAudio() async {
try { try {
await _player?.closePlayer(); if (_player != null) {
// 死锁防护:超时后放弃旧实例(接受泄漏)
await _withTimeout(_player!.closePlayer(), _opTimeout);
}
_player = null; _player = null;
await _playerSubscription?.cancel(); await _playerSubscription?.cancel();
_playerSubscription = null; _playerSubscription = null;
...@@ -211,6 +577,9 @@ class PlayerService { ...@@ -211,6 +577,9 @@ class PlayerService {
_playerIsInit = false; _playerIsInit = false;
_playId = ''; _playId = '';
_playDuration = null; _playDuration = null;
// 清理本地临时音频文件
await _deleteFileQuietly(_localAudioPath);
_localAudioPath = null;
return true; return true;
} }
...@@ -219,7 +588,10 @@ class PlayerService { ...@@ -219,7 +588,10 @@ class PlayerService {
/// ///
Future<void> close() async { Future<void> close() async {
try { try {
await _player?.closePlayer(); if (_player != null) {
// 死锁防护:超时后放弃旧实例(接受泄漏)
await _withTimeout(_player!.closePlayer(), _opTimeout);
}
_player = null; _player = null;
await _playerSubscription?.cancel(); await _playerSubscription?.cancel();
_playerSubscription = null; _playerSubscription = null;
...@@ -229,5 +601,8 @@ class PlayerService { ...@@ -229,5 +601,8 @@ class PlayerService {
_playerIsInit = false; _playerIsInit = false;
_playId = ''; _playId = '';
_playDuration = null; _playDuration = null;
// 清理本地临时音频文件
await _deleteFileQuietly(_localAudioPath);
_localAudioPath = null;
} }
} }
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!