[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: sounds in recorder

+881 -87
+142 -11
lib/src/core/design_system/templates/recording_page_template.dart
··· 4 4 import 'package:flutter/services.dart'; 5 5 import 'package:spark/src/core/design_system/components/molecules/recording_button.dart'; 6 6 import 'package:spark/src/core/design_system/components/molecules/recording_timer.dart'; 7 + import 'package:spark/src/core/l10n/app_localizations.dart'; 7 8 8 9 export 'package:spark/src/core/design_system/components/molecules/recording_button.dart' 9 10 show CaptureMode; ··· 27 28 this.onTap, 28 29 this.onRecordStart, 29 30 this.onRecordStop, 31 + this.soundLabel, 32 + this.onSelectSound, 33 + this.onClearSound, 30 34 super.key, 31 35 }); 32 36 ··· 54 58 /// Called when hold ends (hybrid mode only). 55 59 final VoidCallback? onRecordStop; 56 60 61 + /// Selected recording guide sound title. 62 + final String? soundLabel; 63 + 64 + /// Called to choose or change the recording guide sound. 65 + final VoidCallback? onSelectSound; 66 + 67 + /// Called to clear the selected recording guide sound. 68 + final VoidCallback? onClearSound; 69 + 57 70 @override 58 71 Widget build(BuildContext context) { 59 72 const footerHeight = kBottomNavigationBarHeight + 12; ··· 107 120 _BottomOverlay( 108 121 onFlipCamera: canFlipCamera ? onFlipCamera : null, 109 122 onOpenLibrary: onOpenLibrary, 123 + soundLabel: soundLabel, 124 + onSelectSound: onSelectSound, 125 + onClearSound: onClearSound, 110 126 recordingButton: RecordingButton( 111 127 isRecording: isRecording, 112 128 mode: captureMode, ··· 303 319 const _BottomOverlay({ 304 320 required this.onFlipCamera, 305 321 required this.onOpenLibrary, 322 + required this.soundLabel, 323 + required this.onSelectSound, 324 + required this.onClearSound, 306 325 required this.recordingButton, 307 326 required this.bottomPadding, 308 327 }); 309 328 310 329 final VoidCallback? onFlipCamera; 311 330 final VoidCallback? onOpenLibrary; 331 + final String? soundLabel; 332 + final VoidCallback? onSelectSound; 333 + final VoidCallback? onClearSound; 312 334 final Widget recordingButton; 313 335 final double bottomPadding; 314 336 ··· 330 352 top: 40, 331 353 bottom: bottomPadding, 332 354 ), 333 - child: Row( 334 - mainAxisAlignment: MainAxisAlignment.spaceEvenly, 355 + child: Column( 356 + mainAxisSize: MainAxisSize.min, 335 357 children: [ 336 - if (onFlipCamera != null) 337 - _FlipCameraButton(onPressed: onFlipCamera!) 338 - else 339 - const SizedBox(width: 80), 340 - recordingButton, 341 - if (onOpenLibrary != null) 342 - _LibraryButton(onPressed: onOpenLibrary!) 343 - else 344 - const SizedBox(width: 80), 358 + if (onSelectSound != null) ...[ 359 + _SoundButton( 360 + label: soundLabel, 361 + onPressed: onSelectSound!, 362 + onClear: onClearSound, 363 + ), 364 + const SizedBox(height: 18), 365 + ], 366 + Row( 367 + mainAxisAlignment: MainAxisAlignment.spaceEvenly, 368 + children: [ 369 + if (onFlipCamera != null) 370 + _FlipCameraButton(onPressed: onFlipCamera!) 371 + else 372 + const SizedBox(width: 80), 373 + recordingButton, 374 + if (onOpenLibrary != null) 375 + _LibraryButton(onPressed: onOpenLibrary!) 376 + else 377 + const SizedBox(width: 80), 378 + ], 379 + ), 345 380 ], 381 + ), 382 + ), 383 + ); 384 + } 385 + } 386 + 387 + class _SoundButton extends StatelessWidget { 388 + const _SoundButton({ 389 + required this.label, 390 + required this.onPressed, 391 + required this.onClear, 392 + }); 393 + 394 + final String? label; 395 + final VoidCallback onPressed; 396 + final VoidCallback? onClear; 397 + 398 + @override 399 + Widget build(BuildContext context) { 400 + final l10n = AppLocalizations.of(context); 401 + final effectiveLabel = label ?? l10n.buttonAddSound; 402 + 403 + return Center( 404 + child: ClipRRect( 405 + borderRadius: BorderRadius.circular(999), 406 + child: BackdropFilter( 407 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 408 + child: Container( 409 + constraints: const BoxConstraints(maxWidth: 260, minHeight: 40), 410 + decoration: BoxDecoration( 411 + borderRadius: BorderRadius.circular(999), 412 + color: Colors.black.withAlpha(110), 413 + border: Border.all(color: Colors.white.withAlpha(45)), 414 + ), 415 + child: Row( 416 + mainAxisSize: MainAxisSize.min, 417 + children: [ 418 + Flexible( 419 + child: GestureDetector( 420 + behavior: HitTestBehavior.opaque, 421 + onTap: () { 422 + HapticFeedback.lightImpact(); 423 + onPressed(); 424 + }, 425 + child: Padding( 426 + padding: EdgeInsets.only( 427 + left: 14, 428 + right: onClear == null ? 14 : 8, 429 + top: 10, 430 + bottom: 10, 431 + ), 432 + child: Row( 433 + mainAxisSize: MainAxisSize.min, 434 + children: [ 435 + const Icon( 436 + Icons.music_note_rounded, 437 + color: Colors.white, 438 + size: 18, 439 + ), 440 + const SizedBox(width: 8), 441 + Flexible( 442 + child: Text( 443 + effectiveLabel, 444 + maxLines: 1, 445 + overflow: TextOverflow.ellipsis, 446 + style: const TextStyle( 447 + color: Colors.white, 448 + fontSize: 14, 449 + fontWeight: FontWeight.w600, 450 + ), 451 + ), 452 + ), 453 + ], 454 + ), 455 + ), 456 + ), 457 + ), 458 + if (onClear != null) 459 + GestureDetector( 460 + behavior: HitTestBehavior.opaque, 461 + onTap: () { 462 + HapticFeedback.lightImpact(); 463 + onClear!(); 464 + }, 465 + child: const Padding( 466 + padding: EdgeInsets.fromLTRB(4, 8, 12, 8), 467 + child: Icon( 468 + Icons.close_rounded, 469 + color: Colors.white, 470 + size: 18, 471 + ), 472 + ), 473 + ), 474 + ], 475 + ), 476 + ), 346 477 ), 347 478 ), 348 479 );
+18
lib/src/core/l10n/app_localizations.dart
··· 688 688 /// **'Done'** 689 689 String get buttonDone; 690 690 691 + /// Add sound button text 692 + /// 693 + /// In en, this message translates to: 694 + /// **'Add sound'** 695 + String get buttonAddSound; 696 + 691 697 /// Edit button text 692 698 /// 693 699 /// In en, this message translates to: ··· 892 898 /// **'No videos using this sound yet'** 893 899 String get emptyNoVideosUsingSound; 894 900 901 + /// Empty state for no selectable sounds 902 + /// 903 + /// In en, this message translates to: 904 + /// **'No sounds available'** 905 + String get emptyNoSoundsAvailable; 906 + 895 907 /// Empty state for no photos in library 896 908 /// 897 909 /// In en, this message translates to: ··· 1089 1101 /// In en, this message translates to: 1090 1102 /// **'Sound'** 1091 1103 String get labelSound; 1104 + 1105 + /// Title for the recorder sound picker 1106 + /// 1107 + /// In en, this message translates to: 1108 + /// **'Select sound'** 1109 + String get titleSelectSound; 1092 1110 1093 1111 /// Stickers label 1094 1112 ///
+9
lib/src/core/l10n/app_localizations_en.dart
··· 318 318 String get buttonDone => 'Done'; 319 319 320 320 @override 321 + String get buttonAddSound => 'Add sound'; 322 + 323 + @override 321 324 String get buttonEdit => 'Edit'; 322 325 323 326 @override ··· 430 433 String get emptyNoVideosUsingSound => 'No videos using this sound yet'; 431 434 432 435 @override 436 + String get emptyNoSoundsAvailable => 'No sounds available'; 437 + 438 + @override 433 439 String get emptyNoPhotoLibrary => 434 440 'No photos or videos found in your library.'; 435 441 ··· 538 544 539 545 @override 540 546 String get labelSound => 'Sound'; 547 + 548 + @override 549 + String get titleSelectSound => 'Select sound'; 541 550 542 551 @override 543 552 String get labelStickers => 'Stickers';
+15
lib/src/core/l10n/intl_en.arb
··· 517 517 "description": "Done button text" 518 518 }, 519 519 520 + "buttonAddSound": "Add sound", 521 + "@buttonAddSound": { 522 + "description": "Add sound button text" 523 + }, 524 + 520 525 "buttonEdit": "Edit", 521 526 "@buttonEdit": { 522 527 "description": "Edit button text" ··· 702 707 "description": "Empty state for no videos using a sound" 703 708 }, 704 709 710 + "emptyNoSoundsAvailable": "No sounds available", 711 + "@emptyNoSoundsAvailable": { 712 + "description": "Empty state for no selectable sounds" 713 + }, 714 + 705 715 "emptyNoPhotoLibrary": "No photos or videos found in your library.", 706 716 "@emptyNoPhotoLibrary": { 707 717 "description": "Empty state for no photos in library" ··· 880 890 "labelSound": "Sound", 881 891 "@labelSound": { 882 892 "description": "Sound label for video editor toolbar" 893 + }, 894 + 895 + "titleSelectSound": "Select sound", 896 + "@titleSelectSound": { 897 + "description": "Title for the recorder sound picker" 883 898 }, 884 899 885 900 "labelStickers": "Stickers",
+57
lib/src/core/pro_video_editor/models/sound_audio_track.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto/com_atproto_repo_strongref.dart'; 4 + import 'package:atproto/core.dart'; 5 + import 'package:pro_image_editor/pro_image_editor.dart'; 6 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 + 8 + const _fallbackAudioDuration = Duration(seconds: 9); 9 + 10 + AudioTrack? audioViewToAudioTrack(AudioView audio) { 11 + final audioUrl = audio.audio?.toString(); 12 + if (audioUrl == null || audioUrl.isEmpty) return null; 13 + 14 + return AudioTrack( 15 + id: encodeSoundTrackId( 16 + audio.uri.toString(), 17 + audio.cid, 18 + authorAvatar: audio.author.avatar?.toString(), 19 + ), 20 + title: audio.title, 21 + subtitle: audio.author.handle, 22 + duration: _fallbackAudioDuration, 23 + image: EditorImage(networkUrl: audio.coverArt.toString()), 24 + audio: EditorAudio(networkUrl: audioUrl), 25 + ); 26 + } 27 + 28 + List<AudioTrack> audioViewsToAudioTracks(Iterable<AudioView> audios) { 29 + return audios.map(audioViewToAudioTrack).nonNulls.toList(); 30 + } 31 + 32 + String encodeSoundTrackId(String uri, String cid, {String? authorAvatar}) { 33 + return jsonEncode({'uri': uri, 'cid': cid, 'authorAvatar': authorAvatar}); 34 + } 35 + 36 + RepoStrongRef? decodeSoundTrackStrongRef(String? encoded) { 37 + if (encoded == null) return null; 38 + try { 39 + final map = jsonDecode(encoded) as Map<String, dynamic>; 40 + return RepoStrongRef( 41 + uri: AtUri.parse(map['uri'] as String), 42 + cid: map['cid'] as String, 43 + ); 44 + } catch (_) { 45 + return null; 46 + } 47 + } 48 + 49 + String? decodeSoundTrackAuthorAvatar(String? encoded) { 50 + if (encoded == null) return null; 51 + try { 52 + final map = jsonDecode(encoded) as Map<String, dynamic>; 53 + return map['authorAvatar'] as String?; 54 + } catch (_) { 55 + return null; 56 + } 57 + }
+7 -4
lib/src/core/pro_video_editor/pro_video_editor_repository.dart
··· 2 2 3 3 import 'package:flutter/widgets.dart'; 4 4 import 'package:image_picker/image_picker.dart'; 5 + import 'package:pro_image_editor/pro_image_editor.dart'; 5 6 import 'package:pro_video_editor/pro_video_editor.dart'; 6 7 import 'package:spark/src/core/pro_image_editor/models/story_image_editor_result.dart'; 7 8 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; ··· 50 51 /// Returns `null` if the user cancels without completing an edit. 51 52 Future<VideoEditorResult?> openVideoEditor( 52 53 BuildContext context, 53 - EditorVideo video, 54 - ); 54 + EditorVideo video, { 55 + AudioTrack? initialAudioTrack, 56 + }); 55 57 56 58 /// Opens the Story Image Editor with a fixed 9:16 aspect ratio canvas. 57 59 /// ··· 84 86 /// Returns `null` if the user cancels without completing the edit. 85 87 Future<VideoEditorResult?> openStoryVideoEditor( 86 88 BuildContext context, 87 - EditorVideo video, 88 - ); 89 + EditorVideo video, { 90 + AudioTrack? initialAudioTrack, 91 + }); 89 92 }
+17 -6
lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart
··· 116 116 @override 117 117 Future<VideoEditorResult?> openVideoEditor( 118 118 BuildContext context, 119 - EditorVideo video, 120 - ) async { 119 + EditorVideo video, { 120 + AudioTrack? initialAudioTrack, 121 + }) async { 121 122 return Navigator.of(context).push<VideoEditorResult?>( 122 - MaterialPageRoute(builder: (_) => VideoEditorGroundedPage(video: video)), 123 + MaterialPageRoute( 124 + builder: (_) => VideoEditorGroundedPage( 125 + video: video, 126 + initialAudioTrack: initialAudioTrack, 127 + ), 128 + ), 123 129 ); 124 130 } 125 131 ··· 152 158 @override 153 159 Future<VideoEditorResult?> openStoryVideoEditor( 154 160 BuildContext context, 155 - EditorVideo video, 156 - ) async { 161 + EditorVideo video, { 162 + AudioTrack? initialAudioTrack, 163 + }) async { 157 164 return Navigator.of(context).push<VideoEditorResult?>( 158 165 MaterialPageRoute( 159 - builder: (_) => VideoEditorGroundedPage(video: video, storyMode: true), 166 + builder: (_) => VideoEditorGroundedPage( 167 + video: video, 168 + storyMode: true, 169 + initialAudioTrack: initialAudioTrack, 170 + ), 160 171 ), 161 172 ); 162 173 }
+91 -11
lib/src/core/pro_video_editor/services/audio_helper_service.dart
··· 4 4 import 'package:pro_video_editor/pro_video_editor.dart'; 5 5 import 'package:video_player/video_player.dart'; 6 6 7 + const _resumeSeekTolerance = Duration(milliseconds: 250); 8 + 9 + Duration syncedCustomAudioPosition({ 10 + required Duration? trackStartTime, 11 + required Duration videoPosition, 12 + required Duration videoStart, 13 + }) { 14 + final relativeVideoPosition = videoPosition - videoStart; 15 + final clampedVideoPosition = relativeVideoPosition.isNegative 16 + ? Duration.zero 17 + : relativeVideoPosition; 18 + return (trackStartTime ?? Duration.zero) + clampedVideoPosition; 19 + } 20 + 21 + bool shouldSeekCustomAudioOnResume({ 22 + required Duration? currentPosition, 23 + required Duration targetPosition, 24 + }) { 25 + if (currentPosition == null) return true; 26 + final delta = currentPosition - targetPosition; 27 + return delta.abs() > _resumeSeekTolerance; 28 + } 29 + 7 30 /// A helper service that manages audio playback alongside video playback. 8 31 class AudioHelperService { 9 32 /// Creates an instance of [AudioHelperService] for the ··· 21 44 22 45 /// Returns whether custom audio is currently active. 23 46 bool get useCustomAudio => _useCustomAudio; 47 + 48 + String? _currentTrackId; 24 49 25 50 /// Stores the last applied audio balance between video and overlay. 26 51 double _lastVolumeBalance = 0; ··· 46 71 await _audioPlayer.dispose(); 47 72 } 48 73 49 - /// Plays the given [AudioTrack] with looping enabled. 50 - Future<void> play(AudioTrack track) async { 74 + Source _sourceForTrack(AudioTrack track) { 51 75 final audio = track.audio; 52 - Source source; 53 76 if (audio.hasAssetPath) { 54 - source = AssetSource(audio.assetPath!); 55 - } else if (audio.hasFile) { 56 - source = DeviceFileSource(audio.file!.path); 57 - } else if (audio.hasNetworkUrl) { 58 - source = UrlSource(audio.networkUrl!); 59 - } else { 60 - source = BytesSource(audio.bytes!); 77 + return AssetSource(audio.assetPath!); 78 + } 79 + if (audio.hasFile) { 80 + return DeviceFileSource(audio.file!.path); 81 + } 82 + if (audio.hasNetworkUrl) { 83 + return UrlSource(audio.networkUrl!); 61 84 } 85 + return BytesSource(audio.bytes!); 86 + } 87 + 88 + /// Plays the given [AudioTrack] with looping enabled. 89 + Future<void> play( 90 + AudioTrack track, { 91 + Duration videoPosition = Duration.zero, 92 + Duration videoStart = Duration.zero, 93 + }) async { 94 + final position = syncedCustomAudioPosition( 95 + trackStartTime: track.startTime, 96 + videoPosition: videoPosition, 97 + videoStart: videoStart, 98 + ); 62 99 63 100 await _audioPlayer.setReleaseMode(ReleaseMode.loop); 64 - await _audioPlayer.play(source, position: track.startTime); 101 + if (_currentTrackId != track.id) { 102 + await _audioPlayer.setSource(_sourceForTrack(track)); 103 + _currentTrackId = track.id; 104 + await _audioPlayer.seek(position); 105 + } else if (shouldSeekCustomAudioOnResume( 106 + currentPosition: await _audioPlayer.getCurrentPosition(), 107 + targetPosition: position, 108 + )) { 109 + await _audioPlayer.seek(position); 110 + } 111 + await _audioPlayer.resume(); 112 + } 113 + 114 + Future<void> prepare( 115 + AudioTrack track, { 116 + Duration videoPosition = Duration.zero, 117 + Duration videoStart = Duration.zero, 118 + }) async { 119 + final position = syncedCustomAudioPosition( 120 + trackStartTime: track.startTime, 121 + videoPosition: videoPosition, 122 + videoStart: videoStart, 123 + ); 124 + 125 + await _audioPlayer.setReleaseMode(ReleaseMode.loop); 126 + if (_currentTrackId != track.id) { 127 + await _audioPlayer.setSource(_sourceForTrack(track)); 128 + _currentTrackId = track.id; 129 + } 130 + await _audioPlayer.seek(position); 65 131 } 66 132 67 133 /// Pauses the current audio playback. ··· 79 145 /// Seeks the audio playback to the specified [startTime]. 80 146 Future<void> seek(Duration startTime) { 81 147 return _audioPlayer.seek(startTime); 148 + } 149 + 150 + Future<void> seekToVideoPosition( 151 + AudioTrack track, { 152 + required Duration videoPosition, 153 + required Duration videoStart, 154 + }) { 155 + return seek( 156 + syncedCustomAudioPosition( 157 + trackStartTime: track.startTime, 158 + videoPosition: videoPosition, 159 + videoStart: videoStart, 160 + ), 161 + ); 82 162 } 83 163 84 164 /// Sets the audio mode to either original or custom.
+90 -49
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 1 1 import 'dart:async'; 2 - import 'dart:convert'; 3 2 import 'dart:math' as math; 4 3 5 4 import 'package:atproto/com_atproto_repo_strongref.dart'; 6 - import 'package:atproto/core.dart'; 7 5 import 'package:auto_route/auto_route.dart'; 8 6 import 'package:flutter/foundation.dart'; 9 7 import 'package:flutter/material.dart' hide ColorFilter; ··· 14 12 import 'package:pro_video_editor/pro_video_editor.dart'; 15 13 import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; 16 14 import 'package:spark/src/core/pro_image_editor/story_mention_editing.dart'; 15 + import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; 17 16 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 18 17 import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; 19 18 import 'package:spark/src/core/pro_video_editor/services/audio_waveform_extractor.dart'; ··· 30 29 const VideoEditorGroundedPage({ 31 30 required this.video, 32 31 this.storyMode = false, 32 + this.initialAudioTrack, 33 33 super.key, 34 34 }); 35 35 ··· 39 39 /// When true, uses story-specific tools (no crop/rotate/tune). 40 40 final bool storyMode; 41 41 42 + /// Optional custom sound selected before the editor opens. 43 + final AudioTrack? initialAudioTrack; 44 + 42 45 @override 43 46 State<VideoEditorGroundedPage> createState() => 44 47 _VideoEditorGroundedPageState(); ··· 79 82 80 83 /// Temporarily stores a pending trim duration span. 81 84 TrimDurationSpan? _tempDurationSpan; 85 + 86 + bool _wasPlayingBeforeTimelineSeek = false; 82 87 83 88 /// Controls video playback and trimming functionalities. 84 89 ProVideoController? _proVideoController; ··· 203 208 // Wait for completion 204 209 await metadataFuture; 205 210 _audioTracks = await trendingAudiosFuture; 211 + final initialAudioTrack = widget.initialAudioTrack; 212 + if (initialAudioTrack != null && 213 + !_audioTracks.any((track) => track.id == initialAudioTrack.id)) { 214 + _audioTracks = [initialAudioTrack, ..._audioTracks]; 215 + } 206 216 _videoController = await controllerFuture; 207 217 208 218 // Initialize audio timeline state 209 219 _videoTimelineState = VideoTimelineState( 210 220 videoDuration: _videoMetadata.duration, 211 221 ); 222 + if (initialAudioTrack != null) { 223 + _selectedSoundRef = decodeSoundTrackStrongRef(initialAudioTrack.id); 224 + } 212 225 213 226 _configs = VideoEditorConfigsBuilder.build( 214 227 video: widget.video, ··· 224 237 audioTracks: _audioTracks, 225 238 videoTimelineState: _videoTimelineState, 226 239 onSeek: _onTimelineSeek, 240 + onSeekStart: _onTimelineSeekStart, 241 + onSeekEnd: _onTimelineSeekEnd, 227 242 onTogglePlay: _onTogglePlay, 228 243 onToggleMute: _onToggleMute, 229 244 onAddSound: _showAudioSelectionBottomSheet, ··· 272 287 _videoController.addListener(_onDurationChange); 273 288 274 289 await _audioService.initialize(); 290 + if (initialAudioTrack != null) { 291 + _proVideoController?.audioTrack = initialAudioTrack; 292 + await _prepareCustomAudioForCurrentVideoPosition(initialAudioTrack); 293 + unawaited(_extractCustomAudioWaveform(initialAudioTrack)); 294 + } 275 295 276 296 setState(() {}); 277 297 } ··· 302 322 try { 303 323 final soundRepository = GetIt.instance<SoundRepository>(); 304 324 final trendingAudios = await soundRepository.getTrendingAudios(); 305 - audioTracks.addAll( 306 - trendingAudios.audios.map( 307 - (audio) => AudioTrack( 308 - id: _encodeTrackId( 309 - audio.uri.toString(), 310 - audio.cid, 311 - authorAvatar: audio.author.avatar?.toString(), 312 - ), 313 - title: audio.title, 314 - subtitle: audio.author.handle, 315 - duration: const Duration(seconds: 9), 316 - image: EditorImage(networkUrl: audio.coverArt.toString()), 317 - audio: EditorAudio(networkUrl: audio.audio?.toString()), 318 - ), 319 - ), 320 - ); 325 + audioTracks.addAll(audioViewsToAudioTracks(trendingAudios.audios)); 321 326 } catch (_) {} 322 327 return audioTracks; 323 328 } 324 329 325 - String _encodeTrackId(String uri, String cid, {String? authorAvatar}) => 326 - jsonEncode({'uri': uri, 'cid': cid, 'authorAvatar': authorAvatar}); 327 - 328 - RepoStrongRef? _decodeStrongRef(String? encoded) { 329 - if (encoded == null) return null; 330 - try { 331 - final map = jsonDecode(encoded) as Map<String, dynamic>; 332 - return RepoStrongRef( 333 - uri: AtUri.parse(map['uri'] as String), 334 - cid: map['cid'] as String, 335 - ); 336 - } catch (_) { 337 - return null; 338 - } 339 - } 340 - 341 330 String? _decodeAuthorAvatar(String? encoded) { 342 - if (encoded == null) return null; 343 - try { 344 - final map = jsonDecode(encoded) as Map<String, dynamic>; 345 - return map['authorAvatar'] as String?; 346 - } catch (_) { 347 - return null; 348 - } 331 + return decodeSoundTrackAuthorAvatar(encoded); 349 332 } 350 333 351 334 void _onDurationChange() { ··· 376 359 await _seekToTrimPosition(span, span.start); 377 360 } 378 361 362 + Duration get _playbackStart => _durationSpan?.start ?? Duration.zero; 363 + 364 + Future<void> _syncCustomAudioToVideoPosition(Duration position) async { 365 + final audioTrack = _proVideoController?.audioTrack; 366 + if (audioTrack == null) return; 367 + await _audioService.seekToVideoPosition( 368 + audioTrack, 369 + videoPosition: position, 370 + videoStart: _playbackStart, 371 + ); 372 + } 373 + 374 + Future<void> _playCustomAudioForCurrentVideoPosition(AudioTrack track) async { 375 + await _audioService.play( 376 + track, 377 + videoPosition: _videoController.value.position, 378 + videoStart: _playbackStart, 379 + ); 380 + } 381 + 382 + Future<void> _prepareCustomAudioForCurrentVideoPosition( 383 + AudioTrack track, 384 + ) async { 385 + await _audioService.prepare( 386 + track, 387 + videoPosition: _videoController.value.position, 388 + videoStart: _playbackStart, 389 + ); 390 + } 391 + 379 392 Future<void> _seekToTrimPosition( 380 393 TrimDurationSpan span, 381 394 Duration targetPosition, ··· 392 405 _proVideoController!.setPlayTime(targetPosition); 393 406 394 407 await _videoController.pause(); 408 + await _audioService.pause(); 395 409 await _videoController.seekTo(targetPosition); 410 + await _syncCustomAudioToVideoPosition(targetPosition); 396 411 _videoTimelineState.setProgressFromDuration(targetPosition); 397 412 398 413 _isSeeking = false; ··· 415 430 ); 416 431 417 432 _videoController.seekTo(targetPosition); 433 + if (!_wasPlayingBeforeTimelineSeek) { 434 + unawaited(_syncCustomAudioToVideoPosition(targetPosition)); 435 + } 418 436 _proVideoController?.setPlayTime(targetPosition); 419 437 _videoTimelineState.setProgressFromDuration(targetPosition); 420 438 } 421 439 440 + void _onTimelineSeekStart() { 441 + _wasPlayingBeforeTimelineSeek = _videoController.value.isPlaying; 442 + if (_wasPlayingBeforeTimelineSeek) { 443 + _proVideoController?.pause(); 444 + } 445 + } 446 + 447 + void _onTimelineSeekEnd() { 448 + final position = _videoController.value.position; 449 + unawaited(_syncCustomAudioToVideoPosition(position)); 450 + _wasPlayingBeforeTimelineSeek = false; 451 + } 452 + 422 453 void _onTogglePlay() { 423 454 _proVideoController?.togglePlayState(); 424 455 } ··· 529 560 await _audioService.balanceAudio(balance); 530 561 }, 531 562 onStartTimeChanged: (startTime) async { 532 - await _audioService.seek(startTime); 533 - await _videoController.seekTo(Duration.zero); 563 + await Future.wait([ 564 + _audioService.seek(startTime), 565 + _videoController.seekTo(Duration.zero), 566 + ]); 534 567 }, 535 568 onConfirm: (track) { 536 569 if (track != null) { 537 570 _proVideoController?.audioTrack = track; 571 + unawaited(_prepareCustomAudioForCurrentVideoPosition(track)); 538 572 unawaited(_extractCustomAudioWaveform(track)); 539 573 } 540 574 }, ··· 567 601 unawaited(_audioService.pause()); 568 602 569 603 final customAudioTrack = parameters.customAudioTrack; 570 - _selectedSoundRef = _decodeStrongRef(customAudioTrack?.id); 604 + _selectedSoundRef = decodeSoundTrackStrongRef(customAudioTrack?.id); 571 605 final sourceVideoPath = await _video.safeFilePath(); 572 606 final shouldCompressForUpload = await _shouldCompressForUpload( 573 607 sourceVideoPath, ··· 620 654 VideoAudioTrack( 621 655 path: customAudioPath, 622 656 volume: overlayVolume, 657 + audioStartTime: customAudioTrack?.startTime, 623 658 loop: true, 624 659 ), 625 660 ] ··· 857 892 }, 858 893 onPlay: () { 859 894 _shouldResetOnPlaybackComplete = true; 860 - _videoController.play(); 895 + if (_proVideoController?.audioTrack == null) { 896 + _videoController.play(); 897 + } 861 898 _videoTimelineState.setPlaying(isPlaying: true); 862 899 }, 863 900 onMuteToggle: (isMuted) async { ··· 880 917 await _audioService.balanceAudio(value); 881 918 }, 882 919 onStartTimeChange: (startTime) async { 883 - await Future.value([ 920 + await Future.wait([ 884 921 _audioService.seek(startTime), 885 922 _videoController.seekTo(Duration.zero), 886 923 ]); 887 924 }, 888 925 onPlay: (audio) async { 889 926 final isNewTrack = !_audioService.useCustomAudio; 890 - await _audioService.play(audio); 891 927 if (isNewTrack) { 892 928 await _audioService.setAudioMode(useCustom: true); 893 - unawaited(_extractCustomAudioWaveform(audio)); 894 929 } else { 895 - // Resume with current balance 896 930 await _audioService.balanceAudio(); 931 + } 932 + await _playCustomAudioForCurrentVideoPosition(audio); 933 + if (!_videoController.value.isPlaying) { 934 + await _videoController.play(); 935 + } 936 + if (isNewTrack) { 937 + unawaited(_extractCustomAudioWaveform(audio)); 897 938 } 898 939 }, 899 940 onStop: (audio) async {
+4
lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart
··· 62 62 required Widget Function() videoPlayerBuilder, 63 63 required VideoTimelineState videoTimelineState, 64 64 required void Function(double progress) onSeek, 65 + required VoidCallback onSeekStart, 66 + required VoidCallback onSeekEnd, 65 67 required VoidCallback onTogglePlay, 66 68 required VoidCallback onToggleMute, 67 69 required VoidCallback onAddSound, ··· 120 122 editor: editor, 121 123 videoTimelineState: videoTimelineState, 122 124 onSeek: onSeek, 125 + onSeekStart: onSeekStart, 126 + onSeekEnd: onSeekEnd, 123 127 onTogglePlay: onTogglePlay, 124 128 onToggleMute: onToggleMute, 125 129 onAddSound: onAddSound,
+6
lib/src/core/pro_video_editor/ui/widgets/layout/video_editor_bottom_section.dart
··· 14 14 required this.onToggleMute, 15 15 required this.onAddSound, 16 16 required this.onToggleFullscreen, 17 + required this.onSeekStart, 18 + required this.onSeekEnd, 17 19 this.onTrimChanged, 18 20 this.onTrimEnd, 19 21 super.key, ··· 26 28 final VoidCallback onToggleMute; 27 29 final VoidCallback onAddSound; 28 30 final VoidCallback onToggleFullscreen; 31 + final VoidCallback onSeekStart; 32 + final VoidCallback onSeekEnd; 29 33 final void Function(double start, double end)? onTrimChanged; 30 34 final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 31 35 ··· 44 48 onToggleMute: onToggleMute, 45 49 onAddSound: onAddSound, 46 50 onSeek: onSeek, 51 + onSeekStart: onSeekStart, 52 + onSeekEnd: onSeekEnd, 47 53 onToggleFullscreen: onToggleFullscreen, 48 54 onTrimChanged: onTrimChanged, 49 55 onTrimEnd: onTrimEnd,
+8 -1
lib/src/core/pro_video_editor/ui/widgets/timeline/scrollable_timeline.dart
··· 19 19 required this.videoTimelineState, 20 20 required this.onSeek, 21 21 required this.onAddSound, 22 + this.onSeekStart, 23 + this.onSeekEnd, 22 24 this.onTrimChanged, 23 25 this.onTrimEnd, 24 26 this.thumbnailHeight = 56, ··· 31 33 final VideoTimelineState videoTimelineState; 32 34 final void Function(double progress) onSeek; 33 35 final VoidCallback onAddSound; 36 + final VoidCallback? onSeekStart; 37 + final VoidCallback? onSeekEnd; 34 38 final void Function(double start, double end)? onTrimChanged; 35 39 final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 36 40 final double thumbnailHeight; ··· 253 257 if (!_isProgrammaticScroll) { 254 258 _scrollEndTimer?.cancel(); 255 259 _isUserScrolling = true; 260 + widget.onSeekStart?.call(); 256 261 } 257 262 } else if (notification is ScrollEndNotification) { 258 263 _scrollEndTimer?.cancel(); 259 264 _scrollEndTimer = Timer( 260 265 const Duration(milliseconds: 300), 261 266 () { 262 - if (mounted) _isUserScrolling = false; 267 + if (!mounted) return; 268 + _isUserScrolling = false; 269 + widget.onSeekEnd?.call(); 263 270 }, 264 271 ); 265 272 } else if (notification is ScrollUpdateNotification) {
+12
lib/src/core/pro_video_editor/ui/widgets/timeline/video_timeline.dart
··· 14 14 required this.onToggleMute, 15 15 required this.onAddSound, 16 16 required this.onSeek, 17 + required this.onSeekStart, 18 + required this.onSeekEnd, 17 19 required this.onToggleFullscreen, 18 20 required this.canUndo, 19 21 required this.canRedo, ··· 29 31 final VoidCallback onToggleMute; 30 32 final VoidCallback onAddSound; 31 33 final void Function(double progress) onSeek; 34 + final VoidCallback onSeekStart; 35 + final VoidCallback onSeekEnd; 32 36 final VoidCallback onToggleFullscreen; 33 37 final bool canUndo; 34 38 final bool canRedo; ··· 61 65 onToggleMute: onToggleMute, 62 66 onAddSound: onAddSound, 63 67 onSeek: onSeek, 68 + onSeekStart: onSeekStart, 69 + onSeekEnd: onSeekEnd, 64 70 onTrimChanged: onTrimChanged, 65 71 onTrimEnd: onTrimEnd, 66 72 isMuted: videoTimelineState.isMuted, ··· 205 211 required this.onToggleMute, 206 212 required this.onAddSound, 207 213 required this.onSeek, 214 + required this.onSeekStart, 215 + required this.onSeekEnd, 208 216 required this.isMuted, 209 217 this.onTrimChanged, 210 218 this.onTrimEnd, ··· 214 222 final VoidCallback onToggleMute; 215 223 final VoidCallback onAddSound; 216 224 final void Function(double progress) onSeek; 225 + final VoidCallback onSeekStart; 226 + final VoidCallback onSeekEnd; 217 227 final bool isMuted; 218 228 final void Function(double start, double end)? onTrimChanged; 219 229 final void Function(double start, double end, bool isStartHandle)? onTrimEnd; ··· 230 240 child: ScrollableTimeline( 231 241 videoTimelineState: videoTimelineState, 232 242 onSeek: onSeek, 243 + onSeekStart: onSeekStart, 244 + onSeekEnd: onSeekEnd, 233 245 onAddSound: onAddSound, 234 246 onTrimChanged: onTrimChanged, 235 247 onTrimEnd: onTrimEnd,
+1
lib/src/core/routing/app_router.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:get_it/get_it.dart'; 6 6 import 'package:image_picker/image_picker.dart'; 7 + import 'package:pro_image_editor/pro_image_editor.dart'; 7 8 import 'package:pro_video_editor/pro_video_editor.dart'; 8 9 import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 9 10 import 'package:spark/src/core/auth/data/repositories/onboarding_repository.dart';
+21
lib/src/features/posting/providers/recording_provider.dart
··· 2 2 import 'dart:io'; 3 3 4 4 import 'package:image_picker/image_picker.dart'; 5 + import 'package:pro_image_editor/pro_image_editor.dart'; 5 6 import 'package:riverpod_annotation/riverpod_annotation.dart'; 6 7 import 'package:spark/src/features/posting/providers/recording_state.dart'; 7 8 ··· 58 59 segmentPaths: [...state.segmentPaths, file.path], 59 60 error: null, 60 61 ); 62 + } 63 + 64 + void selectSound(AudioTrack sound) { 65 + state = state.copyWith( 66 + selectedSound: sound, 67 + soundGuideOffset: Duration.zero, 68 + error: null, 69 + ); 70 + } 71 + 72 + void clearSound() { 73 + state = state.copyWith( 74 + selectedSound: null, 75 + soundGuideOffset: Duration.zero, 76 + error: null, 77 + ); 78 + } 79 + 80 + void setSoundGuideOffset(Duration offset) { 81 + state = state.copyWith(soundGuideOffset: offset, error: null); 61 82 } 62 83 63 84 Future<void> discardSession({Iterable<String> keepPaths = const []}) async {
+4
lib/src/features/posting/providers/recording_state.dart
··· 1 1 import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:pro_image_editor/pro_image_editor.dart'; 2 3 3 4 part 'recording_state.freezed.dart'; 4 5 ··· 9 10 @Default(Duration.zero) Duration elapsedDuration, 10 11 @Default(Duration(minutes: 3)) Duration maxDuration, 11 12 @Default(<String>[]) List<String> segmentPaths, 13 + AudioTrack? selectedSound, 14 + @Default(Duration.zero) Duration soundGuideOffset, 12 15 String? error, 13 16 }) = _RecordingState; 14 17 ··· 16 19 17 20 bool get hasReachedMaxDuration => elapsedDuration >= maxDuration; 18 21 bool get hasSegments => segmentPaths.isNotEmpty; 22 + bool get hasSelectedSound => selectedSound != null; 19 23 bool get canResume => hasSegments && !isRecording && !hasReachedMaxDuration; 20 24 bool get canFinalize => hasSegments && !isRecording; 21 25 int get segmentCount => segmentPaths.length;
+219 -5
lib/src/features/posting/ui/pages/recording_page.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:io'; 3 3 4 + import 'package:audioplayers/audioplayers.dart'; 4 5 import 'package:auto_route/auto_route.dart'; 5 6 import 'package:camera/camera.dart'; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 9 import 'package:get_it/get_it.dart'; 10 + import 'package:pro_image_editor/pro_image_editor.dart'; 9 11 import 'package:pro_video_editor/pro_video_editor.dart'; 10 - import 'package:spark/src/core/l10n/app_localizations.dart'; 11 12 import 'package:spark/src/core/design_system/templates/recording_page_template.dart'; 13 + import 'package:spark/src/core/l10n/app_localizations.dart'; 14 + import 'package:spark/src/core/network/atproto/data/models/models.dart'; 15 + import 'package:spark/src/core/network/atproto/data/repositories/sound_repository.dart'; 16 + import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; 12 17 import 'package:spark/src/core/pro_video_editor/models/video_editor_result.dart'; 13 18 import 'package:spark/src/core/pro_video_editor/pro_video_editor_repository.dart'; 14 19 import 'package:spark/src/core/routing/app_router.dart'; ··· 28 33 const RecordingPage({ 29 34 required this.storyMode, 30 35 this.captureMode = CaptureMode.videoOnly, 36 + this.initialSound, 31 37 super.key, 32 38 }); 33 39 ··· 38 44 /// - [CaptureMode.hybrid]: tap for photo, hold for video 39 45 final CaptureMode captureMode; 40 46 47 + /// Optional sound selected before opening the recorder. 48 + final AudioView? initialSound; 49 + 41 50 @override 42 51 ConsumerState<RecordingPage> createState() => _RecordingPageState(); 43 52 } ··· 47 56 bool _isProcessing = false; 48 57 bool _isExiting = false; 49 58 bool _isFinalizingRecordingSession = false; 59 + late final AudioPlayer _guideAudioPlayer; 50 60 51 61 // Store notifier reference for safe disposal 52 62 Recording? _recordingNotifier; ··· 56 66 super.initState(); 57 67 _logger = GetIt.instance<LogService>().getLogger('RecordingPage'); 58 68 _recordingNotifier = ref.read(recordingProvider.notifier); 69 + _guideAudioPlayer = AudioPlayer(); 70 + final initialSound = widget.initialSound; 71 + if (initialSound != null) { 72 + WidgetsBinding.instance.addPostFrameCallback((_) { 73 + if (!mounted) return; 74 + final initialTrack = audioViewToAudioTrack(initialSound); 75 + if (initialTrack != null) { 76 + ref.read(recordingProvider.notifier).selectSound(initialTrack); 77 + } 78 + }); 79 + } 59 80 } 60 81 61 82 bool _hasCameras() { ··· 306 327 cameraNotifier.startVideoRecording().then((success) { 307 328 if (!success && mounted) { 308 329 recordingNotifier.stopRecording(); 330 + unawaited(_pauseSelectedSoundGuide()); 331 + return; 332 + } 333 + if (success && mounted) { 334 + unawaited(_playSelectedSoundGuide()); 309 335 } 310 336 }); 311 337 } ··· 320 346 final cameraNotifier = ref.read(cameraProvider.notifier); 321 347 final recordingNotifier = ref.read(recordingProvider.notifier) 322 348 ..stopRecording(); 349 + unawaited(_pauseSelectedSoundGuide()); 323 350 324 351 // Defer heavy stop so the "processing" frame paints before blocking 325 352 WidgetsBinding.instance.addPostFrameCallback((_) async { ··· 379 406 final stitchedVideo = await repository.stitchVideoSegments(segments); 380 407 if (!mounted) return; 381 408 409 + final selectedSound = recordingState.selectedSound; 410 + 382 411 await ref 383 412 .read(recordingProvider.notifier) 384 413 .discardSession(keepPaths: {stitchedVideo.path}); 385 414 386 415 if (!mounted) return; 387 - await _processVideo(stitchedVideo); 416 + await _processVideo(stitchedVideo, initialAudioTrack: selectedSound); 388 417 } catch (e, stackTrace) { 389 418 _logger.e( 390 419 'Error stitching recorded video segments', ··· 407 436 } 408 437 } 409 438 410 - Future<void> _processVideo(XFile videoFile) async { 439 + Future<void> _processVideo( 440 + XFile videoFile, { 441 + AudioTrack? initialAudioTrack, 442 + }) async { 411 443 if (!mounted) return; 412 444 413 445 try { ··· 427 459 VideoEditorResult? result; 428 460 if (widget.storyMode) { 429 461 if (!context.mounted) return; 430 - result = await repository.openStoryVideoEditor(context, editorVideo); 462 + result = await repository.openStoryVideoEditor( 463 + context, 464 + editorVideo, 465 + initialAudioTrack: 466 + initialAudioTrack ?? ref.read(recordingProvider).selectedSound, 467 + ); 431 468 } else { 432 469 if (!context.mounted) return; 433 - result = await repository.openVideoEditor(context, editorVideo); 470 + result = await repository.openVideoEditor( 471 + context, 472 + editorVideo, 473 + initialAudioTrack: 474 + initialAudioTrack ?? ref.read(recordingProvider).selectedSound, 475 + ); 434 476 } 435 477 436 478 if (!mounted) return; ··· 525 567 await cameraNotifier.flipCamera(); 526 568 } 527 569 570 + Future<void> _playSelectedSoundGuide() async { 571 + final recordingState = ref.read(recordingProvider); 572 + final sound = recordingState.selectedSound; 573 + final audioUrl = sound?.audio.networkUrl; 574 + if (sound == null || audioUrl == null || audioUrl.isEmpty) return; 575 + 576 + try { 577 + await _guideAudioPlayer.play( 578 + UrlSource(audioUrl), 579 + position: recordingState.soundGuideOffset, 580 + ); 581 + } catch (e, stackTrace) { 582 + _logger.e( 583 + 'Error playing recording guide sound', 584 + error: e, 585 + stackTrace: stackTrace, 586 + ); 587 + } 588 + } 589 + 590 + Future<void> _pauseSelectedSoundGuide() async { 591 + try { 592 + final position = await _guideAudioPlayer.getCurrentPosition(); 593 + await _guideAudioPlayer.pause(); 594 + if (position != null && mounted) { 595 + ref.read(recordingProvider.notifier).setSoundGuideOffset(position); 596 + } 597 + } catch (e, stackTrace) { 598 + _logger.e( 599 + 'Error pausing recording guide sound', 600 + error: e, 601 + stackTrace: stackTrace, 602 + ); 603 + } 604 + } 605 + 606 + Future<void> _showSoundPicker() async { 607 + if (_isProcessing) return; 608 + 609 + final recordingState = ref.read(recordingProvider); 610 + if (recordingState.isRecording || recordingState.hasSegments) return; 611 + 612 + final selectedTrack = await showModalBottomSheet<AudioTrack>( 613 + context: context, 614 + isScrollControlled: true, 615 + showDragHandle: true, 616 + builder: (context) => const _RecordingSoundPickerSheet(), 617 + ); 618 + if (!mounted || selectedTrack == null) return; 619 + 620 + ref.read(recordingProvider.notifier).selectSound(selectedTrack); 621 + } 622 + 623 + Future<void> _clearSelectedSound() async { 624 + if (_isProcessing) return; 625 + 626 + final recordingState = ref.read(recordingProvider); 627 + if (recordingState.isRecording || recordingState.hasSegments) return; 628 + 629 + await _pauseSelectedSoundGuide(); 630 + await _guideAudioPlayer.stop(); 631 + if (!mounted) return; 632 + ref.read(recordingProvider.notifier).clearSound(); 633 + } 634 + 528 635 @override 529 636 Widget build(BuildContext context) { 530 637 final cameraAsync = ref.watch(cameraProvider); ··· 650 757 final aspectRatio = cameraState.controller!.value.aspectRatio; 651 758 final canFinalizeSession = 652 759 recordingState.canFinalize && !_isProcessing && hasCameras; 760 + final canChangeSound = 761 + !_isProcessing && 762 + !recordingState.isRecording && 763 + !recordingState.hasSegments; 653 764 final onTap = 654 765 _isProcessing || 655 766 (widget.captureMode == CaptureMode.hybrid && ··· 691 802 recordingState.hasSegments 692 803 ? null 693 804 : _openMediaLibraryPicker, 805 + soundLabel: recordingState.selectedSound?.title, 806 + onSelectSound: canChangeSound ? _showSoundPicker : null, 807 + onClearSound: canChangeSound && recordingState.hasSelectedSound 808 + ? _clearSelectedSound 809 + : null, 694 810 ), 695 811 if (cameraState.isFlipping) 696 812 const Positioned.fill( ··· 744 860 745 861 @override 746 862 void dispose() { 863 + unawaited(_guideAudioPlayer.dispose()); 747 864 // Defer modifying provider to avoid modifying while finalizing widget tree 748 865 final notifier = _recordingNotifier; 749 866 if (notifier != null) { ··· 752 869 super.dispose(); 753 870 } 754 871 } 872 + 873 + class _RecordingSoundPickerSheet extends StatefulWidget { 874 + const _RecordingSoundPickerSheet(); 875 + 876 + @override 877 + State<_RecordingSoundPickerSheet> createState() => 878 + _RecordingSoundPickerSheetState(); 879 + } 880 + 881 + class _RecordingSoundPickerSheetState 882 + extends State<_RecordingSoundPickerSheet> { 883 + late final Future<List<AudioTrack>> _tracksFuture = _loadTracks(); 884 + 885 + Future<List<AudioTrack>> _loadTracks() async { 886 + final response = await GetIt.instance<SoundRepository>() 887 + .getTrendingAudios(); 888 + return audioViewsToAudioTracks(response.audios); 889 + } 890 + 891 + @override 892 + Widget build(BuildContext context) { 893 + final l10n = AppLocalizations.of(context); 894 + final colorScheme = Theme.of(context).colorScheme; 895 + 896 + return SafeArea( 897 + child: SizedBox( 898 + height: MediaQuery.sizeOf(context).height * 0.62, 899 + child: FutureBuilder<List<AudioTrack>>( 900 + future: _tracksFuture, 901 + builder: (context, snapshot) { 902 + final tracks = snapshot.data; 903 + 904 + return Column( 905 + crossAxisAlignment: CrossAxisAlignment.stretch, 906 + children: [ 907 + Padding( 908 + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), 909 + child: Text( 910 + l10n.titleSelectSound, 911 + style: Theme.of(context).textTheme.titleLarge, 912 + ), 913 + ), 914 + if (snapshot.connectionState != ConnectionState.done) 915 + const Expanded( 916 + child: Center(child: CircularProgressIndicator()), 917 + ) 918 + else if (snapshot.hasError) 919 + Expanded(child: Center(child: Text(l10n.errorLoadingSound))) 920 + else if (tracks == null || tracks.isEmpty) 921 + Expanded( 922 + child: Center( 923 + child: Text( 924 + l10n.emptyNoSoundsAvailable, 925 + style: TextStyle(color: colorScheme.onSurfaceVariant), 926 + ), 927 + ), 928 + ) 929 + else 930 + Expanded( 931 + child: ListView.separated( 932 + itemCount: tracks.length, 933 + separatorBuilder: (context, index) => 934 + Divider(height: 1, color: colorScheme.outlineVariant), 935 + itemBuilder: (context, index) { 936 + final track = tracks[index]; 937 + return ListTile( 938 + leading: CircleAvatar( 939 + backgroundImage: track.image?.networkUrl != null 940 + ? NetworkImage(track.image!.networkUrl!) 941 + : null, 942 + child: track.image?.networkUrl == null 943 + ? const Icon(Icons.music_note_rounded) 944 + : null, 945 + ), 946 + title: Text( 947 + track.title, 948 + maxLines: 1, 949 + overflow: TextOverflow.ellipsis, 950 + ), 951 + subtitle: Text( 952 + '@${track.subtitle}', 953 + maxLines: 1, 954 + overflow: TextOverflow.ellipsis, 955 + ), 956 + onTap: () => Navigator.of(context).pop(track), 957 + ); 958 + }, 959 + ), 960 + ), 961 + ], 962 + ); 963 + }, 964 + ), 965 + ), 966 + ); 967 + } 968 + }
+17
lib/src/features/sound/ui/pages/sound_page.dart
··· 60 60 title: Text(l10n.pageTitleSound), 61 61 elevation: 0, 62 62 leading: const AppLeadingButton(), 63 + actions: [ 64 + soundState.maybeWhen( 65 + data: (state) => IconButton( 66 + tooltip: l10n.buttonAddSound, 67 + onPressed: state.audio.audio == null 68 + ? null 69 + : () => context.router.push( 70 + RecordingRoute( 71 + storyMode: false, 72 + initialSound: state.audio, 73 + ), 74 + ), 75 + icon: const Icon(FluentIcons.camera_24_regular), 76 + ), 77 + orElse: () => const SizedBox(width: 48), 78 + ), 79 + ], 63 80 ), 64 81 body: soundState.when( 65 82 data: (state) => RefreshIndicator(
+82
test/src/core/pro_video_editor/services/audio_helper_service_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:spark/src/core/pro_video_editor/services/audio_helper_service.dart'; 3 + 4 + void main() { 5 + group('syncedCustomAudioPosition', () { 6 + test('starts custom audio at selected sound offset when video starts', () { 7 + expect( 8 + syncedCustomAudioPosition( 9 + trackStartTime: const Duration(seconds: 4), 10 + videoPosition: Duration.zero, 11 + videoStart: Duration.zero, 12 + ), 13 + const Duration(seconds: 4), 14 + ); 15 + }); 16 + 17 + test('adds the current video position to the selected sound offset', () { 18 + expect( 19 + syncedCustomAudioPosition( 20 + trackStartTime: const Duration(seconds: 4), 21 + videoPosition: const Duration(seconds: 7), 22 + videoStart: Duration.zero, 23 + ), 24 + const Duration(seconds: 11), 25 + ); 26 + }); 27 + 28 + test('uses position relative to trim start', () { 29 + expect( 30 + syncedCustomAudioPosition( 31 + trackStartTime: const Duration(seconds: 4), 32 + videoPosition: const Duration(seconds: 12), 33 + videoStart: const Duration(seconds: 10), 34 + ), 35 + const Duration(seconds: 6), 36 + ); 37 + }); 38 + 39 + test('clamps positions before trim start to the selected sound offset', () { 40 + expect( 41 + syncedCustomAudioPosition( 42 + trackStartTime: const Duration(seconds: 4), 43 + videoPosition: const Duration(seconds: 8), 44 + videoStart: const Duration(seconds: 10), 45 + ), 46 + const Duration(seconds: 4), 47 + ); 48 + }); 49 + }); 50 + 51 + group('shouldSeekCustomAudioOnResume', () { 52 + test('seeks when current position is unavailable', () { 53 + expect( 54 + shouldSeekCustomAudioOnResume( 55 + currentPosition: null, 56 + targetPosition: const Duration(seconds: 3), 57 + ), 58 + isTrue, 59 + ); 60 + }); 61 + 62 + test('does not seek when already close to the target position', () { 63 + expect( 64 + shouldSeekCustomAudioOnResume( 65 + currentPosition: const Duration(milliseconds: 3100), 66 + targetPosition: const Duration(seconds: 3), 67 + ), 68 + isFalse, 69 + ); 70 + }); 71 + 72 + test('seeks when current position is far from the target position', () { 73 + expect( 74 + shouldSeekCustomAudioOnResume( 75 + currentPosition: const Duration(seconds: 5), 76 + targetPosition: const Duration(seconds: 3), 77 + ), 78 + isTrue, 79 + ); 80 + }); 81 + }); 82 + }
+61
test/src/features/posting/providers/recording_provider_test.dart
··· 3 3 import 'package:flutter_test/flutter_test.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:image_picker/image_picker.dart'; 6 + import 'package:pro_image_editor/pro_image_editor.dart'; 6 7 import 'package:spark/src/features/posting/providers/recording_provider.dart'; 7 8 8 9 void main() { 9 10 group('RecordingProvider', () { 11 + AudioTrack createTrack(String id) { 12 + return AudioTrack( 13 + id: id, 14 + title: 'Test sound', 15 + subtitle: 'tester', 16 + duration: const Duration(seconds: 9), 17 + audio: EditorAudio(networkUrl: 'https://example.com/sound.mp3'), 18 + ); 19 + } 20 + 10 21 test('accumulates elapsed time across resumed segments', () async { 11 22 final container = ProviderContainer(); 12 23 addTearDown(container.dispose); ··· 49 60 expect(resumedState.canFinalize, isTrue); 50 61 }); 51 62 63 + test('stores selected sound and preserves guide offset', () { 64 + final container = ProviderContainer(); 65 + addTearDown(container.dispose); 66 + final subscription = container.listen( 67 + recordingProvider, 68 + (previous, next) {}, 69 + ); 70 + addTearDown(subscription.close); 71 + 72 + final notifier = container.read(recordingProvider.notifier); 73 + final track = createTrack('sound-1'); 74 + 75 + notifier.selectSound(track); 76 + notifier.setSoundGuideOffset(const Duration(seconds: 3)); 77 + 78 + final state = container.read(recordingProvider); 79 + expect(state.selectedSound, same(track)); 80 + expect(state.hasSelectedSound, isTrue); 81 + expect(state.soundGuideOffset, const Duration(seconds: 3)); 82 + }); 83 + 84 + test('clearSound removes selected sound and resets guide offset', () { 85 + final container = ProviderContainer(); 86 + addTearDown(container.dispose); 87 + final subscription = container.listen( 88 + recordingProvider, 89 + (previous, next) {}, 90 + ); 91 + addTearDown(subscription.close); 92 + 93 + final notifier = container.read(recordingProvider.notifier); 94 + notifier.selectSound(createTrack('sound-1')); 95 + notifier.setSoundGuideOffset(const Duration(seconds: 3)); 96 + 97 + notifier.clearSound(); 98 + 99 + final state = container.read(recordingProvider); 100 + expect(state.selectedSound, isNull); 101 + expect(state.hasSelectedSound, isFalse); 102 + expect(state.soundGuideOffset, Duration.zero); 103 + }); 104 + 52 105 test( 53 106 'discardSession deletes temporary files while preserving keepPaths', 54 107 () async { ··· 75 128 ..writeAsStringSync('segment-2'); 76 129 77 130 final notifier = container.read(recordingProvider.notifier); 131 + final track = createTrack('sound-1'); 132 + notifier.selectSound(track); 133 + notifier.setSoundGuideOffset(const Duration(seconds: 2)); 78 134 notifier.addSegment(XFile(firstFile.path)); 79 135 notifier.addSegment(XFile(secondFile.path)); 80 136 ··· 83 139 expect(await firstFile.exists(), isFalse); 84 140 expect(await secondFile.exists(), isTrue); 85 141 expect(container.read(recordingProvider).segmentPaths, isEmpty); 142 + expect(container.read(recordingProvider).selectedSound, isNull); 143 + expect( 144 + container.read(recordingProvider).soundGuideOffset, 145 + Duration.zero, 146 + ); 86 147 }, 87 148 ); 88 149 });