From bec037622f21f44b90bb17655c289ac238934f6e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 2 Aug 2025 14:06:58 +0800 Subject: [PATCH] :sparkles: Audio recorder --- android/app/src/main/AndroidManifest.xml | 1 + assets/i18n/en-US.json | 2 + lib/widgets/post/compose_recorder.dart | 122 +++++++++++++++++++++++ lib/widgets/post/compose_shared.dart | 24 ++++- lib/widgets/post/compose_toolbar.dart | 10 ++ pubspec.lock | 8 ++ pubspec.yaml | 1 + 7 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/post/compose_recorder.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69abf99..5fabde6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 52e41e2..80467f7 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -146,7 +146,9 @@ "edited": "Edited", "addVideo": "Add video", "addPhoto": "Add photo", + "addVoice": "Add your voice", "addFile": "Add file", + "recordAudio": "Record Audio", "linkAttachment": "Link Attachment", "fileIdCannotBeEmpty": "File ID cannot be empty", "failedToFetchFile": "Failed to fetch file: {}", diff --git a/lib/widgets/post/compose_recorder.dart b/lib/widgets/post/compose_recorder.dart new file mode 100644 index 0000000..3879d1e --- /dev/null +++ b/lib/widgets/post/compose_recorder.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/services/time.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart' hide Amplitude; +import 'package:styled_widget/styled_widget.dart'; +import 'package:uuid/uuid.dart'; +import 'package:waveform_flutter/waveform_flutter.dart'; + +class ComposeRecorder extends HookConsumerWidget { + const ComposeRecorder({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recording = useState(false); + final recordingStartAt = useState(null); + final recordingDuration = useState(Duration(seconds: 0)); + + StreamSubscription? originalAmplitude; + StreamController amplitudeStream = StreamController(); + var record = AudioRecorder(); + + final resultPath = useState(null); + + Future startRecord() async { + recording.value = true; + + // Check and request permission if needed + final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp'; + final uuid = const Uuid().v4().substring(0, 8); + if (!await record.hasPermission()) return; + + const recordConfig = RecordConfig( + encoder: AudioEncoder.pcm16bits, + autoGain: true, + echoCancel: true, + noiseSuppress: true, + ); + resultPath.value = '$tempPath/solar-network-record-$uuid.m4a'; + await record.start(recordConfig, path: resultPath.value!); + + recordingStartAt.value = DateTime.now(); + originalAmplitude = record + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((value) async { + amplitudeStream.add( + Amplitude(current: value.current, max: value.max), + ); + recordingDuration.value = DateTime.now().difference( + recordingStartAt.value!, + ); + }); + } + + useEffect(() { + return () { + // Called when widget is unmounted + log('[Recorder] Clean up!'); + originalAmplitude?.cancel(); + amplitudeStream.close(); + record.dispose(); + }; + }, []); + + Future stopRecord() async { + recording.value = false; + await record.pause(); + final newResult = await record.stop(); + await record.cancel(); + if (newResult != null) resultPath.value = newResult; + + if (context.mounted) Navigator.of(context).pop(resultPath.value); + } + + return SheetScaffold( + titleText: "recordAudio".tr(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Gap(32), + Text( + recordingDuration.value.formatDuration(), + ).fontSize(20).bold().padding(bottom: 8), + SizedBox( + height: 120, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Card( + color: Theme.of(context).colorScheme.surfaceContainer, + child: AnimatedWaveList(stream: amplitudeStream.stream), + ), + ), + ), + ), + const Gap(12), + IconButton.filled( + onPressed: recording.value ? stopRecord : startRecord, + iconSize: 32, + icon: + recording.value + ? const Icon(Symbols.stop, fill: 1, color: Colors.white) + : const Icon( + Symbols.play_arrow, + fill: 1, + color: Colors.white, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 36cf029..fc260de 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -15,14 +15,14 @@ import 'package:island/services/file.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/post/compose_recorder.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:textfield_tags/textfield_tags.dart'; import 'dart:async'; import 'dart:developer'; -import 'package:textfield_tags/textfield_tags.dart'; - class ComposeState { final TextEditingController titleController; final TextEditingController descriptionController; @@ -399,6 +399,26 @@ class ComposeLogic { ]; } + static Future recordAudioMedia( + WidgetRef ref, + ComposeState state, + BuildContext context, + ) async { + final audioPath = await showModalBottomSheet( + context: context, + builder: (context) => ComposeRecorder(), + ); + if (audioPath == null) return; + + state.attachments.value = [ + ...state.attachments.value, + UniversalFile( + data: XFile(audioPath, mimeType: 'audio/m4a'), + type: UniversalFileType.audio, + ), + ]; + } + static Future linkAttachment( WidgetRef ref, ComposeState state, diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index ca5a3a2..599c666 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget { ComposeLogic.pickVideoMedia(ref, state); } + void addYourVoice() { + ComposeLogic.recordAudioMedia(ref, state, context); + } + void linkAttachment() { ComposeLogic.linkAttachment(ref, state, context); } @@ -72,6 +76,12 @@ class ComposeToolbar extends HookConsumerWidget { icon: const Icon(Symbols.videocam), color: colorScheme.primary, ), + IconButton( + onPressed: addYourVoice, + tooltip: 'addYourVoice'.tr(), + icon: const Icon(Symbols.mic), + color: colorScheme.primary, + ), IconButton( onPressed: linkAttachment, icon: const Icon(Symbols.attach_file), diff --git a/pubspec.lock b/pubspec.lock index 67e1915..e886c03 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2572,6 +2572,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + waveform_flutter: + dependency: "direct main" + description: + name: waveform_flutter + sha256: "08c9e98d4cf119428d8b3c083ed42c11c468623eaffdf30420ae38e36662922a" + url: "https://pub.dev" + source: hosted + version: "1.2.0" web: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a701551..4f2fab8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -132,6 +132,7 @@ dependencies: html2md: ^1.3.2 flutter_typeahead: ^5.2.0 flutter_langdetect: ^0.0.2 + waveform_flutter: ^1.2.0 dev_dependencies: flutter_test: