wolfplus2048 1 year ago
parent
commit
f11e5f32bb
  1. 94
      .cursorrules
  2. 1
      .gitignore
  3. 15
      android/app/proguard-logging.pro
  4. 5
      android/app/src/main/kotlin/com/yunqiinnovation/deep_voice/MainActivity.kt
  5. 60
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt
  6. 2
      android/settings.gradle.kts
  7. 19
      lib/modules/agent/controllers/agent_controller.dart
  8. 6
      lib/modules/agent/views/music_card.dart
  9. 6
      lib/modules/meeting/controllers/meeting_controller.dart
  10. 104
      lib/modules/meeting/views/meeting_view.dart
  11. 86
      lib/modules/meeting/views/share_bottom_sheet.dart
  12. 18
      lib/modules/music/views/music_playlist_view.dart
  13. 9
      lib/modules/opus_test/bindings/opus_test_binding.dart
  14. 462
      lib/modules/opus_test/controllers/opus_test_controller.dart
  15. 518
      lib/modules/opus_test/views/opus_test_view.dart
  16. 38
      lib/modules/pairing/controllers/pairing_controller.dart
  17. 22
      lib/modules/settings/views/settings_view.dart
  18. 21
      lib/modules/translation/views/translation_view.dart
  19. 9
      lib/routes/app_pages.dart
  20. 79
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  21. 2
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt
  22. 33
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  23. 12
      local_plugins/bytedance_speech/lib/bytedance_speech.dart
  24. 250
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt
  25. 3
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt
  26. 5
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPClient.kt
  27. 6
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/MCPSubClient.kt
  28. 748
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  29. 126
      local_plugins/jl_opus/README.md
  30. 62
      local_plugins/jl_opus/android/build.gradle.kts
  31. 1
      local_plugins/jl_opus/android/settings.gradle.kts
  32. 9
      local_plugins/jl_opus/android/src/main/AndroidManifest.xml
  33. 285
      local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt
  34. BIN
      local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar
  35. 214
      local_plugins/jl_opus/lib/jl_opus.dart
  36. 25
      local_plugins/jl_opus/pubspec.yaml
  37. 9
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  38. 4
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt
  39. 2
      local_plugins/music_service/lib/music_service.dart
  40. 2
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt
  41. 5
      pubspec.yaml

94
.cursorrules

@ -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文件管理
- 支持不同环境的配置切换
- 敏感信息不应直接硬编码在代码中

1
.gitignore

@ -14,6 +14,7 @@ doc/api/
# dotenv environment variables file
.env*
.env.example
CLAUDE.md
# Avoid committing generated Javascript files:
*.dart.js

15
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*(...);
}

5
android/app/src/main/kotlin/com/yunqiinnovation/deep_voice/MainActivity.kt

@ -1,5 +0,0 @@
package com.yunqiinnovation.deep_voice
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

60
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) {
// 反射方法可能不存在,忽略
}
}
}

2
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")
@ -53,3 +54,4 @@ project(":agent_service").projectDir = file("../local_plugins/agent_service/andr
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")
project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android")

19
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<MusicManager>();
final _navigationManager = Get.find<NavigationManager>();
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<String, dynamic> metaResult = {};
metaResult = jsonDecode(metaStr) as Map<String, dynamic>;
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();
}

6
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);

6
lib/modules/meeting/controllers/meeting_controller.dart

@ -32,6 +32,7 @@ class MeetingController extends GetxController {
List<String> selected = [];
final appDocDir = getApplicationDocumentsDirectory();
//final appopus = getExternalStorageDirectories();
// final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
List<FileSystemEntity> 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}");

104
lib/modules/meeting/views/meeting_view.dart

@ -439,7 +439,7 @@ class MeetingView extends GetView<MeetingController> {
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<MeetingController> {
},
),
SizedBox(height: 8.w),
_importAudioItem(
_importAudioItem1(
context,
isDarkMode,
icon: Image.asset('assets/images/explore.png',
@ -465,7 +465,7 @@ class MeetingView extends GetView<MeetingController> {
},
),
SizedBox(height: 8.w),
_importAudioItem(
_importAudioItem1(
context,
isDarkMode,
icon: Row(
@ -556,65 +556,64 @@ class MeetingView extends GetView<MeetingController> {
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 {
enabled: false, // 依赖控制器状态
onTap: false
? () async {
// 条件启用
Navigator.of(context).pop();
await Get.toNamed(Routes.meetingRecord, arguments: {
await Get.toNamed(Routes.meetingRecord,
arguments: {
'audioTypes': 1,
});
controller.addMeetingAudio();
// TODO: 实现从相册导入逻辑
},
}
: 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 {
enabled: false, // 依赖控制器状态
onTap: false
? () async {
// 条件启用
Navigator.of(context).pop();
await Get.toNamed(Routes.meetingRecord, arguments: {
await Get.toNamed(Routes.meetingRecord,
arguments: {
'audioTypes': 2,
});
controller.addMeetingAudio();
// TODO: 实现从其他APP导入逻辑
},
}
: null, // 禁用时置空onTap
),
],
),
@ -643,8 +642,60 @@ class MeetingView extends GetView<MeetingController> {
);
}
// 选项框
// 选项框 (增加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<MeetingController> {
),
);
}
// 新增右侧弹窗
void _showRightDrawerDialog(BuildContext context, bool isDarkMode) {

86
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<ShareBottomSheet> {
_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<ShareBottomSheet> {
);
}
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;
}
}

18
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) {
onSeek: isThisPlaying
? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition = (controller.duration.value * seekProgress).round();
final targetPosition =
(controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
} : null,
}
: null,
);
}),
],

9
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>(() => OpusTestController());
}
}

462
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 = <PlatformFile>[].obs;
// 扫描到的外部存储文件
final externalFiles = <PlatformFile>[].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<void> _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<void> requestPermissions() async {
if (Platform.isAndroid) {
await Permission.storage.request();
}
}
// 加载外部存储文件
Future<void> 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<void> 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<void> 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<File> _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<StreamAudioResponse> 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();
}
}

518
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<OpusTestController> {
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<int>(
value: controller.selectedSampleRate.value,
isExpanded: true,
dropdownColor:
isDarkMode ? Colors.grey[800] : Colors.white,
items: controller.sampleRateOptions.map((rate) {
return DropdownMenuItem<int>(
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<bool>(
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<bool>(
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,
),
],
);
}
}

38
lib/modules/pairing/controllers/pairing_controller.dart

@ -122,32 +122,27 @@ class PairingController extends GetxController {
/// 监听蓝牙开关状态
void _setupBluetoothStateListener() {
_workers.add(
ever(_bluetoothManager.bluetoothStateRx, (String state) {
_workers.add(ever(_bluetoothManager.bluetoothStateRx, (String state) {
Logger.d(_tag, '蓝牙状态变化: $state');
_handleBluetoothStateChange(state);
})
);
}));
}
/// 监听经典蓝牙连接状态
void _setupClassicConnectionListener() {
_workers.add(
ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? 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) {
_workers.add(ever(_bleManager.connectionStateRx, (int state) {
Logger.d(_tag, 'BLE连接状态变化: $state');
_handleBleStateChange(state);
})
);
}));
}
// ==================== 状态处理 ====================
@ -209,7 +204,8 @@ 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);
}
@ -371,7 +367,6 @@ class PairingController extends GetxController {
_currentClassicDevice = classicDevice;
_transition(PairingState.verifying);
await _verifyDevice(classicDevice);
} catch (e) {
Logger.e(_tag, '状态检查失败: $e');
_setError(ErrorType.networkError, '检查失败,请重试');
@ -405,7 +400,8 @@ class PairingController extends GetxController {
// 检查设备名称是否包含 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;
@ -413,13 +409,15 @@ class PairingController extends GetxController {
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 {
@ -484,7 +482,8 @@ class PairingController extends GetxController {
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);
@ -493,7 +492,8 @@ class PairingController extends GetxController {
// 注册后台扫描以开始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配对启动失败');
}

22
lib/modules/settings/views/settings_view.dart

@ -543,6 +543,28 @@ class SettingsView extends GetView<SettingsController> {
},
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

21
lib/modules/translation/views/translation_view.dart

@ -24,13 +24,18 @@ class TranslationView extends GetView<TranslationController> {
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
// 在同一个类中添加辅助方法
PopupMenuItem<String> _buildPopupMenuItem(String value, String text) {
PopupMenuItem<String> _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<TranslationController> {
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, // 关键修改:让宽度自适应内容

9
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(),

79
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<String, Boolean>{
fun autoHandleFcunCallResult(toolname:String,functionCallResult: JSONObject): Map<String,Any>{
println("协议工具返回数据 检查是否存在卡片! functionCallResult: ${functionCallResult}")
try {
val contentStr = functionCallResult.optString("context")
@ -1126,13 +1168,18 @@ object AgentService : CoroutineScope {
println("协议工具返回数据 检查是否存在卡片! textStr: ${textStr}")
val metadata: MutableMap<String, Any> = 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);
}
/**

2
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()
// 扫描设置

33
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
}

12
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<String?> getPlatformVersion() async {
final String? version = await _channel.invokeMethod('getPlatformVersion');
return version;
}
}

250
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<Map<String, Any>> = emptyList()
private var toolCalls: MutableMap<Int, ToolCallInfo> = mutableMapOf()
private var sessions: MutableMap<String, StreamCallback?> = mutableMapOf()
private var currSessionId = "" //
// private var isCanceled = false
private var toolCalls: MutableMap<Int, ToolCallInfo> = 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<Map<String, Any>>
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<Map<String, Any>>) {
// 验证是否是当前会话
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 ->
try {
val index = toolCall.index
// 创建或获取现有的工具调用信息
val toolCallInfo = toolCalls.getOrPut(index) { ToolCallInfo() }
// 安全处理工具调用ID
try {
toolCall.id?.let { i ->
toolCallInfo.id = i.toString()
}
} catch (e: Exception) {
Log.e("ChatApiService", "处理工具调用ID异常: ${e.message}")
// 使用安全调用链处理工具调用信息
toolCall.id?.toString()?.takeIf { it.isNotEmpty() }?.let {
toolCallInfo.id = it
}
// 安全处理函数信息
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 (toolCalls.isEmpty()) {
Log.d("ChatApiService", "[Session: $sessionid] 没有工具调用需要处理")
return false
}
// 输出所有收集到的工具调用信息(用于调试)
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)}...")
}
if (firstToolCall == null) {
// 只处理有效的工具调用
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 = try {
mapOf(
"name" to (firstToolCall.name.takeIf { it.isNotEmpty() } ?: ""),
"arguments" to (firstToolCall.arguments.takeIf { it.isNotEmpty() } ?: "{}"),
"id" to (firstToolCall.id.takeIf { it.isNotEmpty() } ?: "")
val functionCall = mapOf(
"name" to firstToolCall.name,
"arguments" to firstToolCall.arguments,
"id" to firstToolCall.id
)
} catch (e: Exception) {
Log.e("ChatApiService", "创建函数调用字典异常: ${e.message}")
return false
}
// 通知上层工具调用事件
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<String, Any>,
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
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<Map<String, Any>>, tool: Boolean = false) {
// 直接调用sendMessageStream,因为该方法已经处理了工具调用
sendMessageStream(sessionid,messages)
fun chatCompletionStream(sessionid: String, messages: List<Map<String, Any>>, tool: Boolean = false) {
// 验证是否是当前会话
if (sessionid == currSessionId) {
sendMessageStream(sessionid, messages)
}
}
// MARK: - 工具方法

3
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)

5
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(

6
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()

748
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, Any>): 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, Any>): 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, Any>): 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 (date == null) {
return "{\"result\": \"时间格式错误\"}"
// 检查日历权限
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)
}
// 创建事件
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 resultTime = when {
format == "short" || format == "time" -> timeStr
format == "full" -> timeStr // full格式已经包含了星期
else -> "$timeStr $weekDay"
}
val currentTime = formatter.format(Date())
"{\"result\": \"$currentTime\", \"time\": \"$currentTime\"}"
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, Any>): 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)
}
// 注意:实际的位置获取需要异步处理,这里只返回提示信息
// 在实际应用中应该使用 LocationCallback 或 Coroutines 来获取实时位置
return "{\"result\": \"需要通过位置服务获取当前位置\", \"success\": true}"
return JSONObject().apply {
put("error", JSONObject().apply {
put("code", "location_disabled")
put("message", "位置服务未启用,请打开GPS或网络定位")
})
}.toString()
}
// 注意:这里简化了实现,实际应该获取真实位置
// 在生产环境中应该使用 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, Any>): 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, Any>): 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, Any>): 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, Any>): 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, Any>): 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, Any>): 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()
}
}
}

126
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<void> 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<void> 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<void> disposePlugin() async {
await jlOpus.dispose();
print('插件资源已释放');
}
```
## 注意事项
1. OPUS解码需要正确设置参数,特别是采样率和通道数必须与原始音频匹配
2. 使用前请确保jl_audio_decode_V2.0.0_20005.aar包已添加到libs目录
3. 本插件仅支持Android平台

62
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")
}

1
local_plugins/jl_opus/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "jl_opus"

9
local_plugins/jl_opus/android/src/main/AndroidManifest.xml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.jl_opus">
<!-- 外部存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
</manifest>

285
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<String, Any?>) {
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<String>("inPath")
val outPath = call.argument<String>("outPath")
val hasHeader = call.argument<Boolean>("hasHeader") ?: false
val channel = call.argument<Int>("channel") ?: 1
val sampleRate = call.argument<Int>("sampleRate") ?: 16000
val packetSize = call.argument<Int>("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<Boolean>("hasHeader") ?: false
val channel = call.argument<Int>("channel") ?: 1
val sampleRate = call.argument<Int>("sampleRate") ?: 16000
val packetSize = call.argument<Int>("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<ByteArray>("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
}
}

BIN
local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar

Binary file not shown.

214
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<String, dynamic> 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<dynamic, dynamic> 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<OpusEvent>? _eventStream;
/// 获取事件流
Stream<OpusEvent> 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<Uint8List> _decodedDataController = StreamController<Uint8List>.broadcast();
/// 获取解码数据流
Stream<Uint8List> get decodedDataStream => _decodedDataController.stream;
/// 构造函数,设置事件监听
JlOpus() {
_setupEventListener();
}
/// 设置事件监听
void _setupEventListener() {
eventStream.listen((event) {
if (event.event == 'onDecodeStream' && event.data != null) {
_decodedDataController.add(event.data!);
}
});
}
/// 初始化OPUS解码器
Future<bool> initOpusDecoder() async {
try {
final result = await _methodChannel.invokeMethod<bool>('initOpusDecoder');
return result ?? false;
} catch (e) {
return false;
}
}
/// 解码OPUS文件
///
/// [inPath] 输入文件路径
/// [outPath] 输出文件路径
/// [option] 解码参数
Future<String?> decodeOpusFile(String inPath, String outPath, OpusOption option) async {
try {
final result = await _methodChannel.invokeMethod<String>(
'decodeOpusFile',
{
'inPath': inPath,
'outPath': outPath,
...option.toMap(),
},
);
return result;
} catch (e) {
rethrow;
}
}
/// 开始解码OPUS数据流
///
/// [option] 解码参数
Future<bool> startDecodeStream(OpusOption option) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'startDecodeStream',
option.toMap(),
);
return result ?? false;
} catch (e) {
rethrow;
}
}
/// 停止解码OPUS数据流
Future<bool> stopDecodeStream() async {
try {
final result = await _methodChannel.invokeMethod<bool>('stopDecodeStream');
return result ?? false;
} catch (e) {
return false;
}
}
/// 写入OPUS音频数据进行解码
///
/// [data] OPUS音频数据
Future<bool> writeAudioStream(Uint8List data) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'writeAudioStream',
{'data': data},
);
return result ?? false;
} catch (e) {
rethrow;
}
}
/// 判断是否正在解码
Future<bool> isDecoding() async {
try {
final result = await _methodChannel.invokeMethod<bool>('isDecoding');
return result ?? false;
} catch (e) {
return false;
}
}
/// 释放资源
Future<bool> dispose() async {
try {
_decodedDataController.close();
final result = await _methodChannel.invokeMethod<bool>('dispose');
return result ?? false;
} catch (e) {
return false;
}
}
}

25
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

9
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<Map<String,Any>>()
private var currentIndex = 0
fun isServiceRunning() = isRunning.get()
fun isPlaying() = currentStatus
fun getCurrentUrl(): String? = currentUrlInternal
fun getCurrentId(): Int? = currentIdInternal
// 获取播放进度信息
fun getProgress(): Map<String, Any> {
@ -360,14 +360,15 @@ class MusicService : MediaBrowserServiceCompat() {
}
private fun play(song: Map<String,Any>) {
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)

4
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)
}

2
local_plugins/music_service/lib/music_service.dart

@ -56,7 +56,7 @@ class MusicService {
final result = await _methodChannel.invokeMethod<Map>('getStatus');
return {
'isPlaying': result?['isPlaying'] ?? false,
'url': result?['url'] ?? "",
'id': result?['id'] ?? 0,
};
}

2
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()

5
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

Loading…
Cancel
Save