Browse Source

feat(录音): 增加录音保存和结束的错误处理及超时机制

添加录音保存和结束操作时的错误处理和超时机制,包括:
1. 增加超时和失败时的错误提示
2. 添加保存状态标志防止重复操作
3. 失败时恢复录音界面状态
4. 添加相关翻译文本
weicu
lxm 9 months ago
parent
commit
377dc95962
  1. 5
      lib/core/translations/language/en_us.dart
  2. 3
      lib/core/translations/language/zh_cn.dart
  3. 214
      lib/modules/meeting/controllers/meeting_record_controller.dart
  4. 30
      lib/modules/meeting/views/meeting_record_view.dart

5
lib/core/translations/language/en_us.dart

@ -443,6 +443,11 @@ const Map<String, String> enUS = {
'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存
'meetingRecordContinueInBackground':
'Continue recording in background', // 后台继续录音
'meetingRecordStopTimeout':
'Stopping the recording timed out. Please try again.', // 结束录音超时,请重试
'meetingRecordStopFailed':
'Failed to stop the recording. Please try again.', // 结束录音失败,请重试
'meetingRecordSaveFailed': 'Failed to save the recording. Please try again.', // 保存录音失败,请重试
'meetingRecordRecordingNotificationTitle': 'Recording in progress', // 录音进行中
'meetingRecordRecordingNotificationBody':
'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用

3
lib/core/translations/language/zh_cn.dart

@ -419,6 +419,9 @@ const Map<String, String> zhCN = {
'meetingRecordEndAndSave': '结束录音并保存', // 结束录音并保存
'meetingRecordEndWithoutSave': '结束录音不保存', // 结束录音不保存
'meetingRecordContinueInBackground': '后台继续录音', // 后台继续录音
'meetingRecordStopTimeout': '结束录音超时,请重试', // 结束录音超时,请重试
'meetingRecordStopFailed': '结束录音失败,请重试', // 结束录音失败,请重试
'meetingRecordSaveFailed': '保存录音失败,请重试', // 保存录音失败,请重试
'meetingRecordRecordingNotificationTitle': '录音进行中', // 录音进行中
'meetingRecordRecordingNotificationBody': '正在后台录音,点击返回应用', // 正在后台录音,点击返回应用
'overlayPermissionTitle': '悬浮窗权限', // 悬浮窗权限

214
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -31,6 +31,8 @@ enum TimerStatus {
class MeetingRecordController extends GetxController
with GetTickerProviderStateMixin, WidgetsBindingObserver {
static const int _backgroundRecordingNotificationId = 91001;
static const Duration _stopRecordTimeout = Duration(seconds: 12);
static const Duration _fileOpTimeout = Duration(seconds: 8);
static FlutterLocalNotificationsPlugin? _localNotificationsPlugin;
final NativePlugin _nativePlugin = NativePlugin();
@ -160,11 +162,18 @@ class MeetingRecordController extends GetxController
final typeText = _getRecordingTypeText();
final durationText = _formatTimeForFloatingBar(milliseconds.value);
await _nativePlugin.showRecordingFloatingBar(
duration: durationText,
recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
);
try {
await _nativePlugin
.showRecordingFloatingBar(
duration: durationText,
recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
)
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('显示录音悬浮条失败: ${e.toString()}');
return;
}
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer =
@ -185,7 +194,13 @@ class MeetingRecordController extends GetxController
if (!Platform.isAndroid) return;
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer = null;
await _nativePlugin.hideRecordingFloatingBar();
try {
await _nativePlugin
.hideRecordingFloatingBar()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('隐藏录音悬浮条失败: ${e.toString()}');
}
}
Future<void> togglePauseResumeFromFloatingBar() async {
@ -246,6 +261,7 @@ class MeetingRecordController extends GetxController
final RxBool isMarked = false.obs;
final RxBool showTextContent = false.obs;
final RxBool isRecording = false.obs;
final RxBool isSaving = false.obs;
//final RxBool isVoiceRecognitionActive = false.obs;
final RxDouble rms = 0.0.obs;
@ -607,62 +623,152 @@ class MeetingRecordController extends GetxController
}
/// 保存录音并清理相关资源
Future<void> saveRecording() async {
resetTimer();
Future<bool> saveRecording() async {
if (isSaving.value) return false;
if (!isRecording.value) return true;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
if (!isRecording.value) return;
await _asrService.stopRecord(true);
await clearBackgroundRecordingIndicator();
// 如果标题被修改则重命名文件
if (fileName.value != newName.value) {
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
if (isMarked.value) {
final newDir = Directory("${appDir.path}/DestAudio/");
if (!await newDir.exists()) await newDir.create(recursive: true);
await _asrService.moveFile(
"${dir.path}${fileName.value}.wav",
"${newDir.path}${fileName.value}.wav",
);
} else {
await _asrService.renameFile(
"${dir.path}${fileName.value}.wav",
newName.value,
);
try {
final bool stopped = await _asrService
.stopRecord(true)
.timeout(_stopRecordTimeout, onTimeout: () => false);
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
}
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
try {
await clearBackgroundRecordingIndicator()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('清理录音指示器失败: ${e.toString()}');
}
try {
await _applyTitleChangeAfterSave();
} catch (e) {
Logger.error('保存录音后的文件处理失败: ${e.toString()}');
}
resetTimer();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
return true;
} catch (e) {
Logger.error('保存录音失败: ${e.toString()}');
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordSaveFailed'.tr);
return false;
} finally {
isSaving.value = false;
}
}
Future<void> discardRecording() async {
resetTimer();
Future<bool> discardRecording() async {
if (!isRecording.value) return true;
if (isSaving.value) return false;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec();
try {
final bool stopped = await _asrService
.stopRecord(false)
.timeout(_stopRecordTimeout, onTimeout: () => false);
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
if (audioType.value != 0) {
_bleManager.closeCodec();
}
try {
await clearBackgroundRecordingIndicator()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('清理录音指示器失败: ${e.toString()}');
}
resetTimer();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
return true;
} catch (e) {
Logger.error('结束录音不保存失败: ${e.toString()}');
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopFailed'.tr);
return false;
} finally {
isSaving.value = false;
}
if (isRecording.value) {
await _asrService.stopRecord(false);
}
void _restoreRecordingUiAfterFailedStop(
TimerStatus prevStatus,
int prevElapsedMs,
) {
_elapsedMilliseconds = prevElapsedMs;
seconds.value = (prevElapsedMs / 1000).round();
milliseconds.value = prevElapsedMs;
if (prevStatus == TimerStatus.running) {
timerStatus.value = TimerStatus.stopped;
startTimer();
} else {
timerStatus.value = prevStatus;
}
await clearBackgroundRecordingIndicator();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
_startWaveformTimer();
}
Future<void> _applyTitleChangeAfterSave() async {
if (fileName.value == newName.value) return;
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
if (isMarked.value) {
final newDir = Directory("${appDir.path}/DestAudio/");
if (!await newDir.exists()) await newDir.create(recursive: true);
await _asrService
.moveFile(
"${dir.path}${fileName.value}.wav",
"${newDir.path}${fileName.value}.wav",
)
.timeout(_fileOpTimeout);
return;
}
await _asrService
.renameFile(
"${dir.path}${fileName.value}.wav",
newName.value,
)
.timeout(_fileOpTimeout);
}
/// 将控制器重置到初始状态

30
lib/modules/meeting/views/meeting_record_view.dart

@ -214,14 +214,18 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
switch (action) {
case _MeetingRecordExitAction.saveAndEnd:
await controller.saveTitleChange();
await controller.saveRecording();
Get.back(result: 'meeting_record_saved');
_deleteControllerAfterPop();
final saved = await controller.saveRecording();
if (saved) {
Get.back(result: 'meeting_record_saved');
_deleteControllerAfterPop();
}
return;
case _MeetingRecordExitAction.endWithoutSave:
await controller.discardRecording();
Get.back(result: 'meeting_record_discarded');
_deleteControllerAfterPop();
final discarded = await controller.discardRecording();
if (discarded) {
Get.back(result: 'meeting_record_discarded');
_deleteControllerAfterPop();
}
return;
case _MeetingRecordExitAction.continueInBackground:
await controller.saveTitleChange();
@ -695,8 +699,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
textInputAction: TextInputAction.done,
onSubmitted: (_) async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back();
final saved = await controller.saveRecording();
if (saved) {
Get.back();
}
},
),
),
@ -728,9 +734,11 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
child: ElevatedButton(
onPressed: () async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面
final saved = await controller.saveRecording();
if (saved) {
Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面
}
},
style: ElevatedButton.styleFrom(
backgroundColor:

Loading…
Cancel
Save