Browse Source

feat(message): 实现语音消息播放功能

- 新增语音播放管理器 VoicePlayerManager,支持播放、暂停、停止等操作- 在聊天页面添加返回时停止播放的逻辑
-语音消息组件支持点击播放/暂停,并显示播放状态- 集成 audioplayers 库用于音频播放-优化语音消息 UI,根据播放状态切换图标
- 支持通过消息 ID 唯一标识和控制音频播放
- 添加播放失败和异常处理机制
- 更新依赖配置,引入 audioplayers 插件
ios
Jolie 4 months ago
parent
commit
bac87972f4
6 changed files with 316 additions and 96 deletions
  1. 96
      lib/controller/message/voice_player_manager.dart
  2. 136
      lib/pages/message/chat_page.dart
  3. 1
      lib/widget/message/message_item.dart
  4. 122
      lib/widget/message/voice_item.dart
  5. 56
      pubspec.lock
  6. 1
      pubspec.yaml

96
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<String?> currentPlayingId = Rx<String?>(null);
final Rx<PlayerState> playerState = Rx<PlayerState>(PlayerState.stopped);
VoicePlayerManager() {
//
_audioPlayer.onPlayerStateChanged.listen((state) {
playerState.value = state;
if (state == PlayerState.completed) {
//
_currentPlayingId = null;
currentPlayingId.value = null;
}
});
}
///
/// [audioId] ID
/// [filePath]
Future<void> 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<void> stop() async {
try {
await _audioPlayer.stop();
_currentPlayingId = null;
currentPlayingId.value = null;
} catch (e) {
print('停止播放失败: $e');
}
}
///
Future<void> 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();
}
}

136
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<ChatController>(
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);
},
),
],
),
),
);
},

1
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),

122
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<VoiceItem> createState() => _VoiceItemState();
}
class _VoiceItemState extends State<VoiceItem> {
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<void> _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),

56
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:

1
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:

Loading…
Cancel
Save