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.
92 lines
3.7 KiB
92 lines
3.7 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;
|
|
|
|
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;
|
|
Logger.i(_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');
|
|
}
|
|
}
|
|
}
|
|
|