diff --git a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart index 1525e5731..cfb1e2802 100644 --- a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart +++ b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart @@ -1,9 +1,13 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/widgets.dart'; +import 'package:audio_session/audio_session.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -13,8 +17,6 @@ import 'package:uuid/uuid.dart'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../../core/utils/logger.dart'; -import '../../../data/models/user_Info.dart'; -import '../../../data/services/bluetooth_manager.dart'; enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } @@ -23,55 +25,93 @@ class MobileElfMessage { final String text; final DateTime time; final String? ttsUrl; - - MobileElfMessage({ - required this.isUser, - required this.text, - required this.time, - this.ttsUrl, - }); + MobileElfMessage({required this.isUser, required this.text, required this.time, this.ttsUrl}); } class MobileElfController extends GetxController { - // 状态 + // ── 可观察状态 ───────────────────────────────────────────────────────────── final status = MobileElfStatus.idle.obs; final messages = [].obs; final realtimeText = ''.obs; final errorMsg = ''.obs; - - // WebSocket + final inCall = false.obs; + final callSeconds = 0.obs; + final recordingSeconds = 0.obs; + final micRms = 0.0.obs; // 实时麦克风音量(RMS),供 UI 显示 + final micAlive = false.obs; // 麦克风流是否存活 + final pttRecording = false.obs; // 按住说话录音中 + final pttSeconds = 0.obs; // 按住说话计时 + final ScrollController scrollController = ScrollController(); + + // ── WebSocket ────────────────────────────────────────────────────────────── WebSocketChannel? _channel; StreamSubscription? _wsSubscription; + Completer? _readyCompleter; + bool _sessionEnded = true; + String _dialogRequestId = ''; + int _currentAiMsgIndex = -1; - // 录音 + // ── 录音 & 本地 VAD ──────────────────────────────────────────────────────── final AudioRecorder _recorder = AudioRecorder(); - StreamSubscription? _audioSubscription; - final List _audioBuffer = []; - static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono + StreamSubscription? _micSubscription; - // 播放 - final AudioPlayer _player = AudioPlayer(); - - // 配置 - late String _deviceNo; - late String _deviceId; - late String _productId; - late String _productKey; - late String _deviceSecret; + // 音频发送缓冲(凑帧用) + final List _sendBuffer = []; + int _sendBufferOffset = 0; + static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono - // A80 凭证状态 + // 预缓存:滚动保留最近 ~1.5 秒音频,用于在 WS 连接前缓存用户已说的话 + final List _preBuffer = []; + static const int _preBufferMaxChunks = 75; // ~1.5s (按每 chunk 20ms 算) + + // 本地 VAD(仅在空闲等待状态工作,TTS 播放中不做 VAD) + bool _speechDetected = false; + static const int _vadThreshold = 800; + static const int _vadOnsetChunks = 3; // 连续 3 chunk (~60ms) → 检测到说话 + int _vadOnsetCount = 0; + + // 状态标记 + bool _forwardingAudio = false; // 是否正在转发音频到 WS + bool _wsConnecting = false; // WS 正在连接中(防止重复触发) + int _micChunkCount = 0; // 麦克风 chunk 计数(调试用) + + // ── 播放 & TTS 队列 ────────────────────────────────────────────────────── + // 关闭 just_audio 的音频会话管理,防止它与 record 争抢音频硬件: + // - handleInterruptions: false → 不自动暂停/恢复 + // - handleAudioSessionActivation: false → 不自动调用 session.setActive() + // - androidApplyAudioAttributes: false → 不监听 audio_session 配置变更 + final AudioPlayer _player = AudioPlayer( + handleInterruptions: false, + handleAudioSessionActivation: false, + androidApplyAudioAttributes: false, + ); + final List _ttsQueue = []; + bool _ttsPlaying = false; + bool _ttsInterrupted = false; + Completer? _ttsPlayCompleter; // 用于等待当前段播完或被打断 + + // ── 配置 ─────────────────────────────────────────────────────────────────── + late String _deviceNo, _deviceId, _productId, _productKey, _deviceSecret; final credentialReady = false.obs; final credentialError = ''.obs; - final _storage = GetStorage(); static const _kDeviceId = 'mobile_elf_device_id'; static const _kDeviceSecret = 'mobile_elf_device_secret'; static const _kDeviceNo = 'mobile_elf_device_no'; + final _uuid = const Uuid(); - // 当前对话 ID - String _dialogRequestId = ''; + // 计时器 + Timer? _callTimer; + Timer? _recordingTimer; + Timer? _micWatchdog; + int _lastWatchdogChunkCount = 0; + Timer? _idleTimer; + Timer? _pttTimer; + static const int _idleTimeoutSeconds = 15; // TTS 播完后等待说话超时秒数 - final _uuid = const Uuid(); + // ═══════════════════════════════════════════════════════════════════════════ + // 生命周期 + // ═══════════════════════════════════════════════════════════════════════════ @override void onInit() { @@ -85,224 +125,457 @@ class MobileElfController extends GetxController { disconnect(); _recorder.dispose(); _player.dispose(); + scrollController.dispose(); super.onClose(); } void _loadConfig() { _productId = dotenv.env['MOBILE_ELF_PRODUCT_ID'] ?? '1899007766144614402'; _productKey = dotenv.env['MOBILE_ELF_PRODUCT_KEY'] ?? 'd0aI2yidbUUXS'; - // 优先使用缓存的 A80 凭证,否则降级到 .env 默认值 - _deviceNo = _storage.read(_kDeviceNo) ?? - dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '869700070000570'; - _deviceId = _storage.read(_kDeviceId) ?? - dotenv.env['MOBILE_ELF_DEVICE_ID'] ?? '1899013964170694658'; - _deviceSecret = _storage.read(_kDeviceSecret) ?? - dotenv.env['MOBILE_ELF_DEVICE_SECRET'] ?? ''; + _deviceNo = _storage.read(_kDeviceNo) ?? dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? ''; + _deviceId = _storage.read(_kDeviceId) ?? ''; + _deviceSecret = _storage.read(_kDeviceSecret) ?? ''; credentialReady.value = _deviceSecret.isNotEmpty; } - /// 检测连接的耳机是否为 A80,如是则用其 license 换取平台凭证 Future _fetchA80CredentialsIfNeeded() async { try { - final btManager = Get.find(); - final deviceName = btManager.currentDevice?.name ?? ''; - final deviceMac = btManager.currentDevice?.address ?? ''; - final license = _findLicense(deviceMac); - - Logger.i('[MobileElf]', '=== 设备信息 ==='); - Logger.i('[MobileElf]', '设备名称: $deviceName'); - Logger.i('[MobileElf]', '设备MAC: $deviceMac'); - Logger.i('[MobileElf]', '匹配license: $license'); - Logger.i('[MobileElf]', '==============='); - - if (!deviceName.toUpperCase().contains('A80')) { - credentialReady.value = _deviceSecret.isNotEmpty; - return; - } - - // 已有缓存凭证且设备号未变则直接使用 - final cachedNo = _storage.read(_kDeviceNo) ?? ''; - final cachedSecret = _storage.read(_kDeviceSecret) ?? ''; - - if (license == null || license.isEmpty) { - Logger.w('[MobileElf]', 'A80 设备未找到 license,使用默认凭证'); - credentialReady.value = _deviceSecret.isNotEmpty; - return; - } - - if (cachedNo == license && cachedSecret.isNotEmpty) { - Logger.i('[MobileElf]', 'A80 凭证命中缓存,跳过请求'); - credentialReady.value = true; - return; - } - + final deviceNo = dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? ''; + if (deviceNo.isEmpty) return; credentialError.value = ''; - Logger.i('[MobileElf]', 'A80 设备 license=$license,开始请求平台凭证'); - final dio = Dio(BaseOptions( baseUrl: 'https://ivs.chinamobiledevice.com:11443', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); - - final resp = await dio.post( - '/v2/customer/device/secret/info', - data: { - 'deviceNoType': 'SN', - 'deviceNo': license, - 'productId': _productId, - 'productKey': _productKey, - }, - ); - + final resp = await dio.post('/v2/customer/device/secret/info', data: { + 'deviceNoType': 'SN', 'deviceNo': deviceNo, + 'productId': _productId, 'productKey': _productKey, + }); final body = resp.data as Map; if (body['code'].toString() == '200' && body['success'] == true) { final data = body['data'] as Map; - _deviceNo = data['deviceNo'] as String? ?? license; - _deviceId = data['deviceId'] as String? ?? _deviceId; + _deviceNo = data['deviceNo'] as String? ?? deviceNo; + _deviceId = data['deviceId'] as String? ?? ''; _deviceSecret = data['deviceSecret'] as String? ?? ''; - await _storage.write(_kDeviceNo, _deviceNo); await _storage.write(_kDeviceId, _deviceId); await _storage.write(_kDeviceSecret, _deviceSecret); - credentialReady.value = _deviceSecret.isNotEmpty; - Logger.i('[MobileElf]', 'A80 凭证获取成功 deviceId=$_deviceId'); } else { credentialError.value = body['message']?.toString() ?? '凭证请求失败'; - Logger.e('[MobileElf]', '平台返回错误: ${body['message']}'); } } catch (e) { credentialError.value = '凭证请求异常: $e'; - Logger.e('[MobileElf]', '获取 A80 凭证失败: $e'); } } - /// 从用户设备列表中按蓝牙 MAC 匹配 license - String? _findLicense(String? bluetoothMac) { - if (!User.isLoggedIn()) return null; - final devices = User.instance.devices; - if (bluetoothMac != null && bluetoothMac.isNotEmpty) { - final matched = devices.where((d) => - d.devicemac.toUpperCase() == bluetoothMac.toUpperCase()).toList(); - if (matched.isNotEmpty) return matched.first.license; + Future retryCredentials() async { + credentialError.value = ''; + await _fetchA80CredentialsIfNeeded(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 开始/结束通话 + // ═══════════════════════════════════════════════════════════════════════════ + + /// 配置音频会话:playAndRecord 模式,允许录音和播放同时进行 + Future _configureAudioSession() async { + try { + final session = await AudioSession.instance; + await session.configure(AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, + avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.defaultToSpeaker | + AVAudioSessionCategoryOptions.allowBluetooth, + avAudioSessionMode: AVAudioSessionMode.voiceChat, + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + usage: AndroidAudioUsage.voiceCommunication, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gain, + )); + await session.setActive(true); + Logger.info('yiding----------- [音频会话] 已配置 playAndRecord 模式'); + } catch (e) { + Logger.error('yiding----------- [音频会话] 配置失败: $e'); + } + } + + /// 点击通话按钮 → 开麦,进入等待说话状态(不连接服务器) + Future startCall() async { + if (inCall.value) return; + // PTT 模式下 AI 正在播报 → 先打断 TTS,再进入通话 + if (_ttsPlaying || status.value == MobileElfStatus.speaking) { + _interruptTts(); + _ttsInterrupted = false; + // 等一帧让 player.stop() 完成 + await Future.delayed(const Duration(milliseconds: 50)); + } + // 如果 PTT 正在录音中 → 先取消 + if (pttRecording.value) { + await stopPtt(cancel: true); + } + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { _setError('无麦克风权限'); return; } + + await _configureAudioSession(); + // 为播放器单独设置 voiceCommunication 音频属性,与录音器使用同一音频路由 + // 这样 ExoPlayer 和 AudioRecord 可以像 VoIP 应用一样同时工作 + if (!kIsWeb) { + await _player.setAndroidAudioAttributes(const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + usage: AndroidAudioUsage.voiceCommunication, + )); } - // 按设备名兜底匹配 - final byName = devices.where((d) => - d.devicename.toUpperCase().contains('A80')).toList(); - return byName.isNotEmpty ? byName.first.license : null; + await _openMic(); + _startMicWatchdog(); + inCall.value = true; + status.value = MobileElfStatus.idle; // 等待用户说话 + callSeconds.value = 0; + _callTimer?.cancel(); + _callTimer = Timer.periodic(const Duration(seconds: 1), (_) => callSeconds.value++); + _startIdleTimer(); // 开始等待说话超时 + Logger.info('yiding----------- [通话] 已开始,等待用户说话'); } - // ── 连接 ────────────────────────────────────────────────────────────────── + /// 挂断 + void disconnect() { + Logger.info('yiding----------- [通话] 挂断'); + _stopRecordingTimer(); + _stopMicWatchdog(); + _cancelIdleTimer(); + _callTimer?.cancel(); _callTimer = null; + _forwardingAudio = false; + _wsConnecting = false; + _speechDetected = false; + _micSubscription?.cancel(); _micSubscription = null; + _recorder.stop(); + // 清空 TTS 队列 & 停播 + _ttsInterrupted = true; + _ttsQueue.clear(); + _ttsPlaying = false; + if (!(_ttsPlayCompleter?.isCompleted ?? true)) _ttsPlayCompleter?.complete(); + _player.stop(); + _closeWs(); + _preBuffer.clear(); + _sendBuffer.clear(); _sendBufferOffset = 0; + micRms.value = 0; micAlive.value = false; + inCall.value = false; + status.value = MobileElfStatus.idle; + } - Future _connect() async { - if (_channel != null) return; + // ═══════════════════════════════════════════════════════════════════════════ + // 麦克风 & 本地 VAD + // ═══════════════════════════════════════════════════════════════════════════ + + Future _openMic() async { + _micSubscription?.cancel(); + _micSubscription = null; + try { await _recorder.stop(); } catch (_) {} + final stream = await _recorder.startStream( + const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 16000, + numChannels: 1, + echoCancel: true, + noiseSuppress: true, + androidConfig: AndroidRecordConfig( + audioSource: AndroidAudioSource.voiceCommunication, + ), + ), + ); + _speechDetected = false; + _vadOnsetCount = 0; + _micChunkCount = 0; + micAlive.value = false; + _micSubscription = stream.listen(_onMicAudio); + Logger.info('yiding----------- [麦克风] 已开启 (voiceCommunication + echoCancel)'); + } + + /// 启动麦克风看门狗:每 1.5 秒检测一次,如果无新 chunk 则标记 micAlive=false + void _startMicWatchdog() { + _micWatchdog?.cancel(); + _lastWatchdogChunkCount = _micChunkCount; + _micWatchdog = Timer.periodic(const Duration(milliseconds: 1500), (_) { + if (!inCall.value && !pttRecording.value) { _micWatchdog?.cancel(); return; } + if (_micChunkCount == _lastWatchdogChunkCount) { + micAlive.value = false; + if (status.value == MobileElfStatus.speaking || _ttsPlaying) { + // TTS 播放中麦克风已关闭,不需要重启 + } else { + Logger.info('yiding----------- [看门狗] 麦克风无数据! 尝试重启'); + _openMic(); + } + } + _lastWatchdogChunkCount = _micChunkCount; + }); + } + + void _stopMicWatchdog() { + _micWatchdog?.cancel(); + _micWatchdog = null; + } + + void _onMicAudio(Uint8List chunk) { + if (!inCall.value && !pttRecording.value) return; + _micChunkCount++; + + // 计算 RMS(PCM 16-bit little-endian) + final rms = _calcRms(chunk); + + // 更新 UI 可观察值 + micAlive.value = true; + micRms.value = rms; + + // 每 100 个 chunk (~2s) 输出一次调试日志,确认麦克风存活 + if (_micChunkCount % 100 == 0) { + Logger.info('yiding----------- [麦克风] chunk#$_micChunkCount rms=${rms.toStringAsFixed(0)} ' + 'status=${status.value} speechDetected=$_speechDetected fwd=$_forwardingAudio wsConn=$_wsConnecting'); + } + + // ── 当前正在转发音频到 WS ── + if (_forwardingAudio && _channel != null) { + _feedSendBuffer(chunk); + return; + } + + // ── WS 正在连接中,先存入预缓存 ── + if (_wsConnecting) { + _addToPreBuffer(chunk); + return; + } + + // ── TTS 播放中 / thinking 中不做 VAD ── + if (status.value == MobileElfStatus.speaking || _ttsPlaying || + status.value == MobileElfStatus.thinking) { + return; + } + + // ── 空闲/等待状态:连续 chunk VAD ── + _addToPreBuffer(chunk); + + if (rms >= _vadThreshold) { + _vadOnsetCount++; + if (_vadOnsetCount >= _vadOnsetChunks && !_speechDetected) { + _speechDetected = true; + _cancelIdleTimer(); + Logger.info('yiding----------- [VAD] 检测到说话 rms=${rms.toStringAsFixed(0)} ' + 'onset=$_vadOnsetCount/$_vadOnsetChunks,启动连接'); + _onSpeechDetected(); + } + } else { + _vadOnsetCount = 0; + } + } + + double _calcRms(Uint8List pcm) { + if (pcm.length < 2) return 0; + final samples = pcm.length ~/ 2; + double sum = 0; + for (int i = 0; i < pcm.length - 1; i += 2) { + int sample = pcm[i] | (pcm[i + 1] << 8); + if (sample >= 32768) sample -= 65536; // signed + sum += sample * sample; + } + return math.sqrt(sum / samples); + } + + void _addToPreBuffer(Uint8List chunk) { + _preBuffer.add(Uint8List.fromList(chunk)); + while (_preBuffer.length > _preBufferMaxChunks) { + _preBuffer.removeAt(0); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 检测到说话 → 连接服务器 → 发送预缓存 → 实时转发 + // ═══════════════════════════════════════════════════════════════════════════ + + Future _onSpeechDetected() async { + if (_wsConnecting) return; + + _ttsInterrupted = false; + _closeWs(); + _sessionEnded = false; + _wsConnecting = true; status.value = MobileElfStatus.connecting; + _dialogRequestId = _uuid.v4(); + _currentAiMsgIndex = -1; + realtimeText.value = ''; + _sendBuffer.clear(); + _sendBufferOffset = 0; errorMsg.value = ''; + Logger.info('yiding----------- [新轮] 说话触发连接 dialogId=$_dialogRequestId preBuffer=${_preBuffer.length}chunks'); + + try { + await _connect(); + Logger.info('yiding----------- [新轮] WS ready,开始发送预缓存'); + } catch (e) { + Logger.error('yiding----------- [新轮] 连接失败: $e'); + _wsConnecting = false; + _speechDetected = false; + _setError('连接失败: $e'); + return; + } + + // 发送预缓存音频 + _forwardingAudio = true; + _wsConnecting = false; + status.value = MobileElfStatus.recording; + recordingSeconds.value = 0; + _stopRecordingTimer(); + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) => recordingSeconds.value++); + + final cached = List.from(_preBuffer); + _preBuffer.clear(); + int cachedBytes = 0; + for (final c in cached) { + _feedSendBuffer(c); + cachedBytes += c.length; + } + Logger.info('yiding----------- [新轮] 预缓存已发送 ${cached.length}chunks ${cachedBytes}bytes,实时转发中'); + } + + /// 将 chunk 加入发送缓冲,凑满 _frameSize 就发 + void _feedSendBuffer(Uint8List chunk) { + _sendBuffer.addAll(chunk); + while (_sendBuffer.length - _sendBufferOffset >= _frameSize) { + final frame = Uint8List.fromList( + _sendBuffer.sublist(_sendBufferOffset, _sendBufferOffset + _frameSize)); + _sendBufferOffset += _frameSize; + _sendAudioChunk(frame); + } + // 紧缩 + if (_sendBufferOffset >= _frameSize * 8) { + _sendBuffer.removeRange(0, _sendBufferOffset); + _sendBufferOffset = 0; + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WebSocket 连接 + // ═══════════════════════════════════════════════════════════════════════════ + + Future _connect() async { + _readyCompleter = Completer(); try { final ts = DateTime.now().millisecondsSinceEpoch.toString(); final sn = _uuid.v4(); final sign = _calcSign(_deviceSecret, ts); - final url = 'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr' - '?deviceNo=$_deviceNo' - '&deviceId=$_deviceId' - '&productId=$_productId' - '&productKey=$_productKey' - '&sign=$sign' - '&sn=$sn' - '&ts=$ts'; + '?deviceNo=$_deviceNo&deviceId=$_deviceId&productId=$_productId' + '&productKey=$_productKey&sign=$sign&sn=$sn&ts=$ts'; if (kIsWeb) { _channel = WebSocketChannel.connect(Uri.parse(url)); } else { - _channel = IOWebSocketChannel.connect(Uri.parse(url)); + final httpClient = HttpClient()..badCertificateCallback = (_, __, ___) => true; + final ws = await WebSocket.connect(url, customClient: httpClient, headers: { + 'Origin': 'https://ivs.chinamobiledevice.com:11443', + 'Host': 'ivs.chinamobiledevice.com:11443', + }); + _channel = IOWebSocketChannel(ws); } _wsSubscription = _channel!.stream.listen( _handleMessage, - onError: _handleWsError, - onDone: _handleWsDone, + onError: (e) { + Logger.error('yiding----------- [WS] 错误: $e'); + if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.completeError(e); + _handleWsDone(); + }, + onDone: () { + Logger.info('yiding----------- [WS] onDone sessionEnded=$_sessionEnded status=${status.value}'); + if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.completeError('WS closed'); + _handleWsDone(); + }, ); - Logger.info('[MobileElf] WebSocket 连接中: $url'); + _sendStartFrame(); + await _readyCompleter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('等待 ready 超时'), + ); } catch (e) { - Logger.error('[MobileElf] 连接失败: $e'); - _setError('连接失败: $e'); + _closeWs(); + rethrow; } } - // ── 开始对话(录音) ───────────────────────────────────────────────────── - - Future startRecording() async { - if (status.value == MobileElfStatus.recording) return; - if (status.value == MobileElfStatus.thinking) return; - - await _connect(); - - final hasPermission = await _recorder.hasPermission(); - if (!hasPermission) { - _setError('无麦克风权限'); - return; - } - - _dialogRequestId = _uuid.v4(); - realtimeText.value = ''; - - try { - final stream = await _recorder.startStream( - const RecordConfig( - encoder: AudioEncoder.pcm16bits, - sampleRate: 16000, - numChannels: 1, - ), - ); - - _audioBuffer.clear(); - _audioSubscription = stream.listen((chunk) { - _audioBuffer.addAll(chunk); - while (_audioBuffer.length >= _frameSize) { - _sendAudioChunk( - Uint8List.fromList(_audioBuffer.sublist(0, _frameSize))); - _audioBuffer.removeRange(0, _frameSize); - } - }); + void _handleWsDone() { + _channel = null; + _wsSubscription = null; + _forwardingAudio = false; + _wsConnecting = false; - status.value = MobileElfStatus.recording; - Logger.info('[MobileElf] 开始录音'); - } catch (e) { - Logger.error('[MobileElf] 录音失败: $e'); - _setError('录音失败: $e'); + if (_sessionEnded && inCall.value) { + Logger.info('yiding----------- [WS] 正常关闭 (通话模式)'); + if (status.value == MobileElfStatus.speaking) { + // TTS 播放中,等 playTts 完成后自动回到 idle 等待说话 + } else { + status.value = MobileElfStatus.idle; + _resetVad(); + Logger.info('yiding----------- [WS] 回到等待说话'); + } + } else if (_sessionEnded && !inCall.value) { + // PTT/文本模式:WS 正常关闭 + Logger.info('yiding----------- [WS] 正常关闭 (PTT/文本模式)'); + if (status.value != MobileElfStatus.speaking) { + status.value = MobileElfStatus.idle; + } + } else if (inCall.value) { + Logger.info('yiding----------- [WS] 非预期关闭,回到等待说话'); + status.value = MobileElfStatus.idle; + _resetVad(); + } else { + Logger.info('yiding----------- [WS] 非预期关闭'); + status.value = MobileElfStatus.idle; } } - Future stopRecording() async { - if (status.value != MobileElfStatus.recording) return; + void _resetVad() { + _speechDetected = false; + _vadOnsetCount = 0; + _preBuffer.clear(); + _openMic(); + } - await _audioSubscription?.cancel(); - _audioSubscription = null; + // ═══════════════════════════════════════════════════════════════════════════ + // 空闲超时:TTS 播完后等待说话,超时自动挂断 + // ═══════════════════════════════════════════════════════════════════════════ - // 发送剩余缓冲数据 - if (_audioBuffer.isNotEmpty) { - _sendAudioChunk(Uint8List.fromList(_audioBuffer)); - _audioBuffer.clear(); - } + void _startIdleTimer() { + _cancelIdleTimer(); + _idleTimer = Timer(Duration(seconds: _idleTimeoutSeconds), () { + if (inCall.value && status.value == MobileElfStatus.idle && !_speechDetected) { + Logger.info('yiding----------- [超时] ${_idleTimeoutSeconds}秒无说话,自动挂断'); + disconnect(); + } + }); + Logger.info('yiding----------- [超时] 开始${_idleTimeoutSeconds}秒等待说话计时'); + } - await _recorder.stop(); - _sendFinish(); + void _cancelIdleTimer() { + _idleTimer?.cancel(); + _idleTimer = null; + } - status.value = MobileElfStatus.thinking; - Logger.info('[MobileElf] 停止录音,等待 AI 回答'); + void _closeWs() { + final sub = _wsSubscription; _wsSubscription = null; + sub?.cancel(); + final ch = _channel; _channel = null; + try { ch?.sink.close(); } catch (_) {} } - // ── 发送帧 ──────────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + // 发送帧 + // ═══════════════════════════════════════════════════════════════════════════ void _sendStartFrame() { if (_channel == null) return; - - final startFrame = { + _channel!.sink.add(jsonEncode({ 'type': 'start', 'data': { 'cuid': _deviceId, @@ -323,57 +596,57 @@ class MobileElfController extends GetxController { 'messages': _buildContextMessages(), 'client_context': [], }, - }; - - _channel!.sink.add(jsonEncode(startFrame)); - Logger.info('[MobileElf] 已发送 start 帧'); + })); } void _sendAudioChunk(Uint8List data) { - try { - _channel?.sink.add(data); - } catch (e) { - Logger.error('[MobileElf] 发送音频帧失败: $e'); - } + try { _channel?.sink.add(data); } catch (_) {} } void _sendFinish() { - try { - _channel?.sink.add(jsonEncode({'type': 'finish'})); - Logger.info('[MobileElf] 已发送 finish 帧'); - } catch (e) { - Logger.error('[MobileElf] 发送 finish 帧失败: $e'); - } + try { _channel?.sink.add(jsonEncode({'type': 'finish'})); } catch (_) {} } - // ── 接收消息处理 ────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + // 接收消息处理 + // ═══════════════════════════════════════════════════════════════════════════ void _handleMessage(dynamic raw) { try { if (raw is! String) return; - final Map msg = jsonDecode(raw); + final msg = jsonDecode(raw) as Map; final type = msg['type'] as String? ?? ''; switch (type) { case 'ready': - Logger.info('[MobileElf] 收到 ready,发送 start 帧'); - _sendStartFrame(); + if (!(_readyCompleter?.isCompleted ?? true)) _readyCompleter?.complete(); + break; + + case 'stoplisten': + Logger.info('yiding----------- [服务端] stoplisten'); + _stopRecordingTimer(); + _forwardingAudio = false; + _speechDetected = false; // 允许 VAD 在 thinking/speaking 期间检测新语音 + _vadOnsetCount = 0; + // 发送剩余 + final remain = _sendBuffer.length - _sendBufferOffset; + if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset))); + _sendBuffer.clear(); _sendBufferOffset = 0; + _sendFinish(); + status.value = MobileElfStatus.thinking; break; case 'mid_result': - final result = msg['result'] as String? ?? ''; - realtimeText.value = result; + realtimeText.value = msg['result'] as String? ?? ''; break; case 'fin_result': final result = msg['result'] as String? ?? ''; if (result.isNotEmpty) { - realtimeText.value = result; - // 用户消息在 fin_result 时确认写入 + realtimeText.value = ''; _addUserMessage(result); } - final end = msg['end'] as int? ?? 0; - if (end == 1) _handleSessionEnd(); + if (msg['end'] == 1) _markSessionEnded(); break; case 'inside_rc': @@ -381,12 +654,11 @@ class MobileElfController extends GetxController { break; case 'dcs_decide': - final end = msg['end'] as int? ?? 0; - if (end == 1) _handleSessionEnd(); + if (msg['end'] == 1) { + Logger.info('yiding----------- [服务端] dcs_decide end:1'); + _markSessionEnded(); + } break; - - default: - Logger.info('[MobileElf] 未知消息类型: $type'); } } catch (e) { Logger.error('[MobileElf] 处理消息失败: $e'); @@ -396,177 +668,313 @@ class MobileElfController extends GetxController { void _handleInsideRc(Map msg) { final data = msg['data'] as Map?; if (data == null) return; + if ((data['code'] as int? ?? -1) != 0) return; - final code = data['code'] as int? ?? -1; - if (code != 0) { - Logger.error('[MobileElf] inside_rc 错误: ${data['msg']}'); - return; - } - - // 提取 AI 文本回答 final assistantAnswer = data['assistant_answer'] as String? ?? ''; String cleanAnswer = assistantAnswer; - // assistant_answer 可能是 JSON 字符串 try { - final decoded = jsonDecode(assistantAnswer) as Map; - cleanAnswer = decoded['content'] as String? ?? assistantAnswer; + cleanAnswer = (jsonDecode(assistantAnswer) as Map)['content'] as String? ?? assistantAnswer; } catch (_) {} - // 提取 TTS URL(Speak 指令) String? ttsUrl; - final directives = data['data'] as List? ?? []; - for (final item in directives) { - final directive = item as Map; - final header = directive['header'] as Map?; - final ns = header?['namespace'] as String? ?? ''; - final name = header?['name'] as String? ?? ''; - if (ns == 'ai.fxzsos.device_interface.voice_output' && name == 'Speak') { - final payload = directive['payload'] as Map?; - ttsUrl = payload?['url'] as String?; + for (final item in (data['data'] as List? ?? [])) { + final d = item as Map; + final h = d['header'] as Map?; + if (h?['namespace'] == 'ai.fxzsos.device_interface.voice_output' && h?['name'] == 'Speak') { + ttsUrl = (d['payload'] as Map?)?['url'] as String?; break; } } if (cleanAnswer.isNotEmpty) { - _addAiMessage(cleanAnswer, ttsUrl: ttsUrl); - } - - // 播放 TTS - if (ttsUrl != null && ttsUrl.isNotEmpty) { - playTts(ttsUrl); + if (_currentAiMsgIndex >= 0 && _currentAiMsgIndex < messages.length) { + messages[_currentAiMsgIndex] = MobileElfMessage( + isUser: false, text: cleanAnswer, + time: messages[_currentAiMsgIndex].time, + ttsUrl: ttsUrl ?? messages[_currentAiMsgIndex].ttsUrl, + ); + } else { + _currentAiMsgIndex = messages.length; + _addAiMessage(cleanAnswer, ttsUrl: ttsUrl); + } } - final end = msg['end'] as int? ?? 0; - if (end == 1) _handleSessionEnd(); + if (ttsUrl != null && ttsUrl.isNotEmpty) _enqueueTts(ttsUrl); + if (msg['end'] == 1) _markSessionEnded(); } - void _handleSessionEnd() { - if (status.value != MobileElfStatus.speaking) { - status.value = MobileElfStatus.idle; - } + void _markSessionEnded() { + if (_sessionEnded) return; + _sessionEnded = true; + Logger.info('yiding----------- [会话] 本轮结束 status=${status.value}'); + _stopRecordingTimer(); + _forwardingAudio = false; + _speechDetected = false; // 允许 VAD 在 TTS 播放期间检测新语音 + _vadOnsetCount = 0; + _sendBuffer.clear(); _sendBufferOffset = 0; realtimeText.value = ''; - // 断开当前 WebSocket,下次对话重新连接(协议要求每次新对话重连) - _closeWs(); } - void _handleWsError(dynamic error) { - Logger.error('[MobileElf] WebSocket 错误: $error'); - _setError('连接错误: $error'); - _closeWs(); + // ═══════════════════════════════════════════════════════════════════════════ + // TTS 播放队列 + // ═══════════════════════════════════════════════════════════════════════════ + + /// 将 TTS URL 加入队列,如果没有正在播放则关闭麦克风并开始播放 + void _enqueueTts(String url) { + _ttsQueue.add(url); + Logger.info('yiding----------- [TTS] 入队 queue=${_ttsQueue.length}'); + if (!_ttsPlaying) { + // 关闭麦克风,避免音频冲突,TTS 播完后会重开 + _micSubscription?.cancel(); _micSubscription = null; + try { _recorder.stop(); } catch (_) {} + micAlive.value = false; micRms.value = 0; + Logger.info('yiding----------- [TTS] 关闭麦克风,开始播放'); + _playNextTts(); + } } - void _handleWsDone() { - Logger.info('[MobileElf] WebSocket 关闭'); - if (status.value == MobileElfStatus.connecting || - status.value == MobileElfStatus.recording) { - _setError('连接已断开'); + /// 顺序播放队列中的 TTS + Future _playNextTts() async { + if (_ttsQueue.isEmpty || _ttsInterrupted) { + _ttsPlaying = false; + // 队列播完 → 重开麦克风,回到等待说话,启动超时计时 + if (!_ttsInterrupted && status.value == MobileElfStatus.speaking) { + status.value = MobileElfStatus.idle; + Logger.info('yiding----------- [TTS] 队列全部播完,重开麦克风,等待新一轮说话'); + if (inCall.value) { + _resetVad(); + _startIdleTimer(); + } + } + return; } - _channel = null; - _wsSubscription = null; - } - // ── TTS 播放 ────────────────────────────────────────────────────────────── + _ttsPlaying = true; + status.value = MobileElfStatus.speaking; + final url = _ttsQueue.removeAt(0); + + // 用 Completer 等待播放完成或被打断 + _ttsPlayCompleter = Completer(); + StreamSubscription? stateSub; - Future playTts(String url) async { try { - status.value = MobileElfStatus.speaking; + await _player.stop(); + // 不再重新配置音频会话!startCall() 已配置好 playAndRecord 模式, + // 重复配置会导致 audio_session 重新请求音频焦点,杀死录音流 await _player.setUrl(url); + + // 监听 ProcessingState: completed=正常播完, idle=被打断或意外停止 + stateSub = _player.processingStateStream.listen((s) { + if ((s == ProcessingState.completed || s == ProcessingState.idle) && + !(_ttsPlayCompleter?.isCompleted ?? true)) { + Logger.info('yiding----------- [TTS] 当前段结束 state=$s'); + _ttsPlayCompleter?.complete(); + } + }); + await _player.play(); - await _player.processingStateStream - .firstWhere((s) => s == ProcessingState.completed); - status.value = MobileElfStatus.idle; - Logger.info('[MobileElf] TTS 播放完成'); + Logger.info('yiding----------- [TTS] 开始播放 剩余队列=${_ttsQueue.length} micAlive=${micAlive.value} micChunk=$_micChunkCount'); + + // 等待:正常播完 complete() 或被打断 _interruptTts() 也会 complete() + await _ttsPlayCompleter!.future.timeout(const Duration(seconds: 60)); } catch (e) { - Logger.error('[MobileElf] TTS 播放失败: $e'); - status.value = MobileElfStatus.idle; + Logger.error('yiding----------- [TTS] 播放异常: $e'); + } finally { + stateSub?.cancel(); + } + + // 当前段结束,继续下一段(如果没被打断) + if (!_ttsInterrupted) { + _playNextTts(); } } - Future stopSpeaking() async { - await _player.stop(); + /// 立即打断 TTS:停播 + 清队列 + 解除等待 + void _interruptTts() { + Logger.info('yiding----------- [TTS] 打断! 清空队列=${_ttsQueue.length}'); + _ttsInterrupted = true; + _ttsQueue.clear(); + _ttsPlaying = false; + // 先解除 _playNextTts 的等待 + if (!(_ttsPlayCompleter?.isCompleted ?? true)) { + _ttsPlayCompleter?.complete(); + } + _player.stop(); + } + + /// 手动打断 AI 播报(界面按钮调用) + Future interruptSpeaking() async { + _interruptTts(); + _ttsInterrupted = false; // 手动打断后重置,不影响后续 status.value = MobileElfStatus.idle; + if (inCall.value) _resetVad(); } - // ── 断开连接 ────────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + // 手动停止录音 + // ═══════════════════════════════════════════════════════════════════════════ - void disconnect() { - _audioSubscription?.cancel(); - _audioSubscription = null; - _recorder.cancel(); - _closeWs(); - status.value = MobileElfStatus.idle; + Future stopRecording() async { + if (!_forwardingAudio) return; + _stopRecordingTimer(); + _forwardingAudio = false; + final remain = _sendBuffer.length - _sendBufferOffset; + if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset))); + _sendBuffer.clear(); _sendBufferOffset = 0; + _sendFinish(); + status.value = MobileElfStatus.thinking; } - void _closeWs() { - _wsSubscription?.cancel(); - _wsSubscription = null; - _channel?.sink.close(); - _channel = null; + void _stopRecordingTimer() { + _recordingTimer?.cancel(); _recordingTimer = null; } - // ── 辅助方法 ────────────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════════════ + // 按住说话 (PTT) + // ═══════════════════════════════════════════════════════════════════════════ + + /// 按住说话 → 开始录音并连接 WS + Future startPtt() async { + if (pttRecording.value) return; + // 如果正在播放 AI 回复 → 立即打断 + if (_ttsPlaying || status.value == MobileElfStatus.speaking) { + _interruptTts(); + _ttsInterrupted = false; + } + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { _setError('无麦克风权限'); return; } - String _calcSign(String deviceSecret, String ts) { - final bytes = utf8.encode('$deviceSecret$ts'); - return md5.convert(bytes).toString(); - } + // 先设置 connecting 状态,再设置 pttRecording,避免中间按钮橙色闪烁 + status.value = MobileElfStatus.connecting; + pttRecording.value = true; + pttSeconds.value = 0; + _pttTimer = Timer.periodic(const Duration(seconds: 1), (_) => pttSeconds.value++); + + await _configureAudioSession(); + if (!kIsWeb) { + await _player.setAndroidAudioAttributes(const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + usage: AndroidAudioUsage.voiceCommunication, + )); + } + await _openMic(); + _startMicWatchdog(); - List> _buildContextMessages() { - return messages.map((m) => { - 'role': m.isUser ? 'user' : 'assistant', - 'content': m.text, - }).toList(); - } + _ttsInterrupted = false; + _closeWs(); + _sessionEnded = false; + _wsConnecting = true; + _dialogRequestId = _uuid.v4(); + _currentAiMsgIndex = -1; + realtimeText.value = ''; + _sendBuffer.clear(); _sendBufferOffset = 0; + errorMsg.value = ''; - void _addUserMessage(String text) { - // 避免重复添加 - if (messages.isNotEmpty && - messages.last.isUser && - messages.last.text == text) { - return; + try { + await _connect(); + _forwardingAudio = true; + _wsConnecting = false; + status.value = MobileElfStatus.recording; + Logger.info('yiding----------- [PTT] 录音开始'); + } catch (e) { + _wsConnecting = false; + pttRecording.value = false; + _pttTimer?.cancel(); _pttTimer = null; + _stopMicWatchdog(); + _setError('连接失败: $e'); } - messages.add(MobileElfMessage( - isUser: true, - text: text, - time: DateTime.now(), - )); } - void _addAiMessage(String text, {String? ttsUrl}) { - messages.add(MobileElfMessage( - isUser: false, - text: text, - time: DateTime.now(), - ttsUrl: ttsUrl, - )); - } + /// 松开 → 停止录音,发送 finish + Future stopPtt({bool cancel = false}) async { + pttRecording.value = false; + _pttTimer?.cancel(); _pttTimer = null; + _stopMicWatchdog(); + + if (cancel) { + _forwardingAudio = false; + _closeWs(); + _micSubscription?.cancel(); _micSubscription = null; + try { _recorder.stop(); } catch (_) {} + micAlive.value = false; micRms.value = 0; + realtimeText.value = ''; + status.value = MobileElfStatus.idle; + Logger.info('yiding----------- [PTT] 已取消'); + return; + } - void _setError(String msg) { - errorMsg.value = msg; - status.value = MobileElfStatus.error; + // 正常结束:发送剩余音频 + finish + if (_forwardingAudio) { + _forwardingAudio = false; + final remain = _sendBuffer.length - _sendBufferOffset; + if (remain > 0) _sendAudioChunk(Uint8List.fromList(_sendBuffer.sublist(_sendBufferOffset))); + _sendBuffer.clear(); _sendBufferOffset = 0; + _sendFinish(); + status.value = MobileElfStatus.thinking; + } + _micSubscription?.cancel(); _micSubscription = null; + try { _recorder.stop(); } catch (_) {} + micAlive.value = false; micRms.value = 0; + Logger.info('yiding----------- [PTT] 录音结束,等待AI回复'); } - /// 重试获取 A80 凭证 - Future retryCredentials() async { - credentialError.value = ''; - await _fetchA80CredentialsIfNeeded(); - } + // ═══════════════════════════════════════════════════════════════════════════ + // 按钮操作 + // ═══════════════════════════════════════════════════════════════════════════ - /// 按钮主操作:空闲/错误 → 开始录音;录音中 → 停止;播放中 → 停止 Future onMicTap() async { + Logger.info('yiding----------- [按钮] status=${status.value} inCall=${inCall.value}'); switch (status.value) { case MobileElfStatus.idle: case MobileElfStatus.error: - await startRecording(); + if (!inCall.value) { + await startCall(); + } break; case MobileElfStatus.recording: await stopRecording(); break; case MobileElfStatus.speaking: - await stopSpeaking(); + await interruptSpeaking(); break; default: break; } } + + // ═══════════════════════════════════════════════════════════════════════════ + // 工具方法 + // ═══════════════════════════════════════════════════════════════════════════ + + String _calcSign(String secret, String ts) => md5.convert(utf8.encode('$secret$ts')).toString(); + + List> _buildContextMessages() => + messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.text}).toList(); + + void _addUserMessage(String text) { + if (messages.isNotEmpty && messages.last.isUser && messages.last.text == text) return; + messages.add(MobileElfMessage(isUser: true, text: text, time: DateTime.now())); + scrollToBottom(); + } + + void _addAiMessage(String text, {String? ttsUrl}) { + messages.add(MobileElfMessage(isUser: false, text: text, time: DateTime.now(), ttsUrl: ttsUrl)); + scrollToBottom(); + } + + void _setError(String msg) { + errorMsg.value = msg; + status.value = MobileElfStatus.error; + } + + void scrollToBottom() { + if (scrollController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + scrollController.animateTo(scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } + }); + } + } } diff --git a/lib/modules/mobile_elf/views/mobile_elf_view.dart b/lib/modules/mobile_elf/views/mobile_elf_view.dart index 51db8c287..7aaa114b1 100644 --- a/lib/modules/mobile_elf/views/mobile_elf_view.dart +++ b/lib/modules/mobile_elf/views/mobile_elf_view.dart @@ -6,503 +6,811 @@ import '../controllers/mobile_elf_controller.dart'; class MobileElfView extends GetView { const MobileElfView({Key? key}) : super(key: key); + // ── 颜色常量 ────────────────────────────────────────────────────────────── + static const _blue = Color(0xFF2E7CF6); + static const _green = Color(0xFF34C759); + static const _greenDark = Color(0xFF16A34A); + static const _red = Color(0xFFEF4444); + static const _orange = Color(0xFFF79009); + static const _bgLight = Color(0xFFF4F6F9); + static const _surfaceLight = Colors.white; + static const _textPrimary = Color(0xFF101828); + static const _textSecondary = Color(0xFF667085); + static const _border = Color(0xFFEAECF0); + + // 渐变复用 + static const _avatarGradient = LinearGradient( + colors: [Color(0xFF667EEA), Color(0xFF764BA2)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + @override Widget build(BuildContext context) { - final bool isDark = Theme.of(context).brightness == Brightness.dark; + final isDark = Theme.of(context).brightness == Brightness.dark; return Scaffold( - backgroundColor: isDark ? Colors.black : const Color(0xFFF9FAFC), + backgroundColor: isDark ? const Color(0xFF0D0D0D) : _bgLight, appBar: _buildAppBar(isDark), - body: Obx(() { - final err = controller.credentialError.value; - if (err.isNotEmpty) { - return Center( - child: Padding( - padding: EdgeInsets.all(24.w), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.error_outline, size: 48.sp, color: Colors.red), - SizedBox(height: 12.h), - Text(err, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13.sp, color: Colors.red)), - SizedBox(height: 16.h), - ElevatedButton( - onPressed: controller.retryCredentials, - child: const Text('重试'), - ), - ], - ), - ), + // 顶层 Listener 捕获 PTT 松开事件(确保 widget 树重建后仍能接收 pointerUp) + body: Listener( + behavior: HitTestBehavior.translucent, + onPointerUp: (_) { + if (controller.pttRecording.value) controller.stopPtt(); + }, + child: Obx(() { + if (controller.credentialError.value.isNotEmpty) { + return _buildCredentialError(); + } + return Column( + children: [ + Expanded(child: _buildMessageList(isDark)), + _buildBottomBar(isDark), + ], ); - } - return Column( - children: [ - _buildStatusBanner(), - Expanded(child: _buildMessageList(isDark)), - _buildBottomBar(isDark), - ], - ); - }), + }), + ), ); } + // ══════════════════════════════════════════════════════════════════════════ + // AppBar + // ══════════════════════════════════════════════════════════════════════════ + PreferredSizeWidget _buildAppBar(bool isDark) { return AppBar( - backgroundColor: isDark ? const Color(0xFF1A1A1A) : Colors.white, - elevation: 0.5, + backgroundColor: isDark ? const Color(0xFF1A1A1A) : _surfaceLight, + elevation: 0, + surfaceTintColor: Colors.transparent, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + color: isDark ? const Color(0xFF222222) : _border, height: 1), + ), leading: IconButton( icon: Icon(Icons.arrow_back_ios, - size: 18.sp, - color: isDark ? Colors.white : Colors.grey[800]), + size: 18.sp, color: isDark ? Colors.white : _textSecondary), onPressed: () => Get.back(), ), title: Row( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 32.w, - height: 32.w, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], - ), - borderRadius: BorderRadius.circular(8.r), - ), - child: Icon(Icons.cell_tower, color: Colors.white, size: 18.sp), - ), - SizedBox(width: 8.w), - Text( - '移动精灵', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: isDark ? Colors.white : Colors.grey[800], - ), - ), + _gradientCircle(32.w, '晓', 13.sp), + SizedBox(width: 10.w), + Text('移动精灵', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : _textPrimary)), ], ), actions: [ Obx(() => Padding( padding: EdgeInsets.only(right: 16.w), - child: _buildStatusDot(), + child: _buildStatusPill(), )), ], ); } - Widget _buildStatusDot() { - final color = _statusColor(controller.status.value); - return Row( - children: [ - Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow(color: color.withOpacity(0.4), blurRadius: 4) - ], - ), - ), - SizedBox(width: 4.w), - Text( - _statusLabel(controller.status.value), - style: TextStyle(fontSize: 11.sp, color: color), - ), - ], + /// 右上角状态胶囊 + 通话计时 + Widget _buildStatusPill() { + final s = controller.status.value; + final inCall = controller.inCall.value; + final cfg = _pillConfig(s, inCall); + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h), + decoration: BoxDecoration( + color: cfg.bg, + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(cfg.label, + style: TextStyle( + fontSize: 11.sp, + color: cfg.fg, + fontWeight: FontWeight.w500)), + if (inCall) ...[ + SizedBox(width: 4.w), + Obx(() { + final sec = controller.callSeconds.value; + return Text(_fmtDuration(sec), + style: TextStyle( + fontSize: 11.sp, + color: cfg.fg, + fontWeight: FontWeight.w400)); + }), + ], + ], + ), ); } - Widget _buildStatusBanner() { - return Obx(() { - final s = controller.status.value; - if (s == MobileElfStatus.idle) return const SizedBox.shrink(); + _PillCfg _pillConfig(MobileElfStatus s, bool inCall) { + switch (s) { + case MobileElfStatus.connecting: + return const _PillCfg(Color(0xFFFFFAEB), Color(0xFFB54708), '连接中…'); + case MobileElfStatus.recording: + return const _PillCfg(Color(0xFFECFDF3), _greenDark, '聆听中'); + case MobileElfStatus.thinking: + return const _PillCfg(Color(0xFFEFF8FF), Color(0xFF175CD3), '处理中'); + case MobileElfStatus.speaking: + return const _PillCfg(Color(0xFFECFDF3), _greenDark, '朗读中'); + case MobileElfStatus.error: + return const _PillCfg(Color(0xFFFEF2F2), _red, '错误'); + case MobileElfStatus.idle: + return inCall + ? const _PillCfg(Color(0xFFECFDF3), _greenDark, '通话中') + : const _PillCfg(Color(0xFFF2F4F7), _textSecondary, '空闲'); + } + } - final color = _statusColor(s); - final label = _statusBannerLabel(s); + // ══════════════════════════════════════════════════════════════════════════ + // 凭证错误 + // ══════════════════════════════════════════════════════════════════════════ - return Container( - width: double.infinity, - padding: EdgeInsets.symmetric(vertical: 6.h), - color: color.withOpacity(0.12), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + Widget _buildCredentialError() { + return Center( + child: Padding( + padding: EdgeInsets.all(32.w), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (s == MobileElfStatus.thinking || s == MobileElfStatus.connecting) - SizedBox( - width: 12.w, - height: 12.w, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: color, - ), - ), - if (s == MobileElfStatus.recording) - Icon(Icons.mic, size: 14.sp, color: color), - if (s == MobileElfStatus.speaking) - Icon(Icons.volume_up, size: 14.sp, color: color), - if (s == MobileElfStatus.error) - Icon(Icons.error_outline, size: 14.sp, color: color), - SizedBox(width: 6.w), - Text(label, - style: TextStyle( - fontSize: 12.sp, - color: color, - fontWeight: FontWeight.w500)), + Icon(Icons.wifi_off_rounded, size: 56.sp, color: Colors.red[300]), + SizedBox(height: 16.h), + Obx(() => Text(controller.credentialError.value, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13.sp, color: Colors.red[400]))), + SizedBox(height: 20.h), + ElevatedButton.icon( + onPressed: controller.retryCredentials, + icon: const Icon(Icons.refresh), + label: const Text('重试'), + ), ], ), - ); - }); + ), + ); } + // ══════════════════════════════════════════════════════════════════════════ + // 消息列表 + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildMessageList(bool isDark) { return Obx(() { - if (controller.messages.isEmpty) { - return _buildEmptyHint(isDark); - } + final msgs = controller.messages; + final rt = controller.realtimeText.value; + if (msgs.isEmpty && rt.isEmpty) return _buildEmptyState(isDark); return ListView.builder( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - reverse: false, - itemCount: controller.messages.length, - itemBuilder: (_, i) => _buildBubble(controller.messages[i], isDark), + controller: controller.scrollController, + padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 8.h), + itemCount: msgs.length + (rt.isNotEmpty ? 1 : 0), + itemBuilder: (_, i) { + if (i < msgs.length) return _buildBubble(msgs[i], isDark); + return _buildRealtimeBubble(rt); + }, ); }); } - Widget _buildEmptyHint(bool isDark) { + Widget _buildEmptyState(bool isDark) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 72.w, - height: 72.w, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF00BFA5)], - ), - shape: BoxShape.circle, - ), - child: Icon(Icons.cell_tower, color: Colors.white, size: 36.sp), - ), - SizedBox(height: 16.h), - Text( - '移动精灵', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.bold, - color: isDark ? Colors.white : Colors.grey[800], - ), - ), + _gradientCircle(80.w, '晓', 32.sp), + SizedBox(height: 20.h), + Text('移动精灵', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : _textPrimary)), SizedBox(height: 8.h), - Text( - '按住麦克风按钮开始语音对话', - style: TextStyle( - fontSize: 13.sp, - color: isDark ? Colors.grey[400] : Colors.grey[500], - ), - ), + Text('点击右下角按钮,即可与晓晓对话', + style: TextStyle( + fontSize: 13.sp, + color: isDark ? Colors.grey[500] : _textSecondary)), ], ), ); } + // ── 消息气泡 ────────────────────────────────────────────────────────────── + Widget _buildBubble(MobileElfMessage msg, bool isDark) { final isUser = msg.isUser; return Padding( - padding: EdgeInsets.only(bottom: 12.h), - child: Row( - mainAxisAlignment: - isUser ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, + padding: EdgeInsets.only(bottom: 14.h), + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ + // AI 头像 + 名字 if (!isUser) ...[ - _buildAvatar(isUser: false), - SizedBox(width: 8.w), + Row(children: [ + _gradientCircle(28.w, '晓', 11.sp), + SizedBox(width: 6.w), + Text('晓晓', + style: TextStyle(fontSize: 11.sp, color: _textSecondary)), + ]), + SizedBox(height: 4.h), ], - Flexible( - child: Container( - padding: - EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), - decoration: BoxDecoration( - color: isUser - ? const Color(0xFF0091EA) - : (isDark - ? const Color(0xFF2A2A2A) - : Colors.white), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.r), - topRight: Radius.circular(16.r), - bottomLeft: - isUser ? Radius.circular(16.r) : Radius.circular(4.r), - bottomRight: - isUser ? Radius.circular(4.r) : Radius.circular(16.r), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.06), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - msg.text, - style: TextStyle( - fontSize: 14.sp, - color: isUser - ? Colors.white - : (isDark ? Colors.white : Colors.grey[800]), - height: 1.4, + // 气泡体 + Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!isUser) SizedBox(width: 34.w), + Flexible( + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + decoration: BoxDecoration( + color: isUser + ? _blue + : (isDark ? const Color(0xFF2A2A2A) : _surfaceLight), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(18.r), + topRight: Radius.circular(18.r), + bottomLeft: Radius.circular(isUser ? 18.r : 4.r), + bottomRight: Radius.circular(isUser ? 4.r : 18.r), ), - ), - if (!isUser && msg.ttsUrl != null) ...[ - SizedBox(height: 6.h), - GestureDetector( - onTap: () => controller.playTts(msg.ttsUrl!), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.play_circle_outline, - size: 16.sp, - color: const Color(0xFF0091EA), - ), - SizedBox(width: 4.w), - Text( - '重播语音', - style: TextStyle( - fontSize: 11.sp, - color: const Color(0xFF0091EA), - ), - ), - ], + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), ), - ), - ], - ], + ], + ), + child: Text(msg.text, + style: TextStyle( + fontSize: 15.sp, + height: 1.55, + color: isUser + ? Colors.white + : (isDark ? Colors.white : _textPrimary))), + ), ), - ), + ], + ), + // 时间 + SizedBox(height: 3.h), + Padding( + padding: EdgeInsets.only(left: isUser ? 0 : 34.w), + child: Text(_fmtTime(msg.time), + style: TextStyle( + fontSize: 11.sp, color: const Color(0xFFD0D5DD))), ), - if (isUser) ...[ - SizedBox(width: 8.w), - _buildAvatar(isUser: true), - ], ], ), ); } - Widget _buildAvatar({required bool isUser}) { - return Container( - width: 32.w, - height: 32.w, - decoration: BoxDecoration( - color: isUser - ? const Color(0xFF0091EA).withOpacity(0.15) - : const Color(0xFF00BFA5).withOpacity(0.15), - shape: BoxShape.circle, - ), - child: Icon( - isUser ? Icons.person : Icons.cell_tower, - size: 16.sp, - color: isUser ? const Color(0xFF0091EA) : const Color(0xFF00BFA5), - ), + Widget _buildRealtimeBubble(String text) { + return Padding( + padding: EdgeInsets.only(bottom: 14.h), + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + Flexible( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + decoration: BoxDecoration( + color: _blue.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(18.r), + ), + child: Text(text, + style: TextStyle( + fontSize: 15.sp, color: Colors.white, height: 1.55)), + ), + ), + ]), ); } + // ══════════════════════════════════════════════════════════════════════════ + // 底部控制栏 + // ══════════════════════════════════════════════════════════════════════════ + Widget _buildBottomBar(bool isDark) { - return Container( - padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 24.h), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A1A1A) : Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, -2), + return Obx(() { + final inCall = controller.inCall.value; + final s = controller.status.value; + final ptt = controller.pttRecording.value; + + // 状态栏显示条件: + // - 通话模式:recording/thinking/speaking/connecting 都显示 + // - 非通话非PTT:thinking/speaking 显示 + // - PTT 中:不显示(状态在中间按钮体现) + final showStatusBar = !ptt && ( + (inCall && s != MobileElfStatus.idle && s != MobileElfStatus.error) || + (!inCall && (s == MobileElfStatus.speaking || s == MobileElfStatus.thinking)) + ); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A1A) : _surfaceLight, + border: Border(top: BorderSide(color: isDark ? const Color(0xFF222222) : _border)), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, -2)), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showStatusBar) _buildStatusBar(s, inCall, isDark), + if (inCall) + _buildInCallInputBar(isDark) + else + _buildNormalInputBar(isDark), + ], ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 实时识别字幕 - Obx(() { - final text = controller.realtimeText.value; - if (text.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: EdgeInsets.only(bottom: 8.h), - child: Text( - text, - style: TextStyle( - fontSize: 13.sp, - color: isDark ? Colors.grey[400] : Colors.grey[500], - fontStyle: FontStyle.italic, + ), + ); + }); + } + + // ── 状态提示栏 ──────────────────────────────────────────────────────────── + + Widget _buildStatusBar(MobileElfStatus s, bool inCall, bool isDark) { + Widget? content; + + switch (s) { + case MobileElfStatus.speaking: + content = GestureDetector( + onTap: controller.interruptSpeaking, + behavior: HitTestBehavior.opaque, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: const Color(0xFFECFDF3), + child: Row( + children: [ + _MiniWave(color: _greenDark), + SizedBox(width: 8.w), + Expanded(child: Text('晓晓正在朗读...', style: TextStyle(fontSize: 13.sp, color: _greenDark, fontWeight: FontWeight.w500))), + Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), + decoration: BoxDecoration( + border: Border.all(color: _red), + borderRadius: BorderRadius.circular(16.r), + ), + child: Text('× 打断', style: TextStyle(fontSize: 12.sp, color: _red, fontWeight: FontWeight.w500)), ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ); - }), - - // 错误提示 - Obx(() { - final err = controller.errorMsg.value; - if (err.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: EdgeInsets.only(bottom: 8.h), - child: Text( - err, - style: TextStyle( - fontSize: 12.sp, color: Colors.red[400]), - textAlign: TextAlign.center, - ), - ); - }), + ], + ), + ), + ); + break; + case MobileElfStatus.recording: + content = Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: const Color(0xFFECFDF3), + child: Row( + children: [ + _MiniWave(color: _greenDark), + SizedBox(width: 8.w), + Expanded(child: Obx(() { + final rt = controller.realtimeText.value; + return Text(rt.isNotEmpty ? rt : '聆听中...', style: TextStyle(fontSize: 13.sp, color: _greenDark), maxLines: 1, overflow: TextOverflow.ellipsis); + })), + ], + ), + ); + break; + case MobileElfStatus.connecting: + content = Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: const Color(0xFFFFFAEB), + child: Row( + children: [ + SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: _orange)), + SizedBox(width: 8.w), + Text('正在连接...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFFB54708))), + ], + ), + ); + break; + case MobileElfStatus.thinking: + content = Container( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + color: const Color(0xFFEFF8FF), + child: Row( + children: [ + SizedBox(width: 14.w, height: 14.w, child: CircularProgressIndicator(strokeWidth: 2, color: const Color(0xFF175CD3))), + SizedBox(width: 8.w), + Text('AI 思考中...', style: TextStyle(fontSize: 13.sp, color: const Color(0xFF175CD3))), + ], + ), + ); + break; + default: + return const SizedBox.shrink(); + } + return content; + } + + // ── 通话模式底栏 ────────────────────────────────────────────────────────── - // 麦克风按钮 - Obx(() => _buildMicButton(controller.status.value)), + Widget _buildInCallInputBar(bool isDark) { + return Padding( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 14.h), + child: Row( + children: [ + // 通话状态 + 计时 + Expanded( + child: Obx(() { + final s = controller.status.value; + final idle = s == MobileElfStatus.idle; + return Row( + children: [ + Container( + width: 8.w, height: 8.w, + decoration: BoxDecoration(shape: BoxShape.circle, color: idle ? _green : _orange), + ), + SizedBox(width: 6.w), + Text('通话中', style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary)), + SizedBox(width: 4.w), + Obx(() => Text(_fmtDuration(controller.callSeconds.value), + style: TextStyle(fontSize: 13.sp, color: isDark ? Colors.white70 : _textSecondary))), + ], + ); + }), + ), + // 音量指示器 + Obx(() => _VolumeIndicator( + rms: controller.micRms.value, + alive: controller.micAlive.value, + isDark: isDark, + )), + SizedBox(width: 12.w), + // 挂断按钮 + GestureDetector( + onTap: controller.disconnect, + child: Container( + width: 52.w, height: 52.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _red, + boxShadow: [BoxShadow(color: _red.withValues(alpha: 0.3), blurRadius: 14, offset: const Offset(0, 4))], + ), + child: Icon(Icons.call_end_rounded, color: Colors.white, size: 26.sp), + ), + ), ], ), ); } - Widget _buildMicButton(MobileElfStatus status) { - final bool isThinking = status == MobileElfStatus.thinking || - status == MobileElfStatus.connecting; - final Color btnColor = _statusColor(status); - final IconData icon = _micIcon(status); - final String label = _micLabel(status); - - return Column( - children: [ - GestureDetector( - onTap: isThinking ? null : controller.onMicTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 68.w, - height: 68.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isThinking - ? Colors.grey.withOpacity(0.2) - : btnColor.withOpacity(0.15), - border: Border.all( - color: isThinking ? Colors.grey.withOpacity(0.3) : btnColor, - width: 2, - ), - ), - child: isThinking - ? Center( - child: SizedBox( - width: 24.w, - height: 24.w, - child: CircularProgressIndicator( - strokeWidth: 2.5, color: Colors.grey), + // ── 普通输入栏 (按住说话) ────────────────────────────────────────────────── + + Widget _buildNormalInputBar(bool isDark) { + return Obx(() { + final s = controller.status.value; + final ptt = controller.pttRecording.value; + + final disabled = !ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error; + final pttClickable = s == MobileElfStatus.speaking; + final sideDisabled = ptt || (disabled && !pttClickable); + + return Padding( + padding: EdgeInsets.fromLTRB(12.w, 6.h, 12.w, 14.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── PTT 实时识别文字(居中 + 渐变背景) ── + if (ptt) + Obx(() { + final rt = controller.realtimeText.value; + if (rt.isEmpty) return const SizedBox.shrink(); + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + margin: EdgeInsets.only(bottom: 8.h), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDark + ? [const Color(0xFF2A2A2A), const Color(0xFF1A2332)] + : [const Color(0xFFF0F4FF), const Color(0xFFE8F5E9)], ), - ) - : Icon(icon, size: 30.sp, color: btnColor), + borderRadius: BorderRadius.circular(16.r), + border: Border.all( + color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0), + width: 0.5, + ), + ), + child: Text(rt, + style: TextStyle(fontSize: 15.sp, color: isDark ? Colors.white : _textPrimary, height: 1.4), + textAlign: TextAlign.center, + maxLines: 3, overflow: TextOverflow.ellipsis, + ), + ); + }), + // ── 按钮行 ── + Row( + children: [ + // 中间:PTT 按钮 + Expanded( + child: ptt + ? _buildPttRecordingCenter(isDark) + : IgnorePointer( + ignoring: disabled && !pttClickable, + child: Opacity( + opacity: (disabled && !pttClickable) ? 0.5 : 1.0, + child: _buildPttIdleButton(isDark), + ), + ), + ), + SizedBox(width: 8.w), + // 右侧:电话按钮 + IgnorePointer( + ignoring: sideDisabled, + child: Opacity( + opacity: sideDisabled ? 0.4 : 1.0, + child: _roundButton( + onTap: controller.startCall, + gradient: const LinearGradient( + colors: [Color(0xFF34C759), Color(0xFF28A745)], + begin: Alignment.topLeft, end: Alignment.bottomRight, + ), + shadow: _green.withValues(alpha: 0.4), + size: 44, + child: Icon(Icons.phone, color: Colors.white, size: 20.sp), + ), + ), + ), + ], + ), + ], + ), + ); + }); + } + + /// 按住说话按钮(空闲状态)— 参考微信风格 + Widget _buildPttIdleButton(bool isDark) { + return Listener( + onPointerDown: (_) => controller.startPtt(), + child: Container( + height: 42.h, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2A2A2A) : Colors.white, + borderRadius: BorderRadius.circular(21.r), + border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1), + ), + child: Center( + child: Text('按住 说话', + style: TextStyle( + fontSize: 15.sp, + color: isDark ? Colors.white70 : _textPrimary, + fontWeight: FontWeight.w600, + letterSpacing: 2.w, + ), ), ), - SizedBox(height: 6.h), - Text( - label, - style: TextStyle( - fontSize: 11.sp, - color: isThinking ? Colors.grey : btnColor, + ), + ); + } + + /// PTT 录音中:仅中间部分 + /// 连接中=与按住说话相同样式,录音中=橙色 + Widget _buildPttRecordingCenter(bool isDark) { + return Obx(() { + final s = controller.status.value; + final isConnecting = s == MobileElfStatus.connecting; + + if (isConnecting) { + // 连接中:与按住说话空闲状态一样的外观 + 加载指示 + return Container( + height: 42.h, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2A2A2A) : Colors.white, + borderRadius: BorderRadius.circular(21.r), + border: Border.all(color: isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD), width: 1), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 14.w, height: 14.w, + child: CircularProgressIndicator(strokeWidth: 2, color: isDark ? Colors.white70 : _textSecondary)), + SizedBox(width: 8.w), + Text('连接中...', style: TextStyle(fontSize: 14.sp, color: isDark ? Colors.white70 : _textSecondary, fontWeight: FontWeight.w500)), + ], ), + ); + } + + // 录音中:橙色 + return Container( + height: 42.h, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFF79009), Color(0xFFDC6803)]), + borderRadius: BorderRadius.circular(21.r), + boxShadow: [BoxShadow( + color: _orange.withValues(alpha: 0.3), + blurRadius: 8, offset: const Offset(0, 2), + )], ), - ], + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const _MiniWave(color: Colors.white), + SizedBox(width: 8.w), + Obx(() => Text('松开发送 · ${controller.pttSeconds.value}s', + style: TextStyle(fontSize: 14.sp, color: Colors.white, fontWeight: FontWeight.w500))), + ], + ), + ); + }); + } + + // ── 通用圆形按钮 ────────────────────────────────────────────────────────── + + Widget _roundButton({ + required VoidCallback onTap, + required Widget child, + Color? color, + Gradient? gradient, + Color? shadow, + Color? border, + double size = 56, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: size.w, + height: size.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: gradient == null ? color : null, + gradient: gradient, + border: border != null ? Border.all(color: border, width: 2) : null, + boxShadow: shadow != null + ? [BoxShadow(color: shadow, blurRadius: 14, offset: const Offset(0, 4))] + : null, + ), + child: Center(child: child), + ), ); } - // ── 辅助 ────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════ + // 工具方法 + // ══════════════════════════════════════════════════════════════════════════ - Color _statusColor(MobileElfStatus s) { - switch (s) { - case MobileElfStatus.connecting: - return Colors.orange; - case MobileElfStatus.recording: - return const Color(0xFF0066CC); - case MobileElfStatus.thinking: - return Colors.orange; - case MobileElfStatus.speaking: - return const Color(0xFF4CAF50); - case MobileElfStatus.error: - return Colors.red; - case MobileElfStatus.idle: - return const Color(0xFF0091EA); - } + /// 渐变圆形头像 + Widget _gradientCircle(double size, String text, double fontSize) { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: _avatarGradient, + ), + child: Center( + child: Text(text, + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: FontWeight.w600)), + ), + ); } - String _statusLabel(MobileElfStatus s) { - switch (s) { - case MobileElfStatus.connecting: - return '连接中'; - case MobileElfStatus.recording: - return '录音中'; - case MobileElfStatus.thinking: - return '思考中'; - case MobileElfStatus.speaking: - return '播报中'; - case MobileElfStatus.error: - return '错误'; - case MobileElfStatus.idle: - return '就绪'; - } + String _fmtTime(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + String _fmtDuration(int secs) { + final m = (secs ~/ 60).toString().padLeft(2, '0'); + final s = (secs % 60).toString().padLeft(2, '0'); + return '$m:$s'; } +} - String _statusBannerLabel(MobileElfStatus s) { - switch (s) { - case MobileElfStatus.connecting: - return '正在连接移动精灵...'; - case MobileElfStatus.recording: - return '正在聆听,请说话'; - case MobileElfStatus.thinking: - return 'AI 思考中,请稍候'; - case MobileElfStatus.speaking: - return '正在播报回答'; - case MobileElfStatus.error: - return controller.errorMsg.value; - case MobileElfStatus.idle: - return ''; - } +// ═══════════════════════════════════════════════════════════════════════════ +// 辅助类 +// ═══════════════════════════════════════════════════════════════════════════ + +class _PillCfg { + final Color bg, fg; + final String label; + const _PillCfg(this.bg, this.fg, this.label); +} + +/// 3 条小波浪动画 +class _MiniWave extends StatefulWidget { + final Color color; + const _MiniWave({required this.color}); + + @override + State<_MiniWave> createState() => _MiniWaveState(); +} + +class _MiniWaveState extends State<_MiniWave> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 800)) + ..repeat(); } - IconData _micIcon(MobileElfStatus s) { - switch (s) { - case MobileElfStatus.recording: - return Icons.stop_circle_outlined; - case MobileElfStatus.speaking: - return Icons.stop_circle_outlined; - case MobileElfStatus.error: - return Icons.refresh; - default: - return Icons.mic; - } + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); } - String _micLabel(MobileElfStatus s) { - switch (s) { - case MobileElfStatus.recording: - return '点击停止'; - case MobileElfStatus.speaking: - return '点击停止播报'; - case MobileElfStatus.error: - return '重试'; - case MobileElfStatus.thinking: - return '思考中...'; - case MobileElfStatus.connecting: - return '连接中...'; - default: - return '点击说话'; - } + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(3, (i) { + final phase = (_ctrl.value + i * 0.2) % 1.0; + final h = 3.0 + 7.0 * (0.5 - (phase - 0.5).abs()) * 2; + return Container( + width: 2.5, + height: h.clamp(3.0, 10.0), + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ), + ); + } +} + +/// 麦克风音量指示器(通话模式用) +class _VolumeIndicator extends StatelessWidget { + final double rms; + final bool alive; + final bool isDark; + const _VolumeIndicator({required this.rms, required this.alive, required this.isDark}); + + @override + Widget build(BuildContext context) { + // rms 范围大约 0~6000,归一化到 0~1 + final level = alive ? (rms / 5000).clamp(0.0, 1.0) : 0.0; + const barCount = 5; + return Container( + width: 44.w, height: 44.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF2F4F7), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(barCount, (i) { + final threshold = (i + 1) / barCount; + final active = level >= threshold; + return Container( + width: 3.w, + height: (8 + i * 4).h, + margin: EdgeInsets.symmetric(horizontal: 1.w), + decoration: BoxDecoration( + color: active + ? const Color(0xFF34C759) + : (isDark ? const Color(0xFF444444) : const Color(0xFFD0D5DD)), + borderRadius: BorderRadius.circular(1.5.r), + ), + ); + }), + ), + ); } } diff --git a/test_mobile_elf.py b/test_mobile_elf.py new file mode 100644 index 000000000..77d4449c4 --- /dev/null +++ b/test_mobile_elf.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +import ssl, time, hashlib, json, websocket + +DEVICE_NO = '199839C0AAAE99653D2E' +DEVICE_ID = '2032013791868043266' +PRODUCT_ID = '2031271967792472065' +PRODUCT_KEY = 'hotepZZwvouWQjHz' +DEVICE_SECRET = 'ijdthZGQELhkmigf' + +ts = str(int(time.time() * 1000)) +sign = hashlib.md5((DEVICE_SECRET + ts).encode()).hexdigest() +url = (f'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr' + f'?deviceNo={DEVICE_NO}&deviceId={DEVICE_ID}' + f'&productId={PRODUCT_ID}&productKey={PRODUCT_KEY}' + f'&sign={sign}&sn=py-test&ts={ts}') + +print(f'connecting...\n{url}') +ws = websocket.WebSocket(sslopt={'cert_reqs': ssl.CERT_NONE}) +ws.settimeout(15) +ws.connect(url) +print('connected! sending start frame immediately...') + +import uuid +start_frame = json.dumps({ + 'type': 'start', + 'data': { + 'cuid': DEVICE_ID, + 'format': 'pcm', + 'sample': 16000, + 'support_dcs': 2, + 'chunk_size': 10240, + 'support_tts': True, + 'support_text2dcs': True, + 'user_agent': 'voitrans/1.0', + 'dialog_request_id': str(uuid.uuid4()), + 'access_rc': True, + 'support_part_tts': True, + 'need_stoplisten': True, + 'need_dialogue_finish': True, + 'result_trans2directive': False, + 'rc_version': '5', + 'messages': [], + 'client_context': [], + } +}) +ws.send(start_frame) +print('start frame sent, waiting for ready...') + +# 等待 ready +while True: + msg = ws.recv() + print('received:', msg) + data = json.loads(msg) + if data.get('type') == 'ready': + print('--- ready received, sending text "你好" ---') + break + +# 发送文本 +text_frame = json.dumps({'type': 'text', 'data': {'text': '你好'}}) +ws.send(text_frame) +print('text frame sent') + +# 发送 finish +finish_frame = json.dumps({'type': 'finish'}) +ws.send(finish_frame) +print('finish frame sent, waiting for AI response...') + +# 接收所有回包直到 end=1 +while True: + msg = ws.recv() + print('received:', msg) + try: + data = json.loads(msg) + if data.get('end') == 1: + print('--- session end (end=1), closing ---') + break + except Exception: + pass + +ws.close() +print('done.') diff --git a/ui/yidong.html b/ui/yidong.html new file mode 100644 index 000000000..c8be59f76 --- /dev/null +++ b/ui/yidong.html @@ -0,0 +1,563 @@ + + + + + +移动精灵 UI 预览 + + + + + +
+

① 文本输入模式

+
+
+ ‹ +
晓
+ 移动精灵 + 空闲 +
+
+
+
晓
+
移动精灵
+
点击右下角按钮,即可与晓晓对话
+
+
+
+
+ +
+ + +
+ +
+
+
+
+ + +
+

② 语音模式 - 按住说话

+
+
+ ‹ +
晓
+ 移动精灵 + 空闲 +
+
+
+
晓
晓晓
+
你好!我是晓晓,有什么可以帮你的吗?
+ 14:23 +
+
+
+
+ +
按住 说话
+ +
+
+
+
+ + +
+

③ 按住说话 - 连接中(同空闲样式)

+
+
+ ‹ +
晓
+ 移动精灵 + 连接中… +
+
+
+
晓
晓晓
+
你好!我是晓晓,有什么可以帮你的吗?
+ 14:23 +
+
+
+
+ +
+
+ 连接中... +
+ +
+
+
+
+ + +
+

④ 按住说话 - 录音中(橙色)

+
+
+ ‹ +
晓
+ 移动精灵 + 识别中 +
+
+
+
晓
晓晓
+
你好!我是晓晓,有什么可以帮你的吗?
+ 14:23 +
+
+
+ +
今天天气怎么样?
+
+ +
+
+ 松开发送 · 2s +
+ +
+
+
+
+ + +
+

⑤ AI 思考中(输入栏禁用)

+
+
+ ‹ +
晓
+ 移动精灵 + 处理中 +
+
+
+
晓
晓晓
+
你好!我是晓晓,有什么可以帮你的吗?
+ 14:23 +
+
+
今天天气怎么样?
+ 14:24 +
+
+
+
+
+ AI 思考中... +
+
+ +
按住 说话
+ +
+
+
+
+ + +
+

⑥ AI 播报中(可打断)

+
+
+ ‹ +
晓
+ 移动精灵 + 朗读中 +
+
+
+
今天天气怎么样?
+ 14:24 +
+
+
晓
晓晓
+
今天晴天,气温 18°C,微风,空气质量优,很适合出行哦
+ 14:24 +
+
+
+
+
+ 晓晓正在朗读... + +
+
+ +
按住 说话
+ +
+
+
+
+ + +
+

⑦ 通话模式 - 等待说话

+
+
+ ‹ +
晓
+ 移动精灵 + 通话中 01:23 +
+
+
+
今天天气怎么样?
+ 14:24 +
+
+
晓
晓晓
+
今天晴天,气温 18°C,微风,空气质量优
+ 14:24 +
+
+
+
+
+
+ 通话中 + 01:23 +
+ +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+

⑧ 通话模式 - AI 播报

+
+
+ ‹ +
晓
+ 移动精灵 + 朗读中 01:48 +
+
+
+
Flutter 有什么优势?
+ 14:41 +
+
+
晓
晓晓
+
Flutter 的核心优势在于跨平台能力强、渲染性能高,以及 Dart 语言的高效开发体验...
+ 14:41 +
+
+
+
+
+ 晓晓正在朗读... + +
+
+
+
+ 通话中 + 01:48 +
+
+
+
+
+
+
+
+ +
+
+
+
+ + +