diff --git a/lib/modules/meeting/controllers/meeting_home_controller.dart b/lib/modules/meeting/controllers/meeting_home_controller.dart index 736826303..2d597beeb 100644 --- a/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -48,6 +48,63 @@ class MeetingHomeController extends GetxController { bool isAsc = false; // 是否升序排序 TextEditingController filtrateController = TextEditingController(); + // 多选删除模式状态 + final RxBool isSelectionMode = false.obs; + final RxList selectedIds = [].obs; + + bool get isAllSelected => + dataList.isNotEmpty && selectedIds.length == dataList.length; + + void enterSelectionMode([int? id]) { + isSelectionMode.value = true; + if (id != null && !selectedIds.contains(id)) { + selectedIds.add(id); + } + } + + void exitSelectionMode() { + isSelectionMode.value = false; + selectedIds.clear(); + } + + void toggleSelection(int id) { + if (selectedIds.contains(id)) { + selectedIds.remove(id); + } else { + selectedIds.add(id); + } + } + + void toggleSelectAll() { + if (isAllSelected) { + selectedIds.clear(); + } else { + selectedIds.value = + dataList.map((item) => item['id'] as int).toList(); + } + } + + Future deleteSelectedMeetings() async { + if (selectedIds.isEmpty) return; + final ids = List.from(selectedIds); + for (final id in ids) { + final item = originalDataList.firstWhere( + (e) => e['id'] == id, + orElse: () => null, + ); + final filepath = item != null ? (item['filepath'] ?? '') as String : ''; + originalDataList.removeWhere((e) => e['id'] == id); + dataList.removeWhere((e) => e['id'] == id); + SqfliteApi.deleteMeeting(id); + if (filepath.isNotEmpty) { + await _deleteFile(filepath); + } + meetingUploadService.removeUpload(id, isDeleteTask: true); + meetingTaskService.removeTask(id); + } + exitSelectionMode(); + } + // 蓝牙服务 final bleManager = Get.find(); diff --git a/lib/modules/meeting/views/meeting_home_view.dart b/lib/modules/meeting/views/meeting_home_view.dart index 86160c47a..697874262 100644 --- a/lib/modules/meeting/views/meeting_home_view.dart +++ b/lib/modules/meeting/views/meeting_home_view.dart @@ -33,20 +33,31 @@ class _MeetingHomeViewState extends State final isDarkMode = Theme.of(context).brightness == Brightness.dark; return PopScope( - onPopInvokedWithResult: _controller.onPopInvokedWithResult, + canPop: !_controller.isSelectionMode.value, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _controller.isSelectionMode.value) { + _controller.exitSelectionMode(); + return; + } + _controller.onPopInvokedWithResult(didPop, result); + }, child: Scaffold( backgroundColor: MeetingUIUtils.getBackgroundColor(isDarkMode), appBar: _buildAppBar(isDarkMode), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildFiltrate(isDarkMode), - if (_controller.meetingController.type == 0) - _buildConnect(isDarkMode), - ], + Obx( + () => _controller.isSelectionMode.value + ? _buildSelectionBar(isDarkMode) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildFiltrate(isDarkMode), + if (_controller.meetingController.type == 0) + _buildConnect(isDarkMode), + ], + ), ), Expanded( child: _buildBody(context, isDarkMode), @@ -57,6 +68,131 @@ class _MeetingHomeViewState extends State ); } + // 多选模式操作栏:取消 / 全选 / 删除 + Widget _buildSelectionBar(bool isDarkMode) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w), + child: Row( + children: [ + GestureDetector( + onTap: _controller.exitSelectionMode, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 4.w), + child: Text( + 'cancel'.tr, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 15.sp, + ), + ), + ), + ), + 12.horizontalSpace, + Expanded( + child: Obx( + () => Text( + '已选 ${_controller.selectedIds.length} 项', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 15.sp, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + GestureDetector( + onTap: _controller.toggleSelectAll, + behavior: HitTestBehavior.opaque, + child: Obx( + () => Padding( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.w), + child: Text( + _controller.isAllSelected ? '取消全选' : '全选', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 15.sp, + ), + ), + ), + ), + ), + Obx( + () => GestureDetector( + onTap: _controller.selectedIds.isEmpty + ? null + : () => _confirmDeleteSelected(isDarkMode), + behavior: HitTestBehavior.opaque, + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.w), + decoration: BoxDecoration( + color: _controller.selectedIds.isEmpty + ? Colors.red.withValues(alpha: 0.3) + : Colors.red, + borderRadius: BorderRadius.circular(50.w), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, + color: Colors.white, size: 16.sp), + 4.horizontalSpace, + Text( + 'delete'.tr, + style: TextStyle( + color: Colors.white, + fontSize: 14.sp, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void _confirmDeleteSelected(bool isDarkMode) { + Get.dialog( + AlertDialog( + backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, + title: Text( + 'delete'.tr, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + fontSize: 16.sp, + ), + ), + content: Text( + '确定要删除选中的 ${_controller.selectedIds.length} 项会议记录吗?', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: () { + Get.back(); + _controller.deleteSelectedMeetings(); + }, + child: Text( + 'delete'.tr, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + } + Widget _buildFiltrate(bool isDarkMode) { return GestureDetector( onTap: () { @@ -277,30 +413,59 @@ class _MeetingHomeViewState extends State tasktypeText = ''; } - return GestureDetector( - onTap: () { - Get.toNamed(Routes.meetingDetails, arguments: { - 'id': data.id, - }); - }, - onLongPressStart: (LongPressStartDetails details) { - _onLongPressItem(context, isDarkMode, details, data); - }, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), - margin: EdgeInsets.symmetric(vertical: 5.w), - decoration: BoxDecoration( - color: isDarkMode - ? MeetingUIUtils.getTranslucentWhite(0.05) - : MeetingUIUtils.getCardColor(isDarkMode), - borderRadius: BorderRadius.circular(12.w), - boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.title, + return Obx(() { + final bool selectionMode = _controller.isSelectionMode.value; + final bool selected = _controller.selectedIds.contains(data.id); + return GestureDetector( + onTap: () { + if (selectionMode) { + _controller.toggleSelection(data.id); + } else { + Get.toNamed(Routes.meetingDetails, arguments: { + 'id': data.id, + }); + } + }, + onLongPressStart: (LongPressStartDetails details) { + if (selectionMode) return; + Haptics.vibrate(HapticsType.success); + _controller.enterSelectionMode(data.id); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w), + margin: EdgeInsets.symmetric(vertical: 5.w), + decoration: BoxDecoration( + color: isDarkMode + ? MeetingUIUtils.getTranslucentWhite(0.05) + : MeetingUIUtils.getCardColor(isDarkMode), + borderRadius: BorderRadius.circular(12.w), + boxShadow: MeetingUIUtils.getCardShadow(isDarkMode), + border: selectionMode && selected + ? Border.all(color: Colors.red, width: 1.5) + : null, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (selectionMode) + Padding( + padding: EdgeInsets.only(right: 10.w), + child: Icon( + selected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: selected + ? Colors.red + : (isDarkMode ? Colors.grey[400] : Colors.grey), + size: 22.w, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title, style: TextStyle( color: MeetingUIUtils.getTextColor(isDarkMode), fontSize: 18.sp, @@ -417,12 +582,17 @@ class _MeetingHomeViewState extends State ), ], ), - ], + ], + ), + ), + ], + ), ), - ), - ); + ); + }); } + // ignore: unused_element void _onLongPressItem(BuildContext context, bool isDarkMode, LongPressStartDetails details, MeetingModel data) { Haptics.vibrate(HapticsType.success); diff --git a/linux/flutter/ephemeral/.plugin_symlinks/app_links_linux b/linux/flutter/ephemeral/.plugin_symlinks/app_links_linux new file mode 120000 index 000000000..0754d472f --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/app_links_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/app_links_linux-1.0.3/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus b/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus new file mode 120000 index 000000000..5891bf73d --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus b/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus new file mode 120000 index 000000000..2faf5084e --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/file_picker b/linux/flutter/ephemeral/.plugin_symlinks/file_picker new file mode 120000 index 000000000..f060bd847 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/file_picker @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/file_picker-10.3.10/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux b/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux new file mode 120000 index 000000000..dddcf45c0 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/flutter_blue_plus_linux b/linux/flutter/ephemeral/.plugin_symlinks/flutter_blue_plus_linux new file mode 120000 index 000000000..b6ba0971a --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/flutter_blue_plus_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/flutter_blue_plus_linux-7.0.3/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux b/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux new file mode 120000 index 000000000..ae1560fc0 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-5.0.0/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/gtk b/linux/flutter/ephemeral/.plugin_symlinks/gtk new file mode 120000 index 000000000..8c11bb4e1 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/gtk @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/gtk-2.1.0/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux b/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux new file mode 120000 index 000000000..6d1ab2096 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus b/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus new file mode 120000 index 000000000..d4a608e48 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/package_info_plus-8.3.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux new file mode 120000 index 000000000..1772469bd --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/record_linux b/linux/flutter/ephemeral/.plugin_symlinks/record_linux new file mode 120000 index 000000000..7a0a20f90 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/record_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/record_linux-1.3.0/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/share_plus b/linux/flutter/ephemeral/.plugin_symlinks/share_plus new file mode 120000 index 000000000..497c2cf34 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/share_plus @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/share_plus-12.0.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux new file mode 120000 index 000000000..e1d5e58cd --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/window_size b/linux/flutter/ephemeral/.plugin_symlinks/window_size new file mode 120000 index 000000000..3e93d5a47 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/window_size @@ -0,0 +1 @@ +/Users/liwei/.pub-cache/git/flutter-desktop-embedding-6c66ad23ee79749f30a8eece542cf54eaf157ed8/plugins/window_size/ \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index f7d75ee87..3f381812e 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -161,8 +161,35 @@ object AgentService : CoroutineScope { // ========== 音频焦点(唤醒 AI 时打断外部和内部其他音频)========== private var audioManagerForFocus: AudioManager? = null private var aiAudioFocusRequest: AudioFocusRequest? = null + + // focusChange 整数 → 可读名字,方便导出的日志里一眼看懂 + private fun focusChangeName(code: Int): String = when (code) { + AudioManager.AUDIOFOCUS_GAIN -> "GAIN" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> "GAIN_TRANSIENT" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> "GAIN_TRANSIENT_EXCLUSIVE" + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "GAIN_TRANSIENT_MAY_DUCK" + AudioManager.AUDIOFOCUS_LOSS -> "LOSS" + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "LOSS_TRANSIENT" + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "LOSS_TRANSIENT_CAN_DUCK" + else -> "UNKNOWN($code)" + } + + private fun requestResultName(code: Int): String = when (code) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> "GRANTED($code)" + AudioManager.AUDIOFOCUS_REQUEST_FAILED -> "FAILED($code)" + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> "DELAYED($code)" + else -> "UNKNOWN($code)" + } + private val aiAudioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> - Log.d(TAG, "[Agent] 音频焦点变化: $focusChange") + val musicActive = try { audioManagerForFocus?.isMusicActive ?: false } catch (_: Throwable) { false } + _log("INFO", "[Agent][焦点] 焦点变化=${focusChangeName(focusChange)} isMusicActive=$musicActive cachedReq=${aiAudioFocusRequest != null}") + // 永久丢失焦点:系统或其他 App 已把焦点抢走,清掉缓存, + // 这样下次 requestAiAudioFocus 不会再被短路,而是重新发起请求 + if (focusChange == AudioManager.AUDIOFOCUS_LOSS) { + _log("WARNING", "[Agent][焦点] AUDIOFOCUS_LOSS —— 焦点被系统或其他 App 抢走,清空本地缓存") + aiAudioFocusRequest = null + } } private fun requestAiAudioFocus() { @@ -171,36 +198,88 @@ object AgentService : CoroutineScope { val am = audioManagerForFocus ?: (ctx.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.also { audioManagerForFocus = it } ?: return + + // 请求前快照:系统此刻是否有音乐在响 / 音频模式 / 本地是否还留着上次的 request + val musicActiveBefore = try { am.isMusicActive } catch (_: Throwable) { false } + val audioMode = try { am.mode } catch (_: Throwable) { -1 } + _log("INFO", "[Agent][焦点] requestAiAudioFocus 进入: isMusicActive=$musicActiveBefore audioMode=$audioMode cachedReq=${aiAudioFocusRequest != null} sdk=${Build.VERSION.SDK_INT}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (aiAudioFocusRequest != null) return - val attrs = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build() + // 每次唤醒强制重抢焦点: + // 1) 先把上次缓存的 request 主动 abandon 一次 —— 彻底切断"我们以为还持有焦点但系统里早被抢走"的错判, + // 也能避开部分 OEM 对复用同一个 AudioFocusRequest 对象不敏感的问题; + // 2) 然后 build 一个全新的 request,再 requestAudioFocus,确保系统每次都真正 re-evaluate 一次焦点归属。 + aiAudioFocusRequest?.let { prev -> + try { + val abandonResult = am.abandonAudioFocusRequest(prev) + _log("INFO", "[Agent][焦点] 强制重抢前 abandon 旧 request 结果=${requestResultName(abandonResult)}") + } catch (e: Throwable) { + _log("WARNING", "[Agent][焦点] 强制重抢前 abandon 旧 request 异常: ${e.message}") + } + } + aiAudioFocusRequest = null + val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setAudioAttributes(attrs) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) .setOnAudioFocusChangeListener(aiAudioFocusListener) .build() - aiAudioFocusRequest = req val result = am.requestAudioFocus(req) - Log.i(TAG, "[Agent] 请求音频焦点(EXCLUSIVE) 结果=$result") + val resultName = requestResultName(result) + when (result) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED, + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + aiAudioFocusRequest = req + _log("INFO", "[Agent][焦点] 强制重抢音频焦点(EXCLUSIVE) 结果=$resultName") + } + else -> { + aiAudioFocusRequest = null + _log("WARNING", "[Agent][焦点] 强制重抢音频焦点(EXCLUSIVE) 失败 结果=$resultName —— 其他 App 正占用且拒绝让出") + } + } + // 请求完给系统一点时间把 focus loss 通知到其他 App,再复查一次 + // 如果这里 isMusicActive 还是 true,就说明对方没响应(常见于部分 OEM / 抖音 / 某些音乐 App) + launch { + try { + delay(300) + val musicActiveAfter = try { am.isMusicActive } catch (_: Throwable) { false } + _log( + if (musicActiveAfter) "WARNING" else "INFO", + "[Agent][焦点] 请求后 300ms 复查: isMusicActive=$musicActiveAfter [${if (musicActiveAfter) "未暂停" else "已暂停"}]" + ) + } catch (e: Exception) { + _log("ERROR", "[Agent][焦点] 复查 isMusicActive 异常: ${e.message}") + } + } } else { + // legacy 路径同样做一次"abandon + 重新 request",保证每次 wake 都强制刷新焦点归属 + @Suppress("DEPRECATION") + try { + am.abandonAudioFocus(aiAudioFocusListener) + } catch (e: Throwable) { + _log("WARNING", "[Agent][焦点] 强制重抢前 abandon 旧焦点 (legacy) 异常: ${e.message}") + } @Suppress("DEPRECATION") val result = am.requestAudioFocus( aiAudioFocusListener, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE ) - Log.i(TAG, "[Agent] 请求音频焦点(EXCLUSIVE, legacy) 结果=$result") + _log("INFO", "[Agent][焦点] 强制重抢音频焦点(EXCLUSIVE, legacy) 结果=${requestResultName(result)}") } } catch (e: Exception) { - Log.e(TAG, "[Agent] 请求音频焦点失败: ${e.message}", e) + _log("ERROR", "[Agent][焦点] 请求音频焦点异常: ${e.message}") } } private fun abandonAiAudioFocus() { try { val am = audioManagerForFocus ?: return + val had = aiAudioFocusRequest != null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { aiAudioFocusRequest?.let { am.abandonAudioFocusRequest(it) } aiAudioFocusRequest = null @@ -208,10 +287,38 @@ object AgentService : CoroutineScope { @Suppress("DEPRECATION") am.abandonAudioFocus(aiAudioFocusListener) } - Log.i(TAG, "[Agent] 已归还音频焦点") + _log("INFO", "[Agent][焦点] 已归还音频焦点 (之前 cached=$had)") } catch (e: Exception) { - Log.e(TAG, "[Agent] 归还音频焦点失败: ${e.message}", e) + _log("ERROR", "[Agent][焦点] 归还音频焦点异常: ${e.message}") + } + } + + // 仅当 AI / TTS / 延迟发起的 AI 任务都空闲时才释放焦点,避免识别结束后外部音乐抢回播放 + private fun tryReleaseAiAudioFocus(reason: String = "") { + val aiStreaming = _isAiStreaming.get() + val ttsSpeaking = _isTtsSpeaking.get() + val pendingActive = pendingProcessJob?.isActive == true + val aiJobActive = currentAiJob?.isActive == true + val asrActive = _isRecognitionActive.get() + // 停止识别后 ASR 的最终回调是异步到达的,若累计识别文本还未被消费, + // 紧接着就会调度 pendingProcessJob → processWithChatApi 触发 AI + TTS, + // 此时若提前释放焦点,外部音乐会与 AI 播报同时响 + val hasPendingText = recognitionResult.isNotEmpty() || currentRecognitionResult.isNotEmpty() + if (aiStreaming || ttsSpeaking || pendingActive || aiJobActive || asrActive || hasPendingText) { + // 明确把是哪一个 guard 拦下的打出来,方便你定位"为什么音乐没恢复" + val blockers = buildList { + if (aiStreaming) add("aiStreaming") + if (ttsSpeaking) add("ttsSpeaking") + if (pendingActive) add("pendingJob") + if (aiJobActive) add("aiJob") + if (asrActive) add("asr") + if (hasPendingText) add("pendingText") + }.joinToString(",") + _log("INFO", "[Agent][焦点] 延迟释放焦点 reason=$reason blockers=[$blockers] streaming=$aiStreaming tts=$ttsSpeaking pendingJob=$pendingActive aiJob=$aiJobActive asr=$asrActive text=$hasPendingText") + return } + _log("INFO", "[Agent][焦点] 可以释放焦点 reason=$reason") + abandonAiAudioFocus() } // 状态 - 使用原子类型确保线程安全 @@ -566,7 +673,10 @@ object AgentService : CoroutineScope { } TtsEventType.SYNTHESIS_COMPLETED -> { - _isTtsSpeaking.set(false) + // 注意:SYNTHESIS_COMPLETED 只表示 Azure 服务端音频数据全部返回, + // 本地扬声器此刻通常还在播放,真正"播完"以 PLAYBACK_COMPLETED 为准。 + // 因此这里不再清 _isTtsSpeaking,也不去释放焦点,避免外部音乐 + // 在 AI 实际说完前抢回播放。 restartIdleCheck() sendEvent("tts_completed", mapOf("status" to "completed")) } @@ -577,6 +687,7 @@ object AgentService : CoroutineScope { sendEvent("tts_canceled", mapOf("status" to "canceled")) // TTS取消时停止气泡音 // audioPlayer?.stopAudio() + tryReleaseAiAudioFocus("ttsSynthesisCanceled") } TtsEventType.PLAYBACK_STARTED -> { //开始AI播报内容 @@ -603,6 +714,11 @@ object AgentService : CoroutineScope { } TtsEventType.PLAYBACK_COMPLETED -> { //结束AI播报内容 + // 防御性重置:有些 TTS 引擎会把 PLAYBACK_COMPLETED 发在 SYNTHESIS_COMPLETED 之前, + // 导致到这里时 _isTtsSpeaking 还是 true,进而被 tryReleaseAiAudioFocus 的 guard 拦下, + // 结果就是 AI 说完了但外部音乐/焦点一直没恢复 + _isTtsSpeaking.set(false) + _log("INFO", "[Agent][焦点] TTS PLAYBACK_COMPLETED —— AI 对话结束,准备恢复音乐并释放焦点") restartIdleCheck() sendEvent("playback_completed", mapOf("status" to "playback_completed")) // 发送AI说话开始广播 @@ -615,11 +731,7 @@ object AgentService : CoroutineScope { BleService.openB1Encoder() } resumeMusic() - // if(isplaymusic){ - // isplaymusic = false - // Log.e(TAG, "QQ 音乐恢复播放") - // QQMusicSingleton.getInstance().resumeMusic { } - // } + tryReleaseAiAudioFocus("ttsPlaybackCompleted") } TtsEventType.ERROR -> { @@ -627,6 +739,7 @@ object AgentService : CoroutineScope { restartIdleCheck() // TTS错误时停止气泡音 audioPlayer?.stopAudio() + tryReleaseAiAudioFocus("ttsError") val params = event.params val code = params["errorCode"] as? String ?: "UNKNOWN_ERROR" val message = params["errorMessage"] as? String ?: "未知错误" @@ -784,6 +897,7 @@ object AgentService : CoroutineScope { * @param mode 识别模式:normal, ble_wakeup, phone_call, push_to_talk */ fun startRecognition(isExternalActive: Boolean, mode: String = "normal"): Boolean { + _log("INFO", "[Agent][焦点] startRecognition 调用 mode=$mode agentMode=$agentMode external=$isExternalActive") // 抢占音频焦点,让外部和内部其他音频暂停(参考翻译模块的实现) requestAiAudioFocus() if (agentMode == "sts") { @@ -920,6 +1034,9 @@ object AgentService : CoroutineScope { { text = text.replace(Regex("^[,。?、!, . ? !]+"), "") recognitionResult += text + // 最终识别结果已消费 onRecognizing 积累的在途文本,必须清空, + // 否则 tryReleaseAiAudioFocus 的 hasPendingText 守卫会因为它非空而永远拦住焦点释放 + currentRecognitionResult = "" } else{ recognitionResult+= currentRecognitionResult @@ -955,6 +1072,7 @@ object AgentService : CoroutineScope { ) processWithChatApi(currsessionAsrId, recognitionResult) recognitionResult = "" + currentRecognitionResult = "" currsessionAsrId = "" } } @@ -1001,12 +1119,18 @@ object AgentService : CoroutineScope { ) ) processWithChatApi(currsessionAsrId, recognitionResult) + // 清空已消费的识别文本,避免 tryReleaseAiAudioFocus 的 hasPendingText 守卫 + // 在 AI/TTS 完成后仍误判为"有待触发内容"而长期 hold 住音频焦点 + recognitionResult = "" + currsessionAsrId = "" } sendEvent("recognition_stopped", mapOf("sessionid" to currsessionAsrId, "status" to "stopped")) }else{ sendEvent("recognition_stopped", mapOf("sessionid" to sessionid, "status" to "stopped")) audioPlayer?.playAudio(R.raw.stop) + // 会话结束后在途识别文本一定无效,清空避免 hasPendingText 守卫拦住焦点释放 + currentRecognitionResult = "" } stopIdleCheck() _isRecognitionActive.set(false) @@ -1025,6 +1149,7 @@ object AgentService : CoroutineScope { "details" to errorDetails ) ) + tryReleaseAiAudioFocus("asrCanceled") } override fun onError(sessionid:String, code: Int, error: String) { @@ -1041,6 +1166,7 @@ object AgentService : CoroutineScope { "message" to error ) ) + tryReleaseAiAudioFocus("asrError") } }) return true @@ -1060,11 +1186,11 @@ object AgentService : CoroutineScope { if (agentMode == "sts") { // StsAgent 内部根据 inCall / pttRecording 状态自行决定停止方式 stsAgent?.stop() - abandonAiAudioFocus() + tryReleaseAiAudioFocus("stopRecognition_sts") return } if (!isRecognitionActive) { - abandonAiAudioFocus() + tryReleaseAiAudioFocus("stopRecognition_inactive") return } @@ -1085,7 +1211,7 @@ object AgentService : CoroutineScope { // currentRecognitionMode = "normal" return } finally { - abandonAiAudioFocus() + tryReleaseAiAudioFocus("stopRecognition_finally") } } @@ -1165,6 +1291,8 @@ object AgentService : CoroutineScope { _isAiStreaming.set(false) currentAiJob = null restartIdleCheck() + } finally { + tryReleaseAiAudioFocus("stopAiStream") } } } @@ -1422,6 +1550,7 @@ object AgentService : CoroutineScope { // 标记AI流式输出已完成 _isAiStreaming.set(false) currentAiJob = null + tryReleaseAiAudioFocus("aiOnComplete") } catch (e: Exception) { _log("ERROR", "[Agent] AI onComplete异常: ${e.message}") } @@ -1447,6 +1576,7 @@ object AgentService : CoroutineScope { // 标记AI流式输出已完成 _isAiStreaming.set(false) currentAiJob = null + tryReleaseAiAudioFocus("aiOnError") } } catch (e: Exception) { _log("ERROR", "[Agent] AI onError异常: ${e.message}") @@ -1616,6 +1746,7 @@ object AgentService : CoroutineScope { _isTtsSpeaking.set(false) restartIdleCheck() // 状态变化,重启检测 sendEvent("tts_stopped", mapOf("status" to "stopped")) + tryReleaseAiAudioFocus("stopTts") } } @@ -3043,29 +3174,48 @@ object AgentService : CoroutineScope { * 自动暂停音乐 */ private fun autopauseMusic(){ + _log("INFO", "[Agent][音乐] autopauseMusic 进入 type=$musicServiceType isplaymusic=$isplaymusic") if ( musicServiceType == "QQ" ){ QQMusicSingleton.getInstance().autopauseMusic { result -> if (result.isSuccess) { isplaymusic = true + _log("INFO", "[Agent][音乐] QQ 音乐暂停成功,isplaymusic=true") + } else { + _log("WARNING", "[Agent][音乐] QQ 音乐暂停失败: ${result.exceptionOrNull()?.message}") } } }else if ( musicServiceType == "WY" ){ if (MusicService.isPlaying()){ MusicSingleton.startServiceWithCommand(context, "pause") isplaymusic = true + _log("INFO", "[Agent][音乐] WY 音乐暂停指令已发,isplaymusic=true") + } else { + _log("INFO", "[Agent][音乐] WY 当前未在播放,跳过暂停") } + } else { + _log("INFO", "[Agent][音乐] 未配置 QQ/WY 内置音乐源,跳过 autopauseMusic") } } - private fun resumeMusic() { + private fun resumeMusic() { + _log("INFO", "[Agent][音乐] resumeMusic 进入 type=$musicServiceType isplaymusic=$isplaymusic") if ( isplaymusic ){ - Log.d(TAG, "音乐恢复 : $musicServiceType") isplaymusic = false if ( musicServiceType == "QQ" ){ + _log("INFO", "[Agent][音乐] 调用 QQMusic.resumeMusic") QQMusicSingleton.getInstance().resumeMusic {} }else if ( musicServiceType == "WY" ){ + _log("INFO", "[Agent][音乐] 调用 WY MusicSingleton resume") MusicSingleton.startServiceWithCommand(context, "resume") + } else { + _log("WARNING", "[Agent][音乐] isplaymusic=true 但 musicServiceType 未知: $musicServiceType") } + } else { + // isplaymusic=false 有两种情况: + // 1) 本轮 AI 期间没暂停过内置音乐(因为外部音乐走音频焦点路径,不走这里); + // 2) autopauseMusic 被调但没成功(比如 QQ SDK 调用失败)。 + // 任一情况下都不应该操作内置音乐,但需要在日志里区分出来 + _log("INFO", "[Agent][音乐] isplaymusic=false,无内置音乐需要恢复(外部音乐由音频焦点恢复)") } } } diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 9ab8f5f82..6e905b1c5 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -558,6 +558,21 @@ class AgentServiceImpl: NSObject { startIdleCheckForMode(mode: currentRecognitionMode) } } + + // 仅当 ASR / AI / TTS 都空闲、且没有累计识别文本等待触发 AI 时才恢复外部音乐。 + // 否则会出现"超时中断唤醒→立刻恢复音乐→紧接着 onResult 触发 AI + TTS"同时响的问题。 + internal func tryResumeExternalMusicIfIdle(reason: String) { + guard wasMusicPlayingBeforeRecognition else { return } + let pendingWork = (pendingProcessWorkItem != nil) && !(pendingProcessWorkItem?.isCancelled ?? true) + let hasPendingText = !recognitionResult.isEmpty + if isRecognizing || isStartingRecognition || isSpeaking || isAiStreaming || pendingWork || hasPendingText { + _log("INFO", "[Agent] defer music resume (\(reason)) recog=\(isRecognizing) starting=\(isStartingRecognition) tts=\(isSpeaking) ai=\(isAiStreaming) pending=\(pendingWork) text=\(hasPendingText)") + return + } + wasMusicPlayingBeforeRecognition = false + _log("INFO", "[Agent] resume external music (\(reason))") + MusicService.shared.resume() + } func startRecognition(useBle: Bool = false, mode: String = "normal") -> Bool { if agentMode == "sts" { switch mode { @@ -703,13 +718,12 @@ class AgentServiceImpl: NSObject { } stopIdleCheck() - - // 只有之前在播放时才恢复播放 - if wasMusicPlayingBeforeRecognition { - wasMusicPlayingBeforeRecognition = false - MusicService.shared.resume() - } - + + // 不在此处直接恢复音乐:stopContinuousRecognition 的最终 onResult 是异步到达的, + // 此时若立即 resume,外部音乐会与即将触发的 AI/TTS 同时响; + // 交由 onSessionStopped/onCanceled/onError 以及 AI/TTS 完成回调调用 tryResumeExternalMusicIfIdle 统一判断。 + tryResumeExternalMusicIfIdle(reason: "stopRecognition") + // 启动中但已请求停止,也视为已接受停止请求 return success || stopRequestedDuringStart } @@ -1992,6 +2006,7 @@ class ChatApiStreamCallback: StreamCallback { try agentService.saveChatMessage(sessionId: sessionid,userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata)) agentService.isAiStreaming = false iscallingTool = true + agentService.tryResumeExternalMusicIfIdle(reason: "aiOnComplete") agentService._log("INFO", "[Agent] AI stream completed, response=\(response)") }catch{ agentService?._log("ERROR", "[Agent] AI onComplete error: \(error)") @@ -2019,6 +2034,7 @@ class ChatApiStreamCallback: StreamCallback { agentService.azureTtsHelper?.speakStream(sessionid: sessionid,agentService.insufficientIntegralText) } agentService.isAiStreaming = false + agentService.tryResumeExternalMusicIfIdle(reason: "aiOnError") agentService._log("ERROR", "[Agent] AI onError code=\(code), message=\(message)") }catch{ agentService?._log("ERROR", "[Agent] AI onError exception: \(error)") @@ -2141,10 +2157,11 @@ extension AgentServiceImpl: TtsEventListener { } sendEvent(name: "playback_completed", data: ["status": "playback_completed"]) MusicService.shared.restoreVolume() + tryResumeExternalMusicIfIdle(reason: "ttsPlaybackCompleted") case .synthesisCanceled: isSpeaking = false restartIdleCheck() - + var data: [String: Any] = ["status": "canceled"] if let reason = event.params["reason"] as? String { data["reason"] = reason @@ -2152,13 +2169,14 @@ extension AgentServiceImpl: TtsEventListener { if let errorDetails = event.params["errorDetails"] as? String { data["details"] = errorDetails } - + sendEvent(name: "tts_canceled", data: data) - + tryResumeExternalMusicIfIdle(reason: "ttsCanceled") + case .error: isSpeaking = false restartIdleCheck() - + var data: [String: Any] = [:] if let errorCode = event.params["errorCode"] as? String { data["errorCode"] = errorCode @@ -2168,9 +2186,10 @@ extension AgentServiceImpl: TtsEventListener { } else { data["message"] = "TTS错误" } - + sendEvent(name: "error", data: data) - + tryResumeExternalMusicIfIdle(reason: "ttsError") + default: break } @@ -2472,6 +2491,8 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { self.recognitionResult = "" self.currsessionAsrId = "" } + // 清空引用,避免 tryResumeExternalMusicIfIdle 把"已执行完的 workItem"误判为"仍在等待触发" + self.pendingProcessWorkItem = nil } pendingProcessWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: workItem) @@ -2550,6 +2571,10 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { ] sendEvent(name: "recognition_result", data: data) processTextInput(sessionid: currsessionAsrId, recognitionResult, speakResponse: true) + // 清空已消费的识别文本,避免 tryResumeExternalMusicIfIdle 的 hasPendingText 守卫 + // 在 AI/TTS 完成后仍误判为"有待触发内容"而长期 hold 住外部音乐 + recognitionResult = "" + currsessionAsrId = "" } sendEvent(name: "recognition_stopped", data: ["sessionid":currsessionAsrId,"status": "stopped"]) }else{ @@ -2560,15 +2585,12 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { if (currentRecognitionMode == "ble_wakeup") { audioPlayer?.playStopSound() } - - if wasMusicPlayingBeforeRecognition { - wasMusicPlayingBeforeRecognition = false - MusicService.shared.resume() - } + + tryResumeExternalMusicIfIdle(reason: "onSessionStopped") } - + } func onCanceled(sessionid:String ,_ reason: String, _ errorDetails: String) { @@ -2586,13 +2608,10 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { isStartingRecognition = false stopRequestedDuringStart = false stopIdleCheck() - - if wasMusicPlayingBeforeRecognition { - wasMusicPlayingBeforeRecognition = false - MusicService.shared.resume() - } + + tryResumeExternalMusicIfIdle(reason: "onCanceled") } - + func onError(sessionid:String ,_ errorCode: Int, _ error: String) { let data: [String: Any] = ["sessionid":sessionid,"code":errorCode, "message": error.isEmpty ? "未知错误" : error] sendEvent(name: "error", data: data) @@ -2601,11 +2620,8 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { isStartingRecognition = false stopRequestedDuringStart = false stopIdleCheck() - - if wasMusicPlayingBeforeRecognition { - wasMusicPlayingBeforeRecognition = false - MusicService.shared.resume() - } + + tryResumeExternalMusicIfIdle(reason: "asrOnError") } // MARK: - isInterrupt 缓存方法 diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 000000000..29c8eb329 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end