diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 726ebcb..f246c0d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,32 +1,59 @@ PODS: - - AgoraInfra_iOS (1.2.13.1) + - AgoraInfra_iOS (1.2.13) + - camera_avfoundation (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_native_splash (2.4.3): - Flutter - - HyphenateChat (4.15.0): - - AgoraInfra_iOS (~> 1.2.13) - - im_flutter_sdk_ios (4.14.0): + - HyphenateChat (4.15.1): + - AgoraInfra_iOS (= 1.2.13) + - im_flutter_sdk_ios (4.15.2): - Flutter - - HyphenateChat (= 4.15.0) + - HyphenateChat (= 4.15.1) - image_picker_ios (0.0.1): - Flutter + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - photo_manager (3.7.1): + - Flutter + - FlutterMacOS + - record_ios (1.1.0): + - Flutter + - sensors_plus (0.0.1): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - Flutter DEPENDENCIES: + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - Flutter (from `Flutter`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - im_flutter_sdk_ios (from `.symlinks/plugins/im_flutter_sdk_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: @@ -34,6 +61,8 @@ SPEC REPOS: - HyphenateChat EXTERNAL SOURCES: + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" Flutter: :path: Flutter flutter_native_splash: @@ -42,23 +71,45 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/im_flutter_sdk_ios/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08 + AgoraInfra_iOS: 65e11a2183ab7836258768868d06058c22701b13 + camera_avfoundation: 281867ff09f1da66f031a184ecfbc6f2e625c9f5 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 - HyphenateChat: 4523c7fb2075771c49a2c492b31544d6cc82ff50 - im_flutter_sdk_ios: 2348d34baa17e98d8c490d92023410956c8afee1 + HyphenateChat: ec813941100d602d24e06b04b867474d634cb39d + im_flutter_sdk_ios: de87814fcf3a3cb585a78b55fba5f9fec989096b image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d + record_ios: 840d21cce013c5a3b2168b74a54ebdb4136359e2 + sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + video_player_avfoundation: 7993f492ae0bd77edaea24d9dc051d8bb2cd7c86 + wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - 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 79425b0..3c9f60a 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,59 +20,65 @@ 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 { @@ -97,7 +104,8 @@ class ChatPage extends StatelessWidget { ), ], ), - ); + ), + ); }, ); } diff --git a/lib/widget/message/message_item.dart b/lib/widget/message/message_item.dart index bf6dc0d..661d7ec 100644 --- a/lib/widget/message/message_item.dart +++ b/lib/widget/message/message_item.dart @@ -48,6 +48,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 ea8a520..6032ac6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,62 @@ packages: url: "https://pub.dev" 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 4279654..e3ea775 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: record: ^6.1.2 video_player: ^2.9.2 chewie: ^1.8.5 # 视频播放器UI + audioplayers: ^6.5.1 dev_dependencies: flutter_test: