From bac87972f48dd84e2338a5d34865d1b462e4ce6c Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Thu, 13 Nov 2025 02:12:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(message):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=B6=88=E6=81=AF=E6=92=AD=E6=94=BE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增语音播放管理器 VoicePlayerManager,支持播放、暂停、停止等操作- 在聊天页面添加返回时停止播放的逻辑 -语音消息组件支持点击播放/暂停,并显示播放状态- 集成 audioplayers 库用于音频播放-优化语音消息 UI,根据播放状态切换图标 - 支持通过消息 ID 唯一标识和控制音频播放 - 添加播放失败和异常处理机制 - 更新依赖配置,引入 audioplayers 插件 --- .../message/voice_player_manager.dart | 96 +++++++++++++ lib/pages/message/chat_page.dart | 136 +++++++++--------- lib/widget/message/message_item.dart | 1 + lib/widget/message/voice_item.dart | 122 +++++++++++----- pubspec.lock | 56 ++++++++ pubspec.yaml | 1 + 6 files changed, 316 insertions(+), 96 deletions(-) create mode 100644 lib/controller/message/voice_player_manager.dart diff --git a/lib/controller/message/voice_player_manager.dart b/lib/controller/message/voice_player_manager.dart new file mode 100644 index 0000000..0f498c6 --- /dev/null +++ b/lib/controller/message/voice_player_manager.dart @@ -0,0 +1,96 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +/// 语音播放管理器,单例模式,统一管理语音播放 +class VoicePlayerManager extends GetxController { + static VoicePlayerManager? _instance; + static VoicePlayerManager get instance { + _instance ??= Get.put(VoicePlayerManager()); + return _instance!; + } + + final AudioPlayer _audioPlayer = AudioPlayer(); + String? _currentPlayingId; + final Rx currentPlayingId = Rx(null); + final Rx playerState = Rx(PlayerState.stopped); + + VoicePlayerManager() { + // 监听播放状态变化 + _audioPlayer.onPlayerStateChanged.listen((state) { + playerState.value = state; + if (state == PlayerState.completed) { + // 播放完成,重置状态 + _currentPlayingId = null; + currentPlayingId.value = null; + } + }); + } + + /// 播放音频 + /// [audioId] 音频的唯一标识(通常是消息ID) + /// [filePath] 音频文件路径 + Future play(String audioId, String filePath) async { + try { + // 如果正在播放其他音频,先停止 + if (_currentPlayingId != null && _currentPlayingId != audioId) { + await stop(); + } + + // 如果是同一个音频,则暂停/恢复 + if (_currentPlayingId == audioId) { + if (playerState.value == PlayerState.playing) { + await _audioPlayer.pause(); + } else { + await _audioPlayer.resume(); + } + return; + } + // 播放新音频 + _currentPlayingId = audioId; + currentPlayingId.value = audioId; + await _audioPlayer.play(DeviceFileSource(filePath)); + } catch (e) { + print('播放音频失败: $e'); + _currentPlayingId = null; + currentPlayingId.value = null; + } + } + + /// 停止播放 + Future stop() async { + try { + await _audioPlayer.stop(); + _currentPlayingId = null; + currentPlayingId.value = null; + } catch (e) { + print('停止播放失败: $e'); + } + } + + /// 暂停播放 + Future pause() async { + try { + await _audioPlayer.pause(); + } catch (e) { + print('暂停播放失败: $e'); + } + } + + /// 检查指定音频是否正在播放 + bool isPlaying(String audioId) { + return _currentPlayingId == audioId && + playerState.value == PlayerState.playing; + } + + /// 检查指定音频是否已加载 + bool isLoaded(String audioId) { + return _currentPlayingId == audioId; + } + + @override + void onClose() { + _audioPlayer.dispose(); + super.onClose(); + } +} diff --git a/lib/pages/message/chat_page.dart b/lib/pages/message/chat_page.dart index fbf7d4b..5e17a22 100644 --- a/lib/pages/message/chat_page.dart +++ b/lib/pages/message/chat_page.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; import '../../controller/message/chat_controller.dart'; +import '../../controller/message/voice_player_manager.dart'; import '../../generated/assets.dart'; import '../../../widget/message/chat_input_bar.dart'; import '../../../widget/message/message_item.dart'; @@ -19,76 +20,83 @@ class ChatPage extends StatelessWidget { return GetBuilder( init: ChatController(userId: userId), builder: (controller) { - return Scaffold( - backgroundColor: Color(0xffF5F5F5), - appBar: AppBar( - title: Text(controller.userInfo.value?.nickName ?? ''), - centerTitle: true, - actions: [ - Container( - padding: EdgeInsets.only(right: 16.w), - child: Image.asset(Assets.imagesMore, width: 16.w), - ).onTap(() {}), - ], - leading: IconButton( - icon: Icon(Icons.arrow_back_ios), - onPressed: () { - Get.back(); - }, + return WillPopScope( + onWillPop: () async { + // 退出页面时停止播放并销毁播放器 + await VoicePlayerManager.instance.stop(); + return true; + }, + child: Scaffold( + backgroundColor: Color(0xffF5F5F5), + appBar: AppBar( + title: Text(controller.userInfo.value?.nickName ?? ''), + centerTitle: true, + actions: [ + Container( + padding: EdgeInsets.only(right: 16.w), + child: Image.asset(Assets.imagesMore, width: 16.w), + ).onTap(() {}), + ], + leading: IconButton( + icon: Icon(Icons.arrow_back_ios), + onPressed: () { + Get.back(); + }, + ), ), - ), - body: Column( - children: [ - // 消息列表区域 - Expanded( - child: Container( - color: Color(0xffF5F5F5), - child: GestureDetector( - onTap: () { - // 点击消息区域收起键盘 - FocusManager.instance.primaryFocus?.unfocus(); - }, - behavior: HitTestBehavior.opaque, - child: ListView.builder( - reverse: true, - padding: EdgeInsets.all(16.w), - itemCount: controller.messages.length, - itemBuilder: (context, index) { - final message = controller.messages[index]; - final isSentByMe = - message.direction == MessageDirection.SEND; + body: Column( + children: [ + // 消息列表区域 + Expanded( + child: Container( + color: Color(0xffF5F5F5), + child: GestureDetector( + onTap: () { + // 点击消息区域收起键盘 + FocusManager.instance.primaryFocus?.unfocus(); + }, + behavior: HitTestBehavior.opaque, + child: ListView.builder( + reverse: true, + padding: EdgeInsets.all(16.w), + itemCount: controller.messages.length, + itemBuilder: (context, index) { + final message = controller.messages[index]; + final isSentByMe = + message.direction == MessageDirection.SEND; - final previousMessage = index > 0 - ? controller.messages[index - 1] - : null; + final previousMessage = index > 0 + ? controller.messages[index - 1] + : null; - return MessageItem( - message: message, - isSentByMe: isSentByMe, - previousMessage: previousMessage, - ); - }, + return MessageItem( + message: message, + isSentByMe: isSentByMe, + previousMessage: previousMessage, + ); + }, + ), ), ), ), - ), - // 使用抽离的聊天输入栏组件 - ChatInputBar( - onSendMessage: (message) async { - await controller.sendMessage(message); - }, - onImageSelected: (imagePaths) async { - // 为每个图片路径调用控制器的方法发送图片消息 - for (var imagePath in imagePaths) { - await controller.sendImageMessage(imagePath); - } - }, - onVoiceRecorded: (filePath, seconds) async { - // 处理语音录音完成,回传文件路径和秒数 - await controller.sendVoiceMessage(filePath, seconds); - }, - ), - ], + // 使用抽离的聊天输入栏组件 + ChatInputBar( + onSendMessage: (message) async { + await controller.sendMessage(message); + }, + onImageSelected: (imagePaths) async { + // 为每个图片路径调用控制器的方法发送图片消息 + for (var imagePath in imagePaths) { + await controller.sendImageMessage(imagePath); + } + }, + onVoiceRecorded: (filePath, seconds) async { + // 处理语音录音完成,回传文件路径和秒数 + await controller.sendVoiceMessage(filePath, seconds); + }, + ), + ], + ), ), ); }, diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index 95d3196..d4b93d7 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -45,6 +45,7 @@ class MessageItem extends StatelessWidget { final voiceBody = message.body as EMVoiceMessageBody; return VoiceItem( voiceBody: voiceBody, + messageId: message.msgId, // 传递消息ID作为音频唯一标识 isSentByMe: isSentByMe, showTime: shouldShowTime(), formattedTime: formatMessageTime(message.serverTime), diff --git a/lib/widget/message/voice_item.dart b/lib/widget/message/voice_item.dart index c9f6e3c..3e91478 100644 --- a/lib/widget/message/voice_item.dart +++ b/lib/widget/message/voice_item.dart @@ -1,53 +1,110 @@ +import 'package:dating_touchme_app/extension/ex_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; import 'package:im_flutter_sdk/im_flutter_sdk.dart'; import '../../../generated/assets.dart'; +import '../../../controller/message/voice_player_manager.dart'; -class VoiceItem extends StatelessWidget { +class VoiceItem extends StatefulWidget { final EMVoiceMessageBody voiceBody; + final String messageId; // 消息ID,用作音频的唯一标识 final bool isSentByMe; final bool showTime; final String formattedTime; const VoiceItem({ required this.voiceBody, + required this.messageId, required this.isSentByMe, required this.showTime, required this.formattedTime, super.key, }); + @override + State createState() => _VoiceItemState(); +} + +class _VoiceItemState extends State { + final VoicePlayerManager _playerManager = VoicePlayerManager.instance; + + @override + void initState() { + super.initState(); + // 监听播放状态变化 + ever(_playerManager.currentPlayingId, (audioId) { + if (mounted) { + setState(() {}); + } + }); + ever(_playerManager.playerState, (state) { + if (mounted) { + setState(() {}); + } + }); + } + + // 处理播放/暂停 + Future _handlePlayPause() async { + try { + // 获取音频文件路径 + String? filePath; + final localPath = widget.voiceBody.localPath; + final remotePath = widget.voiceBody.remotePath; + + if (localPath.isNotEmpty) { + filePath = localPath; + } else if (remotePath != null && remotePath.isNotEmpty) { + // 如果是远程路径,需要先下载(这里简化处理,实际应该先下载到本地) + filePath = remotePath; + } + SmartDialog.showToast('来了$remotePath'); + if (filePath != null && filePath.isNotEmpty) { + await _playerManager.play(widget.messageId, filePath); + } else { + print('音频文件路径为空'); + } + } catch (e) { + print('播放音频失败: $e'); + } + } + @override Widget build(BuildContext context) { // 获取语音时长(秒) - final duration = voiceBody.duration; + final duration = widget.voiceBody.duration; final durationText = '${duration}s'; + // 判断当前音频是否正在播放 + final isPlaying = _playerManager.isPlaying(widget.messageId); + return Column( children: [ // 显示时间 - if (showTime) _buildTimeLabel(), + if (widget.showTime) _buildTimeLabel(), Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), child: Row( - mainAxisAlignment: isSentByMe + mainAxisAlignment: widget.isSentByMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isSentByMe) _buildAvatar(), - if (!isSentByMe) SizedBox(width: 8.w), + if (!widget.isSentByMe) _buildAvatar(), + if (!widget.isSentByMe) SizedBox(width: 8.w), Container( margin: EdgeInsets.only(top: 10.h), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), decoration: BoxDecoration( - color: isSentByMe ? Color(0xff8E7BF6) : Colors.white, + color: widget.isSentByMe ? Color(0xff8E7BF6) : Colors.white, borderRadius: BorderRadius.only( - topLeft: isSentByMe + topLeft: widget.isSentByMe ? Radius.circular(12.w) : Radius.circular(0), - topRight: isSentByMe + topRight: widget.isSentByMe ? Radius.circular(0) : Radius.circular(12.w), bottomLeft: Radius.circular(12.w), @@ -58,22 +115,21 @@ class VoiceItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ // 播放按钮 - GestureDetector( - onTap: () { - // TODO: 处理播放/暂停逻辑 - }, - child: Container( - width: 20.w, - height: 20.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isSentByMe ? Colors.white : Colors.black, - ), - child: Icon( - Icons.play_arrow, - color: isSentByMe ? Color(0xff8E7BF6) : Colors.white, - size: 16.w, - ), + Container( + width: 20.w, + height: 20.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isSentByMe + ? Colors.white + : Colors.black, + ), + child: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: widget.isSentByMe + ? Color(0xff8E7BF6) + : Colors.white, + size: 16.w, ), ), SizedBox(width: 8.w), @@ -82,7 +138,7 @@ class VoiceItem extends StatelessWidget { durationText, style: TextStyle( fontSize: 14.sp, - color: isSentByMe ? Colors.white : Colors.black, + color: widget.isSentByMe ? Colors.white : Colors.black, fontWeight: FontWeight.w500, ), ), @@ -91,9 +147,11 @@ class VoiceItem extends StatelessWidget { _buildWaveform(), ], ), - ), - if (isSentByMe) SizedBox(width: 8.w), - if (isSentByMe) _buildAvatar(), + ).onTap(() { + _handlePlayPause(); + }), + if (widget.isSentByMe) SizedBox(width: 8.w), + if (widget.isSentByMe) _buildAvatar(), ], ), ), @@ -109,7 +167,7 @@ class VoiceItem extends StatelessWidget { child: Container( padding: EdgeInsets.symmetric(horizontal: 12.w), child: Text( - formattedTime, + widget.formattedTime, style: TextStyle(fontSize: 12.sp, color: Colors.grey), ), ), @@ -134,7 +192,7 @@ class VoiceItem extends StatelessWidget { // 构建音频波形 Widget _buildWaveform() { // 根据时长生成波形条数量(最多20个) - final barCount = (voiceBody.duration / 2).ceil().clamp(5, 20); + final barCount = (widget.voiceBody.duration / 2).ceil().clamp(5, 20); return SizedBox( height: 16.h, @@ -152,7 +210,7 @@ class VoiceItem extends StatelessWidget { height: height, margin: EdgeInsets.symmetric(horizontal: 1.w), decoration: BoxDecoration( - color: isSentByMe + color: widget.isSentByMe ? Colors.white.withOpacity(0.8) : Colors.grey.withOpacity(0.6), borderRadius: BorderRadius.circular(1.w), diff --git a/pubspec.lock b/pubspec.lock index 27d5333..0b256e7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51b1ed1..9f46769 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: wechat_camera_picker: ^4.4.0 tdesign_flutter: ^0.2.5 record: ^6.1.2 + audioplayers: ^6.5.1 dev_dependencies: flutter_test: From db4b45aaacf381687f989a5558827c0da321c50a Mon Sep 17 00:00:00 2001 From: Jolie <412895109@qq.com> Date: Thu, 13 Nov 2025 02:16:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(message):=20=E5=9C=A8=E7=9B=B8?= =?UTF-8?q?=E6=9C=BA=E9=80=89=E6=8B=A9=E5=99=A8=E4=B8=AD=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E5=BD=95=E5=88=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 CameraPickerConfig以支持录制功能 - 更新 pickFromCamera 方法的配置参数- 保持现有拍照功能不变 --- lib/widget/message/more_options_view.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/widget/message/more_options_view.dart b/lib/widget/message/more_options_view.dart index 9dd5750..44bbcc7 100644 --- a/lib/widget/message/more_options_view.dart +++ b/lib/widget/message/more_options_view.dart @@ -88,7 +88,9 @@ class MoreOptionsView extends StatelessWidget { try { AssetEntity? entity = await CameraPicker.pickFromCamera( context, - pickerConfig: const CameraPickerConfig(), + pickerConfig: const CameraPickerConfig( + enableRecording: true, + ), ); if (entity != null) { // 获取拍摄照片的文件路径