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; // 读系统此刻的类别,不要读 audio_session 自己记住的那份。 // 同声翻译开麦之后系统已经是 playAndRecord,缓存却可能还是上一次的 // playback / 空。按缓存再配成 playback,输入声道立刻变成 0,麦流静默, // 看门狗重启也探不到输入。 AVAudioSessionCategory? live; try { live = await AVAudioSession().category; } catch (e) { Logger.w(_tag, '读取音频类别失败: $e'); } final cur = live ?? session.configuration?.avAudioSessionCategory; final recording = cur == AVAudioSessionCategory.playAndRecord || cur == AVAudioSessionCategory.multiRoute; final alreadyOk = recording || cur == AVAudioSessionCategory.playback; Logger.w(_tag, '保活前类别 live=$live 采用=$cur'); if (!alreadyOk) { await session.configure(const AudioSessionConfiguration( avAudioSessionCategory: AVAudioSessionCategory.playback, avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers, avAudioSessionMode: AVAudioSessionMode.defaultMode, )); } // 录音会话已经激活。再 setActive 会让正在跑的输入 tap 停回调。 if (!recording) { await session.setActive(true); } // 播放器自己再 setActive 会盖掉后面独占会话的时机,结束时音乐就续不上。 final p = AudioPlayer(handleAudioSessionActivation: false); await p.setAsset(_asset); await p.setLoopMode(LoopMode.one); await p.setVolume(0); // 不要 await。playAndRecord 已经由麦克风占着时,play() 会一直不返回, // 同声翻译的开始按钮就停在「准备中」。 unawaited(p.play()); _player = p; // warning 级别:release 包的文件日志只收 warning 以上,切后台排查要看得到它 Logger.w(_tag, '后台保活已开启($owner)'); } catch (e) { // 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响 Logger.w(_tag, '后台保活开启失败($owner): $e'); _refs--; } } /// 类别被切成独占之后,静音循环可能被停掉。会话还在就要它继续播, /// 否则后台不再算「正在播放」,系统会把进程挂起。 static Future keepPlaying() async { if (!_apply) return; final p = _player; if (p == null || p.playing) return; try { await p.play(); } catch (e) { Logger.w(_tag, '静音保活续播失败: $e'); } } 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'); } } }