diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index c6539c0e2..08bb41500 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/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(), fenix: true); + // 账号管理服务 + Get.lazyPut(() => AccountService(), fenix: true); + // 杰理蓝牙服务 // // 语言管理器(需要最先初始化) diff --git a/lib/data/services/account_service.dart b/lib/data/services/account_service.dart new file mode 100644 index 000000000..6c01ac65c --- /dev/null +++ b/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 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 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 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 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, + }); +} diff --git a/lib/data/services/asr_service.dart b/lib/data/services/asr_service.dart index 6d09e8744..a9a9e6b52 100644 --- a/lib/data/services/asr_service.dart +++ b/lib/data/services/asr_service.dart @@ -30,13 +30,19 @@ abstract class AsrService { bool isContinuousRecognitionActive(); /// 开始录音 - Future enableRecord(String fileName); + Future enableRecord(String filePath); /// 暂停录音 Future pauseRecord(); + // /// 移动文件到新路径 + Future moveFile(String sourcePath, String destPath); + + // /// 重命名指定路径的音频文件 + Future renameFile(String filePath, String newName); + /// 结束录音 - Future stopRecord(); + Future stopRecord(bool isSave); /// 释放资源 Future dispose(); diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 5e11370a7..acf36ac6b 100644 --- a/lib/data/services/ble_manager.dart +++ b/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', diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 9a1688314..e1c017902 100644 --- a/lib/data/services/chat_history_service.dart +++ b/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 saveHistory(String agentId, List 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 _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 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> 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 clearHistory(String agentId) async { try { @@ -121,7 +122,7 @@ class ChatHistoryService extends GetxService { Logger.error('清除聊天记录失败', e); } } - + /// 清除所有聊天记录 Future clearAllHistory() async { try { @@ -131,4 +132,4 @@ class ChatHistoryService extends GetxService { Logger.error('清除所有聊天记录失败', e); } } -} \ No newline at end of file +} diff --git a/lib/data/services/db/sqflite_api.dart b/lib/data/services/db/sqflite_api.dart index e7319f596..a5f0f1cb3 100644 --- a/lib/data/services/db/sqflite_api.dart +++ b/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') { diff --git a/lib/data/services/music_manager.dart b/lib/data/services/music_manager.dart index 8bfea2f47..90324c290 100644 --- a/lib/data/services/music_manager.dart +++ b/lib/data/services/music_manager.dart @@ -52,7 +52,7 @@ class MusicManager extends GetxService { } //播放 - Future play(Map song) async { + Future play(Map song) async { await _musicService.playMusic(song); } @@ -70,4 +70,14 @@ class MusicManager extends GetxService { Future> getStatus() async { return await _musicService.getStatus(); } + + //获取播放进度 + Future> getProgress() async { + return await _musicService.getProgress(); + } + + //跳转到指定位置 + Future seekTo(int positionMs) async { + await _musicService.seekTo(positionMs); + } } diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index ff8adaa70..b2f43ac10 100644 --- a/lib/data/services/network/api.dart +++ b/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, + ); + } } diff --git a/lib/data/services/network/nw_api.dart b/lib/data/services/network/nw_api.dart index 94d7115f8..8feff1db5 100644 --- a/lib/data/services/network/nw_api.dart +++ b/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"; } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 317836638..39ac44aa9 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -344,10 +344,10 @@ class AzureAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) async { + Future 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 stopRecord() async { + Future 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 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 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; + } + } } diff --git a/lib/data/services/speech_impl/volcano_asr_api_service.dart b/lib/data/services/speech_impl/volcano_asr_api_service.dart index 50cb85f12..a15a4ae0b 100644 --- a/lib/data/services/speech_impl/volcano_asr_api_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_api_service.dart @@ -813,13 +813,13 @@ class VolcanoAsrApiService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future stopRecord(bool isSave) { // TODO: implement stopRecord throw UnimplementedError(); } @@ -829,4 +829,16 @@ class VolcanoAsrApiService implements AsrService { // TODO: implement pauseRecord throw UnimplementedError(); } + + @override + Future moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/volcano_asr_service.dart b/lib/data/services/speech_impl/volcano_asr_service.dart index 923ede8f0..8571f9c4c 100644 --- a/lib/data/services/speech_impl/volcano_asr_service.dart +++ b/lib/data/services/speech_impl/volcano_asr_service.dart @@ -348,13 +348,13 @@ class VolcanoAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future 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 moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/data/services/speech_impl/xunfei_asr_service.dart b/lib/data/services/speech_impl/xunfei_asr_service.dart index 4cd8ac171..f4d77ae50 100644 --- a/lib/data/services/speech_impl/xunfei_asr_service.dart +++ b/lib/data/services/speech_impl/xunfei_asr_service.dart @@ -272,13 +272,13 @@ class XunfeiAsrService extends GetxService implements AsrService { } @override - Future enableRecord(String fileName) { + Future enableRecord(String filePath) { // TODO: implement enableRecord throw UnimplementedError(); } @override - Future stopRecord() { + Future 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 moveFile(String sourcePath, String destPath) { + // TODO: implement moveFile + throw UnimplementedError(); + } + + @override + Future renameFile(String filePath, String newName) { + // TODO: implement renameFile + throw UnimplementedError(); + } } diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index c665ff063..24b9a0824 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/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? _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 song) { + void musicPlay(Map 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; + 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 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 seekToPosition(int positionMs) async { + try { + await _musiceManager.seekTo(positionMs); + // 立即更新进度状态 + await updateMusicProgress(); + } catch (e) { + Logger.e('AgentController', '跳转播放位置失败: $e'); + } } // 修改自动停止处理,遵循TTS设置 diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 7d291e622..fb1887ee5 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/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.initReport(message); + + _showReportForm(context, isDarkMode); + }, ), - '举报'.tr, // 举报 - isDarkMode ? Colors.red[700]! : Colors.red, - () { - // 实现举报功能 - final reportController = Get.find(); - 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, diff --git a/lib/modules/agent/views/circular_progress_button.dart b/lib/modules/agent/views/circular_progress_button.dart new file mode 100644 index 000000000..510bf95a4 --- /dev/null +++ b/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; + } +} diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index c72c22ee5..819c822b9 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/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 { if (meta!.keys.contains('music_play')) { final card_music = meta!["music_play"]['card_music'] as Map; - 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; diff --git a/lib/modules/agent/views/music_card.dart b/lib/modules/agent/views/music_card.dart index d76270cad..7162472b6 100644 --- a/lib/modules/agent/views/music_card.dart +++ b/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(); // 获取控制器 final isDarkMode = Theme.of(context).brightness == Brightness.dark; - Map song = { + Map 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( diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index c512d1eb7..d6cc76ca1 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/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('同意并进入'), ), diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 98cdfae0f..deb838681 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -177,9 +177,23 @@ class LoginView extends GetView { 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 { 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 { 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 { 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, diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index f6d1d7ee9..f077169cc 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/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 selected = []; - final appDocDir = getApplicationSupportDirectory(); + final appDocDir = getApplicationDocumentsDirectory(); // final directory = Directory( // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); List 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 _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 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 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 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 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(); } diff --git a/lib/modules/meeting/controllers/meeting_details_controller.dart b/lib/modules/meeting/controllers/meeting_details_controller.dart index ea15ec38e..8577525ba 100644 --- a/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/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 _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); diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 1ad18656f..2fea38ea5 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/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(); - //语言服务监听 + final BleManager _bleManager = Get.find(); + + // Stream subscriptions StreamSubscription? _recognitionSubscription; - //是否开启识别 + + // Recording state + final Rx 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 waveData = [].obs; - //识别文本 - RxString intermediateContent = ''.obs; - RxString finalContent = ''.obs; - // final RxBool isRecording = false.obs; // 是否正在录音 - // 蓝牙服务 - final _bleManager = Get.find(); - final RxInt seconds = 0.obs; // 录音秒数 - final RxInt audioTypes = 0.obs; // 音频类型 - final RxBool isMarked = false.obs; // 是否标记重点 - final RxList waveData = [].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.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 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 _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 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 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 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 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 _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 _pauseRecording() async { - // 暂停录音 await _asrService.stopContinuousRecognition(); await _asrService.pauseRecord(); pauseTimer(); - //await _asrService.pauseRecognition(); // 假设ASR服务有暂停方法 } + /// Resumes paused recording session Future _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 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; } diff --git a/lib/modules/meeting/views/meeting_details_view.dart b/lib/modules/meeting/views/meeting_details_view.dart index c9235bc5d..de44350a7 100644 --- a/lib/modules/meeting/views/meeting_details_view.dart +++ b/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 { 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 { 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, diff --git a/lib/modules/meeting/views/meeting_record_view.dart b/lib/modules/meeting/views/meeting_record_view.dart index f29de80c6..10d288604 100644 --- a/lib/modules/meeting/views/meeting_record_view.dart +++ b/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 { 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 { 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 { ), ), 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 { ), )), ), + // 录音按钮 GestureDetector( onTap: controller.toggleRecording, child: Obx(() => CircleAvatar( @@ -176,8 +247,9 @@ class MeetingRecordView extends GetView { ), )), ), + // 完成按钮 GestureDetector( - onTap: controller.saveRecording, + onTap: () => _bottomSheet(context, isDarkMode), // 改为匿名函数 child: const CircleAvatar( radius: 32, backgroundColor: Colors.white, @@ -191,6 +263,118 @@ class MeetingRecordView extends GetView { ], ); } + +// 底部编辑弹窗 + 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 { diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index b377232f7..83414157b 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/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 { const MeetingView({super.key}); @@ -563,10 +561,13 @@ class MeetingView extends GetView { 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 { 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 { ], ), title: '通话录音', - onTap: () { + onTap: () async { Navigator.of(context).pop(); - Get.toNamed(Routes.meetingRecord); + + await Get.toNamed(Routes.meetingRecord, arguments: { + 'audioTypes': 2, + }); + controller.addMeetingAudio(); + // TODO: 实现从其他APP导入逻辑 }, ), diff --git a/lib/modules/meeting/views/share_bottom_sheet.dart b/lib/modules/meeting/views/share_bottom_sheet.dart index 682fd0dd7..7ad740d1f 100644 --- a/lib/modules/meeting/views/share_bottom_sheet.dart +++ b/lib/modules/meeting/views/share_bottom_sheet.dart @@ -149,14 +149,12 @@ class _ShareBottomSheetState extends State { 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()}'); } } } diff --git a/lib/modules/music/bindings/music_playlist_binding.dart b/lib/modules/music/bindings/music_playlist_binding.dart new file mode 100644 index 000000000..85f385d42 --- /dev/null +++ b/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(), + ); + } +} diff --git a/lib/modules/music/controllers/music_playlist_controller.dart b/lib/modules/music/controllers/music_playlist_controller.dart new file mode 100644 index 000000000..ac0c8d2ee --- /dev/null +++ b/lib/modules/music/controllers/music_playlist_controller.dart @@ -0,0 +1,19 @@ +import 'package:get/get.dart'; + +class MusicPlaylistController extends GetxController { + // 歌单数据 + final playlist = >[].obs; + + @override + void onInit() { + super.onInit(); + // 从路由参数获取歌单数据 + final List> playlistData = Get.arguments ?? []; + playlist.assignAll(playlistData); + } + + @override + void onClose() { + super.onClose(); + } +} diff --git a/lib/modules/music/views/music_playlist_view.dart b/lib/modules/music/views/music_playlist_view.dart new file mode 100644 index 000000000..c5ca02817 --- /dev/null +++ b/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(); + final List> 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, + ); + }), + ], + ), + ); + } +} diff --git a/lib/modules/profile/controllers/profile_controller.dart b/lib/modules/profile/controllers/profile_controller.dart index bea4404c0..1dae96f4f 100644 --- a/lib/modules/profile/controllers/profile_controller.dart +++ b/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(); loadUserInfo(); loadLanguagePreference(); } @@ -358,49 +364,41 @@ class ProfileController extends GetxController { } } + // 退出登录 Future 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'); } } } diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 40727d7ec..195daf552 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -930,6 +930,16 @@ class SettingsView extends GetView { ), ), ), + //------------测试使用标记 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---------------// ], ), ), diff --git a/lib/modules/translation/bindings/translation_list_binding.dart b/lib/modules/translation/bindings/translation_list_binding.dart new file mode 100644 index 000000000..4dc264ada --- /dev/null +++ b/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()); + } +} diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 5be5a8990..9a911accf 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/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 = [].obs; String? currentSessionId; - + final appDocDir = getApplicationDocumentsDirectory(); + var appDir; + var dir; Future 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 _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 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 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) { diff --git a/lib/modules/translation/controllers/translation_list_controller.dart b/lib/modules/translation/controllers/translation_list_controller.dart new file mode 100644 index 000000000..7b8d0a98e --- /dev/null +++ b/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 selected = []; + + final appDocDir = getApplicationDocumentsDirectory(); + // final directory = Directory( + // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); + final RxList files = [].obs; + + RxList dataList = [].obs; + + List dataListTask = []; + bool isDisposed = false; + // 添加音频播放器 + // 添加音频时长缓存 + final Map 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 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 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()}'); + } + } +} diff --git a/lib/modules/translation/views/translation_list_view.dart b/lib/modules/translation/views/translation_list_view.dart new file mode 100644 index 000000000..a24f56c01 --- /dev/null +++ b/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 { + 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( + 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, + ); + } +} diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 85cb3bbd6..01f3a1ec6 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/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 { @@ -61,25 +62,34 @@ class TranslationView extends GetView { _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 { onPressed: () async { // 停止语音识别和TTS,释放资源 await controller.stopAll(); - await Get.to( - () => const TranslationHistoryView(), - binding: TranslationHistoryBinding(), - ); + Get.toNamed(Routes.translationList); // 返回时不自动开始翻译,只需重新设置滚动监听器 controller.setupScrollListener(); diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 2bb173efe..4631245f0 100644 --- a/lib/routes/app_pages.dart +++ b/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), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 8ee5515dd..3e2e1be89 100644 --- a/lib/routes/app_routes.dart +++ b/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'; } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index ac3f5d9eb..adccc22f8 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -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( + "text" to response, + "userInput" to displayText + ) + if (hasImage) { + responseData["hasImage"] = true + } + sendEvent("assistant_response", responseData) if (response.isNotEmpty()) { - // 发送完整回复,包含是否有图片的标记 - val responseData = mutableMapOf( - "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 = 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>() + val playlist = mutableListOf>() 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){ + fun processMusicPlay(song: Map){ // 在其他 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>){ + fun processMusicPlayList(songs:List>){ // 如果 playlist 中有有效的歌曲,开始播放 if (songs.isNotEmpty()) { MusicServiceStarter.startServiceWithPlaylist(context, command = "playlist", songs = songs) diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index cf00c7f3c..64353c834 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/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("imageUrl") + if (imageUrl.isNullOrEmpty()) { + result.error("INVALID_ARGUMENTS", "图片Url不能为空", null) + return + } val text = call.argument("text") ?: "" val speakResponse = call.argument("speakResponse") ?: false try { val isProcessing = AgentService.processImageInput( imagePath = imagePath, + imageUrl = imageUrl, text = text, speakResponse = speakResponse ) diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index e7452eb45..ccaba0407 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt +++ b/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, ""), diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 2c53859c1..4ee6907cb 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -326,11 +326,12 @@ class AgentService { /// [text] 可选的文本描述或问题 /// [speakResponse] 是否朗读响应 /// 返回是否成功处理图片 - static Future processImageInput(String imagePath, + static Future 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, }); diff --git a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/AmapServicePlugin.kt b/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/AmapServicePlugin.kt index e28e7a484..e9f9d08d6 100644 --- a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/AmapServicePlugin.kt +++ b/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 } + + } diff --git a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationActivity.kt b/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationActivity.kt index 9009ec725..c900c32d5 100644 --- a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationActivity.kt +++ b/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!") } diff --git a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt b/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt index dceab4f76..c926010b0 100644 --- a/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt +++ b/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() + } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index b2680e0c3..9b21bd39a 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/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() - private val isWriting = AtomicBoolean(false) - private var writeThread: Thread? = null - - // 用于存储音频数据的缓冲区 - private val dataBuffer = mutableListOf() - 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 - } - } - } } \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 855d0b843..e19579cc3 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/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("filePath") ?: "" + val newName = call.argument("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("sourcePath") ?: "" + val destPath = call.argument("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("fileName")?: "" + val filePath = call.argument("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("isSave") ?: false + try { + azureAsrHelper.stopRecord(isSave) + result.success(true) + } catch (e: Exception) { + result.error("ENABLERECORD_ERROR", e.message, null) + } } else -> { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index d750fae41..62ac83d49 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt +++ b/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("&", "&") .replace("<", "<") .replace(">", ">") + .replace("#", ">") + .replace("*", ">") .replace(Regex("[😀-🟿]+"), "") // 简化表情符号移除 .trim() diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/RecordFile.kt new file mode 100644 index 000000000..7a32f21da --- /dev/null +++ b/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() + private val isWriting = AtomicBoolean(false) + private var writeThread: Thread? = null + + // 用于存储音频数据的缓冲区 + private val dataBuffer = mutableListOf() + 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 + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index d81842a90..7e6141db4 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -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 /** diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt index c369deac9..055d37757 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt +++ b/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("apiKey") ?: "" val baseUrl = call.argument("baseUrl") ?: "" val model = call.argument("model") ?: "" + val modelmedia = call.argument("modelmedia") ?: "" val mcpServer = call.argument("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>>("messages") ?: emptyList() - val tool = call.argument("tool") ?: false - pluginScope.launch { - chatApiService?.chatCompletionStream(messages, tool) - } - result.success(true) - } + // "chatCompletionStream" -> { + // val messages = call.argument>>("messages") ?: emptyList() + // val tool = call.argument("tool") ?: false + // pluginScope.launch { + // chatApiService?.chatCompletionStream(messages, tool) + // } + // result.success(true) + // } "cancelChatStream" -> { chatApiService?.cancelChatStream() result.success(true) diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt index bad3aa1a2..036ad2782 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt @@ -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> = emptyList() private var toolCalls: MutableMap = mutableMapOf() - private var isCanceled = false + private var sessions: MutableMap = 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> val messageList = convertJsonArrayToList(messages) - + currSessionId = UUID.randomUUID().toString() + sessions[currSessionId] = callback // 调用原始方法 - sendMessageStream(messageList) + sendMessageStream(currSessionId,messageList) } /** * 发送消息(流式输出) */ - fun sendMessageStream(messages: List>) { + fun sendMessageStream(sessionid:String,messages: List>) { 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>, functionCall: Map, 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>, tool: Boolean = false) { + fun chatCompletionStream(sessionid: String,messages: List>, 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() 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() + .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> { val result = mutableListOf>() - +// 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" diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt index 794b66547..672c29634 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/SystemFunctionHandler.kt @@ -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(), + "required" to emptyList() + ), + 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(), + "required" to emptyList() + ), + handler = ExitNavigationHandler(context) + ) + } +} + +/** + * 退出导航服务 + */ +private class ExitNavigationHandler(private val context: Context?) : FunctionHandler { + override suspend fun handle(arguments: Map): 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 { + Log.d("MediaPauseHandler", "📤 正在发送广播 context = $context") + // 发送媒体暂停广播 + val intent = Intent("com.yunqiinnovation.music_service.MEDIA_CLOSE").apply { + setPackage(context?.packageName) // 设置包名使其成为显式 Intent + } + context?.sendBroadcast(intent) + return "{\"result\": \"已关闭媒体播放\"}" + } +} /** * 媒体上一首处理器 */ diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift index c5638966d..cf1c6a3a0 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift +++ b/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) diff --git a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt b/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt index e36eaa569..5a2319917 100644 --- a/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationService.kt +++ b/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) diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt index d346baed0..2e04578f3 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt @@ -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>() + private var playlist = mutableListOf>() private var currentIndex = 0 fun isServiceRunning() = isRunning.get() fun isPlaying() = currentStatus fun getCurrentUrl(): String? = currentUrlInternal + + // 获取播放进度信息 + fun getProgress(): Map { + 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 + val songMap = intent.getSerializableExtra("song") as? Map 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) { + private fun play(song: Map) { 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 diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt index 9756b25f8..ba83f5209 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -27,7 +27,7 @@ class MusicServicePlugin: FlutterPlugin, MethodCallHandler { Log.d(TAG, "Method call: ${call.method}") when (call.method) { "play" -> { - val song = call.argument>("song") + val song = call.argument>("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>>("songs") + val songs = call.argument>>("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("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() } } diff --git a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt index b46fa3230..08821753a 100644 --- a/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt +++ b/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? = null, volume: Float? = null) { + fun startServiceWithCommand(context: Context, command: String, song: Map? = 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>,volume: Float? = null) { + fun startServiceWithPlaylist(context: Context,command: String, songs: List>,volume: Float? = null) { Log.i(TAG, "liwei------------------- startServiceWithPlaylist: $command $songs") - val serializableList = ArrayList>().apply { + val serializableList = ArrayList>().apply { songs.forEach { add(HashMap(it)) } } val intent = Intent(context, MusicService::class.java).apply { diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift index 92c4e9a21..81aa3d2c3 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift +++ b/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() diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index 5a9a1180e..61e1a844e 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/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) } diff --git a/local_plugins/music_service/lib/music_service.dart b/local_plugins/music_service/lib/music_service.dart index 02e3756ec..27c80b8e9 100644 --- a/local_plugins/music_service/lib/music_service.dart +++ b/local_plugins/music_service/lib/music_service.dart @@ -39,7 +39,7 @@ class MusicService { _statusStream = _statusEventChannel.receiveBroadcastStream(); } - Future playMusic(Map song) async { + Future playMusic(Map song) async { await _methodChannel.invokeMethod('play', {'song': song}); } @@ -59,4 +59,19 @@ class MusicService { 'url': result?['url'] ?? "", }; } + + /// 获取播放进度信息 + Future> getProgress() async { + final result = await _methodChannel.invokeMethod('getProgress'); + return { + 'currentPosition': result?['currentPosition'] ?? 0, // 当前播放位置(毫秒) + 'duration': result?['duration'] ?? 0, // 总时长(毫秒) + 'progress': result?['progress'] ?? 0.0, // 播放进度(0.0-1.0) + }; + } + + /// 跳转到指定位置 + Future seekTo(int positionMs) async { + await _methodChannel.invokeMethod('seekTo', {'position': positionMs}); + } } diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index 65bf3ba88..c2ddcfe1f 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt +++ b/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 @@ -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() }