You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

118 lines
4.8 KiB

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<void> 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<void> 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<void> 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');
}
}
}