147 lines
		
	
	
		
			4.8 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			147 lines
		
	
	
		
			4.8 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:file_picker/file_picker.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/talker.dart';
 | |
| import 'package:island/widgets/alert.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<DateTime?>(null);
 | |
|     final recordingDuration = useState<Duration>(Duration(seconds: 0));
 | |
| 
 | |
|     StreamSubscription? originalAmplitude;
 | |
|     StreamController<Amplitude> amplitudeStream = StreamController();
 | |
|     var record = AudioRecorder();
 | |
| 
 | |
|     final resultPath = useState<String?>(null);
 | |
| 
 | |
|     Future<void> 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
 | |
|         talker.info('[Recorder] Clean up!');
 | |
|         originalAmplitude?.cancel();
 | |
|         amplitudeStream.close();
 | |
|         record.dispose();
 | |
|       };
 | |
|     }, []);
 | |
| 
 | |
|     Future<void> 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);
 | |
|     }
 | |
| 
 | |
|     Future<void> addExistingAudio() async {
 | |
|       var result = await FilePicker.platform.pickFiles(
 | |
|         type: FileType.custom,
 | |
|         allowedExtensions: ['mp3', 'm4a', 'wav', 'aac', 'flac', 'ogg', 'opus'],
 | |
|         onFileLoading: (status) {
 | |
|           if (!context.mounted) return;
 | |
|           if (status == FilePickerStatus.picking) {
 | |
|             showLoadingModal(context);
 | |
|           } else {
 | |
|             hideLoadingModal(context);
 | |
|           }
 | |
|         },
 | |
|       );
 | |
|       if (result == null || result.count == 0) return;
 | |
|       if (context.mounted) Navigator.of(context).pop(result.files.first.path);
 | |
|     }
 | |
| 
 | |
|     return SheetScaffold(
 | |
|       titleText: "recordAudio".tr(),
 | |
|       actions: [
 | |
|         IconButton(
 | |
|           onPressed: addExistingAudio,
 | |
|           icon: const Icon(Symbols.upload),
 | |
|         ),
 | |
|       ],
 | |
|       child: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.center,
 | |
|         children: [
 | |
|           const Gap(32),
 | |
|           Text(
 | |
|             recordingDuration.value.formatShortDuration(),
 | |
|           ).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),
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ).padding(horizontal: 24),
 | |
|           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,
 | |
|                     ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |