wolfplus 1 year ago
parent
commit
6748df7921
  1. 4
      lib/core/bindings/initial_binding.dart
  2. 144
      lib/data/services/account_service.dart
  3. 10
      lib/data/services/asr_service.dart
  4. 3
      lib/data/services/ble_manager.dart
  5. 43
      lib/data/services/chat_history_service.dart
  6. 10
      lib/data/services/db/sqflite_api.dart
  7. 12
      lib/data/services/music_manager.dart
  8. 9
      lib/data/services/network/api.dart
  9. 2
      lib/data/services/network/nw_api.dart
  10. 42
      lib/data/services/speech_impl/azure_asr_service.dart
  11. 16
      lib/data/services/speech_impl/volcano_asr_api_service.dart
  12. 16
      lib/data/services/speech_impl/volcano_asr_service.dart
  13. 16
      lib/data/services/speech_impl/xunfei_asr_service.dart
  14. 89
      lib/modules/agent/controllers/agent_controller.dart
  15. 258
      lib/modules/agent/views/agent_view.dart
  16. 140
      lib/modules/agent/views/circular_progress_button.dart
  17. 3
      lib/modules/agent/views/message_bubble.dart
  18. 52
      lib/modules/agent/views/music_card.dart
  19. 55
      lib/modules/login/controllers/login_controller.dart
  20. 54
      lib/modules/login/views/login_view.dart
  21. 225
      lib/modules/meeting/controllers/meeting_controller.dart
  22. 5
      lib/modules/meeting/controllers/meeting_details_controller.dart
  23. 491
      lib/modules/meeting/controllers/meeting_record_controller.dart
  24. 38
      lib/modules/meeting/views/meeting_details_view.dart
  25. 248
      lib/modules/meeting/views/meeting_record_view.dart
  26. 30
      lib/modules/meeting/views/meeting_view.dart
  27. 12
      lib/modules/meeting/views/share_bottom_sheet.dart
  28. 11
      lib/modules/music/bindings/music_playlist_binding.dart
  29. 19
      lib/modules/music/controllers/music_playlist_controller.dart
  30. 203
      lib/modules/music/views/music_playlist_view.dart
  31. 106
      lib/modules/profile/controllers/profile_controller.dart
  32. 10
      lib/modules/settings/views/settings_view.dart
  33. 10
      lib/modules/translation/bindings/translation_list_binding.dart
  34. 98
      lib/modules/translation/controllers/translation_controller.dart
  35. 184
      lib/modules/translation/controllers/translation_list_controller.dart
  36. 473
      lib/modules/translation/views/translation_list_view.dart
  37. 49
      lib/modules/translation/views/translation_view.dart
  38. 18
      lib/routes/app_pages.dart
  39. 5
      lib/routes/app_routes.dart
  40. 141
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  41. 8
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  42. 3
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  43. 3
      local_plugins/agent_service/lib/agent_service.dart
  44. 8
      local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/AmapServicePlugin.kt
  45. 37
      local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationActivity.kt
  46. 40
      local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt
  47. 232
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  48. 52
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  49. 2
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt
  50. 254
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt
  51. 4
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  52. 67
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt
  53. 165
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt
  54. 53
      local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt
  55. 6
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift
  56. 16
      local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt
  57. 84
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  58. 17
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt
  59. 9
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt
  60. 30
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift
  61. 14
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift
  62. 17
      local_plugins/music_service/lib/music_service.dart
  63. 55
      local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt

4
lib/core/bindings/initial_binding.dart

@ -9,6 +9,7 @@ import '../../data/services/language_manager.dart';
import '../../core/utils/logger.dart';
import '../../data/services/bluetooth_manager.dart';
import '../../data/services/ble_manager.dart';
import '../../data/services/account_service.dart';
/// 初始绑定,用于管理全局依赖
class InitialBinding extends Bindings {
@ -42,6 +43,9 @@ class InitialBinding extends Bindings {
// 火山AI服务
// Get.lazyPut<VolcanoAIService>(() => VolcanoAIService(), fenix: true);
// 账号管理服务
Get.lazyPut<AccountService>(() => AccountService(), fenix: true);
// 杰理蓝牙服务
// // 语言管理器(需要最先初始化)

144
lib/data/services/account_service.dart

@ -0,0 +1,144 @@
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart';
import '../../modules/login/controllers/googleLogin.dart';
import '../../modules/login/controllers/facebookLogin.dart';
import '../services/network/api.dart';
/// 账号管理服务
///
/// 负责处理所有与用户账号相关的操作,包括登出和账号删除
class AccountService extends GetxService {
final GetStorage _storage = GetStorage();
final logger = Logger();
/// 初始化服务
@override
void onInit() {
super.onInit();
logger.d('AccountService 已初始化');
}
/// 清理本地用户数据
///
/// 返回操作是否成功
Future<bool> clearUserData() async {
try {
// 清理所有存储
await _storage.erase();
// 或者逐个清理关键存储项
await _storage.write("logintoken", null);
await _storage.write("user_info", null);
await _storage.write("local_avatar", null);
logger.d('用户数据清理成功');
return true;
} catch (e) {
logger.e('用户数据清理失败: $e');
return false;
}
}
/// 退出第三方登录
///
/// 返回操作是否成功
Future<bool> logoutThirdParty() async {
try {
final google = Google();
final facebook = Facebook();
await Future.wait([
google.signOutGoogle(),
facebook.signOutFacebook(),
]);
logger.d('第三方登录退出成功');
return true;
} catch (e) {
logger.e('第三方登录退出失败: $e');
// 返回true,因为这不应阻止整体登出流程
return true;
}
}
/// 用户登出
///
/// 清理本地数据并退出第三方登录
/// 返回操作结果,包含成功状态和错误信息
Future<AccountOperationResult> signOut() async {
try {
// 清理本地数据
final dataCleaned = await clearUserData();
if (!dataCleaned) {
return AccountOperationResult(
success: false,
errorMessage: '清理用户数据失败',
);
}
// 退出第三方登录
await logoutThirdParty();
return AccountOperationResult(
success: true,
message: '已退出登录',
);
} catch (e) {
logger.e('登出过程发生错误: $e');
return AccountOperationResult(
success: false,
errorMessage: '退出登录失败,请重试',
);
}
}
/// 删除用户账号
///
/// 调用API删除账号,然后清理本地数据
/// 返回操作结果,包含成功状态和错误信息
Future<AccountOperationResult> deleteAccount() async {
try {
// 调用API删除账号
final response = await Api.deleteUser();
if (response == null) {
return AccountOperationResult(
success: false,
errorMessage: '删除用户数据失败',
);
}
// 清理本地数据
final dataCleaned = await clearUserData();
if (!dataCleaned) {
return AccountOperationResult(
success: false,
errorMessage: '清理用户数据失败',
);
}
// 退出第三方登录
await logoutThirdParty();
return AccountOperationResult(
success: true,
message: '账号已被永久删除',
);
} catch (e) {
logger.e('删除账号过程发生错误: $e');
return AccountOperationResult(
success: false,
errorMessage: '删除账号失败,请重试',
);
}
}
}
/// 账号操作结果
class AccountOperationResult {
final bool success;
final String? message;
final String? errorMessage;
AccountOperationResult({
required this.success,
this.message,
this.errorMessage,
});
}

10
lib/data/services/asr_service.dart

@ -30,13 +30,19 @@ abstract class AsrService {
bool isContinuousRecognitionActive();
/// 开始录音
Future<bool> enableRecord(String fileName);
Future<bool> enableRecord(String filePath);
/// 暂停录音
Future<bool> pauseRecord();
// /// 移动文件到新路径
Future<bool> moveFile(String sourcePath, String destPath);
// /// 重命名指定路径的音频文件
Future<bool> renameFile(String filePath, String newName);
/// 结束录音
Future<bool> stopRecord();
Future<bool> stopRecord(bool isSave);
/// 释放资源
Future<void> dispose();

3
lib/data/services/ble_manager.dart

@ -522,6 +522,8 @@ class BleManager extends GetxService {
final openaiBaseUrl =
AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1';
final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo';
final openaiModelMedia = AppConfig.env('OPENAI_API_VISION_MODEL') ??
'doubao-1-5-vision-pro-32k-250115';
final openaiagentid =
AppConfig.env('OPENAI_AGENT_ID') ?? 'personal_assistant';
final systemPrompt = AppConfig.agentsystemPrompt(openaiagentid);
@ -536,6 +538,7 @@ class BleManager extends GetxService {
'openaiApiKey': openaiApiKey,
'openaiBaseUrl': openaiBaseUrl,
'openaiModel': openaiModel,
'openaivisionModel': openaiModelMedia,
'systemPrompt': systemPrompt,
'mcpServer': jsonEncode(jsonData),
'volcanoAppId': '2316081933',

43
lib/data/services/chat_history_service.dart

@ -8,50 +8,50 @@ import 'package:chat_storage/chat_storage.dart';
class ChatHistoryService extends GetxService {
// 最大历史记录数量
static const int _maxHistoryCount = 200; // 每个对话最多保存的消息数
@override
void onInit() {
super.onInit();
Logger.info('ChatHistoryService 已初始化');
}
/// 保存聊天记录
///
///
/// [agentId] 代理ID
/// [messages] 消息列表
Future<void> saveHistory(String agentId, List<Message> messages) async {
try {
if (messages.isEmpty) return;
// 获取最新的消息
final latestMessage = messages.last;
// 如果是加载中的消息则跳过
if (latestMessage.isIntermediate) return;
// 直接插入最新的消息到数据库
await ChatStorage.saveMessage(
sessionId: agentId,
message: latestMessage.content,
sender: latestMessage.role,
);
// 检查并清理过多的历史记录
await _cleanupOldMessages(agentId);
} catch (e) {
Logger.error('保存聊天记录失败', e.toString());
}
}
/// 清理旧消息,保持每个会话的消息数量不超过最大限制
Future<void> _cleanupOldMessages(String agentId) async {
try {
final pagedResult = await ChatStorage.getMessages(
sessionId: agentId,
page: 1,
pageSize: 1, // 只获取总数信息
pageSize: 1, // 只获取总数信息
);
// 如果消息总数超过最大限制,删除最早的消息
if (pagedResult.totalCount > _maxHistoryCount) {
final messagesToDelete = pagedResult.totalCount - _maxHistoryCount;
@ -61,13 +61,13 @@ class ChatHistoryService extends GetxService {
page: 1,
pageSize: messagesToDelete,
);
// 构建要删除的消息ID列表
final List<int> messageIds = oldestMessages.messages
.where((m) => m.id != null)
.map((m) => m.id!)
.toList();
// 删除这些消息
if (messageIds.isNotEmpty) {
await ChatStorage.deleteMessages(messageIds: messageIds);
@ -77,9 +77,9 @@ class ChatHistoryService extends GetxService {
Logger.error('清理旧消息失败', e);
}
}
/// 加载聊天记录
///
///
/// [agentId] 代理ID
/// 返回消息列表,如果没有找到或解析错误则返回空列表
Future<List<Message>> loadHistory(String agentId) async {
@ -89,29 +89,30 @@ class ChatHistoryService extends GetxService {
page: 1,
pageSize: _maxHistoryCount,
);
if (pagedResult.messages.isEmpty) {
return [];
}
// 将ChatStorage.ChatMessage转换为Message
final messages = pagedResult.messages.map((chatMessage) {
return Message(
role: chatMessage.sender,
content: chatMessage.message,
timestamp: DateTime.parse(chatMessage.timestamp), // 直接使用chatMessage的timestamp
timestamp: DateTime.parse(
chatMessage.timestamp), // 直接使用chatMessage的timestamp
// isLoading 默认false
// isLoading: metadata['isLoading'] ?? false,
);
}).toList();
return messages;
} catch (e) {
Logger.error('加载聊天记录失败', e);
return [];
}
}
/// 清除特定代理的聊天记录
Future<void> clearHistory(String agentId) async {
try {
@ -121,7 +122,7 @@ class ChatHistoryService extends GetxService {
Logger.error('清除聊天记录失败', e);
}
}
/// 清除所有聊天记录
Future<void> clearAllHistory() async {
try {
@ -131,4 +132,4 @@ class ChatHistoryService extends GetxService {
Logger.error('清除所有聊天记录失败', e);
}
}
}
}

10
lib/data/services/db/sqflite_api.dart

@ -7,18 +7,18 @@ import 'package:get_storage/get_storage.dart';
import 'database_helper.dart';
class SqfliteApi {
static GetStorage storage = GetStorage();
static String userId = '';
// 保存操作记录
static Future saveMeetingOperationRecord(
String method,
dynamic params,
) async {
if (userId.isEmpty) {
Map userInfo = storage.read("user_info");
GetStorage storage = GetStorage();
String userId = '';
Map? userInfo = storage.read("user_info");
if (userInfo != null) {
userId = userInfo['user']['uid'];
}
final db = await DatabaseHelper().database;
String data = '';
if (method == 'records') {

12
lib/data/services/music_manager.dart

@ -52,7 +52,7 @@ class MusicManager extends GetxService {
}
//播放
Future<void> play(Map<String, String> song) async {
Future<void> play(Map<String, dynamic> song) async {
await _musicService.playMusic(song);
}
@ -70,4 +70,14 @@ class MusicManager extends GetxService {
Future<Map<String, dynamic>> getStatus() async {
return await _musicService.getStatus();
}
//获取播放进度
Future<Map<String, dynamic>> getProgress() async {
return await _musicService.getProgress();
}
//跳转到指定位置
Future<void> seekTo(int positionMs) async {
await _musicService.seekTo(positionMs);
}
}

9
lib/data/services/network/api.dart

@ -98,4 +98,13 @@ class Api {
params: params,
);
}
//获取音乐连接
static getMusicUrl([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/music_getmusicurl',
params: params,
);
}
}

2
lib/data/services/network/nw_api.dart

@ -1,5 +1,5 @@
class NWApi {
// static const baseApi = "https://www.deapsound.com";
static const baseApi = "https://api.deapsound.com";
//static const baseApi = "https://api.ideapsound.com";
// static const baseApi = "https://api.ideapsound.com";
}

42
lib/data/services/speech_impl/azure_asr_service.dart

@ -344,10 +344,10 @@ class AzureAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) async {
Future<bool> enableRecord(String filePath) async {
try {
final bool result = await _channel.invokeMethod('enableRecord', {
'fileName': fileName,
'filePath': filePath,
});
return result;
@ -358,9 +358,11 @@ class AzureAsrService extends GetxService implements AsrService {
}
@override
Future<bool> stopRecord() async {
Future<bool> stopRecord(bool isSave) async {
try {
final bool result = await _channel.invokeMethod('stopRecord');
final bool result = await _channel.invokeMethod('stopRecord', {
'isSave': isSave,
});
return result;
} catch (e) {
@ -380,4 +382,36 @@ class AzureAsrService extends GetxService implements AsrService {
rethrow;
}
}
@override
Future<bool> moveFile(String sourcePath, String destPath) async {
// TODO: implement moveFile
try {
final bool result = await _channel.invokeMethod('moveFile', {
'sourcePath': sourcePath,
'destPath': destPath,
});
return result;
} catch (e) {
Logger.error('开始录音: ${e.toString()}');
rethrow;
}
}
@override
Future<bool> renameFile(String filePath, String newName) async {
// TODO: implement renameFile
try {
final bool result = await _channel.invokeMethod('renameFile', {
'filePath': filePath,
'newName': newName,
});
return result;
} catch (e) {
Logger.error('开始录音: ${e.toString()}');
rethrow;
}
}
}

16
lib/data/services/speech_impl/volcano_asr_api_service.dart

@ -813,13 +813,13 @@ class VolcanoAsrApiService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -829,4 +829,16 @@ class VolcanoAsrApiService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

16
lib/data/services/speech_impl/volcano_asr_service.dart

@ -348,13 +348,13 @@ class VolcanoAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -364,4 +364,16 @@ class VolcanoAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

16
lib/data/services/speech_impl/xunfei_asr_service.dart

@ -272,13 +272,13 @@ class XunfeiAsrService extends GetxService implements AsrService {
}
@override
Future<bool> enableRecord(String fileName) {
Future<bool> enableRecord(String filePath) {
// TODO: implement enableRecord
throw UnimplementedError();
}
@override
Future<bool> stopRecord() {
Future<bool> stopRecord(bool isSave) {
// TODO: implement stopRecord
throw UnimplementedError();
}
@ -288,4 +288,16 @@ class XunfeiAsrService extends GetxService implements AsrService {
// TODO: implement pauseRecord
throw UnimplementedError();
}
@override
Future<bool> moveFile(String sourcePath, String destPath) {
// TODO: implement moveFile
throw UnimplementedError();
}
@override
Future<bool> renameFile(String filePath, String newName) {
// TODO: implement renameFile
throw UnimplementedError();
}
}

89
lib/modules/agent/controllers/agent_controller.dart

@ -2,9 +2,12 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:amap_service/amap_service.dart';
import 'package:deep_voice/core/utils/upload_oss.dart';
import 'package:deep_voice/data/models/user_Info.dart';
import 'package:deep_voice/data/services/location_manager.dart';
import 'package:deep_voice/data/services/music_manager.dart';
import 'package:deep_voice/data/services/navigation_manager.dart';
import 'package:deep_voice/data/services/network/api.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:agent_service/agent_service.dart';
@ -34,6 +37,11 @@ class AgentController extends GetxController {
var isPlaying = false.obs;
var currentUrl = "".obs;
// 播放进度相关状态
var currentPosition = 0.obs; // 当前播放位置(毫秒)
var duration = 0.obs; // 总时长(毫秒)
var progress = 0.0.obs; // 播放进度(0.0-1.0)
// 各种状态标志
final isInitialized = false.obs;
final isListening = false.obs; //是否正在语音识别
@ -61,6 +69,9 @@ class AgentController extends GetxController {
// 导航流事件订阅
StreamSubscription<AmapServiceEvent>? _amapeventSubscription;
// 播放进度更新定时器
Timer? _progressTimer;
// 用于跟踪AI回复状态
bool _isNewAssistantResponse = true;
String _currentResponseId = '';
@ -88,6 +99,8 @@ class AgentController extends GetxController {
textController.dispose();
scrollController.dispose();
_eventSubscription?.cancel();
_amapeventSubscription?.cancel();
_progressTimer?.cancel();
super.onClose();
}
@ -548,7 +561,9 @@ class AgentController extends GetxController {
// 复制图片到应用永久存储目录
final permanentImagePath = await _copyImageToPermanentStorage(imagePath);
var ImageUrl = await UploadOss.upload(
filepath: imagePath, rootDir: 'User/${User.instance.uid}/ChatImages');
Logger.i(TAG, '图片上传: $imagePath URL: $ImageUrl');
// 显示的文本,如果没有提供则使用默认值
final displayText = text?.isNotEmpty == true ? text! : '[图片]';
@ -578,6 +593,7 @@ class AgentController extends GetxController {
// 调用Agent Service处理图片,考虑TTS状态
await AgentService.processImageInput(
permanentImagePath,
ImageUrl,
text: text ?? '',
speakResponse: isTtsEnabled.value,
);
@ -800,21 +816,90 @@ class AgentController extends GetxController {
}
// 播放音乐
void musicPlay(Map<String, String> song) {
void musicPlay(Map<String, dynamic> song) async {
final id = song['id'] ?? "";
final timestampMs = song['ts'] ?? 0;
// 当前时间
final now = DateTime.now();
// 将时间戳转换为 DateTime
final tsTime = DateTime.fromMillisecondsSinceEpoch(timestampMs).add(
const Duration(minutes: 10), // 转换为北京时间
);
// 判断是否过期
if (tsTime.isBefore(now)) {
//获取新的连接
var response = await Api.getMusicUrl({
"ids": [id]
});
// 2. 将响应数据转换为User对象
try {
print('getMusicUrl 响应数据: ${response.toString()}'); // 调试输出原始数据
final urldata = response['urls'][0] as Map<String, dynamic>;
song["url"] = urldata['url'] ?? '';
} catch (e) {
Logger.e(TAG, '转换响应数据失败: $e');
return;
}
}
_musiceManager.play(song);
isPlaying.value = true;
currentUrl.value = song['url'] ?? '';
_startProgressTimer();
}
// 播放音乐
void musicPause() {
_musiceManager.pause();
isPlaying.value = false;
_stopProgressTimer();
}
void musicResume() {
_musiceManager.resume();
isPlaying.value = true;
_startProgressTimer();
}
// 启动进度更新定时器
void _startProgressTimer() {
_stopProgressTimer(); // 先停止现有定时器
_progressTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (isPlaying.value) {
updateMusicProgress();
} else {
timer.cancel();
}
});
}
// 停止进度更新定时器
void _stopProgressTimer() {
_progressTimer?.cancel();
_progressTimer = null;
}
// 获取播放进度
Future<void> updateMusicProgress() async {
try {
var progressInfo = await _musiceManager.getProgress();
currentPosition.value = progressInfo['currentPosition'] ?? 0;
duration.value = progressInfo['duration'] ?? 0;
progress.value = progressInfo['progress'] ?? 0.0;
} catch (e) {
Logger.e('AgentController', '获取播放进度失败: $e');
}
}
// 跳转到指定位置
Future<void> seekToPosition(int positionMs) async {
try {
await _musiceManager.seekTo(positionMs);
// 立即更新进度状态
await updateMusicProgress();
} catch (e) {
Logger.e('AgentController', '跳转播放位置失败: $e');
}
}
// 修改自动停止处理,遵循TTS设置

258
lib/modules/agent/views/agent_view.dart

@ -168,34 +168,22 @@ class AgentView extends StatelessWidget {
final message = controller
.messages[controller.messages.length - 1 - index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: !message.isUser
? GestureDetector(
onLongPressStart:
(LongPressStartDetails details) {
_onLongPressMessageContent(
context, isDarkMode, details, message);
},
child: MessageBubble(
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
),
)
: MessageBubble(
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
),
);
padding: const EdgeInsets.only(bottom: 8),
child: GestureDetector(
onLongPressStart: (LongPressStartDetails details) {
_onLongPressMessageContent(
context, isDarkMode, details, message);
},
child: MessageBubble(
isUser: message.isUser,
message: message.text,
timestamp: message.timestamp,
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
meta: message.meta,
),
));
},
),
),
@ -384,7 +372,16 @@ class AgentView extends StatelessWidget {
color: Colors.grey[500],
fontSize: 15,
),
filled: false,
border: InputBorder.none,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey[200]!,
width: 1.0,
),
),
contentPadding: EdgeInsets.symmetric(
vertical: isImageActive ? 12 : 0),
),
@ -593,164 +590,36 @@ class AgentView extends StatelessWidget {
));
},
),
// 自定义分隔线样式
PopupMenuItem(
height: 1, // 控制间距
enabled: false,
child: Divider(
height: 1,
thickness: 0.8,
indent: 0,
endIndent: 0,
color: isDarkMode ? Colors.grey[700] : Colors.grey[300],
if (!message.isUser) ...[
// 自定义分隔线样式
PopupMenuItem(
height: 1, // 控制间距
enabled: false,
child: Divider(
height: 1,
thickness: 0.8,
indent: 0,
endIndent: 0,
color: isDarkMode ? Colors.grey[700] : Colors.grey[300],
),
),
),
_popupMenuEntry(
Icon(
Icons.report,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
_popupMenuEntry(
Icon(
Icons.report,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
),
'举报'.tr, // 举报
isDarkMode ? Colors.red[700]! : Colors.red,
() {
// 实现举报功能
final reportController = Get.find<ReportController>();
reportController.initReport(message);
_showReportForm(context, isDarkMode);
},
),
'举报'.tr, // 举报
isDarkMode ? Colors.red[700]! : Colors.red,
() {
// 实现举报功能
final reportController = Get.find<ReportController>();
reportController.initReport(message);
_showReportForm(context, isDarkMode);
// showModalBottomSheet(
// context: context,
// isScrollControlled: true, // 关键:允许弹窗随键盘移动
// backgroundColor:
// isDarkMode ? AppColors.cardBackground : Colors.white,
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.vertical(top: Radius.circular(16.r)),
// ),
// builder: (BuildContext context) {
// return Padding(
// // 添加底部内边距以容纳键盘
// padding: EdgeInsets.only(
// bottom: MediaQuery.of(context).viewInsets.bottom,
// ),
// child: Container(
// padding: EdgeInsets.all(16.w),
// child: SingleChildScrollView(
// // 添加滚动视图
// child: Column(
// mainAxisSize: MainAxisSize.min,
// crossAxisAlignment: CrossAxisAlignment.stretch,
// children: [
// Stack(
// alignment: Alignment.center,
// children: [
// Center(
// child: Text(
// '举报',
// style: TextStyle(
// fontSize: 18.sp,
// fontWeight: FontWeight.bold,
// color: isDarkMode
// ? Colors.white
// : Colors.black,
// ),
// ),
// ),
// SizedBox(
// height: 20.w,
// ),
// Positioned(
// right: 0,
// child: IconButton(
// icon: Icon(
// Icons.close,
// color: isDarkMode
// ? Colors.grey[400]
// : Colors.black54,
// ),
// onPressed: () => Navigator.pop(context),
// ),
// ),
// ],
// ),
// Container(
// padding: EdgeInsets.symmetric(horizontal: 12.w),
// margin: EdgeInsets.only(bottom: 10.w),
// child: Divider(
// height: 1,
// color: Colors.grey[300],
// ),
// ),
// Wrap(
// spacing: 8.w,
// runSpacing: 8.h,
// children: [
// _buildReportChip('有害/不安全', isDarkMode),
// _buildReportChip('色情低俗', isDarkMode),
// _buildReportChip('误导攻击', isDarkMode),
// _buildReportChip('违法犯罪', isDarkMode),
// _buildReportChip('隐私相关', isDarkMode),
// _buildReportChip('侵犯我的著作权、名誉权等', isDarkMode),
// _buildReportChip('其他', isDarkMode),
// ],
// ),
// SizedBox(height: 16.h),
// Container(
// padding: EdgeInsets.all(12.w),
// decoration: BoxDecoration(
// color: isDarkMode
// ? Colors.grey[800]
// : Colors.grey[200],
// borderRadius: BorderRadius.circular(8.r),
// ),
// child: TextField(
// maxLines: 3,
// style: TextStyle(
// color: isDarkMode ? Colors.white : Colors.black,
// ),
// decoration: InputDecoration(
// hintText: '我们想知道你举报的原因,你可以描述你遇到的问题。',
// hintStyle: TextStyle(
// color: isDarkMode
// ? Colors.white70
// : Colors.black54,
// fontSize: 14.sp,
// ),
// border: InputBorder.none,
// ),
// ),
// ),
// SizedBox(height: 14.h),
// ElevatedButton(
// style: ElevatedButton.styleFrom(
// backgroundColor: Colors.blue,
// foregroundColor: Colors.white,
// padding: EdgeInsets.symmetric(vertical: 12.h),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8.r),
// ),
// ),
// child: Text('提交'),
// onPressed: () {
// Navigator.pop(context);
// Get.snackbar(
// '提示',
// '举报已提交,我们会尽快处理',
// backgroundColor: Colors.green.withOpacity(0.8),
// colorText: Colors.white,
// );
// },
// ),
// SizedBox(height: 8.h),
// ],
// ),
// ),
// ),
// );
// },
// );
},
),
]
],
);
}
@ -775,27 +644,6 @@ class AgentView extends StatelessWidget {
);
}
// // 添加这个辅助方法来创建举报选项
// Widget _buildReportChip(String label, bool isDarkMode) {
// return ChoiceChip(
// label: Text(
// label,
// style: TextStyle(
// color: isDarkMode ? Colors.white : Colors.black87,
// fontSize: 14.sp,
// ),
// ),
// selected: false,
// backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[200],
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(16.r),
// ),
// onSelected: (bool selected) {
// // 可以在这里处理选择逻辑
// },
// );
// }
PopupMenuEntry _popupMenuEntry(
Icon icon,
String title,

140
lib/modules/agent/views/circular_progress_button.dart

@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// 环形进度条播放按钮组件
class CircularProgressButton extends StatelessWidget {
final bool isPlaying;
final double progress; // 0.0 - 1.0
final VoidCallback onPlayPause;
final Function(double)? onSeek; // 点击进度条跳转回调
final double size;
final Color primaryColor;
final Color backgroundColor;
final double strokeWidth;
const CircularProgressButton({
Key? key,
required this.isPlaying,
required this.progress,
required this.onPlayPause,
this.onSeek,
this.size = 36.0,
required this.primaryColor,
this.backgroundColor = Colors.grey,
this.strokeWidth = 2.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (details) {
if (onSeek != null) {
// 计算点击位置对应的进度
final center = Offset(size / 2, size / 2);
final tapPosition = details.localPosition;
final dx = tapPosition.dx - center.dx;
final dy = tapPosition.dy - center.dy;
// 计算角度 (从顶部开始,顺时针)
double angle = math.atan2(dy, dx) + math.pi / 2;
if (angle < 0) angle += 2 * math.pi;
// 转换为进度值 (0.0 - 1.0)
final seekProgress = angle / (2 * math.pi);
// 检查点击是否在圆环区域内
final distance = math.sqrt(dx * dx + dy * dy);
final innerRadius = (size / 2) - strokeWidth - 4;
final outerRadius = (size / 2) + 4;
if (distance >= innerRadius && distance <= outerRadius) {
onSeek!(seekProgress);
} else if (distance < innerRadius) {
// 点击中心区域,播放/暂停
onPlayPause();
}
} else {
onPlayPause();
}
},
child: SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// 背景圆环
CustomPaint(
size: Size(size, size),
painter: _CircularProgressPainter(
progress: 1.0,
color: backgroundColor.withValues(alpha: 0.3),
strokeWidth: strokeWidth,
),
),
// 进度圆环
CustomPaint(
size: Size(size, size),
painter: _CircularProgressPainter(
progress: progress,
color: primaryColor,
strokeWidth: strokeWidth,
),
),
// 播放/暂停按钮
Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: primaryColor,
size: size * 0.5,
),
],
),
),
);
}
}
/// 环形进度条绘制器
class _CircularProgressPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
_CircularProgressPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width / 2) - strokeWidth / 2;
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
// 绘制圆弧,从顶部开始 (-π/2),顺时针绘制
const startAngle = -math.pi / 2;
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
sweepAngle,
false,
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _CircularProgressPainter ||
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}

3
lib/modules/agent/views/message_bubble.dart

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:ffi';
import 'package:common_utils/common_utils.dart';
import 'package:deep_voice/modules/agent/views/navigation_card.dart';
@ -233,7 +234,7 @@ class MessageBubble extends GetView<AgentController> {
if (meta!.keys.contains('music_play')) {
final card_music =
meta!["music_play"]['card_music'] as Map<String, dynamic>;
final musicId = card_music!['id'].toString();
final musicId = card_music!['id'];
final musicName = card_music!['name'] as String;
final musicUrl = card_music!['url'] as String;
final musicSinger = card_music!['sgener'] as String;

52
lib/modules/agent/views/music_card.dart

@ -1,14 +1,15 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/agent_controller.dart';
import 'circular_progress_button.dart';
/// 构建音乐项目
Widget buildMusicItem(BuildContext context, String id, String name, String url,
Widget buildMusicItem(BuildContext context, int id, String name, String url,
String singer, String image) {
final controller = Get.find<AgentController>(); // 获取控制器
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
Map<String, String> song = {
Map<String, dynamic> song = {
'id': id,
'url': url,
'title': name,
@ -19,11 +20,11 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDarkMode ? Color(0xFF1E1E1E) : Colors.white,
color: isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.2 : 0.03),
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
@ -83,8 +84,18 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
final isThisPlaying =
controller.isPlaying.value && controller.currentUrl.value == url;
return IconButton(
onPressed: () {
// 只有当前播放的歌曲才显示进度
final currentProgress =
isThisPlaying ? controller.progress.value : 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 36.0,
strokeWidth: 2.0,
onPlayPause: () {
if (controller.isPlaying.value) {
if (controller.currentUrl.value == url) {
controller.musicPause();
@ -99,13 +110,14 @@ Widget buildMusicItem(BuildContext context, String id, String name, String url,
}
}
},
icon: Icon(
isThisPlaying ? Icons.pause_circle : Icons.play_circle,
color: Theme.of(context).primaryColor,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
iconSize: 36,
onSeek: isThisPlaying
? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition =
(controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
}
: null,
);
}),
],
@ -124,7 +136,8 @@ Widget buildMusicContent(
Text(
'以下是一些推荐的音乐:',
style: TextStyle(
color: isDarkMode ? Colors.white.withOpacity(0.9) : Colors.black87,
color:
isDarkMode ? Colors.white.withValues(alpha: 0.9) : Colors.black87,
fontSize: 14,
height: 1.4,
fontWeight: FontWeight.w400,
@ -154,6 +167,7 @@ Widget buildMusicContent(
Icon(
Icons.music_note,
size: 16,
// ignore: deprecated_member_use
color: Theme.of(context).primaryColor.withOpacity(0.7),
),
const SizedBox(width: 6),
@ -175,7 +189,7 @@ Widget buildMusicContent(
...playlist.asMap().entries.take(4).map((entry) {
final index = entry.key;
final item = entry.value;
final id = item["id"]?.toString() ?? "";
final id = item["id"] ?? 0;
final name = item["name"] ?? "未知歌曲";
final url = item["url"] ?? "";
final singer = item["sgener"] ?? "未知歌手";
@ -197,10 +211,14 @@ Widget buildMusicContent(
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {},
onPressed: () {
// 跳转到完整歌单页面,传递完整的歌单数据
Get.toNamed('/music/playlist', arguments: playlist);
},
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(

55
lib/modules/login/controllers/login_controller.dart

@ -201,6 +201,9 @@ class LoginController extends GetxController {
// 显示隐私政策弹窗
void _showPrivacyDialog() {
// 获取当前主题模式
final bool isDarkMode = Get.theme.brightness == Brightness.dark;
Get.dialog(
WillPopScope(
onWillPop: () async => false, // 禁止通过返回键关闭弹窗
@ -208,16 +211,20 @@ class LoginController extends GetxController {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor:
isDarkMode ? Colors.grey[800] : Colors.white, // 暗黑模式使用深灰色背景
child: Container(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
Text(
'DeapSound 声明',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color:
isDarkMode ? Colors.white : Colors.black, // 暗黑模式使用白色文字
),
),
const SizedBox(height: 16),
@ -226,9 +233,14 @@ class LoginController extends GetxController {
child: Text.rich(
TextSpan(
children: [
const TextSpan(
TextSpan(
text: '欢迎使用DeapSound,请充分阅读',
style: TextStyle(fontSize: 14),
style: TextStyle(
fontSize: 14,
color: isDarkMode
? Colors.white70
: Colors.black87, // 暗黑模式使用浅白色
),
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -244,18 +256,21 @@ class LoginController extends GetxController {
title: 'privacyPolicy'.tr,
));
},
child: Text(
child: const Text(
'《隐私政策》',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF3B82F6),
color: Color(0xFF3B82F6),
),
),
),
),
const TextSpan(
TextSpan(
text: '和',
style: TextStyle(fontSize: 14),
style: TextStyle(
fontSize: 14,
color: isDarkMode ? Colors.white70 : Colors.black87,
),
),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -271,18 +286,21 @@ class LoginController extends GetxController {
title: 'userAgreement'.tr,
));
},
child: Text(
child: const Text(
'《用户协议》',
style: TextStyle(
fontSize: 14,
color: const Color(0xFF3B82F6),
color: Color(0xFF3B82F6),
),
),
),
),
const TextSpan(
TextSpan(
text: ',点击“同意并进入”表示您已充分阅读并同意上述协议。点击“不同意”将退出应用。',
style: TextStyle(fontSize: 14),
style: TextStyle(
fontSize: 14,
color: isDarkMode ? Colors.white70 : Colors.black87,
),
),
],
),
@ -299,9 +317,17 @@ class LoginController extends GetxController {
exit(0);
},
style: TextButton.styleFrom(
foregroundColor: Colors.black54,
foregroundColor: isDarkMode
? Colors.grey[400]
: Colors.black54, // 暗黑模式使用浅灰色
),
child: Text(
'不同意',
style: TextStyle(
color: isDarkMode ? Colors.grey[400] : Colors.black54,
),
),
child: const Text('不同意'),
//child: const Text('不同意'),
),
ElevatedButton(
onPressed: () {
@ -317,6 +343,9 @@ class LoginController extends GetxController {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: isDarkMode ? 4 : 2, // 暗黑模式下增加阴影效果提高辨识度
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10), // 统一的内边距
),
child: const Text('同意并进入'),
),

54
lib/modules/login/views/login_view.dart

@ -177,9 +177,23 @@ class LoginView extends GetView<LoginController> {
return Container(
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[900]!.withOpacity(0.4)
? Colors.grey[850]!.withOpacity(0.6) // 更深的背景色,增加不透明度
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(
color:
isDarkMode ? Colors.grey[700]! : Colors.transparent, // 暗黑模式添加边框
width: 1,
),
boxShadow: isDarkMode
? [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [], // 暗黑模式添加阴影
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 4.h),
@ -213,32 +227,27 @@ class LoginView extends GetView<LoginController> {
Container(
height: 20.h,
width: 1,
color: isDarkMode ? Colors.grey[800] : Colors.grey[300],
color:
isDarkMode ? Colors.grey[600] : Colors.grey[300], // 调整分隔线颜色
margin: EdgeInsets.symmetric(horizontal: 8.w),
),
// Icon(
// Icons.person_outline,
// color: isDarkMode ? Colors.grey[400] : Colors.black54,
// size: 20.sp,
// ),
//SizedBox(width: 12.w),
Expanded(
child: TextField(
controller: controller.contactController,
// keyboardType: controller.selectedCountryFlag == '✉️'
// ? TextInputType.emailAddress
// : TextInputType.phone,
decoration: InputDecoration(
hintText: controller.selectedCountryFlag == '✉️'
? 'email'.tr // 需要在翻译文件中添加
: 'phoneNumber'.tr, // 需要在翻译文件中添加
hintStyle: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[600] : Colors.black38,
color: isDarkMode
? Colors.grey[500]
: Colors.black38, // 调整提示文字颜色
),
border: InputBorder.none,
fillColor: Colors.transparent,
filled: true,
focusedBorder: InputBorder.none,
),
style: TextStyle(
fontSize: 14.sp,
@ -322,9 +331,23 @@ class LoginView extends GetView<LoginController> {
return Container(
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[900]!.withOpacity(0.4)
? Colors.grey[850]!.withOpacity(0.6) // 更深的背景色,增加不透明度
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(16.r),
border: Border.all(
color:
isDarkMode ? Colors.grey[700]! : Colors.transparent, // 暗黑模式添加边框
width: 1,
),
boxShadow: isDarkMode
? [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: [], // 暗黑模式添加阴影
),
child: Padding(
padding: EdgeInsets.only(left: 16.w, right: 4.w, top: 4.h, bottom: 4.h),
@ -345,12 +368,15 @@ class LoginView extends GetView<LoginController> {
hintText: 'verificationCode'.tr, // 验证码
hintStyle: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[600] : Colors.black38,
color: isDarkMode
? Colors.grey[500]
: Colors.black38, // 调整提示文字颜色
),
border: InputBorder.none,
counterText: '',
fillColor: Colors.transparent,
filled: true,
focusedBorder: InputBorder.none,
),
style: TextStyle(
fontSize: 14.sp,

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

@ -7,6 +7,7 @@ import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:just_audio/just_audio.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
@ -17,6 +18,8 @@ import '../../../core/utils/permission_util.dart';
import 'package:share_plus/share_plus.dart';
import '../../../routes/app_routes.dart';
class MeetingController extends GetxController {
final ScrollController listController = ScrollController();
final AudioPlayer _audioPlayer = AudioPlayer();
@ -28,7 +31,7 @@ class MeetingController extends GetxController {
int addQuantity = 0;
List<String> selected = [];
final appDocDir = getApplicationSupportDirectory();
final appDocDir = getApplicationDocumentsDirectory();
// final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
List<FileSystemEntity> files = [];
@ -38,10 +41,11 @@ class MeetingController extends GetxController {
List dataListTask = [];
bool isDisposed = false;
String filePath = '';
final String _appid = '2316081933';
final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En';
final String _cluster = 'volc_auc_meeting_flash';
final String _cluster = 'volc_auc_common_flash';
final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc';
Map<String, String> _headers = {};
@ -49,13 +53,23 @@ class MeetingController extends GetxController {
void onInit() {
super.onInit();
listController.addListener(_onScroll);
//_requestStoragePermission();
_requestStoragePermission();
//Permission.storage.request();
_headers = {
'Authorization': 'Bearer; $_token',
'Content-Type': 'application/json',
};
_getDataList();
addMeetingAudio();
final Map<String, dynamic> args = Get.arguments ?? {};
// 检查参数是否存在且包含有效路径
if (args != null &&
args.containsKey('filePath') &&
args['filePath'] is String &&
args['filePath'].isNotEmpty) {
final String path = args['filePath'];
addAudio(path);
}
}
@override
@ -92,7 +106,7 @@ class MeetingController extends GetxController {
isLoading = false;
}
// 请求录音权限
// 请求存储权限
void _requestStoragePermission() async {
// final BuildContext context = Get.overlayContext!;
@ -127,22 +141,34 @@ class MeetingController extends GetxController {
}
void loadTranslatAudio() async {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
try {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
// Create directory if it doesn't exist
if (!await dir.exists()) {
await dir.create(recursive: true); // Create directory tree if needed
files = []; // No files exist in new directory
return;
}
files = dir
.listSync()
.where((f) =>
f is File &&
(f.path.endsWith('.mp3') ||
f.path.endsWith('.wav') ||
f.path.endsWith('.aac') ||
f.path.endsWith('.m4a') ||
f.path.endsWith('.mp4') ||
f.path.endsWith('.mov')))
.toList();
// Directory exis
files = dir
.listSync()
.where((f) =>
f is File &&
(f.path.endsWith('.mp3') ||
f.path.endsWith('.wav') ||
f.path.endsWith('.aac') ||
f.path.endsWith('.m4a') ||
f.path.endsWith('.mp4') ||
f.path.endsWith('.mov')))
.toList();
} catch (e) {
print("Error loading files: $e");
files = []; // Return empty list on error
}
}
// 导入音频文件
@ -166,6 +192,93 @@ class MeetingController extends GetxController {
}
}
void addMeetingAudio() async {
EasyLoading.show(status: '正在导入本地录音...');
try {
final appDir = await appDocDir;
final sourceDir = Directory("${appDir.path}/MeetingAudio/");
// 检查源目录是否存在
if (!await sourceDir.exists()) {
EasyLoading.dismiss();
return;
}
// 创建目标目录(如果不存在)
final destDir = Directory("${appDir.path}/LocalAudio/");
if (!await destDir.exists()) {
await destDir.create(recursive: true);
}
// 获取目录下所有文件
final files =
await sourceDir.list().where((entity) => entity is File).toList();
int addQuantity = 0;
for (final fileEntity in files) {
try {
final file = File(fileEntity.path);
final filePath = file.path;
// 设置音频源获取时长
await _audioPlayer.setAudioSource(AudioSource.file(filePath));
print(
"_audioPlayer$_audioPlayer,filePath=$filePath,_audioPlayer.duration?.inSeconds${_audioPlayer.duration?.inSeconds}");
int seconds = _audioPlayer.duration?.inSeconds ?? 0;
// 获取文件修改时间
final fileStat = await file.stat();
final creationTime = fileStat.changed.millisecondsSinceEpoch;
// 上传OSS
final audiourl =
await UploadOss.upload(filepath: filePath, rootDir: 'LocalAudio');
// 准备新路径
final fileName = filePath.split('/').last;
final newPath = "${destDir.path}/$fileName";
// 构建数据
Map<String, Object?> data = {
'title': fileName,
'type': 'LOCAL',
'seconds': seconds,
'filepath': newPath, // 存储新路径
'audiourl': audiourl,
'tasktype': 0,
'creationtime': creationTime,
};
print("导入数据: $data");
// 插入数据库
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
// 更新UI数据
dataList.insert(0, {
'id': meetingId,
'taskid': '',
...data,
});
// 移动文件
await file.copy(newPath);
await file.delete();
addQuantity++;
}
} catch (e) {
print("文件处理失败: ${fileEntity.path}, 错误: $e");
}
}
if (addQuantity > 0) EasyLoading.showSuccess('成功导入本地 $addQuantity 条录音');
} catch (e) {
EasyLoading.showError('导入失败: $e');
} finally {
EasyLoading.dismiss();
}
}
void addTranslatAudio() async {
EasyLoading.show(status: '正在批量导入...');
for (final filePath in selected) {
@ -205,39 +318,53 @@ class MeetingController extends GetxController {
EasyLoading.dismiss();
}
void addAudio() async {
void addAudio([String? filePath]) async {
EasyLoading.show(status: '正在导入...');
FilePickerResult? result = await FilePicker.platform.pickFiles(
withData: true,
);
if (result != null) {
PlatformFile file = result.files.first;
String filePath = await _saveFile(file);
String fileName = file.name;
await _audioPlayer.setAudioSource(AudioSource.file(filePath));
var audiourl =
await UploadOss.upload(filepath: filePath, rootDir: 'ExternalAudio');
int seconds = _audioPlayer.duration?.inSeconds ?? 0;
int creationTime = DateTime.now().millisecondsSinceEpoch;
Map<String, Object?> data = {
'title': fileName,
'type': 'IMPORT',
'seconds': seconds,
'filepath': filePath,
'audiourl': audiourl,
'tasktype': 0,
'creationtime': creationTime,
};
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
dataList.insert(0, {
'id': meetingId,
'taskid': '',
...data,
});
addQuantity++;
String fileName = "";
bool isParamProvided = filePath != null; // 更清晰的变量名
if (filePath != null) {
// 正确截取文件名(跨平台安全方式)
fileName = path.basename(filePath);
} else {
FilePickerResult? result = await FilePicker.platform.pickFiles(
withData: true,
);
if (result != null) {
PlatformFile file = result.files.first;
filePath = await _saveFile(file);
fileName = file.name;
}
}
await _audioPlayer.setAudioSource(AudioSource.file(filePath!));
var audiourl =
await UploadOss.upload(filepath: filePath, rootDir: 'ExternalAudio');
int seconds = _audioPlayer.duration?.inSeconds ?? 0;
int creationTime = DateTime.now().millisecondsSinceEpoch;
Map<String, Object?> data = {
'title': fileName,
'type': 'IMPORT',
'seconds': seconds,
'filepath': filePath,
'audiourl': audiourl,
'tasktype': 0,
'creationtime': creationTime,
};
final meetingId = await SqfliteApi.insertMeeting(data);
if (meetingId != null) {
dataList.insert(0, {
'id': meetingId,
'taskid': '',
...data,
});
addQuantity++;
}
if (isParamProvided) {
Get.toNamed(Routes.meetingDetails, arguments: {
'id': meetingId,
});
}
EasyLoading.dismiss();
}

5
lib/modules/meeting/controllers/meeting_details_controller.dart

@ -57,7 +57,7 @@ class MeetingDetailsController extends GetxController
final String _appid = '2316081933';
final String _token = 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En';
final String _cluster = 'volc_auc_meeting_flash';
final String _cluster = 'volc_auc_common_flash';
final String _serviceUrl = 'https://openspeech.bytedance.com/api/v1/auc';
Map<String, String> _headers = {};
@ -145,7 +145,8 @@ class MeetingDetailsController extends GetxController
}
await playerController.preparePlayer(
path: meetingData.value.filepath,
noOfSamples: 1200,
shouldExtractWaveform: false,
noOfSamples: 88,
);
durationMax.value = await playerController.getDuration(DurationType.max);
playerController.setFinishMode(finishMode: FinishMode.pause);

491
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -1,254 +1,419 @@
// meeting_record_controller.dart
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:deep_voice/core/utils/logger.dart';
import 'package:deep_voice/core/utils/permission_util.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
// 新增计时器状态枚举
/// Enumerates the possible states of the recording timer
enum TimerStatus {
stopped, // 已停止/未开始
running, // 运行中
paused, // 已暂停
stopped, // Timer is inactive
running, // Timer is actively counting
paused, // Timer is paused
}
/// 控制器:处理录音状态、波形、文本转录等逻辑
/// Controller for meeting recording screen
/// Handles recording state, audio processing, transcription, and UI interactions
class MeetingRecordController extends GetxController
with SingleGetTickerProviderMixin {
//语言服务
// Services
final AsrService _asrService = Get.find<AsrService>();
//语言服务监听
final BleManager _bleManager = Get.find<BleManager>();
// Stream subscriptions
StreamSubscription? _recognitionSubscription;
//是否开启识别
// Recording state
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs;
final RxInt seconds = 0.obs;
final RxInt audioType = 0.obs; // 0=Live, 1=Media, 2=Call
final RxBool isMarked = false.obs;
final RxBool showTextContent = false.obs;
final RxBool isVoiceRecognitionActive = false.obs;
// 音频输入源
bool _audioSourceType = false;
final RxList<double> waveData = <double>[].obs;
//识别文本
RxString intermediateContent = ''.obs;
RxString finalContent = ''.obs;
// final RxBool isRecording = false.obs; // 是否正在录音
// 蓝牙服务
final _bleManager = Get.find<BleManager>();
final RxInt seconds = 0.obs; // 录音秒数
final RxInt audioTypes = 0.obs; // 音频类型
final RxBool isMarked = false.obs; // 是否标记重点
final RxList<double> waveData = <double>[].obs; // 波形图数据
RxBool showTextContent = false.obs; // 控制文本显示
late TabController tabController;
Timer? _timer;
int _currentPosition = 0; // 当前播放位置(用于波形图动画)
// Transcription content
final RxString intermediateContent = ''.obs;
final RxString finalContent = ''.obs;
// 新增计时器状态变量
final Rx<TimerStatus> timerStatus = TimerStatus.stopped.obs;
int _elapsedMilliseconds = 0; // 累计毫秒数
int? _lastStartTime; // 上次开始计时的时间戳
// File management
final RxString fileName = "初始文件名".obs;
final RxString newName = "".obs;
final RxBool isEditingTitle = false.obs;
late TextEditingController titleEditingController;
late FocusNode titleFocusNode;
final Future<Directory> appDocDir = getApplicationDocumentsDirectory();
// Timing variables
Timer? _timer;
int _elapsedMilliseconds = 0;
int? _lastStartTime;
int _currentPosition = 0;
bool _audioSourceType = false;
late TabController tabController;
@override
void onInit() {
super.onInit();
_asrService.initialize();
tabController = TabController(length: 2, vsync: this);
_initializeComponents();
_setupFromArguments();
generateWaveData();
// 移除初始化的 startTimer() 调用
}
@override
void onClose() {
_timer?.cancel();
_asrService.dispose();
tabController.dispose();
_cleanupResources();
super.onClose();
}
/// 启动/继续计时器
void startTimer() {
if (timerStatus.value != TimerStatus.running) {
timerStatus.value = TimerStatus.running;
_lastStartTime = DateTime.now().millisecondsSinceEpoch;
_timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
if (timerStatus.value == TimerStatus.running) {
// 计算真实经过的时间(毫秒)
final currentTime = DateTime.now().millisecondsSinceEpoch;
_elapsedMilliseconds += currentTime - _lastStartTime!;
_lastStartTime = currentTime;
// 请求录音权限
Future<bool> _requestRecordPermission() async {
try {
final bool granted = await PermissionUtil.instance.requestPermission(
permissionType: Permission.microphone,
permissionName: "录音",
explanationText: "我们需要访问麦克风权限,以便您录制音频内容。",
permanentDenialText: "检测到您已永久拒绝录音权限。如需继续使用此功能,请前往应用设置中手动开启麦克风权限。",
);
// 转换为秒(四舍五入)
seconds.value = (_elapsedMilliseconds / 1000).round();
update();
}
});
if (granted) {
Logger.d("Permission", "用户授予了录音权限");
return true;
} else {
Logger.d("Permission", "用户未授予录音权限");
return false;
}
} catch (e) {
Logger.e("Permission", "请求录音权限时出错: $e");
return false;
}
}
/// 暂停计时器
void pauseTimer() {
if (timerStatus.value == TimerStatus.running) {
timerStatus.value = TimerStatus.paused;
_timer?.cancel();
_timer = null;
/// Initializes UI controllers and focus nodes
void _initializeComponents() {
tabController = TabController(length: 2, vsync: this);
titleEditingController = TextEditingController(text: fileName.value);
titleFocusNode = FocusNode();
titleFocusNode.addListener(_handleTitleFocusChange);
}
// 更新累计时间
final currentTime = DateTime.now().millisecondsSinceEpoch;
_elapsedMilliseconds += currentTime - _lastStartTime!;
_lastStartTime = null;
/// Sets up initial state from navigation arguments
void _setupFromArguments() {
final Map<String, dynamic> args = Get.arguments ?? {};
audioType.value = args['audioTypes'] ?? 0;
// Set default filename based on audio type
switch (audioType.value) {
case 0:
fileName.value = "现场录音";
break;
case 1:
fileName.value = "音、视频录音";
break;
case 2:
fileName.value = "通话录音";
break;
}
newName.value = fileName.value;
}
/// 重置计时器
void resetTimer() {
/// Cleans up resources when controller is disposed
void _cleanupResources() {
_timer?.cancel();
_timer = null;
_elapsedMilliseconds = 0;
_lastStartTime = null;
seconds.value = 0;
timerStatus.value = TimerStatus.stopped;
_recognitionSubscription?.cancel();
titleEditingController.dispose();
titleFocusNode.dispose();
_bleManager.closeCodec();
_asrService.stopContinuousRecognition();
_asrService.stopRecord(true);
}
/// Handles focus changes in title text field
void _handleTitleFocusChange() {
if (!titleFocusNode.hasFocus && isEditingTitle.value) {
saveTitleChange();
}
}
void toggleTextDisplay() {
showTextContent.toggle(); // 点击按钮切换显示状态
/// Starts title editing mode
void startTitleEditing() {
isEditingTitle.value = true;
titleEditingController.text = fileName.value;
newName.value = fileName.value;
Future.delayed(const Duration(milliseconds: 100), () {
titleFocusNode.requestFocus();
});
}
/// 切换录音状态
Future<void> toggleRecording() async {
if (timerStatus.value == TimerStatus.stopped) {
await _startRecording();
} else if (timerStatus.value == TimerStatus.running) {
await _pauseRecording();
} else if (timerStatus.value == TimerStatus.paused) {
await _resumeRecording();
/// Saves new title and exits editing mode
Future<void> saveTitleChange() async {
isEditingTitle.value = true;
if (isEditingTitle.value) {
newName.value = titleEditingController.text;
isEditingTitle.value = false;
// Actual file renaming happens during saveRecording()
}
}
/// 生成模拟波形图数据
void generateWaveData() {
waveData.value = List.generate(120, (i) {
final rand = (i % 20) / 20;
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25;
return baseHeight + rand * 10;
});
/// Toggles visibility of transcribed text content
void toggleTextDisplay() => showTextContent.toggle();
/// Toggles important mark status
void toggleMarked() => isMarked.toggle();
/// Formats seconds to MM:SS display format
String formatTime(int seconds) {
return '${(seconds ~/ 60).toString().padLeft(2, '0')}'
':${(seconds % 60).toString().padLeft(2, '0')}';
}
// 处理语音识别事件
void _handleRecognitionEvent(RecognitionEvent event) {
switch (event.type) {
case RecognitionEventType.intermediateResult:
print(
"_handleRecognitionEvent,intermediateResult,event.text=${event.text}");
intermediateContent.value = event.text;
/// Starts/resumes recording based on current state
Future<void> toggleRecording() async {
// 等待权限请求完成并获取结果
final bool granted = await _requestRecordPermission();
// 如果权限被拒绝,直接返回
if (!granted) {
Logger.d("Permission", "录音权限被拒绝,无法开始录音");
return;
}
// 根据当前计时器状态执行相应操作
switch (timerStatus.value) {
case TimerStatus.stopped:
await _startRecording();
break;
case RecognitionEventType.finalResult:
print("_handleRecognitionEvent,finalResult,event.text=${event.text}");
finalContent.value += _spliceText(event);
intermediateContent.value = '';
//_originaBytes.add(event.audio!);
case TimerStatus.running:
await _pauseRecording();
break;
default:
// 不做任何处理
case TimerStatus.paused:
await _resumeRecording();
break;
}
}
String _spliceText(RecognitionEvent event) {
String eventText = event.text;
String text = '';
if (finalContent.value.isNotEmpty) {
if (event.role != '') {
final punctuations = {
',',
'。',
'!',
'?',
';',
':',
',',
'.',
':',
';',
'!',
'?'
};
final firstChar = eventText[0];
if (punctuations.contains(firstChar)) {
text += '$firstChar\n';
eventText = eventText.substring(1);
} else {
text += '\n';
}
text += '说话人${event.role}:';
}
} else {
text += '说话人${event.role}:';
}
text += eventText;
return text;
}
/// Starts recording session
Future<void> _startRecording() async {
// 开始录音
// Initialize speech recognition
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
await _asrService.enableRecord("录音");
// Prepare storage directory
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
// Generate timestamped filename
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
final fullFileName = "${fileName.value}_$formattedTime";
// Start audio recording
await _asrService.enableRecord("${dir.path}/$fullFileName.wav");
// Update state
fileName.value = fullFileName;
newName.value = fullFileName;
titleEditingController.text = fullFileName;
// Start hardware components
_startHardwareServices();
startTimer();
}
// 蓝牙处理
if (audioTypes == 0) {
_bleManager.openEncoder();
} else if (audioTypes == 1) {
_bleManager.openDecoder();
} else if (audioTypes == 2) {
_bleManager.openA2DPDecoder();
/// Activates appropriate hardware service based on audio type
void _startHardwareServices() {
switch (audioType.value) {
case 0:
_bleManager.openEncoder();
break;
case 1:
_bleManager.openDecoder();
break;
case 2:
_bleManager.openA2DPDecoder();
break;
}
}
/// Pauses current recording session
Future<void> _pauseRecording() async {
// 暂停录音
await _asrService.stopContinuousRecognition();
await _asrService.pauseRecord();
pauseTimer();
//await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法
}
/// Resumes paused recording session
Future<void> _resumeRecording() async {
// 开始录音
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
_recognitionSubscription?.cancel();
_recognitionSubscription =
recognitionStream.listen(_handleRecognitionEvent);
startTimer();
//await _asrService.resumeRecognition(); // 假设ASR服务有继续方法
}
/// 切换是否标记为重点
void toggleMarked() {
isMarked.toggle();
/// Handles speech recognition events
void _handleRecognitionEvent(RecognitionEvent event) {
switch (event.type) {
case RecognitionEventType.intermediateResult:
intermediateContent.value = event.text;
break;
case RecognitionEventType.finalResult:
finalContent.value += _formatTranscript(event);
intermediateContent.value = '';
break;
default:
break;
}
}
/// 格式化时间为 mm:ss
String formatTime(int seconds) {
// 实现你的时间格式化逻辑
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
/// Formats transcript with speaker identification
String _formatTranscript(RecognitionEvent event) {
final buffer = StringBuffer();
if (finalContent.value.isNotEmpty) {
if (event.role.isNotEmpty) {
// Add line break before new speaker
if (_isPunctuation(event.text[0])) {
buffer.write('${event.text[0]}\n');
buffer.write('说话人${event.role}:');
buffer.write(event.text.substring(1));
} else {
buffer.write('\n说话人${event.role}:');
buffer.write(event.text);
}
} else {
buffer.write(event.text);
}
} else {
buffer.write('说话人${event.role}:${event.text}');
}
return buffer.toString();
}
/// 游标在波形图中的位置(0~1)
double get cursorPosition => _currentPosition / waveData.length;
/// Checks if character is Chinese/English punctuation
bool _isPunctuation(String char) {
const punctuations = {
',',
'。',
'!',
'?',
';',
':',
',',
'.',
':',
';',
'!',
'?'
};
return punctuations.contains(char);
}
/// 保存录音(目前模拟为返回上一页)
/// Saves recording and cleans up resources
Future<void> saveRecording() async {
resetTimer(); // 重置计时器
resetTimer();
_bleManager.closeCodec();
await _asrService.stopRecord();
await _asrService.stopRecord(true);
// Rename file if title was edited
if (fileName.value != newName.value) {
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
await _asrService.renameFile(
"${dir.path}/${fileName.value}.wav",
newName.value,
);
}
await _asrService.stopContinuousRecognition();
Get.back();
_resetToDefaultState();
}
/// Resets controller to initial state
void _resetToDefaultState() {
switch (audioType.value) {
case 0:
fileName.value = "现场录音";
break;
case 1:
fileName.value = "音、视频录音";
break;
case 2:
fileName.value = "通话录音";
break;
}
newName.value = fileName.value;
finalContent.value = '';
intermediateContent.value = '';
isMarked.value = false;
}
/// TIMER CONTROL SECTION ///
/// Starts or resumes timer
void startTimer() {
if (timerStatus.value != TimerStatus.running) {
timerStatus.value = TimerStatus.running;
_lastStartTime = DateTime.now().millisecondsSinceEpoch;
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
if (timerStatus.value != TimerStatus.running) return;
final currentTime = DateTime.now().millisecondsSinceEpoch;
_elapsedMilliseconds += currentTime - _lastStartTime!;
_lastStartTime = currentTime;
seconds.value = (_elapsedMilliseconds / 1000).round();
});
}
}
/// Pauses active timer
void pauseTimer() {
if (timerStatus.value == TimerStatus.running) {
timerStatus.value = TimerStatus.paused;
_timer?.cancel();
_timer = null;
final currentTime = DateTime.now().millisecondsSinceEpoch;
_elapsedMilliseconds += currentTime - _lastStartTime!;
_lastStartTime = null;
}
}
/// Resets timer to initial state
void resetTimer() {
_timer?.cancel();
_timer = null;
_elapsedMilliseconds = 0;
_lastStartTime = null;
seconds.value = 0;
timerStatus.value = TimerStatus.stopped;
}
/// UI HELPERS SECTION ///
/// Generates mock waveform data for visualization
void generateWaveData() {
waveData.value = List.generate(120, (i) {
final rand = (i % 20) / 20;
final baseHeight = (sin(i / 5) * 0.5 + 0.5) * 25;
return baseHeight + rand * 10;
});
}
/// Current playback position in waveform (0-1)
double get cursorPosition => _currentPosition / waveData.length;
}

38
lib/modules/meeting/views/meeting_details_view.dart

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:common_utils/common_utils.dart';
import 'package:flutter/material.dart';
@ -101,15 +103,27 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
controller.dragOffset.value = 210.w;
controller.isTop.value = false;
},
child: AudioFileWaveforms(
size: Size(130.w, 30.w),
playerController: controller.playerController,
enableSeekGesture: false,
playerWaveStyle: PlayerWaveStyle(
fixedWaveColor: Colors.grey[400]!,
liveWaveColor: Colors.red,
seekLineColor: Colors.red,
scaleFactor: 100,
child: SizedBox(
width: 130.w,
child: FittedBox(
fit: BoxFit.scaleDown,
child: AudioFileWaveforms(
size: Size(350.w, 30.w),
playerController: controller.playerController,
waveformData: List.generate(88, (int index) {
return 0.01 + Random().nextDouble() * 0.09;
}),
waveformType: WaveformType.fitWidth,
enableSeekGesture: false,
playerWaveStyle: PlayerWaveStyle(
spacing: 4.w,
waveThickness: 2.w,
fixedWaveColor: Colors.grey[400]!,
liveWaveColor: Colors.red,
seekLineColor: Colors.red,
scaleFactor: 200,
),
),
),
),
),
@ -341,7 +355,13 @@ class MeetingDetailsView extends GetView<MeetingDetailsController> {
child: AudioFileWaveforms(
size: Size(350.w, 120.w),
playerController: controller.playerController,
waveformData: List.generate(88, (int index) {
return 0.01 + Random().nextDouble() * 0.09;
}),
waveformType: WaveformType.fitWidth,
playerWaveStyle: PlayerWaveStyle(
spacing: 4.w,
waveThickness: 2.w,
fixedWaveColor: Colors.grey[300]!,
liveWaveColor: Colors.red,
seekLineColor: Colors.red,

248
lib/modules/meeting/views/meeting_record_view.dart

@ -3,47 +3,115 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart';
class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('新录音 29', style: TextStyle(fontSize: 18)), // 更新为29
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
body: Column(
children: [
TabBar(
controller: controller.tabController,
indicatorColor: Colors.red,
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: '音频'),
Tab(text: '文本'),
],
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return WillPopScope(
onWillPop: () async {
// 在返回时执行保存操作
controller.saveTitleChange();
await controller.saveRecording();
return true; // 允许退出
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
// 在返回按钮点击时执行保存操作
controller.saveTitleChange();
await controller.saveRecording();
Get.back();
},
),
Expanded(
child: TabBarView(
title: Obx(() {
if (controller.isEditingTitle.value) {
return SizedBox(
height: 40,
child: TextField(
controller: controller.titleEditingController,
focusNode: controller.titleFocusNode,
autofocus: true,
style: TextStyle(
fontSize: 18,
color: isDarkMode ? Colors.white : Colors.black,
),
decoration: InputDecoration(
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: '输入文件名',
hintStyle: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (value) {
controller.saveTitleChange();
},
),
);
} else {
return GestureDetector(
onTap: () {
controller.startTitleEditing();
},
child: Text(
controller.newName.value,
style: const TextStyle(fontSize: 18),
),
);
}
}),
elevation: 0,
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
actions: [
Obx(() {
if (controller.isEditingTitle.value) {
return IconButton(
icon: const Icon(Icons.check),
onPressed: controller.saveTitleChange,
);
}
return const SizedBox.shrink();
}),
],
),
body: Column(
children: [
TabBar(
controller: controller.tabController,
children: [
_buildAudioTab(context),
_buildTextTab(context),
indicatorColor: Colors.red,
labelColor: isDarkMode ? Colors.white : Colors.black,
unselectedLabelColor: Colors.grey,
tabs: const [
Tab(text: '音频'),
Tab(text: '文本'),
],
),
),
],
Expanded(
child: TabBarView(
controller: controller.tabController,
children: [
_buildAudioTab(context, isDarkMode),
_buildTextTab(context, isDarkMode),
],
),
),
],
),
),
);
}
Widget _buildAudioTab(BuildContext context) {
Widget _buildAudioTab(BuildContext context, bool isDarkMode) {
return Obx(() => Column(
children: [
const SizedBox(height: 24),
@ -77,13 +145,13 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
const Spacer(),
_buildBottomBar(context),
_buildBottomBar(context, isDarkMode),
const SizedBox(height: 32),
],
));
}
Widget _buildTextTab(BuildContext context) {
Widget _buildTextTab(BuildContext context, bool isDarkMode) {
return Obx(() => Column(
children: [
const SizedBox(height: 48),
@ -139,16 +207,18 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
),
const Spacer(),
_buildBottomBar(context),
_buildBottomBar(context, isDarkMode),
const SizedBox(height: 32),
],
));
}
Widget _buildBottomBar(BuildContext context) {
// 底部操作栏
Widget _buildBottomBar(BuildContext context, bool isDarkMode) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// 收藏按钮
GestureDetector(
onTap: controller.toggleMarked,
child: Obx(() => CircleAvatar(
@ -162,6 +232,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
)),
),
// 录音按钮
GestureDetector(
onTap: controller.toggleRecording,
child: Obx(() => CircleAvatar(
@ -176,8 +247,9 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
)),
),
// 完成按钮
GestureDetector(
onTap: controller.saveRecording,
onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数
child: const CircleAvatar(
radius: 32,
backgroundColor: Colors.white,
@ -191,6 +263,118 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
],
);
}
// 底部编辑弹窗
void _bottomSheet(BuildContext context, bool isDarkMode) {
Get.bottomSheet(
Padding(
padding: MediaQuery.of(context).viewInsets,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
borderRadius: BorderRadius.vertical(top: Radius.circular(20.w)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题
Padding(
padding: EdgeInsets.only(bottom: 20.w),
child: Text(
'editMeetingTitle'.tr,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
),
// 输入框
Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.white10 : Colors.white,
borderRadius: BorderRadius.circular(12.w),
),
child: TextField(
controller: controller.titleEditingController,
autofocus: true, // 自动弹出键盘
maxLength: 50, // 添加字符限制
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 16.sp,
),
decoration: InputDecoration(
hintText: 'maxCharacters'.tr,
hintStyle: TextStyle(
color: isDarkMode ? Colors.white60 : Colors.black45,
),
border: InputBorder.none,
contentPadding: EdgeInsets.all(16.w),
counterText: '', // 隐藏默认计数器
),
),
),
Padding(
padding: EdgeInsets.only(top: 30.w),
child: Row(
children: [
// 取消按钮
Expanded(
child: TextButton(
onPressed: Get.back, //
style: TextButton.styleFrom(
foregroundColor:
isDarkMode ? AppColors.primary : Colors.blue,
padding: EdgeInsets.symmetric(vertical: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
side: BorderSide(
color:
isDarkMode ? AppColors.primary : Colors.blue,
width: 2.w,
),
),
),
child: Text('cancel'.tr),
),
),
SizedBox(width: 20.w),
// 确认按钮
Expanded(
child: TextButton(
onPressed: () async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back();
},
style: TextButton.styleFrom(
backgroundColor:
isDarkMode ? AppColors.primary : Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.w),
),
),
child: Text('confirm'.tr),
),
),
],
),
),
],
),
),
),
isScrollControlled: true, // 防止键盘遮挡
backgroundColor: Colors.transparent, // 移除默认背景
);
}
}
class WaveformPainter extends CustomPainter {

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

@ -3,13 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import 'package:path_provider/path_provider.dart';
import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart';
import '../controllers/meeting_controller.dart';
import '../model/meeting_model.dart';
import 'meeting_record_view.dart';
class MeetingView extends GetView<MeetingController> {
const MeetingView({super.key});
@ -563,10 +561,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '音/视频录音',
onTap: () {
title: '现场录音',
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
controller.addMeetingAudio();
// TODO: 实现从文件导入逻辑
},
),
@ -576,11 +577,13 @@ class MeetingView extends GetView<MeetingController> {
isDarkMode,
icon: Image.asset('assets/images/explore.png',
width: 32.w, height: 32.w),
title: '现场录音',
onTap: () {
title: '音、视频录音',
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
controller.addMeetingAudio();
// TODO: 实现从相册导入逻辑
},
),
@ -602,9 +605,14 @@ class MeetingView extends GetView<MeetingController> {
],
),
title: '通话录音',
onTap: () {
onTap: () async {
Navigator.of(context).pop();
Get.toNamed(Routes.meetingRecord);
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
controller.addMeetingAudio();
// TODO: 实现从其他APP导入逻辑
},
),

12
lib/modules/meeting/views/share_bottom_sheet.dart

@ -149,14 +149,12 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
EasyLoading.showToast('没有可分享的音频文件');
return;
}
try {
await Share.shareXFiles(
[XFile(_controller.meetingData.value.filepath)],
text: '分享会议音频',
);
final result = await SharePlus.instance.share(ShareParams(
text: '分享会议音频',
files: [XFile(_controller.meetingData.value.filepath)],
));
if (result.status == ShareResultStatus.success) {
EasyLoading.showToast('分享完成!');
} catch (e) {
EasyLoading.showToast('分享失败: ${e.toString()}');
}
}
}

11
lib/modules/music/bindings/music_playlist_binding.dart

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/music_playlist_controller.dart';
class MusicPlaylistBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<MusicPlaylistController>(
() => MusicPlaylistController(),
);
}
}

19
lib/modules/music/controllers/music_playlist_controller.dart

@ -0,0 +1,19 @@
import 'package:get/get.dart';
class MusicPlaylistController extends GetxController {
// 歌单数据
final playlist = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// 从路由参数获取歌单数据
final List<Map<String, dynamic>> playlistData = Get.arguments ?? [];
playlist.assignAll(playlistData);
}
@override
void onClose() {
super.onClose();
}
}

203
lib/modules/music/views/music_playlist_view.dart

@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../agent/controllers/agent_controller.dart';
import '../../agent/views/circular_progress_button.dart';
class MusicPlaylistView extends StatelessWidget {
const MusicPlaylistView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final controller = Get.find<AgentController>();
final List<Map<String, dynamic>> playlist = Get.arguments ?? [];
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('音乐播放列表'),
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white,
foregroundColor: isDarkMode ? Colors.white : Colors.black,
elevation: 0,
),
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
body: playlist.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'暂无音乐',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: playlist.length,
itemBuilder: (context, index) {
final item = playlist[index];
final id = item["id"]?.toString() ?? "";
final name = item["name"] ?? "未知歌曲";
final url = item["url"] ?? "";
final singer = item["singer"] ?? item["sgener"] ?? "未知歌手";
final image = item["image"] ?? "";
return _buildMusicItem(
context,
controller,
id,
name,
url,
singer,
image,
isDarkMode,
);
},
),
);
}
Widget _buildMusicItem(
BuildContext context,
AgentController controller,
String id,
String name,
String url,
String singer,
String image,
bool isDarkMode,
) {
final song = {
'id': id,
'title': name,
'url': url,
'artist': singer,
'coverUrl': image,
};
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDarkMode ? 0.2 : 0.03),
blurRadius: 3,
spreadRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
// 封面图片
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[300],
),
child: image.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
image,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.music_note,
color: Colors.grey[600],
);
},
),
)
: Icon(
Icons.music_note,
color: Colors.grey[600],
),
),
const SizedBox(width: 12),
// 歌曲信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
singer,
style: TextStyle(
fontSize: 14,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
// 播放按钮
Obx(() {
final isThisPlaying =
controller.isPlaying.value && controller.currentUrl.value == url;
// 只有当前播放的歌曲才显示进度
final currentProgress = isThisPlaying ? controller.progress.value : 0.0;
return CircularProgressButton(
isPlaying: isThisPlaying,
progress: currentProgress,
primaryColor: Theme.of(context).primaryColor,
backgroundColor: Colors.grey,
size: 40.0,
strokeWidth: 2.5,
onPlayPause: () {
if (controller.isPlaying.value) {
if (controller.currentUrl.value == url) {
controller.musicPause();
} else {
controller.musicPlay(song);
}
} else {
if (controller.currentUrl.value == url) {
controller.musicResume();
} else {
controller.musicPlay(song);
}
}
},
onSeek: isThisPlaying ? (seekProgress) {
// 只有当前播放的歌曲才允许跳转
final targetPosition = (controller.duration.value * seekProgress).round();
controller.seekToPosition(targetPosition);
} : null,
);
}),
],
),
);
}
}

106
lib/modules/profile/controllers/profile_controller.dart

@ -7,14 +7,15 @@ import 'package:logger/logger.dart';
import '../../../data/models/user_Info.dart';
import '../../settings/controllers/settings_controller.dart';
import 'dart:io';
import 'dart:convert';
//import 'dart:convert';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
import '../../../data/services/network/api.dart';
import 'package:dio/dio.dart';
import '../../../routes/app_routes.dart';
import '../../../modules/login/controllers/googleLogin.dart';
import '../../../modules/login/controllers/facebookLogin.dart';
//import '../../../modules/login/controllers/googleLogin.dart';
//import '../../../modules/login/controllers/facebookLogin.dart';
import '../../../data/services/account_service.dart';
//import 'package:http_parser/http_parser.dart'; // 新增此导入
class ProfileController extends GetxController {
@ -31,9 +32,14 @@ class ProfileController extends GetxController {
// 使用响应式变量
final currentLanguage = 'zh'.obs;
// 账号服务
late final AccountService _accountService;
@override
void onInit() {
super.onInit();
// 获取或注册账号服务
_accountService = Get.find<AccountService>();
loadUserInfo();
loadLanguagePreference();
}
@ -358,49 +364,41 @@ class ProfileController extends GetxController {
}
}
// 退出登录
Future<void> signOut() async {
try {
//显示加载提示
// Get.dialog(
// const Center(child: CircularProgressIndicator()),
// barrierDismissible: false,
// );
// 清理所有存储
await _storage.erase();
// 或者逐个清理关键存储项
await _storage.write("logintoken", null);
await _storage.write("user_info", null);
await _storage.write("local_avatar", null);
print('清理存储成功');
// 第三方登录退出
try {
final google = Google();
final facebook = Facebook();
await Future.wait([
google.signOutGoogle(),
facebook.signOutFacebook(),
]);
} catch (e) {
print('第三方登录退出错误: $e');
// 继续执行,不中断退出流程
}
// 显示加载提示
Get.dialog(
const Center(child: CircularProgressIndicator()),
barrierDismissible: false,
);
// 调用账号服务执行登出
final result = await _accountService.signOut();
// 关闭加载提示
//Get.back();
Get.back();
// 确保导航到登录页面
if (Get.currentRoute != Routes.login) {
await Get.offAllNamed(Routes.login);
}
if (result.success) {
// 先显示成功提示
Get.snackbar('成功', result.message ?? '已退出登录');
Logger().d('退出登录成功');
Get.snackbar('成功', '已退出登录');
print('退出登录成功');
// 然后导航到登录页面
if (Get.currentRoute != Routes.login) {
await Get.offAllNamed(Routes.login);
}
} else {
Get.snackbar('错误', result.errorMessage ?? '退出登录失败,请重试');
Logger().e('退出登录失败: ${result.errorMessage}');
}
} catch (e) {
//Get.back(); // 关闭加载提示
// 确保加载提示被关闭
if (Get.isDialogOpen ?? false) {
Get.back();
}
Get.snackbar('错误', '退出登录失败,请重试');
print('退出登录错误: $e');
Logger().e('退出登录错误: $e');
}
}
@ -413,24 +411,32 @@ class ProfileController extends GetxController {
barrierDismissible: false,
);
// 删除后台用户数据
final responce = await Api.deleteUser();
if (responce == null) {
Get.back(); // 关闭加载提示
Get.snackbar('错误', '删除用户数据失败');
return;
}
// 调用账号服务执行账号删除
final result = await _accountService.deleteAccount();
// 关闭加载提示
Get.back();
signOut(); // 调用 signOut 方法
if (result.success) {
// 先显示成功提示
Get.snackbar('成功', result.message ?? '账号已被永久删除');
Logger().d('账号删除成功');
Get.snackbar('Success', 'Account has been permanently deleted');
Logger().d('账号删除成功');
// 然后导航到登录页面
if (Get.currentRoute != Routes.login) {
await Get.offAllNamed(Routes.login);
}
} else {
Get.snackbar('错误', result.errorMessage ?? '删除账号失败,请重试');
Logger().e('删除账号失败: ${result.errorMessage}');
}
} catch (e) {
Get.back(); // 关闭加载提示
// 确保加载提示被关闭
if (Get.isDialogOpen ?? false) {
Get.back();
}
Get.snackbar('错误', '删除账号失败,请重试');
Logger().d('账号删除失败: $e');
Logger().e('删除账号失败: $e');
}
}
}

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

@ -930,6 +930,16 @@ class SettingsView extends GetView<SettingsController> {
),
),
),
//------------测试使用标记 start---------------//
SizedBox(height: 4.h),
Text(
'${Platform.operatingSystem}-test-${DateTime.now().month.toString().padLeft(2, '0')}${DateTime.now().day.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12.sp,
color: const Color.fromARGB(255, 143, 94, 94),
),
),
//------------测试使用标记 end---------------//
],
),
),

10
lib/modules/translation/bindings/translation_list_binding.dart

@ -0,0 +1,10 @@
import 'package:get/get.dart';
import '../controllers/translation_list_controller.dart';
class TranslationListBinding extends Bindings {
@override
void dependencies() {
// 注册控制器
Get.lazyPut<TranslationListController>(() => TranslationListController());
}
}

98
lib/modules/translation/controllers/translation_controller.dart

@ -1,8 +1,14 @@
import 'dart:io';
import 'package:deep_voice/core/utils/permission_util.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:async';
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../data/services/volcano_translation_service.dart';
import '../../../data/services/tts_service.dart';
import '../../../data/services/language_manager.dart';
@ -46,14 +52,13 @@ class TranslationController extends GetxController {
// 翻译历史和当前项
final translationHistory = <TranslationItem>[].obs;
String? currentSessionId;
final appDocDir = getApplicationDocumentsDirectory();
var appDir;
var dir;
Future<void> changeTranslationMode(String mode) async {
currentMode.value = mode;
currentModeTitle.value = _getModeTitle(mode);
// 这里添加具体模式切换逻辑
if (isRecording.value) {
await _asrService.enableRecord(currentModeTitle.value.tr);
}
stopAll();
}
@ -99,7 +104,7 @@ class TranslationController extends GetxController {
//_setupEventListeners();
// 加载上次保存的语言设置
_loadSavedLanguages();
_requestStoragePermission();
// 初始化语言代码
final sourceAsrCode =
_languageManager.getAsrCodeByChineseName(sourceLanguage.value);
@ -147,6 +152,49 @@ class TranslationController extends GetxController {
_storage.write(_targetLanguageKey, targetLanguage.value);
}
Future<bool> _requestRecordPermission() async {
try {
final bool granted = await PermissionUtil.instance.requestPermission(
permissionType: Permission.microphone,
permissionName: "录音",
explanationText: "我们需要访问麦克风权限,以便您录制音频内容。",
permanentDenialText: "检测到您已永久拒绝录音权限。如需继续使用此功能,请前往应用设置中手动开启麦克风权限。",
);
if (granted) {
Logger.d("Permission", "用户授予了录音权限");
return true;
} else {
Logger.d("Permission", "用户未授予录音权限");
return false;
}
} catch (e) {
Logger.e("Permission", "请求录音权限时出错: $e");
return false;
}
}
// 请求存储权限
void _requestStoragePermission() async {
// final BuildContext context = Get.overlayContext!;
PermissionUtil.instance
.requestPermission(
// context: context,
permissionType: Permission.storage,
permissionName: "存储",
explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。",
permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。",
)
.then((granted) {
if (granted) {
Logger.d("Permission", "用户授予了存储权限");
} else {
Logger.d("Permission", "用户未授予存储权限");
// 可以提示用户无法进行文件操作
}
});
}
// /// 设置事件监听器
// void _setupEventListeners() {
// // 监听扫描结果
@ -176,7 +224,11 @@ class TranslationController extends GetxController {
await _asrService.initialize(
supportedLanguages: asrSupportedLanguages,
);
appDir = await appDocDir; // 先 await 获取目录
dir = Directory("${appDir.path}/TranslatAudio");
if (!await dir.exists()) {
await dir.create(recursive: true); // Add recursive creation
}
Logger.info('所有服务初始化完成');
} catch (e) {
Logger.error('初始化服务失败: ${e.toString()}');
@ -199,16 +251,29 @@ class TranslationController extends GetxController {
// 切换录音功能
Future<void> toggleisRecord() async {
isRecording.toggle();
if (isRecording.value) {
await _asrService.enableRecord(currentModeTitle.value.tr);
} else {
await _asrService.stopRecord();
}
if (isRecording.value && isRecognizing.value) {
// 使用可读性更好的时间格式
final formattedTime =
DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
await _asrService.enableRecord(
"${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav");
} else if (!isRecording.value) {
await _asrService.stopRecord(true);
} else {}
}
// 开始语音识别
Future<void> startRecognition() async {
Logger.info('开始语音识别');
// 等待权限请求完成并获取结果
final bool granted = await _requestRecordPermission();
// 如果权限被拒绝,直接返回
if (!granted) {
Logger.d("Permission", "录音权限被拒绝,无法开始录音");
return;
}
if (isRecognizing.value) return;
try {
@ -262,7 +327,12 @@ class TranslationController extends GetxController {
final recognitionStream =
await _asrService.startContinuousRecognition(_audioSourceType);
isRecognizing.value = true;
if (isRecording.value && isRecognizing.value) {
final formattedTime =
DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
await _asrService.enableRecord(
"${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav");
}
// 监听识别结果
_recognitionSubscription?.cancel();
_recognitionSubscription =
@ -500,7 +570,7 @@ class TranslationController extends GetxController {
if (isRecognizing.value) {
await stopRecognition();
}
await _asrService.stopRecord(true);
_translationDebounceTimer?.cancel();
await _ttsService.stop();
} catch (e) {

184
lib/modules/translation/controllers/translation_list_controller.dart

@ -0,0 +1,184 @@
import 'dart:io';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:get/get.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/permission_util.dart';
import 'package:share_plus/share_plus.dart';
class TranslationListController extends GetxController {
// 是否还有更多数据
bool isGetMore = true;
bool isLoading = false;
int page = 1;
int addQuantity = 0;
List<String> selected = [];
final appDocDir = getApplicationDocumentsDirectory();
// final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
final RxList<FileSystemEntity> files = <FileSystemEntity>[].obs;
RxList dataList = [].obs;
List dataListTask = [];
bool isDisposed = false;
// 添加音频播放器
// 添加音频时长缓存
final Map<String, Duration> audioDurations = {};
// 播放状态
final RxBool isPlaying = false.obs;
// 当前播放的文件路径
final RxString currentPlayingPath = ''.obs;
final AudioPlayer audioPlayer = AudioPlayer();
final PlayerController playerController = PlayerController();
@override
void onInit() {
super.onInit();
// listController.addListener(_onScroll);
_requestStoragePermission();
//Permission.storage.request();
// _headers = {
// 'Authorization': 'Bearer; $_token',
// 'Content-Type': 'application/json',
// };
// _getDataList();
// addMeetingAudio();
loadTranslatAudio();
// 监听播放状态变化
audioPlayer.playerStateStream.listen((playerState) {
isPlaying.value = playerState.playing;
if (playerState.processingState == ProcessingState.completed) {
// 播放完成后重置状态
currentPlayingPath.value = '';
}
});
}
@override
void onClose() {
audioPlayer.dispose(); // 释放播放器资源
super.onClose();
}
// 播放/暂停音频
void togglePlay(FileSystemEntity file) async {
final filePath = file.path;
if (currentPlayingPath.value == filePath) {
// 同一个文件:切换播放/暂停
if (isPlaying.value) {
await audioPlayer.pause();
} else {
await audioPlayer.play();
}
} else {
// 新文件:停止当前播放,开始新文件
currentPlayingPath.value = filePath;
try {
await audioPlayer.stop();
await audioPlayer.setFilePath(filePath);
await audioPlayer.play();
} catch (e) {
print("播放失败: $e");
currentPlayingPath.value = '';
}
}
}
// 请求存储权限
void _requestStoragePermission() async {
// final BuildContext context = Get.overlayContext!;
PermissionUtil.instance
.requestPermission(
// context: context,
permissionType: Permission.storage,
permissionName: "存储",
explanationText: "我们需要访问存储权限,以便您可以选择并上传音频文件。",
permanentDenialText: "检测到您已永久拒绝存储权限。如需继续使用此功能,请前往应用设置中手动开启存储权限。",
)
.then((granted) {
if (granted) {
Logger.d("Permission", "用户授予了存储权限");
} else {
Logger.d("Permission", "用户未授予存储权限");
// 可以提示用户无法进行文件操作
}
});
}
// 添加方法:获取音频时长
Future<Duration> getAudioDuration(String filePath) async {
if (audioDurations.containsKey(filePath)) {
return audioDurations[filePath]!;
}
try {
final audioPlayer = AudioPlayer();
await audioPlayer.setFilePath(filePath);
final duration = await audioPlayer.duration ?? Duration.zero;
audioDurations[filePath] = duration;
return duration;
} catch (e) {
print("获取音频时长失败: $e");
return Duration.zero;
}
}
void loadTranslatAudio() async {
try {
final appDir = await appDocDir; // 先 await 获取目录
final dir = Directory("${appDir.path}/TranslatAudio");
print("appDocDir=${dir?.path}");
// Create directory if it doesn't exist
if (!await dir.exists()) {
await dir.create(recursive: true); // Create directory tree if needed
files.value = []; // No files exist in new directory
return;
}
// Directory exis
files.value = dir
.listSync()
.where((f) =>
f is File &&
(f.path.endsWith('.mp3') ||
f.path.endsWith('.wav') ||
f.path.endsWith('.aac') ||
f.path.endsWith('.m4a') ||
f.path.endsWith('.mp4') ||
f.path.endsWith('.mov')))
.toList();
} catch (e) {
print("Error loading files: $e");
files.value = []; // Return empty list on error
}
}
// 导入音频文件
Future<void> shareAllTranslatAudio() async {
final filePath = selected[0];
// for (final filePath in selected) {
// if (filePath.isEmpty) {
// EasyLoading.showToast('没有可分享的音频文件');
// return;
// }
// }
try {
await Share.shareXFiles(
[XFile(filePath)],
text: '分享会议音频',
);
EasyLoading.showToast('分享完成!');
} catch (e) {
EasyLoading.showToast('分享失败: ${e.toString()}');
}
}
}

473
lib/modules/translation/views/translation_list_view.dart

@ -0,0 +1,473 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart';
import '../controllers/translation_list_controller.dart';
class TranslationListView extends GetView<TranslationListController> {
const TranslationListView({super.key});
static OverlayEntry? _recordingOverlayEntry;
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50],
appBar: _buildAppBar(context, isDarkMode),
body: _buildAudioList(context, isDarkMode),
);
}
// 构建应用栏
PreferredSizeWidget _buildAppBar(BuildContext context, bool isDarkMode) {
return AppBar(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
elevation: 0.5,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
onPressed: () => Get.back(),
),
title: Text(
"翻译录音文件", //
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
centerTitle: true,
);
}
Widget _buildAudioList(BuildContext context, bool isDarkMode) {
return Obx(() {
final fileList = controller.files; // 不要 toList()
if (fileList.isEmpty) {
return Center(child: Text('暂无音频文件'));
}
return ListView.builder(
itemCount: controller.files.length,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.w),
itemBuilder: (context, index) {
final file = controller.files[index];
return _audioItem(context, isDarkMode, file);
},
);
});
}
Widget _audioItem(
BuildContext context, bool isDarkMode, FileSystemEntity file) {
final fileName = file.path.split('/').last;
final fileStat = file.statSync(); // 获取文件信息
final modifiedTime = fileStat.modified;
// 添加 FutureBuilder 来异步获取音频时长
return FutureBuilder<Duration>(
future: controller.getAudioDuration(file.path),
builder: (context, snapshot) {
String durationText = "计算中...";
if (snapshot.hasData) {
final duration = snapshot.data!;
if (duration.inSeconds > 0) {
durationText =
"${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}";
} else {
durationText = "未知";
}
} else if (snapshot.hasError) {
durationText = "错误";
}
return GestureDetector(
onLongPressStart: (details) {
_onLongPressAudioItem(context, isDarkMode, details, file);
print("长按操作: $fileName");
},
onTap: () {
// 添加点击播放功能
controller.togglePlay(file);
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 15.w, vertical: 10.w),
margin: EdgeInsets.symmetric(vertical: 5.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(12.w),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// 文件名
Text(
fileName,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 18.sp,
),
overflow: TextOverflow.ellipsis,
),
/// 修改时间 & 大小
Padding(
padding: EdgeInsets.symmetric(vertical: 5.w),
child: Row(
children: [
Icon(
Icons.access_time,
size: 14.w,
color: isDarkMode ? Colors.grey[400] : Colors.grey[500],
),
4.horizontalSpace,
Text(
DateFormat('yyyy-MM-dd HH:mm').format(modifiedTime),
style: TextStyle(
color:
isDarkMode ? Colors.grey[400] : Colors.grey[500],
fontSize: 12.sp,
),
),
20.horizontalSpace,
Icon(
Icons.sd_storage,
size: 14.w,
color: isDarkMode ? Colors.grey[400] : Colors.grey[500],
),
4.horizontalSpace,
Text(
'${(fileStat.size / 1024).toStringAsFixed(1)} KB',
style: TextStyle(
color:
isDarkMode ? Colors.grey[400] : Colors.grey[500],
fontSize: 12.sp,
),
),
20.horizontalSpace,
Icon(Icons.music_note,
size: 14.w,
color:
isDarkMode ? Colors.grey[400] : Colors.grey[500]),
4.horizontalSpace,
Text(
durationText, // 使用计算出的时长文本
style: TextStyle(
color:
isDarkMode ? Colors.grey[400] : Colors.grey[500],
fontSize: 12.sp,
),
),
],
),
),
/// 文件类型标签
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding:
EdgeInsets.symmetric(horizontal: 5.w, vertical: 2.w),
margin: EdgeInsets.only(top: 5.w),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
borderRadius: BorderRadius.circular(4.w),
),
child: Text(
file.path.split('.').last.toUpperCase(),
style: TextStyle(
color:
isDarkMode ? Colors.grey[400] : Colors.grey[600],
fontSize: 10.sp,
),
),
),
Obx(() {
final isCurrentFile =
controller.currentPlayingPath.value == file.path;
final isPlaying = controller.isPlaying.value;
return Row(
children: [
// 播放/暂停图标
if (isCurrentFile)
Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
size: 20.w,
color: Colors.blue,
),
// 播放状态文本
Text(
isCurrentFile
? (isPlaying ? '播放中...' : '已暂停')
: '点击播放',
style: TextStyle(
color: Colors.blue,
fontSize: 10.sp,
),
),
],
);
}),
],
),
],
),
),
);
},
);
}
void _onLongPressAudioItem(BuildContext context, bool isDarkMode,
LongPressStartDetails details, FileSystemEntity file) {
Haptics.vibrate(HapticsType.success);
RenderBox? renderBox =
Overlay.of(context).context.findRenderObject() as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromLTRB(
details.globalPosition.dx,
details.globalPosition.dy,
details.globalPosition.dx + 110,
details.globalPosition.dy - 40,
),
Offset.zero & renderBox.size,
);
final fileName = file.path.split('/').last;
showMenu(
context: context,
position: position,
color: isDarkMode ? AppColors.cardBackground : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
items: [
_popupMenuEntry(
Icon(
Icons.drive_file_rename_outline,
size: 20.w,
color: isDarkMode ? Colors.white : Colors.black87,
),
'重命名',
isDarkMode ? Colors.white : Colors.black87,
() {
// 弹出重命名输入框
_renameAudioBottomSheet(context, isDarkMode, file);
},
),
_popupMenuEntry(
Icon(
Icons.delete_outline,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
),
'删除',
isDarkMode ? Colors.red[700]! : Colors.red,
() {
file.delete();
controller.loadTranslatAudio(); // 刷新列表
},
),
_popupMenuEntry(
Icon(
Icons.delete_outline,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
),
'导入',
isDarkMode ? Colors.red[700]! : Colors.red,
() {
Get.toNamed(Routes.meeting, arguments: {
'filePath': file.path,
});
},
),
],
);
}
PopupMenuEntry _popupMenuEntry(
Icon icon,
String title,
Color color,
Function() onTap,
) {
return PopupMenuItem(
onTap: onTap,
child: SizedBox(
width: 140.w,
child: Row(
children: [
icon,
8.horizontalSpace,
Text(
title,
style: TextStyle(
color: color,
fontSize: 14.sp,
),
),
],
),
),
);
}
void _renameAudioBottomSheet(
BuildContext context,
bool isDarkMode,
FileSystemEntity file,
) {
final TextEditingController _renameController = TextEditingController(
text: file.path.split('/').last,
);
Get.bottomSheet(
Padding(
padding: MediaQuery.of(context).viewInsets,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w),
color: isDarkMode ? AppColors.darkBackground : Colors.grey[50],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'重命名音频文件',
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 16.sp,
),
),
Container(
height: 48.w,
margin: EdgeInsets.only(top: 10.w, bottom: 20.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isDarkMode
? Colors.white.withOpacity(0.05)
: Colors.white,
borderRadius: BorderRadius.circular(12.w),
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _renameController,
style: TextStyle(
color: isDarkMode ? Colors.white : Colors.black87,
fontSize: 16.sp,
),
decoration: InputDecoration(
hintText: '请输入新的文件名',
hintStyle: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
fontSize: 16.sp,
),
fillColor: Colors.transparent,
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 10.w),
),
),
),
Padding(
padding: EdgeInsets.only(top: 30.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: () {
Get.back();
},
child: Container(
width: 120.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(
width: 2.w,
color: isDarkMode ? AppColors.primary : Colors.blue,
),
borderRadius: BorderRadius.circular(12.w),
),
child: Text(
'取消',
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? AppColors.primary : Colors.blue,
),
),
),
),
GestureDetector(
onTap: () async {
final newName = _renameController.text.trim();
if (newName.isEmpty) return;
final dir = file.parent.path;
final newPath = '$dir/$newName';
try {
await file.rename(newPath);
controller.loadTranslatAudio(); // 刷新
Get.back();
} catch (e) {
print('重命名失败: $e');
}
},
child: Container(
width: 120.w,
height: 40.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isDarkMode ? AppColors.primary : Colors.blue,
borderRadius: BorderRadius.circular(12.w),
),
child: Text(
'确认',
style: TextStyle(
color: Colors.white,
fontSize: 14.sp,
),
),
),
),
],
),
),
],
),
),
),
isScrollControlled: true,
clipBehavior: Clip.hardEdge,
);
}
}

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

@ -5,6 +5,7 @@ import '../controllers/translation_controller.dart';
import '../views/translation_history_view.dart';
import '../bindings/translation_history_binding.dart';
import '../../../core/theme/app_colors.dart';
import '../../../routes/app_routes.dart';
// import 'package:intl/intl.dart';
class TranslationView extends GetView<TranslationController> {
@ -61,25 +62,34 @@ class TranslationView extends GetView<TranslationController> {
_buildPopupMenuItem('audioVideo', 'audioVideoTranslation'.tr),
_buildPopupMenuItem('call', 'callTranslation'.tr),
],
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.currentModeTitle.value.tr, // 添加 .value 访问实际值
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
child: Row(
mainAxisSize: MainAxisSize.min, // 关键修改:让宽度自适应内容
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
// 防止文本溢出
child: FittedBox(
// 保持文本缩放比例
fit: BoxFit.scaleDown,
child: Text(
controller.currentModeTitle.value.tr,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600, // 保持字体加粗
color: isDarkMode ? Colors.white : Colors.black,
),
overflow: TextOverflow.ellipsis, // 长文本显示省略号
maxLines: 1,
),
),
Icon(
Icons.arrow_drop_down,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
],
),
),
SizedBox(width: 4), // 添加间距
Icon(
Icons.arrow_drop_down,
color: isDarkMode ? Colors.white : Colors.black,
size: 20.sp,
),
],
),
)),
centerTitle: true,
@ -106,10 +116,7 @@ class TranslationView extends GetView<TranslationController> {
onPressed: () async {
// 停止语音识别和TTS,释放资源
await controller.stopAll();
await Get.to(
() => const TranslationHistoryView(),
binding: TranslationHistoryBinding(),
);
Get.toNamed(Routes.translationList);
// 返回时不自动开始翻译,只需重新设置滚动监听器
controller.setupScrollListener();

18
lib/routes/app_pages.dart

@ -21,6 +21,8 @@ import '../modules/timbre/views/timbre_view.dart';
import '../modules/translation/views/translation_view.dart';
import '../modules/translation/bindings/translation_binding.dart';
import '../modules/translation/views/translation_history_view.dart';
import '../modules/translation/bindings/translation_list_binding.dart';
import '../modules/translation/views/translation_list_view.dart';
import '../modules/translation/bindings/translation_history_binding.dart';
import '../modules/login/views/login_view.dart';
import '../modules/login/bindings/login_binding.dart';
@ -40,6 +42,8 @@ import '../modules/ota_upgrade/views/ota_upgrade_view.dart';
import '../modules/ota_upgrade/bindings/ota_upgrade_binding.dart';
import '../modules/realtime/bindings/realtime_binding.dart';
import '../modules/realtime/views/realtime_view.dart';
import '../modules/music/views/music_playlist_view.dart';
import '../modules/music/bindings/music_playlist_binding.dart';
abstract class AppPages {
static final pages = [
@ -128,6 +132,13 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.translationList,
page: () => const TranslationListView(),
binding: TranslationListBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.meeting,
page: () => const MeetingView(),
@ -197,5 +208,12 @@ abstract class AppPages {
page: () => const RealtimeView(),
binding: RealtimeBinding(),
),
GetPage(
name: Routes.musicPlaylist,
page: () => const MusicPlaylistView(),
binding: MusicPlaylistBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
];
}

5
lib/routes/app_routes.dart

@ -27,7 +27,7 @@ abstract class Routes {
static const translation = '/translation'; // 实时翻译
static const translationHistory = '/translation/history'; // 翻译历史
static const speechDemo = '/speech_demo'; // 语音演示
static const translationList = '/translation/List'; // 翻译历史
// 测试路由(生产环境建议移除)
static const ttsTest = '/tts_test'; // 文字转语音测试
static const asrTest = '/asr_test'; // 语音识别测试
@ -46,6 +46,9 @@ abstract class Routes {
static const agent = '/agent'; // 在线客服
static const navigation = '/navigation'; // 实时导航
// 音乐相关
static const musicPlaylist = '/music/playlist'; // 音乐播放列表
// 实时语音对话
static const realtime = '/realtime';
}

141
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -153,6 +153,7 @@ object AgentService : CoroutineScope {
config["openaiApiKey"]?.toString() ?: "",
config["openaiBaseUrl"]?.toString() ?: "",
config["openaiModel"]?.toString() ?: "",
config["openaivisionModel"]?.toString() ?: "",
config["mcpServer"]?.toString() ?: ""
)
@ -576,6 +577,7 @@ object AgentService : CoroutineScope {
) {
// 创建带图片的用户消息并处理
val userMessage = createUserMessageWithImage(text, imageBase64)
Log.d(TAG, "liwei--------- 图像Mesage: ${userMessage}")
// 图片描述用于存储
val displayText = text.ifEmpty { "[图片]" }
processWithChatApiInternal(userMessage, displayText, speakResponse, true)
@ -599,7 +601,7 @@ object AgentService : CoroutineScope {
) {
// 如果有正在进行的AI流式输出,先停止它
stopAiStream()
stopTts()
// 记录新的AI任务
currentAiJob = launch {
try {
@ -610,7 +612,7 @@ object AgentService : CoroutineScope {
val responseBuilder = StringBuilder()
var aiMetadata:String = ""
var userMetadata = JSONObject()
var nobroadcast = false
var broadcast = false //是否播发
// 如果有图片,添加图片信息到元数据
if (hasImage) {
userMetadata.put("hasImage", true)
@ -621,16 +623,16 @@ object AgentService : CoroutineScope {
val messagesWithSystemPrompt = JSONArray()
// 先添加系统提示词
if (systemPrompt.isNotEmpty()) {
val location = LocationService.getCachedLocation()?: mapOf(
"user_location" to "定位未开启",
"user_lat" to "定位未开启",
"user_lng" to "定位未开启",
)
val _systemPrompt = Utils.fillTemplate(systemPrompt,location)
messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt))
Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}")
}
if (systemPrompt.isNotEmpty()) {
val location = LocationService.getCachedLocation()?: mapOf(
"user_location" to "定位未开启",
"user_lat" to "定位未开启",
"user_lng" to "定位未开启",
)
val _systemPrompt = Utils.fillTemplate(systemPrompt,location)
messagesWithSystemPrompt.put(createSystemMessage(_systemPrompt))
Log.d(TAG, "liwei--------- 系统提示词: ${_systemPrompt}")
}
// 再添加历史消息
for (i in 0 until historyMessages.length()) {
@ -665,44 +667,42 @@ object AgentService : CoroutineScope {
// 兜底处理,直接添加原消息
addToHistoryMessages(userMessage)
}
// Log.e("ChatApiService", "liwei---------sendMessageStream messages:${messagesWithSystemPrompt}")
chatApiService.sendMessageStream(
messagesWithSystemPrompt,
object : StreamCallback {
override fun onToken(token: String) {
responseBuilder.append(token)
if (speakResponse && !nobroadcast) {
if (speakResponse && broadcast) {
ttsService?.speakStream(token)
}
// 发送流式回复token
sendEvent("assistant_token", mapOf("token" to token))
if (broadcast){
// 发送流式回复token
sendEvent("assistant_token", mapOf("token" to token))
}
}
override fun onComplete() {
// 视情况决定是否朗读回复
if (speakResponse && !nobroadcast) {
if (speakResponse && broadcast) {
ttsService?.flushStream()
}
val response = responseBuilder.toString()
// 发送完整回复,包含是否有图片的标记
val responseData = mutableMapOf<String, Any>(
"text" to response,
"userInput" to displayText
)
if (hasImage) {
responseData["hasImage"] = true
}
sendEvent("assistant_response", responseData)
if (response.isNotEmpty()) {
// 发送完整回复,包含是否有图片的标记
val responseData = mutableMapOf<String, Any>(
"text" to response,
"userInput" to displayText
)
if (hasImage) {
responseData["hasImage"] = true
}
sendEvent("assistant_response", responseData)
// 添加AI回复到历史记录
addToHistoryMessages(createAssistantMessage(response))
// 保存聊天记录
saveChatMessage(displayText, response,aiMetadata,userMetadata.toString())
}
// 保存聊天记录
saveChatMessage(displayText, response,aiMetadata,userMetadata.toString())
// 标记AI流式输出已完成
isAiStreaming = false
currentAiJob = null
@ -739,15 +739,14 @@ 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);
val (metestr, _broadcast)= autoHandleFcunCallResult(name,functionCallResult);
aiMetadata = metestr;
nobroadcast = broadcast
broadcast = _broadcast
sendEvent("function_call_result", mapOf(
"function_call" to functionCall.toString(),
"result" to functionCallResult.toString(),
"meta" to metestr,
))
}
}
)
@ -861,14 +860,15 @@ object AgentService : CoroutineScope {
)
if (userMessageId != -1L) {
// 保存AI回复
val assistantMessageId = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = assistantMessage,
sender = "assistant",
metadata = aiMetadata
)
if (assistantMessage.isNotEmpty() || aiMetadata.isNotEmpty()) {
// 保存AI回复
val assistantMessageId = chatStorageHelper.saveMessage(
sessionId = sessionId,
message = assistantMessage,
sender = "assistant",
metadata = aiMetadata
)
}
// if (assistantMessageId != -1L) {
// Log.d(TAG, "聊天记录已保存:用户消息ID=$userMessageId, 助手消息ID=$assistantMessageId")
// } else {
@ -970,7 +970,7 @@ object AgentService : CoroutineScope {
/**
* 创建带图片的用户消息
*/
private fun createUserMessageWithImage(text: String, imageBase64: String): JSONObject {
private fun createUserMessageWithImage(text: String, imageUrl: String): JSONObject {
val content = JSONArray().apply {
if (text.isNotEmpty()) {
put(JSONObject().apply {
@ -981,8 +981,8 @@ object AgentService : CoroutineScope {
put(JSONObject().apply {
put("type", "image_url")
put("image_url", JSONObject().apply {
put("url", "data:image/jpeg;base64,$imageBase64")
put("detail", "auto")
// put("url", "data:image/jpeg;base64,$imageBase64")
put("url", imageUrl)
})
})
}
@ -996,7 +996,7 @@ object AgentService : CoroutineScope {
/**
* 处理图片输入
*/
fun processImageInput(imagePath: String, text: String = "", speakResponse: Boolean = false): Boolean {
fun processImageInput(imagePath: String, imageUrl: String,text: String = "", speakResponse: Boolean = false): Boolean {
if (!isInitialized) {
Log.e(TAG, "服务未初始化")
sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化"))
@ -1019,16 +1019,16 @@ object AgentService : CoroutineScope {
launch {
try {
// 将图片转换为Base64格式
val imageBase64 = chatApiService.fileToBase64(imagePath) ?: run {
sendEvent("error", mapOf(
"code" to "IMAGE_CONVERSION_FAILED",
"message" to "图片转换失败"
))
return@launch
}
// val imageBase64 = chatApiService.fileToBase64(imagePath) ?: run {
// sendEvent("error", mapOf(
// "code" to "IMAGE_CONVERSION_FAILED",
// "message" to "图片转换失败"
// ))
// return@launch
// }
// 创建带图片的用户消息并处理
val userMessage = createUserMessageWithImage(text, imageBase64)
val userMessage = createUserMessageWithImage(text, imageUrl)
// 图片描述用于存储
val displayText = text.ifEmpty { "[图片]" }
@ -1037,7 +1037,7 @@ object AgentService : CoroutineScope {
"status" to "ready",
"imagePath" to imagePath
))
Log.d(TAG, "liwei--------- 图像Mesage: ${userMessage}")
// 处理消息并传入存储路径
processWithChatApiInternal(userMessage, displayText, speakResponse, true, imagePath)
} catch (e: Exception) {
@ -1125,54 +1125,58 @@ object AgentService : CoroutineScope {
val textStr = content.optString("text")
println("协议工具返回数据 检查是否存在卡片! textStr: ${textStr}")
val metadata: MutableMap<String, Any> = mutableMapOf()
var broadcast = false;
println("协议工具返回数据 检查是否存在卡片!")
var broadcast = true;
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
}
// Log.d(TAG, "mcp调用结果: $metaStr")
if (meta.has("card_music")) { //音乐卡片
val cardMusic = meta.getJSONObject("card_music")
val id = cardMusic.optString("id", "")
val id = cardMusic.optInt("id", 0)
val url = cardMusic.optString("url", "")
val name = cardMusic.optString("name", "")
val sgener = cardMusic.optString("sgener", "")
val image = cardMusic.optString("image", "")
val ts = cardMusic.optInt("ts", 0)
processMusicPlay(
mapOf(
"id" to id,
"url" to url,
"title" to name,
"artist" to sgener,
"coverUrl" to image
"coverUrl" to image,
"ts" to ts
)
)
broadcast = true;
} else if (meta.has("card_musiclist")) { //音乐列表
val cardMusiclist = meta.getJSONObject("card_musiclist")
val musics = cardMusiclist.optJSONArray("musics")
if (musics != null) {
val playlist = mutableListOf<Map<String, String>>()
val playlist = mutableListOf<Map<String, Any>>()
for (i in 0 until musics.length()) {
val item = musics.optJSONObject(i) ?: continue
val id = item.optString("id", "")
val id = item.optInt("id", 0)
val url = item.optString("url", "")
val name = item.optString("name", "")
val sgener = item.optString("sgener", "")
val image = item.optString("image", "")
val ts = item.optInt("ts", 0)
playlist.add(
mapOf(
"id" to id,
"url" to url,
"title" to name,
"artist" to sgener,
"coverUrl" to image
"coverUrl" to image,
"ts" to ts
)
)
}
if (playlist.isNotEmpty()) {
broadcast = true;
processMusicPlayList(playlist)
} else {
Log.w(TAG, "card_musiclist 中没有有效的音乐条目")
@ -1184,7 +1188,6 @@ object AgentService : CoroutineScope {
val cardNavigation = meta.getJSONObject("card_navigation")
val start = cardNavigation.optString("start", "")
val end = cardNavigation.optString("end", "")
broadcast = true;
processNavigation(start, end);
}
return Pair(metaStr, broadcast);
@ -1193,7 +1196,7 @@ object AgentService : CoroutineScope {
// 捕获其他类型的异常(可选)
println("为解析到卡片数据!")
}
return Pair("", false);
return Pair("", true);
}
/**
@ -1206,7 +1209,7 @@ object AgentService : CoroutineScope {
* @param singer 歌手名称,默认为空
* @return 是否成功开始处理
*/
fun processMusicPlay(song: Map<String,String>){
fun processMusicPlay(song: Map<String,Any>){
// 在其他 Service、BroadcastReceiver 或 Application 中调用
Log.i(TAG, "播放音乐: ${song}")
MusicServiceStarter.startServiceWithCommand(context, command = "play", song = song)
@ -1221,7 +1224,7 @@ object AgentService : CoroutineScope {
* @param singer 歌手,默认为false
* @return 是否成功开始处理
*/
fun processMusicPlayList(songs:List<Map<String,String>>){
fun processMusicPlayList(songs:List<Map<String,Any>>){
// 如果 playlist 中有有效的歌曲,开始播放
if (songs.isNotEmpty()) {
MusicServiceStarter.startServiceWithPlaylist(context, command = "playlist", songs = songs)

8
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -57,6 +57,7 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
config["openaiApiKey"]?.toString()?.let { intent.putExtra("openaiApiKey", it) }
config["openaiBaseUrl"]?.toString()?.let { intent.putExtra("openaiBaseUrl", it) }
config["openaiModel"]?.toString()?.let { intent.putExtra("openaiModel", it) }
config["openaivisionModel"]?.toString()?.let { intent.putExtra("openaivisionModel", it) }
config["systemPrompt"]?.toString()?.let { intent.putExtra("systemPrompt", it) }
config["mcpServer"]?.toString()?.let { intent.putExtra("mcpServer", it) }
config["volcanoAppId"]?.toString()?.let { intent.putExtra("volcanoAppId", it) }
@ -130,13 +131,18 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
result.error("INVALID_ARGUMENTS", "图片路径不能为空", null)
return
}
val imageUrl = call.argument<String>("imageUrl")
if (imageUrl.isNullOrEmpty()) {
result.error("INVALID_ARGUMENTS", "图片Url不能为空", null)
return
}
val text = call.argument<String>("text") ?: ""
val speakResponse = call.argument<Boolean>("speakResponse") ?: false
try {
val isProcessing = AgentService.processImageInput(
imagePath = imagePath,
imageUrl = imageUrl,
text = text,
speakResponse = speakResponse
)

3
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -43,6 +43,7 @@ class BleMediaService : Service(), CoroutineScope {
private const val KEY_OPENAI_API_KEY = "openaiApiKey"
private const val KEY_OPENAI_BASE_URL = "openaiBaseUrl"
private const val KEY_OPENAI_MODEL = "openaiModel"
private const val KEY_OPENAI_VISION_MODEL = "openaivisionModel"
private const val KEY_SYSTEM_PROMPT = "systemPrompt"
private const val KEY_MCP_SERVER = "mcpServer"
private const val KEY_VOLCANO_APP_ID = "volcanoAppId"
@ -165,6 +166,7 @@ class BleMediaService : Service(), CoroutineScope {
it.getStringExtra(KEY_OPENAI_API_KEY)?.let { value -> config[KEY_OPENAI_API_KEY] = value }
it.getStringExtra(KEY_OPENAI_BASE_URL)?.let { value -> config[KEY_OPENAI_BASE_URL] = value }
it.getStringExtra(KEY_OPENAI_MODEL)?.let { value -> config[KEY_OPENAI_MODEL] = value }
it.getStringExtra(KEY_OPENAI_VISION_MODEL)?.let { value -> config[KEY_OPENAI_VISION_MODEL] = value }
it.getStringExtra(KEY_SYSTEM_PROMPT)?.let { value -> config[KEY_SYSTEM_PROMPT] = value }
it.getStringExtra(KEY_MCP_SERVER)?.let { value -> config[KEY_MCP_SERVER] = value }
it.getStringExtra(KEY_VOLCANO_APP_ID)?.let { value -> config[KEY_VOLCANO_APP_ID] = value }
@ -198,6 +200,7 @@ class BleMediaService : Service(), CoroutineScope {
KEY_OPENAI_API_KEY to getConfigString(prefs, KEY_OPENAI_API_KEY, ""),
KEY_OPENAI_BASE_URL to getConfigString(prefs, KEY_OPENAI_BASE_URL, "https://api.openai.com/v1"),
KEY_OPENAI_MODEL to getConfigString(prefs, KEY_OPENAI_MODEL, "gpt-3.5-turbo"),
KEY_OPENAI_VISION_MODEL to getConfigString(prefs, KEY_OPENAI_VISION_MODEL, "gpt-4-vision-preview"),
KEY_SYSTEM_PROMPT to getConfigString(prefs, KEY_SYSTEM_PROMPT, "gpt-3.5-turbo"),
KEY_MCP_SERVER to getConfigString(prefs, KEY_MCP_SERVER, ""),
KEY_VOLCANO_APP_ID to getConfigString(prefs, KEY_VOLCANO_APP_ID, ""),

3
local_plugins/agent_service/lib/agent_service.dart

@ -326,11 +326,12 @@ class AgentService {
/// [text] 可选的文本描述或问题
/// [speakResponse] 是否朗读响应
/// 返回是否成功处理图片
static Future<bool> processImageInput(String imagePath,
static Future<bool> processImageInput(String imagePath, String imageUrl,
{String text = "", bool speakResponse = false}) async {
try {
final bool result = await _channel.invokeMethod('processImageInput', {
'imagePath': imagePath,
'imageUrl': imageUrl,
'text': text,
'speakResponse': speakResponse,
});

8
local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/AmapServicePlugin.kt

@ -1,7 +1,13 @@
package com.yunqiinnovation.amap_service
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat.registerReceiver
import com.amap.api.navi.AMapNaviView
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
@ -98,4 +104,6 @@ class AmapServicePlugin: FlutterPlugin, MethodCallHandler,EventChannel.StreamHan
}
}

37
local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationActivity.kt

@ -1,6 +1,10 @@
package com.yunqiinnovation.amap_service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
@ -23,7 +27,7 @@ import com.amap.api.navi.model.AimLessModeCongestionInfo
import com.amap.api.navi.model.AimLessModeStat
import com.amap.api.navi.model.NaviInfo
import com.amap.api.navi.model.NaviLatLng
import com.yunqiinnovation.amap_service.NavigationService.Companion.sharedNavi
class NavigationActivity : AppCompatActivity(), AMapNaviListener, AMapNaviViewListener {
private lateinit var naviView: AMapNaviView
@ -32,6 +36,7 @@ class NavigationActivity : AppCompatActivity(), AMapNaviListener, AMapNaviViewLi
private var startpos:String = "";
private var endpos:String = "";
private var ntype:String = "";
private var isReceiverRegistered = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_navigation)
@ -60,7 +65,31 @@ class NavigationActivity : AppCompatActivity(), AMapNaviListener, AMapNaviViewLi
}
navi?.addAMapNaviListener(this);
// 绑定当前 activity 到导航状态
// 注册广播监听器
val filter = IntentFilter().apply {
addAction("com.yunqiinnovation.amap_service.EXIT_NAVIGATION")
}
// Android 14+ 需要指定 flag
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(controlReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(controlReceiver, filter)
}
isReceiverRegistered = true
}
private val controlReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.i("NavigationActivity", "接受到通知事件 ${intent?.action}")
when (intent?.action) {
"com.yunqiinnovation.amap_service.EXIT_NAVIGATION" -> {
Log.d("NavigationService", "收到退出导航广播,准备停止服务")
finish()
}
}
}
}
override fun onResume() {
@ -80,6 +109,10 @@ class NavigationActivity : AppCompatActivity(), AMapNaviListener, AMapNaviViewLi
NavigationServiceHelper.startNavigation(context,"stop")
naviView.onDestroy()
super.onDestroy()
if (isReceiverRegistered) {
unregisterReceiver(controlReceiver)
isReceiverRegistered = false
}
Log.i("NavigationActivity", "liwei------------------- onDestroy!")
}

40
local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt

@ -3,8 +3,10 @@ package com.yunqiinnovation.amap_service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.os.Build
import android.os.IBinder
@ -52,11 +54,24 @@ class NavigationService: Service(),AMapNaviListener {
fun isServiceRunning() = isRunning.get()
}
private var isReceiverRegistered = false
override fun onCreate() {
super.onCreate()
instance = this
startForegroundNotification()
// 注册广播监听器
val filter = IntentFilter().apply {
addAction("com.yunqiinnovation.amap_service.EXIT_NAVIGATION")
}
// Android 14+ 需要指定 flag
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(controlReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(controlReceiver, filter)
}
isReceiverRegistered = false
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
@ -84,7 +99,17 @@ class NavigationService: Service(),AMapNaviListener {
}
return START_STICKY
}
private val controlReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.i(TAG, "接受到通知事件 ${intent?.action}")
when (intent?.action) {
"com.yunqiinnovation.amap_service.EXIT_NAVIGATION" -> {
Log.d("NavigationService", "收到退出导航广播,准备停止服务")
stopNavigation();
}
}
}
}
private fun initializeAMapNavi() {
try {
sharedNavi = AMapNavi.getInstance(applicationContext).apply {
@ -399,4 +424,15 @@ class NavigationService: Service(),AMapNaviListener {
Log.e(TAG, "onGpsSignalWeak! $p0")
// TODO("Not yet implemented")
}
override fun onDestroy() {
if (isReceiverRegistered) {
unregisterReceiver(controlReceiver)
isReceiverRegistered = false
}
stopForeground(true)
isRunning.set(false)
super.onDestroy()
}
}

232
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

@ -24,15 +24,8 @@ import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import com.yunqiinnovation.azure_speech.tools.RecordFile
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import android.os.Handler
import android.os.HandlerThread
/**
* Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别
@ -64,14 +57,14 @@ class AzureAsrHelper(private val context: Context) {
// 音频处理
private var microphoneStream: MicrophoneStream? = null
private var externalAudioStream: ExternalAudioPullStream? = null
// 音频模式管理
private var audioManager: AudioManager? = null
private var originalAudioMode = AudioManager.MODE_NORMAL
// 录音文件处理
var recordfile: RecordingFile? = null
private var isRecording = false
var recordfile: RecordFile? = null
/**
* 音频来源类型
@ -108,7 +101,7 @@ class AzureAsrHelper(private val context: Context) {
// 释放之前的资源
dispose()
// 初始化音频管理器
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
originalAudioMode = audioManager?.mode ?: AudioManager.MODE_NORMAL
@ -153,7 +146,7 @@ class AzureAsrHelper(private val context: Context) {
setProperty("Speech_SegmentationStrategy", "Time")
}
// 录音文件类
recordfile = RecordingFile()
recordfile = RecordFile;
// 创建识别器
val setupSuccess = setupRecognizer()
if (setupSuccess) {
@ -360,10 +353,7 @@ class AzureAsrHelper(private val context: Context) {
// 开始连续识别
recognizer?.startContinuousRecognitionAsync()
isContinuousRecognitionActive = true
Log.e(tag, "开始连续语音识别 是否录音${isRecording}")
if (isRecording) {
enableRecord()
}
return true
} catch (e: Exception) {
stopAudioProcessing()
@ -510,7 +500,7 @@ class AzureAsrHelper(private val context: Context) {
// 停止音频处理
stopAudioProcessing()
// 停止录音
recordfile!!.closeFile()
recordfile!!.closeFile(true)
// 释放recognizer
recognizer?.close()
recognizer = null
@ -522,7 +512,7 @@ class AzureAsrHelper(private val context: Context) {
// 释放音频配置
audioConfig?.close()
audioConfig = null
isRecording = false
// 确保状态被重置
isContinuousRecognitionActive = false
microphoneStream = null
@ -532,7 +522,7 @@ class AzureAsrHelper(private val context: Context) {
speechConfig = null
} catch (e: Exception) {
// 确保状态被重置
isRecording = false
isContinuousRecognitionActive = false
microphoneStream = null
externalAudioStream = null
@ -588,41 +578,56 @@ class AzureAsrHelper(private val context: Context) {
/**
* 开启录音
*/
fun enableRecord() {
fun enableRecord(filePath: String) {
Log.i(tag, "开启录音:")
if( recordfile!!.isPause)
{
recordfile!!.isPause=false
}
else{
recordfile!!.closeFile()
isRecording = true
if (isContinuousRecognitionActive) {
recordfile!!.creatingFiles()
}
}
recordfile!!.closeFile(true)
recordfile!!.creatingFiles(filePath)
}
/**
* 移动文件到新路径
*/
fun moveFile(sourcePath: String, destPath: String): Boolean {
Log.i(tag, "移动文件到新路径:")
recordfile!!.moveFile(sourcePath, destPath)
return true
}
/**
* 重命名指定路径的音频文件
*/
fun renameFile(filePath: String, newName: String): Boolean {
Log.i(tag, "重命名指定路径的音频文件:")
recordfile!!.renameFile(filePath, newName)
return true
}
/**
/**
* 停止录音
*/
fun pauseRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=true
recordfile!!.isPause = true
}
/**
* 停止录音
* 关闭录音
*/
fun stopRecord() {
Log.i(tag, "停止连续录音:")
isRecording = false
recordfile!!.isPause=false
recordfile!!.closeFile()
fun stopRecord(isSave: Boolean) {
Log.i(tag, "关闭录音:")
recordfile!!.isPause = false
recordfile!!.closeFile(isSave)
}
/**
@ -702,11 +707,11 @@ else{
*/
private fun applyAudioEffects() {
val sessionId = audioRecord?.audioSessionId ?: return
try {
// 设置音频模式为通信模式
this@AzureAsrHelper.audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION
// 启用声学回音消除
if (AcousticEchoCanceler.isAvailable()) {
echoCanceler = AcousticEchoCanceler.create(sessionId)
@ -715,7 +720,7 @@ else{
} else {
Log.w(tag, "设备不支持声学回音消除")
}
// 启用噪声抑制
if (NoiseSuppressor.isAvailable()) {
noiseSuppressor = NoiseSuppressor.create(sessionId)
@ -724,7 +729,7 @@ else{
} else {
Log.w(tag, "设备不支持噪声抑制")
}
// 启用自动增益控制
if (AutomaticGainControl.isAvailable()) {
automaticGainControl = AutomaticGainControl.create(sessionId)
@ -733,7 +738,7 @@ else{
} else {
Log.w(tag, "设备不支持自动增益控制")
}
Log.i(tag, "回音消除和音频增强功能已全部启用")
} catch (e: Exception) {
Log.e(tag, "设置音频效果失败: ${e.message}")
@ -791,21 +796,21 @@ else{
it.release()
echoCanceler = null
}
// 释放噪声抑制器
noiseSuppressor?.let {
it.enabled = false
it.release()
noiseSuppressor = null
}
// 释放自动增益控制
automaticGainControl?.let {
it.enabled = false
it.release()
automaticGainControl = null
}
// 恢复音频模式
this@AzureAsrHelper.audioManager?.mode = this@AzureAsrHelper.originalAudioMode
@ -941,133 +946,4 @@ else{
}
inner class RecordingFile {
private var currentAudioFile: File? = null
private var fos: FileOutputStream? = null
// 新增:用于异步写入的队列和线程
private val writeQueue = LinkedBlockingQueue<ByteArray>()
private val isWriting = AtomicBoolean(false)
private var writeThread: Thread? = null
// 用于存储音频数据的缓冲区
private val dataBuffer = mutableListOf<ByteArray>()
private var totalBytesWritten = 0
// 音频配置
private val sampleRate = 16000
var isPause=false
var fileName = ""
internal fun creatingFiles() {
if (fos != null || currentAudioFile != null) {
return
}
// 创建新的音频文件
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss")
val timestamp = dateFormat.format(Date())
val parentDir = File(context.filesDir, "TranslatAudio")
if (!parentDir.exists()) {
parentDir.mkdirs()
}
val filePath = File(parentDir, "${fileName}_${timestamp}.wav").absolutePath
currentAudioFile = File(filePath)
currentAudioFile?.createNewFile()
// 仅写入初始文件头
fos = FileOutputStream(currentAudioFile).apply {
write(generateWavHeader(0)) // 初始0长度
close()
}
// 追加音频数据到文件
fos = FileOutputStream(currentAudioFile, true)
startWriteThread() // 新增:启动写入线程
}
// 新增:启动写入线程
private fun startWriteThread() {
if (isWriting.get()) return
isWriting.set(true)
writeThread = Thread {
try {
while (isWriting.get() || writeQueue.isNotEmpty()) {
val data = writeQueue.poll() ?: continue
fos?.write(data)
}
} catch (e: Exception) {
Log.e(tag, "异步写入音频数据失败: ${e.message}")
}
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
writeQueue.offer(buffer.copyOf())
totalBytesWritten += buffer.size
}
// 更新文件头生成(修正RIFF长度计算)
private fun generateWavHeader(dataLength: Int): ByteArray {
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数
return byteArrayOf(
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(),
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(),
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(),
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(),
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(),
16, 0, 0, 0, // PCM头长度
1, 0, // PCM格式
1, 0, // 单声道
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率
2, 0, // 块对齐 (通道数 * 样本位数/8)
16, 0, // 样本位数
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(),
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(),
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte()
)
}
internal fun closeFile() {
try {
if (fos == null || currentAudioFile == null) {
return
}
isWriting.set(false)
writeThread?.join(500) // 等待写线程结束
fos?.close()
// 更新WAV文件头
currentAudioFile?.let { file ->
// 如果文件小于44字节,直接删除
if (file.length() <= 44) {
file.delete()
Log.d(tag, "音频文件过小已删除: ${file.absolutePath}")
} else {
RandomAccessFile(file, "rw").use { raf ->
raf.seek(0)
raf.write(generateWavHeader(totalBytesWritten))
}
Log.d(tag, "音频文件保存完成: ${file.absolutePath}")
}
}
} catch (e: Exception) {
Log.e(tag, "更新WAV文件头失败: ${e.message}")
} finally {
fos = null
currentAudioFile = null
}
}
}
}

52
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -11,7 +11,6 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.EventChannel
import com.microsoft.cognitiveservices.speech.audio.*
import com.deep_voice.speech.tts.TtsEvent
import com.deep_voice.speech.tts.TtsEventListener
import com.deep_voice.speech.tts.TtsEventType
@ -354,24 +353,57 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin {
result.success(true)
}
"renameFile" -> {
val filePath = call.argument<String>("filePath") ?: ""
val newName = call.argument<String>("newName") ?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.renameFile(filePath, newName)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"moveFile" -> {
val sourcePath = call.argument<String>("sourcePath") ?: ""
val destPath = call.argument<String>("destPath") ?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${sourcePath}") //
azureAsrHelper.moveFile(sourcePath, destPath)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"enableRecord" -> {
val fileName = call.argument<String>("fileName")?: ""
val filePath = call.argument<String>("filePath") ?: ""
try {
FileLogger.d(tag, "音频文件名称为: ${fileName}") //
azureAsrHelper.recordfile?.fileName= fileName!!
azureAsrHelper.enableRecord()
result.success(true)
} catch (e: Exception) {
FileLogger.d(tag, "音频文件名称为: ${filePath}") //
azureAsrHelper.enableRecord(filePath)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
"pauseRecord" -> {
"pauseRecord" -> {
azureAsrHelper.pauseRecord()
result.success(true)
}
"stopRecord" -> {
azureAsrHelper.stopRecord()
result.success(true)
val isSave = call.argument<Boolean>("isSave") ?: false
try {
azureAsrHelper.stopRecord(isSave)
result.success(true)
} catch (e: Exception) {
result.error("ENABLERECORD_ERROR", e.message, null)
}
}
else -> {

2
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt

@ -395,6 +395,8 @@ class AzureTtsHelper(private val context: Context) : ITtsService {
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("#", "&gt;")
.replace("*", "&gt;")
.replace(Regex("[😀-🟿]+"), "") // 简化表情符号移除
.trim()

254
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt

@ -0,0 +1,254 @@
package com.yunqiinnovation.azure_speech.tools
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import android.util.Log
import java.io.FileInputStream
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
object RecordFile {
private var currentAudioFile: File? = null
private var fos: FileOutputStream? = null
// 新增:用于异步写入的队列和线程
private val writeQueue = LinkedBlockingQueue<ByteArray>()
private val isWriting = AtomicBoolean(false)
private var writeThread: Thread? = null
// 用于存储音频数据的缓冲区
private val dataBuffer = mutableListOf<ByteArray>()
private var totalBytesWritten = 0
// 音频配置
private val sampleRate = 16000
// 新增:用于存储最后成功保存的文件
private var lastSavedFile: File? = null
private var lastFileTimestamp: String = ""
var isPause = false
var fileName = ""
// 修改:添加 filePath 参数
internal fun creatingFiles(filePath: String) {
if (fos != null || currentAudioFile != null) {
return
}
val file = File(filePath)
val parentDir = file.parentFile
if (!parentDir.exists()) {
parentDir.mkdirs()
}
currentAudioFile = file
currentAudioFile?.createNewFile()
fos = FileOutputStream(currentAudioFile).apply {
write(generateWavHeader(0))
close()
}
fos = FileOutputStream(currentAudioFile, true)
startWriteThread()
}
// 新增:启动写入线程
private fun startWriteThread() {
if (isWriting.get()) return
isWriting.set(true)
writeThread = Thread {
try {
while (isWriting.get() || writeQueue.isNotEmpty()) {
val data = writeQueue.poll() ?: continue
fos?.write(data)
}
} catch (e: Exception) {
Log.e("tag", "异步写入音频数据失败: ${e.message}")
}
}
writeThread?.start()
}
/**
* 保存音频数据到 WAV 文件(异步)
*/
internal fun saveAudioDataToWav(buffer: ByteArray) {
if (fos == null || currentAudioFile == null) return
// 放入队列,由写线程写入
writeQueue.offer(buffer.copyOf())
totalBytesWritten += buffer.size
}
// 更新文件头生成(修正RIFF长度计算)
private fun generateWavHeader(dataLength: Int): ByteArray {
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数
return byteArrayOf(
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(),
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(),
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(),
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(),
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(),
16, 0, 0, 0, // PCM头长度
1, 0, // PCM格式
1, 0, // 单声道
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率
2, 0, // 块对齐 (通道数 * 样本位数/8)
16, 0, // 样本位数
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(),
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(),
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte()
)
}
/**
* 重命名指定路径的音频文件
* @param filePath 要重命名的文件完整路径
* @param newName 新的文件名(不含扩展名)
* @return true 重命名成功,false 失败
*/
internal fun renameFile(filePath: String, newName: String): Boolean {
val sourceFile = File(filePath)
// 验证源文件存在
if (!sourceFile.exists()) {
Log.w("tag", "重命名失败: 文件不存在 $filePath")
return false
}
// 防止重命名正在录制的文件
if (sourceFile == currentAudioFile) {
Log.w("tag", "重命名失败: 不能重命名正在录制的文件")
return false
}
val parentDir = sourceFile.parentFile
val newFile = File(parentDir, "$newName.wav") // 保持.wav扩展名
// 处理目标文件已存在的情况
if (newFile.exists()) {
Log.w("tag", "重命名失败: 目标文件已存在 ${newFile.absolutePath}")
return false
}
// 执行重命名
return try {
val success = sourceFile.renameTo(newFile)
if (success) {
Log.d("tag", "文件重命名成功: ${sourceFile.name} -> ${newFile.name}")
// 更新最后保存的文件引用(如果是同一个文件)
if (lastSavedFile == sourceFile) {
lastSavedFile = newFile
}
} else {
Log.w("tag", "文件重命名操作失败")
}
success
} catch (e: Exception) {
Log.e("tag", "重命名异常: ${e.message}")
false
}
}
/**
* 移动文件到新路径
* @param sourcePath 原始文件完整路径
* @param destPath 目标文件完整路径
* @return true 移动成功,false 移动失败
*/
internal fun moveFile(sourcePath: String, destPath: String): Boolean {
val sourceFile = File(sourcePath)
val destFile = File(destPath)
if (!sourceFile.exists()) {
Log.w("tag", "移动失败: 源文件不存在 ${sourceFile.absolutePath}")
return false
}
// 确保目标目录存在
val destDir = destFile.parentFile
if (!destDir.exists() && !destDir.mkdirs()) {
Log.w("tag", "移动失败: 无法创建目标目录 ${destDir.absolutePath}")
return false
}
// 删除已存在的目标文件
if (destFile.exists() && !destFile.delete()) {
Log.w("tag", "移动失败: 无法覆盖已存在文件 ${destFile.absolutePath}")
return false
}
return try {
// 使用renameTo尝试移动(同分区最快)
if (sourceFile.renameTo(destFile)) {
true
} else {
// 跨分区移动:复制+删除
FileInputStream(sourceFile).use { input ->
FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
if (!sourceFile.delete()) {
Log.w("tag", "移动警告: 源文件删除失败 ${sourceFile.absolutePath}")
}
true
}.also {
if (it) {
Log.d("tag", "文件移动成功: ${sourcePath} -> ${destPath}")
// 更新最后保存的文件引用
if (lastSavedFile?.absolutePath == sourcePath) {
lastSavedFile = destFile
}
}
}
} catch (e: Exception) {
Log.e("tag", "文件移动异常: ${e.message}")
false
}
}
internal fun closeFile(isSave: Boolean): Boolean {
try {
if (fos == null || currentAudioFile == null) {
return false
}
isWriting.set(false)
writeThread?.join(500)
fos?.close()
currentAudioFile?.let { file ->
if (file.length() <= 44 || isSave == false) {
file.delete()
Log.d("tag", "音频文件过小已删除: ${file.absolutePath}")
return false
} else {
RandomAccessFile(file, "rw").use { raf ->
raf.seek(0)
raf.write(generateWavHeader(totalBytesWritten))
}
Log.d("tag", "音频文件保存完成: ${file.absolutePath}")
// 保存成功文件引用
lastSavedFile = file
return true
}
}
} catch (e: Exception) {
Log.e("tag", "更新WAV文件头失败: ${e.message}")
return false
} finally {
fos = null
currentAudioFile = null
}
return false
}
}

4
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -16,14 +16,10 @@ import com.jieli.jl_audio_decode.exceptions.OpusException
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import android.os.Handler
import android.os.HandlerThread
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/**

67
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt

@ -37,30 +37,30 @@ class ChatApiPlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
chatApiService = ChatApiService(flutterPluginBinding.applicationContext)
// 设置流式回调
chatApiService?.setStreamCallback(object : StreamCallback {
override fun onToken(token: String) {
channel.invokeMethod("onToken", token)
}
override fun onComplete() {
channel.invokeMethod("onComplete", null)
}
override fun onError(error: Exception) {
channel.invokeMethod("onError", error.message)
}
override fun onFunctionCall(functionCall: org.json.JSONObject) {
channel.invokeMethod("onFunctionCall", functionCall.toString())
}
override fun onFunctionCallResult(functionCall: org.json.JSONObject, functionCallResult: org.json.JSONObject) {
channel.invokeMethod("onFunctionCallResult", mapOf(
"functionCall" to functionCall.toString(),
"functionCallResult" to functionCallResult.toString()
))
}
})
// chatApiService?.setStreamCallback(object : StreamCallback {
// override fun onToken(token: String) {
// channel.invokeMethod("onToken", token)
// }
//
// override fun onComplete() {
// channel.invokeMethod("onComplete", null)
// }
//
// override fun onError(error: Exception) {
// channel.invokeMethod("onError", error.message)
// }
//
// override fun onFunctionCall(functionCall: org.json.JSONObject) {
// channel.invokeMethod("onFunctionCall", functionCall.toString())
// }
//
// override fun onFunctionCallResult(functionCall: org.json.JSONObject, functionCallResult: org.json.JSONObject) {
// channel.invokeMethod("onFunctionCallResult", mapOf(
// "functionCall" to functionCall.toString(),
// "functionCallResult" to functionCallResult.toString()
// ))
// }
// })
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
@ -80,18 +80,19 @@ class ChatApiPlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandl
val apiKey = call.argument<String>("apiKey") ?: ""
val baseUrl = call.argument<String>("baseUrl") ?: ""
val model = call.argument<String>("model") ?: ""
val modelmedia = call.argument<String>("modelmedia") ?: ""
val mcpServer = call.argument<String>("mcpServer") ?: ""
val success = chatApiService?.initialize(apiKey, baseUrl, model, mcpServer) ?: false
val success = chatApiService?.initialize(apiKey, baseUrl, model,modelmedia, mcpServer) ?: false
result.success(success)
}
"chatCompletionStream" -> {
val messages = call.argument<List<Map<String, Any>>>("messages") ?: emptyList()
val tool = call.argument<Boolean>("tool") ?: false
pluginScope.launch {
chatApiService?.chatCompletionStream(messages, tool)
}
result.success(true)
}
// "chatCompletionStream" -> {
// val messages = call.argument<List<Map<String, Any>>>("messages") ?: emptyList()
// val tool = call.argument<Boolean>("tool") ?: false
// pluginScope.launch {
// chatApiService?.chatCompletionStream(messages, tool)
// }
// result.success(true)
// }
"cancelChatStream" -> {
chatApiService?.cancelChatStream()
result.success(true)

165
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt

@ -23,6 +23,7 @@ import android.util.Log
import org.json.JSONObject
import android.os.Handler
import android.os.Looper
import java.util.UUID
/**
* ChatAPI服务异常
@ -109,10 +110,12 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
// 流式请求相关
private var currentStreamJob: Job? = null
private var streamCallback: StreamCallback? = null
// private var streamCallback: StreamCallback? = null
private var currentMessages: List<Map<String, Any>> = emptyList()
private var toolCalls: MutableMap<Int, ToolCallInfo> = mutableMapOf()
private var isCanceled = false
private var sessions: MutableMap<String, StreamCallback?> = mutableMapOf()
private var currSessionId = "" //
// private var isCanceled = false
@ -124,14 +127,14 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
/**
* 设置流式回调
*/
fun setStreamCallback(callback: StreamCallback) {
streamCallback = callback
}
// fun setStreamCallback(callback: StreamCallback) {
// streamCallback = callback
// }
/**
* 初始化ChatAPI服务
*/
fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean {
fun initialize(apiKey: String, baseUrl: String, model: String, visionModel: String, mcpServer: String): Boolean {
this.apiKey = apiKey
if (baseUrl.isNotEmpty()) {
this.baseUrl = baseUrl
@ -139,7 +142,9 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
if (model.isNotEmpty()) {
this.model = model
}
if (visionModel.isNotEmpty()) {
this.visionModel = visionModel
}
// 处理 baseUrl:移除末尾的 /chat/completions(如果存在)
// 因为 openai-kotlin 会自动拼接 /chat/completions
var processedBaseUrl = if (this.baseUrl.endsWith("/chat/completions")) {
@ -258,30 +263,30 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
*/
fun sendMessageStream(messages: org.json.JSONArray, callback: StreamCallback) {
// 设置回调
setStreamCallback(callback)
// setStreamCallback(callback)
// 转换JSONArray为List<Map<String, Any>>
val messageList = convertJsonArrayToList(messages)
currSessionId = UUID.randomUUID().toString()
sessions[currSessionId] = callback
// 调用原始方法
sendMessageStream(messageList)
sendMessageStream(currSessionId,messageList)
}
/**
* 发送消息(流式输出)
*/
fun sendMessageStream(messages: List<Map<String, Any>>) {
fun sendMessageStream(sessionid:String,messages: List<Map<String, Any>>) {
if (!isInitialized || apiKey.isEmpty() || openAI == null) {
Log.e("ChatApiService", "ChatAPI服务未初始化,无法发送消息")
streamCallback?.onError(ChatApiException("ChatAPI服务未初始化"))
sessions[sessionid]?.onError(ChatApiException("ChatAPI服务未初始化"))
return
}
// 重置状态
isCanceled = false
// isCanceled = false
currentMessages = messages
toolCalls.clear()
// 检查是否包含图片,决定使用哪个模型
val currentModel = if (containsImage(messages)) visionModel else model
@ -323,7 +328,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
Log.e("ChatApiService", "openAI对象为null")
throw IllegalStateException("OpenAI客户端未初始化")
}
// Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}")
val flow = openAI!!.chatCompletions(chatCompletionRequest)
flow
} catch (e: Exception) {
@ -332,7 +337,9 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
}
chatsFlow.collect { result ->
if (isCanceled) return@collect
if (currSessionId != sessionid) {
return@collect
}
val choice = result.choices.firstOrNull()
if (choice == null) {
@ -348,7 +355,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
// 处理普通文本内容
delta.content?.let { content ->
streamCallback?.onToken(content)
sessions[sessionid]?.onToken(content)
}
// 收集工具调用信息
@ -389,18 +396,20 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
}
}
if (!isCanceled) {
if (currSessionId == sessionid) {
// 检查是否有工具调用需要处理
val hasToolCalls = processToolCalls()
val hasToolCalls = processToolCalls(sessionid)
if (!hasToolCalls) {
streamCallback?.onComplete()
sessions[sessionid]?.onComplete()
}
}else{
sessions[sessionid]?.onComplete()
}
} catch (e: Exception) {
if (!isCanceled) {
streamCallback?.onError(ChatApiException("流式请求失败: ${e.message}"))
}
// if (currSessionId == sessionid) {
sessions[sessionid]?.onError(ChatApiException("流式请求失败: ${e.message}"))
// }
}
}
}
@ -408,7 +417,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
/**
* 处理工具调用
*/
private suspend fun processToolCalls(): Boolean {
private suspend fun processToolCalls(sessionid: String): Boolean {
val firstToolCall = try {
toolCalls.values.firstOrNull { it.isValid() }
} catch (e: Exception) {
@ -430,11 +439,11 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
return false
}
// 通知上层工具调用事件
streamCallback?.onFunctionCall(convertMapToJsonObject(functionCall))
sessions[sessionid]?.onFunctionCall(convertMapToJsonObject(functionCall))
// 在后台队列处理工具调用
launch {
try {
if (!isCanceled) {
if (currSessionId == sessionid) {
// 通过MCP客户端处理工具调用
val functionName = firstToolCall.name
val argumentsJson = firstToolCall.arguments
@ -471,29 +480,35 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
// 工具不存在
mapOf("context" to "Tool not found: $functionName")
}
if (!isCanceled) {
if (currSessionId == sessionid) {
// 处理结果
streamCallback?.onFunctionCallResult(
sessions[sessionid]?.onFunctionCallResult(
convertMapToJsonObject(functionCall),
convertMapToJsonObject(result)
)
// 将结果发送回OpenAI继续对话
sendFunctionCallResultInternal(
sessionid= sessionid,
messages = currentMessages,
functionCall = functionCall,
functionResult = gson.toJson(result)
)
}else{
sessions[sessionid]?.onComplete()
}
}
} catch (e: Exception) {
Log.e("ChatApiService", "工具调用处理过程中出错: ${e.message}", e)
if (!isCanceled) {
if (currSessionId == sessionid) {
val errorMessage = "工具调用处理失败: ${e.message}"
sendFunctionCallResultInternal(
sessionid= sessionid,
messages = currentMessages,
functionCall = functionCall,
functionResult = errorMessage
)
}else{
sessions[sessionid]?.onComplete()
}
}
}
@ -505,11 +520,12 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
* 发送函数调用结果(内部方法)
*/
private suspend fun sendFunctionCallResultInternal(
sessionid: String,
messages: List<Map<String, Any>>,
functionCall: Map<String, Any>,
functionResult: String
) {
if (isCanceled) return
if (currSessionId != sessionid) return
val fullMessages = messages.toMutableList()
@ -542,14 +558,16 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
)
// 重新发起对话,让AI基于工具调用结果生成回复
sendMessageStream(fullMessages)
sendMessageStream(sessionid,fullMessages)
}
/**
* 取消当前流式请求
*/
fun cancelCurrentStream(): Boolean {
isCanceled = true
sessions[currSessionId]?.onComplete()
sessions.remove(currSessionId)
currSessionId = ""
currentStreamJob?.cancel()
currentStreamJob = null
return true
@ -778,9 +796,9 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
* 聊天完成流式接口
* 与iOS版本保持一致的接口
*/
fun chatCompletionStream(messages: List<Map<String, Any>>, tool: Boolean = false) {
fun chatCompletionStream(sessionid: String,messages: List<Map<String, Any>>, tool: Boolean = false) {
// 直接调用sendMessageStream,因为该方法已经处理了工具调用
sendMessageStream(messages)
sendMessageStream(sessionid,messages)
}
// MARK: - 工具方法
@ -893,27 +911,70 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
}
is List<*> -> {
// 对于多模态内容,提取文本部分(简化处理)
var textContent = ""
val contentParts = mutableListOf<ContentPart>()
content.forEach { item ->
if (item is Map<*, *>) {
val type = item["type"] as? String
if (type == "text") {
val text = item["text"] as? String ?: ""
textContent += text
when (type) {
"text" -> {
val text = item["text"] as? String ?: ""
contentParts.add(TextPart(text))
}
"image_url" -> {
val imageUrlMap = item["image_url"] as? Map<*, *>
val url = imageUrlMap?.get("url") as? String ?: ""
val detail = imageUrlMap?.get("detail") as? String ?: "auto"
if (url.isNotEmpty()) {
contentParts.add(ImagePart(url, detail))
}
} // 处理其他可能的内容类型
else -> {
// 记录未知类型,便于调试
Log.d("MessageParser", "Unknown content type: $type")
}
}
} else {
// 非Map类型的内容,记录日志
Log.d("MessageParser", "Item is not a Map: $item")
}
}
val finalContent = if (textContent.isEmpty()) "多媒体内容" else textContent
val chatMessage = when (chatRole) {
Role.System -> ChatMessage.System(finalContent)
Role.User -> ChatMessage.User(finalContent)
Role.Assistant -> ChatMessage.Assistant(finalContent)
else -> ChatMessage.User(finalContent)
// 创建消息对象
if (contentParts.isNotEmpty()) {
val chatMessage = when (chatRole) {
Role.System -> {
// 系统消息简化显示,将图片替换为[图片]
val displayText = contentParts.joinToString { part ->
when (part) {
is TextPart -> part.text // 使用TextPart
is ImagePart -> "[图片]" // 使用ImagePart
}
}
ChatMessage.System(displayText)
}
Role.User -> ChatMessage.User(contentParts)
Role.Assistant -> {
// 将多模态内容转换为ListContent
val listContent = ListContent(contentParts)
// 提取文本内容作为Assistant消息的content
val textContent = contentParts
.filterIsInstance<TextPart>()
.joinToString(separator = "") { it.text }
// 调用Assistant函数,传递文本内容
ChatMessage.Assistant(
content = textContent.ifEmpty { null },
toolCalls = null // 如果有工具调用,需要在这里添加
)
}
else -> ChatMessage.User(contentParts)
}
chatMessages.add(chatMessage)
} else {
// 内容为空时的处理
Log.d("MessageParser", "No valid content parts found")
}
chatMessages.add(chatMessage)
}
}
}
@ -1035,13 +1096,13 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
*/
private fun convertJsonArrayToList(jsonArray: org.json.JSONArray): List<Map<String, Any>> {
val result = mutableListOf<Map<String, Any>>()
// Log.e("ChatApiService", "convertJsonArrayToList 1:${jsonArray}")
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.optJSONObject(i) ?: continue
val map = convertJsonObjectToMap(jsonObject)
result.add(map)
}
// Log.e("ChatApiService", "convertJsonArrayToList 2:${result}")
return result
}
@ -1123,9 +1184,9 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor
}
// 通知Flutter端处理函数调用
mainHandler.post {
streamCallback?.onFunctionCall(functionCall)
}
// mainHandler.post {
// streamCallback?.onFunctionCall(functionCall)
// }
// 返回一个标记,表示函数已被调用
return "Function '$functionName' called with arguments: $arguments"

53
local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt

@ -163,7 +163,17 @@ class SystemFunctionHandler(private val context: Context? = null) {
),
handler = MediaPauseHandler(context)
)
// 注册媒体暂停功能
client.registerLocalFunction(
name = "media_close",
description = "关闭媒体播放",
parameters = mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
handler = MediaCloseHandler(context)
)
// 注册媒体上一首功能
client.registerLocalFunction(
name = "media_previous",
@ -199,6 +209,32 @@ class SystemFunctionHandler(private val context: Context? = null) {
),
handler = OpenRecorderHandler(context)
)
// 注册退出交互函数
client.registerLocalFunction(
name = "exit_navigation",
description = "结束当前导航服务",
parameters = mapOf(
"type" to "object",
"properties" to emptyMap<String, Any>(),
"required" to emptyList<String>()
),
handler = ExitNavigationHandler(context)
)
}
}
/**
* 退出导航服务
*/
private class ExitNavigationHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
// 发送广播通知退出交互
val intent = Intent("com.yunqiinnovation.amap_service.EXIT_NAVIGATION").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已结束当前导航服务\"}"
}
}
@ -417,7 +453,20 @@ private class MediaPauseHandler(private val context: Context?) : FunctionHandler
return "{\"result\": \"已暂停媒体播放\"}"
}
}
/**
* 媒体暂停处理器
*/
private class MediaCloseHandler(private val context: Context?) : FunctionHandler {
override suspend fun handle(arguments: Map<String, Any>): String {
Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context")
// 发送媒体暂停广播
val intent = Intent("com.yunqiinnovation.music_service.MEDIA_CLOSE").apply {
setPackage(context?.packageName) // 设置包名使其成为显式 Intent
}
context?.sendBroadcast(intent)
return "{\"result\": \"已关闭媒体播放\"}"
}
}
/**
* 媒体上一首处理器
*/

6
local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift

@ -184,9 +184,9 @@ public class ChatApiService: NSObject {
// 检查是否包含图片,决定使用哪个模型
var currentModel = model
if containsImage(in: messages) {
currentModel = visionModel
}
// if containsImage(in: messages) {
// currentModel = visionModel
// }
// 转换消息格式
let chatMessages = try convertToChatMessages(messages)

16
local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt

@ -15,32 +15,32 @@ object LocationService {
fun initialize(context: Context) {
if (initialized) return
Log.e(TAG, "liwei--------- 初始化高德定位服务 1")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 1")
try {
AMapLocationClient.updatePrivacyShow(context, true, true)
AMapLocationClient.updatePrivacyAgree(context, true)
Log.e(TAG, "liwei--------- 初始化高德定位服务 2")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 2")
locationClient = AMapLocationClient(context.applicationContext)
val option = AMapLocationClientOption().apply {
locationMode = AMapLocationClientOption.AMapLocationMode.Hight_Accuracy
isOnceLocation = false
interval = 5000L
interval = 30000L
}
Log.e(TAG, "liwei--------- 初始化高德定位服务 3")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 3")
locationClient?.setLocationOption(option)
Log.e(TAG, "liwei--------- 初始化高德定位服务 4")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 4")
locationClient?.setLocationListener { location ->
if (location != null && location.errorCode == 0) {
currentLocation = location
Log.d(TAG, "liwei---------定位更新: ${location.latitude}, ${location.longitude}")
// Log.d(TAG, "liwei---------定位更新: ${location.latitude}, ${location.longitude}")
} else {
Log.w(TAG, "liwei---------定位失败: ${location?.errorCode} - ${location?.errorInfo}")
}
}
Log.e(TAG, "liwei--------- 初始化高德定位服务 5")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 5")
locationClient?.startLocation()
Log.e(TAG, "liwei--------- 初始化高德定位服务 6")
// Log.e(TAG, "liwei--------- 初始化高德定位服务 6")
initialized = true
} catch (e: Exception) {
Log.e(TAG, "高德定位初始化失败: ${e.message}", e)

84
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -37,12 +37,45 @@ class MusicService : MediaBrowserServiceCompat() {
private val isRunning = AtomicBoolean(false)
private var currentUrlInternal: String? = null
private var currentStatus = false
private var playlist = mutableListOf<Map<String,String>>()
private var playlist = mutableListOf<Map<String,Any>>()
private var currentIndex = 0
fun isServiceRunning() = isRunning.get()
fun isPlaying() = currentStatus
fun getCurrentUrl(): String? = currentUrlInternal
// 获取播放进度信息
fun getProgress(): Map<String, Any> {
return if (instance != null && instance!!.mediaPlayer != null) {
val player = instance!!.mediaPlayer!!
try {
val currentPosition = if (player.isPlaying || currentStatus) player.currentPosition else 0
val duration = player.duration
val progress = if (duration > 0) currentPosition.toDouble() / duration.toDouble() else 0.0
mapOf(
"currentPosition" to currentPosition,
"duration" to duration,
"progress" to progress
)
} catch (e: Exception) {
Log.e(TAG, "获取播放进度失败: ${e.message}")
mapOf(
"currentPosition" to 0,
"duration" to 0,
"progress" to 0.0
)
}
} else {
mapOf(
"currentPosition" to 0,
"duration" to 0,
"progress" to 0.0
)
}
}
private var instance: MusicService? = null
}
private var isReceiverRegistered = false
private lateinit var mediaSession: MediaSessionCompat
@ -51,6 +84,7 @@ class MusicService : MediaBrowserServiceCompat() {
override fun onCreate() {
super.onCreate()
instance = this
isRunning.set(true)
setupMediaSession()
startForeground(NOTIFICATION_ID, createNotification("等待播放","DeepSound",""))
@ -60,6 +94,7 @@ class MusicService : MediaBrowserServiceCompat() {
val filter = IntentFilter().apply {
addAction("com.yunqiinnovation.music_service.MEDIA_PLAY")
addAction("com.yunqiinnovation.music_service.MEDIA_PAUSE")
addAction("com.yunqiinnovation.music_service.MEDIA_CLOSE")
addAction("com.yunqiinnovation.music_service.MEDIA_PREVIOUS")
addAction("com.yunqiinnovation.music_service.MEDIA_NEXT")
}
@ -85,7 +120,7 @@ class MusicService : MediaBrowserServiceCompat() {
val command = intent?.getStringExtra("command") ?: return START_NOT_STICKY
when (command) {
"play" -> intent.getSerializableExtra("song")?.let { url ->
val songMap = intent.getSerializableExtra("song") as? Map<String, String>
val songMap = intent.getSerializableExtra("song") as? Map<String, Any>
songMap?.let { song ->
val songId = song["id"] ?: return@let
@ -115,6 +150,12 @@ class MusicService : MediaBrowserServiceCompat() {
"stop" -> stopSelf()
"next" -> if (currentIndex + 1 < playlist.size) play(playlist[++currentIndex])
"previous" -> if (currentIndex - 1 >= 0) play(playlist[--currentIndex])
"seekTo" -> {
val position = intent.getIntExtra("position", -1)
if (position >= 0) {
seekTo(position)
}
}
}
return START_STICKY
}
@ -132,6 +173,10 @@ class MusicService : MediaBrowserServiceCompat() {
Log.i(TAG, "收到 MEDIA_PLAY 广播")
resume()
}
"com.yunqiinnovation.music_service.MEDIA_CLOSE" -> {
Log.i(TAG, "收到 MEDIA_CLOSE 广播")
stopSelf()
}
"com.yunqiinnovation.music_service.MEDIA_PREVIOUS" -> {
Log.i(TAG, "收到 MEDIA_PREVIOUS 广播")
playPrevious()
@ -314,15 +359,15 @@ class MusicService : MediaBrowserServiceCompat() {
}
}
private fun play(song: Map<String,String>) {
private fun play(song: Map<String,Any>) {
val url = song["url"] ?: return
Log.i(TAG, "开始播放: $url")
val title = song["title"] ?: "未知标题"
val artist = song["artist"] ?: "未知歌手"
val coverUrl = song["coverUrl"]
val title = song["title"] as? String ?: "未知标题"
val artist = song["artist"] as? String ?: "未知歌手"
val coverUrl = song["coverUrl"] as String
mediaPlayer?.release()
currentStatus = true
currentUrlInternal = url
currentUrlInternal = url as String
mediaPlayer = MediaPlayer().apply {
setDataSource(url)
setAudioStreamType(AudioManager.STREAM_MUSIC)
@ -351,9 +396,9 @@ class MusicService : MediaBrowserServiceCompat() {
.build()
)
val song = playlist[currentIndex]
val title = song["title"] ?: "未知标题"
val artist = song["artist"] ?: "未知歌手"
val coverUrl = song["coverUrl"]
val title = song["title"] as? String ?: "未知标题"
val artist = song["artist"] as? String ?: "未知歌手"
val coverUrl = song["coverUrl"] as String
updateNotification(title,artist,coverUrl)
setPlaybackState(PlaybackStateCompat.STATE_PAUSED);
Log.i(TAG, "已暂停")
@ -371,9 +416,9 @@ class MusicService : MediaBrowserServiceCompat() {
.build()
)
val song = playlist[currentIndex]
val title = song["title"] ?: "未知标题"
val artist = song["artist"] ?: "未知歌手"
val coverUrl = song["coverUrl"]
val title = song["title"] as? String ?: "未知标题"
val artist = song["artist"] as? String ?: "未知歌手"
val coverUrl = song["coverUrl"] as String
updateNotification(title,artist,coverUrl)
setPlaybackState(PlaybackStateCompat.STATE_PLAYING);
// Log.i(TAG, "继续播放")
@ -396,6 +441,19 @@ class MusicService : MediaBrowserServiceCompat() {
else stopSelf()
}
private fun seekTo(positionMs: Int) {
mediaPlayer?.let { player ->
try {
if (player.isPlaying || currentStatus) {
player.seekTo(positionMs)
Log.i(TAG, "跳转到位置: ${positionMs}ms")
}
} catch (e: Exception) {
Log.e(TAG, "跳转失败: ${e.message}")
}
}
}
private fun updateNotification(title: String,artist: String,coverUrl: String?) {
val notification = createNotification(title, artist, coverUrl)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

17
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt

@ -27,7 +27,7 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler {
Log.d(TAG, "Method call: ${call.method}")
when (call.method) {
"play" -> {
val song = call.argument<Map<String, String>>("song")
val song = call.argument<Map<String, Any>>("song")
if (song != null) {
MusicServiceStarter.startServiceWithCommand(context, "play", song)
result.success(null)
@ -36,7 +36,7 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler {
}
}
"playlist" -> {
val songs = call.argument<List<Map<String, String>>>("songs")
val songs = call.argument<List<Map<String, Any>>>("songs")
if (songs != null && songs.isNotEmpty()) {
MusicServiceStarter.startServiceWithPlaylist(context, "playlist", songs)
result.success(null)
@ -70,6 +70,19 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler {
)
result.success(status)
}
"getProgress" -> {
val progressInfo = MusicService.getProgress()
result.success(progressInfo)
}
"seekTo" -> {
val position = call.argument<Int>("position")
if (position != null) {
MusicServiceStarter.startServiceWithCommand(context, "seekTo", position = position)
result.success(null)
} else {
result.error("INVALID_ARGUMENT", "Missing 'position' parameter", null)
}
}
else -> result.notImplemented()
}
}

9
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt

@ -18,7 +18,7 @@ import java.util.jar.Manifest
object MusicServiceStarter {
private val TAG = "MusicServiceStarter"
//播放单曲
fun startServiceWithCommand(context: Context, command: String, song: Map<String,String>? = null, volume: Float? = null) {
fun startServiceWithCommand(context: Context, command: String, song: Map<String,Any>? = null, volume: Float? = null, position: Int? = null) {
Log.i(TAG, "liwei------------------- startServiceWithCommand: $command $song")
val intent = Intent(context, MusicService::class.java).apply {
putExtra("command", command)
@ -26,6 +26,7 @@ object MusicServiceStarter {
putExtra("song", HashMap(it)) // 只有非 null 时才 putExtra
}
volume?.let { putExtra("volume", it) }
position?.let { putExtra("position", it) }
}
val success = try {
ContextCompat.startForegroundService(context, intent)
@ -34,13 +35,13 @@ object MusicServiceStarter {
Log.e("MusicServiceStarter", "liwei------------------- Failed to start service", e)
false
}
Log.d(" MusicServiceStarter", "liwei------------------- Service start success: $success")
Log.d(" MusicServiceStarter", "Service start success: $success")
}
//启动播放列表
fun startServiceWithPlaylist(context: Context,command: String, songs: List<Map<String,String>>,volume: Float? = null) {
fun startServiceWithPlaylist(context: Context,command: String, songs: List<Map<String,Any>>,volume: Float? = null) {
Log.i(TAG, "liwei------------------- startServiceWithPlaylist: $command $songs")
val serializableList = ArrayList<HashMap<String, String>>().apply {
val serializableList = ArrayList<HashMap<String, Any>>().apply {
songs.forEach { add(HashMap(it)) }
}
val intent = Intent(context, MusicService::class.java).apply {

30
local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift

@ -75,6 +75,36 @@ public class MusicService: NSObject {
func setVolume(volume: Float) {
player?.volume = volume
}
func getProgress() -> [String: Any] {
guard let player = player, let currentItem = player.currentItem else {
return [
"currentPosition": 0,
"duration": 0,
"progress": 0.0
]
}
let currentTime = CMTimeGetSeconds(player.currentTime())
let duration = CMTimeGetSeconds(currentItem.duration)
// 处理无效值
let currentPosition = currentTime.isNaN || currentTime.isInfinite ? 0 : Int(currentTime * 1000)
let totalDuration = duration.isNaN || duration.isInfinite ? 0 : Int(duration * 1000)
let progress = totalDuration > 0 ? currentTime / duration : 0.0
return [
"currentPosition": currentPosition,
"duration": totalDuration,
"progress": progress.isNaN || progress.isInfinite ? 0.0 : progress
]
}
func seekTo(positionMs: Int) {
guard let player = player else { return }
let time = CMTime(seconds: Double(positionMs) / 1000.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
player.seek(to: time)
}
private func setupRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared()

14
local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift

@ -65,6 +65,20 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
"url": currentUrl != nil ? currentUrl! : NSNull()
]
result(status)
case "getProgress":
let progressInfo = MusicService.shared.getProgress()
result(progressInfo)
case "seekTo":
if let args = call.arguments as? [String: Any],
let position = args["position"] as? Int {
MusicService.shared.seekTo(positionMs: position)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing or invalid 'position'", details: nil))
}
default:
result(FlutterMethodNotImplemented)
}

17
local_plugins/music_service/lib/music_service.dart

@ -39,7 +39,7 @@ class MusicService {
_statusStream = _statusEventChannel.receiveBroadcastStream();
}
Future<void> playMusic(Map<String, String> song) async {
Future<void> playMusic(Map<String, dynamic> song) async {
await _methodChannel.invokeMethod('play', {'song': song});
}
@ -59,4 +59,19 @@ class MusicService {
'url': result?['url'] ?? "",
};
}
/// 获取播放进度信息
Future<Map<String, dynamic>> getProgress() async {
final result = await _methodChannel.invokeMethod<Map>('getProgress');
return {
'currentPosition': result?['currentPosition'] ?? 0, // 当前播放位置(毫秒)
'duration': result?['duration'] ?? 0, // 总时长(毫秒)
'progress': result?['progress'] ?? 0.0, // 播放进度(0.0-1.0)
};
}
/// 跳转到指定位置
Future<void> seekTo(int positionMs) async {
await _methodChannel.invokeMethod('seekTo', {'position': positionMs});
}
}

55
local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt

@ -268,32 +268,36 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler {
streamSink = null
}
})
// 初始化 otaManager 和 bluetoothHelper
ConfigHelper.initialize(context)
otaManager = OTAManager(context)
bluetoothHelper = BluetoothHelper(context)
// 如果需要额外的初始化逻辑,请在这里添加
otaManager.registerBluetoothCallback(bluetoothCallback)
// 初始化BleService
// 2. 初始化BleService并注册回调
if (BleService.initialize(context)) {
if (BleService.addCallback(this)) {
isRegisteredToBle = true
Log.d(TAG, "已注册BLE回调")
} else {
Log.w(TAG, "BLE回调注册失败")
}
} else {
Log.e(TAG, "BLE服务初始化失败")
}
Log.i("TAG", "onAttachedToEngine")
}
override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "startUpgrading") {
if (call.method == "init") {
// 初始化 otaManager 和 bluetoothHelper
ConfigHelper.initialize(context)
otaManager = OTAManager(context)
bluetoothHelper = BluetoothHelper(context)
// 如果需要额外的初始化逻辑,请在这里添加
otaManager.registerBluetoothCallback(bluetoothCallback)
// 初始化BleService
// 2. 初始化BleService并注册回调
if (BleService.initialize(context)) {
if (BleService.addCallback(this)) {
isRegisteredToBle = true
Log.d(TAG, "已注册BLE回调")
} else {
Log.w(TAG, "BLE回调注册失败")
}
} else {
Log.e(TAG, "BLE服务初始化失败")
}
Log.i("TAG", "onAttachedToEngine")
result.success("")
} else if (call.method == "startUpgrading") {
Log.d("TAG", "startUpgrading")
// 接收传入的参数
val arguments = call.arguments as? Map<String, Any>
@ -326,13 +330,16 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler {
BleService.isEnterOta = false
}
result.success("")
} else if (call.method == "onOtaClose") {
BleService.isEnterOta = false
isDisconnect = false
otaManager.bluetoothHelper.stopScan()
otaManager.bluetoothHelper.disconnectDevice(otaDevice)
otaManager?.bluetoothHelper?.stopScan()
otaManager?.bluetoothHelper?.disconnectDevice(otaDevice)
result.success("")
} else {
result.notImplemented()
}

Loading…
Cancel
Save