From 0a370a022634bf2c36ec1d971aa049e83674dab4 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Sat, 10 May 2025 09:57:59 +0800 Subject: [PATCH] ble_service --- lib/core/bindings/initial_binding.dart | 9 +- lib/data/services/music_manager.dart | 27 +++ .../agent/controllers/agent_controller.dart | 204 ++++++++++-------- lib/modules/agent/views/agent_view.dart | 104 +++++---- lib/modules/agent/views/message_bubble.dart | 132 ++++++++---- local_plugins/agent_service/pubspec.yaml | 2 + local_plugins/music_service/.gitignore | 33 +++ local_plugins/music_service/.metadata | 33 +++ local_plugins/music_service/CHANGELOG.md | 3 + local_plugins/music_service/LICENSE | 1 + local_plugins/music_service/README.md | 15 ++ .../music_service/analysis_options.yaml | 4 + .../music_service/android/.gitignore | 9 + .../music_service/android/build.gradle | 70 ++++++ .../music_service/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 11 + .../music_service/MusicService.kt | 153 +++++++++++++ .../music_service/MusicServicePlugin.kt | 46 ++++ .../music_service/MusicServiceStarter.kt | 19 ++ local_plugins/music_service/ios/.gitignore | 38 ++++ .../music_service/ios/Assets/.gitkeep | 0 .../ios/Classes/MusicServicePlugin.swift | 19 ++ .../ios/Resources/PrivacyInfo.xcprivacy | 14 ++ .../music_service/ios/music_service.podspec | 29 +++ .../music_service/lib/music_service.dart | 55 +++++ local_plugins/music_service/pubspec.yaml | 72 +++++++ pubspec.yaml | 2 + 27 files changed, 924 insertions(+), 181 deletions(-) create mode 100644 lib/data/services/music_manager.dart create mode 100644 local_plugins/music_service/.gitignore create mode 100644 local_plugins/music_service/.metadata create mode 100644 local_plugins/music_service/CHANGELOG.md create mode 100644 local_plugins/music_service/LICENSE create mode 100644 local_plugins/music_service/README.md create mode 100644 local_plugins/music_service/analysis_options.yaml create mode 100644 local_plugins/music_service/android/.gitignore create mode 100644 local_plugins/music_service/android/build.gradle create mode 100644 local_plugins/music_service/android/settings.gradle create mode 100644 local_plugins/music_service/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt create mode 100644 local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt create mode 100644 local_plugins/music_service/ios/.gitignore create mode 100644 local_plugins/music_service/ios/Assets/.gitkeep create mode 100644 local_plugins/music_service/ios/Classes/MusicServicePlugin.swift create mode 100644 local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy create mode 100644 local_plugins/music_service/ios/music_service.podspec create mode 100644 local_plugins/music_service/lib/music_service.dart create mode 100644 local_plugins/music_service/pubspec.yaml diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 3356166c3..8921a6fca 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -1,3 +1,4 @@ +import 'package:deep_voice/data/services/music_manager.dart'; import 'package:get/get.dart'; import '../../data/services/speech_factory.dart'; import '../../data/services/chat_history_service.dart'; @@ -8,6 +9,7 @@ import '../../core/utils/logger.dart'; import '../../data/services/open_ai_service_adapter.dart'; import '../../data/services/bluetooth_manager.dart'; import '../../data/services/ble_manager.dart'; + /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override @@ -34,23 +36,20 @@ class InitialBinding extends Bindings { adapter.initialize(); return adapter; }, fenix: true); - // 蓝牙服务 Get.put(BluetoothManager(), permanent: true); // BleManager Get.put(BleManager(), permanent: true); - + // MusicManager + Get.put(MusicManager(), permanent: true); // 火山AI服务 Get.lazyPut(() => VolcanoAIService(), fenix: true); - // 蓝牙媒体按钮服务 // Get.lazyPut( // () => BluetoothMediaButtonService(), // fenix: true); - - // // 语音交互服务 // Get.lazyPut(() => VoiceInteractionService(), diff --git a/lib/data/services/music_manager.dart b/lib/data/services/music_manager.dart new file mode 100644 index 000000000..5efabeaf1 --- /dev/null +++ b/lib/data/services/music_manager.dart @@ -0,0 +1,27 @@ +import 'package:deep_voice/core/utils/logger.dart'; +import 'package:get/get.dart'; +import 'package:music_service/music_service.dart'; + +//音乐播放管理 +class MusicManager extends GetxService { + static MusicManager get to => Get.find(); + + // 音乐服务实例 + final MusicService _musicService = MusicService(); + + /// 初始化 + @override + void onInit() { + super.onInit(); + Logger.info('BLE管理器初始化'); + } + + /// 释放资源 + @override + void onClose() { + super.onClose(); + } + + //播放 音乐 + Future play(String url) async {} +} diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index b55455446..7428e3b19 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -15,6 +15,7 @@ class Message { final bool isRecognizing; // 是否为语音识别中的临时消息 final bool hasImage; // 是否包含图片 final String? imagePath; // 图片路径 + final Map? meta; //卡片数据 Message({ required this.isUser, @@ -23,50 +24,51 @@ class Message { this.isRecognizing = false, this.hasImage = false, this.imagePath, + this.meta, }) : this.timestamp = timestamp ?? DateTime.now(); } class AgentController extends GetxController { static AgentController get to => Get.find(); - + final logger = Logger(); - + // 聊天消息列表 final messages = [].obs; - + // 控制器 final TextEditingController textController = TextEditingController(); final ScrollController scrollController = ScrollController(); - + // 状态标志 final isInitialized = false.obs; final isListening = false.obs; final isSpeaking = false.obs; final isProcessing = false.obs; - + // 图片处理状态 final isImageProcessing = false.obs; - + // 输入模式控制 final isTextInputMode = true.obs; - + // 当前输入的文本内容 final currentText = ''.obs; - + // 图片输入相关 final isImageInputActive = false.obs; // 是否处于图片输入模式 final selectedImagePath = Rx(null); // 当前选择的图片路径 - + // 流事件订阅 StreamSubscription? _eventSubscription; - + // 用于跟踪AI回复状态 bool _isNewAssistantResponse = true; String _currentResponseId = ''; - + // 会话ID final String sessionId = 'default_agent'; - + // 是否已加载聊天记录 final isHistoryLoaded = false.obs; @@ -86,7 +88,7 @@ class AgentController extends GetxController { _eventSubscription?.cancel(); super.onClose(); } - + // 加载聊天历史记录 Future _loadChatHistory() async { try { @@ -96,19 +98,21 @@ class AgentController extends GetxController { page: 1, pageSize: 20, ); - + if (result.messages.isNotEmpty) { // 转换并添加消息到列表 - final historyMessages = result.messages.map((msg) => Message( - isUser: msg.sender == 'user', - text: msg.message, - timestamp: DateTime.parse(msg.timestamp), - )).toList(); - + final historyMessages = result.messages + .map((msg) => Message( + isUser: msg.sender == 'user', + text: msg.message, + timestamp: DateTime.parse(msg.timestamp), + )) + .toList(); + // 消息按时间排序,最新的消息在底部 historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); messages.assignAll(historyMessages); - + logger.i('成功加载 ${historyMessages.length} 条聊天记录'); } else { // 没有历史记录时添加欢迎消息 @@ -120,16 +124,15 @@ class AgentController extends GetxController { _addWelcomeMessage(); } finally { isHistoryLoaded.value = true; - + messages.refresh(); // 在加载完成后直接定位到底部,无需动画 WidgetsBinding.instance.addPostFrameCallback((_) { scrollController.jumpTo(scrollController.position.maxScrollExtent); }); - } } - + // 添加欢迎消息 void _addWelcomeMessage() { messages.add(Message( @@ -159,15 +162,16 @@ class AgentController extends GetxController { isTextInputMode.value = true; isTextInputMode.refresh(); break; - + case AgentServiceEventType.recognizing: final text = event.data['text'] ?? ''; currentText.value = text; // 保留当前文本,以便其他地方使用 - + if (text.isNotEmpty) { // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); - + final index = messages + .lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (index >= 0) { // 更新已有的识别中消息 messages[index] = Message( @@ -188,13 +192,14 @@ class AgentController extends GetxController { _scrollToBottom(); } break; - + case AgentServiceEventType.recognitionResult: final text = event.data['text'] ?? ''; - + // 查找是否有正在识别中的消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); - + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (text.isNotEmpty) { if (index >= 0) { // 将识别中的消息转换为最终消息 @@ -208,7 +213,7 @@ class AgentController extends GetxController { // 如果没有找到识别中的消息,添加新消息 messages.add(Message(isUser: true, text: text)); } - + isProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; @@ -220,11 +225,11 @@ class AgentController extends GetxController { messages.refresh(); } break; - + case AgentServiceEventType.ttsStarted: isSpeaking.value = true; break; - + case AgentServiceEventType.ttsCompleted: case AgentServiceEventType.ttsStopped: case AgentServiceEventType.ttsCanceled: @@ -238,16 +243,17 @@ class AgentController extends GetxController { case AgentServiceEventType.imageReady: // 图片准备就绪,但AI还没有开始处理,保持processing状态 break; - + case AgentServiceEventType.assistantToken: if (!isProcessing.value) isProcessing.value = true; - + final token = event.data['token'] ?? ''; final responseId = event.data['responseId'] ?? ''; - + if (token.isNotEmpty) { // 如果是新的回复或者响应ID改变,创建新消息 - if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) { + if (_isNewAssistantResponse || + (_currentResponseId != responseId && responseId.isNotEmpty)) { messages.add(Message(isUser: false, text: token)); _isNewAssistantResponse = false; _currentResponseId = responseId; @@ -267,35 +273,37 @@ class AgentController extends GetxController { _scrollToBottom(); } break; - + case AgentServiceEventType.assistantResponse: isProcessing.value = false; isImageProcessing.value = false; // 标记当前回复完成 _isNewAssistantResponse = true; break; - + case AgentServiceEventType.error: isListening.value = false; isSpeaking.value = false; isProcessing.value = false; isImageProcessing.value = false; - + // 移除临时的识别消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); if (index >= 0) { messages.removeAt(index); messages.refresh(); } - + logger.e('代理服务错误: ${event.data['message']}'); break; - + case AgentServiceEventType.autoStop: isListening.value = false; - + // 移除临时的识别消息 - final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = + messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); if (index >= 0) { // 只有当消息为空或文本太短时才移除 if (messages[index].text.trim().length < 2) { @@ -309,26 +317,51 @@ class AgentController extends GetxController { timestamp: messages[index].timestamp, ); messages.refresh(); - + // 发送此消息到AI处理 isProcessing.value = true; _isNewAssistantResponse = true; - AgentService.processTextInput(messages[index].text, speakResponse: true); + AgentService.processTextInput(messages[index].text, + speakResponse: true); } } break; case AgentServiceEventType.functionCall: - // final functionName = event.data['name'] ?? ''; - // final arguments = event.data['arguments'] ?? ''; - // logger.i('mcp: $functionName, $arguments'); + final functionName = event.data['name'] ?? ''; + final arguments = event.data['arguments'] ?? ''; + logger.i('Fultter 开始调用 mcp: $functionName, $arguments'); break; case AgentServiceEventType.functionCallResult: + final responseId = event.data['responseId'] ?? ''; final result = event.data['result'] ?? ''; final functionCall = event.data['function_call'] ?? ''; + if (!result.isBlank) { + final meta = result["meta"] ?? {}; + //不为空 + // 如果是新的回复或者响应ID改变,创建新消息 + if (_isNewAssistantResponse || + (_currentResponseId != responseId && responseId.isNotEmpty)) { + messages.add(Message(isUser: false, text: "", meta: meta)); + _isNewAssistantResponse = false; + _currentResponseId = responseId; + } else { + // 否则更新最后一条消息 + final lastIndex = messages.length - 1; + + if (lastIndex >= 0 && !messages[lastIndex].isUser) { + messages[lastIndex] = Message( + isUser: false, + text: messages[lastIndex].text, + meta: meta, + timestamp: messages[lastIndex].timestamp, + ); + } + } + } + logger.i('Fultter 结束调用 mcp: $result, $functionCall'); break; - default: break; } @@ -359,22 +392,22 @@ class AgentController extends GetxController { final text = textController.text.trim(); final imagePath = selectedImagePath.value!; // 使用!强制断言非空 await sendImageMessage(imagePath, text: text); - + // 清除图片输入状态 clearImageInput(); return; } - + final text = textController.text.trim(); if (text.isEmpty) return; - + final message = Message(isUser: true, text: text); messages.add(message); textController.clear(); - + // 滚动到底部 _scrollToBottom(); - + try { isProcessing.value = true; // 标记为新的AI回复 @@ -392,31 +425,31 @@ class AgentController extends GetxController { logger.e('图片不存在: $imagePath'); return; } - + // 显示的文本,如果没有提供则使用默认值 final displayText = text?.isNotEmpty == true ? text! : '[图片]'; - + // 添加用户消息 final message = Message( - isUser: true, + isUser: true, text: displayText, hasImage: true, imagePath: imagePath, ); messages.add(message); - + // 清空文本输入 textController.clear(); - + // 滚动到底部 _scrollToBottom(); - + try { isProcessing.value = true; isImageProcessing.value = true; // 标记为新的AI回复 _isNewAssistantResponse = true; - + // 调用Agent Service处理图片 await AgentService.processImageInput( imagePath, @@ -433,7 +466,7 @@ class AgentController extends GetxController { // 开始语音输入 Future startVoiceInput() async { if (isListening.value) return; - + try { await AgentService.startConversation(); } catch (e) { @@ -444,7 +477,7 @@ class AgentController extends GetxController { // 停止语音输入 Future stopVoiceInput() async { if (!isListening.value) return; - + try { await AgentService.stopConversation(); } catch (e) { @@ -456,21 +489,20 @@ class AgentController extends GetxController { Future clearChat() async { try { await AgentService.clearChatHistory(); - // 清空本地消息列表 messages.clear(); - + // 添加欢迎消息 _addWelcomeMessage(); - + // 重置回复状态 _isNewAssistantResponse = true; } catch (e) { logger.e('清除聊天历史失败: $e'); } } - + // 切换输入模式 void toggleInputMode() { // 如果当前在图片输入模式,先清除 @@ -478,9 +510,9 @@ class AgentController extends GetxController { clearImageInput(); return; } - + isTextInputMode.toggle(); - + // 切换到语音模式时,直接开始语音输入 if (!isTextInputMode.value) { startVoiceInput(); @@ -498,10 +530,10 @@ class AgentController extends GetxController { final XFile? image = await picker.pickImage( source: ImageSource.gallery, imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 + maxWidth: 1024, // 限制最大宽度 + maxHeight: 1024, // 限制最大高度 ); - + if (image != null) { // 设置已选择的图片 selectedImagePath.value = image.path; @@ -520,10 +552,10 @@ class AgentController extends GetxController { final XFile? photo = await picker.pickImage( source: ImageSource.camera, imageQuality: 80, // 设置图片质量 - maxWidth: 1024, // 限制最大宽度 - maxHeight: 1024, // 限制最大高度 + maxWidth: 1024, // 限制最大宽度 + maxHeight: 1024, // 限制最大高度 ); - + if (photo != null) { // 设置已选择的图片 selectedImagePath.value = photo.path; @@ -534,18 +566,18 @@ class AgentController extends GetxController { logger.e('拍照失败: $e'); } } - + // 清除图片输入状态 void clearImageInput() { selectedImagePath.value = null; isImageInputActive.value = false; } - + // 使用预设提示词处理图片 void useImagePrompt(String prompt) { // 确保selectedImagePath.value不为null if (selectedImagePath.value == null) return; - + // 填充文本框同时直接发送消息 textController.text = prompt; // 立即发送图片和提示词 @@ -554,19 +586,19 @@ class AgentController extends GetxController { // 发送后清除图片输入状态 clearImageInput(); } - + // 预设提示词:总结图片内容 void summarizeImage() { useImagePrompt('请总结这张图片的内容'); } - + // 预设提示词:抽取图像文字 void extractTextFromImage() { useImagePrompt('请提取这张图片中的所有文字'); } - + // 预设提示词:翻译图像文字 void translateImageText() { useImagePrompt('请翻译这张图片中的文字'); } -} \ No newline at end of file +} diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 8189ec0b7..be56a48f9 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -10,7 +10,7 @@ class AgentView extends StatelessWidget { @override Widget build(BuildContext context) { final controller = Get.find(); - + return Scaffold( appBar: AppBar( title: Row( @@ -93,12 +93,13 @@ class AgentView extends StatelessWidget { isRecognizing: message.isRecognizing, hasImage: message.hasImage, imagePath: message.imagePath, + mate: message.meta, ); }, ), ), ), - + // 输入区域 Container( padding: const EdgeInsets.all(16), @@ -117,7 +118,8 @@ class AgentView extends StatelessWidget { children: [ // 图片预览区域 - 仅在图片输入模式下显示 Obx(() { - if (controller.isImageInputActive.value && controller.selectedImagePath.value != null) { + if (controller.isImageInputActive.value && + controller.selectedImagePath.value != null) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -154,7 +156,7 @@ class AgentView extends StatelessWidget { ), ], ), - + // 预设提示选项 SizedBox(height: 8), SingleChildScrollView( @@ -188,7 +190,7 @@ class AgentView extends StatelessWidget { return SizedBox.shrink(); } }), - + // 输入控件行 Row( children: [ @@ -213,14 +215,14 @@ class AgentView extends StatelessWidget { ), ); }), - + const SizedBox(width: 8), - + // 文本输入框或语音波形图 Expanded( child: Obx(() { final isTextMode = controller.isTextInputMode.value; - + if (isTextMode) { // 文本输入模式 - 注意观察图片模式时的高度变化 return Container( @@ -234,18 +236,23 @@ class AgentView extends StatelessWidget { Expanded( child: Obx(() { // 图片模式下使用更高的输入框 - final isImageActive = controller.isImageInputActive.value; - + final isImageActive = + controller.isImageInputActive.value; + return TextField( controller: controller.textController, maxLines: isImageActive ? 2 : 1, minLines: isImageActive ? 2 : 1, decoration: InputDecoration( - hintText: isImageActive ? '添加对图片的问题或描述...' : '输入消息...', + hintText: isImageActive + ? '添加对图片的问题或描述...' + : '输入消息...', border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: isImageActive ? 12 : 0), + contentPadding: EdgeInsets.symmetric( + vertical: isImageActive ? 12 : 0), ), - onSubmitted: (_) => controller.sendTextMessage(), + onSubmitted: (_) => + controller.sendTextMessage(), ); }), ), @@ -271,7 +278,8 @@ class AgentView extends StatelessWidget { }, child: Container( height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: + const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(24), @@ -281,7 +289,8 @@ class AgentView extends StatelessWidget { // 语音波形图 - 始终显示 Expanded( child: Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: + MainAxisAlignment.start, children: [ ...List.generate(10, (index) { return _buildSoundBar(index); @@ -289,14 +298,16 @@ class AgentView extends StatelessWidget { const Spacer(), // "正在聆听"文字 Obx(() { - final isListening = controller.isListening.value; + final isListening = + controller.isListening.value; return Row( children: [ Icon( Icons.mic, size: 14, - color: isListening - ? Theme.of(context).primaryColor + color: isListening + ? Theme.of(context) + .primaryColor : Colors.grey, ), const SizedBox(width: 4), @@ -304,8 +315,9 @@ class AgentView extends StatelessWidget { '正在聆听', style: TextStyle( fontSize: 12, - color: isListening - ? Theme.of(context).primaryColor + color: isListening + ? Theme.of(context) + .primaryColor : Colors.grey, ), ), @@ -322,9 +334,9 @@ class AgentView extends StatelessWidget { } }), ), - + const SizedBox(width: 8), - + // 图片选择按钮 Container( width: 48, @@ -351,7 +363,8 @@ class AgentView extends StatelessWidget { value: 'camera', child: Row( children: [ - Icon(Icons.camera_alt, color: Theme.of(context).primaryColor), + Icon(Icons.camera_alt, + color: Theme.of(context).primaryColor), SizedBox(width: 8), Text('拍照'), ], @@ -361,7 +374,8 @@ class AgentView extends StatelessWidget { value: 'gallery', child: Row( children: [ - Icon(Icons.photo_library, color: Theme.of(context).primaryColor), + Icon(Icons.photo_library, + color: Theme.of(context).primaryColor), SizedBox(width: 8), Text('从相册选择'), ], @@ -379,7 +393,7 @@ class AgentView extends StatelessWidget { ), ); } - + // 构建提示选项芯片 Widget _buildPromptChip({ required BuildContext context, @@ -408,15 +422,15 @@ class AgentView extends StatelessWidget { ), ); } - + // 构建声音柱 Widget _buildSoundBar(int index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: Obx(() => _SoundBar( - delay: index * 0.1, - isActive: Get.find().isListening.value, - )), + delay: index * 0.1, + isActive: Get.find().isListening.value, + )), ); } } @@ -425,41 +439,42 @@ class AgentView extends StatelessWidget { class _SoundBar extends StatefulWidget { final double delay; final bool isActive; - + const _SoundBar({ required this.delay, required this.isActive, }); - + @override _SoundBarState createState() => _SoundBarState(); } -class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin { +class _SoundBarState extends State<_SoundBar> + with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; - + @override void initState() { super.initState(); - + _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), ); - + _animation = Tween(begin: 3, end: 15).animate( CurvedAnimation( parent: _controller, curve: Curves.easeInOut, ), )..addListener(() { - setState(() {}); - }); - + setState(() {}); + }); + _startOrStopAnimation(); } - + @override void didUpdateWidget(_SoundBar oldWidget) { super.didUpdateWidget(oldWidget); @@ -467,7 +482,7 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi _startOrStopAnimation(); } } - + void _startOrStopAnimation() { if (widget.isActive) { Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { @@ -481,22 +496,23 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi _controller.animateTo(0.1, duration: Duration(milliseconds: 300)); } } - + @override void dispose() { _controller.dispose(); super.dispose(); } - + @override Widget build(BuildContext context) { return Container( width: 2, height: widget.isActive ? _animation.value : 3, decoration: BoxDecoration( - color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], + color: + widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], borderRadius: BorderRadius.circular(1), ), ); } -} \ No newline at end of file +} diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index c7c4b581c..cc539bc18 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -10,6 +10,7 @@ class MessageBubble extends StatelessWidget { final bool isRecognizing; final bool hasImage; final String? imagePath; + final Map? mate; const MessageBubble({ Key? key, @@ -19,31 +20,42 @@ class MessageBubble extends StatelessWidget { this.isRecognizing = false, this.hasImage = false, this.imagePath, + this.mate, }) : super(key: key); @override Widget build(BuildContext context) { final timeFormat = DateFormat('HH:mm'); - + return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( - mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) _buildAvatar(isUser, context), const SizedBox(width: 8), Flexible( child: Column( - crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( - padding: hasImage ? (message == '[图片]' ? EdgeInsets.zero : const EdgeInsets.all(12)) : const EdgeInsets.all(12), + padding: hasImage + ? (message == '[图片]' + ? EdgeInsets.zero + : const EdgeInsets.all(12)) + : const EdgeInsets.all(12), decoration: BoxDecoration( color: _getBubbleColor(context), borderRadius: BorderRadius.circular(18).copyWith( - bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), - bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4), + bottomLeft: isUser + ? const Radius.circular(18) + : const Radius.circular(4), + bottomRight: !isUser + ? const Radius.circular(18) + : const Radius.circular(4), ), boxShadow: [ BoxShadow( @@ -79,7 +91,7 @@ class MessageBubble extends StatelessWidget { /// 获取气泡颜色 Color _getBubbleColor(BuildContext context) { if (isUser) { - return isRecognizing + return isRecognizing ? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 : Color(0xFF3B82F6); // 使用更明亮的蓝色 } else { @@ -135,7 +147,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + // 如果是图片消息,显示图片 if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 @@ -150,7 +162,7 @@ class MessageBubble extends StatelessWidget { ), ); } - + // 如果消息是自定义文本,显示文本+图片 return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -177,18 +189,34 @@ class MessageBubble extends StatelessWidget { ], ); } - + // 判断消息内容,显示特殊卡片 - if (!isUser) { - if (message.contains('播放列表') || message.contains('音乐')) { + if (!isUser && mate != null) { + if (mate!.keys.contains('card_music')) { + final musicName = mate!['card_music']['name'] as String; + final musicUrl = mate!['card_music']['url'] as String; + final musicSinger = mate!['card_music']['sgener'] as String; + //音乐卡片 + return _buildMusicItem(context, musicName, musicUrl, musicSinger); + } else if (mate!.keys.contains('card_musiclist')) { + //音乐播放列表 return _buildMusicContent(context); - } else if (message.contains('景点') || message.contains('旅游')) { - return _buildTravelContent(context); - } else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) { + } else if (mate!.keys.contains('card_weather')) { + //天气 return _buildWeatherContent(context); } + + // if (message.contains('播放列表') || message.contains('音乐')) { + // return _buildMusicContent(context); + // } else if (message.contains('景点') || message.contains('旅游')) { + // return _buildTravelContent(context); + // } else if (message.contains('天气') || + // message.contains('气温') || + // message.contains('预报')) { + // return _buildWeatherContent(context); + // } } - + return Text( message, style: TextStyle( @@ -272,14 +300,19 @@ class MessageBubble extends StatelessWidget { ], ), const SizedBox(height: 10), - + // 歌曲示例 - _buildMusicItem('雨声与钢琴曲', '纯音乐', context), + _buildMusicItem( + context, + '雨声与钢琴曲', + "", + '纯音乐', + ), Divider(height: 1, thickness: 1, color: Colors.grey[100]), - _buildMusicItem('Rainy Day', 'Coldplay', context), + _buildMusicItem(context, 'Rainy Day', "", 'Coldplay'), Divider(height: 1, thickness: 1, color: Colors.grey[100]), - _buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context), - + _buildMusicItem(context, '雨天的爵士咖啡馆', "", '爵士合集'), + const SizedBox(height: 8), Center( child: TextButton( @@ -313,7 +346,8 @@ class MessageBubble extends StatelessWidget { label: Text('播放全部'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -344,7 +378,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + /// 构建旅游内容卡片 Widget _buildTravelContent(BuildContext context) { return Column( @@ -396,14 +430,14 @@ class MessageBubble extends StatelessWidget { ], ), const SizedBox(height: 10), - + // 景点列表 _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), Divider(height: 1, thickness: 1, color: Colors.grey[100]), _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), Divider(height: 1, thickness: 1, color: Colors.grey[100]), _buildLocationItem('浅草寺', '东京最古老的寺庙', context), - + const SizedBox(height: 8), Center( child: TextButton( @@ -437,7 +471,8 @@ class MessageBubble extends StatelessWidget { label: Text('查看地图'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -470,7 +505,8 @@ class MessageBubble extends StatelessWidget { } /// 构建音乐项目 - Widget _buildMusicItem(String title, String artist, BuildContext context) { + Widget _buildMusicItem( + BuildContext context, String name, String url, String singer) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( @@ -494,7 +530,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - title, + name, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -504,7 +540,7 @@ class MessageBubble extends StatelessWidget { ), SizedBox(height: 2), Text( - artist, + singer, style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -516,7 +552,8 @@ class MessageBubble extends StatelessWidget { ), IconButton( onPressed: () {}, - icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor), + icon: + Icon(Icons.play_circle, color: Theme.of(context).primaryColor), padding: EdgeInsets.zero, constraints: BoxConstraints(), iconSize: 24, @@ -525,9 +562,10 @@ class MessageBubble extends StatelessWidget { ), ); } - + /// 构建地点项目 - Widget _buildLocationItem(String name, String description, BuildContext context) { + Widget _buildLocationItem( + String name, String description, BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( @@ -749,7 +787,8 @@ class MessageBubble extends StatelessWidget { label: Text('更多地区'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).primaryColor, - side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity(0.5)), padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -780,7 +819,7 @@ class MessageBubble extends StatelessWidget { ], ); } - + /// 构建单日天气预报 Widget _buildForecastDay(String day, IconData icon, String temp) { return Column( @@ -815,26 +854,27 @@ class MessageBubble extends StatelessWidget { /// 动画点组件 class _AnimatedDot extends StatefulWidget { final int delayMilliseconds; - + const _AnimatedDot({required this.delayMilliseconds}); - + @override _AnimatedDotState createState() => _AnimatedDotState(); } -class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { +class _AnimatedDotState extends State<_AnimatedDot> + with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; - + @override void initState() { super.initState(); - + _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); - + _animation = Tween(begin: 0, end: 6).animate( CurvedAnimation( parent: _controller, @@ -842,22 +882,22 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), ), )..addListener(() { - setState(() {}); - }); - + setState(() {}); + }); + Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { if (mounted) { _controller.repeat(); } }); } - + @override void dispose() { _controller.dispose(); super.dispose(); } - + @override Widget build(BuildContext context) { return Container( @@ -876,4 +916,4 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta ), ); } -} \ No newline at end of file +} diff --git a/local_plugins/agent_service/pubspec.yaml b/local_plugins/agent_service/pubspec.yaml index 3c348c619..a12f742b4 100644 --- a/local_plugins/agent_service/pubspec.yaml +++ b/local_plugins/agent_service/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: path: ../deepvoice_utils chat_storage: path: ../chat_storage + music_service: + path: ../music_service get: ^4.6.5 dev_dependencies: diff --git a/local_plugins/music_service/.gitignore b/local_plugins/music_service/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/music_service/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/music_service/.metadata b/local_plugins/music_service/.metadata new file mode 100644 index 000000000..cff4c42e1 --- /dev/null +++ b/local_plugins/music_service/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: android + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/music_service/CHANGELOG.md b/local_plugins/music_service/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/music_service/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/music_service/LICENSE b/local_plugins/music_service/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/music_service/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/music_service/README.md b/local_plugins/music_service/README.md new file mode 100644 index 000000000..af4d1a7b1 --- /dev/null +++ b/local_plugins/music_service/README.md @@ -0,0 +1,15 @@ +# music_service + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/music_service/analysis_options.yaml b/local_plugins/music_service/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/music_service/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/music_service/android/.gitignore b/local_plugins/music_service/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/music_service/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/music_service/android/build.gradle b/local_plugins/music_service/android/build.gradle new file mode 100644 index 000000000..8c1602312 --- /dev/null +++ b/local_plugins/music_service/android/build.gradle @@ -0,0 +1,70 @@ +group = "com.yunqiinnovation.music_service" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "1.8.22" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.yunqiinnovation.music_service" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} + +dependencies { + implementation project(':deepvoice_utils') +} diff --git a/local_plugins/music_service/android/settings.gradle b/local_plugins/music_service/android/settings.gradle new file mode 100644 index 000000000..48f745bec --- /dev/null +++ b/local_plugins/music_service/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'music_service' diff --git a/local_plugins/music_service/android/src/main/AndroidManifest.xml b/local_plugins/music_service/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7653a17a6 --- /dev/null +++ b/local_plugins/music_service/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + 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 new file mode 100644 index 000000000..2deda573b --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt @@ -0,0 +1,153 @@ +package com.yunqiinnovation.music_service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.yunqiinnovation.music_service.MusicServiceStarter +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.atomic.AtomicBoolean + +class MusicService : Service() { + + companion object { + private const val TAG = "MusicService" + private const val CHANNEL_ID = "music_media_service_channel" + private const val NOTIFICATION_ID = 1002 + private const val PREF_NAME = "deep_voice_config" + + // 用于跟踪服务是否正在运行 + private val isRunning = AtomicBoolean(false) + + // 获取服务运行状态 + fun isServiceRunning(): Boolean = isRunning.get() + } + private var mediaPlayer: MediaPlayer? = null + private val playlist = mutableListOf() // 可扩展支持播放列表 + private var currentIndex = 0 + + override fun onCreate() { + super.onCreate() + val notification = createNotification() // 创建一个非空通知 + startForeground(1, notification) + } + private var isPlaying = false + /** + * 创建前台服务通知 + */ + private fun createNotification(): Notification { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Android 8.0+ 需要创建通知渠道 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "音乐播放服务", + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + + // 点击通知打开主界面 + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = if (intent != null) { + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } else { + null + } + + // 构建通知 + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("DeepSound") + .setContentText(if (isPlaying) "正在播放" else "已暂停") + .setSmallIcon(android.R.drawable.ic_media_play) + + // 设置点击意图 + pendingIntent?.let { builder.setContentIntent(it) } + + return builder.build() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val command = intent?.getStringExtra("command") ?: return START_NOT_STICKY + Log.i(TAG, "onStartCommand: $command") + when (command) { + "play" -> { + val url = intent.getStringExtra("url") ?: return START_NOT_STICKY + play(url) + } + "pause" -> mediaPlayer?.pause() + "resume" -> mediaPlayer?.start() + "stop" -> stopSelf() + "next" -> { + currentIndex++ + if (currentIndex < playlist.size) play(playlist[currentIndex]) + } + "previous" -> { + currentIndex-- + if (currentIndex >= 0) play(playlist[currentIndex]) + } + "setVolume" -> { + val volume = intent.getFloatExtra("volume", 1.0f) + mediaPlayer?.setVolume(volume, volume) + } + } + return START_STICKY + } + + private fun play(url: String) { + Log.i(TAG, "开始播放音乐: $url") + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setDataSource(url) + setAudioStreamType(AudioManager.STREAM_MUSIC) + prepareAsync() + setOnPreparedListener { start() } + setOnCompletionListener { stopSelf() } + } +// startForeground(1, NotificationUtils.createNotification(this, "Playing...")) + } + + override fun onDestroy() { + mediaPlayer?.release() + stopForeground(true) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + /** + * 释放资源 + */ + fun release() { + try { + mediaPlayer?.apply { + if (isPlaying) stop() + release() + } + mediaPlayer = null + } catch (e: Exception) { + // 忽略异常 + } + } + /** + * 是否正在播放 + */ + fun isPlaying() = mediaPlayer?.isPlaying == true +} 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 new file mode 100644 index 000000000..6741e7937 --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt @@ -0,0 +1,46 @@ +package com.yunqiinnovation.music_service + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import com.yunqiinnovation.deepvoice_utils.FileLogger +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler + + +/** MusicServicePlugin */ +class MusicServicePlugin: FlutterPlugin, MethodCallHandler { + private val TAG = "MusicServicePlugin" + private lateinit var context: Context + private lateinit var channel : MethodChannel + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "music_service") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + FileLogger.d(TAG, "Method call: ${call.method}") + val intent = Intent(context, MusicService::class.java) + when (call.method) { + "play" -> { + val url = call.argument("url") + MusicServiceStarter.startServiceWithCommand(context, "play", url) + } + "pause" -> MusicServiceStarter.startServiceWithCommand(context, "pause") + "resume" -> MusicServiceStarter.startServiceWithCommand(context, "resume") + "stop" -> MusicServiceStarter.startServiceWithCommand(context, "stop") + "setVolume" -> { + val volume = call.argument("volume")?.toFloat() + MusicServiceStarter.startServiceWithCommand(context, "setVolume", volume = volume) + } + else -> result.notImplemented() + } + result.success(null) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} +} 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 new file mode 100644 index 000000000..dbcd05cca --- /dev/null +++ b/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt @@ -0,0 +1,19 @@ +package com.yunqiinnovation.music_service + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat + +object MusicServiceStarter { + private val TAG = "MusicServiceStarter" + fun startServiceWithCommand(context: Context, command: String, url: String? = null, volume: Float? = null) { + Log.i(TAG, "startServiceWithCommand: $command $url") + val intent = Intent(context, MusicService::class.java).apply { + putExtra("command", command) + url?.let { putExtra("url", it) } + volume?.let { putExtra("volume", it) } + } + ContextCompat.startForegroundService(context, intent) + } +} \ No newline at end of file diff --git a/local_plugins/music_service/ios/.gitignore b/local_plugins/music_service/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/local_plugins/music_service/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/local_plugins/music_service/ios/Assets/.gitkeep b/local_plugins/music_service/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift b/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift new file mode 100644 index 000000000..87d033474 --- /dev/null +++ b/local_plugins/music_service/ios/Classes/MusicServicePlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class MusicServicePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "music_service", binaryMessenger: registrar.messenger()) + let instance = MusicServicePlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy b/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/local_plugins/music_service/ios/music_service.podspec b/local_plugins/music_service/ios/music_service.podspec new file mode 100644 index 000000000..d9aa1dcb3 --- /dev/null +++ b/local_plugins/music_service/ios/music_service.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint music_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'music_service' + s.version = '0.0.1' + s.summary = 'A new Flutter project.' + s.description = <<-DESC +A new Flutter project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'music_service_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/local_plugins/music_service/lib/music_service.dart b/local_plugins/music_service/lib/music_service.dart new file mode 100644 index 000000000..cace4292f --- /dev/null +++ b/local_plugins/music_service/lib/music_service.dart @@ -0,0 +1,55 @@ +import 'package:flutter/services.dart'; + +class MusicService { + static final MusicService _instance = MusicService._internal(); + + /// 获取单例 + factory MusicService() => _instance; + + // 方法通道 + final MethodChannel _methodChannel = const MethodChannel( + 'com.yunqiinnovation.music_service', + ); + + // 事件通道 - 简化为只使用数据和状态通道 + late final EventChannel _dataEventChannel; + late final EventChannel _statusEventChannel; + + // 事件流 + late final Stream _dataStream; + late final Stream _statusStream; + + // 获取事件流 + Stream get onDataReceived => _dataStream; + + // 状态流直接传递原始数据 + Stream get onStatusUpdated => _statusStream; + + // 内部构造函数 + MusicService._internal() { + _dataEventChannel = const EventChannel( + 'com.yunqiinnovation.music_service/data_events', + ); + _statusEventChannel = const EventChannel( + 'com.yunqiinnovation.music_service/status_events', + ); + + // 初始化事件流 + _dataStream = _dataEventChannel.receiveBroadcastStream(); + _statusStream = _statusEventChannel.receiveBroadcastStream(); + } + + Future playMusic(String url) async { + await _methodChannel.invokeMethod('play', {'url': url}); + } + + Future pauseMusic() async => _methodChannel.invokeMethod('pause'); + Future resumeMusic() async => _methodChannel.invokeMethod('resume'); + Future nextTrack() async => _methodChannel.invokeMethod('next'); + Future previousTrack() async => _methodChannel.invokeMethod('previous'); + Future stopMusic() async => _methodChannel.invokeMethod('stop'); + Future setVolume(double volume) async => _methodChannel.invokeMethod( + 'setVolume', + {'volume': volume}, + ); // volume: 0.0~1.0 +} diff --git a/local_plugins/music_service/pubspec.yaml b/local_plugins/music_service/pubspec.yaml new file mode 100644 index 000000000..28633c08c --- /dev/null +++ b/local_plugins/music_service/pubspec.yaml @@ -0,0 +1,72 @@ +name: music_service +description: "A new Flutter project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.2 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.yunqiinnovation.music_service + pluginClass: MusicServicePlugin + ios: + pluginClass: MusicServicePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/pubspec.yaml b/pubspec.yaml index 2cbc56592..427b3b9c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,6 +86,8 @@ dependencies: path: local_plugins/classic_bluetooth agent_service: path: local_plugins/agent_service + music_service: + path: local_plugins/music_service dev_dependencies: