From 35a2af642d1fdd24f1521c0f82b7d56430f66f0f Mon Sep 17 00:00:00 2001 From: Jolie <> Date: Tue, 16 Dec 2025 18:12:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=AD=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../message/voice_player_manager.dart | 88 ++++++-- lib/widget/message/voice_input_view.dart | 43 +++- lib/widget/message/voice_item.dart | 193 +++++++++++++----- 3 files changed, 249 insertions(+), 75 deletions(-) diff --git a/lib/controller/message/voice_player_manager.dart b/lib/controller/message/voice_player_manager.dart index af94c32..ebf6102 100644 --- a/lib/controller/message/voice_player_manager.dart +++ b/lib/controller/message/voice_player_manager.dart @@ -1,5 +1,4 @@ import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; /// 语音播放管理器,单例模式,统一管理语音播放 @@ -32,43 +31,98 @@ class VoicePlayerManager extends GetxController { /// [filePath] 音频文件路径 Future play(String audioId, String filePath) async { try { + print('🎵 [VoicePlayerManager] 准备播放音频: audioId=$audioId, filePath=$filePath'); + + // 如果是同一个音频,则暂停/恢复 + if (_currentPlayingId == audioId) { + print('⏯️ [VoicePlayerManager] 切换播放/暂停状态'); + try { + if (playerState.value == PlayerState.playing) { + await _audioPlayer.pause(); + } else { + await _audioPlayer.resume(); + } + } catch (e) { + print('⚠️ [VoicePlayerManager] 暂停/恢复失败,尝试重新播放: $e'); + // 如果暂停/恢复失败,继续执行播放新音频的逻辑 + } + // 如果成功暂停/恢复,直接返回 + if (_currentPlayingId == audioId && + (playerState.value == PlayerState.playing || + playerState.value == PlayerState.paused)) { + return; + } + } + // 如果正在播放其他音频,先停止 if (_currentPlayingId != null && _currentPlayingId != audioId) { - await stop(); + print('🛑 [VoicePlayerManager] 停止当前播放的音频: $_currentPlayingId'); + try { + // 只在播放器状态不是 stopped 时才调用 stop + if (playerState.value != PlayerState.stopped) { + await _audioPlayer.stop(); + } + } catch (e) { + print('⚠️ [VoicePlayerManager] 停止播放器时出错(可忽略): $e'); + } + // 重置当前播放ID + _currentPlayingId = null; + currentPlayingId.value = null; + // 等待一小段时间确保停止完成 + await Future.delayed(Duration(milliseconds: 200)); } - - // 如果是同一个音频,则暂停/恢复 - if (_currentPlayingId == audioId) { - if (playerState.value == PlayerState.playing) { - await _audioPlayer.pause(); - } else { - await _audioPlayer.resume(); + + // 播放新音频前,如果播放器正在播放或暂停,先停止 + print('🔄 [VoicePlayerManager] 准备播放新音频,当前状态: ${playerState.value}'); + if (playerState.value == PlayerState.playing || playerState.value == PlayerState.paused) { + try { + await _audioPlayer.stop(); + await Future.delayed(Duration(milliseconds: 150)); + } catch (e) { + // 如果 stop 失败,可能是播放器还没有初始化,继续尝试播放 + print('⚠️ [VoicePlayerManager] 停止播放器时出错(可忽略): $e'); } - return; } - // 播放新音频 + + // 设置当前播放的音频ID _currentPlayingId = audioId; currentPlayingId.value = audioId; - if(filePath.startsWith('https://')){ + + // 根据路径类型选择不同的播放源 + if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + print('🌐 [VoicePlayerManager] 使用网络URL播放: $filePath'); await _audioPlayer.play(UrlSource(filePath)); - }else{ + } else { + print('📁 [VoicePlayerManager] 使用本地文件播放: $filePath'); await _audioPlayer.play(DeviceFileSource(filePath)); } - } catch (e) { - print('播放音频失败: $e'); + + print('✅ [VoicePlayerManager] 音频播放请求已发送'); + } catch (e, stackTrace) { + print('❌ [VoicePlayerManager] 播放音频失败: $e'); + print('📚 [VoicePlayerManager] 堆栈跟踪: $stackTrace'); + // 重置状态 _currentPlayingId = null; currentPlayingId.value = null; + // 重新抛出异常,让调用者知道播放失败 + rethrow; } } /// 停止播放 Future stop() async { try { - await _audioPlayer.stop(); + // 只在播放器状态不是 stopped 时才调用 stop + if (playerState.value != PlayerState.stopped) { + await _audioPlayer.stop(); + } _currentPlayingId = null; currentPlayingId.value = null; } catch (e) { - print('停止播放失败: $e'); + print('⚠️ [VoicePlayerManager] 停止播放失败(可忽略): $e'); + // 即使停止失败,也重置状态 + _currentPlayingId = null; + currentPlayingId.value = null; } } diff --git a/lib/widget/message/voice_input_view.dart b/lib/widget/message/voice_input_view.dart index c3d4ee1..f7931a6 100644 --- a/lib/widget/message/voice_input_view.dart +++ b/lib/widget/message/voice_input_view.dart @@ -30,7 +30,18 @@ class _VoiceInputViewState extends State { @override void dispose() { + // 取消计时器 _timer?.cancel(); + _timer = null; + // 如果正在录音,停止录音 + if (_isRecording) { + _audioRecorder.stop().catchError((e) { + // 忽略停止录音时的错误 + print('停止录音时出错(可忽略): $e'); + return null; // 返回 null 以符合 catchError 的返回类型要求 + }); + } + // 释放录音器 _audioRecorder.dispose(); super.dispose(); } @@ -59,6 +70,8 @@ class _VoiceInputViewState extends State { await _audioRecorder.start(const RecordConfig(), path: path); + // 检查 mounted 状态再更新 UI + if (!mounted) return; setState(() { _isRecording = true; _seconds = 0; @@ -69,6 +82,11 @@ class _VoiceInputViewState extends State { // 启动计时器 _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + // 检查 mounted 状态,避免在 dispose 后调用 setState + if (!mounted) { + timer.cancel(); + return; + } setState(() { _seconds++; }); @@ -91,19 +109,26 @@ class _VoiceInputViewState extends State { final finalPath = _recordingPath; await _audioRecorder.stop(); - setState(() { - _isRecording = false; - _seconds = 0; - _isCanceling = false; - _panStartPosition = Offset.zero; - _recordingPath = null; - }); + + // 检查 mounted 状态再更新 UI + if (mounted) { + setState(() { + _isRecording = false; + _seconds = 0; + _isCanceling = false; + _panStartPosition = Offset.zero; + _recordingPath = null; + }); + } // 如果不是取消,回传音频文件地址和秒数 if (!cancel && finalPath != null && finalSeconds > 0) { // 延迟一下,确保状态更新完成后再回调 Future.microtask(() { - widget.onVoiceRecorded?.call(finalPath, finalSeconds); + // 检查 mounted 状态,避免在 dispose 后回调 + if (mounted) { + widget.onVoiceRecorded?.call(finalPath, finalSeconds); + } }); } } catch (e) { @@ -326,7 +351,7 @@ class _VoiceInputViewState extends State { // 如果向上滑动超过一定距离(60像素),标记为取消 final shouldCancel = deltaY > 60; - if (_isCanceling != shouldCancel) { + if (_isCanceling != shouldCancel && mounted) { setState(() { _isCanceling = shouldCancel; }); diff --git a/lib/widget/message/voice_item.dart b/lib/widget/message/voice_item.dart index 20530c1..09a1607 100644 --- a/lib/widget/message/voice_item.dart +++ b/lib/widget/message/voice_item.dart @@ -108,40 +108,95 @@ class _VoiceItemState extends State with TickerProviderStateMixin { print('🔍 语音消息路径检查: localPath=$localPath, remotePath=$remotePath'); - // 如果是接收到的消息且本地文件不存在或大小为0,先下载 + // 对于接收到的消息,必须先确保文件已下载 if (!widget.isSentByMe && widget.message != null) { - final localFile = localPath.isNotEmpty ? File(localPath) : null; bool needDownload = false; + bool fileValid = false; - if (localPath.isEmpty || localFile == null || !await localFile.exists()) { - needDownload = true; - print('📥 本地文件不存在,需要下载'); - } else { - final fileSize = await localFile.length(); - if (fileSize == 0) { + // 检查本地文件是否存在且有效 + 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; - print('📥 本地文件大小为0,需要下载'); } + } else { + print('📥 本地路径为空,需要下载'); + needDownload = true; } // 如果需要下载,先下载附件 if (needDownload) { + if (remotePath == null || remotePath.isEmpty) { + SmartDialog.showToast('无法获取语音文件'); + print('❌ 远程路径为空,无法下载'); + return; + } + try { - print('📥 开始下载语音文件...'); + print('📥 开始下载语音文件,远程路径: $remotePath'); + SmartDialog.showToast('正在下载语音...'); + + // 下载附件 await EMClient.getInstance.chatManager .downloadAttachment(widget.message!); // 下载后,等待一小段时间确保文件写入完成 - await Future.delayed(Duration(milliseconds: 300)); + await Future.delayed(Duration(milliseconds: 500)); // 下载后,从消息对象获取新的本地路径(下载后会自动更新 body 中的路径) - // 重新从消息对象获取 voiceBody,因为下载后路径会更新 - if (widget.message!.body is EMVoiceMessageBody) { - final updatedVoiceBody = widget.message!.body as EMVoiceMessageBody; - localPath = updatedVoiceBody.localPath; - print('✅ 语音文件下载完成,新路径: $localPath'); - } else { - print('⚠️ 消息 body 类型不是 EMVoiceMessageBody'); + // 需要多次尝试获取,因为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; } // 刷新状态 @@ -152,75 +207,115 @@ class _VoiceItemState extends State with TickerProviderStateMixin { 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(); - if (fileSize > 0) { + print('📊 本地文件大小: $fileSize bytes'); + if (fileSize > 1024) { // 至少1KB,确保文件有效 filePath = localPath; print('✅ 使用本地音频文件: $localPath, 文件大小: $fileSize bytes'); } else { - print('⚠️ 本地音频文件大小为0: $localPath'); - // 文件大小为0,尝试使用远程路径 - if (remotePath != null && remotePath.isNotEmpty) { - if (remotePath.startsWith('http://') || - remotePath.startsWith('https://')) { - filePath = remotePath; - print('✅ 使用远程音频文件: $remotePath'); + 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('音频文件无效'); - print('⚠️ 本地文件大小为0,远程路径不是URL: $remotePath'); return; } } else { - SmartDialog.showToast('音频文件无效'); - print('⚠️ 本地文件大小为0,且没有远程路径'); + // 接收的消息,文件应该已经下载,如果无效说明下载失败 + SmartDialog.showToast('语音文件无效,请重试'); return; } } } else { print('⚠️ 本地音频文件不存在: $localPath'); - // 本地文件不存在,尝试使用远程路径 - if (remotePath != null && remotePath.isNotEmpty) { - if (remotePath.startsWith('http://') || - remotePath.startsWith('https://')) { - filePath = remotePath; - print('✅ 使用远程音频文件: $remotePath'); + // 对于接收到的消息,文件应该已经下载,不存在说明下载失败 + 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('音频文件不存在'); - print('⚠️ 本地文件不存在,远程路径不是URL: $remotePath'); return; } } else { - SmartDialog.showToast('音频文件不存在'); - print('⚠️ 本地和远程路径都无效'); + // 接收的消息,文件不存在说明下载失败 + SmartDialog.showToast('语音文件不存在,请重试'); return; } } - } else if (remotePath != null && remotePath.isNotEmpty) { - // 没有本地路径,直接使用远程路径 + } else if (widget.isSentByMe && remotePath != null && remotePath.isNotEmpty) { + // 只有发送的消息才允许直接使用远程URL(接收的消息必须先下载) if (remotePath.startsWith('http://') || remotePath.startsWith('https://')) { - // 如果是HTTP URL,可以直接播放 filePath = remotePath; - print('✅ 使用远程音频文件(无本地路径): $remotePath'); + print('✅ 使用远程音频文件(发送的消息): $remotePath'); } else { SmartDialog.showToast('音频文件不存在'); - print('⚠️ 远程路径不是URL: $remotePath'); return; } + } else { + SmartDialog.showToast('无法获取音频文件'); + print('❌ 本地和远程路径都无效'); + return; } - if (filePath != null && filePath.isNotEmpty) { - print('🎵 开始播放音频: $filePath'); + // filePath 应该已经在上面的逻辑中设置好了 + print('🎵 开始播放音频: $filePath'); + try { await _playerManager.play(widget.messageId, filePath); - } else { - SmartDialog.showToast('无法获取音频文件'); - print('❌ 音频文件路径为空'); + } 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');