Compare commits
	
		
			2 Commits
		
	
	
		
			a0d8c1a9b3
			...
			e1286c797f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e1286c797f | |||
| bec037622f | 
@@ -4,6 +4,7 @@
 | 
				
			|||||||
    <uses-permission android:name="android.permission.INTERNET" />
 | 
					    <uses-permission android:name="android.permission.INTERNET" />
 | 
				
			||||||
    <uses-permission android:name="android.permission.CAMERA" />
 | 
					    <uses-permission android:name="android.permission.CAMERA" />
 | 
				
			||||||
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
 | 
					    <uses-permission android:name="android.permission.RECORD_AUDIO" />
 | 
				
			||||||
 | 
					    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
 | 
				
			||||||
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 | 
					    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 | 
				
			||||||
    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
 | 
					    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
 | 
				
			||||||
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
 | 
					    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -146,7 +146,9 @@
 | 
				
			|||||||
  "edited": "Edited",
 | 
					  "edited": "Edited",
 | 
				
			||||||
  "addVideo": "Add video",
 | 
					  "addVideo": "Add video",
 | 
				
			||||||
  "addPhoto": "Add photo",
 | 
					  "addPhoto": "Add photo",
 | 
				
			||||||
 | 
					  "addVoice": "Add your voice",
 | 
				
			||||||
  "addFile": "Add file",
 | 
					  "addFile": "Add file",
 | 
				
			||||||
 | 
					  "recordAudio": "Record Audio",
 | 
				
			||||||
  "linkAttachment": "Link Attachment",
 | 
					  "linkAttachment": "Link Attachment",
 | 
				
			||||||
  "fileIdCannotBeEmpty": "File ID cannot be empty",
 | 
					  "fileIdCannotBeEmpty": "File ID cannot be empty",
 | 
				
			||||||
  "failedToFetchFile": "Failed to fetch file: {}",
 | 
					  "failedToFetchFile": "Failed to fetch file: {}",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,6 +20,33 @@ extension DurationFormatter on Duration {
 | 
				
			|||||||
    return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
 | 
					    return '${isNegative ? '-' : ''}$hours:$minutes:$seconds';
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String formatShortDuration() {
 | 
				
			||||||
 | 
					    final isNegative = inMicroseconds < 0;
 | 
				
			||||||
 | 
					    final positiveDuration = isNegative ? -this : this;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final hours = positiveDuration.inHours;
 | 
				
			||||||
 | 
					    final minutes = (positiveDuration.inMinutes % 60).toString().padLeft(
 | 
				
			||||||
 | 
					      2,
 | 
				
			||||||
 | 
					      '0',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final seconds = (positiveDuration.inSeconds % 60).toString().padLeft(
 | 
				
			||||||
 | 
					      2,
 | 
				
			||||||
 | 
					      '0',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    final milliseconds = (positiveDuration.inMilliseconds % 1000)
 | 
				
			||||||
 | 
					        .toString()
 | 
				
			||||||
 | 
					        .padLeft(3, '0');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    String result;
 | 
				
			||||||
 | 
					    if (hours > 0) {
 | 
				
			||||||
 | 
					      result =
 | 
				
			||||||
 | 
					          '${isNegative ? '-' : ''}${hours.toString().padLeft(2, '0')}:$minutes:$seconds.$milliseconds';
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      result = '${isNegative ? '-' : ''}$minutes:$seconds.$milliseconds';
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return result;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String formatOffset() {
 | 
					  String formatOffset() {
 | 
				
			||||||
    final isNegative = inMicroseconds < 0;
 | 
					    final isNegative = inMicroseconds < 0;
 | 
				
			||||||
    final positiveDuration = isNegative ? -this : this;
 | 
					    final positiveDuration = isNegative ? -this : this;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										146
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								lib/widgets/content/audio.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,146 @@
 | 
				
			|||||||
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 | 
				
			||||||
 | 
					import 'package:gap/gap.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:island/pods/network.dart';
 | 
				
			||||||
 | 
					import 'package:island/services/time.dart';
 | 
				
			||||||
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
 | 
					import 'package:media_kit/media_kit.dart';
 | 
				
			||||||
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UniversalAudio extends ConsumerStatefulWidget {
 | 
				
			||||||
 | 
					  final String uri;
 | 
				
			||||||
 | 
					  final bool autoplay;
 | 
				
			||||||
 | 
					  const UniversalAudio({super.key, required this.uri, this.autoplay = false});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  ConsumerState<UniversalAudio> createState() => _UniversalAudioState();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class _UniversalAudioState extends ConsumerState<UniversalAudio> {
 | 
				
			||||||
 | 
					  Player? _player;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Duration _duration = Duration(seconds: 1);
 | 
				
			||||||
 | 
					  Duration _duartionBuffered = Duration(seconds: 1);
 | 
				
			||||||
 | 
					  Duration _position = Duration(seconds: 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  bool _sliderWorking = false;
 | 
				
			||||||
 | 
					  Duration _sliderPosition = Duration(seconds: 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _openAudio() async {
 | 
				
			||||||
 | 
					    final url = widget.uri;
 | 
				
			||||||
 | 
					    MediaKit.ensureInitialized();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _player = Player();
 | 
				
			||||||
 | 
					    _player!.stream.position.listen((value) {
 | 
				
			||||||
 | 
					      _position = value;
 | 
				
			||||||
 | 
					      if (!_sliderWorking) _sliderPosition = _position;
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    _player!.stream.buffer.listen((value) {
 | 
				
			||||||
 | 
					      _duartionBuffered = value;
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    _player!.stream.duration.listen((value) {
 | 
				
			||||||
 | 
					      _duration = value;
 | 
				
			||||||
 | 
					      setState(() {});
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    String? uri;
 | 
				
			||||||
 | 
					    final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
 | 
				
			||||||
 | 
					    if (inCacheInfo == null) {
 | 
				
			||||||
 | 
					      log('[MediaPlayer] Miss cache: $url');
 | 
				
			||||||
 | 
					      final token = ref.watch(tokenProvider)?.token;
 | 
				
			||||||
 | 
					      DefaultCacheManager().downloadFile(
 | 
				
			||||||
 | 
					        url,
 | 
				
			||||||
 | 
					        authHeaders: {'Authorization': 'AtField $token'},
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      uri = url;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      uri = inCacheInfo.file.path;
 | 
				
			||||||
 | 
					      log('[MediaPlayer] Hit cache: $url');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _player!.open(Media(uri), play: widget.autoplay);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void initState() {
 | 
				
			||||||
 | 
					    super.initState();
 | 
				
			||||||
 | 
					    _openAudio();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void dispose() {
 | 
				
			||||||
 | 
					    super.dispose();
 | 
				
			||||||
 | 
					    _player?.dispose();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    if (_player == null) {
 | 
				
			||||||
 | 
					      return Center(child: CircularProgressIndicator());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Card(
 | 
				
			||||||
 | 
					      color: Theme.of(context).colorScheme.surfaceContainerLowest,
 | 
				
			||||||
 | 
					      child: Row(
 | 
				
			||||||
 | 
					        children: [
 | 
				
			||||||
 | 
					          IconButton.filled(
 | 
				
			||||||
 | 
					            onPressed: () {
 | 
				
			||||||
 | 
					              _player!.playOrPause().then((_) {
 | 
				
			||||||
 | 
					                if (mounted) setState(() {});
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            icon:
 | 
				
			||||||
 | 
					                _player!.state.playing
 | 
				
			||||||
 | 
					                    ? const Icon(Symbols.pause, fill: 1, color: Colors.white)
 | 
				
			||||||
 | 
					                    : const Icon(
 | 
				
			||||||
 | 
					                      Symbols.play_arrow,
 | 
				
			||||||
 | 
					                      fill: 1,
 | 
				
			||||||
 | 
					                      color: Colors.white,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          const Gap(20),
 | 
				
			||||||
 | 
					          Expanded(
 | 
				
			||||||
 | 
					            child: Column(
 | 
				
			||||||
 | 
					              mainAxisSize: MainAxisSize.min,
 | 
				
			||||||
 | 
					              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
				
			||||||
 | 
					              children: [
 | 
				
			||||||
 | 
					                Row(
 | 
				
			||||||
 | 
					                  children: [
 | 
				
			||||||
 | 
					                    Text(
 | 
				
			||||||
 | 
					                      '${_position.formatShortDuration()} / ${_duration.formatShortDuration()}',
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ],
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                Slider(
 | 
				
			||||||
 | 
					                  value: _sliderPosition.inMilliseconds.toDouble(),
 | 
				
			||||||
 | 
					                  secondaryTrackValue:
 | 
				
			||||||
 | 
					                      _duartionBuffered.inMilliseconds.toDouble(),
 | 
				
			||||||
 | 
					                  max: _duration.inMilliseconds.toDouble(),
 | 
				
			||||||
 | 
					                  onChangeStart: (_) {
 | 
				
			||||||
 | 
					                    _sliderWorking = true;
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onChanged: (value) {
 | 
				
			||||||
 | 
					                    _sliderPosition = Duration(milliseconds: value.toInt());
 | 
				
			||||||
 | 
					                    setState(() {});
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  onChangeEnd: (value) {
 | 
				
			||||||
 | 
					                    _sliderPosition = Duration(milliseconds: value.toInt());
 | 
				
			||||||
 | 
					                    _sliderWorking = false;
 | 
				
			||||||
 | 
					                    _player!.seek(_sliderPosition);
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                  year2023: true,
 | 
				
			||||||
 | 
					                  padding: EdgeInsets.zero,
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ],
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ).padding(horizontal: 24, vertical: 16),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -63,16 +63,8 @@ class CloudFileList extends HookConsumerWidget {
 | 
				
			|||||||
    if (files.isEmpty) return const SizedBox.shrink();
 | 
					    if (files.isEmpty) return const SizedBox.shrink();
 | 
				
			||||||
    if (files.length == 1) {
 | 
					    if (files.length == 1) {
 | 
				
			||||||
      final isImage = files.first.mimeType?.startsWith('image') ?? false;
 | 
					      final isImage = files.first.mimeType?.startsWith('image') ?? false;
 | 
				
			||||||
      return Container(
 | 
					      final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
 | 
				
			||||||
        padding: padding,
 | 
					      final widgetItem = ClipRRect(
 | 
				
			||||||
        constraints: BoxConstraints(
 | 
					 | 
				
			||||||
          maxHeight: disableConstraint ? double.infinity : maxHeight,
 | 
					 | 
				
			||||||
          minWidth: minWidth ?? 0,
 | 
					 | 
				
			||||||
          maxWidth: files.length == 1 ? maxWidth : double.infinity,
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        child: AspectRatio(
 | 
					 | 
				
			||||||
          aspectRatio: calculateAspectRatio(),
 | 
					 | 
				
			||||||
          child: ClipRRect(
 | 
					 | 
				
			||||||
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
					        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
				
			||||||
        child: _CloudFileListEntry(
 | 
					        child: _CloudFileListEntry(
 | 
				
			||||||
          file: files.first,
 | 
					          file: files.first,
 | 
				
			||||||
@@ -91,7 +83,21 @@ class CloudFileList extends HookConsumerWidget {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return Container(
 | 
				
			||||||
 | 
					        padding: padding,
 | 
				
			||||||
 | 
					        constraints: BoxConstraints(
 | 
				
			||||||
 | 
					          maxHeight: disableConstraint ? double.infinity : maxHeight,
 | 
				
			||||||
 | 
					          minWidth: minWidth ?? 0,
 | 
				
			||||||
 | 
					          maxWidth: files.length == 1 ? maxWidth : double.infinity,
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
 | 
					        height: isAudio ? 180 : null,
 | 
				
			||||||
 | 
					        child:
 | 
				
			||||||
 | 
					            isAudio
 | 
				
			||||||
 | 
					                ? widgetItem
 | 
				
			||||||
 | 
					                : AspectRatio(
 | 
				
			||||||
 | 
					                  aspectRatio: calculateAspectRatio(),
 | 
				
			||||||
 | 
					                  child: widgetItem,
 | 
				
			||||||
                ),
 | 
					                ),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,5 @@
 | 
				
			|||||||
 | 
					import 'dart:math' as math;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:cached_network_image/cached_network_image.dart';
 | 
					import 'package:cached_network_image/cached_network_image.dart';
 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
					import 'package:flutter_hooks/flutter_hooks.dart';
 | 
				
			||||||
@@ -5,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			|||||||
import 'package:island/models/file.dart';
 | 
					import 'package:island/models/file.dart';
 | 
				
			||||||
import 'package:island/pods/config.dart';
 | 
					import 'package:island/pods/config.dart';
 | 
				
			||||||
import 'package:island/services/time.dart';
 | 
					import 'package:island/services/time.dart';
 | 
				
			||||||
 | 
					import 'package:island/widgets/content/audio.dart';
 | 
				
			||||||
import 'package:material_symbols_icons/symbols.dart';
 | 
					import 'package:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,6 +52,14 @@ class CloudFileWidget extends ConsumerWidget {
 | 
				
			|||||||
        aspectRatio: ratio,
 | 
					        aspectRatio: ratio,
 | 
				
			||||||
        child: CloudVideoWidget(item: item),
 | 
					        child: CloudVideoWidget(item: item),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 | 
					      "audio" => Center(
 | 
				
			||||||
 | 
					        child: ConstrainedBox(
 | 
				
			||||||
 | 
					          constraints: BoxConstraints(
 | 
				
			||||||
 | 
					            maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          child: UniversalAudio(uri: uri),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
      _ => Text('Unable render for ${item.mimeType}'),
 | 
					      _ => Text('Unable render for ${item.mimeType}'),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										122
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								lib/widgets/post/compose_recorder.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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<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
 | 
				
			||||||
 | 
					        log('[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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return SheetScaffold(
 | 
				
			||||||
 | 
					      titleText: "recordAudio".tr(),
 | 
				
			||||||
 | 
					      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),
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          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,
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -15,14 +15,14 @@ import 'package:island/services/file.dart';
 | 
				
			|||||||
import 'package:island/services/compose_storage_db.dart';
 | 
					import 'package:island/services/compose_storage_db.dart';
 | 
				
			||||||
import 'package:island/widgets/alert.dart';
 | 
					import 'package:island/widgets/alert.dart';
 | 
				
			||||||
import 'package:island/widgets/content/sheet.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:material_symbols_icons/symbols.dart';
 | 
				
			||||||
import 'package:pasteboard/pasteboard.dart';
 | 
					import 'package:pasteboard/pasteboard.dart';
 | 
				
			||||||
import 'package:styled_widget/styled_widget.dart';
 | 
					import 'package:styled_widget/styled_widget.dart';
 | 
				
			||||||
 | 
					import 'package:textfield_tags/textfield_tags.dart';
 | 
				
			||||||
import 'dart:async';
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:developer';
 | 
					import 'dart:developer';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import 'package:textfield_tags/textfield_tags.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ComposeState {
 | 
					class ComposeState {
 | 
				
			||||||
  final TextEditingController titleController;
 | 
					  final TextEditingController titleController;
 | 
				
			||||||
  final TextEditingController descriptionController;
 | 
					  final TextEditingController descriptionController;
 | 
				
			||||||
@@ -399,6 +399,26 @@ class ComposeLogic {
 | 
				
			|||||||
    ];
 | 
					    ];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static Future<void> recordAudioMedia(
 | 
				
			||||||
 | 
					    WidgetRef ref,
 | 
				
			||||||
 | 
					    ComposeState state,
 | 
				
			||||||
 | 
					    BuildContext context,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    final audioPath = await showModalBottomSheet<String?>(
 | 
				
			||||||
 | 
					      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<void> linkAttachment(
 | 
					  static Future<void> linkAttachment(
 | 
				
			||||||
    WidgetRef ref,
 | 
					    WidgetRef ref,
 | 
				
			||||||
    ComposeState state,
 | 
					    ComposeState state,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget {
 | 
				
			|||||||
      ComposeLogic.pickVideoMedia(ref, state);
 | 
					      ComposeLogic.pickVideoMedia(ref, state);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    void addYourVoice() {
 | 
				
			||||||
 | 
					      ComposeLogic.recordAudioMedia(ref, state, context);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    void linkAttachment() {
 | 
					    void linkAttachment() {
 | 
				
			||||||
      ComposeLogic.linkAttachment(ref, state, context);
 | 
					      ComposeLogic.linkAttachment(ref, state, context);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -72,6 +76,12 @@ class ComposeToolbar extends HookConsumerWidget {
 | 
				
			|||||||
                icon: const Icon(Symbols.videocam),
 | 
					                icon: const Icon(Symbols.videocam),
 | 
				
			||||||
                color: colorScheme.primary,
 | 
					                color: colorScheme.primary,
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
 | 
					              IconButton(
 | 
				
			||||||
 | 
					                onPressed: addYourVoice,
 | 
				
			||||||
 | 
					                tooltip: 'addYourVoice'.tr(),
 | 
				
			||||||
 | 
					                icon: const Icon(Symbols.mic),
 | 
				
			||||||
 | 
					                color: colorScheme.primary,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
              IconButton(
 | 
					              IconButton(
 | 
				
			||||||
                onPressed: linkAttachment,
 | 
					                onPressed: linkAttachment,
 | 
				
			||||||
                icon: const Icon(Symbols.attach_file),
 | 
					                icon: const Icon(Symbols.attach_file),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2572,6 +2572,14 @@ packages:
 | 
				
			|||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "1.1.2"
 | 
					    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:
 | 
					  web:
 | 
				
			||||||
    dependency: "direct main"
 | 
					    dependency: "direct main"
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -132,6 +132,7 @@ dependencies:
 | 
				
			|||||||
  html2md: ^1.3.2
 | 
					  html2md: ^1.3.2
 | 
				
			||||||
  flutter_typeahead: ^5.2.0
 | 
					  flutter_typeahead: ^5.2.0
 | 
				
			||||||
  flutter_langdetect: ^0.0.2
 | 
					  flutter_langdetect: ^0.0.2
 | 
				
			||||||
 | 
					  waveform_flutter: ^1.2.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
dev_dependencies:
 | 
					dev_dependencies:
 | 
				
			||||||
  flutter_test:
 | 
					  flutter_test:
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user