diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index d8b46d5..4b905b3 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -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 _recoverFromStall() async { + /// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。 + /// + /// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。 + Future _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 _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 _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(); diff --git a/lib/services/network_watcher.dart b/lib/services/network_watcher.dart new file mode 100644 index 0000000..cb8c3b1 --- /dev/null +++ b/lib/services/network_watcher.dart @@ -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 _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; + } + } +}