import 'dart:async' show unawaited; import 'dart:io' show Platform; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:just_audio/just_audio.dart'; import '../../core/utils/logger.dart'; /// iOS 后台保活:循环播一段**静音**,让 `UIBackgroundModes=audio` 有个成立的理由。 /// /// ## 为什么需要它 /// 设备按键唤醒的 AI 会话,音频全走耳机(BLE),手机侧一个 AVAudioSession 都没激活; /// 而 Info.plist 里只有 `bluetooth-central / audio / location`——光靠 bluetooth-central, /// 锁屏后系统只给短暂的唤醒窗口,撑不住一段几十秒、每秒 50 包上行 + 一条 WebSocket /// 的会话。Android 有 `AudioForegroundService`,iOS 没有等价物,这是 iOS 上让进程 /// 在锁屏后活下去的标准做法(2026-09-20 苹果测试「锁屏后耳机对话很容易中断」)。 /// /// - 用引用计数:多条链路同时要保活时,最后一个 release 才真的停; /// - 类别 `playback` + `mixWithOthers`:不打断别的 App 的声音,也不抢路由; /// - 音量 0 + 文件本身就是静音,用户听不到任何东西; /// - Android / Web 全是空操作。 class BackgroundKeepalive { BackgroundKeepalive._(); static const String _tag = 'BackgroundKeepalive'; static const String _asset = 'assets/audio/silence_1s.wav'; static AudioPlayer? _player; static int _refs = 0; static bool get _apply => !kIsWeb && Platform.isIOS; /// 静音循环此刻是否真的在播(取样日志用;被电话打断会变 false) static bool get isPlaying => _player?.playing ?? false; static int get refs => _refs; static Future acquire(String owner) async { if (!_apply) return; _refs++; if (_refs > 1) return; try { final session = await AudioSession.instance; // ⚠️ **只在当前类别撑不住后台音频时才改它**。 // // 无条件 configure 成 .playback 会把通话翻译/多媒体翻译正在跑的 // .playAndRecord 录音链路踩掉(同 SubtitlePip 的 prepareAudioSessionForPip, // 那边栽过一次)。而 .playAndRecord / .multiRoute 本身就满足 // UIBackgroundModes=audio 的要求,不需要降级——这时只要把静音循环播起来, // 让「确实有音频在播」这个条件成立就够了。 final cur = session.configuration?.avAudioSessionCategory; final alreadyOk = cur == AVAudioSessionCategory.playback || cur == AVAudioSessionCategory.playAndRecord || cur == AVAudioSessionCategory.multiRoute; if (!alreadyOk) { await session.configure(const AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playback, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers, avAudioSessionMode: AVAudioSessionMode.defaultMode, )); } else { Logger.i(_tag, '当前音频类别 $cur 已支持后台音频,不改类别'); } await session.setActive(true); final p = AudioPlayer(); await p.setAsset(_asset); await p.setLoopMode(LoopMode.one); await p.setVolume(0); unawaited(p.play()); _player = p; // warning 级别:release 包的文件日志只收 warning 以上,切后台排查要看得到它 Logger.w(_tag, '后台保活已开启($owner)'); } catch (e) { // 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响 Logger.w(_tag, '后台保活开启失败($owner): $e'); _refs--; } } static Future release(String owner) async { if (!_apply) return; if (_refs == 0) return; _refs--; if (_refs > 0) return; final p = _player; _player = null; if (p == null) return; try { await p.stop(); await p.dispose(); Logger.i(_tag, '后台保活已关闭($owner)'); } catch (e) { Logger.w(_tag, '后台保活关闭失败: $e'); } } }