[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: upload flow in video review page

+613 -151
+9 -5
lib/src/core/design_system/components/atoms/buttons/interactive_pressable.dart
··· 33 33 34 34 @override 35 35 Widget build(BuildContext context) { 36 + final isEnabled = widget.onTap != null; 37 + 36 38 return GestureDetector( 37 39 onTap: widget.onTap, 38 - onTapDown: _onTapDown, 39 - onTapUp: _onTapUp, 40 - onTapCancel: _onTapCancel, 40 + onTapDown: isEnabled ? _onTapDown : null, 41 + onTapUp: isEnabled ? _onTapUp : null, 42 + onTapCancel: isEnabled ? _onTapCancel : null, 41 43 child: AnimatedScale( 42 - scale: _isPressed ? widget.pressedScale : 1.0, 44 + scale: isEnabled && _isPressed ? widget.pressedScale : 1.0, 43 45 duration: widget.duration, 44 46 curve: Curves.easeOut, 45 47 child: Stack( ··· 51 53 duration: widget.duration, 52 54 curve: Curves.easeOut, 53 55 decoration: BoxDecoration( 54 - color: _isPressed ? widget.overlayColor : Colors.transparent, 56 + color: isEnabled && _isPressed 57 + ? widget.overlayColor 58 + : Colors.transparent, 55 59 borderRadius: widget.borderRadius, 56 60 ), 57 61 ),
+40 -26
lib/src/core/design_system/components/atoms/buttons/long_button.dart
··· 30 30 @override 31 31 Widget build(BuildContext context) { 32 32 final isPrimary = variant == LongButtonVariant.primary; 33 - final isDark = Theme.of(context).brightness == Brightness.dark; 33 + final theme = Theme.of(context); 34 + final isDark = theme.brightness == Brightness.dark; 35 + final colorScheme = theme.colorScheme; 36 + final isEnabled = onPressed != null; 37 + final backgroundColor = !isEnabled 38 + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.55) 39 + : isPrimary 40 + ? AppColors.primary600 41 + : (isDark ? AppColors.darkGreyButton : AppColors.lightGreyButton); 42 + final border = !isEnabled 43 + ? Border.all(color: colorScheme.outline.withValues(alpha: 0.45)) 44 + : isPrimary 45 + ? null 46 + : Border.all( 47 + color: isDark ? AppColors.greyBorder : AppColors.grey200, 48 + width: 1.14667, 49 + ); 50 + final textColor = !isEnabled 51 + ? colorScheme.onSurface.withValues(alpha: 0.55) 52 + : isPrimary 53 + ? AppColors.greyWhite 54 + : (isDark ? AppColors.greyWhite : AppColors.grey900); 34 55 35 - return InteractivePressable( 36 - onTap: onPressed, 37 - borderRadius: BorderRadius.circular(8), 38 - child: Container( 39 - // height: 40, 40 - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 11), 41 - decoration: BoxDecoration( 42 - color: isPrimary 43 - ? AppColors.primary600 44 - : (isDark ? AppColors.darkGreyButton : AppColors.lightGreyButton), 45 - borderRadius: BorderRadius.circular(8), 46 - border: isPrimary 47 - ? null 48 - : Border.all( 49 - color: isDark ? AppColors.greyBorder : AppColors.grey200, 50 - width: 1.14667, 51 - ), 52 - ), 53 - child: Center( 54 - child: Text( 55 - label, 56 - style: AppTypography.textMediumBold.copyWith( 57 - color: isPrimary 58 - ? AppColors.greyWhite 59 - : (isDark ? AppColors.greyWhite : AppColors.grey900), 56 + return Semantics( 57 + button: true, 58 + enabled: isEnabled, 59 + child: InteractivePressable( 60 + onTap: onPressed, 61 + borderRadius: BorderRadius.circular(8), 62 + child: Container( 63 + // height: 40, 64 + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 11), 65 + decoration: BoxDecoration( 66 + color: backgroundColor, 67 + borderRadius: BorderRadius.circular(8), 68 + border: border, 69 + ), 70 + child: Center( 71 + child: Text( 72 + label, 73 + style: AppTypography.textMediumBold.copyWith(color: textColor), 60 74 ), 61 75 ), 62 76 ),
+1
lib/src/core/design_system/components/molecules/input_field.dart
··· 133 133 return TextField( 134 134 controller: controller, 135 135 focusNode: focusNode, 136 + onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), 136 137 onSubmitted: onSubmitted, 137 138 textInputAction: textInputAction, 138 139 maxLines: maxLines,
+2
lib/src/core/design_system/templates/image_review_page_template.dart
··· 93 93 children: [ 94 94 Expanded( 95 95 child: SingleChildScrollView( 96 + keyboardDismissBehavior: 97 + ScrollViewKeyboardDismissBehavior.onDrag, 96 98 child: Padding( 97 99 padding: const EdgeInsets.all(16), 98 100 child: Column(
+98
lib/src/core/design_system/templates/video_review_page_template.dart
··· 31 31 this.aspectRatio = 1.0, 32 32 this.backgroundColor, 33 33 this.isOverLimit = false, 34 + this.uploadProgress, 35 + this.uploadStatusLabel, 36 + this.uploadIndeterminate = false, 37 + this.hasUploadError = false, 38 + this.onUploadRetry, 34 39 super.key, 35 40 }); 36 41 ··· 52 57 final double aspectRatio; 53 58 final Color? backgroundColor; 54 59 final bool isOverLimit; 60 + final double? uploadProgress; 61 + final String? uploadStatusLabel; 62 + final bool uploadIndeterminate; 63 + final bool hasUploadError; 64 + final VoidCallback? onUploadRetry; 55 65 56 66 @override 57 67 Widget build(BuildContext context) { ··· 75 85 children: [ 76 86 Expanded( 77 87 child: SingleChildScrollView( 88 + keyboardDismissBehavior: 89 + ScrollViewKeyboardDismissBehavior.onDrag, 78 90 child: Padding( 79 91 padding: const EdgeInsets.all(16), 80 92 child: Column( ··· 85 97 onAltEdit: onAltEdit, 86 98 child: videoPreview, 87 99 ), 100 + if (uploadStatusLabel != null) ...[ 101 + const SizedBox(height: 16), 102 + _UploadStatusSection( 103 + progress: uploadProgress ?? 0, 104 + label: uploadStatusLabel!, 105 + isIndeterminate: uploadIndeterminate, 106 + hasError: hasUploadError, 107 + onRetry: onUploadRetry, 108 + ), 109 + ], 88 110 const SizedBox(height: 20), 89 111 _DescriptionSection( 90 112 controller: descriptionController, ··· 133 155 ), 134 156 ], 135 157 ), 158 + ), 159 + ); 160 + } 161 + } 162 + 163 + class _UploadStatusSection extends StatelessWidget { 164 + const _UploadStatusSection({ 165 + required this.progress, 166 + required this.label, 167 + required this.isIndeterminate, 168 + required this.hasError, 169 + this.onRetry, 170 + }); 171 + 172 + final double progress; 173 + final String label; 174 + final bool isIndeterminate; 175 + final bool hasError; 176 + final VoidCallback? onRetry; 177 + 178 + @override 179 + Widget build(BuildContext context) { 180 + final theme = Theme.of(context); 181 + final colorScheme = theme.colorScheme; 182 + final clampedProgress = progress.clamp(0, 1).toDouble(); 183 + final percent = (clampedProgress * 100).round(); 184 + final accent = hasError ? AppColors.red300 : AppColors.primary500; 185 + 186 + return Container( 187 + padding: const EdgeInsets.all(12), 188 + decoration: BoxDecoration( 189 + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), 190 + borderRadius: BorderRadius.circular(8), 191 + border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), 192 + ), 193 + child: Column( 194 + crossAxisAlignment: CrossAxisAlignment.start, 195 + children: [ 196 + Row( 197 + children: [ 198 + Expanded( 199 + child: Text( 200 + label, 201 + style: AppTypography.textMediumBold.copyWith( 202 + color: colorScheme.onSurface, 203 + ), 204 + ), 205 + ), 206 + if (!isIndeterminate) 207 + Text( 208 + '$percent%', 209 + style: AppTypography.textSmallBold.copyWith(color: accent), 210 + ), 211 + ], 212 + ), 213 + const SizedBox(height: 10), 214 + ClipRRect( 215 + borderRadius: BorderRadius.circular(999), 216 + child: LinearProgressIndicator( 217 + value: isIndeterminate ? null : clampedProgress, 218 + minHeight: 6, 219 + backgroundColor: colorScheme.outline.withValues(alpha: 0.18), 220 + valueColor: AlwaysStoppedAnimation<Color>(accent), 221 + ), 222 + ), 223 + if (hasError && onRetry != null) ...[ 224 + const SizedBox(height: 8), 225 + Align( 226 + alignment: Alignment.centerRight, 227 + child: TextButton( 228 + onPressed: onRetry, 229 + child: const Text('Try again'), 230 + ), 231 + ), 232 + ], 233 + ], 136 234 ), 137 235 ); 138 236 }
+4 -1
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 162 162 /// Upload a video to the server 163 163 /// 164 164 /// [videoPath] The path to the video file 165 - Future<VideoUploadResult> uploadVideo(String videoPath); 165 + Future<VideoUploadResult> uploadVideo( 166 + String videoPath, { 167 + void Function(double progress)? onUploadProgress, 168 + }); 166 169 167 170 /// Returns a [VideoUploadResult] containing the video blob & optional audio 168 171
+52 -12
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 import 'dart:io'; 3 4 import 'dart:typed_data'; ··· 1169 1170 } 1170 1171 1171 1172 @override 1172 - Future<VideoUploadResult> uploadVideo(String videoPath) async { 1173 + Future<VideoUploadResult> uploadVideo( 1174 + String videoPath, { 1175 + void Function(double progress)? onUploadProgress, 1176 + }) async { 1173 1177 _logger.d('Uploading video from path: $videoPath'); 1174 1178 1175 1179 return _client.executeWithRetry(() async { ··· 1215 1219 limitBytes: maxUploadSizeBytes, 1216 1220 ); 1217 1221 } 1218 - final videoBytes = await file.readAsBytes(); 1219 1222 1220 1223 final pdsService = authAtProto.service; 1221 1224 final serviceTokenRes = await authAtProto.server.getServiceAuth( ··· 1230 1233 ); 1231 1234 1232 1235 final serviceToken = serviceTokenRes.data.token; 1233 - var response = await http.post( 1234 - Uri.parse( 1235 - '${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo', 1236 - ), 1237 - headers: { 1238 - 'Authorization': 'Bearer $serviceToken', 1239 - 'Content-Type': _getContentType(cleanVideoPath), 1240 - }, 1241 - body: videoBytes, 1242 - ); 1236 + final uploadRequest = 1237 + http.StreamedRequest( 1238 + 'POST', 1239 + Uri.parse( 1240 + '${AppConfig.videoServiceUrl}/xrpc/so.sprk.video.uploadVideo', 1241 + ), 1242 + ) 1243 + ..contentLength = videoSizeBytes 1244 + ..headers.addAll({ 1245 + 'Authorization': 'Bearer $serviceToken', 1246 + 'Content-Type': _getContentType(cleanVideoPath), 1247 + }); 1248 + 1249 + onUploadProgress?.call(0); 1250 + final uploadResponseFuture = uploadRequest.send(); 1251 + try { 1252 + await uploadRequest.sink.addStream( 1253 + _trackUploadProgress( 1254 + file.openRead(), 1255 + totalBytes: videoSizeBytes, 1256 + onUploadProgress: onUploadProgress, 1257 + ), 1258 + ); 1259 + } finally { 1260 + unawaited(uploadRequest.sink.close()); 1261 + } 1262 + var response = await http.Response.fromStream(await uploadResponseFuture); 1243 1263 1244 1264 if (response.statusCode != 200) { 1245 1265 _logger.e( ··· 1344 1364 audioDetails: audioDetails, 1345 1365 ); 1346 1366 }); 1367 + } 1368 + 1369 + Stream<List<int>> _trackUploadProgress( 1370 + Stream<List<int>> chunks, { 1371 + required int totalBytes, 1372 + void Function(double progress)? onUploadProgress, 1373 + }) async* { 1374 + var uploadedBytes = 0; 1375 + 1376 + await for (final chunk in chunks) { 1377 + uploadedBytes += chunk.length; 1378 + if (totalBytes > 0) { 1379 + onUploadProgress?.call( 1380 + (uploadedBytes / totalBytes).clamp(0, 1).toDouble(), 1381 + ); 1382 + } 1383 + yield chunk; 1384 + } 1385 + 1386 + onUploadProgress?.call(1); 1347 1387 } 1348 1388 1349 1389 /// Crosspost images to Bluesky using adapter to handle Bluesky-specific model
+51 -4
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 48 48 with StoryMentionEditing<VideoEditorGroundedPage> { 49 49 static const _storyCanvasSize = Size(1440, 2560); 50 50 static const _trimTolerance = Duration(milliseconds: 100); 51 + static const _uploadCompressionMinFileSizeBytes = 25 * 1024 * 1024; 52 + static const _uploadCompressionBitrate = 3000000; 53 + static const _uploadCompressionMaxLongEdge = 1920.0; 51 54 52 55 final _editorKey = GlobalKey<ProImageEditorState>(); 53 56 final bool _useMaterialDesign = ··· 504 507 505 508 final customAudioTrack = parameters.customAudioTrack; 506 509 _selectedSoundRef = _decodeStrongRef(customAudioTrack?.id); 510 + final sourceVideoPath = await _video.safeFilePath(); 511 + final shouldCompressForUpload = await _shouldCompressForUpload( 512 + sourceVideoPath, 513 + ); 507 514 508 - if (_canUseOriginalVideo(parameters)) { 509 - _outputPath = await _video.safeFilePath(); 515 + if (_canUseOriginalVideo(parameters) && !shouldCompressForUpload) { 516 + _outputPath = sourceVideoPath; 510 517 return; 511 518 } 512 519 ··· 530 537 ); 531 538 532 539 final transform = _buildExportTransform(parameters); 540 + final exportTransform = shouldCompressForUpload 541 + ? _uploadCompressionTransform(transform) 542 + : transform; 533 543 final exportModel = VideoRenderData( 534 544 id: _taskId, 535 545 videoSegments: [VideoSegment(video: _video, volume: originalVolume)], ··· 544 554 .toList(), 545 555 startTime: parameters.startTime, 546 556 endTime: parameters.endTime, 547 - transform: transform, 548 - bitrate: _targetExportBitrate(transform), 557 + transform: exportTransform, 558 + bitrate: shouldCompressForUpload 559 + ? _uploadCompressionBitrate 560 + : _targetExportBitrate(exportTransform), 561 + shouldOptimizeForNetworkUse: shouldCompressForUpload, 549 562 audioTracks: customAudioPath != null 550 563 ? [ 551 564 VideoAudioTrack( ··· 561 574 _outputPath = await ProVideoEditor.instance.renderVideoToFile( 562 575 '${directory.path}/spark_edited_$now.mp4', 563 576 exportModel, 577 + ); 578 + } 579 + 580 + Future<bool> _shouldCompressForUpload(String videoPath) async { 581 + try { 582 + return await XFile(videoPath).length() >= 583 + _uploadCompressionMinFileSizeBytes; 584 + } catch (_) { 585 + return false; 586 + } 587 + } 588 + 589 + ExportTransform? _uploadCompressionTransform(ExportTransform? transform) { 590 + final resolution = _targetExportResolution(transform); 591 + final longEdge = math.max(resolution.width, resolution.height); 592 + if (longEdge <= _uploadCompressionMaxLongEdge) { 593 + return transform; 594 + } 595 + 596 + final scale = _uploadCompressionMaxLongEdge / longEdge; 597 + if (transform == null) { 598 + return ExportTransform(scaleX: scale, scaleY: scale); 599 + } 600 + 601 + return ExportTransform( 602 + width: transform.width, 603 + height: transform.height, 604 + rotateTurns: transform.rotateTurns, 605 + x: transform.x, 606 + y: transform.y, 607 + flipX: transform.flipX, 608 + flipY: transform.flipY, 609 + scaleX: (transform.scaleX ?? 1) * scale, 610 + scaleY: (transform.scaleY ?? 1) * scale, 564 611 ); 565 612 } 566 613
+142 -77
lib/src/features/posting/providers/video_upload_provider.dart
··· 7 7 import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 8 8 import 'package:spark/src/core/utils/bluesky_crosspost_text.dart'; 9 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 10 + import 'package:spark/src/core/utils/logging/logger.dart'; 10 11 import 'package:spark/src/core/utils/share_urls.dart'; 11 12 12 13 part 'video_upload_provider.g.dart'; ··· 51 52 }) async { 52 53 final logger = GetIt.I<LogService>().getLogger('Posting Video'); 53 54 try { 54 - logger.d( 55 - 'Posting video (size=${blob.size}, crosspost=$crosspostToBsky, ' 56 - 'sound=${soundRef?.uri})', 55 + return await _postVideoRecord( 56 + logger: logger, 57 + blob: blob, 58 + description: description, 59 + altText: altText, 60 + crosspostToBsky: crosspostToBsky, 61 + soundRef: soundRef, 62 + facets: facets, 57 63 ); 64 + } catch (error, stackTrace) { 65 + logger.e('Error posting video', error: error, stackTrace: stackTrace); 66 + rethrow; 67 + } 68 + } 58 69 59 - final postRecord = PostRecord( 60 - caption: CaptionRef( 61 - text: description.isNotEmpty ? description : '', 62 - facets: facets, 63 - ), 64 - media: Media.video(video: blob, alt: altText), 65 - createdAt: DateTime.now().toUtc(), 66 - sound: soundRef, 67 - ); 68 - 69 - final result = await GetIt.I<SprkRepository>().repo.createRecord( 70 - collection: 'so.sprk.feed.post', 71 - record: postRecord.toJson(), 70 + Future<RepoStrongRef?> postProcessedVideo({ 71 + required VideoUploadResult uploadResult, 72 + String description = '', 73 + String altText = '', 74 + bool crosspostToBsky = false, 75 + bool storyMode = false, 76 + RepoStrongRef? soundRef, 77 + List<Facet> facets = const [], 78 + List<StoryEmbed> storyEmbeds = const [], 79 + }) async { 80 + final logger = GetIt.I<LogService>().getLogger('Post Processed Video') 81 + ..d( 82 + 'Posting already processed video (storyMode=$storyMode, ' 83 + 'sound=${soundRef?.uri})', 72 84 ); 73 85 74 - var finalResult = result; 86 + try { 87 + final videoBlob = uploadResult.videoBlob; 75 88 76 - if (crosspostToBsky) { 89 + var effectiveSoundRef = soundRef; 90 + if (soundRef == null && uploadResult.audioBlob != null) { 91 + logger.d('Creating new sound record from extracted audio'); 77 92 try { 78 - final bskyResult = await _crosspostVideoToBlueSky( 79 - ref, 80 - description, 81 - blob, 82 - altText, 83 - result.uri.rkey, 84 - facets, 93 + final soundRepository = GetIt.I<SprkRepository>().sound; 94 + effectiveSoundRef = await soundRepository.createSound( 95 + sound: uploadResult.audioBlob!, 96 + title: 'Original Sound', 97 + details: uploadResult.audioDetails, 85 98 ); 86 - finalResult = await GetIt.I<SprkRepository>().repo.editRecord( 87 - uri: result.uri, 88 - record: postRecord.copyWith(crossposts: [bskyResult]), 99 + logger.i('Sound record created: ${effectiveSoundRef.uri}'); 100 + } catch (e, s) { 101 + logger.w( 102 + 'Failed to create sound record, proceeding without sound', 103 + error: e, 104 + stackTrace: s, 105 + ); 106 + } 107 + } 108 + 109 + if (storyMode) { 110 + try { 111 + final storyRepository = GetIt.I<StoryRepository>(); 112 + final res = await storyRepository.postStory( 113 + Media.video(video: videoBlob), 114 + soundRef: effectiveSoundRef, 115 + embeds: storyEmbeds, 89 116 ); 117 + logger.i('Story posted: ${res.uri}'); 118 + return res; 90 119 } catch (e, s) { 91 - logger.w('Crosspost to Bluesky failed: $e', error: e, stackTrace: s); 120 + logger.e('Failed to post story', error: e, stackTrace: s); 121 + rethrow; 92 122 } 93 123 } 94 - logger.i('Video posted successfully: ${finalResult.uri}'); 95 - return finalResult; 124 + 125 + final res = await _postVideoRecord( 126 + logger: logger, 127 + blob: videoBlob, 128 + description: description, 129 + altText: altText, 130 + crosspostToBsky: crosspostToBsky, 131 + soundRef: effectiveSoundRef, 132 + facets: facets, 133 + ); 134 + logger.i('Video flow complete (storyMode=false) success=${res != null}'); 135 + return res; 96 136 } catch (error, stackTrace) { 97 - logger.e('Error posting video', error: error, stackTrace: stackTrace); 137 + logger.e( 138 + 'Error posting processed video', 139 + error: error, 140 + stackTrace: stackTrace, 141 + ); 98 142 rethrow; 99 143 } 100 144 } ··· 123 167 throw Exception('Failed to process video'); 124 168 } 125 169 126 - final videoBlob = uploadResult.videoBlob; 170 + return postProcessedVideo( 171 + uploadResult: uploadResult, 172 + description: description, 173 + altText: altText, 174 + crosspostToBsky: crosspostToBsky, 175 + storyMode: storyMode, 176 + soundRef: soundRef, 177 + facets: facets, 178 + storyEmbeds: storyEmbeds, 179 + ); 180 + } 181 + 182 + Future<RepoStrongRef?> _postVideoRecord({ 183 + required SparkLogger logger, 184 + required Blob blob, 185 + required String description, 186 + required String altText, 187 + required bool crosspostToBsky, 188 + required RepoStrongRef? soundRef, 189 + required List<Facet> facets, 190 + }) async { 191 + logger.d( 192 + 'Posting video (size=${blob.size}, crosspost=$crosspostToBsky, ' 193 + 'sound=${soundRef?.uri})', 194 + ); 195 + 196 + final postRecord = PostRecord( 197 + caption: CaptionRef( 198 + text: description.isNotEmpty ? description : '', 199 + facets: facets, 200 + ), 201 + media: Media.video(video: blob, alt: altText), 202 + createdAt: DateTime.now().toUtc(), 203 + sound: soundRef, 204 + ); 127 205 128 - // If no sound selected & video has extracted audio, create new sound record 129 - var effectiveSoundRef = soundRef; 130 - if (soundRef == null && uploadResult.audioBlob != null) { 131 - logger.d('Creating new sound record from extracted audio'); 206 + final result = await GetIt.I<SprkRepository>().repo.createRecord( 207 + collection: 'so.sprk.feed.post', 208 + record: postRecord.toJson(), 209 + ); 210 + 211 + var finalResult = result; 212 + 213 + if (crosspostToBsky) { 132 214 try { 133 - final soundRepository = GetIt.I<SprkRepository>().sound; 134 - effectiveSoundRef = await soundRepository.createSound( 135 - sound: uploadResult.audioBlob!, 136 - title: 'Original Sound', 137 - details: uploadResult.audioDetails, 215 + final bskyResult = await _crosspostVideoToBlueSkyRecord( 216 + description, 217 + blob, 218 + altText, 219 + result.uri.rkey, 220 + facets, 138 221 ); 139 - logger.i('Sound record created: ${effectiveSoundRef.uri}'); 140 - } catch (e, s) { 141 - logger.w( 142 - 'Failed to create sound record, proceeding without sound', 143 - error: e, 144 - stackTrace: s, 222 + finalResult = await GetIt.I<SprkRepository>().repo.editRecord( 223 + uri: result.uri, 224 + record: postRecord.copyWith(crossposts: [bskyResult]), 145 225 ); 146 - } 147 - } 148 - 149 - if (storyMode) { 150 - try { 151 - final storyRepository = GetIt.I<StoryRepository>(); 152 - final res = await storyRepository.postStory( 153 - Media.video(video: videoBlob), 154 - soundRef: effectiveSoundRef, 155 - embeds: storyEmbeds, 156 - ); 157 - logger.i('Story posted: ${res.uri}'); 158 - return res; 159 226 } catch (e, s) { 160 - logger.e('Failed to post story', error: e, stackTrace: s); 161 - rethrow; 227 + logger.w('Crosspost to Bluesky failed: $e', error: e, stackTrace: s); 162 228 } 163 - } else { 164 - final res = await postVideo( 165 - ref, 166 - blob: videoBlob, 167 - description: description, 168 - altText: altText, 169 - videoPath: videoPath, 170 - crosspostToBsky: crosspostToBsky, 171 - soundRef: effectiveSoundRef, 172 - facets: facets, 173 - ); 174 - logger.i('Video flow complete (storyMode=false) success=${res != null}'); 175 - return res; 176 229 } 230 + logger.i('Video posted successfully: ${finalResult.uri}'); 231 + return finalResult; 177 232 } 178 233 179 234 /// Crosspost video to Bluesky using same blob but Bluesky models 180 235 @riverpod 181 236 Future<RepoStrongRef> _crosspostVideoToBlueSky( 182 - Ref ref, 237 + Ref _, 238 + String text, 239 + Blob blob, 240 + String altText, 241 + String rkey, 242 + List<Facet> sparkFacets, 243 + ) async { 244 + return _crosspostVideoToBlueSkyRecord(text, blob, altText, rkey, sparkFacets); 245 + } 246 + 247 + Future<RepoStrongRef> _crosspostVideoToBlueSkyRecord( 183 248 String text, 184 249 Blob blob, 185 250 String altText,
+214 -26
lib/src/features/posting/ui/pages/video_review_page.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:io'; 2 3 3 4 import 'package:atproto/com_atproto_repo_strongref.dart'; ··· 5 6 import 'package:auto_route/auto_route.dart'; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter_riverpod/flutter_riverpod.dart'; 9 + import 'package:get_it/get_it.dart'; 8 10 import 'package:image_picker/image_picker.dart'; 9 11 import 'package:spark/src/core/design_system/templates/video_review_page_template.dart'; 10 12 import 'package:spark/src/core/design_system/tokens/constants.dart'; 13 + import 'package:spark/src/core/network/atproto/atproto.dart'; 11 14 import 'package:spark/src/core/routing/app_router.dart'; 12 15 import 'package:spark/src/core/ui/widgets/alt_text_editor_dialog.dart'; 13 16 import 'package:spark/src/core/utils/error_messages.dart'; ··· 16 19 import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; 17 20 import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; 18 21 import 'package:video_player/video_player.dart'; 22 + 23 + enum _VideoUploadPhase { uploading, processing, ready } 19 24 20 25 @RoutePage() 21 26 class VideoReviewPage extends ConsumerStatefulWidget { ··· 45 50 String _videoAltText = ''; 46 51 bool _crosspostToBsky = false; 47 52 late XFile _video; 53 + late final FeedRepository _feedRepository; 48 54 VideoPlayerController? _player; 55 + VideoUploadResult? _uploadResult; 56 + String? _uploadErrorMessage; 57 + double _uploadProgress = 0; 58 + _VideoUploadPhase? _uploadPhase; 59 + bool _isUploadingVideo = false; 49 60 50 61 @override 51 62 void initState() { 52 63 super.initState(); 53 64 _video = XFile(widget.videoPath); 54 - _initPlayer(); 65 + _feedRepository = GetIt.I<SprkRepository>().feed; 66 + _descriptionController.textController.addListener( 67 + _handleDescriptionChanged, 68 + ); 69 + unawaited( 70 + _initPlayer().whenComplete(() { 71 + if (mounted) { 72 + _startVideoUpload(); 73 + } 74 + }), 75 + ); 55 76 } 56 77 57 78 @override 58 79 void dispose() { 80 + _descriptionController.textController.removeListener( 81 + _handleDescriptionChanged, 82 + ); 59 83 _descriptionController.dispose(); 60 - _player?.dispose(); 84 + final player = _player; 85 + _player = null; 86 + if (player != null) { 87 + unawaited(_disposePlayer(player)); 88 + } 61 89 super.dispose(); 62 90 } 63 91 92 + void _handleDescriptionChanged() { 93 + if (mounted) setState(() {}); 94 + } 95 + 64 96 Future<void> _initPlayer() async { 65 97 final c = VideoPlayerController.file(File(_video.path)); 66 - await c.initialize(); 67 - await c.setLooping(true); 68 - await c.setVolume(1); 69 - if (!mounted) return; 70 - setState(() => _player = c); 71 - c.play(); 98 + try { 99 + await c.initialize(); 100 + if (!mounted) { 101 + await _disposePlayer(c); 102 + return; 103 + } 104 + 105 + await c.setLooping(true); 106 + if (!mounted) { 107 + await _disposePlayer(c); 108 + return; 109 + } 110 + 111 + await c.setVolume(1); 112 + if (!mounted) { 113 + await _disposePlayer(c); 114 + return; 115 + } 116 + 117 + setState(() => _player = c); 118 + unawaited(c.play()); 119 + } catch (_) { 120 + await _disposePlayer(c); 121 + if (!mounted) return; 122 + _showPostError('Unable to preview this video. Please try again.'); 123 + } 124 + } 125 + 126 + Future<void> _pausePlayer(VideoPlayerController player) async { 127 + try { 128 + if (player.value.isPlaying) { 129 + await player.pause(); 130 + } 131 + } catch (_) { 132 + // Best-effort native player cleanup. 133 + } 134 + } 135 + 136 + Future<void> _disposePlayer(VideoPlayerController player) async { 137 + await _pausePlayer(player); 138 + try { 139 + await player.dispose(); 140 + } catch (_) { 141 + // Best-effort native player cleanup. 142 + } 72 143 } 73 144 74 145 Future<void> _editAltText() async { ··· 83 154 }); 84 155 } 85 156 86 - Future<void> _uploadVideo() async { 157 + void _startVideoUpload({bool notify = true}) { 158 + if (_isUploadingVideo) return; 159 + 160 + void updateState() { 161 + _isUploadingVideo = true; 162 + _uploadPhase = _VideoUploadPhase.uploading; 163 + _uploadProgress = 0; 164 + _uploadResult = null; 165 + _uploadErrorMessage = null; 166 + } 167 + 168 + if (notify && mounted) { 169 + setState(updateState); 170 + } else { 171 + updateState(); 172 + } 173 + 174 + unawaited(_uploadVideoForReview()); 175 + } 176 + 177 + Future<void> _uploadVideoForReview() async { 178 + try { 179 + final result = await _feedRepository.uploadVideo( 180 + _video.path, 181 + onUploadProgress: (progress) { 182 + final phase = progress >= 1 183 + ? _VideoUploadPhase.processing 184 + : _VideoUploadPhase.uploading; 185 + _handleUploadProgress(phase, progress); 186 + }, 187 + ); 188 + 189 + if (!mounted) return; 190 + setState(() { 191 + _uploadResult = result; 192 + _uploadPhase = _VideoUploadPhase.ready; 193 + _uploadProgress = 1; 194 + _uploadErrorMessage = null; 195 + _isUploadingVideo = false; 196 + }); 197 + } catch (e) { 198 + if (!mounted) return; 199 + setState(() { 200 + _uploadErrorMessage = ErrorMessages.getUserFriendlyMessage(e); 201 + _isUploadingVideo = false; 202 + }); 203 + } 204 + } 205 + 206 + void _handleUploadProgress(_VideoUploadPhase phase, double progress) { 207 + if (!mounted) return; 208 + final nextProgress = progress.clamp(0, 1).toDouble(); 209 + final currentPercent = (_uploadProgress * 100).floor(); 210 + final nextPercent = (nextProgress * 100).floor(); 211 + if (_uploadPhase == phase && 212 + currentPercent == nextPercent && 213 + nextProgress < 1) { 214 + return; 215 + } 216 + 217 + setState(() { 218 + _uploadPhase = phase; 219 + _uploadProgress = nextProgress; 220 + }); 221 + } 222 + 223 + String? get _uploadStatusLabel { 224 + if (_uploadErrorMessage != null) return _uploadErrorMessage; 225 + return switch (_uploadPhase) { 226 + _VideoUploadPhase.uploading => 'Uploading video', 227 + _VideoUploadPhase.processing => 'Processing video', 228 + _VideoUploadPhase.ready => 'Ready to post', 229 + null => null, 230 + }; 231 + } 232 + 233 + String get _postLabel { 234 + if (_uploadErrorMessage != null) return 'Upload failed'; 235 + if (_uploadResult != null) return 'Post'; 236 + final percent = (_uploadProgress * 100).round(); 237 + switch (_uploadPhase) { 238 + case _VideoUploadPhase.uploading: 239 + return 'Uploading $percent%'; 240 + case _VideoUploadPhase.processing: 241 + return 'Processing video'; 242 + case _VideoUploadPhase.ready: 243 + return 'Post'; 244 + case null: 245 + return 'Uploading video'; 246 + } 247 + } 248 + 249 + Future<void> _postVideo() async { 87 250 if (_isPosting) return; 251 + final uploadResult = _uploadResult; 252 + if (uploadResult == null) { 253 + if (_uploadErrorMessage != null) { 254 + _startVideoUpload(); 255 + return; 256 + } 257 + _showPostError('Video is still uploading. Please wait for it to finish.'); 258 + return; 259 + } 88 260 89 261 setState(() { 90 262 _isPosting = true; ··· 94 266 final description = _descriptionController.text; 95 267 final facets = _descriptionController.buildFacets(); 96 268 97 - // Process and post the video with the video upload provider 98 - final postRef = await ref.read( 99 - processAndPostVideoProvider( 100 - videoPath: _video.path, 101 - description: description, 102 - altText: _videoAltText, 103 - storyMode: widget.storyMode, 104 - soundRef: widget.soundRef, 105 - crosspostToBsky: !widget.storyMode && _crosspostToBsky, 106 - facets: facets, 107 - ).future, 269 + final postRef = await postProcessedVideo( 270 + uploadResult: uploadResult, 271 + description: description, 272 + altText: _videoAltText, 273 + storyMode: widget.storyMode, 274 + soundRef: widget.soundRef, 275 + crosspostToBsky: !widget.storyMode && _crosspostToBsky, 276 + facets: facets, 108 277 ); 109 278 110 279 if (!mounted) return; ··· 128 297 ); 129 298 } 130 299 300 + final player = _player; 301 + if (player != null) { 302 + await _pausePlayer(player); 303 + if (!mounted) return; 304 + } 305 + 131 306 final router = context.router; 132 307 router.popUntilRoot(); 133 308 if (!widget.storyMode) { ··· 157 332 : 1.0; 158 333 final textLength = _descriptionController.text.runes.length; 159 334 final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; 335 + final uploadStatusLabel = _uploadStatusLabel; 336 + final canPost = 337 + !_isPosting && 338 + _uploadResult != null && 339 + _uploadErrorMessage == null && 340 + !isOverLimit; 160 341 161 342 return VideoReviewPageTemplate( 162 343 title: 'Review Video', ··· 166 347 ? const Center(child: CircularProgressIndicator()) 167 348 : VideoPlayer(_player!), 168 349 onAltEdit: _editAltText, 350 + uploadProgress: _uploadProgress, 351 + uploadStatusLabel: uploadStatusLabel, 352 + uploadIndeterminate: _uploadPhase == _VideoUploadPhase.processing, 353 + hasUploadError: _uploadErrorMessage != null, 354 + onUploadRetry: _uploadErrorMessage == null 355 + ? null 356 + : () => _startVideoUpload(), 169 357 mentionController: _descriptionController, 170 358 onMentionsChanged: (mentions) { 171 359 // Mentions are automatically tracked in the controller ··· 174 362 showCrossPost: !widget.storyMode, 175 363 crossPostValue: _crosspostToBsky, 176 364 onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), 177 - postLabel: 'Post', 365 + postLabel: _postLabel, 178 366 isPosting: _isPosting, 179 367 isOverLimit: isOverLimit, 180 - onPost: _isPosting 181 - ? null 182 - : () async { 183 - await _uploadVideo(); 184 - }, 368 + onPost: canPost 369 + ? () async { 370 + await _postVideo(); 371 + } 372 + : null, 185 373 ); 186 374 } 187 375 }