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.
 
 
 
 
 
 

97 lines
4.0 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;
// ⚠️ **只在当前类别撑不住后台音频时才改它**。
//
// 无条件 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<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');
}
}
}