[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(editor): video trimming

+610 -141
+83 -17
lib/src/core/pro_video_editor/ui/video_editor_grounded_page.dart
··· 69 69 /// Indicates whether a seek operation is in progress. 70 70 bool _isSeeking = false; 71 71 72 + /// Tracks whether reaching the end should jump back to the active span start. 73 + /// 74 + /// This is enabled only after the user explicitly starts playback, so manual 75 + /// scrubbing to the end does not snap the editor back to the beginning. 76 + bool _shouldResetOnPlaybackComplete = false; 77 + 72 78 /// Stores the currently selected trim duration span. 73 79 TrimDurationSpan? _durationSpan; 74 80 ··· 223 229 onToggleMute: _onToggleMute, 224 230 onAddSound: _showAudioSelectionBottomSheet, 225 231 onToggleFullscreen: _openFullscreenPreview, 232 + onTrimChanged: _onTrimChanged, 233 + onTrimEnd: _onTrimEnd, 226 234 onMention: widget.storyMode ? addStoryMention : null, 227 235 onDone: widget.storyMode ? finishStoryEditing : null, 228 236 ); ··· 349 357 // Update audio timeline progress 350 358 _videoTimelineState.setProgressFromDuration(duration); 351 359 352 - if (_durationSpan != null && duration >= _durationSpan!.end) { 360 + if (_durationSpan != null && duration < _durationSpan!.start) { 361 + _seekToPosition(_durationSpan!); 362 + } else if (_durationSpan != null && 363 + duration >= _durationSpan!.end && 364 + _shouldResetOnPlaybackComplete) { 365 + _shouldResetOnPlaybackComplete = false; 353 366 _seekToPosition(_durationSpan!); 354 - } else if (duration >= totalVideoDuration) { 367 + } else if (duration >= totalVideoDuration && 368 + _shouldResetOnPlaybackComplete) { 369 + _shouldResetOnPlaybackComplete = false; 355 370 _seekToPosition( 356 371 TrimDurationSpan(start: Duration.zero, end: totalVideoDuration), 357 372 ); ··· 359 374 } 360 375 361 376 Future<void> _seekToPosition(TrimDurationSpan span) async { 377 + await _seekToTrimPosition(span, span.start); 378 + } 379 + 380 + Future<void> _seekToTrimPosition( 381 + TrimDurationSpan span, 382 + Duration targetPosition, 383 + ) async { 362 384 _durationSpan = span; 363 385 364 386 if (_isSeeking) { 365 - _tempDurationSpan = span; // Store the latest seek request 387 + _tempDurationSpan = span; 366 388 return; 367 389 } 368 390 _isSeeking = true; 369 391 370 392 _proVideoController!.pause(); 371 - _proVideoController!.setPlayTime(_durationSpan!.start); 393 + _proVideoController!.setPlayTime(targetPosition); 372 394 373 395 await _videoController.pause(); 374 - await _videoController.seekTo(span.start); 396 + await _videoController.seekTo(targetPosition); 397 + _videoTimelineState.setProgressFromDuration(targetPosition); 375 398 376 399 _isSeeking = false; 377 400 378 - // Check if there's a pending seek request 379 401 if (_tempDurationSpan != null) { 380 402 final nextSeek = _tempDurationSpan!; 381 - _tempDurationSpan = null; // Clear the pending seek 382 - await _seekToPosition(nextSeek); // Process the latest request 403 + _tempDurationSpan = null; 404 + await _seekToTrimPosition(nextSeek, nextSeek.start); 383 405 } 384 406 } 385 407 386 408 void _onTimelineSeek(double progress) { 409 + _shouldResetOnPlaybackComplete = false; 387 410 final duration = _videoMetadata.duration; 411 + final targetProgress = progress 412 + .clamp(_videoTimelineState.trimStart, _videoTimelineState.trimEnd) 413 + .toDouble(); 388 414 final targetPosition = Duration( 389 - milliseconds: (duration.inMilliseconds * progress).round(), 415 + milliseconds: (duration.inMilliseconds * targetProgress).round(), 390 416 ); 391 417 392 418 _videoController.seekTo(targetPosition); ··· 395 421 } 396 422 397 423 void _onTogglePlay() { 398 - // Use ProVideoController to toggle play state - this updates the internal 399 - // overlay and triggers the video player callbacks we've configured 400 424 _proVideoController?.togglePlayState(); 401 425 } 402 426 403 427 void _onToggleMute() { 404 - // Use ProVideoController to toggle mute state - this updates the internal 405 - // state and triggers our configured onMuteToggle callback 406 428 final isMuted = _proVideoController?.isMutedNotifier.value ?? false; 407 429 _proVideoController?.setMuteState(!isMuted); 408 430 } 409 431 432 + void _onTrimChanged(double start, double end) { 433 + _shouldResetOnPlaybackComplete = false; 434 + if (_videoController.value.isPlaying) { 435 + _proVideoController?.pause(); 436 + } 437 + _setTrimSpan(_spanFromTrimFractions(start, end)); 438 + } 439 + 440 + Future<void> _onTrimEnd(double start, double end, bool isStartHandle) async { 441 + final span = _spanFromTrimFractions(start, end); 442 + _setTrimSpan(span); 443 + if (isStartHandle) { 444 + await _seekToPosition(span); 445 + return; 446 + } 447 + 448 + final currentPosition = _videoController.value.position; 449 + final clampedPosition = currentPosition < span.start 450 + ? span.start 451 + : (currentPosition > span.end ? span.end : currentPosition); 452 + await _seekToTrimPosition(span, clampedPosition); 453 + } 454 + 455 + TrimDurationSpan _spanFromTrimFractions(double start, double end) { 456 + final totalMs = _videoMetadata.duration.inMilliseconds; 457 + final clampedStart = start.clamp(0.0, 1.0).toDouble(); 458 + final clampedEnd = end.clamp(0.0, 1.0).toDouble(); 459 + final trimStart = math.min(clampedStart, clampedEnd); 460 + final trimEnd = math.max(clampedStart, clampedEnd); 461 + return TrimDurationSpan( 462 + start: Duration(milliseconds: (trimStart * totalMs).round()), 463 + end: Duration(milliseconds: (trimEnd * totalMs).round()), 464 + ); 465 + } 466 + 467 + void _setTrimSpan(TrimDurationSpan span) { 468 + _durationSpan = span; 469 + _proVideoController?.setTrimSpan(span); 470 + } 471 + 410 472 Future<void> _openFullscreenPreview() async { 411 473 if (!mounted) return; 412 474 if (_proVideoController == null) return; ··· 552 614 colorFilters: parameters.colorFilters 553 615 .map((matrix) => ColorFilter(matrix: matrix)) 554 616 .toList(), 555 - startTime: parameters.startTime, 556 - endTime: parameters.endTime, 617 + startTime: _durationSpan?.start ?? parameters.startTime, 618 + endTime: _durationSpan?.end ?? parameters.endTime, 557 619 transform: exportTransform, 558 620 bitrate: shouldCompressForUpload 559 621 ? _uploadCompressionBitrate ··· 632 694 } 633 695 634 696 bool _hasTrim(CompleteParameters parameters) { 635 - final startTime = parameters.startTime ?? Duration.zero; 636 - final endTime = parameters.endTime ?? _videoMetadata.duration; 697 + final startTime = 698 + _durationSpan?.start ?? parameters.startTime ?? Duration.zero; 699 + final endTime = 700 + _durationSpan?.end ?? parameters.endTime ?? _videoMetadata.duration; 637 701 638 702 return startTime > _trimTolerance || 639 703 _videoMetadata.duration - endTime > _trimTolerance; ··· 841 905 onCloseEditor: onCloseEditor, 842 906 videoEditorCallbacks: VideoEditorCallbacks( 843 907 onPause: () { 908 + _shouldResetOnPlaybackComplete = false; 844 909 _videoController.pause(); 845 910 _videoTimelineState.setPlaying(isPlaying: false); 846 911 }, 847 912 onPlay: () { 913 + _shouldResetOnPlaybackComplete = true; 848 914 _videoController.play(); 849 915 _videoTimelineState.setPlaying(isPlaying: true); 850 916 },
+4
lib/src/core/pro_video_editor/ui/widgets/common/video_editor_configs_builder.dart
··· 66 66 required VoidCallback onToggleMute, 67 67 required VoidCallback onAddSound, 68 68 required VoidCallback onToggleFullscreen, 69 + void Function(double start, double end)? onTrimChanged, 70 + void Function(double start, double end, bool isStartHandle)? onTrimEnd, 69 71 Future<void> Function()? onMention, 70 72 void Function(ProImageEditorState editor)? onDone, 71 73 bool storyMode = false, ··· 122 124 onToggleMute: onToggleMute, 123 125 onAddSound: onAddSound, 124 126 onToggleFullscreen: onToggleFullscreen, 127 + onTrimChanged: onTrimChanged, 128 + onTrimEnd: onTrimEnd, 125 129 ); 126 130 }, 127 131 stream: rebuildStream,
+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 + this.onTrimChanged, 18 + this.onTrimEnd, 17 19 super.key, 18 20 }); 19 21 ··· 24 26 final VoidCallback onToggleMute; 25 27 final VoidCallback onAddSound; 26 28 final VoidCallback onToggleFullscreen; 29 + final void Function(double start, double end)? onTrimChanged; 30 + final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 27 31 28 32 @override 29 33 Widget build(BuildContext context) { ··· 41 45 onAddSound: onAddSound, 42 46 onSeek: onSeek, 43 47 onToggleFullscreen: onToggleFullscreen, 48 + onTrimChanged: onTrimChanged, 49 + onTrimEnd: onTrimEnd, 44 50 canUndo: editor.canUndo, 45 51 canRedo: editor.canRedo, 46 52 ),
+12 -7
lib/src/core/pro_video_editor/ui/widgets/player/video_fullscreen_preview_page.dart
··· 164 164 child: AnimatedBuilder( 165 165 animation: videoTimelineState, 166 166 builder: (context, _) { 167 - final duration = videoTimelineState.videoDuration; 167 + final duration = videoTimelineState.trimmedDuration; 168 168 final isDurationValid = duration.inMilliseconds > 0; 169 169 final progress = isDurationValid 170 - ? videoTimelineState.progress.clamp(0.0, 1.0) 170 + ? videoTimelineState.trimmedProgress 171 171 : 0.0; 172 - final currentPosition = Duration( 173 - milliseconds: (progress * duration.inMilliseconds) 174 - .round(), 175 - ); 172 + final currentPosition = 173 + videoTimelineState.trimmedPosition; 176 174 177 175 return Column( 178 176 mainAxisSize: MainAxisSize.min, ··· 225 223 ), 226 224 child: Slider( 227 225 value: progress, 228 - onChanged: isDurationValid ? onSeek : null, 226 + onChanged: isDurationValid 227 + ? (value) => onSeek( 228 + videoTimelineState 229 + .sourceProgressFromTrimmedProgress( 230 + value, 231 + ), 232 + ) 233 + : null, 229 234 ), 230 235 ), 231 236 ],
+383 -108
lib/src/core/pro_video_editor/ui/widgets/timeline/scrollable_timeline.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:math' as math; 3 + 2 4 import 'package:cached_network_image/cached_network_image.dart'; 3 5 import 'package:flutter/material.dart'; 4 6 import 'package:spark/src/core/design_system/tokens/colors.dart'; 5 7 import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; 6 8 7 - /// A scrollable video timeline widget that displays thumbnails, audio track, 8 - /// and a time ruler. The timeline scrolls horizontally & auto-follows playhead. 9 + const _kHandleWidth = 12.0; 10 + 11 + const _kCapWidth = 6.0; 12 + const _kCapHeight = 2.5; 13 + const _kMinTrimDuration = Duration(seconds: 1); 14 + 15 + enum _TrimHandleSide { start, end } 16 + 9 17 class ScrollableTimeline extends StatefulWidget { 10 18 const ScrollableTimeline({ 11 19 required this.videoTimelineState, 12 20 required this.onSeek, 13 21 required this.onAddSound, 22 + this.onTrimChanged, 23 + this.onTrimEnd, 14 24 this.thumbnailHeight = 56, 15 25 this.audioTrackHeight = 44, 16 26 this.rulerHeight = 24, ··· 21 31 final VideoTimelineState videoTimelineState; 22 32 final void Function(double progress) onSeek; 23 33 final VoidCallback onAddSound; 34 + final void Function(double start, double end)? onTrimChanged; 35 + final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 24 36 final double thumbnailHeight; 25 37 final double audioTrackHeight; 26 38 final double rulerHeight; ··· 34 46 late ScrollController _scrollController; 35 47 bool _isUserScrolling = false; 36 48 bool _isProgrammaticScroll = false; 49 + bool _isDraggingHandle = false; 50 + bool _trimModeActive = false; 51 + _TrimHandleSide? _activeTrimHandle; 37 52 Timer? _scrollEndTimer; 38 53 39 - double get _totalWidth => 40 - widget.videoTimelineState.videoDuration.inMilliseconds / 41 - 1000 * 42 - widget.pixelsPerSecond; 54 + bool get _canTrim => widget.onTrimChanged != null || widget.onTrimEnd != null; 55 + 56 + double get _sourceWidth => math.max( 57 + 1.0, 58 + widget.videoTimelineState.videoDuration.inMilliseconds / 59 + 1000 * 60 + widget.pixelsPerSecond, 61 + ); 62 + 63 + double get _timelineWidth => 64 + math.max(1.0, _sourceWidth * widget.videoTimelineState.trimSpanFraction); 43 65 44 66 double get _totalHeight => 45 67 widget.rulerHeight + widget.thumbnailHeight + 8 + widget.audioTrackHeight; 46 68 69 + double get _minTrimFraction { 70 + final ms = widget.videoTimelineState.videoDuration.inMilliseconds; 71 + if (ms <= 0) return 0.0; 72 + return _kMinTrimDuration.inMilliseconds / ms; 73 + } 74 + 47 75 @override 48 76 void initState() { 49 77 super.initState(); 50 78 _scrollController = ScrollController(); 79 + _scrollController.addListener(_onScrollChange); 51 80 widget.videoTimelineState.addListener(_onProgressChange); 52 81 } 53 82 54 83 @override 55 84 void dispose() { 56 85 widget.videoTimelineState.removeListener(_onProgressChange); 86 + _scrollController.removeListener(_onScrollChange); 57 87 _scrollEndTimer?.cancel(); 58 88 _scrollController.dispose(); 59 89 super.dispose(); 60 90 } 61 91 92 + void _onScrollChange() { 93 + if (mounted) setState(() {}); 94 + } 95 + 62 96 void _onProgressChange() { 63 - if (_isUserScrolling || !_scrollController.hasClients) return; 97 + if (_isUserScrolling || 98 + _isDraggingHandle || 99 + !_scrollController.hasClients) { 100 + return; 101 + } 64 102 65 - final targetScroll = widget.videoTimelineState.progress * _totalWidth; 103 + final targetScroll = 104 + widget.videoTimelineState.trimmedProgress * _timelineWidth; 66 105 67 - final clampedScroll = targetScroll.clamp( 68 - 0.0, 69 - _scrollController.position.maxScrollExtent, 70 - ); 106 + final clampedScroll = targetScroll 107 + .clamp(0.0, _scrollController.position.maxScrollExtent) 108 + .toDouble(); 71 109 72 110 _isProgrammaticScroll = true; 73 111 _scrollController ··· 82 120 void _onScrollUpdate() { 83 121 if (!_scrollController.hasClients) return; 84 122 85 - final progress = (_scrollController.offset / _totalWidth).clamp(0.0, 1.0); 86 - widget.onSeek(progress); 123 + final progress = (_scrollController.offset / _timelineWidth) 124 + .clamp(0.0, 1.0) 125 + .toDouble(); 126 + widget.onSeek( 127 + widget.videoTimelineState.sourceProgressFromTrimmedProgress(progress), 128 + ); 129 + } 130 + 131 + void _onThumbnailTap() { 132 + if (!_canTrim) return; 133 + setState(() => _trimModeActive = !_trimModeActive); 134 + } 135 + 136 + void _onTrimStartDragStart(DragStartDetails _) { 137 + _isDraggingHandle = true; 138 + _activeTrimHandle = _TrimHandleSide.start; 139 + } 140 + 141 + void _onTrimStartDragUpdate(DragUpdateDetails details) { 142 + final delta = details.delta.dx / _sourceWidth; 143 + final state = widget.videoTimelineState; 144 + final maxStart = (state.trimEnd - _minTrimFraction) 145 + .clamp(0.0, 1.0) 146 + .toDouble(); 147 + final newStart = (state.trimStart + delta).clamp(0.0, maxStart).toDouble(); 148 + _updateTrim(newStart, state.trimEnd); 149 + } 150 + 151 + void _onTrimEndDragStart(DragStartDetails _) { 152 + _isDraggingHandle = true; 153 + _activeTrimHandle = _TrimHandleSide.end; 154 + } 155 + 156 + void _onTrimEndDragUpdate(DragUpdateDetails details) { 157 + final delta = details.delta.dx / _sourceWidth; 158 + final state = widget.videoTimelineState; 159 + final minEnd = (state.trimStart + _minTrimFraction) 160 + .clamp(0.0, 1.0) 161 + .toDouble(); 162 + final newEnd = (state.trimEnd + delta).clamp(minEnd, 1.0).toDouble(); 163 + _updateTrim(state.trimStart, newEnd); 164 + } 165 + 166 + void _onTrimDragEnd(DragEndDetails _) { 167 + _isDraggingHandle = false; 168 + final state = widget.videoTimelineState; 169 + final activeTrimHandle = _activeTrimHandle; 170 + _activeTrimHandle = null; 171 + widget.onTrimEnd?.call( 172 + state.trimStart, 173 + state.trimEnd, 174 + activeTrimHandle == _TrimHandleSide.start, 175 + ); 176 + } 177 + 178 + void _updateTrim(double start, double end) { 179 + widget.videoTimelineState.setTrimRange(start, end); 180 + widget.onTrimChanged?.call(start, end); 181 + } 182 + 183 + Widget _buildTrimFrameLine({ 184 + required double left, 185 + required double width, 186 + required double top, 187 + }) { 188 + return Positioned( 189 + left: left, 190 + width: width, 191 + top: top, 192 + height: 2, 193 + child: IgnorePointer(child: Container(color: AppColors.greyWhite)), 194 + ); 195 + } 196 + 197 + Widget _buildTrimHandle({ 198 + required double left, 199 + required bool isLeft, 200 + required GestureDragStartCallback onDragStart, 201 + required GestureDragUpdateCallback onDragUpdate, 202 + }) { 203 + return Positioned( 204 + left: left, 205 + top: widget.rulerHeight, 206 + width: _kHandleWidth + _kCapWidth, 207 + height: widget.thumbnailHeight, 208 + child: GestureDetector( 209 + behavior: HitTestBehavior.opaque, 210 + onHorizontalDragStart: onDragStart, 211 + onHorizontalDragUpdate: onDragUpdate, 212 + onHorizontalDragEnd: _onTrimDragEnd, 213 + child: CustomPaint(painter: _TrimHandlePainter(isLeft: isLeft)), 214 + ), 215 + ); 87 216 } 88 217 89 218 @override ··· 94 223 return LayoutBuilder( 95 224 builder: (context, constraints) { 96 225 final viewportWidth = constraints.maxWidth; 226 + final scrollOffset = _scrollController.hasClients 227 + ? _scrollController.offset 228 + : 0.0; 229 + final sourceWidth = _sourceWidth; 230 + final timelineWidth = _timelineWidth; 231 + final sourceOffset = 232 + widget.videoTimelineState.trimStart * sourceWidth; 233 + final trimStartVp = viewportWidth / 2 - scrollOffset; 234 + final trimEndVp = viewportWidth / 2 + timelineWidth - scrollOffset; 235 + final frameStartVp = trimStartVp 236 + .clamp(0.0, viewportWidth) 237 + .toDouble(); 238 + final frameEndVp = trimEndVp.clamp(0.0, viewportWidth).toDouble(); 239 + final frameWidth = (frameEndVp - frameStartVp) 240 + .clamp(0.0, viewportWidth) 241 + .toDouble(); 242 + final leftHandleLeft = trimStartVp - _kHandleWidth; 243 + final rightHandleLeft = trimEndVp; 97 244 98 245 return SizedBox( 99 246 height: _totalHeight, 100 247 child: Stack( 248 + clipBehavior: Clip.hardEdge, 101 249 children: [ 102 250 NotificationListener<ScrollNotification>( 103 251 onNotification: (notification) { 104 252 if (notification is ScrollStartNotification) { 105 - // Only mark user scrolling if not programmatic scroll 106 253 if (!_isProgrammaticScroll) { 107 254 _scrollEndTimer?.cancel(); 108 255 _isUserScrolling = true; ··· 112 259 _scrollEndTimer = Timer( 113 260 const Duration(milliseconds: 300), 114 261 () { 115 - if (mounted) { 116 - _isUserScrolling = false; 117 - } 262 + if (mounted) _isUserScrolling = false; 118 263 }, 119 264 ); 120 265 } else if (notification is ScrollUpdateNotification) { ··· 129 274 scrollDirection: Axis.horizontal, 130 275 physics: const ClampingScrollPhysics(), 131 276 child: SizedBox( 132 - width: _totalWidth + viewportWidth, 277 + width: timelineWidth + viewportWidth, 133 278 child: Padding( 134 279 padding: EdgeInsets.symmetric( 135 280 horizontal: viewportWidth / 2, ··· 137 282 child: Column( 138 283 children: [ 139 284 _TimeRuler( 140 - totalWidth: _totalWidth, 285 + totalWidth: timelineWidth, 141 286 pixelsPerSecond: widget.pixelsPerSecond, 142 287 height: widget.rulerHeight, 143 288 videoDuration: 144 - widget.videoTimelineState.videoDuration, 289 + widget.videoTimelineState.trimmedDuration, 145 290 ), 146 - _VideoThumbnailTrack( 147 - totalWidth: _totalWidth, 148 - height: widget.thumbnailHeight, 149 - videoTimelineState: widget.videoTimelineState, 291 + GestureDetector( 292 + onTap: _onThumbnailTap, 293 + child: _VideoThumbnailTrack( 294 + totalWidth: timelineWidth, 295 + sourceWidth: sourceWidth, 296 + sourceOffset: sourceOffset, 297 + height: widget.thumbnailHeight, 298 + videoTimelineState: widget.videoTimelineState, 299 + ), 150 300 ), 151 301 const SizedBox(height: 8), 152 302 _AudioTrack( 153 - totalWidth: _totalWidth, 303 + totalWidth: timelineWidth, 304 + sourceWidth: sourceWidth, 305 + sourceOffset: sourceOffset, 154 306 height: widget.audioTrackHeight, 155 307 videoTimelineState: widget.videoTimelineState, 156 308 onAddSound: widget.onAddSound, ··· 162 314 ), 163 315 ), 164 316 ), 165 - // Fixed playhead in center 317 + if (_trimModeActive && _canTrim) ...[ 318 + _buildTrimFrameLine( 319 + left: frameStartVp, 320 + width: frameWidth, 321 + top: widget.rulerHeight, 322 + ), 323 + _buildTrimFrameLine( 324 + left: frameStartVp, 325 + width: frameWidth, 326 + top: widget.rulerHeight + widget.thumbnailHeight - 2, 327 + ), 328 + _buildTrimHandle( 329 + left: leftHandleLeft, 330 + isLeft: true, 331 + onDragStart: _onTrimStartDragStart, 332 + onDragUpdate: _onTrimStartDragUpdate, 333 + ), 334 + _buildTrimHandle( 335 + left: rightHandleLeft - _kCapWidth, 336 + isLeft: false, 337 + onDragStart: _onTrimEndDragStart, 338 + onDragUpdate: _onTrimEndDragUpdate, 339 + ), 340 + ], 166 341 Positioned( 167 342 left: viewportWidth / 2 - 1, 168 343 top: 0, ··· 193 368 } 194 369 } 195 370 371 + class _TrimHandlePainter extends CustomPainter { 372 + const _TrimHandlePainter({required this.isLeft}); 373 + 374 + final bool isLeft; 375 + 376 + @override 377 + void paint(Canvas canvas, Size size) { 378 + final fillPaint = Paint() 379 + ..color = AppColors.greyWhite 380 + ..style = PaintingStyle.fill; 381 + 382 + final barLeft = isLeft ? 0.0 : size.width - _kHandleWidth; 383 + canvas.drawRRect( 384 + RRect.fromRectAndCorners( 385 + Rect.fromLTWH(barLeft, 0, _kHandleWidth, size.height), 386 + topLeft: isLeft ? const Radius.circular(3) : Radius.zero, 387 + bottomLeft: isLeft ? const Radius.circular(3) : Radius.zero, 388 + topRight: isLeft ? Radius.zero : const Radius.circular(3), 389 + bottomRight: isLeft ? Radius.zero : const Radius.circular(3), 390 + ), 391 + fillPaint, 392 + ); 393 + 394 + final capLeft = isLeft ? _kHandleWidth : 0.0; 395 + final capWidth = size.width - _kHandleWidth; 396 + canvas.drawRect( 397 + Rect.fromLTWH(capLeft, 0, capWidth, _kCapHeight), 398 + fillPaint, 399 + ); 400 + canvas.drawRect( 401 + Rect.fromLTWH(capLeft, size.height - _kCapHeight, capWidth, _kCapHeight), 402 + fillPaint, 403 + ); 404 + 405 + final gripPaint = Paint() 406 + ..color = AppColors.grey500 407 + ..strokeWidth = 1.5 408 + ..strokeCap = StrokeCap.round; 409 + final gripX = barLeft + _kHandleWidth / 2; 410 + canvas.drawLine( 411 + Offset(gripX, size.height * 0.35), 412 + Offset(gripX, size.height * 0.45), 413 + gripPaint, 414 + ); 415 + canvas.drawLine( 416 + Offset(gripX, size.height * 0.55), 417 + Offset(gripX, size.height * 0.65), 418 + gripPaint, 419 + ); 420 + } 421 + 422 + @override 423 + bool shouldRepaint(covariant _TrimHandlePainter old) => old.isLeft != isLeft; 424 + } 425 + 196 426 class _TimeRuler extends StatelessWidget { 197 427 const _TimeRuler({ 198 428 required this.totalWidth, ··· 303 533 class _VideoThumbnailTrack extends StatelessWidget { 304 534 const _VideoThumbnailTrack({ 305 535 required this.totalWidth, 536 + required this.sourceWidth, 537 + required this.sourceOffset, 306 538 required this.height, 307 539 required this.videoTimelineState, 308 540 }); 309 541 310 542 final double totalWidth; 543 + final double sourceWidth; 544 + final double sourceOffset; 311 545 final double height; 312 546 final VideoTimelineState videoTimelineState; 313 547 ··· 324 558 border: Border.all(color: AppColors.grey500), 325 559 ), 326 560 clipBehavior: Clip.antiAlias, 327 - child: thumbnails == null || thumbnails.isEmpty 328 - ? _buildSkeleton() 329 - : Row( 330 - children: thumbnails.map((thumbnail) { 331 - return Expanded( 332 - child: Image( 333 - image: thumbnail, 334 - fit: BoxFit.cover, 335 - height: height, 336 - ), 337 - ); 338 - }).toList(), 339 - ), 561 + child: _buildSourceViewport( 562 + thumbnails == null || thumbnails.isEmpty 563 + ? _buildSkeleton() 564 + : Row( 565 + children: thumbnails.map((thumbnail) { 566 + return Expanded( 567 + child: Image( 568 + image: thumbnail, 569 + fit: BoxFit.cover, 570 + height: height, 571 + ), 572 + ); 573 + }).toList(), 574 + ), 575 + ), 576 + ); 577 + } 578 + 579 + Widget _buildSourceViewport(Widget child) { 580 + return ClipRect( 581 + child: OverflowBox( 582 + alignment: Alignment.centerLeft, 583 + minWidth: sourceWidth, 584 + maxWidth: sourceWidth, 585 + minHeight: height, 586 + maxHeight: height, 587 + child: Transform.translate( 588 + offset: Offset(-sourceOffset, 0), 589 + child: SizedBox(width: sourceWidth, height: height, child: child), 590 + ), 591 + ), 340 592 ); 341 593 } 342 594 ··· 357 609 class _AudioTrack extends StatelessWidget { 358 610 const _AudioTrack({ 359 611 required this.totalWidth, 612 + required this.sourceWidth, 613 + required this.sourceOffset, 360 614 required this.height, 361 615 required this.videoTimelineState, 362 616 required this.onAddSound, ··· 364 618 }); 365 619 366 620 final double totalWidth; 621 + final double sourceWidth; 622 + final double sourceOffset; 367 623 final double height; 368 624 final VideoTimelineState videoTimelineState; 369 625 final VoidCallback onAddSound; ··· 391 647 borderRadius: BorderRadius.circular(6), 392 648 ), 393 649 clipBehavior: Clip.antiAlias, 394 - child: Stack( 395 - children: [ 396 - // Waveform 397 - Positioned.fill( 398 - child: CustomPaint( 399 - painter: _AudioWaveformPainter( 400 - waveformData: videoTimelineState.customWaveformData, 401 - totalWidth: totalWidth, 402 - pixelsPerSecond: pixelsPerSecond, 403 - ), 404 - ), 405 - ), 406 - // Audio info overlay at the start 407 - Positioned( 408 - left: 0, 409 - top: 0, 410 - bottom: 0, 411 - child: Container( 412 - padding: const EdgeInsets.symmetric(horizontal: 10), 413 - decoration: BoxDecoration( 414 - gradient: LinearGradient( 415 - colors: [ 416 - AppColors.primary700, 417 - AppColors.primary700.withAlpha(200), 418 - Colors.transparent, 419 - ], 420 - stops: const [0.0, 0.7, 1.0], 421 - ), 422 - ), 423 - child: Row( 424 - mainAxisSize: MainAxisSize.min, 650 + child: ClipRect( 651 + child: OverflowBox( 652 + alignment: Alignment.centerLeft, 653 + minWidth: sourceWidth, 654 + maxWidth: sourceWidth, 655 + minHeight: height, 656 + maxHeight: height, 657 + child: Transform.translate( 658 + offset: Offset(-sourceOffset, 0), 659 + child: SizedBox( 660 + width: sourceWidth, 661 + height: height, 662 + child: Stack( 425 663 children: [ 426 - if (videoTimelineState.authorAvatarUrl != null) 427 - ClipRRect( 428 - borderRadius: BorderRadius.circular(12), 429 - child: CachedNetworkImage( 430 - fadeInDuration: Duration.zero, 431 - imageUrl: videoTimelineState.authorAvatarUrl!, 432 - width: 24, 433 - height: 24, 434 - fit: BoxFit.cover, 435 - placeholder: (_, _) => _buildAvatarPlaceholder(), 436 - errorWidget: (_, _, _) => _buildAvatarPlaceholder(), 664 + // Waveform 665 + Positioned.fill( 666 + child: CustomPaint( 667 + painter: _AudioWaveformPainter( 668 + waveformData: videoTimelineState.customWaveformData, 669 + totalWidth: sourceWidth, 670 + pixelsPerSecond: pixelsPerSecond, 437 671 ), 438 - ) 439 - else 440 - const Icon( 441 - Icons.music_note, 442 - color: AppColors.greyWhite, 443 - size: 18, 444 672 ), 445 - const SizedBox(width: 8), 446 - Column( 447 - mainAxisAlignment: MainAxisAlignment.center, 448 - crossAxisAlignment: CrossAxisAlignment.start, 449 - children: [ 450 - Text( 451 - videoTimelineState.activeAudioName, 452 - style: const TextStyle( 453 - color: AppColors.greyWhite, 454 - fontSize: 11, 455 - fontWeight: FontWeight.w600, 673 + ), 674 + // Audio info overlay at the start 675 + Positioned( 676 + left: 0, 677 + top: 0, 678 + bottom: 0, 679 + child: Container( 680 + padding: const EdgeInsets.symmetric(horizontal: 10), 681 + decoration: BoxDecoration( 682 + gradient: LinearGradient( 683 + colors: [ 684 + AppColors.primary700, 685 + AppColors.primary700.withAlpha(200), 686 + Colors.transparent, 687 + ], 688 + stops: const [0.0, 0.7, 1.0], 456 689 ), 457 690 ), 458 - if (videoTimelineState.activeAudioSubtitle != null) 459 - Text( 460 - videoTimelineState.activeAudioSubtitle!, 461 - style: TextStyle( 462 - color: AppColors.greyWhite.withAlpha(180), 463 - fontSize: 10, 691 + child: Row( 692 + mainAxisSize: MainAxisSize.min, 693 + children: [ 694 + if (videoTimelineState.authorAvatarUrl != null) 695 + ClipRRect( 696 + borderRadius: BorderRadius.circular(12), 697 + child: CachedNetworkImage( 698 + fadeInDuration: Duration.zero, 699 + imageUrl: videoTimelineState.authorAvatarUrl!, 700 + width: 24, 701 + height: 24, 702 + fit: BoxFit.cover, 703 + placeholder: (_, _) => 704 + _buildAvatarPlaceholder(), 705 + errorWidget: (_, _, _) => 706 + _buildAvatarPlaceholder(), 707 + ), 708 + ) 709 + else 710 + const Icon( 711 + Icons.music_note, 712 + color: AppColors.greyWhite, 713 + size: 18, 714 + ), 715 + const SizedBox(width: 8), 716 + Column( 717 + mainAxisAlignment: MainAxisAlignment.center, 718 + crossAxisAlignment: CrossAxisAlignment.start, 719 + children: [ 720 + Text( 721 + videoTimelineState.activeAudioName, 722 + style: const TextStyle( 723 + color: AppColors.greyWhite, 724 + fontSize: 11, 725 + fontWeight: FontWeight.w600, 726 + ), 727 + ), 728 + if (videoTimelineState.activeAudioSubtitle != 729 + null) 730 + Text( 731 + videoTimelineState.activeAudioSubtitle!, 732 + style: TextStyle( 733 + color: AppColors.greyWhite.withAlpha(180), 734 + fontSize: 10, 735 + ), 736 + ), 737 + ], 464 738 ), 465 - ), 466 - ], 739 + ], 740 + ), 741 + ), 467 742 ), 468 743 ], 469 744 ), 470 745 ), 471 746 ), 472 - ], 747 + ), 473 748 ), 474 749 ); 475 750 }
+14 -7
lib/src/core/pro_video_editor/ui/widgets/timeline/video_timeline.dart
··· 17 17 required this.onToggleFullscreen, 18 18 required this.canUndo, 19 19 required this.canRedo, 20 + this.onTrimChanged, 21 + this.onTrimEnd, 20 22 super.key, 21 23 }); 22 24 ··· 30 32 final VoidCallback onToggleFullscreen; 31 33 final bool canUndo; 32 34 final bool canRedo; 35 + final void Function(double start, double end)? onTrimChanged; 36 + final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 33 37 34 38 @override 35 39 Widget build(BuildContext context) { ··· 57 61 onToggleMute: onToggleMute, 58 62 onAddSound: onAddSound, 59 63 onSeek: onSeek, 64 + onTrimChanged: onTrimChanged, 65 + onTrimEnd: onTrimEnd, 60 66 isMuted: videoTimelineState.isMuted, 61 67 ), 62 68 ], ··· 96 102 97 103 @override 98 104 Widget build(BuildContext context) { 99 - final currentPosition = Duration( 100 - milliseconds: 101 - (videoTimelineState.progress * 102 - videoTimelineState.videoDuration.inMilliseconds) 103 - .round(), 104 - ); 105 - final totalDuration = videoTimelineState.videoDuration; 105 + final currentPosition = videoTimelineState.trimmedPosition; 106 + final totalDuration = videoTimelineState.trimmedDuration; 106 107 107 108 return Padding( 108 109 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ··· 205 206 required this.onAddSound, 206 207 required this.onSeek, 207 208 required this.isMuted, 209 + this.onTrimChanged, 210 + this.onTrimEnd, 208 211 }); 209 212 210 213 final VideoTimelineState videoTimelineState; ··· 212 215 final VoidCallback onAddSound; 213 216 final void Function(double progress) onSeek; 214 217 final bool isMuted; 218 + final void Function(double start, double end)? onTrimChanged; 219 + final void Function(double start, double end, bool isStartHandle)? onTrimEnd; 215 220 216 221 @override 217 222 Widget build(BuildContext context) { ··· 226 231 videoTimelineState: videoTimelineState, 227 232 onSeek: onSeek, 228 233 onAddSound: onAddSound, 234 + onTrimChanged: onTrimChanged, 235 + onTrimEnd: onTrimEnd, 229 236 pixelsPerSecond: 50, 230 237 ), 231 238 ),
+79 -2
lib/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart
··· 46 46 bool _isMuted = false; 47 47 bool get isMuted => _isMuted; 48 48 49 + /// Trim start position (0.0–1.0 fraction of total duration). 50 + double _trimStart = 0.0; 51 + double get trimStart => _trimStart; 52 + 53 + /// Trim end position (0.0–1.0 fraction of total duration). 54 + double _trimEnd = 1.0; 55 + double get trimEnd => _trimEnd; 56 + 57 + /// Width of the active trim span as a fraction of the source video. 58 + double get trimSpanFraction => _clampFraction(_trimEnd - _trimStart); 59 + 60 + /// True when trim range differs from full video (within 0.1% tolerance). 61 + bool get hasTrim => _trimStart > 0.001 || _trimEnd < 0.999; 62 + 63 + /// Source-video position for the current progress. 64 + Duration get sourcePosition => _durationAtFraction(_progress); 65 + 66 + /// Source-video position where the active trim starts. 67 + Duration get trimStartPosition => _durationAtFraction(_trimStart); 68 + 69 + /// Source-video position where the active trim ends. 70 + Duration get trimEndPosition => _durationAtFraction(_trimEnd); 71 + 72 + /// Duration of the active edited timeline. 73 + Duration get trimmedDuration => trimEndPosition - trimStartPosition; 74 + 75 + /// Current playhead position relative to the active edited timeline. 76 + Duration get trimmedPosition { 77 + final position = sourcePosition; 78 + final start = trimStartPosition; 79 + final end = trimEndPosition; 80 + 81 + if (position <= start) return Duration.zero; 82 + if (position >= end) return trimmedDuration; 83 + return position - start; 84 + } 85 + 86 + /// Current playhead progress inside the active edited timeline. 87 + double get trimmedProgress { 88 + final span = trimSpanFraction; 89 + if (span <= 0) return 0.0; 90 + return ((_progress - _trimStart) / span).clamp(0.0, 1.0).toDouble(); 91 + } 92 + 93 + /// Converts edited-timeline progress back to source-video progress. 94 + double sourceProgressFromTrimmedProgress(double progress) { 95 + final span = trimSpanFraction; 96 + final trimmedProgress = _clampFraction(progress); 97 + return _clampFraction(_trimStart + trimmedProgress * span); 98 + } 99 + 49 100 /// Returns the active waveform data based on audio mode. 50 101 List<double> get activeWaveformData => 51 102 _useCustomAudio ? _customWaveformData : videoWaveformData; ··· 107 158 108 159 /// Updates the current playback progress. 109 160 void setProgress(double value) { 110 - _progress = value.clamp(0.0, 1.0); 161 + _progress = _clampFraction(value); 111 162 notifyListeners(); 112 163 } 113 164 ··· 123 174 notifyListeners(); 124 175 } 125 176 177 + /// Updates the trim range (fractions 0.0–1.0). Both values are clamped. 178 + void setTrimRange(double start, double end) { 179 + final clampedStart = _clampFraction(start); 180 + final clampedEnd = _clampFraction(end); 181 + _trimStart = clampedStart <= clampedEnd ? clampedStart : clampedEnd; 182 + _trimEnd = clampedStart <= clampedEnd ? clampedEnd : clampedStart; 183 + notifyListeners(); 184 + } 185 + 186 + /// Resets trim to full video range. 187 + void resetTrim() { 188 + _trimStart = 0.0; 189 + _trimEnd = 1.0; 190 + notifyListeners(); 191 + } 192 + 126 193 /// Updates progress from a duration position. 127 194 void setProgressFromDuration(Duration position) { 128 195 if (videoDuration.inMilliseconds == 0) { 129 196 _progress = 0; 130 197 } else { 131 - _progress = position.inMilliseconds / videoDuration.inMilliseconds; 198 + _progress = _clampFraction( 199 + position.inMilliseconds / videoDuration.inMilliseconds, 200 + ); 132 201 } 133 202 notifyListeners(); 134 203 } ··· 141 210 _useCustomAudio = false; 142 211 notifyListeners(); 143 212 } 213 + 214 + Duration _durationAtFraction(double fraction) { 215 + return Duration( 216 + milliseconds: (videoDuration.inMilliseconds * fraction).round(), 217 + ); 218 + } 219 + 220 + double _clampFraction(double value) => value.clamp(0.0, 1.0).toDouble(); 144 221 }
+29
test/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:spark/src/core/pro_video_editor/ui/widgets/timeline/video_timeline_state.dart'; 3 + 4 + void main() { 5 + group('VideoTimelineState trimming', () { 6 + test('maps source position to the trimmed timeline', () { 7 + final state = 8 + VideoTimelineState(videoDuration: const Duration(seconds: 10)) 9 + ..setTrimRange(0.2, 0.8) 10 + ..setProgressFromDuration(const Duration(seconds: 4)); 11 + 12 + expect(state.trimStartPosition, const Duration(seconds: 2)); 13 + expect(state.trimEndPosition, const Duration(seconds: 8)); 14 + expect(state.trimmedDuration, const Duration(seconds: 6)); 15 + expect(state.trimmedPosition, const Duration(seconds: 2)); 16 + expect(state.trimmedProgress, closeTo(1 / 3, 0.0001)); 17 + }); 18 + 19 + test('maps trimmed progress back to source progress', () { 20 + final state = VideoTimelineState( 21 + videoDuration: const Duration(seconds: 10), 22 + )..setTrimRange(0.2, 0.8); 23 + 24 + expect(state.sourceProgressFromTrimmedProgress(0), 0.2); 25 + expect(state.sourceProgressFromTrimmedProgress(0.5), 0.5); 26 + expect(state.sourceProgressFromTrimmedProgress(1), 0.8); 27 + }); 28 + }); 29 + }