针对公司共享出口(整栋楼抢网): - 恢复退避:连续失败 3 次后 30s、4 次 60s、5 次 120s, 冷却结束自动再试一次;累计 6 次后停止,等用户手动点 - 用户手动点播放会清零退避计数 - 成功出声(position>1s)后清零 - mpv 缓冲加大:cache-secs=60s、demuxer-max-bytes=64MiB、 demuxer-readahead-secs=30 真机验证:缓冲参数设置成功,起播 1576ms(此前 2098ms)。
165 lines
5.5 KiB
Dart
165 lines
5.5 KiB
Dart
// lib/services/playback_service.dart
|
||
import 'dart:async';
|
||
|
||
import 'package:media_kit/media_kit.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
class PlaybackService {
|
||
static final PlaybackService _instance = PlaybackService._internal();
|
||
factory PlaybackService() => _instance;
|
||
PlaybackService._internal() {
|
||
// ⭐ 给单例本身打上 ID
|
||
_serviceId = DateTime.now().microsecondsSinceEpoch.toString();
|
||
debugPrint('🎯 [PlaybackService] 单例创建, serviceId=$_serviceId');
|
||
}
|
||
|
||
/// 弱网/源站抖动时,mpv 的 open 可能长时间不返回。
|
||
/// 不设上限的话 _playCurrent() 的 finally 永远不执行,
|
||
/// _isChangingTrack 会永久为 true,整个切歌链路假死。
|
||
static const Duration _openTimeout = Duration(seconds: 20);
|
||
static const Duration _controlTimeout = Duration(seconds: 5);
|
||
|
||
Player? _player;
|
||
bool _initialized = false;
|
||
late final String _serviceId; // ⭐ 服务实例 ID
|
||
|
||
// ---------- Getter ----------
|
||
Player get player {
|
||
debugPrint(
|
||
'🎯 [PlaybackService] get player called, serviceId=$_serviceId, initialized=$_initialized, player=${_player?.hashCode}');
|
||
if (!_initialized) init();
|
||
return _player!;
|
||
}
|
||
|
||
bool get isInitialized => _initialized;
|
||
|
||
// ---------- 初始化 ----------
|
||
void init() {
|
||
if (_initialized) {
|
||
debugPrint(
|
||
'⚠️ [PlaybackService] init() 被重复调用, 跳过, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
return;
|
||
}
|
||
_player = Player();
|
||
|
||
// 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
|
||
// 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
|
||
// 通过 platform 动态调用,避免依赖 media_kit 的内部类型。
|
||
try {
|
||
final platform = _player!.platform as dynamic;
|
||
platform.setProperty('cache-secs', '60');
|
||
platform.setProperty('demuxer-max-bytes', '64MiB');
|
||
platform.setProperty('demuxer-readahead-secs', '30');
|
||
debugPrint('✅ [PlaybackService] mpv 缓冲参数已设置(cache 60s / 64MiB)');
|
||
} catch (e) {
|
||
debugPrint('⚠️ [PlaybackService] 设置 mpv 缓冲参数失败: $e');
|
||
}
|
||
|
||
_initialized = true;
|
||
debugPrint(
|
||
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
||
}
|
||
|
||
// ---------- 播放 ----------
|
||
Future<void> play(String url, {Map<String, String>? headers}) async {
|
||
debugPrint(
|
||
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}, url=${_shortUrl(url)}');
|
||
if (!_initialized) init();
|
||
|
||
final media = (headers != null && headers.isNotEmpty)
|
||
? Media(url, httpHeaders: headers)
|
||
: Media(url);
|
||
|
||
try {
|
||
final sw = Stopwatch()..start();
|
||
await _player!.open(media).timeout(_openTimeout);
|
||
final openMs = sw.elapsedMilliseconds;
|
||
await _player!.play().timeout(_controlTimeout);
|
||
debugPrint('⏱️ [PlaybackService] open 完成 ${openMs}ms,'
|
||
'play 完成 ${sw.elapsedMilliseconds}ms');
|
||
} on TimeoutException {
|
||
debugPrint(
|
||
'❌ [PlaybackService] open timeout (${_openTimeout.inSeconds}s): ${_shortUrl(url)}');
|
||
await _safeStop();
|
||
rethrow;
|
||
} catch (e) {
|
||
debugPrint('❌ [PlaybackService] play failed: $e');
|
||
await _safeStop();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 失败时尽量把播放器拉回可用的空闲状态,避免下一次 open 继续卡住。
|
||
Future<void> _safeStop() async {
|
||
try {
|
||
await _player?.stop().timeout(const Duration(seconds: 3));
|
||
} catch (_) {
|
||
// 忽略:stop 本身失败也不应影响上层解锁
|
||
}
|
||
}
|
||
|
||
/// 日志里不打完整 URL(WebDAV 路径很长,且可能带签名参数)
|
||
String _shortUrl(String url) {
|
||
final hash = url.hashCode.toRadixString(16);
|
||
return url.length <= 48 ? url : '${url.substring(0, 48)}…#$hash';
|
||
}
|
||
|
||
// ---------- 控制 ----------
|
||
Future<void> pause() async {
|
||
debugPrint(
|
||
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
if (_initialized && _player != null) {
|
||
await _player!.pause().timeout(_controlTimeout);
|
||
}
|
||
}
|
||
|
||
Future<void> resume() async {
|
||
debugPrint(
|
||
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
if (_initialized && _player != null) {
|
||
await _player!.play().timeout(_controlTimeout);
|
||
}
|
||
}
|
||
|
||
Future<void> stop() async {
|
||
debugPrint(
|
||
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
if (_initialized && _player != null) {
|
||
await _player!.stop().timeout(_controlTimeout);
|
||
}
|
||
}
|
||
|
||
Future<void> seek(Duration position) async {
|
||
debugPrint(
|
||
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
if (_initialized && _player != null) {
|
||
await _player!.seek(position).timeout(_controlTimeout);
|
||
}
|
||
}
|
||
|
||
void setVolume(double volume) {
|
||
if (_initialized && _player != null) {
|
||
_player!.setVolume(volume);
|
||
}
|
||
}
|
||
|
||
// ---------- 释放 ----------
|
||
void dispose() {
|
||
debugPrint(
|
||
'🗑️ [PlaybackService] dispose() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||
if (_initialized && _player != null) {
|
||
_player!.dispose();
|
||
_player = null;
|
||
_initialized = false;
|
||
}
|
||
}
|
||
|
||
// ---------- 重新初始化 ----------
|
||
void reinitialize() {
|
||
debugPrint(
|
||
'🔄 [PlaybackService] reinitialize() called, serviceId=$_serviceId');
|
||
dispose();
|
||
init();
|
||
}
|
||
}
|