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

ROCAM ROCAM

C3B 07162897 783ba0aa

+587 -63
+136 -63
lib/screens/home_screen.dart
··· 4 4 import 'package:ionicons/ionicons.dart'; 5 5 import '../widgets/video_side_action_bar.dart'; 6 6 import '../widgets/video_info/video_info_bar.dart'; 7 + import '../widgets/video_controls/video_controller_overlay.dart'; 7 8 import 'package:video_player/video_player.dart'; 8 9 import 'package:visibility_detector/visibility_detector.dart'; 9 10 ··· 12 13 13 14 @override 14 15 Widget build(BuildContext context) { 15 - // Calculate proper bottom padding based on screen size and safe area 16 - final bottomPadding = MediaQuery.of(context).padding.bottom + 50; 16 + // Calculate proper padding based on screen size and safe area 17 + final bottomNavHeight = 50.0; // Standard height for bottom navigation bar 18 + final bottomSafeArea = MediaQuery.of(context).padding.bottom; 19 + final totalBottomPadding = bottomNavHeight + bottomSafeArea; 20 + final topPadding = MediaQuery.of(context).padding.top; 17 21 18 22 return CupertinoPageScaffold( 19 23 backgroundColor: CupertinoColors.black, 20 24 child: Stack( 21 25 children: [ 22 - // Main content 23 - Column( 24 - children: [ 25 - // Top navigation bar - add padding to account for status bar 26 - Padding( 26 + // Full-screen video feed 27 + SizedBox( 28 + height: MediaQuery.of(context).size.height, 29 + width: MediaQuery.of(context).size.width, 30 + child: PageView.builder( 31 + scrollDirection: Axis.vertical, 32 + itemCount: 5, // Sample videos 33 + itemBuilder: (context, index) { 34 + // Sample videos with different aspect ratios to demonstrate proper sizing 35 + final videoUrls = [ 36 + 'https://cdn.justdavi.dev/vid_9_16.mp4', // Vertical 9:16 37 + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', // Horizontal 16:9 38 + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', // Horizontal 16:9 39 + null, // Custom colored container 40 + null, // Custom colored container 41 + ]; 42 + 43 + return Padding( 44 + // Add padding at bottom to prevent content from being hidden behind bottom nav 45 + padding: EdgeInsets.only(bottom: totalBottomPadding), 46 + child: VideoItem( 47 + index: index, 48 + videoUrl: index < videoUrls.length ? videoUrls[index] : null, 49 + ), 50 + ); 51 + }, 52 + ), 53 + ), 54 + 55 + // Overlay for top navigation 56 + Positioned( 57 + top: 0, 58 + left: 0, 59 + right: 0, 60 + child: Container( 61 + // Add gradient background to ensure readability of the top navigation 62 + decoration: BoxDecoration( 63 + gradient: LinearGradient( 64 + begin: Alignment.topCenter, 65 + end: Alignment.bottomCenter, 66 + colors: [ 67 + CupertinoColors.black.withOpacity(0.7), 68 + CupertinoColors.black.withOpacity(0.0), 69 + ], 70 + ), 71 + ), 72 + child: Padding( 27 73 padding: EdgeInsets.only( 28 - top: MediaQuery.of(context).padding.top + 10, 74 + top: topPadding + 10, 29 75 left: 16.0, 30 76 right: 16.0, 31 - bottom: 10.0, 77 + bottom: 20.0, 32 78 ), 33 79 child: Row( 34 80 mainAxisAlignment: MainAxisAlignment.center, ··· 64 110 ], 65 111 ), 66 112 ), 67 - 68 - // Video Feed (main content) 69 - Expanded( 113 + ), 114 + ), 115 + 116 + // Bottom navigation bar (simulated) 117 + Positioned( 118 + bottom: 0, 119 + left: 0, 120 + right: 0, 121 + child: Container( 122 + height: totalBottomPadding, 123 + decoration: BoxDecoration( 124 + gradient: LinearGradient( 125 + begin: Alignment.bottomCenter, 126 + end: Alignment.topCenter, 127 + colors: [ 128 + CupertinoColors.black.withOpacity(0.9), 129 + CupertinoColors.black.withOpacity(0.0), 130 + ], 131 + stops: const [0.4, 1.0], 132 + ), 133 + ), 134 + child: Align( 135 + alignment: Alignment.topCenter, 70 136 child: Padding( 71 - // Dynamically calculate bottom padding based on device 72 - padding: EdgeInsets.only(bottom: bottomPadding), 73 - child: PageView.builder( 74 - scrollDirection: Axis.vertical, 75 - itemCount: 5, // Sample videos 76 - itemBuilder: (context, index) { 77 - // Sample videos with different aspect ratios to demonstrate proper sizing 78 - final videoUrls = [ 79 - 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', // Horizontal 16:9 80 - 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', // Horizontal 16:9 81 - 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', // Horizontal 16:9 82 - null, // Custom colored container 83 - null, // Custom colored container 84 - ]; 85 - 86 - return VideoItem( 87 - index: index, 88 - videoUrl: index < videoUrls.length ? videoUrls[index] : null, 89 - ); 90 - }, 137 + padding: EdgeInsets.only(bottom: bottomSafeArea), 138 + child: SizedBox( 139 + height: bottomNavHeight, 140 + child: Row( 141 + mainAxisAlignment: MainAxisAlignment.spaceEvenly, 142 + children: [ 143 + _buildNavItem(Ionicons.home, true), 144 + _buildNavItem(Ionicons.search_outline, false), 145 + _buildNavItem(Ionicons.add_circle_outline, false), 146 + _buildNavItem(Ionicons.chatbubble_outline, false), 147 + _buildNavItem(Ionicons.person_outline, false), 148 + ], 149 + ), 91 150 ), 92 151 ), 93 152 ), 94 - ], 153 + ), 95 154 ), 96 155 ], 97 156 ), 98 157 ); 99 158 } 159 + 160 + Widget _buildNavItem(IconData icon, bool isSelected) { 161 + return Icon( 162 + icon, 163 + color: isSelected ? CupertinoColors.white : CupertinoColors.systemGrey, 164 + size: 26, 165 + ); 166 + } 100 167 } 101 168 102 169 class VideoItem extends StatefulWidget { ··· 156 223 // Sample data for the video item 157 224 final String username = 'username${widget.index + 1}'; 158 225 final String description = widget.videoUrl != null 159 - ? 'Sample video ${widget.index + 1}: This is a horizontally shot video that demonstrates proper fitting on the screen without cutting off content.' 226 + ? 'Sample video ${widget.index + 1}: This is a video that demonstrates proper fitting on the screen without cutting off content.' 160 227 : 'This is a placeholder for video ${widget.index + 1}'; 161 228 final List<String> hashtags = ['spark', 'sample', 'video${widget.index + 1}']; 162 229 163 - return Container( 164 - height: MediaQuery.of(context).size.height, 165 - width: MediaQuery.of(context).size.width, 166 - color: CupertinoColors.black, 230 + return SizedBox.expand( 167 231 child: VisibilityDetector( 168 232 key: Key(_videoKey), 169 233 onVisibilityChanged: (visibilityInfo) { ··· 185 249 child: Stack( 186 250 fit: StackFit.expand, 187 251 children: [ 188 - // Blurred video background (only if video is initialized) 252 + // Blurred video background 189 253 if (widget.videoUrl != null && _controller != null && _isInitialized) 190 254 _buildBlurredBackground(), 191 255 192 - // Video content 256 + // Video content - main focus 193 257 Center( 194 258 child: _buildVideoContent(), 195 259 ), 260 + 261 + // Gradient overlay for better text readability 262 + Positioned.fill( 263 + child: Container( 264 + decoration: BoxDecoration( 265 + gradient: LinearGradient( 266 + begin: Alignment.topCenter, 267 + end: Alignment.bottomCenter, 268 + colors: [ 269 + Colors.transparent, 270 + Colors.transparent, 271 + Colors.transparent, 272 + Colors.black.withOpacity(0.0), 273 + Colors.black.withOpacity(0.3), 274 + Colors.black.withOpacity(0.3), 275 + ], 276 + stops: const [0.0, 0.5, 0.65, 0.75, 0.85, 0.95], 277 + ), 278 + ), 279 + ), 280 + ), 281 + 282 + // Video controller overlay - new addition 283 + if (widget.videoUrl != null && _controller != null && _isInitialized) 284 + VideoControllerOverlay( 285 + controller: _controller!, 286 + onTap: () { 287 + // This is handled internally by the controller 288 + }, 289 + ), 196 290 197 - // Video info - now using the modular component 291 + // Video info 198 292 Positioned( 199 293 bottom: 20, 200 294 left: 10, ··· 247 341 radius: 20, 248 342 ), 249 343 ), 250 - 251 - // Tap to play/pause overlay 252 - Positioned.fill( 253 - child: GestureDetector( 254 - onTap: () { 255 - if (_controller != null && _isInitialized) { 256 - setState(() { 257 - if (_controller!.value.isPlaying) { 258 - _controller!.pause(); 259 - } else { 260 - _controller!.play(); 261 - } 262 - }); 263 - } 264 - }, 265 - // Make the entire area tappable but transparent 266 - child: Container( 267 - color: Colors.transparent, 268 - ), 269 - ), 270 - ), 271 344 ], 272 345 ), 273 346 ), 274 347 ); 275 348 } 276 349 277 - // New method to build the blurred background 350 + // Build the blurred background 278 351 Widget _buildBlurredBackground() { 279 352 return Stack( 280 353 fit: StackFit.expand,
+451
lib/widgets/video_controls/video_controller_overlay.dart
··· 1 + import 'dart:async'; 2 + import 'package:flutter/cupertino.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:ionicons/ionicons.dart'; 5 + import 'package:video_player/video_player.dart'; 6 + 7 + class VideoControllerOverlay extends StatefulWidget { 8 + final VideoPlayerController controller; 9 + final VoidCallback onTap; 10 + 11 + const VideoControllerOverlay({ 12 + Key? key, 13 + required this.controller, 14 + required this.onTap, 15 + }) : super(key: key); 16 + 17 + @override 18 + State<VideoControllerOverlay> createState() => _VideoControllerOverlayState(); 19 + } 20 + 21 + class _VideoControllerOverlayState extends State<VideoControllerOverlay> with SingleTickerProviderStateMixin { 22 + bool _controlsVisible = false; 23 + bool _isSpeedUp = false; 24 + Timer? _hideTimer; 25 + Timer? _updateTimer; 26 + double _dragPosition = 0.0; 27 + bool _isDragging = false; 28 + bool _knobEnlarged = false; 29 + 30 + // Animation controller for the timestamp animation 31 + late AnimationController _timestampAnimationController; 32 + late Animation<Offset> _timestampAnimation; 33 + 34 + @override 35 + void initState() { 36 + super.initState(); 37 + 38 + // Initialize animation controller for timestamp 39 + _timestampAnimationController = AnimationController( 40 + vsync: this, 41 + duration: const Duration(milliseconds: 200), 42 + ); 43 + 44 + // Create animation for moving timestamp upward 45 + _timestampAnimation = Tween<Offset>( 46 + begin: const Offset(0, 0), 47 + end: const Offset(0, -1.0), 48 + ).animate(CurvedAnimation( 49 + parent: _timestampAnimationController, 50 + curve: Curves.easeOutCubic, 51 + )); 52 + 53 + // Start periodic timer to update UI with current video position 54 + _updateTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) { 55 + if (mounted && !_isDragging) { 56 + setState(() {}); 57 + } 58 + }); 59 + } 60 + 61 + @override 62 + void dispose() { 63 + _hideTimer?.cancel(); 64 + _updateTimer?.cancel(); 65 + _timestampAnimationController.dispose(); 66 + super.dispose(); 67 + } 68 + 69 + void _toggleControls() { 70 + setState(() { 71 + _controlsVisible = !_controlsVisible; 72 + if (_controlsVisible) { 73 + _startHideTimer(); 74 + } else { 75 + _hideTimer?.cancel(); 76 + } 77 + }); 78 + } 79 + 80 + void _startHideTimer() { 81 + _hideTimer?.cancel(); 82 + _hideTimer = Timer(const Duration(seconds: 3), () { 83 + if (mounted && _controlsVisible && !_isDragging) { 84 + setState(() { 85 + _controlsVisible = false; 86 + }); 87 + } 88 + }); 89 + } 90 + 91 + void _cancelHideTimer() { 92 + _hideTimer?.cancel(); 93 + } 94 + 95 + void _playPause() { 96 + if (widget.controller.value.isPlaying) { 97 + widget.controller.pause(); 98 + } else { 99 + widget.controller.play(); 100 + } 101 + _startHideTimer(); 102 + setState(() {}); 103 + } 104 + 105 + void _rewind() { 106 + final newPosition = widget.controller.value.position - const Duration(seconds: 5); 107 + widget.controller.seekTo( 108 + newPosition < Duration.zero ? Duration.zero : newPosition, 109 + ); 110 + _startHideTimer(); 111 + setState(() {}); 112 + } 113 + 114 + void _fastForward() { 115 + final newPosition = widget.controller.value.position + const Duration(seconds: 5); 116 + final duration = widget.controller.value.duration; 117 + widget.controller.seekTo( 118 + newPosition > duration ? duration : newPosition, 119 + ); 120 + _startHideTimer(); 121 + setState(() {}); 122 + } 123 + 124 + void _onDragStart(double position) { 125 + _cancelHideTimer(); 126 + setState(() { 127 + _isDragging = true; 128 + _dragPosition = position; 129 + _knobEnlarged = true; 130 + 131 + // Start the animation for timestamp 132 + _timestampAnimationController.forward(); 133 + }); 134 + } 135 + 136 + void _onDragUpdate(double position) { 137 + setState(() { 138 + _dragPosition = position; 139 + }); 140 + } 141 + 142 + void _onDragEnd() { 143 + final duration = widget.controller.value.duration; 144 + final position = duration * _dragPosition; 145 + 146 + widget.controller.seekTo(position); 147 + 148 + setState(() { 149 + _isDragging = false; 150 + _knobEnlarged = false; 151 + 152 + // Reverse the timestamp animation 153 + _timestampAnimationController.reverse(); 154 + }); 155 + 156 + _startHideTimer(); 157 + } 158 + 159 + void _handleSpeedUp(bool isLongPress) { 160 + if (isLongPress != _isSpeedUp) { 161 + setState(() { 162 + _isSpeedUp = isLongPress; 163 + }); 164 + // Set playback speed 165 + widget.controller.setPlaybackSpeed(_isSpeedUp ? 2.0 : 1.0); 166 + } 167 + } 168 + 169 + String _formatDuration(Duration duration) { 170 + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); 171 + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); 172 + return '$minutes:$seconds'; 173 + } 174 + 175 + @override 176 + Widget build(BuildContext context) { 177 + final duration = widget.controller.value.duration; 178 + final position = _isDragging 179 + ? duration * _dragPosition 180 + : widget.controller.value.position; 181 + 182 + final screenWidth = MediaQuery.of(context).size.width; 183 + final screenHeight = MediaQuery.of(context).size.height; 184 + 185 + // Calculate bottom safe area for proper slider positioning 186 + final bottomSafeArea = MediaQuery.of(context).padding.bottom; 187 + final bottomNavHeight = 50.0; // Match the HomeScreen bottom nav height 188 + 189 + // Progress bar configuration - easily adjustable 190 + final progressBarWidthPercentage = 0.7; // 70% of screen width 191 + final progressBarWidth = screenWidth * progressBarWidthPercentage; 192 + final progressBarHeight = 4.0; 193 + final knobSizeNormal = 14.0; 194 + final knobSizeEnlarged = 20.0; 195 + final progressBarBottomPadding = bottomNavHeight + bottomSafeArea + 10; 196 + 197 + return GestureDetector( 198 + onTap: _toggleControls, 199 + onLongPressStart: (_) => _handleSpeedUp(true), 200 + onLongPressEnd: (_) => _handleSpeedUp(false), 201 + child: Stack( 202 + fit: StackFit.expand, 203 + children: [ 204 + // Transparent layer for tap detection 205 + Container(color: Colors.transparent), 206 + 207 + // Speed indicator (2x) when long pressing 208 + if (_isSpeedUp) 209 + Positioned( 210 + left: 10, 211 + bottom: 120, 212 + child: Container( 213 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 214 + decoration: BoxDecoration( 215 + color: CupertinoColors.black.withOpacity(0.7), 216 + borderRadius: BorderRadius.circular(16), 217 + ), 218 + child: const Text( 219 + '2x', 220 + style: TextStyle( 221 + color: CupertinoColors.white, 222 + fontWeight: FontWeight.bold, 223 + fontSize: 16, 224 + ), 225 + ), 226 + ), 227 + ), 228 + 229 + // Controls overlay 230 + if (_controlsVisible) 231 + AnimatedOpacity( 232 + opacity: _controlsVisible ? 1.0 : 0.0, 233 + duration: const Duration(milliseconds: 200), 234 + child: Container( 235 + color: CupertinoColors.black.withOpacity(0.5), 236 + child: Stack( 237 + children: [ 238 + // Timestamp indicator (centered below controls) 239 + Positioned( 240 + left: 0, 241 + right: 0, 242 + top: screenHeight * 0.55, // Position below play controls 243 + child: Center( 244 + child: Container( 245 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 246 + decoration: BoxDecoration( 247 + color: CupertinoColors.black.withOpacity(0.5), 248 + borderRadius: BorderRadius.circular(8), 249 + ), 250 + child: Text( 251 + '${_formatDuration(position)}/${_formatDuration(duration)}', 252 + style: const TextStyle( 253 + color: CupertinoColors.white, 254 + fontWeight: FontWeight.bold, 255 + fontSize: 16, 256 + ), 257 + ), 258 + ), 259 + ), 260 + ), 261 + 262 + // Center play/pause and skip buttons - perfectly centered 263 + Center( 264 + child: Row( 265 + mainAxisAlignment: MainAxisAlignment.center, 266 + crossAxisAlignment: CrossAxisAlignment.center, 267 + children: [ 268 + // Rewind 5 seconds 269 + IconButton( 270 + icon: const Icon(Ionicons.play_back_outline, color: CupertinoColors.white, size: 36), 271 + onPressed: _rewind, 272 + padding: const EdgeInsets.all(16), 273 + ), 274 + const SizedBox(width: 24), 275 + 276 + // Play/Pause 277 + GestureDetector( 278 + onTap: _playPause, 279 + child: Container( 280 + width: 80, 281 + height: 80, 282 + decoration: BoxDecoration( 283 + color: CupertinoColors.white.withOpacity(0.3), 284 + shape: BoxShape.circle, 285 + ), 286 + child: Center( 287 + child: Icon( 288 + widget.controller.value.isPlaying 289 + ? Ionicons.pause 290 + : Ionicons.play, 291 + color: CupertinoColors.white, 292 + size: 46, 293 + ), 294 + ), 295 + ), 296 + ), 297 + const SizedBox(width: 24), 298 + 299 + // Fast forward 5 seconds 300 + IconButton( 301 + icon: const Icon(Ionicons.play_forward_outline, color: CupertinoColors.white, size: 36), 302 + onPressed: _fastForward, 303 + padding: const EdgeInsets.all(16), 304 + ), 305 + ], 306 + ), 307 + ), 308 + 309 + // Bottom progress bar - positioned just above bottom nav 310 + Positioned( 311 + left: 0, 312 + right: 0, 313 + bottom: progressBarBottomPadding, 314 + child: Center( 315 + child: Container( 316 + width: progressBarWidth, 317 + height: 40, // Taller touch area 318 + padding: const EdgeInsets.symmetric(vertical: 15), 319 + child: LayoutBuilder( 320 + builder: (context, constraints) { 321 + // Get actual width from constraints for more accurate calculations 322 + final actualWidth = constraints.maxWidth; 323 + 324 + return GestureDetector( 325 + onHorizontalDragStart: (details) { 326 + final RenderBox box = context.findRenderObject() as RenderBox; 327 + final Offset localPos = box.globalToLocal(details.globalPosition); 328 + // Calculate position within the progress bar container 329 + final progressBarLeft = (screenWidth - progressBarWidth) / 2; 330 + final relativeX = localPos.dx - progressBarLeft; 331 + final normalizedPosition = (relativeX / progressBarWidth).clamp(0.0, 1.0); 332 + _onDragStart(normalizedPosition); 333 + }, 334 + onHorizontalDragUpdate: (details) { 335 + final RenderBox box = context.findRenderObject() as RenderBox; 336 + final Offset localPos = box.globalToLocal(details.globalPosition); 337 + // Calculate position within the progress bar container 338 + final progressBarLeft = (screenWidth - progressBarWidth) / 2; 339 + final relativeX = localPos.dx - progressBarLeft; 340 + final normalizedPosition = (relativeX / progressBarWidth).clamp(0.0, 1.0); 341 + _onDragUpdate(normalizedPosition); 342 + }, 343 + onHorizontalDragEnd: (_) => _onDragEnd(), 344 + child: Stack( 345 + clipBehavior: Clip.none, 346 + children: [ 347 + // Background track 348 + Container( 349 + height: progressBarHeight, 350 + width: double.infinity, // Full width of the parent container 351 + color: CupertinoColors.systemGrey.withOpacity(0.5), 352 + ), 353 + 354 + // Filled progress - now white 355 + FractionallySizedBox( 356 + widthFactor: _isDragging 357 + ? _dragPosition.clamp(0.0, 1.0) 358 + : (position.inMilliseconds / duration.inMilliseconds) 359 + .clamp(0.0, 1.0), 360 + child: Container( 361 + height: progressBarHeight, 362 + color: CupertinoColors.white, 363 + ), 364 + ), 365 + 366 + // Draggable knob 367 + Positioned( 368 + left: _isDragging 369 + ? (_dragPosition * actualWidth).clamp(0.0, actualWidth) 370 + : ((position.inMilliseconds / duration.inMilliseconds) * actualWidth).clamp(0.0, actualWidth), 371 + top: -5, 372 + child: GestureDetector( 373 + behavior: HitTestBehavior.translucent, 374 + onHorizontalDragStart: (details) { 375 + // Use the same calculation method as the parent 376 + final RenderBox box = context.findRenderObject() as RenderBox; 377 + final Offset localPos = box.globalToLocal(details.globalPosition); 378 + final progressBarLeft = (screenWidth - progressBarWidth) / 2; 379 + final relativeX = localPos.dx - progressBarLeft; 380 + final normalizedPosition = (relativeX / progressBarWidth).clamp(0.0, 1.0); 381 + _onDragStart(normalizedPosition); 382 + }, 383 + onHorizontalDragUpdate: (details) { 384 + // Use the same calculation method as the parent 385 + final RenderBox box = context.findRenderObject() as RenderBox; 386 + final Offset localPos = box.globalToLocal(details.globalPosition); 387 + final progressBarLeft = (screenWidth - progressBarWidth) / 2; 388 + final relativeX = localPos.dx - progressBarLeft; 389 + final normalizedPosition = (relativeX / progressBarWidth).clamp(0.0, 1.0); 390 + _onDragUpdate(normalizedPosition); 391 + }, 392 + onHorizontalDragEnd: (_) => _onDragEnd(), 393 + child: Container( 394 + width: _knobEnlarged ? knobSizeEnlarged : knobSizeNormal, 395 + height: _knobEnlarged ? knobSizeEnlarged : knobSizeNormal, 396 + decoration: BoxDecoration( 397 + color: CupertinoColors.white, 398 + shape: BoxShape.circle, 399 + boxShadow: [ 400 + BoxShadow( 401 + color: CupertinoColors.black.withOpacity(0.3), 402 + blurRadius: 4, 403 + offset: const Offset(0, 2), 404 + ), 405 + ], 406 + ), 407 + ), 408 + ), 409 + ), 410 + 411 + // Animated timestamp above knob when dragging 412 + if (_isDragging) 413 + Positioned( 414 + left: (_dragPosition * actualWidth - 25).clamp(0.0, actualWidth - 50), 415 + bottom: 15, 416 + child: SlideTransition( 417 + position: _timestampAnimation, 418 + child: Container( 419 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 420 + decoration: BoxDecoration( 421 + color: CupertinoColors.black.withOpacity(0.7), 422 + borderRadius: BorderRadius.circular(4), 423 + ), 424 + child: Text( 425 + _formatDuration(duration * _dragPosition), 426 + style: const TextStyle( 427 + color: CupertinoColors.white, 428 + fontSize: 12, 429 + fontWeight: FontWeight.bold, 430 + ), 431 + ), 432 + ), 433 + ), 434 + ), 435 + ], 436 + ), 437 + ); 438 + } 439 + ), 440 + ), 441 + ), 442 + ), 443 + ], 444 + ), 445 + ), 446 + ), 447 + ], 448 + ), 449 + ); 450 + } 451 + }