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

+421 -27
+122 -2
lib/src/core/design_system/templates/recording_page_template.dart
··· 19 19 required this.onFlipCamera, 20 20 required this.canFlipCamera, 21 21 required this.captureMode, 22 + this.isProcessing = false, 23 + this.processingLabel, 22 24 this.onOpenLibrary, 25 + this.onDone, 26 + this.doneLabel, 23 27 this.onTap, 24 28 this.onRecordStart, 25 29 this.onRecordStop, ··· 35 39 final VoidCallback? onFlipCamera; 36 40 final bool canFlipCamera; 37 41 final CaptureMode captureMode; 42 + final bool isProcessing; 43 + final String? processingLabel; 38 44 final VoidCallback? onOpenLibrary; 45 + final VoidCallback? onDone; 46 + final String? doneLabel; 39 47 40 48 /// Called on tap. In videoOnly: toggle recording. In hybrid: take photo. 41 49 final VoidCallback? onTap; ··· 88 96 duration: elapsedDuration, 89 97 maxDuration: maxDuration, 90 98 ), 99 + trailing: onDone != null 100 + ? _ActionButton( 101 + label: doneLabel ?? 'Done', 102 + onPressed: onDone!, 103 + ) 104 + : const SizedBox(width: 40), 91 105 ), 92 106 // Bottom overlay sits inside rounded view 93 107 _BottomOverlay( ··· 102 116 ), 103 117 bottomPadding: 24, 104 118 ), 119 + if (isProcessing) 120 + _ProcessingOverlay( 121 + label: processingLabel ?? 'Processing...', 122 + ), 105 123 ], 106 124 ), 107 125 ); ··· 119 137 } 120 138 } 121 139 140 + class _ProcessingOverlay extends StatelessWidget { 141 + const _ProcessingOverlay({required this.label}); 142 + 143 + final String label; 144 + 145 + @override 146 + Widget build(BuildContext context) { 147 + return Positioned.fill( 148 + child: ColoredBox( 149 + color: Colors.black.withAlpha(110), 150 + child: Center( 151 + child: ClipRRect( 152 + borderRadius: BorderRadius.circular(20), 153 + child: BackdropFilter( 154 + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), 155 + child: Container( 156 + padding: const EdgeInsets.symmetric( 157 + horizontal: 20, 158 + vertical: 16, 159 + ), 160 + decoration: BoxDecoration( 161 + color: Colors.black.withAlpha(130), 162 + borderRadius: BorderRadius.circular(20), 163 + border: Border.all(color: Colors.white.withAlpha(35)), 164 + ), 165 + child: Column( 166 + mainAxisSize: MainAxisSize.min, 167 + children: [ 168 + const SizedBox( 169 + width: 24, 170 + height: 24, 171 + child: CircularProgressIndicator( 172 + strokeWidth: 2.4, 173 + color: Colors.white, 174 + ), 175 + ), 176 + const SizedBox(height: 12), 177 + Text( 178 + label, 179 + style: const TextStyle( 180 + color: Colors.white, 181 + fontSize: 15, 182 + fontWeight: FontWeight.w600, 183 + ), 184 + textAlign: TextAlign.center, 185 + ), 186 + ], 187 + ), 188 + ), 189 + ), 190 + ), 191 + ), 192 + ), 193 + ); 194 + } 195 + } 196 + 122 197 class _TopOverlay extends StatelessWidget { 123 - const _TopOverlay({required this.onBack, required this.timer}); 198 + const _TopOverlay({ 199 + required this.onBack, 200 + required this.timer, 201 + required this.trailing, 202 + }); 124 203 125 204 final VoidCallback onBack; 126 205 final Widget timer; 206 + final Widget trailing; 127 207 128 208 @override 129 209 Widget build(BuildContext context) { ··· 136 216 children: [ 137 217 _CloseButton(onPressed: onBack), 138 218 timer, 139 - const SizedBox(width: 40), 219 + trailing, 140 220 ], 221 + ), 222 + ), 223 + ); 224 + } 225 + } 226 + 227 + class _ActionButton extends StatelessWidget { 228 + const _ActionButton({required this.label, required this.onPressed}); 229 + 230 + final String label; 231 + final VoidCallback onPressed; 232 + 233 + @override 234 + Widget build(BuildContext context) { 235 + return GestureDetector( 236 + onTap: () { 237 + HapticFeedback.lightImpact(); 238 + onPressed(); 239 + }, 240 + child: ClipRRect( 241 + borderRadius: BorderRadius.circular(999), 242 + child: BackdropFilter( 243 + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), 244 + child: Container( 245 + constraints: const BoxConstraints(minHeight: 40, minWidth: 40), 246 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), 247 + decoration: BoxDecoration( 248 + borderRadius: BorderRadius.circular(999), 249 + color: Colors.black.withAlpha(90), 250 + border: Border.all(color: Colors.white.withAlpha(40)), 251 + ), 252 + child: Text( 253 + label, 254 + style: const TextStyle( 255 + color: Colors.white, 256 + fontSize: 14, 257 + fontWeight: FontWeight.w600, 258 + ), 259 + ), 260 + ), 141 261 ), 142 262 ), 143 263 );
+6
lib/src/core/pro_video_editor/pro_video_editor_repository.dart
··· 32 32 /// [ThumbnailConfigs.id]/[KeyFramesConfigs.id]. 33 33 Stream<ProgressModel> progressStream(); 34 34 35 + /// Concatenates recorded video [segments] into a single file for editing. 36 + /// 37 + /// When only one segment is provided, returns a copied file so the caller can 38 + /// clean up the original temporary capture safely. 39 + Future<XFile> stitchVideoSegments(List<XFile> segments); 40 + 35 41 /// Opens the ProImageEditor UI to edit the given [source] image and returns 36 42 /// an edited image file when the editor is closed. 37 43 ///
+41
lib/src/core/pro_video_editor/pro_video_editor_repository_impl.dart
··· 46 46 ProVideoEditor.instance.progressStream; 47 47 48 48 @override 49 + Future<XFile> stitchVideoSegments(List<XFile> segments) async { 50 + if (segments.isEmpty) { 51 + throw ArgumentError.value( 52 + segments, 53 + 'segments', 54 + 'At least one segment is required', 55 + ); 56 + } 57 + 58 + final dir = await getTemporaryDirectory(); 59 + final timestamp = DateTime.now().millisecondsSinceEpoch; 60 + final outputPath = '${dir.path}/spark_recording_$timestamp.mp4'; 61 + 62 + if (segments.length == 1) { 63 + final copiedFile = await File(segments.first.path).copy(outputPath); 64 + return XFile( 65 + copiedFile.path, 66 + mimeType: 'video/mp4', 67 + name: copiedFile.uri.pathSegments.last, 68 + ); 69 + } 70 + 71 + await ProVideoEditor.instance.renderVideoToFile( 72 + outputPath, 73 + VideoRenderData( 74 + videoSegments: segments 75 + .map( 76 + (segment) => VideoSegment(video: EditorVideo.file(segment.path)), 77 + ) 78 + .toList(), 79 + ), 80 + ); 81 + 82 + return XFile( 83 + outputPath, 84 + mimeType: 'video/mp4', 85 + name: outputPath.split('/').last, 86 + ); 87 + } 88 + 89 + @override 49 90 Future<XFile?> openImageEditor(BuildContext context, XFile source) async { 50 91 return Navigator.of(context).push<XFile?>( 51 92 MaterialPageRoute(
+34 -10
lib/src/features/posting/providers/recording_provider.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:io'; 2 3 4 + import 'package:image_picker/image_picker.dart'; 3 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 6 import 'package:spark/src/features/posting/providers/recording_state.dart'; 5 7 ··· 16 18 } 17 19 18 20 void startRecording() { 19 - if (state.isRecording) { 21 + if (state.isRecording || state.hasReachedMaxDuration) { 20 22 return; 21 23 } 22 24 23 - state = state.copyWith( 24 - isRecording: true, 25 - elapsedDuration: Duration.zero, 26 - error: null, 27 - ); 25 + state = state.copyWith(isRecording: true, error: null); 28 26 29 27 _timer = Timer.periodic(const Duration(milliseconds: 100), (timer) { 30 28 final newDuration = ··· 32 30 33 31 if (newDuration >= state.maxDuration) { 34 32 stopTimer(); 35 - state = state.copyWith( 36 - elapsedDuration: state.maxDuration, 37 - isRecording: false, 38 - ); 33 + state = state.copyWith(elapsedDuration: state.maxDuration); 39 34 return; 40 35 } 41 36 ··· 50 45 51 46 stopTimer(); 52 47 state = state.copyWith(isRecording: false); 48 + } 49 + 50 + void addSegment(XFile file) { 51 + state = state.copyWith( 52 + segmentPaths: [...state.segmentPaths, file.path], 53 + error: null, 54 + ); 55 + } 56 + 57 + Future<void> discardSession({Iterable<String> keepPaths = const []}) async { 58 + stopTimer(); 59 + 60 + final keepSet = keepPaths.toSet(); 61 + final pathsToDelete = state.segmentPaths.where( 62 + (path) => !keepSet.contains(path), 63 + ); 64 + 65 + for (final path in pathsToDelete) { 66 + try { 67 + final file = File(path); 68 + if (await file.exists()) { 69 + await file.delete(); 70 + } 71 + } catch (_) { 72 + // Best-effort cleanup for temporary session files. 73 + } 74 + } 75 + 76 + state = const RecordingState(); 53 77 } 54 78 55 79 void reset() {
+5
lib/src/features/posting/providers/recording_state.dart
··· 8 8 @Default(false) bool isRecording, 9 9 @Default(Duration.zero) Duration elapsedDuration, 10 10 @Default(Duration(minutes: 3)) Duration maxDuration, 11 + @Default(<String>[]) List<String> segmentPaths, 11 12 String? error, 12 13 }) = _RecordingState; 13 14 14 15 const RecordingState._(); 15 16 16 17 bool get hasReachedMaxDuration => elapsedDuration >= maxDuration; 18 + bool get hasSegments => segmentPaths.isNotEmpty; 19 + bool get canResume => hasSegments && !isRecording && !hasReachedMaxDuration; 20 + bool get canFinalize => hasSegments && !isRecording; 21 + int get segmentCount => segmentPaths.length; 17 22 18 23 double get progress => 19 24 elapsedDuration.inMilliseconds / maxDuration.inMilliseconds;
+124 -15
lib/src/features/posting/ui/pages/recording_page.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:io'; 2 3 3 4 import 'package:auto_route/auto_route.dart'; ··· 45 46 late final SparkLogger _logger; 46 47 bool _isProcessing = false; 47 48 bool _isExiting = false; 49 + bool _isFinalizingRecordingSession = false; 48 50 49 51 // Store notifier reference for safe disposal 50 52 Recording? _recordingNotifier; ··· 53 55 void initState() { 54 56 super.initState(); 55 57 _logger = GetIt.instance<LogService>().getLogger('RecordingPage'); 56 - // Save reference to notifier for use in dispose 57 - WidgetsBinding.instance.addPostFrameCallback((_) { 58 - if (mounted) { 59 - _recordingNotifier = ref.read(recordingProvider.notifier); 60 - } 61 - }); 58 + _recordingNotifier = ref.read(recordingProvider.notifier); 62 59 } 63 60 64 61 bool _hasCameras() { ··· 83 80 void _handleTap() { 84 81 if (!_isCameraReady()) return; 85 82 83 + final recordingState = ref.read(recordingProvider); 84 + 86 85 if (widget.captureMode == CaptureMode.videoOnly) { 87 - final recordingState = ref.read(recordingProvider); 88 86 if (recordingState.isRecording) { 89 87 _stopRecording(); 90 88 } else { 91 89 _startRecording(); 92 90 } 93 91 } else { 92 + if (recordingState.hasSegments) { 93 + return; 94 + } 94 95 // Hybrid mode - tap takes photo 95 96 _takePhoto(); 96 97 } ··· 138 139 if (_isProcessing) return; 139 140 140 141 final recordingState = ref.read(recordingProvider); 141 - if (recordingState.isRecording) return; 142 + if (recordingState.isRecording || recordingState.hasSegments) return; 142 143 143 144 final selection = await showMediaLibraryPickerSheet( 144 145 context, ··· 291 292 } 292 293 293 294 void _startRecording() { 295 + if (_isProcessing) return; 296 + 297 + final recordingState = ref.read(recordingProvider); 298 + if (recordingState.hasReachedMaxDuration) return; 299 + 294 300 final cameraNotifier = ref.read(cameraProvider.notifier); 295 301 final recordingNotifier = ref.read(recordingProvider.notifier) 296 302 // Start timer optimistically so UI responds immediately ··· 304 310 }); 305 311 } 306 312 307 - void _stopRecording() { 313 + void _stopRecording({bool finalizeSession = false}) { 308 314 if (_isProcessing) return; 309 315 310 316 setState(() { ··· 312 318 }); 313 319 314 320 final cameraNotifier = ref.read(cameraProvider.notifier); 315 - ref.read(recordingProvider.notifier).stopRecording(); 321 + final recordingNotifier = ref.read(recordingProvider.notifier) 322 + ..stopRecording(); 316 323 317 324 // Defer heavy stop so the "processing" frame paints before blocking 318 325 WidgetsBinding.instance.addPostFrameCallback((_) async { ··· 326 333 return; 327 334 } 328 335 329 - await _processVideo(videoFile); 336 + recordingNotifier.addSegment(videoFile); 337 + 338 + final shouldFinalize = 339 + finalizeSession || ref.read(recordingProvider).hasReachedMaxDuration; 340 + if (!shouldFinalize) { 341 + setState(() { 342 + _isProcessing = false; 343 + }); 344 + return; 345 + } 346 + 347 + await _finalizeRecordingSession(); 330 348 }); 331 349 } 332 350 351 + Future<void> _finalizeRecordingSession() async { 352 + if (!mounted) return; 353 + 354 + if (!_isProcessing) { 355 + setState(() { 356 + _isProcessing = true; 357 + }); 358 + } 359 + if (!_isFinalizingRecordingSession) { 360 + setState(() { 361 + _isFinalizingRecordingSession = true; 362 + }); 363 + } 364 + 365 + final recordingState = ref.read(recordingProvider); 366 + if (!recordingState.canFinalize) { 367 + if (mounted) { 368 + setState(() { 369 + _isProcessing = false; 370 + }); 371 + } 372 + return; 373 + } 374 + 375 + final segments = recordingState.segmentPaths.map(XFile.new).toList(); 376 + final repository = GetIt.I<ProVideoEditorRepository>(); 377 + 378 + try { 379 + final stitchedVideo = await repository.stitchVideoSegments(segments); 380 + if (!mounted) return; 381 + 382 + await ref 383 + .read(recordingProvider.notifier) 384 + .discardSession(keepPaths: {stitchedVideo.path}); 385 + 386 + if (!mounted) return; 387 + await _processVideo(stitchedVideo); 388 + } catch (e, stackTrace) { 389 + _logger.e( 390 + 'Error stitching recorded video segments', 391 + error: e, 392 + stackTrace: stackTrace, 393 + ); 394 + if (mounted) { 395 + setState(() { 396 + _isProcessing = false; 397 + _isFinalizingRecordingSession = false; 398 + }); 399 + ScaffoldMessenger.of(context).showSnackBar( 400 + SnackBar( 401 + content: Text( 402 + AppLocalizations.of(context).errorWithDetail(e.toString()), 403 + ), 404 + ), 405 + ); 406 + } 407 + } 408 + } 409 + 333 410 Future<void> _processVideo(XFile videoFile) async { 334 411 if (!mounted) return; 335 412 336 413 try { 414 + if (_isFinalizingRecordingSession) { 415 + setState(() { 416 + _isFinalizingRecordingSession = false; 417 + }); 418 + } 419 + 337 420 final cameraNotifier = ref.read(cameraProvider.notifier); 338 421 await cameraNotifier.disposeCamera(); 339 422 ··· 355 438 if (result == null) { 356 439 setState(() { 357 440 _isProcessing = false; 441 + _isFinalizingRecordingSession = false; 358 442 }); 359 443 await cameraNotifier.reinitializeCamera(); 360 444 return; ··· 401 485 setState(() { 402 486 _isExiting = false; 403 487 _isProcessing = false; 488 + _isFinalizingRecordingSession = false; 404 489 }); 405 490 } 406 491 } else { ··· 422 507 if (mounted) { 423 508 setState(() { 424 509 _isProcessing = false; 510 + _isFinalizingRecordingSession = false; 425 511 }); 426 512 ScaffoldMessenger.of(context).showSnackBar( 427 513 SnackBar( ··· 448 534 recordingState.isRecording && 449 535 !_isProcessing) { 450 536 WidgetsBinding.instance.addPostFrameCallback((_) { 451 - _stopRecording(); 537 + _stopRecording(finalizeSession: true); 452 538 }); 453 539 } 454 540 ··· 540 626 onFlipCamera: null, 541 627 canFlipCamera: false, 542 628 captureMode: widget.captureMode, 629 + isProcessing: _isFinalizingRecordingSession, 630 + processingLabel: AppLocalizations.of( 631 + context, 632 + ).messageProcessingVideo, 633 + onDone: null, 543 634 onTap: null, 544 635 onRecordStart: null, 545 636 onRecordStop: null, ··· 554 645 availableLensDirections.contains(CameraLensDirection.front) && 555 646 availableLensDirections.contains(CameraLensDirection.back) && 556 647 !recordingState.isRecording && 648 + !recordingState.hasSegments && 557 649 !cameraState.isFlipping; 558 650 final aspectRatio = cameraState.controller!.value.aspectRatio; 651 + final canFinalizeSession = 652 + recordingState.canFinalize && !_isProcessing && hasCameras; 653 + final onTap = 654 + _isProcessing || 655 + (widget.captureMode == CaptureMode.hybrid && 656 + recordingState.hasSegments) 657 + ? null 658 + : _handleTap; 559 659 560 660 return Stack( 561 661 children: [ ··· 576 676 onFlipCamera: canFlipCamera ? _handleFlipCamera : null, 577 677 canFlipCamera: canFlipCamera, 578 678 captureMode: widget.captureMode, 579 - onTap: _isProcessing ? null : _handleTap, 679 + isProcessing: _isFinalizingRecordingSession, 680 + processingLabel: AppLocalizations.of( 681 + context, 682 + ).messageProcessingVideo, 683 + doneLabel: AppLocalizations.of(context).buttonDone, 684 + onDone: canFinalizeSession ? _finalizeRecordingSession : null, 685 + onTap: onTap, 580 686 onRecordStart: _isProcessing ? null : _handleRecordStart, 581 687 onRecordStop: _isProcessing ? null : _handleRecordStop, 582 - onOpenLibrary: _isProcessing || recordingState.isRecording 688 + onOpenLibrary: 689 + _isProcessing || 690 + recordingState.isRecording || 691 + recordingState.hasSegments 583 692 ? null 584 693 : _openMediaLibraryPicker, 585 694 ), ··· 638 747 // Defer modifying provider to avoid modifying while finalizing widget tree 639 748 final notifier = _recordingNotifier; 640 749 if (notifier != null) { 641 - Future(notifier.reset); 750 + unawaited(notifier.discardSession()); 642 751 } 643 752 super.dispose(); 644 753 }
+89
test/src/features/posting/providers/recording_provider_test.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:image_picker/image_picker.dart'; 6 + import 'package:spark/src/features/posting/providers/recording_provider.dart'; 7 + 8 + void main() { 9 + group('RecordingProvider', () { 10 + test('accumulates elapsed time across resumed segments', () async { 11 + final container = ProviderContainer(); 12 + addTearDown(container.dispose); 13 + final subscription = container.listen( 14 + recordingProvider, 15 + (previous, next) {}, 16 + ); 17 + addTearDown(subscription.close); 18 + 19 + final notifier = container.read(recordingProvider.notifier); 20 + 21 + notifier.startRecording(); 22 + await Future<void>.delayed(const Duration(milliseconds: 350)); 23 + notifier.stopRecording(); 24 + notifier.addSegment(XFile('/tmp/segment-1.mp4')); 25 + 26 + final pausedState = container.read(recordingProvider); 27 + expect(pausedState.isRecording, isFalse); 28 + expect( 29 + pausedState.elapsedDuration, 30 + greaterThanOrEqualTo(const Duration(milliseconds: 300)), 31 + ); 32 + expect(pausedState.canFinalize, isTrue); 33 + 34 + notifier.startRecording(); 35 + await Future<void>.delayed(const Duration(milliseconds: 250)); 36 + notifier.stopRecording(); 37 + notifier.addSegment(XFile('/tmp/segment-2.mp4')); 38 + 39 + final resumedState = container.read(recordingProvider); 40 + expect(resumedState.isRecording, isFalse); 41 + expect( 42 + resumedState.elapsedDuration, 43 + greaterThanOrEqualTo(const Duration(milliseconds: 500)), 44 + ); 45 + expect(resumedState.segmentPaths, [ 46 + '/tmp/segment-1.mp4', 47 + '/tmp/segment-2.mp4', 48 + ]); 49 + expect(resumedState.canFinalize, isTrue); 50 + }); 51 + 52 + test( 53 + 'discardSession deletes temporary files while preserving keepPaths', 54 + () async { 55 + final container = ProviderContainer(); 56 + addTearDown(container.dispose); 57 + final subscription = container.listen( 58 + recordingProvider, 59 + (previous, next) {}, 60 + ); 61 + addTearDown(subscription.close); 62 + 63 + final tempDir = await Directory.systemTemp.createTemp( 64 + 'recording-provider-test', 65 + ); 66 + addTearDown(() async { 67 + if (await tempDir.exists()) { 68 + await tempDir.delete(recursive: true); 69 + } 70 + }); 71 + 72 + final firstFile = File('${tempDir.path}/segment-1.mp4') 73 + ..writeAsStringSync('segment-1'); 74 + final secondFile = File('${tempDir.path}/segment-2.mp4') 75 + ..writeAsStringSync('segment-2'); 76 + 77 + final notifier = container.read(recordingProvider.notifier); 78 + notifier.addSegment(XFile(firstFile.path)); 79 + notifier.addSegment(XFile(secondFile.path)); 80 + 81 + await notifier.discardSession(keepPaths: {secondFile.path}); 82 + 83 + expect(await firstFile.exists(), isFalse); 84 + expect(await secondFile.exists(), isTrue); 85 + expect(container.read(recordingProvider).segmentPaths, isEmpty); 86 + }, 87 + ); 88 + }); 89 + }