[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: preview sounds before selection

+105 -11
+32 -3
lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:pro_image_editor/pro_image_editor.dart'; 3 + import 'package:spark/src/core/l10n/app_localizations.dart'; 3 4 import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart'; 4 5 import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart'; 5 6 ··· 81 82 void _handleTrackSelection(AudioTrack track) { 82 83 setState(() { 83 84 _selectedTrack = track; 84 - _showEditControls = true; 85 85 }); 86 - widget.onTrackSelected(track); 87 86 widget.onTrackPlay(track); 88 87 } 89 88 89 + void _handleContinue() { 90 + final selectedTrack = _selectedTrack; 91 + if (selectedTrack == null) return; 92 + 93 + setState(() { 94 + _showEditControls = true; 95 + }); 96 + widget.onTrackSelected(selectedTrack); 97 + } 98 + 90 99 void _handleChangeTrack() { 91 100 if (_selectedTrack != null) { 92 101 widget.onTrackStop(_selectedTrack!); ··· 165 174 ), 166 175 ), 167 176 ), 177 + if (!_showEditControls) _buildContinueButton(), 168 178 ], 169 179 ), 170 180 ); ··· 186 196 187 197 Widget _buildHeader() { 188 198 final i18n = widget.configs.i18n.audioEditor; 199 + final l10n = AppLocalizations.of(context); 189 200 final colorScheme = Theme.of(context).colorScheme; 190 201 191 202 return Padding( ··· 194 205 children: [ 195 206 Expanded( 196 207 child: Text( 197 - _showEditControls ? i18n.editTrack : 'Select Track', 208 + _showEditControls ? i18n.editTrack : l10n.titleSelectSound, 198 209 style: TextStyle( 199 210 fontSize: 18, 200 211 fontWeight: FontWeight.w600, ··· 208 219 onPressed: () => Navigator.of(context).pop(), 209 220 ), 210 221 ], 222 + ), 223 + ); 224 + } 225 + 226 + Widget _buildContinueButton() { 227 + final l10n = AppLocalizations.of(context); 228 + 229 + return SafeArea( 230 + top: false, 231 + child: Padding( 232 + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), 233 + child: SizedBox( 234 + width: double.infinity, 235 + child: ElevatedButton( 236 + onPressed: _selectedTrack == null ? null : _handleContinue, 237 + child: Text(l10n.buttonContinue), 238 + ), 239 + ), 211 240 ), 212 241 ); 213 242 }
+73 -8
lib/src/features/posting/ui/pages/recording_page.dart
··· 62 62 Future<void>? _guideAudioPrepareFuture; 63 63 String? _preparedGuideAudioUrl; 64 64 int _guideAudioPrepareRequestId = 0; 65 + int _soundPickerSessionId = 0; 65 66 66 67 // Store notifier reference for safe disposal 67 68 Recording? _recordingNotifier; ··· 656 657 await cameraNotifier.flipCamera(); 657 658 } 658 659 659 - Future<void> _playSelectedSoundGuide({bool requireRecording = true}) async { 660 + Future<void> _playSelectedSoundGuide({ 661 + bool requireRecording = true, 662 + bool Function()? canPlay, 663 + }) async { 660 664 final recordingState = ref.read(recordingProvider); 661 665 final sound = recordingState.selectedSound; 662 666 final audioUrl = sound?.audio.networkUrl; ··· 666 670 await _guideAudioPrepareFuture; 667 671 if (!mounted || 668 672 _cancelPendingRecordingStart || 673 + canPlay?.call() == false || 669 674 (requireRecording && !ref.read(recordingProvider).isRecording)) { 670 675 return; 671 676 } ··· 673 678 await _prepareSelectedSoundGuide(); 674 679 if (!mounted || 675 680 _cancelPendingRecordingStart || 681 + canPlay?.call() == false || 676 682 (requireRecording && !ref.read(recordingProvider).isRecording)) { 677 683 return; 678 684 } ··· 782 788 final recordingState = ref.read(recordingProvider); 783 789 if (recordingState.isRecording || recordingState.hasSegments) return; 784 790 785 - final selectedTrack = await showModalBottomSheet<AudioTrack>( 791 + final pickerSessionId = ++_soundPickerSessionId; 792 + await showModalBottomSheet<void>( 786 793 context: context, 787 794 isScrollControlled: true, 788 795 showDragHandle: true, 789 - builder: (context) => const _RecordingSoundPickerSheet(), 796 + builder: (context) => _RecordingSoundPickerSheet( 797 + initialSelectedTrack: recordingState.selectedSound, 798 + onTrackPreview: (track) => 799 + _previewSoundPickerTrack(track, pickerSessionId), 800 + ), 790 801 ); 791 - if (!mounted || selectedTrack == null) return; 802 + if (!mounted) return; 803 + 804 + if (_soundPickerSessionId == pickerSessionId) { 805 + _soundPickerSessionId++; 806 + } 807 + unawaited(_pauseSelectedSoundGuide()); 808 + } 792 809 793 - ref.read(recordingProvider.notifier).selectSound(selectedTrack); 794 - unawaited(_prepareSelectedSoundGuide()); 810 + bool _isSoundPickerSessionActive(int pickerSessionId) { 811 + return mounted && _soundPickerSessionId == pickerSessionId; 812 + } 813 + 814 + Future<void> _previewSoundPickerTrack( 815 + AudioTrack track, 816 + int pickerSessionId, 817 + ) async { 818 + if (!_isSoundPickerSessionActive(pickerSessionId)) return; 819 + 820 + await _pauseSelectedSoundGuide(saveOffset: false); 821 + if (!_isSoundPickerSessionActive(pickerSessionId)) return; 822 + 823 + ref.read(recordingProvider.notifier).selectSound(track); 824 + await _prepareSelectedSoundGuide(); 825 + if (!_isSoundPickerSessionActive(pickerSessionId)) return; 826 + 827 + await _playSelectedSoundGuide( 828 + requireRecording: false, 829 + canPlay: () => _isSoundPickerSessionActive(pickerSessionId), 830 + ); 795 831 } 796 832 797 833 Future<void> _clearSelectedSound() async { ··· 1043 1079 1044 1080 @override 1045 1081 void dispose() { 1082 + _soundPickerSessionId++; 1046 1083 unawaited(_guideAudioPlayer.dispose()); 1047 1084 // Defer modifying provider to avoid modifying while finalizing widget tree 1048 1085 final notifier = _recordingNotifier; ··· 1054 1091 } 1055 1092 1056 1093 class _RecordingSoundPickerSheet extends StatefulWidget { 1057 - const _RecordingSoundPickerSheet(); 1094 + const _RecordingSoundPickerSheet({ 1095 + required this.onTrackPreview, 1096 + this.initialSelectedTrack, 1097 + }); 1098 + 1099 + final AudioTrack? initialSelectedTrack; 1100 + final Future<void> Function(AudioTrack track) onTrackPreview; 1058 1101 1059 1102 @override 1060 1103 State<_RecordingSoundPickerSheet> createState() => ··· 1064 1107 class _RecordingSoundPickerSheetState 1065 1108 extends State<_RecordingSoundPickerSheet> { 1066 1109 late final Future<List<AudioTrack>> _tracksFuture = _loadTracks(); 1110 + AudioTrack? _selectedTrack; 1111 + 1112 + @override 1113 + void initState() { 1114 + super.initState(); 1115 + _selectedTrack = widget.initialSelectedTrack; 1116 + } 1067 1117 1068 1118 Future<List<AudioTrack>> _loadTracks() async { 1069 1119 final response = await GetIt.instance<SoundRepository>() ··· 1071 1121 return audioViewsToAudioTracks(response.audios); 1072 1122 } 1073 1123 1124 + void _handleTrackTap(AudioTrack track) { 1125 + setState(() { 1126 + _selectedTrack = track; 1127 + }); 1128 + unawaited(widget.onTrackPreview(track)); 1129 + } 1130 + 1074 1131 @override 1075 1132 Widget build(BuildContext context) { 1076 1133 final l10n = AppLocalizations.of(context); ··· 1117 1174 Divider(height: 1, color: colorScheme.outlineVariant), 1118 1175 itemBuilder: (context, index) { 1119 1176 final track = tracks[index]; 1177 + final isSelected = track.id == _selectedTrack?.id; 1120 1178 return ListTile( 1121 1179 leading: CircleAvatar( 1122 1180 backgroundImage: track.image?.networkUrl != null ··· 1136 1194 maxLines: 1, 1137 1195 overflow: TextOverflow.ellipsis, 1138 1196 ), 1139 - onTap: () => Navigator.of(context).pop(track), 1197 + trailing: isSelected 1198 + ? Icon( 1199 + Icons.check_circle_rounded, 1200 + color: colorScheme.primary, 1201 + ) 1202 + : null, 1203 + selected: isSelected, 1204 + onTap: () => _handleTrackTap(track), 1140 1205 ); 1141 1206 }, 1142 1207 ),