From 964515d76c93d0294d5747462435759ce2ef6a9d Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sat, 1 Jan 2022 14:17:31 +0100 Subject: [PATCH] feat: Recording dialog with displaying amplitude --- lib/pages/chat/chat.dart | 4 + lib/pages/chat/events/audio_player.dart | 103 +++++++++++++++------ lib/pages/chat/recording_dialog.dart | 114 +++++++++++++++--------- pubspec.yaml | 2 +- 4 files changed, 149 insertions(+), 74 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index e723b22c..3698dec9 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -422,6 +422,10 @@ class ChatController extends State { 'duration': result.duration, }, 'org.matrix.msc3245.voice': {}, + 'org.matrix.msc1767.audio': { + 'duration': result.duration, + 'waveform': result.waveform, + }, }), ); setState(() { diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 9cfa9354..2c116764 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -1,3 +1,5 @@ +//@dart=2.12 + import 'dart:async'; import 'dart:io'; @@ -8,6 +10,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/sentry_controller.dart'; import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart'; @@ -15,9 +18,9 @@ class AudioPlayerWidget extends StatefulWidget { final Color color; final Event event; - static String currentId; + static String? currentId; - const AudioPlayerWidget(this.event, {this.color = Colors.black, Key key}) + const AudioPlayerWidget(this.event, {this.color = Colors.black, Key? key}) : super(key: key); @override @@ -30,16 +33,16 @@ class _AudioPlayerState extends State { AudioPlayerStatus status = AudioPlayerStatus.notDownloaded; final AudioPlayer audioPlayer = AudioPlayer(); - StreamSubscription onAudioPositionChanged; - StreamSubscription onDurationChanged; - StreamSubscription onPlayerStateChanged; - StreamSubscription onPlayerError; + StreamSubscription? onAudioPositionChanged; + StreamSubscription? onDurationChanged; + StreamSubscription? onPlayerStateChanged; + StreamSubscription? onPlayerError; - String statusText; - double currentPosition = 0; + String? statusText; + int currentPosition = 0; double maxPosition = 0; - File audioFile; + File? audioFile; @override void dispose() { @@ -60,6 +63,7 @@ class _AudioPlayerState extends State { try { final matrixFile = await widget.event.downloadAndDecryptAttachmentCached(); + if (matrixFile == null) throw ('Download failed'); final tempDir = await getTemporaryDirectory(); final fileName = widget.event.content.tryGet('filename') ?? matrixFile.name; @@ -86,7 +90,7 @@ class _AudioPlayerState extends State { if (AudioPlayerWidget.currentId != null) { if (audioPlayer.state != PlayerState.STOPPED) { await audioPlayer.stop(); - setState(() => null); + setState(() {}); } } AudioPlayerWidget.currentId = widget.event.eventId; @@ -105,30 +109,31 @@ class _AudioPlayerState extends State { setState(() { statusText = '${state.inMinutes.toString().padLeft(2, '0')}:${(state.inSeconds % 60).toString().padLeft(2, '0')}'; - currentPosition = state.inMilliseconds.toDouble(); + currentPosition = + ((state.inMilliseconds.toDouble() / maxPosition) * 100).round(); }); }); onDurationChanged ??= audioPlayer.onDurationChanged.listen((max) => setState(() => maxPosition = max.inMilliseconds.toDouble())); - onPlayerStateChanged ??= audioPlayer.onPlayerStateChanged - .listen((_) => setState(() => null)); + onPlayerStateChanged ??= + audioPlayer.onPlayerStateChanged.listen((_) => setState(() {})); onPlayerError ??= audioPlayer.onPlayerError.listen((e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(L10n.of(context).oopsSomethingWentWrong), + content: Text(L10n.of(context)!.oopsSomethingWentWrong), ), ); SentryController.captureException(e, StackTrace.current); }); - await audioPlayer.play(audioFile.path); + await audioPlayer.play(audioFile!.path); break; } } static const double buttonSize = 36; - String get _durationString { + String? get _durationString { final durationInt = widget.event.content .tryGetMap('info') ?.tryGet('duration'); @@ -137,9 +142,30 @@ class _AudioPlayerState extends State { return '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; } + List get waveform { + final eventWaveForm = widget.event.content + .tryGetMap('org.matrix.msc1767.audio') + ?.tryGetList('waveform'); + if (eventWaveForm == null) { + return List.filled(100, 500); + } + while (eventWaveForm.length < 100) { + for (var i = 0; i < eventWaveForm.length; i = i + 2) { + eventWaveForm.insert(i, eventWaveForm[i]); + } + } + var i = 0; + final step = (eventWaveForm.length / 100).round(); + while (eventWaveForm.length > 100) { + eventWaveForm.removeAt(i); + i = (i + step) % 100; + } + return eventWaveForm; + } + @override Widget build(BuildContext context) { - statusText ??= _durationString ?? '00:00'; + final statusText = this.statusText ??= _durationString ?? '00:00'; return Padding( padding: const EdgeInsets.symmetric(horizontal: 6.0), child: Row( @@ -172,21 +198,40 @@ class _AudioPlayerState extends State { }, ), ), + const SizedBox(width: 8), Expanded( - child: Slider( - activeColor: Theme.of(context).colorScheme.secondaryVariant, - inactiveColor: widget.color.withAlpha(64), - value: currentPosition, - onChanged: (double position) => - audioPlayer.seek(Duration(milliseconds: position.toInt())), - max: status == AudioPlayerStatus.downloaded ? maxPosition : 0, - min: 0, + child: Row( + children: [ + for (var i = 0; i < 100; i++) + Expanded( + child: InkWell( + onTap: () => audioPlayer.seek(Duration( + milliseconds: (maxPosition / 100).round() * i)), + child: Opacity( + opacity: currentPosition > i ? 1 : 0.5, + child: Container( + margin: const EdgeInsets.only(left: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + height: 64 * (waveform[i] / 1024)), + ), + ), + ) + ], ), ), - Text( - statusText, - style: TextStyle( - color: widget.color, + const SizedBox(width: 8), + Container( + alignment: Alignment.centerRight, + width: 42, + child: Text( + statusText, + style: TextStyle( + color: widget.color, + ), ), ), ], diff --git a/lib/pages/chat/recording_dialog.dart b/lib/pages/chat/recording_dialog.dart index 0297c391..5be96fbb 100644 --- a/lib/pages/chat/recording_dialog.dart +++ b/lib/pages/chat/recording_dialog.dart @@ -1,3 +1,5 @@ +//@dart=2.12 + import 'dart:async'; import 'package:flutter/cupertino.dart'; @@ -8,13 +10,14 @@ import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import 'package:wakelock/wakelock.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/sentry_controller.dart'; class RecordingDialog extends StatefulWidget { static const String recordingFileType = 'm4a'; const RecordingDialog({ - Key key, + Key? key, }) : super(key: key); @override @@ -22,13 +25,13 @@ class RecordingDialog extends StatefulWidget { } class _RecordingDialogState extends State { - Timer _recorderSubscription; + Timer? _recorderSubscription; Duration _duration = Duration.zero; bool error = false; - String _recordedPath; + String? _recordedPath; final _audioRecorder = Record(); - Amplitude _amplitude; + final List amplitudeTimeline = []; static const int bitRate = 64000; static const double samplingRate = 22050.0; @@ -55,7 +58,10 @@ class _RecordingDialogState extends State { _recorderSubscription?.cancel(); _recorderSubscription = Timer.periodic(const Duration(milliseconds: 100), (_) async { - _amplitude = await _audioRecorder.getAmplitude(); + 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); }); @@ -83,52 +89,64 @@ class _RecordingDialogState extends State { void _stopAndSend() async { _recorderSubscription?.cancel(); await _audioRecorder.stop(); - Navigator.of(context, rootNavigator: false) - .pop(RecordingResult( - path: _recordedPath, - duration: _duration.inMilliseconds, - )); + final path = _recordedPath; + if (path == null) throw ('Recording failed!'); + final step = amplitudeTimeline.length < 100 + ? 1 + : (amplitudeTimeline.length / 100).round(); + final waveform = []; + for (var i = 0; i < amplitudeTimeline.length; i += step) { + waveform.add((amplitudeTimeline[i] / 100 * 1024).round()); + } + Navigator.of(context, rootNavigator: false).pop( + RecordingResult( + path: path, + duration: _duration.inMilliseconds, + waveform: waveform, + ), + ); } @override Widget build(BuildContext context) { const maxDecibalWidth = 64.0; - final decibalWidth = - ((_amplitude == null || _amplitude.current == double.negativeInfinity - ? 0 - : 1 / _amplitude.current / _amplitude.max) - .abs() + - 2) * - (maxDecibalWidth / 4).toDouble(); final time = '${_duration.inMinutes.toString().padLeft(2, '0')}:${(_duration.inSeconds % 60).toString().padLeft(2, '0')}'; final content = error - ? Text(L10n.of(context).oopsSomethingWentWrong) + ? Text(L10n.of(context)!.oopsSomethingWentWrong) : Row( - children: [ + children: [ Container( - width: maxDecibalWidth, - height: maxDecibalWidth, - alignment: Alignment.center, - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - width: decibalWidth, - height: decibalWidth, - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(decibalWidth), - ), + width: 16, + height: 16, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: Colors.red, ), ), - const SizedBox(width: 8), Expanded( - child: Text( - '${L10n.of(context).recording}: $time', - style: const TextStyle( - fontSize: 18, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: amplitudeTimeline.reversed + .take(26) + .toList() + .reversed + .map((amplitude) => Container( + margin: const EdgeInsets.only(left: 2), + width: 4, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + height: maxDecibalWidth * (amplitude / 100))) + .toList(), ), ), ), + Text(time), ], ); if (PlatformInfos.isCupertinoStyle) { @@ -138,17 +156,20 @@ class _RecordingDialogState extends State { CupertinoDialogAction( onPressed: () => Navigator.of(context, rootNavigator: false).pop(), child: Text( - L10n.of(context).cancel.toUpperCase(), + L10n.of(context)!.cancel.toUpperCase(), style: TextStyle( - color: - Theme.of(context).textTheme.bodyText2.color.withAlpha(150), + color: Theme.of(context) + .textTheme + .bodyText2 + ?.color + ?.withAlpha(150), ), ), ), if (error != true) CupertinoDialogAction( onPressed: _stopAndSend, - child: Text(L10n.of(context).send.toUpperCase()), + child: Text(L10n.of(context)!.send.toUpperCase()), ), ], ); @@ -159,9 +180,10 @@ class _RecordingDialogState extends State { TextButton( onPressed: () => Navigator.of(context, rootNavigator: false).pop(), child: Text( - L10n.of(context).cancel.toUpperCase(), + L10n.of(context)!.cancel.toUpperCase(), style: TextStyle( - color: Theme.of(context).textTheme.bodyText2.color.withAlpha(150), + color: + Theme.of(context).textTheme.bodyText2?.color?.withAlpha(150), ), ), ), @@ -171,7 +193,7 @@ class _RecordingDialogState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(L10n.of(context).send.toUpperCase()), + Text(L10n.of(context)!.send.toUpperCase()), const SizedBox(width: 4), const Icon(Icons.send_outlined, size: 15), ], @@ -185,20 +207,24 @@ class _RecordingDialogState extends State { class RecordingResult { final String path; final int duration; + final List waveform; const RecordingResult({ - @required this.path, - @required this.duration, + required this.path, + required this.duration, + required this.waveform, }); factory RecordingResult.fromJson(Map json) => RecordingResult( path: json['path'], duration: json['duration'], + waveform: List.from(json['waveform']), ); Map toJson() => { 'path': path, 'duration': duration, + 'waveform': waveform, }; } diff --git a/pubspec.yaml b/pubspec.yaml index 96b6ad04..6fd735d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: qr_code_scanner: ^0.6.1 qr_flutter: ^4.0.0 receive_sharing_intent: ^1.4.5 - record: ^3.0.0 + record: ^3.0.2 salomon_bottom_bar: ^3.1.0 scroll_to_index: ^2.1.0 sentry: ^6.0.1