Browse Source

上传一个 优化版本

newdev_shunjiawei
liwei1dao 6 months ago
parent
commit
c07a51d637
  1. 57
      lib/modules/meeting/controllers/meeting_home_controller.dart
  2. 240
      lib/modules/meeting/views/meeting_home_view.dart
  3. 1
      linux/flutter/ephemeral/.plugin_symlinks/app_links_linux
  4. 1
      linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus
  5. 1
      linux/flutter/ephemeral/.plugin_symlinks/device_info_plus
  6. 1
      linux/flutter/ephemeral/.plugin_symlinks/file_picker
  7. 1
      linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux
  8. 1
      linux/flutter/ephemeral/.plugin_symlinks/flutter_blue_plus_linux
  9. 1
      linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux
  10. 1
      linux/flutter/ephemeral/.plugin_symlinks/gtk
  11. 1
      linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux
  12. 1
      linux/flutter/ephemeral/.plugin_symlinks/package_info_plus
  13. 1
      linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux
  14. 1
      linux/flutter/ephemeral/.plugin_symlinks/record_linux
  15. 1
      linux/flutter/ephemeral/.plugin_symlinks/share_plus
  16. 1
      linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux
  17. 1
      linux/flutter/ephemeral/.plugin_symlinks/window_size
  18. 198
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  19. 76
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  20. 42
      macos/Podfile

57
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<int> selectedIds = <int>[].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<int>((item) => item['id'] as int).toList();
}
}
Future<void> deleteSelectedMeetings() async {
if (selectedIds.isEmpty) return;
final ids = List<int>.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<BleManager>();

240
lib/modules/meeting/views/meeting_home_view.dart

@ -33,20 +33,31 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
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<MeetingHomeView>
);
}
// 多选模式操作栏:取消 / 全选 / 删除
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<MeetingHomeView>
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<MeetingHomeView>
),
],
),
],
],
),
),
],
),
),
),
);
);
});
}
// ignore: unused_element
void _onLongPressItem(BuildContext context, bool isDarkMode,
LongPressStartDetails details, MeetingModel data) {
Haptics.vibrate(HapticsType.success);

1
linux/flutter/ephemeral/.plugin_symlinks/app_links_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/app_links_linux-1.0.3/

1
linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/connectivity_plus-6.1.5/

1
linux/flutter/ephemeral/.plugin_symlinks/device_info_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/device_info_plus-11.5.0/

1
linux/flutter/ephemeral/.plugin_symlinks/file_picker

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/file_picker-10.3.10/

1
linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/

1
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/

1
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/

1
linux/flutter/ephemeral/.plugin_symlinks/gtk

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/gtk-2.1.0/

1
linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/

1
linux/flutter/ephemeral/.plugin_symlinks/package_info_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/package_info_plus-8.3.1/

1
linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/

1
linux/flutter/ephemeral/.plugin_symlinks/record_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/record_linux-1.3.0/

1
linux/flutter/ephemeral/.plugin_symlinks/share_plus

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/share_plus-12.0.2/

1
linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux

@ -0,0 +1 @@
/Users/liwei/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/

1
linux/flutter/ephemeral/.plugin_symlinks/window_size

@ -0,0 +1 @@
/Users/liwei/.pub-cache/git/flutter-desktop-embedding-6c66ad23ee79749f30a8eece542cf54eaf157ed8/plugins/window_size/

198
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,无内置音乐需要恢复(外部音乐由音频焦点恢复)")
}
}
}

76
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 缓存方法

42
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
Loading…
Cancel
Save