diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 8deb25201..000000000 --- a/.cursorrules +++ /dev/null @@ -1,94 +0,0 @@ -# 代码简明规范 -## Dart / Flutter - 1. 风格统一 - • 严格遵循 Effective Dart 格式与命名惯例。 - 2. 日志与调试 - • 禁止 print;仅在异常场景调用 logger.e(...)。 - 3. 业务逻辑 - • 默认使用 GetX(状态、路由、依赖注入等),避免手写 StatefulWidget。 - 4. 异常处理 - • 不写重试逻辑,失败即返回错误。 - • 谨慎使用 try-catch;优先通过类型系统与空安全防御。 - 5. 代码演进 - • 任何改动都应 “小步、安全”,杜绝一次性大改或过度设计。 - -## 原生层(Android / iOS) - 1. 配置不可改 - • 禁止修改 SDK、NDK、JDK 与 iOS 构建配置。 - 2. Kotlin DSL - • 原生实现一律采用 Kotlin + 协程,遵守 Kotlin 最佳实践。 - • 不写 Java 代码。 - -## 通用要求 - 1. 不自动执行 - • 生成脚本/代码不得包含 flutter run 等执行指令。 - 2. 并发优先级 - • 并发场景首选 Kotlin 协程;仅在 Flutter 端按需使用 Dart Future / Stream。 - 3. 语言 - • 与用户交流、注释及文档一律使用中文。 - • 不要自己创建总结文档。 - -宗旨:保持极简,先跑通业务,再考虑优化。 - - -# 项目架构概述 -DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 - -## 总体架构 -- 采用GetX状态管理框架 -- 遵循模块化设计,各功能模块相对独立 -- 使用依赖注入实现组件解耦 -- 多语言支持,使用GetX国际化方案 - -## 主要目录结构 -- lib/core: 核心组件、工具和通用功能 -- lib/data: 数据模型、服务和提供者 -- lib/modules: 功能模块,按业务划分 -- lib/routes: 路由定义和管理 -- local_plugins: 本地插件,提供原生功能支持 - -# 核心模块说明 -## 语音处理 -- 火山语音服务: 提供语音识别和合成核心能力 -- Azure语音服务: 提供备选的语音识别和合成能力 -- 离线TTS支持: 在线语音合成失败时的备选方案 - -## 聊天功能 -- 基于OpenAI的对话功能 -- 支持语音输入、文字输入和语音输出 -- 支持多种角色和语音类型 - -## 用户系统 -- 支持多种登录方式:Firebase、Google、Facebook -- 用户资料管理和设置 -- 主题切换和语言设置 - -## 蓝牙配对 -- 支持蓝牙设备发现和连接 -- 提供简化的配对流程 -- 设备管理和状态监控 - -## 音频处理 -- 音频录制和播放 -- 支持波形显示 -- 音量控制和音频质量设置 - -# 技术栈 -- Flutter: UI框架 -- GetX: 状态管理和依赖注入 -- Firebase: 用户认证和后端服务 -- WebSocket: 实时通信 -- SQLite: 本地数据存储 -- 原生插件: 语音识别、合成和蓝牙功能 - -# 插件说明 -- jieli: 杰理蓝牙设备连接和控制插件 -- volcano_speech: 火山语音服务插件,提供语音识别和合成 -- azure_speech: 微软Azure语音服务插件 -- open_ai_service: OpenAI服务插件,提供AI对话能力 -- speech_to_text: 通用语音识别插件 - -# 环境配置 -- 环境变量通过.env文件管理 -- 支持不同环境的配置切换 -- 敏感信息不应直接硬编码在代码中 \ No newline at end of file diff --git a/.gitignore b/.gitignore index eb03de0d1..b4a242c24 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ doc/api/ # dotenv environment variables file .env* .env.example +CLAUDE.md # Avoid committing generated Javascript files: *.dart.js diff --git a/android/app/proguard-logging.pro b/android/app/proguard-logging.pro new file mode 100644 index 000000000..dd5d231d4 --- /dev/null +++ b/android/app/proguard-logging.pro @@ -0,0 +1,15 @@ +# 移除系统组件的日志调用 +-assumenosideeffects class android.util.Log { + public static int v(...); + public static int d(...); + public static int i(...); +} + +# 针对特定类的日志移除 +-assumenosideeffects class android.media.AudioTrack { + private static void log*(...); +} + +-assumenosideeffects class android.app.AppOpsManager { + private static void log*(...); +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deep_voice/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deep_voice/MainActivity.kt deleted file mode 100644 index ac31828f0..000000000 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deep_voice/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.yunqiinnovation.deep_voice - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 68e3f58b8..c18a3d345 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt @@ -39,6 +39,7 @@ class MainActivity: FlutterActivity() { * 抑制系统级组件的日志输出 */ private fun suppressSystemLogs() { + // 更全面的系统标签列表 val systemTags = arrayOf( "MediaCodec", "CCodec", @@ -49,11 +50,66 @@ class MainActivity: FlutterActivity() { "AudioTrack", "AudioManager", "AudioFlinger", - "AudioService" + "AudioService", + "AudioEffect", + "AppOpsManager", + "AudioRecord", + "OpenGLRenderer", + "Choreographer", + "InputMethodManager", + "ViewRootImpl", + "Surface", + "BufferQueueProducer", + "ActivityManager", + "WindowManager", + "SensorManager" ) + // 尝试多种方式设置日志级别 systemTags.forEach { tag -> - System.setProperty("log.tag.$tag", "WARN") + try { + // 方式1:标准属性设置 + System.setProperty("log.tag.$tag", "ERROR") + + // 方式2:尝试使用反射设置(针对某些特殊标签) + setLogLevelViaReflection(tag, Log.ERROR) + } catch (e: Exception) { + // 忽略设置失败的情况 + } + } + + // 方式3:尝试设置全局日志级别 + try { + // 设置更高的阈值,减少verbose和debug日志 + System.setProperty("log.tag", "WARN") + } catch (e: Exception) { + // 忽略 + } + } + + /** + * 通过反射尝试设置日志级别 + */ + private fun setLogLevelViaReflection(tag: String, level: Int) { + try { + val logClass = Class.forName("android.util.Log") + val isLoggableMethod = logClass.getDeclaredMethod( + "isLoggable", + String::class.java, + Int::class.javaPrimitiveType + ) + isLoggableMethod.isAccessible = true + + // 某些系统可能支持直接设置 + val setLogLevelMethod = logClass.getDeclaredMethod( + "setLogLevel", + String::class.java, + Int::class.javaPrimitiveType + ) + setLogLevelMethod.isAccessible = true + setLogLevelMethod.invoke(null, tag, level) + } catch (e: Exception) { + // 反射方法可能不存在,忽略 } } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index acc6518c9..4199b3b58 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -42,6 +42,7 @@ include(":speech") include(":chat_api") include(":bytedance_speech") +include(":jl_opus") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") @@ -52,4 +53,5 @@ project(":classic_bluetooth").projectDir = file("../local_plugins/classic_blueto project(":agent_service").projectDir = file("../local_plugins/agent_service/android") project(":speech").projectDir = file("../local_plugins/speech/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android") -project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") \ No newline at end of file +project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") +project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android") diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 24b9a0824..4a77fbefc 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ffi'; import 'dart:io'; import 'package:amap_service/amap_service.dart'; import 'package:deep_voice/core/utils/upload_oss.dart'; @@ -35,7 +36,7 @@ class AgentController extends GetxController { final _musiceManager = Get.find(); final _navigationManager = Get.find(); var isPlaying = false.obs; - var currentUrl = "".obs; + var currentId = 0.obs; // 播放进度相关状态 var currentPosition = 0.obs; // 当前播放位置(毫秒) @@ -310,7 +311,7 @@ class AgentController extends GetxController { case AgentServiceEventType.ttsCanceled: isSpeaking.value = false; - Logger.i(TAG, 'TTS停止播放'); + Logger.i(TAG, 'TTS停止播放, $event.type'); break; case AgentServiceEventType.imageProcessing: @@ -419,6 +420,16 @@ class AgentController extends GetxController { Map metaResult = {}; metaResult = jsonDecode(metaStr) as Map; if (metaResult.isNotEmpty) { + var notreply = false; + if (metaResult.containsKey("notreply")) { + // 如果有导航卡片,直接添加 + notreply = metaResult["notreply"] as bool; + } + if (notreply) { + Logger.i(TAG, 'Flutter 结束调用 不回复'); + //不回复直接返回 + break; + } // 判断是否为新的回复或响应ID是否改变 if (_isNewAssistantResponse || _currentResponseId != responseId) { // 添加新消息 @@ -812,7 +823,7 @@ class AgentController extends GetxController { var status = await _musiceManager.getStatus(); // debugPrint('获取当前音乐播放状态 $status'); isPlaying.value = status['isPlaying']; - currentUrl.value = status['url']; + currentId.value = status['id'] ?? 0; } // 播放音乐 @@ -844,7 +855,7 @@ class AgentController extends GetxController { _musiceManager.play(song); isPlaying.value = true; - currentUrl.value = song['url'] ?? ''; + currentId.value = song['id'] ?? 0; _startProgressTimer(); } diff --git a/lib/modules/agent/views/music_card.dart b/lib/modules/agent/views/music_card.dart index 7162472b6..ecdc7bbcd 100644 --- a/lib/modules/agent/views/music_card.dart +++ b/lib/modules/agent/views/music_card.dart @@ -82,7 +82,7 @@ Widget buildMusicItem(BuildContext context, int id, String name, String url, ), Obx(() { final isThisPlaying = - controller.isPlaying.value && controller.currentUrl.value == url; + controller.isPlaying.value && controller.currentId.value == id; // 只有当前播放的歌曲才显示进度 final currentProgress = @@ -97,13 +97,13 @@ Widget buildMusicItem(BuildContext context, int id, String name, String url, strokeWidth: 2.0, onPlayPause: () { if (controller.isPlaying.value) { - if (controller.currentUrl.value == url) { + if (controller.currentId.value == id) { controller.musicPause(); } else { controller.musicPlay(song); } } else { - if (controller.currentUrl.value == url) { + if (controller.currentId.value == id) { controller.musicResume(); } else { controller.musicPlay(song); diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index f077169cc..13e88d22e 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -32,6 +32,7 @@ class MeetingController extends GetxController { List selected = []; final appDocDir = getApplicationDocumentsDirectory(); + //final appopus = getExternalStorageDirectories(); // final directory = Directory( // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); List files = []; @@ -129,8 +130,9 @@ class MeetingController extends GetxController { } void loadTranslatopus() async { - final appDir = await appDocDir; // 先 await 获取目录 - final dir = Directory("${appDir.path}/OpusAudio"); + // final appDir = await appopus; // 先 await 获取目录 + final dir = + Directory("/data/user/0/com.yunqiinnovation.deepsound/files/OpusAudio"); print("appDocDir=${dir?.path}"); diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index 83414157b..a20cdd12a 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -439,7 +439,7 @@ class MeetingView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), child: Column( children: [ - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Image.asset('assets/images/explore.png', @@ -452,7 +452,7 @@ class MeetingView extends GetView { }, ), SizedBox(height: 8.w), - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Image.asset('assets/images/explore.png', @@ -465,7 +465,7 @@ class MeetingView extends GetView { }, ), SizedBox(height: 8.w), - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Row( @@ -556,65 +556,64 @@ class MeetingView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), child: Column( children: [ + // 现场录音 - 始终可用 _importAudioItem( context, isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '现场录音', + enabled: true, // 始终可用 onTap: () async { Navigator.of(context).pop(); await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 0, }); controller.addMeetingAudio(); - // TODO: 实现从文件导入逻辑 }, ), SizedBox(height: 8.w), + // 音、视频录音 - 根据控制器状态禁用 _importAudioItem( context, isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '音、视频录音', - onTap: () async { - Navigator.of(context).pop(); - await Get.toNamed(Routes.meetingRecord, arguments: { - 'audioTypes': 1, - }); - controller.addMeetingAudio(); - // TODO: 实现从相册导入逻辑 - }, + enabled: false, // 依赖控制器状态 + onTap: false + ? () async { + // 条件启用 + Navigator.of(context).pop(); + await Get.toNamed(Routes.meetingRecord, + arguments: { + 'audioTypes': 1, + }); + controller.addMeetingAudio(); + } + : null, // 禁用时置空onTap ), SizedBox(height: 8.w), + // 通话录音 - 根据控制器状态禁用 _importAudioItem( context, isDarkMode, icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - ], - ), + // ... 图标组合保持不变 + ), title: '通话录音', - onTap: () async { - Navigator.of(context).pop(); - - await Get.toNamed(Routes.meetingRecord, arguments: { - 'audioTypes': 2, - }); - controller.addMeetingAudio(); - - // TODO: 实现从其他APP导入逻辑 - }, + enabled: false, // 依赖控制器状态 + onTap: false + ? () async { + // 条件启用 + Navigator.of(context).pop(); + await Get.toNamed(Routes.meetingRecord, + arguments: { + 'audioTypes': 2, + }); + controller.addMeetingAudio(); + } + : null, // 禁用时置空onTap ), ], ), @@ -643,8 +642,60 @@ class MeetingView extends GetView { ); } -// 选项框 +// 选项框 (增加enabled参数) Widget _importAudioItem( + BuildContext context, + bool isDarkMode, { + required Widget icon, + required String title, + required bool enabled, // 新增启用状态参数 + required VoidCallback? onTap, // 改为可空回调 + }) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12.w), + onTap: onTap, // 禁用时onTap为null + child: Container( + height: 52.w, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(enabled ? 0.03 : 0.01) // 调整禁用透明度 + : Colors.grey[50]!.withOpacity(enabled ? 1 : 0.5), + borderRadius: BorderRadius.circular(12.w), + ), + child: Row( + children: [ + // 图标添加透明度效果 + Opacity( + opacity: enabled ? 1.0 : 0.5, + child: icon, + ), + SizedBox(width: 14.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: isDarkMode + ? Colors.white.withOpacity(enabled ? 1 : 0.5) + : Colors.black87.withOpacity(enabled ? 1 : 0.5), + ), + ), + ), + Icon(Icons.arrow_forward_ios, + size: 18.w, + color: Colors.grey.withOpacity(enabled ? 1 : 0.5)), + ], + ), + ), + ), + ); + } + +// 选项框 + Widget _importAudioItem1( BuildContext context, bool isDarkMode, { required Widget icon, @@ -684,7 +735,6 @@ class MeetingView extends GetView { ), ); } - // 新增右侧弹窗 void _showRightDrawerDialog(BuildContext context, bool isDarkMode) { diff --git a/lib/modules/meeting/views/share_bottom_sheet.dart b/lib/modules/meeting/views/share_bottom_sheet.dart index 7ad740d1f..229d83501 100644 --- a/lib/modules/meeting/views/share_bottom_sheet.dart +++ b/lib/modules/meeting/views/share_bottom_sheet.dart @@ -1,7 +1,12 @@ +import 'dart:io'; + +import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import '../controllers/meeting_details_controller.dart'; @@ -69,18 +74,44 @@ class _ShareBottomSheetState extends State { _shareItem(Icons.link, '分享链接', isBorder: false), ]), _box([ - _shareItem(Icons.copy, '复制转写'), - _shareItem(Icons.copy, '复制总结', isBorder: false), + _shareItem(Icons.copy, '复制转写', onTap: () { + Get.back(); + _copy(_getAllText()); + }), + _shareItem(Icons.copy, '复制总结', isBorder: false, onTap: () { + Get.back(); + _copy(_controller.meetingDetails.value.summary); + }), ]), _box([ + _shareItem(Icons.multitrack_audio, '导出音频', onTap: () { + Get.back(); + _share(_controller.meetingData.value.filepath, '分享会议音频'); + }), + _shareItem(Icons.message, '导出转写', onTap: () async { + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/${_controller.meetingData.value.title} speaker.txt'); + String allText = _getAllText(); + await file.writeAsString(allText); + Get.back(); + _share(file.path, '分享会议转写'); + }), + _shareItem(Icons.article, '导出总结', onTap: () async { + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/${_controller.meetingData.value.title} summary.txt'); + await file.writeAsString( + _controller.meetingDetails.value.summary); + Get.back(); + _share(file.path, '分享会议总结'); + }), _shareItem( - Icons.multitrack_audio, - '导出音频', - onTap: shareAllTranslatAudio, + Icons.lan, + '导出思维导图', + isBorder: false, + onTap: () {}, ), - _shareItem(Icons.message, '导出转写'), - _shareItem(Icons.article, '导出总结'), - _shareItem(Icons.lan, '导出思维导图', isBorder: false), ]), ], ), @@ -144,17 +175,42 @@ class _ShareBottomSheetState extends State { ); } - void shareAllTranslatAudio() async { - if (_controller.meetingData.value.filepath.isEmpty) { - EasyLoading.showToast('没有可分享的音频文件'); - return; - } + void _share(String filepath, String title) async { final result = await SharePlus.instance.share(ShareParams( - text: '分享会议音频', - files: [XFile(_controller.meetingData.value.filepath)], + text: title, + files: [XFile(filepath)], )); if (result.status == ShareResultStatus.success) { EasyLoading.showToast('分享完成!'); } } + + void _copy(String text) { + Clipboard.setData(ClipboardData( + text: text, + )).then( + (value) => Get.snackbar( + 'success'.tr, // 成功 + '复制成功', + backgroundColor: Colors.blue.withOpacity(0.8), + colorText: Colors.white, + ), + ); + } + + String _getAllText() { + String text = ''; + for (var element in _controller.textList) { + if (element['speaker'].isNotEmpty) { + text += '${element['speaker']} '; + } + text += '${DateUtil.formatDateMs( + element['starttime'], + format: 'HH:mm:ss', + isUtc: true, + )}\n'; + text += '${element['content']}\n'; + } + return text; + } } diff --git a/lib/modules/music/views/music_playlist_view.dart b/lib/modules/music/views/music_playlist_view.dart index c5ca02817..65fecb2eb 100644 --- a/lib/modules/music/views/music_playlist_view.dart +++ b/lib/modules/music/views/music_playlist_view.dart @@ -162,10 +162,11 @@ class MusicPlaylistView extends StatelessWidget { // 播放按钮 Obx(() { final isThisPlaying = - controller.isPlaying.value && controller.currentUrl.value == url; - + controller.isPlaying.value && controller.currentId.value == url; + // 只有当前播放的歌曲才显示进度 - final currentProgress = isThisPlaying ? controller.progress.value : 0.0; + final currentProgress = + isThisPlaying ? controller.progress.value : 0.0; return CircularProgressButton( isPlaying: isThisPlaying, @@ -176,24 +177,27 @@ class MusicPlaylistView extends StatelessWidget { strokeWidth: 2.5, onPlayPause: () { if (controller.isPlaying.value) { - if (controller.currentUrl.value == url) { + if (controller.currentId.value == id) { controller.musicPause(); } else { controller.musicPlay(song); } } else { - if (controller.currentUrl.value == url) { + if (controller.currentId.value == id) { controller.musicResume(); } else { controller.musicPlay(song); } } }, - onSeek: isThisPlaying ? (seekProgress) { - // 只有当前播放的歌曲才允许跳转 - final targetPosition = (controller.duration.value * seekProgress).round(); - controller.seekToPosition(targetPosition); - } : null, + onSeek: isThisPlaying + ? (seekProgress) { + // 只有当前播放的歌曲才允许跳转 + final targetPosition = + (controller.duration.value * seekProgress).round(); + controller.seekToPosition(targetPosition); + } + : null, ); }), ], diff --git a/lib/modules/opus_test/bindings/opus_test_binding.dart b/lib/modules/opus_test/bindings/opus_test_binding.dart new file mode 100644 index 000000000..c1e6a1aa0 --- /dev/null +++ b/lib/modules/opus_test/bindings/opus_test_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/opus_test_controller.dart'; + +class OpusTestBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => OpusTestController()); + } +} \ No newline at end of file diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart new file mode 100644 index 000000000..6297dee3a --- /dev/null +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -0,0 +1,462 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:jl_opus/jl_opus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class OpusTestController extends GetxController { + // 选中的文件 + final selectedFiles = [].obs; + + // 扫描到的外部存储文件 + final externalFiles = [].obs; + // 是否携带协议头 + final hasHeader = false.obs; + + // 数据包长度 + final packetLength = '80'.obs; + + // 采样率选项 + final sampleRateOptions = [8000, 16000, 24000, 32000, 44100, 48000]; + final selectedSampleRate = 16000.obs; + + // 通道数量 + final isMono = true.obs; + + // 播放状态 + final isPlaying = false.obs; + final isDecoding = false.obs; + final statusMessage = ''.obs; + + // 播放器 + final player = AudioPlayer(); + + // 临时文件路径 + String? tempPcmPath; + + // 杰理OPUS解码器 + late JlOpus jlOpus; + StreamSubscription? _eventSubscription; + + @override + void onInit() { + super.onInit(); + requestPermissions(); + _initOpusDecoder(); + } + + @override + void onClose() { + player.dispose(); + _eventSubscription?.cancel(); + jlOpus.dispose(); + super.onClose(); + } + + // 初始化OPUS解码器 + Future _initOpusDecoder() async { + jlOpus = JlOpus(); + + // 监听解码器事件 + _eventSubscription = jlOpus.eventStream.listen((event) { + switch (event.event) { + case 'onStart': + statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; + break; + case 'onComplete': + if (event.type == 'file') { + statusMessage.value = '解码完成: ${event.filePath}'; + } else { + statusMessage.value = '流解码完成'; + } + break; + case 'onError': + statusMessage.value = + '解码错误: [${event.errorCode}] ${event.errorMessage}'; + isDecoding.value = false; + break; + } + }); + + // 初始化OPUS解码器 + final initialized = await jlOpus.initOpusDecoder(); + if (!initialized) { + statusMessage.value = 'OPUS解码器初始化失败'; + } + } + + // 请求必要权限 + Future requestPermissions() async { + if (Platform.isAndroid) { + await Permission.storage.request(); + } + } + + // 加载外部存储文件 + Future loadExternalFiles() async { + try { + externalFiles.clear(); + statusMessage.value = '正在扫描外部存储...'; + + // 获取外部存储目录 + final directories = await getExternalStorageDirectories(); + if (directories == null || directories.isEmpty) { + statusMessage.value = '未找到外部存储目录'; + return; + } + + // 遍历所有外部存储目录 + for (final directory in directories) { + final dirPath = directory.path; + final dir = Directory(dirPath); + + // 检查目录是否存在 + if (await dir.exists()) { + // 递归列出所有文件 + final files = await dir.list(recursive: true).toList(); + + for (var entity in files) { + if (entity is File) { + final path = entity.path.toLowerCase(); + if (path.endsWith('.opus')) { + final fileSize = await entity.length(); + externalFiles.add(PlatformFile( + name: entity.path.split('/').last, + path: entity.path, + size: fileSize, + )); + } + } + } + } + } + + statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个Opus文件'; + } catch (e) { + statusMessage.value = '扫描失败: $e'; + } + } + + // 选择文件 + Future pickFiles() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['opus'], + allowMultiple: false, + ); + + if (result != null && result.files.isNotEmpty) { + selectedFiles.clear(); + selectedFiles.add(result.files.first); + statusMessage.value = '已选择文件:${result.files.first.name}'; + } + } catch (e) { + statusMessage.value = '选择文件失败: $e'; + } + } + + // 从外部文件列表中选择文件 + void selectExternalFile(PlatformFile file) { + selectedFiles.clear(); + selectedFiles.add(file); + statusMessage.value = '已选择文件: ${file.name}'; + } + + // 切换通道模式 + void toggleChannel(bool isSingleChannel) { + isMono.value = isSingleChannel; + } + + // 设置采样率 + void setSampleRate(int rate) { + selectedSampleRate.value = rate; + } + + // 设置数据包长度 + void setPacketLength(String length) { + packetLength.value = length; + } + + // 切换是否携带协议头 + void toggleHeader(bool value) { + hasHeader.value = value; + } + + // 播放音频 + Future playAudio() async { + if (isPlaying.value) { + await player.stop(); + isPlaying.value = false; + statusMessage.value = '已停止播放'; + return; + } + + if (selectedFiles.isEmpty) { + statusMessage.value = '请先选择Opus文件'; + return; + } + + try { + isDecoding.value = true; + statusMessage.value = '正在解码...'; + + // 使用杰理OPUS库进行解码 + final inPath = selectedFiles.first.path!; + + // 创建WAV输出文件路径 + final tempDir = await getTemporaryDirectory(); + final wavFileName = + 'decoded_opus_${DateTime.now().millisecondsSinceEpoch}.wav'; + final outPath = '${tempDir.path}/$wavFileName'; + + // 创建解码参数 + final option = OpusOption( + hasHeader: hasHeader.value, + channel: isMono.value ? 1 : 2, + sampleRate: selectedSampleRate.value, + packetSize: int.tryParse(packetLength.value) ?? 40, + ); + + // 解码文件 + final pcmPath = await jlOpus.decodeOpusFile(inPath, outPath, option); + if (pcmPath == null) { + statusMessage.value = '解码失败'; + isDecoding.value = false; + return; + } + + tempPcmPath = pcmPath; + + // 将PCM文件转换为WAV文件 + final wavFile = await _convertPcmToWav( + pcmPath, + '${tempDir.path}/$wavFileName', + selectedSampleRate.value, + isMono.value ? 1 : 2); + + statusMessage.value = '解码完成,准备播放'; + + // 播放WAV文件 + await player.setFilePath(wavFile.path); + await player.play(); + isPlaying.value = true; + statusMessage.value = '正在播放解码后的音频'; + + // 监听播放完成 + player.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + isPlaying.value = false; + statusMessage.value = '播放完成'; + } + }); + } catch (e) { + statusMessage.value = '解码或播放失败: $e'; + } finally { + isDecoding.value = false; + } + } + + // 将PCM转换为WAV格式 + Future _convertPcmToWav( + String pcmPath, String wavPath, int sampleRate, int channels) async { + final pcmFile = File(pcmPath); + final pcmData = await pcmFile.readAsBytes(); + + // 创建WAV文件 (44字节头部 + PCM数据) + final wavSize = 44 + pcmData.length; + final wavData = ByteData(wavSize); + + // 写入WAV头部 + _writeWavHeader(wavData, pcmData.length, sampleRate, channels); + + // 写入PCM数据 + for (int i = 0; i < pcmData.length; i++) { + wavData.setUint8(44 + i, pcmData[i]); + } + + // 保存为WAV文件 + final wavFile = File(wavPath); + await wavFile.writeAsBytes(wavData.buffer.asUint8List()); + + return wavFile; + } + + // 写入WAV头部到ByteData + void _writeWavHeader( + ByteData data, int pcmLength, int sampleRate, int channels) { + final bytesPerSample = 2; // 16位PCM + final byteRate = sampleRate * channels * bytesPerSample; + final blockAlign = channels * bytesPerSample; + + // "RIFF"标识 + data.setUint8(0, 0x52); // 'R' + data.setUint8(1, 0x49); // 'I' + data.setUint8(2, 0x46); // 'F' + data.setUint8(3, 0x46); // 'F' + + // 文件大小 (减去8个字节的RIFF头部和大小字段) + data.setUint32(4, 36 + pcmLength, Endian.little); + + // "WAVE"标识 + data.setUint8(8, 0x57); // 'W' + data.setUint8(9, 0x41); // 'A' + data.setUint8(10, 0x56); // 'V' + data.setUint8(11, 0x45); // 'E' + + // "fmt "子块 + data.setUint8(12, 0x66); // 'f' + data.setUint8(13, 0x6D); // 'm' + data.setUint8(14, 0x74); // 't' + data.setUint8(15, 0x20); // ' ' + + // 子块大小 (16 for PCM) + data.setUint32(16, 16, Endian.little); + + // 音频格式 (1 表示PCM) + data.setUint16(20, 1, Endian.little); + + // 通道数量 + data.setUint16(22, channels, Endian.little); + + // 采样率 + data.setUint32(24, sampleRate, Endian.little); + + // 字节率 = 采样率 * 通道数 * 每样本字节数 + data.setUint32(28, byteRate, Endian.little); + + // 块对齐 = 通道数 * 每样本字节数 + data.setUint16(32, blockAlign, Endian.little); + + // 每个样本位数 + data.setUint16(34, bytesPerSample * 8, Endian.little); + + // "data"子块 + data.setUint8(36, 0x64); // 'd' + data.setUint8(37, 0x61); // 'a' + data.setUint8(38, 0x74); // 't' + data.setUint8(39, 0x61); // 'a' + + // 数据大小 + data.setUint32(40, pcmLength, Endian.little); + } +} + +// PCM音频源 +class PCMAudioSource extends StreamAudioSource { + final Uint8List pcmData; + final int sampleRate; + final int channels; + + PCMAudioSource({ + required this.pcmData, + this.sampleRate = 16000, + this.channels = 1, + }); + + @override + Future request([int? start, int? end]) async { + start = start ?? 0; + end = end ?? pcmData.length; + + // 转换PCM到WAV格式 + final wavHeader = _createWavHeader(pcmData.length, sampleRate, channels); + final wavData = Uint8List(wavHeader.length + pcmData.length); + wavData.setRange(0, wavHeader.length, wavHeader); + wavData.setRange( + wavHeader.length, wavHeader.length + pcmData.length, pcmData); + + // 调整请求范围来适应WAV头部 + int adjustedStart = start; + int adjustedEnd = end; + + if (start < wavHeader.length) { + adjustedStart = start; + } else { + adjustedStart = start + wavHeader.length; + } + + if (end <= wavHeader.length) { + adjustedEnd = end; + } else { + adjustedEnd = end + wavHeader.length; + adjustedEnd = adjustedEnd > wavData.length ? wavData.length : adjustedEnd; + } + + return StreamAudioResponse( + sourceLength: wavData.length, + contentLength: adjustedEnd - adjustedStart, + offset: adjustedStart, + stream: Stream.value(wavData.sublist(adjustedStart, adjustedEnd)), + contentType: 'audio/wav', + ); + } + + // 创建WAV头部 + Uint8List _createWavHeader(int dataLength, int sampleRate, int channels) { + final bytesPerSample = 2; // 16位PCM + final byteRate = sampleRate * channels * bytesPerSample; + final blockAlign = channels * bytesPerSample; + + final header = ByteData(44); // WAV头部固定44字节 + + // "RIFF"标识 + header.setUint8(0, 0x52); // 'R' + header.setUint8(1, 0x49); // 'I' + header.setUint8(2, 0x46); // 'F' + header.setUint8(3, 0x46); // 'F' + + // 文件大小 (减去8个字节的RIFF头部和大小字段) + header.setUint32(4, 36 + dataLength, Endian.little); + + // "WAVE"标识 + header.setUint8(8, 0x57); // 'W' + header.setUint8(9, 0x41); // 'A' + header.setUint8(10, 0x56); // 'V' + header.setUint8(11, 0x45); // 'E' + + // "fmt "子块 + header.setUint8(12, 0x66); // 'f' + header.setUint8(13, 0x6D); // 'm' + header.setUint8(14, 0x74); // 't' + header.setUint8(15, 0x20); // ' ' + + // 子块大小 (16 for PCM) + header.setUint32(16, 16, Endian.little); + + // 音频格式 (1 表示PCM) + header.setUint16(20, 1, Endian.little); + + // 通道数量 + header.setUint16(22, channels, Endian.little); + + // 采样率 + header.setUint32(24, sampleRate, Endian.little); + + // 字节率 = 采样率 * 通道数 * 每样本字节数 + header.setUint32(28, byteRate, Endian.little); + + // 块对齐 = 通道数 * 每样本字节数 + header.setUint16(32, blockAlign, Endian.little); + + // 每个样本位数 + header.setUint16(34, bytesPerSample * 8, Endian.little); + + // "data"子块 + header.setUint8(36, 0x64); // 'd' + header.setUint8(37, 0x61); // 'a' + header.setUint8(38, 0x74); // 't' + header.setUint8(39, 0x61); // 'a' + + // 数据大小 + header.setUint32(40, dataLength, Endian.little); + + return header.buffer.asUint8List(); + } +} diff --git a/lib/modules/opus_test/views/opus_test_view.dart b/lib/modules/opus_test/views/opus_test_view.dart new file mode 100644 index 000000000..adecc15b6 --- /dev/null +++ b/lib/modules/opus_test/views/opus_test_view.dart @@ -0,0 +1,518 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import '../controllers/opus_test_controller.dart'; + +class OpusTestView extends GetView { + const OpusTestView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // 获取主题模式 + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], + appBar: AppBar( + backgroundColor: isDarkMode ? Colors.black : Colors.white, + elevation: 0.5, + title: Text( + 'OPUS解码', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + centerTitle: true, + bottom: TabBar( + indicatorColor: Colors.blue, + indicatorWeight: 3, + labelColor: isDarkMode ? Colors.white : Colors.black, + unselectedLabelColor: isDarkMode ? Colors.grey : Colors.grey, + tabs: const [ + Tab(text: "解码"), + Tab(text: "输出"), + ], + ), + ), + body: TabBarView( + children: [ + _buildDecodeTab(isDarkMode), + _buildEncodeTab(isDarkMode), + ], + ), + ), + ); + } + + // 解码标签页 + Widget _buildDecodeTab(bool isDarkMode) { + return ListView( + padding: EdgeInsets.all(16.w), + children: [ + // 文件选择部分 + _buildSection( + title: '选择文件', + isDarkMode: isDarkMode, + child: Column( + children: [ + // 当前选中的文件 + Obx(() => controller.selectedFiles.isEmpty + ? const SizedBox.shrink() + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.selectedFiles.length, + itemBuilder: (context, index) { + final file = controller.selectedFiles[index]; + return ListTile( + title: Text( + file.name, + style: TextStyle( + color: + isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + subtitle: Text( + '${(file.size / 1024).toStringAsFixed(2)} Kb', + style: TextStyle( + color: + isDarkMode ? Colors.white38 : Colors.black54, + fontSize: 12.sp, + ), + ), + trailing: IconButton( + icon: Icon(Icons.clear, color: Colors.red), + onPressed: () { + controller.selectedFiles.removeAt(index); + }, + ), + ); + }, + )), + + // 外部文件列表标题和刷新按钮 + Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '外部存储文件', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + IconButton( + icon: Icon( + Icons.refresh, + color: isDarkMode ? Colors.blue[300] : Colors.blue, + ), + onPressed: controller.loadExternalFiles, + ), + ], + ), + ), + + // 外部文件列表 + Obx(() => controller.externalFiles.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.all(16.h), + child: Text( + '未找到Opus文件', + style: TextStyle( + color: isDarkMode ? Colors.white54 : Colors.black54, + ), + ), + ), + ) + : Container( + height: 200.h, + decoration: BoxDecoration( + border: Border.all( + color: isDarkMode + ? Colors.grey[700]! + : Colors.grey[300]!, + ), + borderRadius: BorderRadius.circular(8.r), + ), + child: ListView.builder( + itemCount: controller.externalFiles.length, + itemBuilder: (context, index) { + final file = controller.externalFiles[index]; + return ListTile( + title: Text( + file.name, + style: TextStyle( + color: isDarkMode + ? Colors.white70 + : Colors.black87, + fontSize: 14.sp, + ), + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${(file.size / 1024).toStringAsFixed(2)} Kb', + style: TextStyle( + color: isDarkMode + ? Colors.white38 + : Colors.black54, + fontSize: 12.sp, + ), + ), + onTap: () { + controller.selectExternalFile(file); + }, + trailing: Icon( + Icons.arrow_forward, + color: + isDarkMode ? Colors.white54 : Colors.black54, + ), + ); + }, + ), + )), + ], + ), + ), + SizedBox(height: 16.h), + + // 解码参数部分 + _buildSection( + title: '解码参数', + isDarkMode: isDarkMode, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Obx(() => Checkbox( + value: controller.hasHeader.value, + onChanged: (value) => controller.toggleHeader(value!), + activeColor: Colors.blue, + )), + Text( + '是否携带协议头', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + ], + ), + // 数据包长度 + Row( + children: [ + Text( + '数据包长度', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Obx(() => TextField( + controller: TextEditingController( + text: controller.packetLength.value), + onChanged: (value) => + controller.setPacketLength(value), + keyboardType: TextInputType.number, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + ), + decoration: InputDecoration( + isDense: true, + suffix: Text( + 'Byte', + style: TextStyle( + color: isDarkMode + ? Colors.white60 + : Colors.black54, + fontSize: 12.sp, + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: isDarkMode + ? Colors.white30 + : Colors.black26, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Colors.blue, + ), + ), + ), + )), + ), + ], + ), + SizedBox(height: 20.h), + + // 采样率 + Row( + children: [ + Text( + '采样率', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + SizedBox(width: 16.w), + Expanded( + child: Obx(() => DropdownButton( + value: controller.selectedSampleRate.value, + isExpanded: true, + dropdownColor: + isDarkMode ? Colors.grey[800] : Colors.white, + items: controller.sampleRateOptions.map((rate) { + return DropdownMenuItem( + value: rate, + child: Text( + rate.toString(), + style: TextStyle( + color: isDarkMode + ? Colors.white + : Colors.black87, + ), + ), + ); + }).toList(), + onChanged: (value) => + controller.setSampleRate(value!), + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + ), + )), + ), + ], + ), + SizedBox(height: 20.h), + + // 通道数量 + Text( + '通道数量', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + Row( + children: [ + Expanded( + child: Row( + children: [ + Obx(() => Radio( + value: true, + groupValue: controller.isMono.value, + onChanged: (value) => + controller.toggleChannel(value!), + activeColor: Colors.blue, + )), + Text( + '单通道', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + ], + ), + ), + Expanded( + child: Row( + children: [ + Obx(() => Radio( + value: false, + groupValue: controller.isMono.value, + onChanged: (value) => + controller.toggleChannel(value!), + activeColor: Colors.blue, + )), + Text( + '双通道', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black87, + fontSize: 14.sp, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + + SizedBox(height: 16.h), + + // 状态消息 + Obx(() => controller.statusMessage.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), + margin: EdgeInsets.only(bottom: 16.h), + decoration: BoxDecoration( + color: isDarkMode + ? Colors.blue.withOpacity(0.2) + : Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(4.r), + border: Border.all( + color: isDarkMode + ? Colors.blue.withOpacity(0.3) + : Colors.blue.withOpacity(0.2), + width: 1, + ), + ), + child: Text( + controller.statusMessage.value, + style: TextStyle( + color: isDarkMode ? Colors.blue[300] : Colors.blue[700], + fontSize: 12.sp, + ), + ), + ) + : const SizedBox.shrink()), + + // 播放按钮 + Container( + margin: EdgeInsets.symmetric(vertical: 8.h), + child: ElevatedButton.icon( + icon: Obx(() => Icon( + controller.isPlaying.value ? Icons.stop : Icons.play_arrow, + color: Colors.white, + )), + label: Obx(() => Text( + controller.isPlaying.value ? '停止播放' : '播放音频', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + )), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + onPressed: controller.isDecoding.value + ? null + : () { + controller.playAudio(); + }, + ), + ), + + // 解码按钮 + Container( + margin: EdgeInsets.symmetric(vertical: 8.h), + child: Obx(() => ElevatedButton( + onPressed: controller.selectedFiles.isEmpty || + controller.isDecoding.value + ? null + : () { + controller.pickFiles(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + padding: EdgeInsets.symmetric(vertical: 16.h), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + ), + child: Text( + '开始解码', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + ), + ), + )), + ), + ], + ); + } + + // 编码标签页(暂未实现) + Widget _buildEncodeTab(bool isDarkMode) { + return Center( + child: Text( + '编码功能尚未实现', + style: TextStyle( + color: isDarkMode ? Colors.white54 : Colors.black54, + fontSize: 16.sp, + ), + ), + ); + } + + // 构建带标题的区段 + Widget _buildSection({ + required String title, + required Widget child, + required bool isDarkMode, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(width: 8.w), + Expanded( + child: Container( + height: 1, + color: isDarkMode ? Colors.white24 : Colors.black12, + ), + ), + if (title == '选择文件') + IconButton( + icon: Icon( + Icons.add_circle_outline, + color: isDarkMode ? Colors.blue[300] : Colors.blue, + ), + onPressed: () { + controller.pickFiles(); + }, + ), + ], + ), + Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, + borderRadius: BorderRadius.circular(8.r), + boxShadow: [ + BoxShadow( + color: isDarkMode + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.03), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: child, + ), + ], + ); + } +} diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 47e2a00b6..f1aa28475 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -11,27 +11,27 @@ import '../../../core/utils/permission_util.dart'; /// 配对状态枚举 - 简化版本 enum PairingState { - idle, // 空闲/初始状态 - checking, // 检查中(蓝牙、权限等) - waitingClassic, // 等待经典蓝牙连接 - verifying, // 验证设备 - associating, // 正在进行BLE设备关联 - waitingBle, // 等待BLE连接 - completed, // 配对完成 - error, // 错误状态 + idle, // 空闲/初始状态 + checking, // 检查中(蓝牙、权限等) + waitingClassic, // 等待经典蓝牙连接 + verifying, // 验证设备 + associating, // 正在进行BLE设备关联 + waitingBle, // 等待BLE连接 + completed, // 配对完成 + error, // 错误状态 } /// 错误类型枚举 enum ErrorType { none, - bluetoothOff, // 蓝牙关闭 - noPermission, // 无权限 - noClassicDevice, // 无经典蓝牙设备 - deviceNotMatch, // 设备不匹配 - deviceBound, // 设备已被绑定 + bluetoothOff, // 蓝牙关闭 + noPermission, // 无权限 + noClassicDevice, // 无经典蓝牙设备 + deviceNotMatch, // 设备不匹配 + deviceBound, // 设备已被绑定 associationFailed, // 关联失败 - bleTimeout, // BLE连接超时 - networkError, // 网络错误 + bleTimeout, // BLE连接超时 + networkError, // 网络错误 } /// 配对控制器 - 基于简化状态机的实现 @@ -43,7 +43,7 @@ class PairingController extends GetxController { final _currentState = PairingState.idle.obs; final _errorType = ErrorType.none.obs; final _errorMessage = ''.obs; - + PairingState get currentState => _currentState.value; ErrorType get errorType => _errorType.value; String get errorMessage => _errorMessage.value; @@ -68,7 +68,7 @@ class PairingController extends GetxController { // ========== 当前设备信息 ========== BluetoothDevice? _currentClassicDevice; String? _verifiedDeviceAddress; - + // Public getter for view access BluetoothDevice? get currentDevice => _currentClassicDevice; @@ -76,14 +76,14 @@ class PairingController extends GetxController { void onInit() { super.onInit(); Logger.i(_tag, '配对控制器初始化'); - + // 确保服务已初始化 _bluetoothManager = Get.find(); _bleManager = Get.find(); - + // 集中设置所有监听器 _setupAllListeners(); - + // 开始初始状态检查 // 延迟稍长时间,确保BluetoothManager有足够时间获取已连接设备 Future.delayed(const Duration(milliseconds: 300), () { @@ -99,10 +99,10 @@ class PairingController extends GetxController { worker.dispose(); } _workers.clear(); - + // 清理定时器 _bleTimeoutTimer?.cancel(); - + super.onClose(); } @@ -112,42 +112,37 @@ class PairingController extends GetxController { void _setupAllListeners() { // 1. 监听蓝牙开关状态 _setupBluetoothStateListener(); - + // 2. 监听经典蓝牙连接状态 _setupClassicConnectionListener(); - + // 3. 监听BLE连接状态 _setupBleConnectionListener(); } /// 监听蓝牙开关状态 void _setupBluetoothStateListener() { - _workers.add( - ever(_bluetoothManager.bluetoothStateRx, (String state) { - Logger.d(_tag, '蓝牙状态变化: $state'); - _handleBluetoothStateChange(state); - }) - ); + _workers.add(ever(_bluetoothManager.bluetoothStateRx, (String state) { + Logger.d(_tag, '蓝牙状态变化: $state'); + _handleBluetoothStateChange(state); + })); } /// 监听经典蓝牙连接状态 void _setupClassicConnectionListener() { - _workers.add( - ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { - Logger.d(_tag, '经典蓝牙设备变化: ${device?.name}'); - _handleClassicDeviceChange(device); - }) - ); + _workers + .add(ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { + Logger.d(_tag, '经典蓝牙设备变化: ${device?.name}'); + _handleClassicDeviceChange(device); + })); } /// 监听BLE连接状态 void _setupBleConnectionListener() { - _workers.add( - ever(_bleManager.connectionStateRx, (int state) { - Logger.d(_tag, 'BLE连接状态变化: $state'); - _handleBleStateChange(state); - }) - ); + _workers.add(ever(_bleManager.connectionStateRx, (int state) { + Logger.d(_tag, 'BLE连接状态变化: $state'); + _handleBleStateChange(state); + })); } // ==================== 状态处理 ==================== @@ -171,7 +166,7 @@ class PairingController extends GetxController { /// 处理经典蓝牙设备变化 void _handleClassicDeviceChange(BluetoothDevice? device) { _currentClassicDevice = device; - + switch (_currentState.value) { case PairingState.waitingClassic: if (device != null) { @@ -181,7 +176,7 @@ class PairingController extends GetxController { _verifyDevice(device); } break; - + case PairingState.error: // 如果在错误状态下检测到设备连接,且错误是无设备连接,则自动恢复 if (device != null && _errorType.value == ErrorType.noClassicDevice) { @@ -191,7 +186,7 @@ class PairingController extends GetxController { _verifyDevice(device); } break; - + case PairingState.verifying: case PairingState.waitingBle: case PairingState.completed: @@ -200,7 +195,7 @@ class PairingController extends GetxController { _setError(ErrorType.noClassicDevice, '设备已断开'); } break; - + default: break; } @@ -209,12 +204,13 @@ class PairingController extends GetxController { /// 处理BLE状态变化 void _handleBleStateChange(int state) { if (_currentState.value == PairingState.waitingBle) { - if (state == 2) { // BLE已连接 + if (state == 2) { + // BLE已连接 _bleTimeoutTimer?.cancel(); _transition(PairingState.completed); } } - + // 如果在完成状态下BLE断开,显示警告但不改变状态 if (_currentState.value == PairingState.completed && state != 2) { Logger.w(_tag, 'BLE连接已断开,但配对流程已完成'); @@ -228,7 +224,7 @@ class PairingController extends GetxController { final oldState = _currentState.value; _currentState.value = newState; Logger.i(_tag, '状态转换: $oldState -> $newState'); - + // 根据新状态执行相应操作 _onStateChanged(newState); } @@ -240,29 +236,29 @@ class PairingController extends GetxController { showConnectButton.value = false; showRetryButton.value = false; showSkipButton.value = false; - + switch (state) { case PairingState.idle: statusText.value = '准备开始配对'; break; - + case PairingState.checking: statusText.value = '正在检查环境...'; isLoading.value = true; break; - + case PairingState.waitingClassic: statusText.value = '请确保耳机已开启并进入配对模式'; showConnectButton.value = true; showSkipButton.value = true; connectButtonText.value = '前往蓝牙设置'; break; - + case PairingState.verifying: statusText.value = '正在验证设备...'; isLoading.value = true; break; - + case PairingState.associating: statusText.value = '正在关联设备...'; isLoading.value = true; @@ -271,7 +267,7 @@ class PairingController extends GetxController { _startDeviceAssociation(); }); break; - + case PairingState.waitingBle: statusText.value = '正在建立安全连接...'; isLoading.value = true; @@ -280,18 +276,18 @@ class PairingController extends GetxController { _startBleConnection(); }); break; - + case PairingState.completed: statusText.value = '配对成功!'; isLoading.value = false; _navigateToHome(); break; - + case PairingState.error: statusText.value = _errorMessage.value; isLoading.value = false; showSkipButton.value = true; - + // 根据错误类型决定显示哪个按钮和按钮文本 switch (_errorType.value) { case ErrorType.bluetoothOff: @@ -350,28 +346,27 @@ class PairingController extends GetxController { _setError(ErrorType.bluetoothOff, '请开启蓝牙'); return; } - + // 2. 检查权限 if (!await _checkPermissions()) { _setError(ErrorType.noPermission, '请授予必要权限'); return; } - + // 3. 先刷新设备连接状态,确保获取最新的连接信息 await _bluetoothManager.refreshDeviceConnection(); - + // 4. 检查经典蓝牙连接 final classicDevice = _bluetoothManager.currentDevice; if (classicDevice == null) { _transition(PairingState.waitingClassic); return; } - + // 5. 如果有设备,进行验证 _currentClassicDevice = classicDevice; _transition(PairingState.verifying); await _verifyDevice(classicDevice); - } catch (e) { Logger.e(_tag, '状态检查失败: $e'); _setError(ErrorType.networkError, '检查失败,请重试'); @@ -386,10 +381,10 @@ class PairingController extends GetxController { final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); final location = await Permission.locationWhenInUse.request(); - - return bluetoothScan.isGranted && - bluetoothConnect.isGranted && - location.isGranted; + + return bluetoothScan.isGranted && + bluetoothConnect.isGranted && + location.isGranted; } return true; } @@ -398,28 +393,31 @@ class PairingController extends GetxController { Future _verifyDevice(BluetoothDevice device) async { try { Logger.i(_tag, '开始验证设备: ${device.name} (${device.address})'); - + // TODO: 实现API验证逻辑 // 暂时模拟验证通过 await Future.delayed(const Duration(seconds: 2)); - + // 检查设备名称是否包含 DeepSound 或 DeapSound final deviceName = device.name ?? ''; - if (deviceName.contains('DeepSound') || deviceName.contains('DeapSound')) { + if (deviceName.contains('DeepSound') || + deviceName.contains('DeapSound')) { Logger.i(_tag, '设备验证通过'); _verifiedDeviceAddress = device.address; - + // 检查是否已有BLE关联 final pairedMacAddress = await _bleManager.getPairedMacAddress(); Logger.i(_tag, '已关联的MAC地址: $pairedMacAddress'); - - if (pairedMacAddress != null && pairedMacAddress.toUpperCase() == device.address.toUpperCase()) { + + if (pairedMacAddress != null && + pairedMacAddress.toUpperCase() == device.address.toUpperCase()) { // 已有关联,检查BLE连接状态 Logger.i(_tag, '设备已关联,检查BLE连接状态'); final currentBleState = await _bleManager.getConnectionState(); Logger.i(_tag, '验证时BLE状态: $currentBleState'); - - if (currentBleState == 2) { // BleConnectionState.connected + + if (currentBleState == 2) { + // BleConnectionState.connected Logger.i(_tag, 'BLE已连接,直接完成配对'); _transition(PairingState.completed); } else { @@ -447,15 +445,15 @@ class PairingController extends GetxController { _setError(ErrorType.associationFailed, '设备地址无效'); return; } - + Logger.i(_tag, '开始BLE设备关联: $_verifiedDeviceAddress'); - + // 调用BLE管理器的关联方法 final success = await _bleManager.startDeviceAssociation( - deviceNamePattern: 'DeapSound', // 或者使用 _currentClassicDevice?.name + deviceNamePattern: 'DeapSound', // 或者使用 _currentClassicDevice?.name macAddress: _verifiedDeviceAddress!, ); - + if (success) { Logger.i(_tag, 'BLE设备关联成功'); // 关联成功后,转到等待BLE连接状态 @@ -479,21 +477,23 @@ class PairingController extends GetxController { _setError(ErrorType.bleTimeout, 'BLE连接超时,请重试'); } }); - + // 检查是否已连接 - 直接从原生层获取最新状态 final currentBleState = await _bleManager.getConnectionState(); Logger.i(_tag, 'BLE当前连接状态: $currentBleState'); - - if (currentBleState == 2) { // BleConnectionState.connected + + if (currentBleState == 2) { + // BleConnectionState.connected Logger.i(_tag, 'BLE已经连接,直接完成配对'); _bleTimeoutTimer?.cancel(); _transition(PairingState.completed); return; } - + // 注册后台扫描以开始BLE配对 if (_verifiedDeviceAddress != null) { - final success = await _bleManager.registerBackgroundScan(macAddress: _verifiedDeviceAddress); + final success = await _bleManager.registerBackgroundScan( + macAddress: _verifiedDeviceAddress); if (!success && _currentState.value == PairingState.waitingBle) { _setError(ErrorType.bleTimeout, 'BLE配对启动失败'); } @@ -524,7 +524,7 @@ class PairingController extends GetxController { /// 用户点击连接按钮 void onConnectTap() { Logger.i(_tag, '用户点击连接按钮'); - + // 根据当前状态或错误类型决定行为 if (_currentState.value == PairingState.error) { switch (_errorType.value) { @@ -558,7 +558,7 @@ class PairingController extends GetxController { /// 用户点击重试按钮 void onRetryTap() { Logger.i(_tag, '用户点击重试按钮'); - + // 如果是权限问题,引导去设置 if (_errorType.value == ErrorType.noPermission) { Logger.i(_tag, '引导用户去应用设置授予权限'); @@ -589,7 +589,7 @@ class PairingController extends GetxController { void openBluetoothSettings() { AppSettings.openAppSettings(type: AppSettingsType.bluetooth); } - + /// 清除设备关联(在需要重新配对时调用) Future clearDeviceAssociation() async { try { @@ -599,4 +599,4 @@ class PairingController extends GetxController { Logger.e(_tag, '清除设备关联失败: $e'); } } -} \ No newline at end of file +} diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 195daf552..fc37c5bad 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -543,6 +543,28 @@ class SettingsView extends GetView { }, isDarkMode: isDarkMode, ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + // BLE测试 + _buildSimpleNavigationSetting( + title: 'opus解码测试', + subtitle: '测试opus解码', + icon: Icons.bluetooth_searching, + iconBgColor: isDarkMode + ? Colors.green[900]!.withOpacity(0.3) + : Colors.green[100]!, + iconColor: + isDarkMode ? Colors.green[300]! : Colors.green[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.opusTest); + }, + isDarkMode: isDarkMode, + ), Divider( height: 1, color: isDarkMode diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 01f3a1ec6..e8eaf3d3e 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -24,13 +24,18 @@ class TranslationView extends GetView { PreferredSizeWidget _buildAppBar(bool isDarkMode) { // 在同一个类中添加辅助方法 - PopupMenuItem _buildPopupMenuItem(String value, String text) { + PopupMenuItem _buildPopupMenuItem(String value, String text, + {bool enabled = true // 默认启用 + }) { return PopupMenuItem( value: value, + enabled: enabled, // 设置启用状态 child: Text( text, style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, + color: enabled + ? null // 使用默认颜色 + : Colors.grey, // 禁用时显示灰色 ), ), ); @@ -59,8 +64,16 @@ class TranslationView extends GetView { itemBuilder: (BuildContext context) => [ _buildPopupMenuItem('simultaneous', 'simultaneousTranslation'.tr), _buildPopupMenuItem('faceToFace', 'faceToFaceTranslation'.tr), - _buildPopupMenuItem('audioVideo', 'audioVideoTranslation'.tr), - _buildPopupMenuItem('call', 'callTranslation'.tr), + _buildPopupMenuItem( + 'audioVideo', + 'audioVideoTranslation'.tr, + enabled: true, // 根据连接状态启用/禁用 + ), + _buildPopupMenuItem( + 'call', + 'callTranslation'.tr, + enabled: true, // 根据连接状态启用/禁用 + ), ], child: Row( mainAxisSize: MainAxisSize.min, // 关键修改:让宽度自适应内容 diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 4631245f0..66c5f2516 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -11,6 +11,8 @@ import '../modules/meeting/views/meeting_details_view.dart'; import '../modules/meeting/bindings/meeting_record_binding.dart'; import '../modules/meeting/views/meeting_record_view.dart'; import '../modules/meeting/views/meeting_view.dart'; +import '../modules/opus_test/bindings/opus_test_binding.dart'; +import '../modules/opus_test/views/opus_test_view.dart'; import '../modules/profile/views/profile_view.dart'; import '../modules/profile/bindings/profile_binding.dart'; import '../modules/chat/bindings/chat_binding.dart'; @@ -189,6 +191,13 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.opusTest, + page: () => const OpusTestView(), + binding: OpusTestBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), GetPage( name: Routes.agent, page: () => const AgentView(), 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 009ccd4ba..d9d2c9700 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 @@ -90,6 +90,21 @@ object AgentService : CoroutineScope { // 空闲检测相关 private var idleCheckJob: Job? = null private val maxIdleSeconds = 10 // 最大空闲秒数 + + // 打断防抖相关 + private var lastInterruptTime = 0L + private val INTERRUPT_DEBOUNCE_MS = 300L + + // 语气词过滤列表(中英文) + private val FILLER_WORDS = setOf( + // 中文语气词 + "嗯", "啊", "呃", "哦", "唉", "哎", "哈", "呵", "嘿", "喂", + "哇", "呀", "哟", "嘛", "吧", "呢", "啦", "咦", "嘻", "嗨", + "额", "这个", "那个", "就是", "然后", + // 英文语气词 + "um", "uh", "ah", "oh", "eh", "er", "hmm", "huh", "wow", + "well", "like", "you know", "i mean", "so", "actually" + ) /** * 初始化系统提示词 */ @@ -381,8 +396,28 @@ object AgentService : CoroutineScope { "language" to detectedLanguage )) - // 如果TTS正在播放或AI正在生成,则触发打断 + // 如果TTS正在播放或AI正在生成,检查是否需要打断 if (isTtsSpeaking || isAiStreaming) { + val currentTime = System.currentTimeMillis() + + // 防抖处理:避免过于频繁的打断 + if (currentTime - lastInterruptTime < INTERRUPT_DEBOUNCE_MS) { + return + } + + // 简单过滤:太短的内容可能是噪音 + if (recognizing.trim().length < 2) { + return + } + + // 过滤纯语气词 + val trimmedText = recognizing.trim().lowercase() + if (FILLER_WORDS.contains(trimmedText)) { + return + } + + // 执行打断 + lastInterruptTime = currentTime interruptCurrentResponse() } } @@ -482,14 +517,21 @@ object AgentService : CoroutineScope { */ fun interruptCurrentResponse() { if(isAiStreaming || isTtsSpeaking) { - // 停止TTS播放 - stopTts() + val startTime = System.currentTimeMillis() - // 停止AI流输出 - stopAiStream() + // 并行执行停止操作,加快响应速度 + launch { stopTts() } + launch { stopAiStream() } - // 发送打断事件 - sendEvent("response_interrupted", mapOf("status" to "interrupted")) + // 记录打断耗时 + launch { + delay(100) // 短暂延迟后计算耗时 + val duration = System.currentTimeMillis() - startTime + sendEvent("response_interrupted", mapOf( + "status" to "interrupted", + "duration_ms" to duration + )) + } } } @@ -739,13 +781,13 @@ object AgentService : CoroutineScope { override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { audioPlayer?.stopAudio() val name = functionCall.get("name") as String; - val (metestr, _broadcast)= autoHandleFcunCallResult(name,functionCallResult); - aiMetadata = metestr; - broadcast = _broadcast + val resultmap = autoHandleFcunCallResult(name,functionCallResult); + aiMetadata = resultmap["meta"] as String; + broadcast = resultmap["broadcast"] as Boolean; sendEvent("function_call_result", mapOf( "function_call" to functionCall.toString(), "result" to functionCallResult.toString(), - "meta" to metestr, + "meta" to aiMetadata, )) } } @@ -1116,7 +1158,7 @@ object AgentService : CoroutineScope { /* * 自动播放音乐 * */ - fun autoHandleFcunCallResult(toolname:String,functionCallResult: JSONObject): Pair{ + fun autoHandleFcunCallResult(toolname:String,functionCallResult: JSONObject): Map{ println("协议工具返回数据 检查是否存在卡片! functionCallResult: ${functionCallResult}") try { val contentStr = functionCallResult.optString("context") @@ -1126,13 +1168,18 @@ object AgentService : CoroutineScope { println("协议工具返回数据 检查是否存在卡片! textStr: ${textStr}") val metadata: MutableMap = mutableMapOf() var broadcast = true; + var notreply = false; if (textStr.isNotEmpty()) { val meta = JSONObject(textStr) - metadata[toolname] = meta // 现在可以赋值 - val metaStr = JSONObject(metadata as Map<*, *>).toString() if (meta.has("broadcast")){ broadcast = meta["broadcast"] as Boolean } + if (meta.has("notreply")){ + notreply = meta["notreply"] as Boolean + } + metadata[toolname] = meta // 现在可以赋值 + metadata["notreply"] = notreply // 现在可以赋值 + val metaStr = JSONObject(metadata as Map<*, *>).toString() // Log.d(TAG, "mcp调用结果: $metaStr") if (meta.has("card_music")) { //音乐卡片 val cardMusic = meta.getJSONObject("card_music") @@ -1190,13 +1237,13 @@ object AgentService : CoroutineScope { val end = cardNavigation.optString("end", "") processNavigation(start, end); } - return Pair(metaStr, broadcast); + return mapOf("meta" to metaStr, "broadcast" to broadcast,"notreply" to notreply); } } catch (e: Exception) { // 捕获其他类型的异常(可选) println("为解析到卡片数据!") } - return Pair("", true); + return mapOf("meta" to "", "broadcast" to true,"notreply" to false); } /** diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt index 9ffd523bc..03f2634df 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt @@ -395,7 +395,7 @@ class BleCompanionManager(private val context: Context) { // 构建过滤器 val filter = ScanFilter.Builder() .setDeviceAddress(mac) - // .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01)) + .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01)) .build() // 扫描设置 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 7e6141db4..55eb87911 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -1084,8 +1084,6 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开解码 */ fun openDecoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - // val opusStarted = startOpusStreamDecoding( // hasHeader = false, // BLE传输的Opus数据通常无文件头 // channel = 1, // 单声道 @@ -1095,9 +1093,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // // The library might expect this in a different unit or derive it. // // Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms. // ) + startOpusStreamDecoding(false, 1, 16000, 40) - - Log.i(TAG, "打开解码...") + Log.i(TAG, "打开解码0xA2") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( BleConst.CODEC_CONTROL_DECODE_ON.toByte(), @@ -1111,8 +1109,8 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开解码 */ fun openA2DPDecoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - + Log.i(TAG, "打开编码 0xA1") + startOpusStreamDecoding(false, 1, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1135,9 +1133,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开编码 */ fun openEncoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - + Log.i(TAG, "打开编码0xB1") + startOpusStreamDecoding(false, 1, 16000, 40) // 目前仅发送命令通知设备开始编码。 return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), @@ -1410,11 +1408,8 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // 创建新的音频文件 val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) val timestamp = dateFormat.format(Date()) - val parentDir = File(context.filesDir, "OpusAudio") - if (!parentDir.exists()) { - parentDir.mkdirs() - } - val filePath = File(parentDir, "${fileName}_${timestamp}.opus").absolutePath + + val filePath = File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath currentAudioFile = File(filePath) currentAudioFile?.createNewFile() // 追加音频数据到文件 @@ -1445,7 +1440,17 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) internal fun saveAudioDataToWav(buffer: ByteArray) { if (fos == null || currentAudioFile == null) return // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) + // 1. 将字节数据转换为十六进制字符串 + // val hexData = buildString { + // buffer.forEachIndexed { index, byte -> + // append("%02X".format(byte)) + + // } + // } + + // 2. 写入十六进制字符串 + + writeQueue.offer(buffer) totalBytesWritten += buffer.size } diff --git a/local_plugins/bytedance_speech/lib/bytedance_speech.dart b/local_plugins/bytedance_speech/lib/bytedance_speech.dart new file mode 100644 index 000000000..522adb9b7 --- /dev/null +++ b/local_plugins/bytedance_speech/lib/bytedance_speech.dart @@ -0,0 +1,12 @@ +library bytedance_speech; + +import 'package:flutter/services.dart'; + +class BytedanceSpeech { + static const MethodChannel _channel = MethodChannel('bytedance_speech'); + + static Future getPlatformVersion() async { + final String? version = await _channel.invokeMethod('getPlatformVersion'); + return version; + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt index 036ad2782..3e3d056db 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt @@ -24,6 +24,7 @@ import org.json.JSONObject import android.os.Handler import android.os.Looper import java.util.UUID +import java.util.Collections /** * ChatAPI服务异常 @@ -70,8 +71,32 @@ private data class ToolCallInfo( var arguments: String = "" ) { fun isValid(): Boolean { + // 确保参数是完整的 JSON + return id.isNotEmpty() && name.isNotEmpty() && + arguments.isNotEmpty() && isCompleteJson(arguments) + } + + private fun isCompleteJson(json: String): Boolean { return try { - id.isNotEmpty() && name.isNotEmpty() + // 检查 JSON 是否完整(括号匹配) + val trimmed = json.trim() + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false + } + // 简单的括号匹配检查 + var braceCount = 0 + for (char in trimmed) { + when (char) { + '{' -> braceCount++ + '}' -> braceCount-- + } + if (braceCount < 0) return false + } + if (braceCount != 0) return false + + // 验证是否是有效的 JSON + JSONObject(json) + true } catch (e: Exception) { false } @@ -110,12 +135,10 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 流式请求相关 private var currentStreamJob: Job? = null -// private var streamCallback: StreamCallback? = null private var currentMessages: List> = emptyList() - private var toolCalls: MutableMap = mutableMapOf() - private var sessions: MutableMap = mutableMapOf() - private var currSessionId = "" // -// private var isCanceled = false + private var toolCalls: MutableMap = Collections.synchronizedMap(mutableMapOf()) + private var currSessionId = "" + private var currSessionCallback: StreamCallback? = null @@ -125,11 +148,33 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // MARK: - 公共方法 /** - * 设置流式回调 + * 中止当前会话 */ -// fun setStreamCallback(callback: StreamCallback) { -// streamCallback = callback -// } + private fun abortCurrentSession() { + if (currSessionId.isNotEmpty()) { + // 1. 取消当前的协程任务 + currentStreamJob?.cancel() + currentStreamJob = null + + // 2. 通知旧会话被中止 + currSessionCallback?.onError(ChatApiException("Session aborted by new request")) + + // 3. 清理状态 + currSessionId = "" + currSessionCallback = null + toolCalls.clear() + } + } + + /** + * 清理当前会话 + */ + private fun clearCurrentSession() { + currSessionId = "" + currSessionCallback = null + currentStreamJob = null + toolCalls.clear() + } /** * 初始化ChatAPI服务 @@ -262,29 +307,34 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor * 发送消息(流式输出)- 兼容JSONArray格式 */ fun sendMessageStream(messages: org.json.JSONArray, callback: StreamCallback) { - // 设置回调 -// setStreamCallback(callback) + // 1. 立即中止旧会话 + abortCurrentSession() - // 转换JSONArray为List> - val messageList = convertJsonArrayToList(messages) + // 2. 创建新会话 currSessionId = UUID.randomUUID().toString() - sessions[currSessionId] = callback - // 调用原始方法 - sendMessageStream(currSessionId,messageList) + currSessionCallback = callback + + // 3. 转换消息并开始处理 + val messageList = convertJsonArrayToList(messages) + sendMessageStream(currSessionId, messageList) } /** * 发送消息(流式输出) */ fun sendMessageStream(sessionid:String,messages: List>) { + // 验证是否是当前会话 + if (sessionid != currSessionId) { + return // 忽略非当前会话的请求 + } + if (!isInitialized || apiKey.isEmpty() || openAI == null) { Log.e("ChatApiService", "ChatAPI服务未初始化,无法发送消息") - sessions[sessionid]?.onError(ChatApiException("ChatAPI服务未初始化")) + currSessionCallback?.onError(ChatApiException("ChatAPI服务未初始化")) return } // 重置状态 -// isCanceled = false currentMessages = messages toolCalls.clear() // 检查是否包含图片,决定使用哪个模型 @@ -337,79 +387,93 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } chatsFlow.collect { result -> - if (currSessionId != sessionid) { - return@collect + // 每次处理前检查是否仍是当前会话 + if (sessionid != currSessionId) { + throw CancellationException("Session cancelled") } val choice = result.choices.firstOrNull() if (choice == null) { - Log.e("ChatApiService", "choice为null") + Log.w("ChatApiService", "[Session: $sessionid] choice为null") return@collect } val delta = choice.delta if (delta == null) { - Log.e("ChatApiService", "delta为null") + Log.w("ChatApiService", "[Session: $sessionid] delta为null") return@collect } // 处理普通文本内容 delta.content?.let { content -> - sessions[sessionid]?.onToken(content) + currSessionCallback?.onToken(content) } // 收集工具调用信息 delta.toolCalls?.forEach { toolCall -> - val index = toolCall.index - // 创建或获取现有的工具调用信息 - val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } - // 安全处理工具调用ID try { - toolCall.id?.let { i -> - toolCallInfo.id = i.toString() + val index = toolCall.index + val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() } + + // 使用安全调用链处理工具调用信息 + toolCall.id?.toString()?.takeIf { it.isNotEmpty() }?.let { + toolCallInfo.id = it } - } catch (e: Exception) { - Log.e("ChatApiService", "处理工具调用ID异常: ${e.message}") - } - // 安全处理函数信息 - try { + toolCall.function?.let { function -> + // 安全处理函数名称 - 使用 try-catch 避免 null 属性访问 try { - function.name.let { name -> - toolCallInfo.name = name + val functionName = function.name + if (!functionName.isNullOrEmpty()) { + toolCallInfo.name = functionName } } catch (e: Exception) { - Log.e("ChatApiService", "处理工具调用函数名称异常! function:${toolCall.function} err:${e.message}") + // 在流式响应中,name 可能在后续的 delta 中为 null,这是正常的 + // 不记录日志,避免干扰 } + + // 安全处理函数参数 - 使用 try-catch 避免 null 属性访问 try { - function.arguments.let { args -> - toolCallInfo.arguments += args + val functionArgs = function.arguments + if (!functionArgs.isNullOrEmpty()) { + toolCallInfo.arguments += functionArgs } } catch (e: Exception) { - Log.e("ChatApiService", "处理工具调用参数异常: ${e.message}") + // 在流式响应中,arguments 可能在后续的 delta 中为 null,这是正常的 + // 不记录日志,避免干扰 } - Log.e("ChatApiService", "AI发起工具调用:${toolCallInfo.name} ${toolCallInfo.arguments}") + + // 不设置默认值,让参数自然累积 + } + + // 只在有完整有效数据时记录日志(避免流式响应过程中的重复日志) + if (toolCallInfo.isValid()) { + Log.d("ChatApiService", "[Session: $sessionid] AI发起工具调用: ${toolCallInfo.name}, 参数: ${toolCallInfo.arguments}") } } catch (e: Exception) { - Log.e("ChatApiService", "处理工具调用函数信息异常: ${e.message}") + Log.w("ChatApiService", "[Session: $sessionid] 处理工具调用信息异常: ${e.message}", e) } } } - if (currSessionId == sessionid) { + // 完成处理 + if (sessionid == currSessionId) { // 检查是否有工具调用需要处理 val hasToolCalls = processToolCalls(sessionid) if (!hasToolCalls) { - sessions[sessionid]?.onComplete() + currSessionCallback?.onComplete() + clearCurrentSession() } - }else{ - sessions[sessionid]?.onComplete() } + } catch (e: CancellationException) { + // 会话被取消,不需要处理 + Log.d("ChatApiService", "Session $sessionid cancelled") } catch (e: Exception) { -// if (currSessionId == sessionid) { - sessions[sessionid]?.onError(ChatApiException("流式请求失败: ${e.message}")) -// } + if (sessionid == currSessionId) { + currSessionCallback?.onError(ChatApiException("流式请求失败: ${e.message}")) + clearCurrentSession() + } } } } @@ -418,42 +482,54 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor * 处理工具调用 */ private suspend fun processToolCalls(sessionid: String): Boolean { - val firstToolCall = try { - toolCalls.values.firstOrNull { it.isValid() } - } catch (e: Exception) { - Log.e("ChatApiService", "查找有效工具调用异常: ${e.message}") - null - } - if (firstToolCall == null) { + // 验证工具调用集合不为空 + if (toolCalls.isEmpty()) { + Log.d("ChatApiService", "[Session: $sessionid] 没有工具调用需要处理") return false } - // 创建函数调用字典 - val functionCall = try { - mapOf( - "name" to (firstToolCall.name.takeIf { it.isNotEmpty() } ?: ""), - "arguments" to (firstToolCall.arguments.takeIf { it.isNotEmpty() } ?: "{}"), - "id" to (firstToolCall.id.takeIf { it.isNotEmpty() } ?: "") - ) - } catch (e: Exception) { - Log.e("ChatApiService", "创建函数调用字典异常: ${e.message}") + + // 输出所有收集到的工具调用信息(用于调试) + Log.d("ChatApiService", "[Session: $sessionid] 收集到 ${toolCalls.size} 个工具调用") + toolCalls.forEach { (index, toolCall) -> + Log.d("ChatApiService", "[Session: $sessionid] 工具调用[$index]: name=${toolCall.name}, args.length=${toolCall.arguments.length}, args=${toolCall.arguments.take(100)}...") + } + + // 只处理有效的工具调用 + val validToolCalls = toolCalls.values.filter { it.isValid() } + if (validToolCalls.isEmpty()) { + Log.w("ChatApiService", "[Session: $sessionid] 没有有效的工具调用(参数可能不完整)") return false } + + // 处理第一个有效的工具调用 + val firstToolCall = validToolCalls.first() + Log.d("ChatApiService", "[Session: $sessionid] 处理工具调用: ${firstToolCall.name},参数: ${firstToolCall.arguments}") + // 创建函数调用字典 + val functionCall = mapOf( + "name" to firstToolCall.name, + "arguments" to firstToolCall.arguments, + "id" to firstToolCall.id + ) // 通知上层工具调用事件 - sessions[sessionid]?.onFunctionCall(convertMapToJsonObject(functionCall)) + currSessionCallback?.onFunctionCall(convertMapToJsonObject(functionCall)) // 在后台队列处理工具调用 launch { try { - if (currSessionId == sessionid) { + if (sessionid == currSessionId) { // 通过MCP客户端处理工具调用 val functionName = firstToolCall.name val argumentsJson = firstToolCall.arguments val result = if (_mcpClient?.hasToolWithName(functionName) == true) { - // 解析参数 - val arguments = _mcpClient?.parseJsonArguments(argumentsJson) ?: emptyMap() + // 解析参数,如果参数为空JSON对象,使用空Map + val arguments = if (argumentsJson == "{}") { + emptyMap() + } else { + _mcpClient?.parseJsonArguments(argumentsJson) ?: emptyMap() + } // 调用MCP工具 val toolResult = _mcpClient?.callTool(functionName, arguments) - Log.e("ChatApiService", "AI调用工具结果: $functionName}:$arguments} toolResult:${toolResult}") + Log.d("ChatApiService", "[Session: $sessionid] AI调用工具结果: $functionName, 参数: $arguments, 结果: $toolResult") // 处理结果 if (toolResult != null) { if (toolResult["isError"] == true) { @@ -473,16 +549,16 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor mapOf("context" to text) } } else { - Log.e("ChatApiService", "MCP工具调用返回null") + Log.w("ChatApiService", "[Session: $sessionid] MCP工具调用返回null") mapOf("context" to "Tool call failed") } } else { // 工具不存在 mapOf("context" to "Tool not found: $functionName") } - if (currSessionId == sessionid) { + if (sessionid == currSessionId) { // 处理结果 - sessions[sessionid]?.onFunctionCallResult( + currSessionCallback?.onFunctionCallResult( convertMapToJsonObject(functionCall), convertMapToJsonObject(result) ) @@ -493,13 +569,11 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor functionCall = functionCall, functionResult = gson.toJson(result) ) - }else{ - sessions[sessionid]?.onComplete() } } } catch (e: Exception) { Log.e("ChatApiService", "工具调用处理过程中出错: ${e.message}", e) - if (currSessionId == sessionid) { + if (sessionid == currSessionId) { val errorMessage = "工具调用处理失败: ${e.message}" sendFunctionCallResultInternal( sessionid= sessionid, @@ -507,8 +581,6 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor functionCall = functionCall, functionResult = errorMessage ) - }else{ - sessions[sessionid]?.onComplete() } } } @@ -525,7 +597,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor functionCall: Map, functionResult: String ) { - if (currSessionId != sessionid) return + if (sessionid != currSessionId) return val fullMessages = messages.toMutableList() @@ -565,19 +637,19 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor * 取消当前流式请求 */ fun cancelCurrentStream(): Boolean { - sessions[currSessionId]?.onComplete() - sessions.remove(currSessionId) - currSessionId = "" - currentStreamJob?.cancel() - currentStreamJob = null - return true + if (currSessionId.isNotEmpty()) { + abortCurrentSession() + return true + } + return false } + /** * 取消所有操作并释放资源 (与 iOS 版本保持一致) */ fun cancelAll() { - cancelCurrentStream() + abortCurrentSession() } /** @@ -706,7 +778,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor description = description, parameters = parameters ) - Log.e("ChatApiService", "AI携带工具:${name}:${parameters}") + Log.d("ChatApiService", "AI携带工具: $name, 参数定义: $parameters") tools.add(tool) } catch (e: Exception) { @@ -796,9 +868,11 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor * 聊天完成流式接口 * 与iOS版本保持一致的接口 */ - fun chatCompletionStream(sessionid: String,messages: List>, tool: Boolean = false) { - // 直接调用sendMessageStream,因为该方法已经处理了工具调用 - sendMessageStream(sessionid,messages) + fun chatCompletionStream(sessionid: String, messages: List>, tool: Boolean = false) { + // 验证是否是当前会话 + if (sessionid == currSessionId) { + sendMessageStream(sessionid, messages) + } } // MARK: - 工具方法 diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt index f4bf2de9b..12982f0fb 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt @@ -236,8 +236,7 @@ class CustomSseClientTransport( if (!response.status.isSuccess()) { val text = response.bodyAsText() - Log.e(TAG, "发送消息失败:URL:${urlString} HTTP ${response.status}, $text") - error("Error POSTing to endpoint (HTTP ${response.status}): $text") + // Log.e(TAG, "发送消息失败:URL:${urlString} HTTP ${response.status}, $text") } } catch (e: Exception) { Log.e(TAG, "发送消息异常: ${e.message}", e) diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt index 23c538115..c727a8f82 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt @@ -237,7 +237,10 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { localFunctions[name]?.let { handler -> return try { val result = handler.handle(arguments) - mapOf("context" to result) + mapOf("context" to mapOf( + "type" to "text", + "text" to result, + )) } catch (e: Exception) { mapOf( "content" to listOf(mapOf( diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt index b7b3adbe2..3fe6b926a 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt @@ -357,9 +357,9 @@ class MCPSubClient( val response = mcpClient?.ping() val latency = System.currentTimeMillis() - startTime - Log.d(TAG, "心跳检测成功,延迟: ${latency}ms") + // Log.d(TAG, "心跳检测成功,延迟: ${latency}ms") } catch (e: Exception) { - Log.e(TAG, "心跳检测失败: ${e.message}") + // Log.e(TAG, "心跳检测失败: ${e.message}") handleConnectionError(e) break } @@ -377,7 +377,7 @@ class MCPSubClient( retryCount++ currentReconnectDelay = minOf(currentReconnectDelay * 2, maxReconnectDelay) - Log.w(TAG, "连接失败,将在 ${currentReconnectDelay}ms 后尝试重连 (尝试 $retryCount/$maxRetryAttempts)") + // Log.w(TAG, "连接失败,将在 ${currentReconnectDelay}ms 后尝试重连 (尝试 $retryCount/$maxRetryAttempts)") delay(currentReconnectDelay) connect() diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 672c29634..642ce5ca1 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -1,5 +1,9 @@ package com.yunqiinnovation.chat_api +import android.Manifest +import android.app.ActivityManager +import android.content.ContentUris +import android.content.ContentValues import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -7,10 +11,13 @@ import android.location.LocationManager import android.net.Uri import android.os.Build import android.provider.CalendarContract +import android.provider.ContactsContract +import android.provider.Settings import android.telephony.SmsManager import android.util.Log import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED +import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* @@ -19,6 +26,9 @@ import java.util.* * 提供内置的系统函数,与 iOS 版本保持一致 */ class SystemFunctionHandler(private val context: Context? = null) { + companion object { + const val TAG = "SystemFunctionHandler" + } /** * 注册所有系统函数 @@ -222,6 +232,171 @@ class SystemFunctionHandler(private val context: Context? = null) { handler = ExitNavigationHandler(context) ) } + + /** + * 检查权限并根据应用状态采取不同行为 + * @param permission 需要检查的权限 + * @param permissionName 权限名称(用于显示给用户) + * @param functionName 功能名称(用于日志和提示) + * @return 是否拥有权限 + */ + internal fun checkPermissionAndGuide(permission: String, permissionName: String, functionName: String): Boolean { + val ctx = context ?: return false + + if (ContextCompat.checkSelfPermission(ctx, permission) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "缺少${permissionName}权限,无法执行${functionName}") + + // 检查应用是否在前台 + if (isAppInForeground()) { + // 应用在前台,尝试发送广播通知主应用弹出权限申请对话框 + try { + val intent = Intent("com.yunqiinnovation.deepsound.action.REQUEST_PERMISSION").apply { + setPackage(ctx.packageName) + putExtra("permission", permission) + putExtra("permissionName", permissionName) + putExtra("functionName", functionName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + ctx.sendBroadcast(intent) + + // 设置一个短暂延迟,让用户有时间响应权限请求 + Thread.sleep(500) + + // 再次检查权限是否已授予 + if (ContextCompat.checkSelfPermission(ctx, permission) == PackageManager.PERMISSION_GRANTED) { + return true + } + } catch (e: Exception) { + Log.e(TAG, "请求权限失败: ${e.message}", e) + } + + // 如果权限仍未授予,引导用户到权限管理页面 + try { + val settingsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", ctx.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ctx.startActivity(settingsIntent) + } catch (e: Exception) { + Log.e(TAG, "打开权限设置页面失败: ${e.message}", e) + } + } + return false + } + return true + } + + /** + * 检查应用是否在前台运行 + * @return 应用是否在前台 + */ + internal fun isAppInForeground(): Boolean { + val ctx = context ?: return false + try { + val activityManager = ctx.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = ctx.packageName + + for (appProcess in appProcesses) { + if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + && appProcess.processName == packageName) { + return true + } + } + } catch (e: Exception) { + Log.e(TAG, "检查应用状态失败: ${e.message}", e) + } + return false + } + + /** + * 根据联系人名称获取电话号码 + * @param contact 联系人名称或电话号码 + * @return 电话号码 + */ + internal fun getPhoneNumberForContact(contact: String): String { + val ctx = context ?: return "" + + // 如果输入的是电话号码(简单判断,包含数字和特殊符号) + if (contact.matches(Regex("^[+\\d\\s\\-()]*\$")) && contact.any { it.isDigit() }) { + // 移除非数字字符 + return contact.replace(Regex("[^+\\d]"), "") + } + + // 检查读取联系人权限 + if (!checkPermissionAndGuide(Manifest.permission.READ_CONTACTS, "读取联系人", "访问联系人")) { + return "" + } + + // 从联系人数据库查询 + try { + val contentResolver = ctx.contentResolver + val uri = Uri.withAppendedPath( + ContactsContract.Contacts.CONTENT_FILTER_URI, + Uri.encode(contact) + ) + + val projection = arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Contacts.DISPLAY_NAME, + ContactsContract.Contacts.HAS_PHONE_NUMBER + ) + + contentResolver.query(uri, projection, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val hasPhoneNumber = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) + + if (hasPhoneNumber > 0) { + val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) + val phoneUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI + val phoneProjection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) + val phoneSelection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" + val phoneSelectionArgs = arrayOf(contactId) + + contentResolver.query(phoneUri, phoneProjection, phoneSelection, phoneSelectionArgs, null)?.use { phoneCursor -> + if (phoneCursor.moveToFirst()) { + return phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) + } + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "查询联系人信息失败: ${e.message}", e) + } + + return "" + } + + /** + * 获取默认日历ID + */ + internal fun getDefaultCalendarId(): Long { + val ctx = context ?: return -1L + val projection = arrayOf(CalendarContract.Calendars._ID) + var calendarId = -1L + + try { + val uri = CalendarContract.Calendars.CONTENT_URI + val selection = "(${CalendarContract.Calendars.VISIBLE} = 1) AND (${CalendarContract.Calendars.IS_PRIMARY} = 1)" + ctx.contentResolver.query(uri, projection, selection, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + calendarId = cursor.getLong(0) + } else { + // 如果没有找到主日历,尝试找任何可见的日历 + ctx.contentResolver.query(uri, projection, "${CalendarContract.Calendars.VISIBLE} = 1", null, null)?.use { c -> + if (c.moveToFirst()) { + calendarId = c.getLong(0) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "获取默认日历ID失败: ${e.message}", e) + } + + return calendarId + } } /** @@ -229,12 +404,25 @@ class SystemFunctionHandler(private val context: Context? = null) { */ private class ExitNavigationHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送广播通知退出交互 val intent = Intent("com.yunqiinnovation.amap_service.EXIT_NAVIGATION").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) // 设置包名使其成为显式 Intent } - context?.sendBroadcast(intent) - return "{\"result\": \"已结束当前导航服务\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已结束当前导航服务") + put("broadcast", false) + }.toString() } } @@ -243,9 +431,21 @@ private class ExitNavigationHandler(private val context: Context?) : FunctionHan */ private class ExitInteractionHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送广播通知退出交互 - context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.EXIT_INTERACTION")) - return "{\"result\": \"已结束当前交互\"}" + context.sendBroadcast(Intent("com.yunqiinnovation.deepsound.EXIT_INTERACTION")) + + return JSONObject().apply { + put("result", "已结束当前交互") + }.toString() } } @@ -254,9 +454,21 @@ private class ExitInteractionHandler(private val context: Context?) : FunctionHa */ private class TranslationModeHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送广播通知进入翻译模式 - context?.sendBroadcast(Intent("com.yunqiinnovation.deepsound.ENTER_TRANSLATION_MODE")) - return "{\"result\": \"已进入翻译模式\"}" + context.sendBroadcast(Intent("com.yunqiinnovation.deepsound.ENTER_TRANSLATION_MODE")) + + return JSONObject().apply { + put("result", "已进入翻译模式") + }.toString() } } @@ -269,24 +481,75 @@ private class SendTextMessageHandler(private val context: Context?) : FunctionHa val message = arguments["message"] as? String if (contact == null || message == null) { - return "{\"result\": \"缺少必要参数\"}" + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "invalid_arguments") + put("message", "缺少必要参数:contact 或 message") + }) + }.toString() } + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + + val handler = context.let { SystemFunctionHandler(it) } + return try { - val smsIntent = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.parse("smsto:$contact") - putExtra("sms_body", message) - flags = Intent.FLAG_ACTIVITY_NEW_TASK + // 检查发送短信权限 + if (!handler.checkPermissionAndGuide(Manifest.permission.SEND_SMS, "发送短信", "发送短信")) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "permission_denied") + put("message", "需要短信权限才能发送短信,请在权限管理中开启短信权限") + }) + }.toString() } - if (context?.packageManager?.queryIntentActivities(smsIntent, 0)?.isNotEmpty() == true) { - context.startActivity(smsIntent) - "{\"result\": \"已打开短信应用\"}" + val phoneNumber = handler.getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "contact_not_found") + put("message", "未找到联系人信息: $contact") + }) + }.toString() + } + + // 使用SmsManager发送短信 + val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(SmsManager::class.java) + } else { + @Suppress("DEPRECATION") + SmsManager.getDefault() + } + + // 处理长短信 + val parts = smsManager.divideMessage(message) + if (parts.size == 1) { + smsManager.sendTextMessage(phoneNumber, null, message, null, null) } else { - "{\"result\": \"无法打开短信应用\"}" + smsManager.sendMultipartTextMessage(phoneNumber, null, parts, null, null) } + + JSONObject().apply { + put("result", "已成功向${contact}发送短信") + put("phone", phoneNumber) + put("message", message) + }.toString() } catch (e: Exception) { - "{\"result\": \"发送短信失败:${e.message}\"}" + Log.e(SystemFunctionHandler.TAG, "发送短信失败: ${e.message}", e) + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "send_failed") + put("message", "发送短信失败: ${e.message}") + }) + }.toString() } } } @@ -299,23 +562,73 @@ private class MakePhoneCallHandler(private val context: Context?) : FunctionHand val contact = arguments["contact"] as? String if (contact == null) { - return "{\"result\": \"缺少联系人参数\"}" + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "invalid_arguments") + put("message", "缺少必要参数:contact") + }) + }.toString() + } + + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() } + val handler = context.let { SystemFunctionHandler(it) } + return try { - val callIntent = Intent(Intent.ACTION_DIAL).apply { - data = Uri.parse("tel:$contact") - flags = Intent.FLAG_ACTIVITY_NEW_TASK + // 检查拨打电话权限 + if (!handler.checkPermissionAndGuide(Manifest.permission.CALL_PHONE, "拨打电话", "拨打电话")) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "permission_denied") + put("message", "需要电话权限才能拨打电话,请在权限管理中开启电话权限") + }) + }.toString() } - if (context?.packageManager?.queryIntentActivities(callIntent, 0)?.isNotEmpty() == true) { - context.startActivity(callIntent) - "{\"result\": \"已发起电话呼叫\"}" - } else { - "{\"result\": \"无法拨打电话\"}" + val phoneNumber = handler.getPhoneNumberForContact(contact) + if (phoneNumber.isEmpty()) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "contact_not_found") + put("message", "未找到联系人信息: $contact") + }) + }.toString() } + + // 创建拨号意图(使用ACTION_CALL直接拨打) + val intent = Intent(Intent.ACTION_CALL).apply { + data = Uri.parse("tel:$phoneNumber") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + + JSONObject().apply { + put("result", "正在拨打${contact}的电话") + put("phone", phoneNumber) + }.toString() + } catch (e: SecurityException) { + Log.e(SystemFunctionHandler.TAG, "拨打电话权限被拒绝: ${e.message}", e) + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "permission_denied") + put("message", "权限被拒绝,无法拨打电话") + }) + }.toString() } catch (e: Exception) { - "{\"result\": \"拨打电话失败:${e.message}\"}" + Log.e(SystemFunctionHandler.TAG, "拨打电话失败: ${e.message}", e) + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "call_failed") + put("message", "拨打电话失败: ${e.message}") + }) + }.toString() } } } @@ -330,37 +643,148 @@ private class SetReminderHandler(private val context: Context?) : FunctionHandle val timeString = arguments["time"] as? String if (title == null || timeString == null) { - return "{\"result\": \"缺少必要参数\"}" + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "invalid_arguments") + put("message", "缺少必要参数:title 或 time") + }) + }.toString() + } + + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() } + val handler = context.let { SystemFunctionHandler(it) } + return try { // 解析时间 val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - val date = formatter.parse(timeString) + val time = formatter.parse(timeString)?.time ?: run { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "invalid_time_format") + put("message", "提醒时间格式无效,请使用'yyyy-MM-dd HH:mm'格式") + }) + }.toString() + } + + // 检查日历权限 + if (!handler.checkPermissionAndGuide(Manifest.permission.WRITE_CALENDAR, "日历写入", "设置提醒")) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "permission_denied") + put("message", "需要日历权限才能创建提醒,请在权限管理中开启日历权限") + }) + }.toString() + } + + // 直接使用ContentProvider写入日历 + val endTime = time + 3600000 // 默认1小时后结束 + + // 获取默认日历ID + val calendarId = handler.getDefaultCalendarId() + if (calendarId == -1L) { + Log.w(SystemFunctionHandler.TAG, "未找到默认日历") + // 如果没有找到日历,尝试使用Intent创建 + return tryCreateWithIntent(context, title, content, time) + } - if (date == null) { - return "{\"result\": \"时间格式错误\"}" + // 创建事件 + val eventValues = ContentValues().apply { + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.TITLE, title) + put(CalendarContract.Events.DESCRIPTION, content) + put(CalendarContract.Events.DTSTART, time) + put(CalendarContract.Events.DTEND, endTime) + put(CalendarContract.Events.HAS_ALARM, 1) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) } - // 创建日历事件 + val eventUri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, eventValues) + if (eventUri == null) { + Log.e(SystemFunctionHandler.TAG, "插入日历事件失败") + return tryCreateWithIntent(context, title, content, time) + } + + // 获取事件ID + val eventId = ContentUris.parseId(eventUri) + + // 添加提醒(15分钟前提醒) + val reminderValues = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, eventId) + put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) + put(CalendarContract.Reminders.MINUTES, 15) // 提前15分钟提醒 + } + + val reminderUri = context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, reminderValues) + if (reminderUri != null) { + return JSONObject().apply { + put("result", "已成功创建提醒: $title") + put("title", title) + put("content", content) + put("time", timeString) + put("eventId", eventId) + }.toString() + } else { + // 仍然创建了事件,但没有提醒 + return JSONObject().apply { + put("result", "已创建日历事件,但未设置提醒: $title") + put("title", title) + put("time", timeString) + put("eventId", eventId) + }.toString() + } + } catch (e: Exception) { + Log.e(SystemFunctionHandler.TAG, "设置提醒失败: ${e.message}", e) + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "reminder_failed") + put("message", "设置提醒失败: ${e.message}") + }) + }.toString() + } + } + + private fun tryCreateWithIntent(context: Context, title: String, content: String, time: Long): String { + return try { val calendarIntent = Intent(Intent.ACTION_INSERT).apply { data = CalendarContract.Events.CONTENT_URI putExtra(CalendarContract.Events.TITLE, title) putExtra(CalendarContract.Events.DESCRIPTION, content) - putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, date.time) - putExtra(CalendarContract.EXTRA_EVENT_END_TIME, date.time + 60 * 60 * 1000) // 默认1小时 + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, time) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, time + 3600000) putExtra(CalendarContract.Events.HAS_ALARM, 1) flags = Intent.FLAG_ACTIVITY_NEW_TASK } - if (context?.packageManager?.queryIntentActivities(calendarIntent, 0)?.isNotEmpty() == true) { + if (context.packageManager?.queryIntentActivities(calendarIntent, 0)?.isNotEmpty() == true) { context.startActivity(calendarIntent) - "{\"result\": \"提醒设置成功\"}" + JSONObject().apply { + put("result", "已打开日历应用创建提醒") + put("title", title) + put("content", content) + }.toString() } else { - "{\"result\": \"无法打开日历应用\"}" + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "no_calendar_app") + put("message", "未找到可用的日历应用") + }) + }.toString() } } catch (e: Exception) { - "{\"result\": \"设置提醒失败:${e.message}\"}" + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "intent_failed") + put("message", "创建日历事件失败: ${e.message}") + }) + }.toString() } } } @@ -373,16 +797,61 @@ private class GetCurrentTimeHandler : FunctionHandler { val format = arguments["format"] as? String return try { - val formatter = if (!format.isNullOrEmpty()) { - SimpleDateFormat(format, Locale.getDefault()) - } else { - SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss", Locale.CHINA) + val calendar = Calendar.getInstance() + val date = calendar.time + + val timeStr = when (format?.lowercase(Locale.getDefault())) { + "short" -> { + SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + } + "date" -> { + SimpleDateFormat("yyyy年MM月dd日", Locale.getDefault()).format(date) + } + "time" -> { + SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date) + } + "full" -> { + SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm:ss", Locale.getDefault()).format(date) + } + "iso" -> { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()).format(date) + } + else -> { + SimpleDateFormat("yyyy年MM月dd日 HH:mm", Locale.getDefault()).format(date) + } + } + + // 获取星期几 + val weekDay = when (calendar.get(Calendar.DAY_OF_WEEK)) { + Calendar.SUNDAY -> "星期日" + Calendar.MONDAY -> "星期一" + Calendar.TUESDAY -> "星期二" + Calendar.WEDNESDAY -> "星期三" + Calendar.THURSDAY -> "星期四" + Calendar.FRIDAY -> "星期五" + Calendar.SATURDAY -> "星期六" + else -> "" } - val currentTime = formatter.format(Date()) - "{\"result\": \"$currentTime\", \"time\": \"$currentTime\"}" + // 根据格式决定是否添加星期几 + val resultTime = when { + format == "short" || format == "time" -> timeStr + format == "full" -> timeStr // full格式已经包含了星期 + else -> "$timeStr $weekDay" + } + + JSONObject().apply { + put("result", resultTime) + put("time", resultTime) + put("timestamp", date.time) + }.toString() } catch (e: Exception) { - "{\"result\": \"获取时间失败:${e.message}\"}" + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "time_error") + put("message", "获取时间失败: ${e.message}") + }) + }.toString() } } } @@ -393,21 +862,24 @@ private class GetCurrentTimeHandler : FunctionHandler { private class GetCurrentLocationHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { if (context == null) { - return "{\"result\": \"上下文未初始化\", \"success\": false}" + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() } - // 检查位置权限 - val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - } else { - true - } + val handler = context.let { SystemFunctionHandler(it) } - if (!hasPermission) { - return "{\"result\": \"位置权限被拒绝。请前往设置中启用位置权限\", \"success\": false}" + // 检查位置权限 + if (!handler.checkPermissionAndGuide(Manifest.permission.ACCESS_FINE_LOCATION, "位置信息", "获取位置")) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "permission_denied") + put("message", "需要位置权限才能获取当前位置,请在权限管理中开启位置权限") + }) + }.toString() } // 检查位置服务是否启用 @@ -416,12 +888,29 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) == true if (!isLocationEnabled) { - return "{\"result\": \"位置服务未启用。请前往设置中启用位置服务\", \"success\": false}" + // 尝试引导用户打开位置服务 + try { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } catch (e: Exception) { + Log.e(SystemFunctionHandler.TAG, "打开位置设置失败: ${e.message}", e) + } + + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "location_disabled") + put("message", "位置服务未启用,请打开GPS或网络定位") + }) + }.toString() } - // 注意:实际的位置获取需要异步处理,这里只返回提示信息 - // 在实际应用中应该使用 LocationCallback 或 Coroutines 来获取实时位置 - return "{\"result\": \"需要通过位置服务获取当前位置\", \"success\": true}" + // 注意:这里简化了实现,实际应该获取真实位置 + // 在生产环境中应该使用 LocationManager 或 FusedLocationProviderClient 获取实际位置 + return JSONObject().apply { + put("result", "位置服务已启用,可以获取位置信息") + put("note", "需要实现实际的位置获取逻辑") + }.toString() } } @@ -430,12 +919,26 @@ private class GetCurrentLocationHandler(private val context: Context?) : Functio */ private class MediaPlayHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送媒体播放广播 val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PLAY").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) } - context?.sendBroadcast(intent) - return "{\"result\": \"已开始播放媒体\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已开始播放媒体") + put("notreply", true) + put("broadcast", false) + }.toString() } } @@ -444,27 +947,55 @@ private class MediaPlayHandler(private val context: Context?) : FunctionHandler */ private class MediaPauseHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context") // 发送媒体暂停广播 val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PAUSE").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) } - context?.sendBroadcast(intent) - return "{\"result\": \"已暂停媒体播放\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已暂停媒体播放") + put("notreply", true) + put("broadcast", false) + }.toString() } } /** - * 媒体暂停处理器 + * 媒体关闭处理器 */ private class MediaCloseHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { - Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context") - // 发送媒体暂停广播 + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + + Log.d("MediaCloseHandler", "📤 正在发送广播 context = $context") + // 发送媒体关闭广播 val intent = Intent("com.yunqiinnovation.music_service.MEDIA_CLOSE").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) } - context?.sendBroadcast(intent) - return "{\"result\": \"已关闭媒体播放\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已关闭媒体播放") + put("notreply", true) + put("broadcast", false) + }.toString() } } /** @@ -472,13 +1003,26 @@ private class MediaCloseHandler(private val context: Context?) : FunctionHandler */ private class MediaPreviousHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送切换上一首广播 -// context?.applicationContext?.sendBroadcast(Intent("com.yunqiinnovation.music_service.MEDIA_PREVIOUS")) val intent = Intent("com.yunqiinnovation.music_service.MEDIA_PREVIOUS").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) } - context?.sendBroadcast(intent) - return "{\"result\": \"已切换到上一首\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已切换到上一首") + put("notreply", true) + put("broadcast", false) + }.toString() } } @@ -487,13 +1031,26 @@ private class MediaPreviousHandler(private val context: Context?) : FunctionHand */ private class MediaNextHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { + if (context == null) { + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() + } + // 发送切换下一首广播 -// context?.applicationContext?.sendBroadcast(Intent("com.yunqiinnovation.music_service.MEDIA_NEXT")) val intent = Intent("com.yunqiinnovation.music_service.MEDIA_NEXT").apply { - setPackage(context?.packageName) // 设置包名使其成为显式 Intent + setPackage(context.packageName) } - context?.sendBroadcast(intent) - return "{\"result\": \"已切换到下一首\"}" + context.sendBroadcast(intent) + + return JSONObject().apply { + put("result", "已切换到下一首") + put("notreply", true) + put("broadcast", false) + }.toString() } } @@ -503,7 +1060,12 @@ private class MediaNextHandler(private val context: Context?) : FunctionHandler private class OpenRecorderHandler(private val context: Context?) : FunctionHandler { override suspend fun handle(arguments: Map): String { if (context == null) { - return "{\"result\": \"上下文未初始化\"}" + return JSONObject().apply { + put("error", JSONObject().apply { + put("code", "context_error") + put("message", "上下文未初始化") + }) + }.toString() } return try { @@ -520,18 +1082,32 @@ private class OpenRecorderHandler(private val context: Context?) : FunctionHandl when { context.packageManager?.queryIntentActivities(recordIntent, 0)?.isNotEmpty() == true -> { context.startActivity(recordIntent) - "{\"result\": \"已打开录音机\"}" + JSONObject().apply { + put("result", "已打开录音机") + }.toString() } context.packageManager?.queryIntentActivities(recorderIntent, 0)?.isNotEmpty() == true -> { context.startActivity(recorderIntent) - "{\"result\": \"已打开音频应用\"}" + JSONObject().apply { + put("result", "已打开音频应用") + }.toString() } else -> { - "{\"result\": \"无法打开录音机应用\"}" + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "no_recorder_app") + put("message", "无法打开录音机应用") + }) + }.toString() } } } catch (e: Exception) { - "{\"result\": \"打开录音机失败:${e.message}\"}" + JSONObject().apply { + put("error", JSONObject().apply { + put("code", "recorder_failed") + put("message", "打开录音机失败: ${e.message}") + }) + }.toString() } } } \ No newline at end of file diff --git a/local_plugins/jl_opus/README.md b/local_plugins/jl_opus/README.md new file mode 100644 index 000000000..124a1c56c --- /dev/null +++ b/local_plugins/jl_opus/README.md @@ -0,0 +1,126 @@ +# JL OPUS Plugin + +基于杰理音频编解码库的Flutter插件,提供OPUS音频格式的解码功能。 + +## 功能 + +- 支持OPUS文件解码为PCM格式 +- 支持OPUS数据流解码 +- 支持多种采样率和通道配置 +- 支持带协议头/不带协议头的OPUS数据 + +## 引入方式 + +在pubspec.yaml中添加依赖: + +```yaml +dependencies: + jl_opus: + path: ./local_plugins/jl_opus +``` + +## 使用方法 + +### 初始化插件 + +```dart +import 'package:jl_opus/jl_opus.dart'; + +final jlOpus = JlOpus(); +``` + +### 解码OPUS文件 + +```dart +Future decodeOpusFile() async { + final inPath = '/path/to/input.opus'; + final outPath = '/path/to/output.pcm'; + + // 创建解码选项 + final option = OpusOption( + hasHeader: false, // 是否有协议头 + channel: 1, // 通道数:1=单声道,2=立体声 + sampleRate: 16000, // 采样率 + packetSize: 40, // 数据包大小(仅当hasHeader=false时生效) + ); + + try { + // 开始解码文件 + final outFilePath = await jlOpus.decodeOpusFile(inPath, outPath, option); + print('解码成功: $outFilePath'); + } catch (e) { + print('解码失败: $e'); + } +} +``` + +### 解码OPUS数据流 + +```dart +Future decodeOpusStream() async { + // 监听解码后的PCM数据 + jlOpus.decodedDataStream.listen((pcmData) { + print('收到解码后的PCM数据: ${pcmData.length} 字节'); + // 处理解码后的PCM数据 + }); + + // 创建解码选项 + final option = OpusOption( + hasHeader: false, + channel: 1, + sampleRate: 16000, + packetSize: 40, + ); + + // 开始解码数据流 + final success = await jlOpus.startDecodeStream(option); + if (success) { + print('开始解码数据流'); + + // 写入OPUS数据进行解码 + final opusData = Uint8List.fromList([/* OPUS数据 */]); + await jlOpus.writeAudioStream(opusData); + + // 数据处理完成后停止解码 + await jlOpus.stopDecodeStream(); + } +} +``` + +### 监听事件 + +```dart +void listenToEvents() { + jlOpus.eventStream.listen((event) { + switch (event.event) { + case 'onStart': + print('开始${event.type == "file" ? "文件" : "流"}解码'); + break; + case 'onComplete': + print('完成${event.type == "file" ? "文件" : "流"}解码'); + if (event.filePath != null) { + print('输出文件: ${event.filePath}'); + } + break; + case 'onError': + print('解码错误: [${event.errorCode}] ${event.errorMessage}'); + break; + } + }); +} +``` + +### 资源释放 + +```dart +Future disposePlugin() async { + await jlOpus.dispose(); + print('插件资源已释放'); +} +``` + +## 注意事项 + +1. OPUS解码需要正确设置参数,特别是采样率和通道数必须与原始音频匹配 +2. 使用前请确保jl_audio_decode_V2.0.0_20005.aar包已添加到libs目录 +3. 本插件仅支持Android平台 \ No newline at end of file diff --git a/local_plugins/jl_opus/android/build.gradle.kts b/local_plugins/jl_opus/android/build.gradle.kts new file mode 100644 index 000000000..3c0b820e2 --- /dev/null +++ b/local_plugins/jl_opus/android/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + // Android Library 插件 + id("com.android.library") + // Kotlin Android 插件 + id("org.jetbrains.kotlin.android") + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +// 添加mavenLocal()仓库 +repositories { + mavenLocal() + google() + mavenCentral() +} + +android { + // 命名空间,对应你插件的包名(需与代码内包名保持一致) + namespace = "com.yunqiinnovation.jl_opus" + + // 目标 SDK 版本 + compileSdk = 33 + + defaultConfig { + // 最低 SDK 版本 + minSdk = 21 + targetSdk = 33 + } + + // Java 语言级别兼容配置 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + // Kotlin 语言级别 + kotlinOptions { + jvmTarget = "11" + } + + // 添加packaging配置,排除冲突文件 + packaging { + resources { + excludes.add("META-INF/DEPENDENCIES") + excludes.add("META-INF/LICENSE") + excludes.add("META-INF/LICENSE.txt") + excludes.add("META-INF/license.txt") + excludes.add("META-INF/NOTICE") + excludes.add("META-INF/NOTICE.txt") + excludes.add("META-INF/notice.txt") + excludes.add("META-INF/*.kotlin_module") + } + } +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10") + implementation("androidx.annotation:annotation:1.5.0") + implementation("androidx.core:core-ktx:1.9.0") + // 使用从本地Maven仓库引用的AAR依赖 + implementation("com.jieli.audio:jl_audio_decode:2.0.0") +} + diff --git a/local_plugins/jl_opus/android/settings.gradle.kts b/local_plugins/jl_opus/android/settings.gradle.kts new file mode 100644 index 000000000..453a0b3f4 --- /dev/null +++ b/local_plugins/jl_opus/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "jl_opus" \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/AndroidManifest.xml b/local_plugins/jl_opus/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ee906a1ee --- /dev/null +++ b/local_plugins/jl_opus/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt b/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt new file mode 100644 index 000000000..a2c9ad16c --- /dev/null +++ b/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt @@ -0,0 +1,285 @@ +package com.yunqiinnovation.jl_opus + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.NonNull +import com.jieli.jl_audio_decode.opus.model.OpusOption +import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback +import com.jieli.jl_audio_decode.callback.OnStateCallback +import com.jieli.jl_audio_decode.opus.OpusManager +import com.jieli.jl_audio_decode.exceptions.OpusException +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File + +/** JlOpusPlugin */ +class JlOpusPlugin: FlutterPlugin, MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var context: Context + private var opusManager: OpusManager? = null + private var streamSink: EventChannel.EventSink? = null + + // 添加主线程Handler用于确保在主线程回调 + private val mainHandler = Handler(Looper.getMainLooper()) + + companion object { + private const val TAG = "JlOpusPlugin" + private const val CHANNEL_NAME = "com.yunqiinnovation.jl_opus" + private const val EVENT_CHANNEL_NAME = "com.yunqiinnovation.jl_opus/events" + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel.setMethodCallHandler(this) + + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL_NAME) + eventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + streamSink = events + } + + override fun onCancel(arguments: Any?) { + streamSink = null + } + }) + + + } + + // 在主线程上安全发送事件 + private fun sendEventOnMainThread(event: Map) { + if (streamSink == null) return + + if (Looper.myLooper() == Looper.getMainLooper()) { + // 如果已经在主线程,直接发送 + streamSink?.success(event) + } else { + // 否则切换到主线程 + mainHandler.post { + streamSink?.success(event) + } + } + } + + // 在主线程上安全返回结果 + private fun sendResultOnMainThread(result: Result, value: Any?) { + if (Looper.myLooper() == Looper.getMainLooper()) { + // 如果已经在主线程,直接返回 + result.success(value) + } else { + // 否则切换到主线程 + mainHandler.post { + result.success(value) + } + } + } + + // 在主线程上安全返回错误 + private fun sendErrorOnMainThread(result: Result, errorCode: String, errorMessage: String, errorDetails: Any?) { + if (Looper.myLooper() == Looper.getMainLooper()) { + // 如果已经在主线程,直接返回 + result.error(errorCode, errorMessage, errorDetails) + } else { + // 否则切换到主线程 + mainHandler.post { + result.error(errorCode, errorMessage, errorDetails) + } + } + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + // if (opusManager == null) { + // result.error("OPUS_MANAGER_NULL", "OpusManager未初始化", null) + // return + // } + + when (call.method) { + "initOpusDecoder" -> { + // 初始化OpusManager + + opusManager = OpusManager() + Log.i("TAG", "OpusManager初始化成功") + result.success(true) + + + } + "decodeOpusFile" -> { + val inPath = call.argument("inPath") + val outPath = call.argument("outPath") + val hasHeader = call.argument("hasHeader") ?: false + val channel = call.argument("channel") ?: 1 + val sampleRate = call.argument("sampleRate") ?: 16000 + val packetSize = call.argument("packetSize") ?: 40 + + if (inPath == null || outPath == null) { + result.error("INVALID_ARGS", "输入或输出路径不能为空", null) + return + } + + // 确保输入文件存在 + val inFile = File(inPath) + if (!inFile.exists()) { + result.error("FILE_NOT_FOUND", "输入文件不存在: $inPath", null) + return + } + + // 创建解码参数 + val option = OpusOption() + .setHasHead(hasHeader) + .setChannel(channel) + .setSampleRate(sampleRate) + .setPacketSize(packetSize) + + Log.d(TAG, "开始解码文件, 参数: $option, 输入: $inPath, 输出: $outPath") + + opusManager?.decodeFile(inPath, outPath, option, object : OnStateCallback { + override fun onStart() { + Log.d(TAG, "文件解码开始") + sendEventOnMainThread(mapOf( + "event" to "onStart", + "type" to "file" + )) + } + + override fun onComplete(outFilePath: String?) { + Log.d(TAG, "文件解码完成: $outFilePath") + sendEventOnMainThread(mapOf( + "event" to "onComplete", + "type" to "file", + "filePath" to outFilePath + )) + sendResultOnMainThread(result, outFilePath) + } + + override fun onError(code: Int, message: String?) { + Log.e(TAG, "文件解码错误: [$code] $message") + sendEventOnMainThread(mapOf( + "event" to "onError", + "type" to "file", + "code" to code, + "message" to message + )) + sendErrorOnMainThread(result, "DECODE_ERROR", "解码出错: [$code] $message", null) + } + }) + } + "startDecodeStream" -> { + val hasHeader = call.argument("hasHeader") ?: false + val channel = call.argument("channel") ?: 1 + val sampleRate = call.argument("sampleRate") ?: 16000 + val packetSize = call.argument("packetSize") ?: 40 + + // 如果已经在解码流,先停止 + if (opusManager?.isDecodeStream == true) { + opusManager?.stopDecodeStream() + } + + // 创建解码参数 + val option = OpusOption() + .setHasHead(hasHeader) + .setChannel(channel) + .setSampleRate(sampleRate) + .setPacketSize(packetSize) + + Log.d(TAG, "开始解码数据流, 参数: $option") + + opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { + override fun onDecodeStream(data: ByteArray?) { + if (data != null) { + sendEventOnMainThread(mapOf( + "event" to "onDecodeStream", + "data" to data + )) + } + } + + override fun onStart() { + Log.d(TAG, "数据流解码开始") + sendEventOnMainThread(mapOf( + "event" to "onStart", + "type" to "stream" + )) + sendResultOnMainThread(result, true) + } + + override fun onComplete(outPath: String?) { + Log.d(TAG, "数据流解码完成: $outPath") + sendEventOnMainThread(mapOf( + "event" to "onComplete", + "type" to "stream" + )) + } + + override fun onError(code: Int, message: String?) { + Log.e(TAG, "数据流解码错误: [$code] $message") + sendEventOnMainThread(mapOf( + "event" to "onError", + "type" to "stream", + "code" to code, + "message" to message + )) + sendErrorOnMainThread(result, "DECODE_STREAM_ERROR", "数据流解码出错: [$code] $message", null) + } + }) + } + "stopDecodeStream" -> { + if (opusManager?.isDecodeStream == true) { + opusManager?.stopDecodeStream() + result.success(true) + } else { + result.success(false) + } + } + "writeAudioStream" -> { + val data = call.argument("data") + if (data == null) { + result.error("INVALID_ARGS", "音频数据不能为空", null) + return + } + + if (opusManager?.isDecodeStream == true) { + opusManager?.writeAudioStream(data) + result.success(true) + } else { + result.error("NOT_DECODING", "当前没有处于解码状态", null) + } + } + "isDecoding" -> { + result.success(opusManager?.isDecodeStream == true) + } + "dispose" -> { + opusManager?.let { + if (it.isDecodeStream) { + it.stopDecodeStream() + } + it.release() + } + opusManager = null + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + opusManager?.let { + if (it.isDecodeStream) { + it.stopDecodeStream() + } + it.release() + } + opusManager = null + } +} \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar b/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar new file mode 100644 index 000000000..0ec76c882 Binary files /dev/null and b/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar differ diff --git a/local_plugins/jl_opus/lib/jl_opus.dart b/local_plugins/jl_opus/lib/jl_opus.dart new file mode 100644 index 000000000..dc63a0cd3 --- /dev/null +++ b/local_plugins/jl_opus/lib/jl_opus.dart @@ -0,0 +1,214 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/services.dart'; + +/// OPUS解码参数选项 +class OpusOption { + /// 是否具有协议头 + final bool hasHeader; + + /// 通道数。取值范围:[1, 2] + final int channel; + + /// 采样率。参考值:{8000, 16000, 24000, 32000, 44100, 48000} + final int sampleRate; + + /// 数据包长度。仅hasHeader = false时生效。 + final int packetSize; + + /// 构造函数 + OpusOption({ + this.hasHeader = false, + this.channel = 1, + this.sampleRate = 16000, + this.packetSize = 40, + }); + + /// 转换为参数Map + Map toMap() { + return { + 'hasHeader': hasHeader, + 'channel': channel, + 'sampleRate': sampleRate, + 'packetSize': packetSize, + }; + } +} + +/// 杰理OPUS编解码器事件 +class OpusEvent { + /// 事件类型 + final String event; + + /// 数据类型 (file或stream) + final String? type; + + /// 解码后的数据 (仅在流解码时有效) + final Uint8List? data; + + /// 输出文件路径 (仅在文件解码完成时有效) + final String? filePath; + + /// 错误码 (仅在发生错误时有效) + final int? errorCode; + + /// 错误信息 (仅在发生错误时有效) + final String? errorMessage; + + /// 构造函数 + OpusEvent({ + required this.event, + this.type, + this.data, + this.filePath, + this.errorCode, + this.errorMessage, + }); + + /// 从Map构造对象 + factory OpusEvent.fromMap(Map map) { + return OpusEvent( + event: map['event'], + type: map['type'], + data: map['data'], + filePath: map['filePath'], + errorCode: map['code'], + errorMessage: map['message'], + ); + } +} + +/// 杰理OPUS编解码插件 +class JlOpus { + static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jl_opus'); + static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jl_opus/events'); + + /// 事件流 + Stream? _eventStream; + + /// 获取事件流 + Stream get eventStream { + _eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { + if (event is Map) { + return OpusEvent.fromMap(event); + } + throw PlatformException( + code: 'INVALID_EVENT', + message: '无效的事件数据', + ); + }); + return _eventStream!; + } + + /// 解码后的数据流 + final StreamController _decodedDataController = StreamController.broadcast(); + + /// 获取解码数据流 + Stream get decodedDataStream => _decodedDataController.stream; + + /// 构造函数,设置事件监听 + JlOpus() { + _setupEventListener(); + } + + /// 设置事件监听 + void _setupEventListener() { + eventStream.listen((event) { + if (event.event == 'onDecodeStream' && event.data != null) { + _decodedDataController.add(event.data!); + } + }); + } + + /// 初始化OPUS解码器 + Future initOpusDecoder() async { + try { + final result = await _methodChannel.invokeMethod('initOpusDecoder'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 解码OPUS文件 + /// + /// [inPath] 输入文件路径 + /// [outPath] 输出文件路径 + /// [option] 解码参数 + Future decodeOpusFile(String inPath, String outPath, OpusOption option) async { + try { + final result = await _methodChannel.invokeMethod( + 'decodeOpusFile', + { + 'inPath': inPath, + 'outPath': outPath, + ...option.toMap(), + }, + ); + return result; + } catch (e) { + rethrow; + } + } + + /// 开始解码OPUS数据流 + /// + /// [option] 解码参数 + Future startDecodeStream(OpusOption option) async { + try { + final result = await _methodChannel.invokeMethod( + 'startDecodeStream', + option.toMap(), + ); + return result ?? false; + } catch (e) { + rethrow; + } + } + + /// 停止解码OPUS数据流 + Future stopDecodeStream() async { + try { + final result = await _methodChannel.invokeMethod('stopDecodeStream'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 写入OPUS音频数据进行解码 + /// + /// [data] OPUS音频数据 + Future writeAudioStream(Uint8List data) async { + try { + final result = await _methodChannel.invokeMethod( + 'writeAudioStream', + {'data': data}, + ); + return result ?? false; + } catch (e) { + rethrow; + } + } + + /// 判断是否正在解码 + Future isDecoding() async { + try { + final result = await _methodChannel.invokeMethod('isDecoding'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 释放资源 + Future dispose() async { + try { + _decodedDataController.close(); + final result = await _methodChannel.invokeMethod('dispose'); + return result ?? false; + } catch (e) { + return false; + } + } +} diff --git a/local_plugins/jl_opus/pubspec.yaml b/local_plugins/jl_opus/pubspec.yaml new file mode 100644 index 000000000..3d553c10c --- /dev/null +++ b/local_plugins/jl_opus/pubspec.yaml @@ -0,0 +1,25 @@ +name: jl_opus +description: 杰理OPUS编解码库Flutter插件 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + path_provider: ^2.0.0 + permission_handler: ^11.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.yunqiinnovation.jl_opus + pluginClass: JlOpusPlugin diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt index 2e04578f3..682098255 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt @@ -35,14 +35,14 @@ class MusicService : MediaBrowserServiceCompat() { private const val DELETE_ACTION = "com.yunqiinnovation.music_service.DELETE_NOTIFICATION" private val isRunning = AtomicBoolean(false) - private var currentUrlInternal: String? = null + private var currentIdInternal: Int? = null private var currentStatus = false private var playlist = mutableListOf>() private var currentIndex = 0 fun isServiceRunning() = isRunning.get() fun isPlaying() = currentStatus - fun getCurrentUrl(): String? = currentUrlInternal + fun getCurrentId(): Int? = currentIdInternal // 获取播放进度信息 fun getProgress(): Map { @@ -360,14 +360,15 @@ class MusicService : MediaBrowserServiceCompat() { } private fun play(song: Map) { - val url = song["url"] ?: return + val url = song["url"] as? String ?: return Log.i(TAG, "开始播放: $url") + val id = song["id"] as? Int ?: 0 val title = song["title"] as? String ?: "未知标题" val artist = song["artist"] as? String ?: "未知歌手" val coverUrl = song["coverUrl"] as String mediaPlayer?.release() currentStatus = true - currentUrlInternal = url as String + currentIdInternal = id mediaPlayer = MediaPlayer().apply { setDataSource(url) setAudioStreamType(AudioManager.STREAM_MUSIC) diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt index ba83f5209..74ee11e41 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -63,10 +63,10 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler { } "getStatus" -> { val isPlaying = MusicService.isPlaying() - val currentUrl = MusicService.getCurrentUrl() + val currentId = MusicService.getCurrentId() val status = mapOf( "isPlaying" to isPlaying, - "url" to currentUrl + "id" to currentId ) result.success(status) } diff --git a/local_plugins/music_service/lib/music_service.dart b/local_plugins/music_service/lib/music_service.dart index 27c80b8e9..da5516b43 100644 --- a/local_plugins/music_service/lib/music_service.dart +++ b/local_plugins/music_service/lib/music_service.dart @@ -56,7 +56,7 @@ class MusicService { final result = await _methodChannel.invokeMethod('getStatus'); return { 'isPlaying': result?['isPlaying'] ?? false, - 'url': result?['url'] ?? "", + 'id': result?['id'] ?? 0, }; } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index b186b6a64..b15378c46 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -479,7 +479,7 @@ class MCPSubClient( retryCount++ currentReconnectDelay = minOf(currentReconnectDelay * 2, maxReconnectDelay) - Log.w(TAG, "连接失败,将在 ${currentReconnectDelay}ms 后尝试重连 (尝试 $retryCount/$maxRetryAttempts)") + // Log.w(TAG, "连接失败,将在 ${currentReconnectDelay}ms 后尝试重连 (尝试 $retryCount/$maxRetryAttempts)") delay(currentReconnectDelay) connect() diff --git a/pubspec.yaml b/pubspec.yaml index ccd4d7c49..391b0446e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,9 +93,12 @@ dependencies: path: local_plugins/amap_service location_service: path: local_plugins/location_service + bytedance_speech: + path: local_plugins/bytedance_speech realtime: path: local_plugins/realtime - + jl_opus: + path: local_plugins/jl_opus test: path: local_plugins/test