[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.

fix: custom audio timing in editor

+371 -44
+19 -1
lib/src/core/pro_video_editor/services/audio_helper_service.dart
··· 15 15 final clampedVideoPosition = relativeVideoPosition.isNegative 16 16 ? Duration.zero 17 17 : relativeVideoPosition; 18 - return (trackStartTime ?? Duration.zero) + clampedVideoPosition; 18 + return customAudioRenderStartTime( 19 + trackStartTime: trackStartTime, 20 + videoStart: videoStart, 21 + ) + 22 + clampedVideoPosition; 23 + } 24 + 25 + Duration customAudioRenderStartTime({ 26 + required Duration? trackStartTime, 27 + required Duration videoStart, 28 + }) { 29 + return (trackStartTime ?? Duration.zero) + videoStart; 30 + } 31 + 32 + Duration customAudioExportStartTime({required Duration? trackStartTime}) { 33 + return trackStartTime ?? Duration.zero; 19 34 } 20 35 21 36 bool shouldSeekCustomAudioOnResume({ ··· 90 105 AudioTrack track, { 91 106 Duration videoPosition = Duration.zero, 92 107 Duration videoStart = Duration.zero, 108 + bool forceSeek = false, 93 109 }) async { 94 110 final position = syncedCustomAudioPosition( 95 111 trackStartTime: track.startTime, ··· 101 117 if (_currentTrackId != track.id) { 102 118 await _audioPlayer.setSource(_sourceForTrack(track)); 103 119 _currentTrackId = track.id; 120 + await _audioPlayer.seek(position); 121 + } else if (forceSeek) { 104 122 await _audioPlayer.seek(position); 105 123 } else if (shouldSeekCustomAudioOnResume( 106 124 currentPosition: await _audioPlayer.getCurrentPosition(),
+63 -13
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 53 53 static const _uploadCompressionMinFileSizeBytes = 25 * 1024 * 1024; 54 54 static const _uploadCompressionBitrate = 3000000; 55 55 static const _uploadCompressionMaxLongEdge = 1920.0; 56 + static const _videoPlaybackStartPollInterval = Duration(milliseconds: 10); 57 + static const _videoPlaybackStartWaitTimeout = Duration(milliseconds: 220); 56 58 57 59 final _editorKey = GlobalKey<ProImageEditorState>(); 58 60 final bool _useMaterialDesign = ··· 361 363 362 364 Duration get _playbackStart => _durationSpan?.start ?? Duration.zero; 363 365 366 + Duration _playablePosition(Duration position) { 367 + final span = _durationSpan; 368 + if (span == null) return position; 369 + if (position < span.start || position >= span.end) { 370 + return span.start; 371 + } 372 + return position; 373 + } 374 + 375 + Future<Duration> _ensurePlayableVideoPosition() async { 376 + final position = _videoController.value.position; 377 + final playablePosition = _playablePosition(position); 378 + if (playablePosition == position) return position; 379 + 380 + await _videoController.seekTo(playablePosition); 381 + _proVideoController?.setPlayTime(playablePosition); 382 + _videoTimelineState.setProgressFromDuration(playablePosition); 383 + return playablePosition; 384 + } 385 + 386 + Future<void> _seekToPlaybackStartWithCustomAudio() async { 387 + final playbackStart = _playbackStart; 388 + await _videoController.seekTo(playbackStart); 389 + _proVideoController?.setPlayTime(playbackStart); 390 + _videoTimelineState.setProgressFromDuration(playbackStart); 391 + await _syncCustomAudioToVideoPosition(playbackStart); 392 + } 393 + 364 394 Future<void> _syncCustomAudioToVideoPosition(Duration position) async { 365 395 final audioTrack = _proVideoController?.audioTrack; 366 396 if (audioTrack == null) return; ··· 372 402 } 373 403 374 404 Future<void> _playCustomAudioForCurrentVideoPosition(AudioTrack track) async { 405 + final videoPosition = await _ensurePlayableVideoPosition(); 406 + final isPlaybackStart = videoPosition == _playbackStart; 407 + final syncedVideoPosition = isPlaybackStart 408 + ? await _startVideoPlaybackFromBeginning() 409 + : videoPosition; 375 410 await _audioService.play( 376 411 track, 377 - videoPosition: _videoController.value.position, 412 + videoPosition: syncedVideoPosition, 378 413 videoStart: _playbackStart, 414 + forceSeek: isPlaybackStart, 379 415 ); 380 416 } 381 417 418 + Future<Duration> _startVideoPlaybackFromBeginning() async { 419 + if (!_videoController.value.isPlaying) { 420 + await _videoController.play(); 421 + } 422 + 423 + final stopwatch = Stopwatch()..start(); 424 + while (stopwatch.elapsed < _videoPlaybackStartWaitTimeout) { 425 + final position = _videoController.value.position; 426 + if (position > _playbackStart) { 427 + return position; 428 + } 429 + await Future<void>.delayed(_videoPlaybackStartPollInterval); 430 + } 431 + 432 + return _videoController.value.position; 433 + } 434 + 382 435 Future<void> _prepareCustomAudioForCurrentVideoPosition( 383 436 AudioTrack track, 384 437 ) async { ··· 559 612 onBalanceChanged: (balance) async { 560 613 await _audioService.balanceAudio(balance); 561 614 }, 562 - onStartTimeChanged: (startTime) async { 563 - await Future.wait([ 564 - _audioService.seek(startTime), 565 - _videoController.seekTo(Duration.zero), 566 - ]); 615 + onStartTimeChanged: (_) async { 616 + await _seekToPlaybackStartWithCustomAudio(); 567 617 }, 568 618 onConfirm: (track) { 569 619 if (track != null) { ··· 630 680 final exportTransform = shouldCompressForUpload 631 681 ? _uploadCompressionTransform(transform) 632 682 : transform; 683 + final exportStartTime = _durationSpan?.start ?? parameters.startTime; 633 684 final exportModel = VideoRenderData( 634 685 id: _taskId, 635 686 videoSegments: [VideoSegment(video: _video, volume: originalVolume)], ··· 642 693 colorFilters: parameters.colorFilters 643 694 .map((matrix) => ColorFilter(matrix: matrix)) 644 695 .toList(), 645 - startTime: _durationSpan?.start ?? parameters.startTime, 696 + startTime: exportStartTime, 646 697 endTime: _durationSpan?.end ?? parameters.endTime, 647 698 transform: exportTransform, 648 699 bitrate: shouldCompressForUpload ··· 654 705 VideoAudioTrack( 655 706 path: customAudioPath, 656 707 volume: overlayVolume, 657 - audioStartTime: customAudioTrack?.startTime, 708 + audioStartTime: customAudioExportStartTime( 709 + trackStartTime: customAudioTrack?.startTime, 710 + ), 658 711 loop: true, 659 712 ), 660 713 ] ··· 916 969 onBalanceChange: (value) async { 917 970 await _audioService.balanceAudio(value); 918 971 }, 919 - onStartTimeChange: (startTime) async { 920 - await Future.wait([ 921 - _audioService.seek(startTime), 922 - _videoController.seekTo(Duration.zero), 923 - ]); 972 + onStartTimeChange: (_) async { 973 + await _seekToPlaybackStartWithCustomAudio(); 924 974 }, 925 975 onPlay: (audio) async { 926 976 final isNewTrack = !_audioService.useCustomAudio;
+12 -1
lib/src/features/posting/providers/recording_provider.dart
··· 64 64 void selectSound(AudioTrack sound) { 65 65 state = state.copyWith( 66 66 selectedSound: sound, 67 - soundGuideOffset: Duration.zero, 67 + soundGuideOffset: sound.startTime ?? Duration.zero, 68 + error: null, 69 + ); 70 + } 71 + 72 + void setSelectedSoundStartTime(Duration offset) { 73 + final sound = state.selectedSound; 74 + if (sound == null) return; 75 + 76 + state = state.copyWith( 77 + selectedSound: sound.copyWith(startTime: offset), 78 + soundGuideOffset: offset, 68 79 error: null, 69 80 ); 70 81 }
+204 -21
lib/src/features/posting/ui/pages/recording_page.dart
··· 56 56 bool _isProcessing = false; 57 57 bool _isExiting = false; 58 58 bool _isFinalizingRecordingSession = false; 59 + bool _isStartingRecording = false; 60 + bool _cancelPendingRecordingStart = false; 59 61 late final AudioPlayer _guideAudioPlayer; 62 + Future<void>? _guideAudioPrepareFuture; 63 + String? _preparedGuideAudioUrl; 64 + int _guideAudioPrepareRequestId = 0; 60 65 61 66 // Store notifier reference for safe disposal 62 67 Recording? _recordingNotifier; ··· 67 72 _logger = GetIt.instance<LogService>().getLogger('RecordingPage'); 68 73 _recordingNotifier = ref.read(recordingProvider.notifier); 69 74 _guideAudioPlayer = AudioPlayer(); 75 + unawaited( 76 + _guideAudioPlayer.setAudioContext( 77 + AudioContext( 78 + android: const AudioContextAndroid( 79 + audioFocus: AndroidAudioFocus.none, 80 + ), 81 + iOS: AudioContextIOS( 82 + options: const { 83 + AVAudioSessionOptions.mixWithOthers, 84 + AVAudioSessionOptions.duckOthers, 85 + }, 86 + ), 87 + ), 88 + ), 89 + ); 70 90 final initialSound = widget.initialSound; 71 91 if (initialSound != null) { 72 92 WidgetsBinding.instance.addPostFrameCallback((_) { ··· 74 94 final initialTrack = audioViewToAudioTrack(initialSound); 75 95 if (initialTrack != null) { 76 96 ref.read(recordingProvider.notifier).selectSound(initialTrack); 97 + unawaited(_prepareSelectedSoundGuide()); 77 98 } 78 99 }); 79 100 } ··· 101 122 void _handleTap() { 102 123 if (!_isCameraReady()) return; 103 124 125 + if (_isStartingRecording) { 126 + _cancelPendingRecordingStart = true; 127 + return; 128 + } 129 + 104 130 final recordingState = ref.read(recordingProvider); 105 131 106 132 if (widget.captureMode == CaptureMode.videoOnly) { ··· 122 148 void _handleRecordStart() { 123 149 if (!_isCameraReady()) return; 124 150 if (widget.captureMode != CaptureMode.hybrid) return; 151 + if (_isStartingRecording) return; 125 152 _startRecording(); 126 153 } 127 154 ··· 129 156 void _handleRecordStop() { 130 157 if (!_isCameraReady()) return; 131 158 if (widget.captureMode != CaptureMode.hybrid) return; 159 + if (_isStartingRecording) { 160 + _cancelPendingRecordingStart = true; 161 + return; 162 + } 132 163 final recordingState = ref.read(recordingProvider); 133 164 if (recordingState.isRecording) { 134 165 _stopRecording(); ··· 313 344 } 314 345 315 346 void _startRecording() { 316 - if (_isProcessing) return; 347 + unawaited(_startRecordingAsync()); 348 + } 349 + 350 + Future<void> _startRecordingAsync() async { 351 + if (_isProcessing || _isStartingRecording) return; 317 352 318 353 final recordingState = ref.read(recordingProvider); 319 - if (recordingState.hasReachedMaxDuration) return; 354 + if (recordingState.isRecording || recordingState.hasReachedMaxDuration) { 355 + return; 356 + } 357 + final isFirstSegment = 358 + !recordingState.hasSegments && 359 + recordingState.elapsedDuration == Duration.zero; 320 360 321 361 final cameraNotifier = ref.read(cameraProvider.notifier); 322 - final recordingNotifier = ref.read(recordingProvider.notifier) 323 - // Start timer optimistically so UI responds immediately 324 - ..startRecording(); 362 + final recordingNotifier = ref.read(recordingProvider.notifier); 363 + 364 + setState(() { 365 + _isStartingRecording = true; 366 + _cancelPendingRecordingStart = false; 367 + }); 368 + 369 + try { 370 + await _playSelectedSoundGuide(requireRecording: false); 371 + if (!mounted) return; 325 372 326 - // Start native recording; revert timer if it fails 327 - cameraNotifier.startVideoRecording().then((success) { 328 - if (!success && mounted) { 329 - recordingNotifier.stopRecording(); 330 - unawaited(_pauseSelectedSoundGuide()); 373 + if (_cancelPendingRecordingStart) { 374 + await _pauseSelectedSoundGuide( 375 + saveOffset: false, 376 + resetOffset: recordingState.soundGuideOffset, 377 + ); 331 378 return; 332 379 } 333 - if (success && mounted) { 334 - unawaited(_playSelectedSoundGuide()); 380 + 381 + final success = await cameraNotifier.startVideoRecording(); 382 + if (!mounted) return; 383 + 384 + if (!success) { 385 + await _pauseSelectedSoundGuide( 386 + saveOffset: false, 387 + resetOffset: recordingState.soundGuideOffset, 388 + ); 389 + return; 390 + } 391 + 392 + if (_cancelPendingRecordingStart) { 393 + final canceledFile = await cameraNotifier.stopVideoRecording(); 394 + if (canceledFile != null) { 395 + try { 396 + await File(canceledFile.path).delete(); 397 + } catch (e, stackTrace) { 398 + _logger.w( 399 + 'Error deleting canceled recording', 400 + error: e, 401 + stackTrace: stackTrace, 402 + ); 403 + } 404 + } 405 + await _pauseSelectedSoundGuide( 406 + saveOffset: false, 407 + resetOffset: recordingState.soundGuideOffset, 408 + ); 409 + return; 410 + } 411 + 412 + recordingNotifier.startRecording(); 413 + if (ref.read(recordingProvider).isRecording) { 414 + await _captureSelectedSoundOffsetAtRecordingStart( 415 + isFirstSegment: isFirstSegment, 416 + ); 417 + } 418 + } finally { 419 + _cancelPendingRecordingStart = false; 420 + if (mounted) { 421 + setState(() { 422 + _isStartingRecording = false; 423 + }); 335 424 } 336 - }); 425 + } 337 426 } 338 427 339 428 void _stopRecording({bool finalizeSession = false}) { ··· 567 656 await cameraNotifier.flipCamera(); 568 657 } 569 658 570 - Future<void> _playSelectedSoundGuide() async { 659 + Future<void> _playSelectedSoundGuide({bool requireRecording = true}) async { 571 660 final recordingState = ref.read(recordingProvider); 572 661 final sound = recordingState.selectedSound; 573 662 final audioUrl = sound?.audio.networkUrl; 574 663 if (sound == null || audioUrl == null || audioUrl.isEmpty) return; 575 664 576 665 try { 577 - await _guideAudioPlayer.play( 578 - UrlSource(audioUrl), 666 + await _guideAudioPrepareFuture; 667 + if (!mounted || 668 + _cancelPendingRecordingStart || 669 + (requireRecording && !ref.read(recordingProvider).isRecording)) { 670 + return; 671 + } 672 + if (_preparedGuideAudioUrl != audioUrl) { 673 + await _prepareSelectedSoundGuide(); 674 + if (!mounted || 675 + _cancelPendingRecordingStart || 676 + (requireRecording && !ref.read(recordingProvider).isRecording)) { 677 + return; 678 + } 679 + } 680 + await _guideAudioPlayer.resume(); 681 + } catch (e, stackTrace) { 682 + _logger.e( 683 + 'Error playing recording guide sound', 684 + error: e, 685 + stackTrace: stackTrace, 686 + ); 687 + } 688 + } 689 + 690 + Future<void> _captureSelectedSoundOffsetAtRecordingStart({ 691 + required bool isFirstSegment, 692 + }) async { 693 + if (!isFirstSegment) return; 694 + 695 + final recordingState = ref.read(recordingProvider); 696 + if (recordingState.selectedSound == null) return; 697 + 698 + final position = await _guideAudioPlayer.getCurrentPosition(); 699 + if (position == null || !mounted) return; 700 + 701 + ref.read(recordingProvider.notifier).setSelectedSoundStartTime(position); 702 + } 703 + 704 + Future<void> _prepareSelectedSoundGuide() { 705 + final recordingState = ref.read(recordingProvider); 706 + final sound = recordingState.selectedSound; 707 + final audioUrl = sound?.audio.networkUrl; 708 + if (sound == null || audioUrl == null || audioUrl.isEmpty) { 709 + return Future<void>.value(); 710 + } 711 + 712 + final requestId = ++_guideAudioPrepareRequestId; 713 + final previousPrepareFuture = _guideAudioPrepareFuture; 714 + final prepareFuture = () async { 715 + try { 716 + await previousPrepareFuture; 717 + } catch (_) { 718 + // Preparation errors are logged in _prepareSoundGuide. 719 + } 720 + if (!mounted || requestId != _guideAudioPrepareRequestId) return; 721 + await _prepareSoundGuide( 722 + audioUrl: audioUrl, 579 723 position: recordingState.soundGuideOffset, 724 + requestId: requestId, 580 725 ); 726 + }(); 727 + _guideAudioPrepareFuture = prepareFuture; 728 + return prepareFuture; 729 + } 730 + 731 + Future<void> _prepareSoundGuide({ 732 + required String audioUrl, 733 + required Duration position, 734 + required int requestId, 735 + }) async { 736 + try { 737 + await _guideAudioPlayer.setReleaseMode(ReleaseMode.stop); 738 + if (!mounted || requestId != _guideAudioPrepareRequestId) return; 739 + if (_preparedGuideAudioUrl != audioUrl) { 740 + await _guideAudioPlayer.setSourceUrl(audioUrl); 741 + if (!mounted || requestId != _guideAudioPrepareRequestId) return; 742 + _preparedGuideAudioUrl = audioUrl; 743 + } 744 + await _guideAudioPlayer.seek(position); 581 745 } catch (e, stackTrace) { 746 + if (requestId == _guideAudioPrepareRequestId) { 747 + _preparedGuideAudioUrl = null; 748 + } 582 749 _logger.e( 583 - 'Error playing recording guide sound', 750 + 'Error preparing recording guide sound', 584 751 error: e, 585 752 stackTrace: stackTrace, 586 753 ); 587 754 } 588 755 } 589 756 590 - Future<void> _pauseSelectedSoundGuide() async { 757 + Future<void> _pauseSelectedSoundGuide({ 758 + bool saveOffset = true, 759 + Duration? resetOffset, 760 + }) async { 591 761 try { 592 762 final position = await _guideAudioPlayer.getCurrentPosition(); 593 763 await _guideAudioPlayer.pause(); 594 - if (position != null && mounted) { 764 + if (resetOffset != null) { 765 + await _guideAudioPlayer.seek(resetOffset); 766 + } 767 + if (saveOffset && position != null && mounted) { 595 768 ref.read(recordingProvider.notifier).setSoundGuideOffset(position); 596 769 } 597 770 } catch (e, stackTrace) { ··· 618 791 if (!mounted || selectedTrack == null) return; 619 792 620 793 ref.read(recordingProvider.notifier).selectSound(selectedTrack); 794 + unawaited(_prepareSelectedSoundGuide()); 621 795 } 622 796 623 797 Future<void> _clearSelectedSound() async { ··· 628 802 629 803 await _pauseSelectedSoundGuide(); 630 804 await _guideAudioPlayer.stop(); 805 + _guideAudioPrepareRequestId++; 806 + _guideAudioPrepareFuture = null; 807 + _preparedGuideAudioUrl = null; 631 808 if (!mounted) return; 632 809 ref.read(recordingProvider.notifier).clearSound(); 633 810 } ··· 751 928 final canFlipCamera = 752 929 availableLensDirections.contains(CameraLensDirection.front) && 753 930 availableLensDirections.contains(CameraLensDirection.back) && 931 + !_isStartingRecording && 754 932 !recordingState.isRecording && 755 933 !recordingState.hasSegments && 756 934 !cameraState.isFlipping; 757 935 final aspectRatio = cameraState.controller!.value.aspectRatio; 758 936 final canFinalizeSession = 759 - recordingState.canFinalize && !_isProcessing && hasCameras; 937 + recordingState.canFinalize && 938 + !_isProcessing && 939 + !_isStartingRecording && 940 + hasCameras; 760 941 final canChangeSound = 761 942 !_isProcessing && 943 + !_isStartingRecording && 762 944 !recordingState.isRecording && 763 945 !recordingState.hasSegments; 764 946 final onTap = ··· 779 961 elapsedDuration: recordingState.elapsedDuration, 780 962 maxDuration: recordingState.maxDuration, 781 963 onBack: () { 782 - if (recordingState.isRecording) { 964 + if (_isStartingRecording || recordingState.isRecording) { 783 965 return; 784 966 } 785 967 context.router.pop(); ··· 798 980 onRecordStop: _isProcessing ? null : _handleRecordStop, 799 981 onOpenLibrary: 800 982 _isProcessing || 983 + _isStartingRecording || 801 984 recordingState.isRecording || 802 985 recordingState.hasSegments 803 986 ? null
+45 -8
test/src/core/pro_video_editor/services/audio_helper_service_test.dart
··· 25 25 ); 26 26 }); 27 27 28 - test('uses position relative to trim start', () { 28 + test( 29 + 'adds trim start to the selected sound offset during trimmed playback', 30 + () { 31 + expect( 32 + syncedCustomAudioPosition( 33 + trackStartTime: const Duration(seconds: 4), 34 + videoPosition: const Duration(seconds: 12), 35 + videoStart: const Duration(seconds: 10), 36 + ), 37 + const Duration(seconds: 16), 38 + ); 39 + }, 40 + ); 41 + 42 + test( 43 + 'clamps positions before trim start to trim start plus selected sound offset', 44 + () { 45 + expect( 46 + syncedCustomAudioPosition( 47 + trackStartTime: const Duration(seconds: 4), 48 + videoPosition: const Duration(seconds: 8), 49 + videoStart: const Duration(seconds: 10), 50 + ), 51 + const Duration(seconds: 14), 52 + ); 53 + }, 54 + ); 55 + }); 56 + 57 + group('customAudioRenderStartTime', () { 58 + test('starts at the selected sound offset for untrimmed playback', () { 29 59 expect( 30 - syncedCustomAudioPosition( 60 + customAudioRenderStartTime( 31 61 trackStartTime: const Duration(seconds: 4), 32 - videoPosition: const Duration(seconds: 12), 33 - videoStart: const Duration(seconds: 10), 62 + videoStart: Duration.zero, 34 63 ), 35 - const Duration(seconds: 6), 64 + const Duration(seconds: 4), 36 65 ); 37 66 }); 38 67 39 - test('clamps positions before trim start to the selected sound offset', () { 68 + test('adds the trimmed video start to match preview playback', () { 40 69 expect( 41 - syncedCustomAudioPosition( 70 + customAudioRenderStartTime( 42 71 trackStartTime: const Duration(seconds: 4), 43 - videoPosition: const Duration(seconds: 8), 44 72 videoStart: const Duration(seconds: 10), 45 73 ), 74 + const Duration(seconds: 14), 75 + ); 76 + }); 77 + }); 78 + 79 + group('customAudioExportStartTime', () { 80 + test('does not add the trimmed video start to the audio offset', () { 81 + expect( 82 + customAudioExportStartTime(trackStartTime: const Duration(seconds: 4)), 46 83 const Duration(seconds: 4), 47 84 ); 48 85 });
+28
test/src/features/posting/providers/recording_provider_test.dart
··· 81 81 expect(state.soundGuideOffset, const Duration(seconds: 3)); 82 82 }); 83 83 84 + test('stores selected sound recording start 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 + final track = createTrack( 95 + 'sound-1', 96 + ).copyWith(startTime: const Duration(seconds: 2)); 97 + 98 + notifier.selectSound(track); 99 + 100 + expect( 101 + container.read(recordingProvider).soundGuideOffset, 102 + const Duration(seconds: 2), 103 + ); 104 + 105 + notifier.setSelectedSoundStartTime(const Duration(seconds: 4)); 106 + 107 + final state = container.read(recordingProvider); 108 + expect(state.selectedSound?.startTime, const Duration(seconds: 4)); 109 + expect(state.soundGuideOffset, const Duration(seconds: 4)); 110 + }); 111 + 84 112 test('clearSound removes selected sound and resets guide offset', () { 85 113 final container = ProviderContainer(); 86 114 addTearDown(container.dispose);