import 'dart:io'; import 'dart:typed_data'; import 'package:electronic_assistant/base/base_controller.dart'; import 'package:electronic_assistant/data/consts/error_code.dart'; import 'package:electronic_assistant/data/repositories/talk_repository.dart'; import 'package:electronic_assistant/dialog/alert_dialog.dart'; import 'package:electronic_assistant/module/record/constants.dart'; import 'package:electronic_assistant/module/record/record_task.dart'; import 'package:electronic_assistant/module/talk/view.dart'; import 'package:electronic_assistant/resource/string.gen.dart'; import 'package:electronic_assistant/router/app_pages.dart'; import 'package:electronic_assistant/utils/http_handler.dart'; import 'package:electronic_assistant/utils/mmkv_util.dart'; import 'package:electronic_assistant/utils/toast_util.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import 'package:uuid/uuid.dart'; import '../../utils/pcm_wav_converter.dart'; import '../../widget/frame_animation_view.dart'; class RecordController extends BaseController { static const String keyLastRecordId = "last_record_id"; static const int minRecordDuration = 3; final FrameAnimationController frameAnimationController = FrameAnimationController(autoPlay: false); final Rx currentStatus = RecordStatus.pending.obs; final RxDouble currentDuration = 0.0.obs; final AudioRecorder _record = AudioRecorder(); final RecordConfig _recordConfig = RecordConfig( encoder: AudioEncoder.pcm16bits, bitRate: 16000, sampleRate: SampleRate.rate32k.value, numChannels: Channel.mono.value, ); late final String _lastRecordId; @override void onInit() { super.onInit(); _initLastRecordId(); _initLastRecordStatus(); _initForegroundService(); } @override void onClose() { super.onClose(); _record.dispose(); } void _initLastRecordId() { String? lastRecordId = KVUtil.getString(keyLastRecordId, null); if (lastRecordId == null || lastRecordId.isEmpty) { _lastRecordId = const Uuid().v4(); KVUtil.putString(keyLastRecordId, _lastRecordId); } else { _lastRecordId = lastRecordId; } } Future _initLastRecordStatus() async { var currentRecordFile = await _getCurrentRecordFile(); var fileLength = currentRecordFile.lengthSync(); if (currentRecordFile.existsSync() && fileLength > 0) { _changeRecordStatus(RecordStatus.paused); currentDuration.value = _getPcmDuration( fileLength, _recordConfig.sampleRate, 16, _recordConfig.numChannels); } } _initForegroundService() { WidgetsBinding.instance .addPostFrameCallback((_) => FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( channelId: StringName.recordNotificationChannelId, channelName: StringName.recordNotificationChannelName, channelDescription: StringName.recordNotificationChannelDescription, channelImportance: NotificationChannelImportance.LOW, priority: NotificationPriority.LOW, ), iosNotificationOptions: const IOSNotificationOptions( showNotification: false, playSound: false, ), foregroundTaskOptions: ForegroundTaskOptions( eventAction: ForegroundTaskEventAction.once(), autoRunOnBoot: false, autoRunOnMyPackageReplaced: true, allowWakeLock: true, allowWifiLock: false, ), )); } void addShortcut() {} void onBackClick() { if (currentStatus.value == RecordStatus.pending || currentStatus.value == RecordStatus.paused) { Get.back(); } else { EAAlertDialog.show( title: "是否保存当前录音?", confirmText: "确定", cancelText: "取消", confirmOnTap: () { _saveCurrentRecord(); EAAlertDialog.dismiss(); }, cancelOnTap: () { EAAlertDialog.dismiss(); _stopRecord().then((_) => Get.back()); }, ); } } void onActionClick() { RecordStatus nextStatus = currentStatus.value.nextStatus; if (nextStatus == RecordStatus.recording) { _startOrContinueRecord(); } else { _stopRecord(); } } void onCancelClick() { if (currentStatus.value == RecordStatus.pending) { return; } EAAlertDialog.show( title: "是否删除当前录音?", confirmText: "删除", cancelText: "取消", confirmOnTap: () { _deleteCurrentRecord(); EAAlertDialog.dismiss(); }, cancelOnTap: () { EAAlertDialog.dismiss(); }, ); } void onSaveClick() { if (currentStatus.value == RecordStatus.pending) { return; } _saveCurrentRecord(); } Future _startOrContinueRecord() async { bool hasPermission = await _record.hasPermission(); if (!hasPermission) { _onRecordPermissionDenied(); return; } await _requestForegroundTaskPermission().catchError((error) { debugPrint("requestForegroundTaskPermission error: $error"); }); File targetFile = await _getCurrentRecordFile(); Stream recordStream = await _record.startStream(_recordConfig); _startForegroundService(); recordStream.listen((data) async { if (data.isEmpty) { return; } if (currentStatus.value != RecordStatus.recording) { _changeRecordStatus(RecordStatus.recording); } targetFile.writeAsBytesSync(data, mode: FileMode.append); currentDuration.value = currentDuration.value + _getPcmDuration(data.length, _recordConfig.sampleRate, 16, _recordConfig.numChannels); }, onDone: () { _changeRecordStatus(RecordStatus.paused); }, onError: (error) { _changeRecordStatus(RecordStatus.paused); }); } _onRecordPermissionDenied() {} Future _stopRecord() { return _record .pause() .then((_) => _changeRecordStatus(RecordStatus.paused)) .then((_) => FlutterForegroundTask.stopService()); } Future _getCurrentRecordFile() async { Directory documentDir = await getApplicationDocumentsDirectory(); File file = File("${documentDir.path}/.atmob/record/$_lastRecordId"); if (!file.existsSync()) { file.createSync(recursive: true); } return file; } double _getPcmDuration( int fileSize, int sampleRate, int bitDepth, int channels) { final bytesPerSecond = sampleRate * (bitDepth / 8) * channels; final durationInSeconds = fileSize / bytesPerSecond; return durationInSeconds; } Future _deleteCurrentRecord() async { await _stopRecord(); _getCurrentRecordFile().then((file) { if (file.existsSync()) { file.deleteSync(); } }).then((_) { currentDuration.value = 0; _changeRecordStatus(RecordStatus.pending); }); } Future _saveCurrentRecord() async { final currentDurationValue = currentDuration.value; if (currentDurationValue < minRecordDuration) { ToastUtil.showToast("录音时长不足$minRecordDuration秒"); return; } await _stopRecord(); talkRepository .talkCreate(_lastRecordId, currentDuration.value.toInt()) .then((talkInfo) async { File pcmFile = await _getCurrentRecordFile(); if (pcmFile.existsSync()) { File wavFile = await getRecordFile(talkInfo.id); PcmWavConverter.convert(pcmFile, wavFile, _recordConfig.sampleRate, _recordConfig.numChannels, 16); pcmFile.delete(); KVUtil.putString(keyLastRecordId, ""); Get.back(); TalkPage.start(talkInfo); } else { throw Exception("pcm file not found"); } }).catchError((error) { if (error is ServerErrorException) { ToastUtil.showToast("${error.message}"); if (error.code == ErrorCode.errorCodeNoLogin) { Get.toNamed(RoutePath.login)?.then((loginSuccess) { loginSuccess != null && loginSuccess ? _saveCurrentRecord() : null; }); } } else { ToastUtil.showToast("保存失败, 请重试"); } }); } void _changeRecordStatus(RecordStatus status) { currentStatus.value = status; status == RecordStatus.recording ? frameAnimationController.play() : null; } Future _requestForegroundTaskPermission() async { final NotificationPermission notificationPermission = await FlutterForegroundTask.checkNotificationPermission(); if (notificationPermission != NotificationPermission.granted) { await FlutterForegroundTask.requestNotificationPermission(); } if (Platform.isAndroid) { if (!await FlutterForegroundTask.isIgnoringBatteryOptimizations) { // This function requires `android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission. await FlutterForegroundTask.requestIgnoreBatteryOptimization(); } } } Future _startForegroundService() async { if (await FlutterForegroundTask.isRunningService) { return FlutterForegroundTask.restartService(); } else { return FlutterForegroundTask.startService( serviceId: 256, notificationTitle: StringName.appName, notificationText: StringName.recordStatusRecording, notificationIcon: null, notificationButtons: [], callback: setRecordCallback, ); } } /// 获取录音文件地址 static Future getRecordFile(String talkId) async { Directory documentDir = await getApplicationDocumentsDirectory(); return File("${documentDir.path}/.atmob/record/$talkId.wav"); } /// 判断是否有未上传的录音 static Future hasUnUploadRecord() async { String? lastRecordId = KVUtil.getString(keyLastRecordId, null); if (lastRecordId == null || lastRecordId.isEmpty) { return false; } Directory documentDir = await getApplicationDocumentsDirectory(); File file = File("${documentDir.path}/.atmob/record/$lastRecordId"); return await file.exists() && await file.length() > 0; } }