feat(playback): 网络切换后自动重连当前歌曲
mpv 的 HTTP 长连接不会跟随网络迁移:换 Wi-Fi / Wi-Fi<->移动数据 会换掉出口 IP,旧连接失效但不一定报错,表现为进度停住、一直缓冲。 - 新增 NetworkWatcher:每 10s 枚举 IPv4 接口,签名变化即视为网络切换 (不依赖插件和额外权限,也能覆盖"同类型网络换 AP"的情况) - AudioService 收到变化后 3s 去抖,然后强制重连当前歌曲并 seek 回原位置 (force 参数跳过 2 分钟节流,因为这是明确的外部事件) 真机验证(公网链路 + 移动数据): 21:43:23.123 检测到网络变化 21:43:26.124 主动重连(3s 去抖) 21:43:26.289 自动恢复完成(165ms) 随后 position 35.5s -> 41.7s 正常推进
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart' as media_kit;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'playback_service.dart';
|
||||
import 'network_watcher.dart';
|
||||
import '../metadata/metadata_service.dart';
|
||||
import '../database/song_database.dart';
|
||||
import '../utils/artwork_helper.dart';
|
||||
@@ -91,6 +92,9 @@ class AudioService extends ChangeNotifier {
|
||||
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
|
||||
DateTime? _lastBufferingAt;
|
||||
|
||||
/// 网络切换去抖
|
||||
Timer? _networkDebounce;
|
||||
|
||||
/// 进度看门狗:某些卡死不会触发 buffering 事件(例如 CDN 半开连接),
|
||||
/// 用「播放中进度长时间不动」兜底。
|
||||
Timer? _progressTimer;
|
||||
@@ -436,10 +440,10 @@ class AudioService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓冲卡死自愈:重开当前歌曲并 seek 回原位置。 ///
|
||||
/// 触发条件:mpv 的 buffering 持续为 true 超过 [_stallThreshold]。
|
||||
/// 两次自动恢复之间至少间隔 2 分钟,避免源站长时间不可用时反复重连。
|
||||
Future<void> _recoverFromStall() async {
|
||||
/// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。
|
||||
///
|
||||
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
|
||||
Future<void> _recoverFromStall({bool force = false}) async {
|
||||
if (_isChangingTrack) return;
|
||||
|
||||
final song = _currentSong;
|
||||
@@ -448,7 +452,8 @@ class AudioService extends ChangeNotifier {
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastStallRecoveryAt != null &&
|
||||
if (!force &&
|
||||
_lastStallRecoveryAt != null &&
|
||||
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
|
||||
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
|
||||
return;
|
||||
@@ -456,8 +461,7 @@ class AudioService extends ChangeNotifier {
|
||||
_lastStallRecoveryAt = now;
|
||||
|
||||
final resumeAt = positionNotifier.value;
|
||||
debugPrint(
|
||||
'🩺 [AudioService] 缓冲超过 ${_stallThreshold.inSeconds}s,尝试重连:${song.title}');
|
||||
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
||||
try {
|
||||
await _playWithHeaders(url);
|
||||
if (resumeAt > Duration.zero) {
|
||||
@@ -472,7 +476,22 @@ class AudioService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackStarted() async { final player = PlaybackService().player;
|
||||
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
|
||||
///
|
||||
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
|
||||
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
|
||||
void _onNetworkChanged() {
|
||||
_networkDebounce?.cancel();
|
||||
_networkDebounce = Timer(const Duration(seconds: 3), () {
|
||||
if (_currentSong == null) return;
|
||||
if (_isChangingTrack) return;
|
||||
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
|
||||
_recoverFromStall(force: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackStarted() async {
|
||||
final player = PlaybackService().player;
|
||||
|
||||
if (player.state.playing) {
|
||||
_hasStartedCurrentPlayback = true;
|
||||
@@ -847,6 +866,10 @@ class AudioService extends ChangeNotifier {
|
||||
(_) => _checkPlaybackProgress(),
|
||||
);
|
||||
|
||||
// 网络变化监听:换 Wi-Fi / 切移动数据后长连接会失效,主动重连
|
||||
NetworkWatcher().onChanged = _onNetworkChanged;
|
||||
NetworkWatcher().start();
|
||||
|
||||
// ⭐ 完整生命周期校验的 completed 监听
|
||||
_subscriptions.add(
|
||||
player.stream.completed.listen((_) {
|
||||
@@ -1070,6 +1093,9 @@ class AudioService extends ChangeNotifier {
|
||||
// 先保存再清理
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_networkDebounce?.cancel();
|
||||
_networkDebounce = null;
|
||||
NetworkWatcher().stop();
|
||||
savePlaybackState();
|
||||
_stopListening();
|
||||
positionNotifier.dispose();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// lib/services/network_watcher.dart
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 监听本机网络接口的地址变化。
|
||||
///
|
||||
/// 为什么不直接依赖 mpv 自己恢复:HTTP 流是长连接,Wi-Fi 切换 / 移动数据切换
|
||||
/// 会换掉出口 IP,旧连接直接失效。mpv 不一定会重连,通常表现为
|
||||
/// "进度不动、缓冲一直转"。所以在网络变化时由上层主动重开当前歌曲。
|
||||
///
|
||||
/// 实现方式:每 10s 枚举一次 IPv4 地址,签名变化即视为网络切换。
|
||||
/// 相比平台 Channel 的方案,它不需要新增权限或插件,
|
||||
/// 且能覆盖"同一个 Wi-Fi 换了 AP / 换了出口"这类 transport 类型不变的情况。
|
||||
class NetworkWatcher {
|
||||
static final NetworkWatcher _instance = NetworkWatcher._internal();
|
||||
factory NetworkWatcher() => _instance;
|
||||
NetworkWatcher._internal();
|
||||
|
||||
Timer? _timer;
|
||||
String? _lastSignature;
|
||||
bool _ticking = false;
|
||||
|
||||
/// 网络签名变化时回调(已在内部做过去抖)
|
||||
void Function()? onChanged;
|
||||
|
||||
void start({Duration interval = const Duration(seconds: 10)}) {
|
||||
if (_timer != null) return;
|
||||
_timer = Timer.periodic(interval, (_) => _tick());
|
||||
_tick();
|
||||
debugPrint('🌐 [NetworkWatcher] 已启动(每 ${interval.inSeconds}s 检查一次)');
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastSignature = null;
|
||||
debugPrint('🌐 [NetworkWatcher] 已停止');
|
||||
}
|
||||
|
||||
Future<void> _tick() async {
|
||||
if (_ticking) return;
|
||||
_ticking = true;
|
||||
try {
|
||||
final interfaces = await NetworkInterface.list(
|
||||
type: InternetAddressType.IPv4,
|
||||
includeLoopback: false,
|
||||
);
|
||||
final signature = interfaces
|
||||
.map((i) => '${i.name}:${i.addresses.map((a) => a.address).join(',')}')
|
||||
.join('|');
|
||||
|
||||
final previous = _lastSignature;
|
||||
_lastSignature = signature;
|
||||
|
||||
// 首次只记录基线,不触发
|
||||
if (previous != null && previous != signature) {
|
||||
debugPrint('🌐 [NetworkWatcher] 检测到网络变化');
|
||||
onChanged?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NetworkWatcher] 枚举网络接口失败: $e');
|
||||
} finally {
|
||||
_ticking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user