import 'dart:io'; import 'dart:math' as math; import 'package:audioplayers/audioplayers.dart'; 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 'package:cached_network_image/cached_network_image.dart'; import '../../../generated/assets.dart'; import '../../../controller/message/voice_player_manager.dart'; import '../../../controller/global.dart'; import '../../../controller/message/chat_controller.dart'; class VoiceItem extends StatefulWidget { final EMVoiceMessageBody voiceBody; final String messageId; // 消息ID,用作音频的唯一标识 final EMMessage? message; // 添加消息对象以获取状态和错误码 final bool isSentByMe; final bool showTime; final String formattedTime; final VoidCallback? onResend; // 添加重发回调 const VoiceItem({ required this.voiceBody, required this.messageId, this.message, required this.isSentByMe, required this.showTime, required this.formattedTime, this.onResend, super.key, }); @override State createState() => _VoiceItemState(); } class _VoiceItemState extends State with TickerProviderStateMixin { final VoicePlayerManager _playerManager = VoicePlayerManager.instance; late AnimationController _waveformAnimationController; int _animationFrame = 0; @override void initState() { super.initState(); // 创建波形动画控制器 _waveformAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), // 2秒完成一个波浪周期 ); // 监听动画帧,更新波形 _waveformAnimationController.addListener(() { if (mounted && _playerManager.isPlaying(widget.messageId)) { setState(() { // 使用动画值来计算波浪位置,让波浪从左到右传播 _animationFrame++; }); } }); // 监听播放状态变化 ever(_playerManager.currentPlayingId, (audioId) { if (mounted) { setState(() {}); // 根据播放状态控制动画 if (_playerManager.isPlaying(widget.messageId)) { if (!_waveformAnimationController.isAnimating) { _waveformAnimationController.repeat(); } } else { _waveformAnimationController.stop(); _waveformAnimationController.reset(); _animationFrame = 0; } } }); ever(_playerManager.playerState, (state) { if (mounted) { setState(() {}); // 根据播放状态控制动画 if (state == PlayerState.playing && _playerManager.currentPlayingId.value == widget.messageId) { _waveformAnimationController.repeat(); } else { _waveformAnimationController.stop(); _animationFrame = 0; } } }); } @override void dispose() { _waveformAnimationController.dispose(); super.dispose(); } // 处理播放/暂停 Future _handlePlayPause() async { try { // 获取音频文件路径 String? filePath; var localPath = widget.voiceBody.localPath; final remotePath = widget.voiceBody.remotePath; print('🔍 语音消息路径检查: localPath=$localPath, remotePath=$remotePath'); // 对于接收到的消息,必须先确保文件已下载 if (!widget.isSentByMe && widget.message != null) { bool needDownload = false; bool fileValid = false; // 检查本地文件是否存在且有效 if (localPath.isNotEmpty) { final localFile = File(localPath); if (await localFile.exists()) { final fileSize = await localFile.length(); // 文件大小应该大于0,且至少应该大于1KB(语音文件通常不会太小) if (fileSize > 1024) { fileValid = true; print('✅ 本地文件存在且有效: $localPath, 大小: $fileSize bytes'); } else { print('⚠️ 本地文件大小异常: $localPath, 大小: $fileSize bytes'); needDownload = true; } } else { print('📥 本地文件不存在: $localPath'); needDownload = true; } } else { print('📥 本地路径为空,需要下载'); needDownload = true; } // 如果需要下载,先下载附件 if (needDownload) { if (remotePath == null || remotePath.isEmpty) { SmartDialog.showToast('无法获取语音文件'); print('❌ 远程路径为空,无法下载'); return; } try { print('📥 开始下载语音文件,远程路径: $remotePath'); SmartDialog.showToast('正在下载语音...'); // 下载附件 await EMClient.getInstance.chatManager .downloadAttachment(widget.message!); // 下载后,等待一小段时间确保文件写入完成 await Future.delayed(Duration(milliseconds: 500)); // 下载后,从消息对象获取新的本地路径(下载后会自动更新 body 中的路径) // 需要多次尝试获取,因为SDK可能需要一些时间来更新路径 String? downloadedPath; for (int i = 0; i < 3; i++) { if (widget.message!.body is EMVoiceMessageBody) { final updatedVoiceBody = widget.message!.body as EMVoiceMessageBody; final newPath = updatedVoiceBody.localPath; if (newPath.isNotEmpty) { downloadedPath = newPath; final downloadedFile = File(newPath); if (await downloadedFile.exists()) { final fileSize = await downloadedFile.length(); if (fileSize > 1024) { print('✅ 语音文件下载完成,新路径: $newPath, 大小: $fileSize bytes'); localPath = newPath; // 更新局部变量 fileValid = true; break; // 找到有效文件,退出循环 } else { print('⚠️ 下载的文件大小异常: $newPath, 大小: $fileSize bytes'); } } else { print('⚠️ 下载后文件不存在: $newPath'); } } } // 如果还没找到有效文件,等待一下再重试 if (!fileValid && i < 2) { await Future.delayed(Duration(milliseconds: 300)); } } // 验证下载结果 if (!fileValid) { if (downloadedPath == null || downloadedPath.isEmpty) { print('❌ 下载后本地路径为空'); SmartDialog.showToast('下载失败,无法获取文件路径'); } else { print('❌ 下载的文件无效: $downloadedPath'); SmartDialog.showToast('下载的文件无效,请重试'); } return; } // 刷新状态 setState(() {}); } catch (e) { print('❌ 下载语音文件失败: $e'); SmartDialog.showToast('下载语音文件失败: $e'); return; } } // 如果文件无效,不能播放 if (!fileValid) { print('❌ 文件验证失败,无法播放'); SmartDialog.showToast('语音文件无效,请重试'); return; } } // 对于接收到的消息,应该已经下载完成,只使用本地文件 // 对于发送的消息,可以使用本地文件或远程URL // 注意:对于接收到的消息,localPath 可能已经在下载过程中更新了 if (localPath.isNotEmpty) { print('🔍 检查本地文件: $localPath'); final localFile = File(localPath); if (await localFile.exists()) { // 检查文件大小,确保文件有效 final fileSize = await localFile.length(); print('📊 本地文件大小: $fileSize bytes'); if (fileSize > 1024) { // 至少1KB,确保文件有效 filePath = localPath; print('✅ 使用本地音频文件: $localPath, 文件大小: $fileSize bytes'); } else { print('⚠️ 本地音频文件大小异常: $localPath, 大小: $fileSize bytes'); // 对于接收到的消息,不应该回退到远程URL(应该已经下载了) if (widget.isSentByMe) { // 发送的消息,可以尝试使用远程路径 if (remotePath != null && remotePath.isNotEmpty) { if (remotePath.startsWith('http://') || remotePath.startsWith('https://')) { filePath = remotePath; print('✅ 使用远程音频文件: $remotePath'); } else { SmartDialog.showToast('音频文件无效'); return; } } else { SmartDialog.showToast('音频文件无效'); return; } } else { // 接收的消息,文件应该已经下载,如果无效说明下载失败 SmartDialog.showToast('语音文件无效,请重试'); return; } } } else { print('⚠️ 本地音频文件不存在: $localPath'); // 对于接收到的消息,文件应该已经下载,不存在说明下载失败 if (widget.isSentByMe) { // 发送的消息,可以尝试使用远程路径 if (remotePath != null && remotePath.isNotEmpty) { if (remotePath.startsWith('http://') || remotePath.startsWith('https://')) { filePath = remotePath; print('✅ 使用远程音频文件: $remotePath'); } else { SmartDialog.showToast('音频文件不存在'); return; } } else { SmartDialog.showToast('音频文件不存在'); return; } } else { // 接收的消息,文件不存在说明下载失败 SmartDialog.showToast('语音文件不存在,请重试'); return; } } } else if (widget.isSentByMe && remotePath != null && remotePath.isNotEmpty) { // 只有发送的消息才允许直接使用远程URL(接收的消息必须先下载) if (remotePath.startsWith('http://') || remotePath.startsWith('https://')) { filePath = remotePath; print('✅ 使用远程音频文件(发送的消息): $remotePath'); } else { SmartDialog.showToast('音频文件不存在'); return; } } else { SmartDialog.showToast('无法获取音频文件'); print('❌ 本地和远程路径都无效'); return; } // filePath 应该已经在上面的逻辑中设置好了 print('🎵 开始播放音频: $filePath'); try { await _playerManager.play(widget.messageId, filePath); } catch (e) { print('❌ 播放音频失败: $e'); // 对于发送的消息,如果本地文件播放失败,可以尝试使用远程URL // 对于接收的消息,不应该回退到远程URL(应该已经下载了) if (widget.isSentByMe && !filePath.startsWith('http://') && !filePath.startsWith('https://')) { final remotePath = widget.voiceBody.remotePath; if (remotePath != null && remotePath.isNotEmpty && (remotePath.startsWith('http://') || remotePath.startsWith('https://'))) { print('🔄 本地文件播放失败,尝试使用远程URL: $remotePath'); try { await _playerManager.play(widget.messageId, remotePath); SmartDialog.showToast('正在播放语音'); return; } catch (e2) { print('❌ 远程URL播放也失败: $e2'); } } } SmartDialog.showToast('播放失败,请重试'); } } catch (e) { SmartDialog.showToast('播放失败: $e'); print('❌ 播放音频失败: $e'); } } @override Widget build(BuildContext context) { // 获取语音时长(秒) final duration = widget.voiceBody.duration; final durationText = '${duration}s'; // 判断当前音频是否正在播放 final isPlaying = _playerManager.isPlaying(widget.messageId); // 检查是否有金币信息(只对接收的消息显示) final revenueInfo = _getRevenueInfo(); return Column( children: [ // 显示时间 if (widget.showTime) _buildTimeLabel(), Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), child: Row( mainAxisAlignment: widget.isSentByMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!widget.isSentByMe) _buildAvatar(), if (!widget.isSentByMe) SizedBox(width: 8.w), Column( crossAxisAlignment: widget.isSentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( margin: EdgeInsets.only(top: 10.h), padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h), decoration: BoxDecoration( color: widget.isSentByMe ? Color(0xff8E7BF6) : Colors.white, borderRadius: BorderRadius.only( topLeft: widget.isSentByMe ? Radius.circular(12.w) : Radius.circular(0), topRight: widget.isSentByMe ? Radius.circular(0) : Radius.circular(12.w), bottomLeft: Radius.circular(12.w), bottomRight: Radius.circular(12.w), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // 播放按钮 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), // 时长文本 Text( durationText, style: TextStyle( fontSize: 14.sp, color: widget.isSentByMe ? Colors.white : Colors.black, fontWeight: FontWeight.w500, ), ), SizedBox(width: 8.w), // 音频波形 _buildWaveform(), ], ), ).onTap(() { _handlePlayPause(); }), // 金币标签(只对接收的消息显示) if (!widget.isSentByMe && revenueInfo != null) Padding( padding: EdgeInsets.only(top: 10.h), child: _buildCoinLabel(revenueInfo), ), ], ), if (widget.isSentByMe) SizedBox(width: 8.w), if (widget.isSentByMe) _buildAvatar(), ], ), ), ], ); } // 构建时间标签 Widget _buildTimeLabel() { return Container( alignment: Alignment.center, padding: EdgeInsets.symmetric(horizontal: 16.w), child: Container( padding: EdgeInsets.symmetric(horizontal: 12.w), child: Text( widget.formattedTime, style: TextStyle(fontSize: 12.sp, color: Colors.grey), ), ), ); } // 构建头像 Widget _buildAvatar() { String? avatarUrl; if (widget.isSentByMe) { // 发送的消息:使用当前登录用户的头像 // 优先从消息 attributes 中获取 try { if (widget.message != null) { final attributes = widget.message!.attributes; if (attributes != null) { avatarUrl = attributes['sender_avatarUrl'] as String? ?? attributes['avatarUrl'] as String?; } } } catch (e) { // 忽略错误 } // 如果消息中没有,使用当前登录用户的头像 if (avatarUrl == null || avatarUrl.isEmpty) { avatarUrl = GlobalData().userData?.profilePhoto; } } else { // 接收的消息:使用发送者的头像 try { if (widget.message != null) { final attributes = widget.message!.attributes; if (attributes != null) { avatarUrl = attributes['sender_avatarUrl'] as String? ?? attributes['avatarUrl'] as String?; } } } catch (e) { // 忽略错误 } // 如果消息中没有,尝试从 ChatController 获取对方用户头像 if ((avatarUrl == null || avatarUrl.isEmpty)) { try { // 尝试从 Get 获取 ChatController final chatController = Get.find(); avatarUrl = chatController.userAvatarUrl; } catch (e) { // ChatController 可能不存在,忽略错误 } } } // 清理头像URL(移除反引号) if (avatarUrl != null && avatarUrl.isNotEmpty) { avatarUrl = avatarUrl.trim().replaceAll('`', ''); } return ClipOval( child: avatarUrl != null && avatarUrl.isNotEmpty ? CachedNetworkImage( imageUrl: avatarUrl, width: 40.w, height: 40.w, fit: BoxFit.cover, placeholder: (context, url) => Image.asset( Assets.imagesUserAvatar, width: 40.w, height: 40.w, fit: BoxFit.cover, ), errorWidget: (context, url, error) => Image.asset( Assets.imagesUserAvatar, width: 40.w, height: 40.w, fit: BoxFit.cover, ), ) : Image.asset( Assets.imagesUserAvatar, width: 40.w, height: 40.w, fit: BoxFit.cover, ), ); } // 获取金币信息(从 revenueInfo 或 coin_value 中获取) String? _getRevenueInfo() { try { if (widget.message != null) { final attributes = widget.message!.attributes; if (attributes != null) { // 优先从 revenueInfo 获取 final revenueInfo = attributes['revenueInfo'] as String?; if (revenueInfo != null && revenueInfo.isNotEmpty) { return revenueInfo; } // 如果没有 revenueInfo,从 coin_value 获取(可能是之前存储的 revenueInfo 值) final coinValueStr = attributes['coin_value'] as String?; if (coinValueStr != null && coinValueStr.isNotEmpty) { return coinValueStr; } } } } catch (e) { // 忽略错误 } return null; } // 构建金币标签 Widget _buildCoinLabel(String revenueInfo) { return Container( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h), decoration: BoxDecoration( color: Color.fromRGBO(0, 0, 0, 0.05), borderRadius: BorderRadius.circular(12.w), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Image.asset( Assets.imagesImCoinIcon, width: 10.w, height: 10.w, ), SizedBox(width: 2.w), Text( revenueInfo, style: TextStyle( fontSize: 9.sp, color: Color.fromRGBO(255, 132, 0, 1), ), ), ], ), ); } // 构建音频波形 Widget _buildWaveform() { // 根据时长生成波形条数量(最多20个) final barCount = (widget.voiceBody.duration / 2).ceil().clamp(5, 20); final isPlaying = _playerManager.isPlaying(widget.messageId); return SizedBox( height: 16.h, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: List.generate(barCount, (index) { double height; Color color; if (isPlaying) { // 播放时:波浪形动画效果 // 使用正弦波函数创建波浪效果,从左到右传播 // wavePhase: 时间因子(_animationFrame)让波浪移动,空间因子(index)让每个条的位置不同 // 移除模运算,让波浪连续传播 final wavePhase = _animationFrame * 0.15 + index * 0.6; // 使用正弦波计算高度,范围在 4-16 之间 final sinValue = math.sin(wavePhase); final normalizedValue = (sinValue + 1) / 2; // 归一化到 0-1 final baseHeight = 4 + normalizedValue * 12; height = (baseHeight.clamp(4, 16)).h; // 根据高度设置颜色透明度,创造渐变效果 final opacity = 0.5 + normalizedValue * 0.5; color = widget.isSentByMe ? Colors.white.withOpacity(opacity.clamp(0.5, 1.0)) : Colors.grey.withOpacity(opacity.clamp(0.5, 0.9)); } else { // 未播放时:静态波形 final random = (index * 7) % 5; final baseHeight = 6 + random * 2; height = (baseHeight.clamp(4, 16)).h; color = widget.isSentByMe ? Colors.white.withOpacity(0.8) : Colors.grey.withOpacity(0.6); } return AnimatedContainer( duration: const Duration(milliseconds: 100), curve: Curves.easeOut, width: 2.w, height: height, margin: EdgeInsets.symmetric(horizontal: 1.w), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(1.w), ), ); }), ), ); } }