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.
1074 lines
42 KiB
1074 lines
42 KiB
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:haptic_feedback/haptic_feedback.dart';
|
|
|
|
import '../../../data/services/network/api.dart';
|
|
import '../../../core/utils/logger.dart';
|
|
import '../../../data/models/appconfig.dart';
|
|
import '../../../data/models/user_Info.dart' as user_info;
|
|
import '../../../data/services/assistant_directive_service.dart';
|
|
import '../../../data/services/bailian_multimodal_service.dart';
|
|
import '../../../data/models/emai_message.dart';
|
|
import '../../../data/services/device_ai_session_service.dart';
|
|
import '../../../data/services/emai_chat_store.dart';
|
|
import '../../../data/utils/mic_pcm_streamer.dart';
|
|
import '../../../data/services/mcp_token_service.dart';
|
|
import '../../../data/utils/pcm_stream_player.dart';
|
|
|
|
/// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没",
|
|
/// 那个说的是"连上之后现在在听/在想/在答")
|
|
enum EmaiLinkState {
|
|
idle,
|
|
connecting,
|
|
ready,
|
|
failed,
|
|
unconfigured,
|
|
|
|
/// 耳机按键唤醒了 AI,会话让给耳机了;耳机那边结束后本页自动接回来
|
|
onEarphone,
|
|
}
|
|
|
|
/// EMAI 助手控制器。
|
|
///
|
|
/// 整条链路只有一个外部依赖——[BailianMultimodalService](阿里百炼多模态交互协议),
|
|
/// ASR / LLM / TTS 全在服务端完成。客户端要做的三件事:
|
|
/// 1. 上行:麦克风 PCM([MicPcmStreamer])按 push2talk 的 SendSpeech/StopSpeech 划段推上去
|
|
/// 2. 下行:服务端推回的 PCM 交给 [PcmStreamPlayer] 边收边播
|
|
/// 3. 文本:SpeechContent / RespondingContent 流式拼进消息列表
|
|
class EmaiController extends GetxController
|
|
with WidgetsBindingObserver
|
|
implements EmaiSessionOwner {
|
|
/// [service] 不传就用全局那个(EMAI 的百炼应用),行为与改造前一致。
|
|
///
|
|
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar
|
|
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见
|
|
/// [BailianMultimodalService] 的类注释。
|
|
EmaiController({BailianMultimodalService? service, this.halfDuplexMic = false})
|
|
: _svc = service ?? Get.find<BailianMultimodalService>();
|
|
|
|
/// 手机麦半双工:AI 播报期间上行换成等长静音,本地播完再放开(2026-09-26)。
|
|
///
|
|
/// 只有车载香薰页开([EaiCarChatBinding] 传 true),与设备那条链路的门控口径一致:
|
|
/// 外放 + 两台设备互聊时,AEC 挡不住对方/自己的声音,服务端 VAD 会把它当成插话,
|
|
/// 一轮没说完就被打断,下一轮的识别和回复插进来,字幕气泡互相串。
|
|
/// EMAI 页保持原样(靠 AEC 全双工,用户可以随时插话)。
|
|
final bool halfDuplexMic;
|
|
|
|
static const String _tag = 'EmaiController';
|
|
|
|
final BailianMultimodalService _svc;
|
|
final MicPcmStreamer _mic =
|
|
MicPcmStreamer(sampleRate: BailianMultimodalService.upstreamSampleRate);
|
|
final PcmStreamPlayer _player = PcmStreamPlayer(
|
|
sampleRate: BailianMultimodalService.downstreamSampleRate);
|
|
|
|
// ---------------- UI 状态 ----------------
|
|
|
|
/// 对话记录来自共享 store:耳机按键唤醒的那些轮次也在里面,
|
|
/// 页面没打开时发生的对话,打开后照样看得到
|
|
final EmaiChatStore _store = Get.find<EmaiChatStore>();
|
|
RxList<EmaiMessage> get messages => _store.messages;
|
|
final linkState = EmaiLinkState.idle.obs;
|
|
|
|
/// 服务端对话状态:聆听 / 思考 / 回答
|
|
Rx<BailianDialogState> get dialogState => _svc.state;
|
|
|
|
/// 是否处于"按住说话"中
|
|
final isTalking = false.obs;
|
|
|
|
/// 手指是否已滑出按钮区域(松手将取消这段语音)
|
|
final isCancelPending = false.obs;
|
|
|
|
/// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频
|
|
final level = 0.0.obs;
|
|
|
|
/// 连续(长期)对话开关。
|
|
///
|
|
/// 关:`push2talk` —— 按住说话,客户端用 beginSpeech/endSpeech 自己划段。
|
|
/// 开:`duplex` —— 麦克风一直开着,切句交给服务端 VAD,用户可以一直说下去。
|
|
///
|
|
/// ⚠️ 切换必须重连会话:模式是 `start()` 时定死的会话级参数,中途改不了。
|
|
///
|
|
/// ⚠️ duplex 在手机上能成立的前提是 AEC —— 外放的播报会被自己的麦收回去,
|
|
/// 服务端 VAD 会当成用户插话,每次 AI 一开口就被自己打断。
|
|
/// [MicPcmStreamer] 已经开了 echoCancel/noiseSuppress,所以不需要像耳机那条
|
|
/// 链路那样做「播报期间门控上行」的半双工妥协(见 DeviceAiSessionService 的注释:
|
|
/// 耳机没把下行接进 AEC 参考路径,只能门控)。
|
|
///
|
|
/// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮
|
|
/// 显式开始,再点一下结束,可以反复开关;退出页面时 [onClose] 一并收掉。
|
|
///
|
|
/// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在
|
|
/// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次
|
|
/// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期
|
|
/// (见 DeviceAiSessionService)。
|
|
final isContinuous = false.obs;
|
|
|
|
/// 文字输入模式 / 语音输入模式
|
|
final isTextMode = false.obs;
|
|
final hasInputText = false.obs;
|
|
|
|
final TextEditingController textController = TextEditingController();
|
|
final ScrollController scrollController = ScrollController();
|
|
|
|
/// 用户是不是手动往上翻历史去了。
|
|
/// 翻上去之后就别再把他拽回底部,直到他自己滑回底部附近。
|
|
final isPinnedToBottom = true.obs;
|
|
|
|
/// 距底部多少像素以内算"还贴着底"
|
|
static const double _bottomSlack = 80;
|
|
|
|
bool get isResponding =>
|
|
dialogState.value == BailianDialogState.responding ||
|
|
dialogState.value == BailianDialogState.thinking;
|
|
|
|
bool get canTalk => linkState.value == EmaiLinkState.ready;
|
|
|
|
// ---------------- 内部 ----------------
|
|
|
|
final _subs = <StreamSubscription>[];
|
|
Worker? _storeWorker;
|
|
|
|
/// Start 发出去之后等 Started 事件的看门狗。
|
|
/// 服务端偶尔会既不回 Started 也不回 task-failed(鉴权被网关拦掉时),
|
|
/// 没有这个定时器 UI 会永远停在"正在连接",连重连入口都点不到。
|
|
Timer? _handshakeTimer;
|
|
static const Duration _handshakeTimeout = Duration(seconds: 15);
|
|
|
|
// ---- 手机麦半双工门(仅 [halfDuplexMic])----
|
|
bool _micGated = false;
|
|
Timer? _micGateOpenTimer;
|
|
Timer? _micGateWatchdog;
|
|
|
|
/// 播完之后再等这么久才开麦:外放尾音与房间混响
|
|
static const Duration _micGateTail = Duration(milliseconds: 300);
|
|
|
|
/// 本轮开始收下行音频的时刻,与累计收到的音频秒数:用来估「这一轮应该什么时候播完」
|
|
DateTime? _roundAudioStart;
|
|
double _roundAudioSec = 0;
|
|
|
|
/// 预计播完时刻之后再宽限这么久,还没等到播放器的完成回调就判定卡住
|
|
static const Duration _playbackGrace = Duration(seconds: 8);
|
|
|
|
/// 回复开始后一包音频都没来(纯文字回复、或下行断了)时的兜底
|
|
static const Duration _noAudioTimeout = Duration(seconds: 30);
|
|
|
|
void _closeMicGate() {
|
|
_micGateOpenTimer?.cancel();
|
|
_micGateOpenTimer = null;
|
|
if (!_micGated) {
|
|
_micGated = true;
|
|
_roundAudioStart = null;
|
|
_roundAudioSec = 0;
|
|
Logger.i(_tag, '半双工:AI 开口,手机麦静音');
|
|
}
|
|
_armPlaybackWatchdog();
|
|
}
|
|
|
|
/// 每收到一包下行音频调一次:把这包的时长记进本轮
|
|
void _onDownlinkAudio(int bytes) {
|
|
_roundAudioStart ??= DateTime.now();
|
|
// 16bit 单声道:每秒 sampleRate*2 字节
|
|
_roundAudioSec += bytes / (BailianMultimodalService.downstreamSampleRate * 2);
|
|
_armPlaybackWatchdog();
|
|
}
|
|
|
|
/// 播放兜底:按「开始收音频 + 已收音频总时长 + 宽限」算截止时刻(没音频就按 [_noAudioTimeout])。
|
|
///
|
|
/// ⚠️ 不能按「多久没来新音频」算:百炼合成比实时快,一段 60 秒的长回复可能 10 秒就全到了,
|
|
/// 那样会在还在播的时候就误判卡住、把麦打开。
|
|
void _armPlaybackWatchdog() {
|
|
_micGateWatchdog?.cancel();
|
|
final start = _roundAudioStart;
|
|
final Duration wait;
|
|
if (start == null) {
|
|
wait = _noAudioTimeout;
|
|
} else {
|
|
final end = start.add(Duration(milliseconds: (_roundAudioSec * 1000).round()) + _playbackGrace);
|
|
final left = end.difference(DateTime.now());
|
|
wait = left.isNegative ? Duration.zero : left;
|
|
}
|
|
_micGateWatchdog = Timer(wait, _onPlaybackStuck);
|
|
}
|
|
|
|
/// 过了预计播完时刻还没等到播放器报完成:播放卡住了。
|
|
///
|
|
/// 只开麦是不够的——百炼 duplex 要等客户端发 LocalRespondingEnded 才回到聆听,
|
|
/// 播放器不报完成、这条通知就永远不发,服务端一直停在「回复中」,之后说什么都没反应
|
|
/// (2026-09-26 真机:第 50 轮左右卡住,再也不回复)。所以这里替播放器收尾:
|
|
/// 停掉播放、补发通知、再开麦。
|
|
void _onPlaybackStuck() {
|
|
if (!_micGated) return;
|
|
Logger.w(_tag,
|
|
'半双工:本轮音频 ${_roundAudioSec.toStringAsFixed(1)}s 已过预计播完时刻仍未收到播完回调,'
|
|
'判定播放卡住:停播放、补发本地播完、开麦');
|
|
unawaited(_player.stopNow());
|
|
_svc.notifyLocalRespondingEnded();
|
|
level.value = 0;
|
|
_openMicGate();
|
|
}
|
|
|
|
void _openMicGate({Duration after = Duration.zero}) {
|
|
_micGateOpenTimer?.cancel();
|
|
void open() {
|
|
_micGateOpenTimer = null;
|
|
_micGateWatchdog?.cancel();
|
|
_micGateWatchdog = null;
|
|
if (_micGated) {
|
|
_micGated = false;
|
|
Logger.i(_tag, '半双工:AI 说完,手机麦恢复');
|
|
}
|
|
}
|
|
|
|
if (after == Duration.zero) {
|
|
open();
|
|
} else {
|
|
_micGateOpenTimer = Timer(after, open);
|
|
}
|
|
}
|
|
|
|
/// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。
|
|
static const Duration _continuousIdleTimeout = Duration(seconds: 10);
|
|
|
|
/// 空闲计时器。只在连续对话期间跑。
|
|
///
|
|
/// ⚠️ 计时的依据必须是**对话事件**,不能是上行音频包——连续模式下麦克风
|
|
/// 即使没人说话也在稳定出包,拿它续命等于这个超时永远不会触发。
|
|
Timer? _idleTimer;
|
|
|
|
/// 连上之后被动断线时自动重连一次;再断就交给用户手动点
|
|
bool _autoRetried = false;
|
|
|
|
/// 一次"按住说话"的代际号。
|
|
///
|
|
/// [startTalk] 里要 await 麦克风启动,用户完全可能在这期间就松手了;
|
|
/// 没有这个号的话,await 回来后仍会补发 SendSpeech —— 排在 StopSpeech 之后,
|
|
/// 服务端的语音段就错位了,而且麦克风开着再没人去关。
|
|
int _talkGen = 0;
|
|
|
|
/// SendSpeech 已发出、还没 StopSpeech/CancelSpeech 收尾。
|
|
/// 没开过段就不该发收尾指令,否则服务端会收到一条无主的 StopSpeech。
|
|
bool _speechOpen = false;
|
|
|
|
/// 当前正在流式接收的两条消息(收完就置空)
|
|
EmaiMessage? _streamingUser;
|
|
EmaiMessage? _streamingAssistant;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
textController.addListener(() {
|
|
hasInputText.value = textController.text.trim().isNotEmpty;
|
|
});
|
|
scrollController.addListener(_onScroll);
|
|
// 耳机会话是往同一个 store 里写的(页面开着时耳机也可能在聊),
|
|
// 那条路径不经过本控制器的事件处理,得单独盯着 store 才能跟到底
|
|
_storeWorker = ever(_store.messages, (_) => _scrollToBottom());
|
|
|
|
// 让 DeviceAiSessionService 能在耳机唤醒时找到本页并要求让出会话,
|
|
// 同时避免 data 层反向 import modules 层。
|
|
// 用普通引用而不是 Get.put<EmaiSessionOwner>(this):后者会让 GetX
|
|
// 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。
|
|
if (Get.isRegistered<DeviceAiSessionService>()) {
|
|
DeviceAiSessionService.to.attachPhonePage(this);
|
|
}
|
|
|
|
_subs.add(_svc.events.listen(_onEvent));
|
|
_subs.add(_svc.audioOut.listen((pcm) {
|
|
if (halfDuplexMic) {
|
|
_closeMicGate();
|
|
_onDownlinkAudio(pcm.length);
|
|
}
|
|
_player.feed(pcm);
|
|
}));
|
|
_subs.add(_mic.packets.listen((pcm) {
|
|
// 门关着就推等长静音,不停推流:服务端的 VAD / 断句按时间走,断流会被当成网络问题
|
|
_svc.pushAudio(_micGated ? Uint8List(pcm.length) : pcm);
|
|
}));
|
|
// ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false,
|
|
// 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。
|
|
_subs.add(_mic.level.listen((v) {
|
|
if (isTalking.value || isContinuous.value) level.value = v;
|
|
}));
|
|
_subs.add(_player.level.listen((v) {
|
|
if (!isTalking.value) level.value = v;
|
|
}));
|
|
_subs.add(_player.onCompleted.listen((_) {
|
|
// 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle
|
|
_svc.notifyLocalRespondingEnded();
|
|
level.value = 0;
|
|
// 真正播完再开麦,多等一点让外放的尾音散掉,别被自己的麦收回去
|
|
if (halfDuplexMic) _openMicGate(after: _micGateTail);
|
|
}));
|
|
|
|
_jumpToBottomOnEnter();
|
|
if (Get.isRegistered<DeviceAiSessionService>()) {
|
|
Logger.i(_tag,
|
|
'聊天页打开 (设备对话${DeviceAiSessionService.to.isActive.value ? '进行中,本页只显示、由设备收音' : '未进行'})');
|
|
}
|
|
connect();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
if (Get.isRegistered<DeviceAiSessionService>()) {
|
|
final device = DeviceAiSessionService.to;
|
|
// 离开聊天页 = 结束这次对话。设备唤醒的会话此刻正显示在本页上(onEarphone),
|
|
// 也一并结束并通知设备退出 AI 模式——否则 App 这边聊天页没了,
|
|
// 设备还停在「聆听中」、AI 还在后台说话,两边状态对不上(2026-09-25 反馈)。
|
|
//
|
|
// ⚠️ 必须是「正在显示这场设备对话的那个页面」才能结束它。只判 isActive 的话,
|
|
// 进 EaiCar 页时被顶掉的旧页面实例一 onClose 就把刚唤醒的设备会话掐了——
|
|
// 用户在主界面唤醒、点进 EaiCar 页,接着对设备说话就再也没回应(2026-09-25 真机)。
|
|
// 只对车载香薰:恒玄耳机的会话离开页面后照旧继续(保持原有行为)
|
|
final showingDevice = device.activeIsEaiCar &&
|
|
device.isAttachedPage(this) &&
|
|
linkState.value == EmaiLinkState.onEarphone;
|
|
device.detachPhonePage(this);
|
|
Logger.i(_tag,
|
|
'聊天页关闭 (设备会话${device.isActive.value ? '进行中' : '未进行'},本页${showingDevice ? '正显示它,一并结束' : '未显示它,不动'})');
|
|
if (showingDevice && device.isActive.value) {
|
|
unawaited(device.requestStop());
|
|
}
|
|
}
|
|
_handshakeTimer?.cancel();
|
|
_micGateOpenTimer?.cancel();
|
|
_micGateWatchdog?.cancel();
|
|
_cancelIdle();
|
|
for (final s in _subs) {
|
|
s.cancel();
|
|
}
|
|
_subs.clear();
|
|
_mic.dispose();
|
|
_player.dispose();
|
|
_svc.stop();
|
|
_storeWorker?.dispose();
|
|
textController.dispose();
|
|
scrollController.removeListener(_onScroll);
|
|
scrollController.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// 退到后台时松开麦克风:iOS 后台录音要额外能力,Android 上也没必要占着
|
|
if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.inactive) {
|
|
if (isTalking.value) _abortTalk();
|
|
// 连续模式下麦克风常开,切后台必须松开:iOS 后台持麦既费电,也可能被系统
|
|
// 直接掐掉会话。
|
|
//
|
|
// ⚠️ 这里只停麦、**不动 isContinuous**,也不重连。连续是手机端的默认模式,
|
|
// 切一次后台就把它降级成「按住说话」的话,用户回来会发现说话没反应,
|
|
// 还得自己再点一下——而且重连要一秒多,纯属白费。
|
|
if (_micRunning) {
|
|
_micRunning = false;
|
|
_mic.stop();
|
|
level.value = 0;
|
|
}
|
|
// 后台不计空闲:回来时再重新开始数,否则挂后台十秒就被判定结束了
|
|
_cancelIdle();
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
// 回前台把麦接回来。会话还在(duplex 会话本身没断),只是麦停了。
|
|
if (isContinuous.value) {
|
|
unawaited(_ensureContinuousMic());
|
|
_touchIdle();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- 连接 ----------------
|
|
|
|
Future<void> connect() async {
|
|
if (linkState.value == EmaiLinkState.connecting) return;
|
|
// 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来
|
|
if (linkState.value == EmaiLinkState.onEarphone) return;
|
|
if (Get.isRegistered<DeviceAiSessionService>() &&
|
|
DeviceAiSessionService.to.isActive.value) {
|
|
linkState.value = EmaiLinkState.onEarphone;
|
|
return;
|
|
}
|
|
|
|
if (!AppConfig.isInitialized() || !_svc.isConfigured) {
|
|
linkState.value = EmaiLinkState.unconfigured;
|
|
Logger.w(_tag, 'EMAI 助手未配置:${_svc.configHint}');
|
|
return;
|
|
}
|
|
|
|
linkState.value = EmaiLinkState.connecting;
|
|
// MCP 令牌要在 Start 之前拿到:biz_params 是握手时定死的,
|
|
// 会话建起来之后再补就没地方放了。
|
|
final mcpToken = await McpTokenService.get();
|
|
final ok = await _svc.start(
|
|
mode: isContinuous.value ? 'duplex' : 'push2talk',
|
|
userId: _currentUserId(),
|
|
mcpToken: mcpToken,
|
|
);
|
|
if (!ok) {
|
|
linkState.value = EmaiLinkState.failed;
|
|
return;
|
|
}
|
|
// ok == true 只代表 Start 发出去了,真正就绪要等 Started 事件回来
|
|
_handshakeTimer?.cancel();
|
|
_handshakeTimer = Timer(_handshakeTimeout, () {
|
|
if (linkState.value != EmaiLinkState.connecting) return;
|
|
Logger.e(_tag, '握手超时:${_handshakeTimeout.inSeconds}s 内没有收到 Started');
|
|
linkState.value = EmaiLinkState.failed;
|
|
_pushError('emaiConnectFailed'.tr);
|
|
});
|
|
}
|
|
|
|
Future<void> reconnect() async {
|
|
_handshakeTimer?.cancel();
|
|
_autoRetried = false;
|
|
await _abortTalk();
|
|
await _svc.stop();
|
|
linkState.value = EmaiLinkState.idle;
|
|
await connect();
|
|
}
|
|
|
|
// ---------------- 与耳机 AI 会话互斥 ----------------
|
|
|
|
@override
|
|
Future<void> yieldToEarphone() async {
|
|
_handshakeTimer?.cancel();
|
|
_handshakeTimer = null;
|
|
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的
|
|
await _abortTalk();
|
|
await _player.stopNow();
|
|
if (halfDuplexMic) _openMicGate();
|
|
await _svc.stop();
|
|
linkState.value = EmaiLinkState.onEarphone;
|
|
}
|
|
|
|
/// 手机页面主动把控制权要回来:让耳机退出 AI 模式。
|
|
///
|
|
/// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
|
|
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
|
|
/// 所以这里不用自己 connect(),否则会和耳机会话抢。
|
|
/// 设备对话中点了本页的说话按钮:结束设备对话,接回来后直接进连续对话。
|
|
///
|
|
/// ⚠️ 2026-09-25 以前设备对话中这个按钮是灰的、写着「离线」,用户在主界面唤醒后
|
|
/// 点进 EaiCar 页想接着聊,按了没有任何反应。
|
|
Future<void> takeBackAndTalk() async {
|
|
_talkAfterTakeBack = true;
|
|
await takeBackFromEarphone();
|
|
}
|
|
|
|
bool _talkAfterTakeBack = false;
|
|
|
|
/// 设备对话中底部按钮要不要换成「改用手机」:只对车载香薰。
|
|
/// 恒玄耳机保持原样(按钮置灰,顶部状态条上有「收回控制」)。
|
|
bool get showDeviceTakeOverButton =>
|
|
linkState.value == EmaiLinkState.onEarphone &&
|
|
Get.isRegistered<DeviceAiSessionService>() &&
|
|
DeviceAiSessionService.to.activeIsEaiCar;
|
|
|
|
Future<void> takeBackFromEarphone() async {
|
|
Logger.i(_tag, '用户点「收回」:结束设备对话,改在手机上聊');
|
|
if (!Get.isRegistered<DeviceAiSessionService>()) {
|
|
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone
|
|
linkState.value = EmaiLinkState.idle;
|
|
await connect();
|
|
return;
|
|
}
|
|
linkState.value = EmaiLinkState.connecting;
|
|
await DeviceAiSessionService.to.requestStop();
|
|
}
|
|
|
|
@override
|
|
Future<void> resumeFromEarphone() async {
|
|
// onEarphone = 耳机自己结束了;connecting = 本页刚点了"收回控制"。
|
|
// 其它状态说明页面已经在正常跑,不要打断。
|
|
if (linkState.value != EmaiLinkState.onEarphone &&
|
|
linkState.value != EmaiLinkState.connecting) {
|
|
return;
|
|
}
|
|
if (_talkAfterTakeBack) {
|
|
_talkAfterTakeBack = false;
|
|
// 连续对话:Started 一到就开手机麦(见 _onEvent 的 started 分支)
|
|
isContinuous.value = true;
|
|
}
|
|
linkState.value = EmaiLinkState.idle;
|
|
_autoRetried = false;
|
|
await connect();
|
|
}
|
|
|
|
String _currentUserId() {
|
|
try {
|
|
return user_info.User.isLoggedIn()
|
|
? user_info.User.instance.uid
|
|
: 'eaimar-guest';
|
|
} catch (_) {
|
|
return 'eaimar-guest';
|
|
}
|
|
}
|
|
|
|
// ---------------- 语音交互(push2talk) ----------------
|
|
|
|
/// 切换连续对话。
|
|
///
|
|
/// 走的是「停会话 → 换模式重连 → 开麦」这条路,因为模式是会话级参数。
|
|
/// 重连期间界面会短暂回到「正在连接」,这是预期的。
|
|
Future<void> toggleContinuous() async {
|
|
if (isContinuous.value) {
|
|
await _exitContinuous();
|
|
} else {
|
|
await _enterContinuous();
|
|
}
|
|
}
|
|
|
|
Future<void> _enterContinuous() async {
|
|
if (!canTalk) return;
|
|
// 手上还有一段按住说话没结束的,按取消收掉——它属于旧模式
|
|
await _abortTalk();
|
|
isContinuous.value = true;
|
|
_buzz(HapticsType.light);
|
|
await reconnect();
|
|
if (!isContinuous.value) return; // 重连期间用户又关掉了
|
|
// ⚠️ 必须等 Started 事件,不能在 reconnect() 一返回就判断状态。
|
|
// reconnect() 只负责把 Start 指令发出去,`ready` 要等服务端回 Started 才置位,
|
|
// 那是一次网络往返。原来这里直接判 `!= ready`,几乎每次都命中,于是刚打开的
|
|
// 连续对话被当场关掉——按钮闪一下就退回「点击开始连续对话」,而 Started 稍后
|
|
// 到达时又会把麦克风开起来,留下一个界面上看不见的常开麦克风。
|
|
if (!await _waitLinkReady(_handshakeTimeout)) {
|
|
// 没连上就别开麦,否则麦克风白开着还没人收
|
|
isContinuous.value = false;
|
|
// Started 可能已经抢先把麦开起来了,一并收掉,别留孤儿
|
|
if (_micRunning) {
|
|
_micRunning = false;
|
|
await _mic.stop();
|
|
level.value = 0;
|
|
}
|
|
return;
|
|
}
|
|
await _ensureContinuousMic();
|
|
_touchIdle();
|
|
}
|
|
|
|
/// 有对话往来就把空闲计时重新拨到头
|
|
void _touchIdle() {
|
|
_idleTimer?.cancel();
|
|
if (!isContinuous.value) return;
|
|
_idleTimer = Timer(_continuousIdleTimeout, _onIdleTimeout);
|
|
}
|
|
|
|
void _cancelIdle() {
|
|
_idleTimer?.cancel();
|
|
_idleTimer = null;
|
|
}
|
|
|
|
void _onIdleTimeout() {
|
|
if (!isContinuous.value) return;
|
|
// 助手还在想/还在说(或者本地还在播它的音频)不算空闲——
|
|
// 一段长回复中间可能十几秒没有任何新事件,这时结束会把话掐断。
|
|
if (isResponding || _player.isPlaying) {
|
|
_touchIdle();
|
|
return;
|
|
}
|
|
Logger.i(_tag, '连续对话空闲 ${_continuousIdleTimeout.inSeconds}s,自动结束');
|
|
unawaited(_exitContinuous());
|
|
}
|
|
|
|
/// 等握手落地。ready 返回 true;失败/超时返回 false。
|
|
Future<bool> _waitLinkReady(Duration timeout) async {
|
|
if (linkState.value == EmaiLinkState.ready) return true;
|
|
final done = Completer<bool>();
|
|
final worker = ever<EmaiLinkState>(linkState, (s) {
|
|
if (done.isCompleted) return;
|
|
if (s == EmaiLinkState.ready) {
|
|
done.complete(true);
|
|
} else if (s == EmaiLinkState.failed ||
|
|
s == EmaiLinkState.unconfigured ||
|
|
s == EmaiLinkState.onEarphone) {
|
|
done.complete(false);
|
|
}
|
|
});
|
|
try {
|
|
return await done.future.timeout(timeout, onTimeout: () => false);
|
|
} finally {
|
|
worker.dispose();
|
|
}
|
|
}
|
|
|
|
/// 连续模式下把麦克风拉起来(幂等,重复调用无副作用)。
|
|
///
|
|
/// ⚠️ duplex 下**不发** beginSpeech/endSpeech:切句由服务端 VAD 决定,
|
|
/// 客户端再去划段会和它打架(见 BailianMultimodalService.start 的说明)。
|
|
Future<void> _ensureContinuousMic() async {
|
|
if (!isContinuous.value) return;
|
|
if (linkState.value != EmaiLinkState.ready) return;
|
|
if (_micRunning) return;
|
|
if (!await _mic.hasPermission()) {
|
|
isContinuous.value = false;
|
|
_pushError('emaiMicPermission'.tr);
|
|
return;
|
|
}
|
|
if (!isContinuous.value) return; // 等权限期间用户关掉了
|
|
// ⚠️ 必须在 await 之前同步置位。`Started` 事件和 `_enterContinuous` 会几乎
|
|
// 同时调进来,都在这里 await 的话两边都会看到 false、都去开麦,
|
|
// 同一个 recorder 被 startStream 两遍,第二次顶掉第一次那条流。
|
|
// (MicPcmStreamer 内部也做了一层去重,两层都留着——这里少一次无谓的往返。)
|
|
_micRunning = true;
|
|
if (!await _mic.start()) {
|
|
_micRunning = false;
|
|
isContinuous.value = false;
|
|
_pushError('emaiMicPermission'.tr);
|
|
}
|
|
}
|
|
|
|
/// 连续模式下麦克风是否已经开着。[MicPcmStreamer] 自己不暴露状态,
|
|
/// 重复 start 会重开一路采集,必须在这里记账。
|
|
bool _micRunning = false;
|
|
|
|
|
|
Future<void> _exitContinuous() async {
|
|
isContinuous.value = false;
|
|
_cancelIdle();
|
|
_buzz(HapticsType.light);
|
|
await _mic.stop();
|
|
_micRunning = false;
|
|
level.value = 0;
|
|
await reconnect();
|
|
}
|
|
|
|
Future<void> startTalk() async {
|
|
if (!canTalk || isTalking.value) return;
|
|
// 连续模式下麦克风本来就开着,再走按住说话那套会重复开麦
|
|
if (isContinuous.value) return;
|
|
|
|
// isTalking 必须在任何 await 之前同步置位。
|
|
// 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后
|
|
// 照样把麦克风开起来 —— 按钮永远停在"松开发送",麦克风再没人关得掉。
|
|
final gen = ++_talkGen;
|
|
isTalking.value = true;
|
|
isCancelPending.value = false;
|
|
_talkPressAt = DateTime.now();
|
|
_buzz(HapticsType.light);
|
|
|
|
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权
|
|
if (isResponding) {
|
|
await _player.stopNow();
|
|
if (halfDuplexMic) _openMicGate();
|
|
_svc.interrupt();
|
|
}
|
|
if (gen != _talkGen) return;
|
|
|
|
if (!await _mic.hasPermission()) {
|
|
if (gen == _talkGen) {
|
|
isTalking.value = false;
|
|
_pushError('emaiMicPermission'.tr);
|
|
}
|
|
return;
|
|
}
|
|
if (gen != _talkGen) return;
|
|
|
|
// 先把麦克风拉起来再通知服务端开始收音:反过来的话,
|
|
// SendSpeech 到首包音频之间的空档会被服务端 VAD 当成静音
|
|
final started = await _mic.start();
|
|
|
|
// 启动期间已经松手:把刚拉起来的麦克风关掉就完事,
|
|
// 这一段语音当没发生过,不要再向服务端开段
|
|
if (gen != _talkGen) {
|
|
await _mic.stop();
|
|
return;
|
|
}
|
|
if (!started) {
|
|
isTalking.value = false;
|
|
_pushError('emaiMicPermission'.tr);
|
|
return;
|
|
}
|
|
_svc.beginSpeech();
|
|
_speechOpen = true;
|
|
}
|
|
|
|
/// 手指滑出/滑回按钮区域
|
|
void updateCancelPending(bool pending) {
|
|
if (!isTalking.value) return;
|
|
if (isCancelPending.value == pending) return;
|
|
isCancelPending.value = pending;
|
|
// 越过取消线时给一下反馈,用户不用低头看也知道松手会取消
|
|
_buzz(HapticsType.warning);
|
|
}
|
|
|
|
/// 按下到松开短于这个时长,算「点一下」而不是「按住说话」。
|
|
///
|
|
/// 250ms 是取舍出来的:再短容易把用户想按住说话时手抖的一下误判成点击,
|
|
/// 再长会让真正的点击有明显延迟。反正短于 250ms 的语音本来也没有内容。
|
|
static const Duration _tapThreshold = Duration(milliseconds: 250);
|
|
DateTime? _talkPressAt;
|
|
|
|
Future<void> endTalk() async {
|
|
if (!isTalking.value) return;
|
|
final pressed = _talkPressAt;
|
|
final isTap = !isCancelPending.value &&
|
|
pressed != null &&
|
|
DateTime.now().difference(pressed) < _tapThreshold;
|
|
if (isTap) {
|
|
// 点一下 = 切换连续对话。刚才 onPointerDown 已经开了一小段语音,
|
|
// 按取消收掉——这半秒不到的音频没有任何内容,发上去只会多一轮空回复。
|
|
await _finishTalk(cancel: true);
|
|
await toggleContinuous();
|
|
return;
|
|
}
|
|
final cancelled = isCancelPending.value;
|
|
_buzz(cancelled ? HapticsType.warning : HapticsType.light);
|
|
await _finishTalk(cancel: cancelled);
|
|
}
|
|
|
|
/// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理
|
|
Future<void> _abortTalk() {
|
|
if (!isTalking.value) return Future.value();
|
|
return _finishTalk(cancel: true);
|
|
}
|
|
|
|
Future<void> _finishTalk({required bool cancel}) async {
|
|
// 先推进代际:还卡在 _mic.start() 里的那次 startTalk 会因此作废
|
|
_talkGen++;
|
|
isTalking.value = false;
|
|
isCancelPending.value = false;
|
|
level.value = 0;
|
|
|
|
// 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令,
|
|
// 保证最后半个字也进了服务端
|
|
await _mic.stop();
|
|
_micRunning = false;
|
|
|
|
if (_speechOpen) {
|
|
_speechOpen = false;
|
|
if (cancel) {
|
|
_svc.cancelSpeech();
|
|
} else {
|
|
_svc.endSpeech();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- 文字交互 ----------------
|
|
|
|
void sendTextMessage() {
|
|
final text = textController.text.trim();
|
|
if (text.isEmpty || !canTalk) return;
|
|
textController.clear();
|
|
hasInputText.value = false;
|
|
|
|
if (isResponding) {
|
|
_player.stopNow();
|
|
if (halfDuplexMic) _openMicGate();
|
|
_svc.interrupt();
|
|
}
|
|
|
|
// 服务端会把这条 prompt 当成"用户说的话"再回一遍 SpeechContent。
|
|
// 把本地这条挂到 _streamingUser 上,回显就并进同一个气泡,
|
|
// 否则同一句话会在列表里出现两次。
|
|
final bubble = EmaiMessage(isUser: true, text: text, isFinal: true);
|
|
_store.append(bubble);
|
|
_scrollToBottom(force: true); // 自己发的消息一定要看到
|
|
_streamingUser = bubble;
|
|
|
|
_svc.sendText(text);
|
|
}
|
|
|
|
/// 一轮对话结束后上报**原始字数**(输入 = 这轮之前最近一条用户消息,输出 = 助手这条回复),
|
|
/// 服务端按后台换算系数折成算力记账。客户端**不算算力**——系数可在后台改,客户端算的话
|
|
/// 改一次系数就要发一次版。字符数按 Unicode 码点(runes)计,与服务端口径一致。
|
|
/// 上报失败只记日志:这是记账不是闸门,不该影响对话。
|
|
void _reportAiUsage(EmaiMessage assistant) {
|
|
int up = 0;
|
|
for (final msg in messages.reversed) {
|
|
if (identical(msg, assistant)) continue;
|
|
if (msg.isUser) {
|
|
up = msg.text.runes.length;
|
|
break;
|
|
}
|
|
}
|
|
final down = assistant.text.runes.length;
|
|
if (up == 0 && down == 0) return;
|
|
unawaited(Api.usagesResPoints({
|
|
'usagetype': 3, // UsageType.AI
|
|
'usages': <String, int>{},
|
|
'upchars': up,
|
|
'downchars': down,
|
|
}).catchError((e) {
|
|
Logger.w(_tag, 'AI 用量上报失败: $e');
|
|
return null;
|
|
}));
|
|
}
|
|
|
|
/// 打断当前回复
|
|
Future<void> interrupt() async {
|
|
if (!isResponding) return;
|
|
await _player.stopNow();
|
|
if (halfDuplexMic) _openMicGate();
|
|
_svc.interrupt();
|
|
_streamingAssistant?.isFinal = true;
|
|
_streamingAssistant = null;
|
|
messages.refresh();
|
|
}
|
|
|
|
void toggleInputMode() {
|
|
isTextMode.value = !isTextMode.value;
|
|
if (!isTextMode.value) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
}
|
|
|
|
void clearMessages() {
|
|
_store.clear();
|
|
_streamingUser = null;
|
|
_streamingAssistant = null;
|
|
}
|
|
|
|
// ---------------- 事件处理 ----------------
|
|
|
|
void _onEvent(BailianEvent e) {
|
|
// 任何一条对话事件都算「还在聊」,把空闲计时拨回去。
|
|
// 到这里的事件都是真实往来(SpeechXxx / RespondingXxx / 状态变化),
|
|
// 心跳在协议层就被吞掉了,不会误续命。
|
|
_touchIdle();
|
|
switch (e.type) {
|
|
case BailianEventType.started:
|
|
_handshakeTimer?.cancel();
|
|
_handshakeTimer = null;
|
|
_autoRetried = false;
|
|
// 新会话,上一条连接里没收尾的语音段跟着作废
|
|
_speechOpen = false;
|
|
linkState.value = EmaiLinkState.ready;
|
|
// duplex 会话就绪 = 该开麦了。服务端此刻开始做 VAD,麦克风不开的话
|
|
// 它永远收不到音频,界面上就是「已就绪但说什么都没反应」。
|
|
if (isContinuous.value) unawaited(_ensureContinuousMic());
|
|
break;
|
|
|
|
case BailianEventType.speechStarted:
|
|
// 这里**不**预先建气泡:ASR 一句都没识别出来时,服务端回的是
|
|
// Error("No speech recognized from audio!")而不是 SpeechEnded,
|
|
// 预建的空气泡就没人清理,会永久留在列表里。
|
|
// 真正的气泡等第一个 SpeechContent 到了再懒建(见下面的分支)。
|
|
_streamingUser = null;
|
|
break;
|
|
|
|
case BailianEventType.speechContent:
|
|
_streamingUser ??= () {
|
|
final m = EmaiMessage(isUser: true, text: '');
|
|
_append(m);
|
|
return m;
|
|
}();
|
|
_mergeInto(_streamingUser!, e.text);
|
|
if (e.isFinal) {
|
|
_streamingUser!.isFinal = true;
|
|
_streamingUser = null;
|
|
}
|
|
messages.refresh();
|
|
break;
|
|
|
|
case BailianEventType.speechEnded:
|
|
final m = _streamingUser;
|
|
if (m != null) {
|
|
if (e.text.isNotEmpty) _mergeInto(m, e.text);
|
|
// ASR 一个字都没出来(误触/纯噪声):把空气泡撤掉,别留一条空消息
|
|
_store.finalize(m);
|
|
_streamingUser = null;
|
|
}
|
|
break;
|
|
|
|
case BailianEventType.respondingStarted:
|
|
if (halfDuplexMic) _closeMicGate();
|
|
// 上一轮回复没等到 RespondingEnded 就被新一轮顶掉(被插话打断):先把它收尾,
|
|
// 否则它一直是「流式中」,新旧两轮的字幕会交错写进列表(2026-09-26 真机「字幕乱串」)
|
|
final prev = _streamingAssistant;
|
|
if (prev != null) {
|
|
_dropIfEmpty(prev);
|
|
prev.isFinal = true;
|
|
_store.finalize(prev);
|
|
}
|
|
_streamingAssistant = EmaiMessage(isUser: false, text: '');
|
|
_append(_streamingAssistant!);
|
|
break;
|
|
|
|
case BailianEventType.respondingContent:
|
|
_dispatchDirectives(e);
|
|
// finish_reason=command_calls 的纯指令帧 text 是空的,而且这一轮
|
|
// 不会再来 RespondingStarted/Ended。照常建气泡的话会在列表里留一个
|
|
// 永远填不上的空泡(就是聊天记录里那几个孤零零的 “• • •”)。
|
|
if (e.text.isEmpty && e.finishReason == 'command_calls') break;
|
|
_streamingAssistant ??= () {
|
|
final m = EmaiMessage(isUser: false, text: '');
|
|
_append(m);
|
|
return m;
|
|
}();
|
|
_mergeInto(_streamingAssistant!, e.text);
|
|
messages.refresh();
|
|
_scrollToBottom();
|
|
break;
|
|
|
|
case BailianEventType.respondingEnded:
|
|
final m = _streamingAssistant;
|
|
if (m != null) {
|
|
if (e.text.isNotEmpty) _mergeInto(m, e.text);
|
|
_store.finalize(m);
|
|
_streamingAssistant = null;
|
|
_reportAiUsage(m);
|
|
}
|
|
// 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完
|
|
_player.end();
|
|
break;
|
|
|
|
case BailianEventType.stateChanged:
|
|
// dialogState 直接绑的是 service 里的 Rx,这里只需要驱动一次滚动
|
|
_scrollToBottom();
|
|
break;
|
|
|
|
case BailianEventType.error:
|
|
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
|
|
_abortTalk();
|
|
if (halfDuplexMic) _openMicGate();
|
|
if (e.fatal) {
|
|
// 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗,
|
|
// 状态条转成可重连的错误态
|
|
_handshakeTimer?.cancel();
|
|
_handshakeTimer = null;
|
|
linkState.value = e.code == 'NOT_CONFIGURED'
|
|
? EmaiLinkState.unconfigured
|
|
: EmaiLinkState.failed;
|
|
} else {
|
|
// 轮次级错误(音色不对、没识别到语音、模型超时之类):连接还在,
|
|
// 把这一轮的流式气泡收干净就行,不要把整页判成"连接失败"
|
|
_player.stopNow();
|
|
_dropIfEmpty(_streamingUser);
|
|
_streamingUser = null;
|
|
_dropIfEmpty(_streamingAssistant);
|
|
_streamingAssistant?.isFinal = true;
|
|
_streamingAssistant = null;
|
|
messages.refresh();
|
|
}
|
|
// 只有致命错误才在对话里留一条红色提示。
|
|
//
|
|
// 轮次级错误("No speech recognized from audio!"、音色不对、模型超时之类)
|
|
// 属于正常噪声:按住说话没出声、误触都会触发,连接还好好的。上面那段已经
|
|
// 把这一轮的空气泡收干净了,再塞一条红色英文报错只会吓到用户,
|
|
// 而且它会永久留在聊天记录里。这类错误只记日志(本分支开头已记)。
|
|
if (e.fatal) {
|
|
_pushError(e.text.isEmpty ? 'emaiConnectFailed'.tr : e.text);
|
|
}
|
|
break;
|
|
|
|
case BailianEventType.closed:
|
|
_handshakeTimer?.cancel();
|
|
_handshakeTimer = null;
|
|
// 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备
|
|
_abortTalk();
|
|
_player.stopNow();
|
|
if (halfDuplexMic) _openMicGate();
|
|
if (linkState.value == EmaiLinkState.ready) {
|
|
linkState.value = EmaiLinkState.idle;
|
|
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点
|
|
if (!_autoRetried) {
|
|
_autoRetried = true;
|
|
Logger.i(_tag, '连接断开,自动重连一次');
|
|
connect();
|
|
}
|
|
}
|
|
break;
|
|
|
|
case BailianEventType.requestAccepted:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 把流式文本写进气泡。
|
|
///
|
|
/// 一律整体替换,不做任何拼接。会话是按 `incremental_response=false` 起的
|
|
/// (见 [BailianMultimodalService.start]),每一帧回的都是"到目前为止的全文"。
|
|
///
|
|
/// 之前这里用「新文本以旧文本开头就替换,否则追加」来两种都兼容,
|
|
/// 结果在 ASR 上翻车了:识别中间结果是会**回改**的,
|
|
/// "That's right" 下一帧可能变成 "Thats right",不构成前缀就被当增量拼上去,
|
|
/// 真机上拼出了 "ThatThat's rightThatThat's right" 这种东西。
|
|
/// 把本帧携带的端侧指令交给 [AssistantDirectiveService] 落地。
|
|
///
|
|
/// 只做转发:解析在协议层、分发与落库在 service,控制器不碰业务。
|
|
void _dispatchDirectives(BailianEvent e) {
|
|
if (e.directives.isEmpty) return;
|
|
if (!Get.isRegistered<AssistantDirectiveService>()) {
|
|
Logger.w(_tag, '指令服务未注册,丢弃 ${e.directives.length} 条指令');
|
|
return;
|
|
}
|
|
AssistantDirectiveService.to.handle(e.directives);
|
|
}
|
|
|
|
void _mergeInto(EmaiMessage m, String incoming) =>
|
|
_store.applyText(m, incoming);
|
|
|
|
void _dropIfEmpty(EmaiMessage? m) => _store.dropIfEmpty(m);
|
|
|
|
void _append(EmaiMessage m) {
|
|
_store.append(m);
|
|
_scrollToBottom();
|
|
}
|
|
|
|
void _pushError(String text) {
|
|
_append(
|
|
EmaiMessage(isUser: false, text: text, isFinal: true, isError: true));
|
|
}
|
|
|
|
/// 流式回复每来一帧就滚一次,用 animateTo 会不停打断上一个动画、
|
|
/// 看起来一顿一顿的。这里合并到下一帧再跳,并且只在用户本来就贴着底部时跳——
|
|
/// 他往上翻看历史的时候不该被硬拽回来。
|
|
bool _scrollScheduled = false;
|
|
|
|
void _onScroll() {
|
|
if (!scrollController.hasClients) return;
|
|
final pos = scrollController.position;
|
|
// 滑回底部附近就重新跟随;往上翻就停住
|
|
isPinnedToBottom.value = pos.maxScrollExtent - pos.pixels <= _bottomSlack;
|
|
}
|
|
|
|
/// 滚到底部。
|
|
///
|
|
/// [force] = true 用于进入页面和自己发消息这两种情况——那时必须落到最新一条,
|
|
/// 不管之前停在哪。其余情况(流式刷新)只在用户还贴着底部时才跟随,
|
|
/// 他正在翻历史就别打扰。
|
|
void _scrollToBottom({bool force = false}) {
|
|
if (!force && !isPinnedToBottom.value) return;
|
|
if (_scrollScheduled) return;
|
|
_scrollScheduled = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollScheduled = false;
|
|
if (!scrollController.hasClients) return;
|
|
final pos = scrollController.position;
|
|
if (!force && !isPinnedToBottom.value) return;
|
|
scrollController.jumpTo(pos.maxScrollExtent);
|
|
if (force) isPinnedToBottom.value = true;
|
|
});
|
|
}
|
|
|
|
/// 「回到最新」按钮
|
|
void jumpToLatest() => _scrollToBottom(force: true);
|
|
|
|
/// 进入页面时直接落到最底部。
|
|
///
|
|
/// 历史记录可能有上百条,列表默认停在第一行,用户得一路拉到底才看得到
|
|
/// 最新内容——这里在首帧之后直接跳到底,往上翻才是去看历史。
|
|
void _jumpToBottomOnEnter() {
|
|
if (messages.isEmpty) return;
|
|
// 首帧布局完成前 maxScrollExtent 还不准,连跳两帧兜住图片/Markdown
|
|
// 撑开高度带来的二次布局
|
|
for (final delay in const [Duration.zero, Duration(milliseconds: 120)]) {
|
|
Future.delayed(delay, () {
|
|
if (!scrollController.hasClients) return;
|
|
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
|
isPinnedToBottom.value = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _buzz(HapticsType type) {
|
|
// 震动失败(设备不支持/被系统禁用)不该影响对话
|
|
Haptics.vibrate(type).catchError((_) {});
|
|
}
|
|
}
|
|
|