import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:custom_notification/custom_notification.dart'; import 'package:electronic_assistant/module/record/record_task.dart'; import 'package:electronic_assistant/resource/colors.gen.dart'; import 'package:electronic_assistant/router/app_pages.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_sound/flutter_sound.dart'; import 'package:flutter_sound/public/flutter_sound_recorder.dart'; import 'package:get/get.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get_rx/src/rx_types/rx_types.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:uuid/uuid.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../data/bean/talks.dart'; import '../../data/consts/error_code.dart'; import '../../data/consts/event_report_id.dart'; import '../../data/repositories/talk_repository.dart'; import '../../dialog/alert_dialog.dart'; import '../../resource/string.gen.dart'; import '../../utils/http_handler.dart'; import '../../utils/mmkv_util.dart'; import '../../utils/notification_util.dart'; import '../../utils/pcm_wav_converter.dart'; import '../../utils/toast_util.dart'; import '../talk/view.dart'; import 'constants.dart'; class RecordHandler { RecordHandler._(); static const int minRecordDuration = 3; static const String recordDone = 'done'; static const String recordPause = 'pause'; static const String recordRecording = 'recording'; static const String keyLastRecordId = "last_record_id"; final Rx currentStatus = RecordStatus.pending.obs; final RxDouble currentDuration = 0.0.obs; final FlutterSoundRecorder _soundPlayer = FlutterSoundRecorder(); StreamController? recordingDataController; final RecordConfig _recordConfig = RecordConfig( codec: Codec.pcm16, sampleRate: SampleRate.rate44_1k.value, numChannels: Channel.mono.value, ); StreamSubscription? _recorderSubscription; bool? _isSoundInited; String? _lastRecordId; final int _serviceId = 256; final String _channelId = StringName.recordNotificationChannelId.tr; final String _channelName = StringName.recordNotificationChannelName.tr; String get lastRecordId => _lastRecordId ?? ''; StreamSubscription? _currentDurationListener; StreamSubscription? _recordActionListener; void init() { if (currentStatus.value != RecordStatus.recording) { _initLastRecordId(); _initLastRecordStatus(); _initForegroundService(); _initRecordDurationStream(); } } void _initRecordDurationStream() { _currentDurationListener?.cancel(); _currentDurationListener = currentDuration.listen((event) async { if (currentStatus.value == RecordStatus.pending || !await FlutterForegroundTask.isRunningService) { return; } NotificationUtil.showRecordNotification( _serviceId, currentStatus.value == RecordStatus.recording, event, channelId: _channelId, channelName: _channelName); }); _recordActionListener?.cancel(); _recordActionListener = CustomNotification.recordActionStream().listen((action) { if (action == recordDone) { _recordNotificationDone(); } else if (action == recordPause) { stopRecord(); } else if (action == recordRecording) { startOrContinueRecord(); } }); } void _recordNotificationDone() { saveCurrentRecord().then((talkInfo) { if (Get.currentRoute == RoutePath.record) { Get.back(); } TalkPage.start(talkInfo, eventTag: EventId.id_001); }).catchError((error) { if (error is ServerErrorException) { if (error.code == ErrorCode.errorCodeNoLogin) { ToastUtil.showToast("录音已保存,请登录"); } else { ToastUtil.showToast("${error.message}"); } } else { ToastUtil.showToast("录音已保存,请检查网络并重试"); } }); } 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); double time = _getPcmDuration( fileLength, _recordConfig.sampleRate, 16, _recordConfig.numChannels); currentDuration.value = time; } else { currentDuration.value = 0; } } double _getPcmDuration( int fileSize, int sampleRate, int bitDepth, int channels) { final bytesPerSecond = sampleRate * (bitDepth / 8) * channels; final durationInSeconds = fileSize / bytesPerSecond; return durationInSeconds; } Future _getCurrentRecordFile() async { Directory documentDir = await getApplicationDocumentsDirectory(); File file = File("${documentDir.path}/.atmob/record/$_lastRecordId"); if (!file.existsSync()) { file.createSync(recursive: true); } return file; } Future stopRecord({bool? isStopService}) async { _releaseWakeLock(); if (_soundPlayer.isRecording) { await _soundPlayer.pauseRecorder(); } _changeRecordStatus(RecordStatus.paused); if (isStopService == true) { await FlutterForegroundTask.stopService(); } } void _changeRecordStatus(RecordStatus status) async { currentStatus.value = status; if (status == RecordStatus.pending || !await FlutterForegroundTask.isRunningService) { return; } NotificationUtil.showRecordNotification( _serviceId, status == RecordStatus.recording, currentDuration.value, channelId: _channelId, channelName: _channelName); } void _setWakeLock() { WakelockPlus.enable(); } void _releaseWakeLock() { WakelockPlus.disable(); } _initForegroundService() { WidgetsBinding.instance .addPostFrameCallback((_) => FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( channelId: _channelId, channelName: _channelName, channelDescription: StringName.recordNotificationChannelDescription.tr, 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, ), )); } _onRecordPermissionDenied() { ToastUtil.showToast("需要授予录音权限才能使用录音功能"); } Future _showRequestPermissionDialog() async { bool? isAllow = await EAAlertDialog.show( contentWidget: Container( margin: EdgeInsets.only(top: 16.h), child: Text( textAlign: TextAlign.center, '是否允许小听获取此设备的麦克风权限,为您提供转文字、智能总结服务?', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 15.sp, color: ColorName.primaryTextColor), ), ), cancelText: '禁止', confirmText: '允许', cancelOnTap: () { EAAlertDialog.dismiss(result: false); }, confirmOnTap: () { EAAlertDialog.dismiss(result: true); }); return isAllow ?? false; } Future startOrContinueRecord() async { var isGranted = await Permission.microphone.status; if (isGranted == PermissionStatus.granted) { await Permission.microphone.request(); } else { bool isAllow = await _showRequestPermissionDialog(); if (isAllow) { var status = await Permission.microphone.request(); if (status != PermissionStatus.granted) { _onRecordPermissionDenied(); return; } } else { _onRecordPermissionDenied(); return; } } await _requestForegroundTaskPermission().catchError((error) { debugPrint("requestForegroundTaskPermission error: $error"); }); recordingDataController = StreamController(); File targetFile = await _getCurrentRecordFile(); if (_recorderSubscription != null) { _recorderSubscription?.cancel(); } _recorderSubscription = recordingDataController!.stream.listen((data) { if (data.isEmpty) { return; } 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); }); await _soundPlayer.openRecorder(); _isSoundInited = true; await _soundPlayer.startRecorder( toStream: recordingDataController!.sink, codec: _recordConfig.codec, numChannels: _recordConfig.numChannels, sampleRate: _recordConfig.sampleRate); _setWakeLock(); _startForegroundService(); if (currentStatus.value != RecordStatus.recording) { _changeRecordStatus(RecordStatus.recording); } } 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 deleteCurrentRecord() async { await stopRecord(isStopService: true); File file = await _getCurrentRecordFile(); if (file.existsSync()) { file.deleteSync(); } KVUtil.putString(keyLastRecordId, ""); _changeRecordStatus(RecordStatus.pending); currentDuration.value = 0; } Future _startForegroundService() async { final isRunningService = await FlutterForegroundTask.isRunningService; if (isRunningService) { return; } await FlutterForegroundTask.startService( serviceId: _serviceId, notificationTitle: StringName.appName.tr, notificationText: StringName.recordStatusRecording.tr, notificationIcon: null, notificationButtons: [], callback: setRecordCallback, ); } /// 判断是否有未上传的录音 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; } void onClose() async { if (currentStatus.value != RecordStatus.recording) { releaseSoundRecorder(); _cancelRecorderSubscriptions(); } } void releaseSoundRecorder() { if (_isSoundInited == true) { _soundPlayer.closeRecorder(); _isSoundInited = false; } } void _cancelRecorderSubscriptions() { recordingDataController = null; _recorderSubscription?.cancel(); } Future getConvertWavFile(String talkId) async { File pcmFile = await _getCurrentRecordFile(); if (pcmFile.existsSync()) { File wavFile = await getRecordFile(talkId); PcmWavConverter.convert(pcmFile, wavFile, _recordConfig.sampleRate, _recordConfig.numChannels, 16); pcmFile.delete(); KVUtil.putString(keyLastRecordId, ""); } else { throw Exception("pcm file not found"); } } /// 获取录音文件地址 static Future getRecordFile(String talkId) async { Directory documentDir = await getApplicationDocumentsDirectory(); return File("${documentDir.path}/.atmob/record/$talkId.wav"); } Future saveCurrentRecord() async { final currentDurationValue = currentDuration.value; if (currentDurationValue < minRecordDuration) { throw ServerErrorException(-1, "录音时长不足$minRecordDuration秒"); } await recordHandler.stopRecord(isStopService: true); return talkRepository .talkCreate(recordHandler.lastRecordId, currentDuration.value.toInt()) .then((talkInfo) async { await recordHandler.getConvertWavFile(talkInfo.id); return talkInfo; }); } } class RecordConfig { Codec codec; int numChannels; int sampleRate; RecordConfig( {required this.codec, required this.numChannels, required this.sampleRate}); } final recordHandler = RecordHandler._();