// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'dart:async'; import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import 'package:path/path.dart' as path_lib; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'events/audio_player.dart'; class RecordingViewModel extends StatefulWidget { final Widget Function(BuildContext, RecordingViewModelState) builder; const RecordingViewModel({required this.builder, super.key}); @override RecordingViewModelState createState() => RecordingViewModelState(); } class RecordingViewModelState extends State { Timer? _recorderSubscription; Duration duration = Duration.zero; bool get isRecording => _audioRecorder != null; AudioRecorder? _audioRecorder; final List amplitudeTimeline = []; String? fileName; bool isPaused = false; Future startRecording(Room room) async { room.client.getConfig(); // Preload server file configuration. if (PlatformInfos.isAndroid) { final info = await DeviceInfoPlugin().androidInfo; if (!mounted) return; if (info.version.sdkInt < 19) { showOkAlertDialog( context: context, title: L10n.of(context).unsupportedAndroidVersion, message: L10n.of(context).unsupportedAndroidVersionLong, okLabel: L10n.of(context).close, ); return; } } if (await AudioRecorder().hasPermission() == false) return; final audioRecorder = _audioRecorder ??= AudioRecorder(); setState(() {}); try { final codec = !PlatformInfos .isIOS && // Blocked by https://github.com/llfbandit/record/issues/560 await audioRecorder.isEncoderSupported(AudioEncoder.opus) ? AudioEncoder.opus : AudioEncoder.aacLc; fileName = 'voice_message_${DateTime.now().millisecondsSinceEpoch}.${codec.fileExtension}'; String? path; if (!kIsWeb) { final tempDir = await getTemporaryDirectory(); path = path_lib.join(tempDir.path, fileName); } final result = await audioRecorder.hasPermission(); if (result != true) { if (!mounted) return; showOkAlertDialog( context: context, title: L10n.of(context).oopsSomethingWentWrong, message: L10n.of(context).noPermission, ); return; } await WakelockPlus.enable(); await audioRecorder.start( RecordConfig( bitRate: AppSettings.audioRecordingBitRate.value, sampleRate: AppSettings.audioRecordingSamplingRate.value, numChannels: AppSettings.audioRecordingNumChannels.value, autoGain: AppSettings.audioRecordingAutoGain.value, echoCancel: AppSettings.audioRecordingEchoCancel.value, noiseSuppress: AppSettings.audioRecordingNoiseSuppress.value, encoder: codec, ), path: path ?? '', ); if (!mounted) return; setState(() => duration = Duration.zero); _subscribe(); } catch (e, s) { Logs().w('Unable to start voice message recording', e, s); if (!mounted) return; showOkAlertDialog( context: context, title: L10n.of(context).oopsSomethingWentWrong, message: e.toString(), ); setState(_reset); } } @override void dispose() { _reset(); super.dispose(); } void _subscribe() { _recorderSubscription?.cancel(); _recorderSubscription = Timer.periodic(const Duration(milliseconds: 100), ( _, ) async { final amplitude = await _audioRecorder!.getAmplitude(); var value = 100 + amplitude.current * 2; value = value < 1 ? 1 : value; amplitudeTimeline.add(value); setState(() { duration += const Duration(milliseconds: 100); }); }); } void _reset() { WakelockPlus.disable(); _recorderSubscription?.cancel(); _audioRecorder?.stop(); _audioRecorder = null; fileName = null; duration = Duration.zero; amplitudeTimeline.clear(); isPaused = false; } void cancel() { setState(_reset); } void pause() { _audioRecorder?.pause(); _recorderSubscription?.cancel(); setState(() { isPaused = true; }); } void resume() { _audioRecorder?.resume(); _subscribe(); setState(() { isPaused = false; }); } Future stopAndSend( Future Function( String path, int duration, List waveform, String fileName, ) onSend, ) async { _recorderSubscription?.cancel(); final path = await _audioRecorder?.stop(); if (path == null) throw ('Recording failed!'); const waveCount = AudioPlayerWidget.wavesCount; final step = amplitudeTimeline.length < waveCount ? 1 : (amplitudeTimeline.length / waveCount).round(); final waveform = []; for (var i = 0; i < amplitudeTimeline.length; i += step) { waveform.add((amplitudeTimeline[i] / 100 * 1024).round()); } onSend(path, duration.inMilliseconds, waveform, fileName!); cancel(); } @override Widget build(BuildContext context) => widget.builder(context, this); } extension on AudioEncoder { String get fileExtension { switch (this) { case AudioEncoder.aacLc: case AudioEncoder.aacEld: case AudioEncoder.aacHe: return 'm4a'; case AudioEncoder.opus: return 'ogg'; case AudioEncoder.wav: return 'wav'; case AudioEncoder.amrNb: case AudioEncoder.amrWb: case AudioEncoder.flac: case AudioEncoder.pcm16bits: throw UnsupportedError('Not yet used'); } } }