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

video

C3B 074e25ae 5728d3d0

+315 -123
+3
android/app/src/main/AndroidManifest.xml
··· 1 1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"> 2 + <!-- Add Internet permission for video streaming --> 3 + <uses-permission android:name="android.permission.INTERNET"/> 4 + 2 5 <application 3 6 android:label="Spark" 4 7 android:name="${applicationName}"
+7
ios/Podfile.lock
··· 5 5 - path_provider_foundation (0.0.1): 6 6 - Flutter 7 7 - FlutterMacOS 8 + - shared_preferences_foundation (0.0.1): 9 + - Flutter 10 + - FlutterMacOS 8 11 - sqflite_darwin (0.0.4): 9 12 - Flutter 10 13 - FlutterMacOS ··· 16 19 - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) 17 20 - Flutter (from `Flutter`) 18 21 - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 22 + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 19 23 - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) 20 24 - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) 21 25 ··· 26 30 :path: Flutter 27 31 path_provider_foundation: 28 32 :path: ".symlinks/plugins/path_provider_foundation/darwin" 33 + shared_preferences_foundation: 34 + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" 29 35 sqflite_darwin: 30 36 :path: ".symlinks/plugins/sqflite_darwin/darwin" 31 37 video_player_avfoundation: ··· 35 41 camera_avfoundation: 04b44aeb14070126c6529e5ab82cc7c9fca107cf 36 42 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 37 43 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 44 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 38 45 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 39 46 video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b 40 47
+5
ios/Runner/Info.plist
··· 45 45 <true/> 46 46 <key>UIApplicationSupportsIndirectInputEvents</key> 47 47 <true/> 48 + <key>NSAppTransportSecurity</key> 49 + <dict> 50 + <key>NSAllowsArbitraryLoads</key> 51 + <true/> 52 + </dict> 48 53 </dict> 49 54 </plist>
+290 -122
lib/screens/home_screen.dart
··· 1 1 import 'package:flutter/cupertino.dart'; 2 + import 'package:flutter/material.dart' show Colors; 2 3 import 'package:ionicons/ionicons.dart'; 3 4 import '../widgets/video_side_action_bar.dart'; 4 5 import '../widgets/video_info/video_info_bar.dart'; 6 + import 'package:video_player/video_player.dart'; 7 + import 'package:visibility_detector/visibility_detector.dart'; 5 8 6 9 class HomeScreen extends StatelessWidget { 7 10 const HomeScreen({super.key}); ··· 13 16 14 17 return CupertinoPageScaffold( 15 18 backgroundColor: CupertinoColors.black, 16 - child: SafeArea( 17 - bottom: false, 18 - child: Column( 19 - children: [ 20 - const SizedBox(height: 10), 21 - // Top navigation bar 22 - Padding( 23 - padding: const EdgeInsets.symmetric(horizontal: 16.0), 24 - child: Row( 25 - mainAxisAlignment: MainAxisAlignment.center, 26 - children: [ 27 - const SizedBox(width: 30), // For balance 28 - Expanded( 29 - child: Center( 30 - child: CupertinoSegmentedControl<int>( 31 - children: const { 32 - 0: Padding( 33 - padding: EdgeInsets.symmetric(horizontal: 20), 34 - child: Text('Following'), 35 - ), 36 - 1: Padding( 37 - padding: EdgeInsets.symmetric(horizontal: 20), 38 - child: Text('For You'), 39 - ), 40 - }, 41 - onValueChanged: (value) {}, 42 - groupValue: 1, // Default to "For You" 43 - borderColor: CupertinoColors.systemGrey, 44 - selectedColor: CupertinoColors.white, 45 - unselectedColor: CupertinoColors.black, 46 - padding: const EdgeInsets.all(4), 19 + child: Stack( 20 + children: [ 21 + // Main content 22 + Column( 23 + children: [ 24 + // Top navigation bar - add padding to account for status bar 25 + Padding( 26 + padding: EdgeInsets.only( 27 + top: MediaQuery.of(context).padding.top + 10, 28 + left: 16.0, 29 + right: 16.0, 30 + bottom: 10.0, 31 + ), 32 + child: Row( 33 + mainAxisAlignment: MainAxisAlignment.center, 34 + children: [ 35 + const SizedBox(width: 30), // For balance 36 + Expanded( 37 + child: Center( 38 + child: CupertinoSegmentedControl<int>( 39 + children: const { 40 + 0: Padding( 41 + padding: EdgeInsets.symmetric(horizontal: 20), 42 + child: Text('Following'), 43 + ), 44 + 1: Padding( 45 + padding: EdgeInsets.symmetric(horizontal: 20), 46 + child: Text('For You'), 47 + ), 48 + }, 49 + onValueChanged: (value) {}, 50 + groupValue: 1, // Default to "For You" 51 + borderColor: CupertinoColors.systemGrey, 52 + selectedColor: CupertinoColors.white, 53 + unselectedColor: CupertinoColors.black, 54 + padding: const EdgeInsets.all(4), 55 + ), 47 56 ), 48 57 ), 49 - ), 50 - const Icon( 51 - Ionicons.search_outline, 52 - color: CupertinoColors.white, 53 - size: 30, 54 - ), 55 - ], 58 + const Icon( 59 + Ionicons.search_outline, 60 + color: CupertinoColors.white, 61 + size: 30, 62 + ), 63 + ], 64 + ), 56 65 ), 57 - ), 58 - const SizedBox(height: 10), 59 66 60 - // Video Feed (main content) 61 - Expanded( 62 - child: Padding( 63 - // Dynamically calculate bottom padding based on device 64 - padding: EdgeInsets.only(bottom: bottomPadding), 65 - child: PageView.builder( 66 - scrollDirection: Axis.vertical, 67 - itemCount: 10, // Sample videos 68 - itemBuilder: (context, index) { 69 - return VideoItem(index: index); 70 - }, 67 + // Video Feed (main content) 68 + Expanded( 69 + child: Padding( 70 + // Dynamically calculate bottom padding based on device 71 + padding: EdgeInsets.only(bottom: bottomPadding), 72 + child: PageView.builder( 73 + scrollDirection: Axis.vertical, 74 + itemCount: 5, // Sample videos 75 + itemBuilder: (context, index) { 76 + // Sample videos with different aspect ratios to demonstrate proper sizing 77 + final videoUrls = [ 78 + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', // Horizontal 16:9 79 + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', // Horizontal 16:9 80 + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', // Horizontal 16:9 81 + null, // Custom colored container 82 + null, // Custom colored container 83 + ]; 84 + 85 + return VideoItem( 86 + index: index, 87 + videoUrl: index < videoUrls.length ? videoUrls[index] : null, 88 + ); 89 + }, 90 + ), 71 91 ), 72 92 ), 73 - ), 74 - ], 75 - ), 93 + ], 94 + ), 95 + ], 76 96 ), 77 97 ); 78 98 } 79 99 } 80 100 81 - class VideoItem extends StatelessWidget { 101 + class VideoItem extends StatefulWidget { 82 102 final int index; 103 + final String? videoUrl; 104 + 105 + const VideoItem({super.key, required this.index, this.videoUrl}); 83 106 84 - const VideoItem({super.key, required this.index}); 107 + @override 108 + State<VideoItem> createState() => _VideoItemState(); 109 + } 110 + 111 + class _VideoItemState extends State<VideoItem> { 112 + VideoPlayerController? _controller; 113 + bool _isInitialized = false; 114 + bool _isVisible = false; 115 + final String _videoKey = UniqueKey().toString(); 116 + 117 + @override 118 + void initState() { 119 + super.initState(); 120 + _initializeVideoPlayer(); 121 + } 122 + 123 + void _initializeVideoPlayer() { 124 + if (widget.videoUrl != null) { 125 + _controller = VideoPlayerController.network(widget.videoUrl!) 126 + ..initialize().then((_) { 127 + setState(() { 128 + _isInitialized = true; 129 + // If video is visible when initialized, play it 130 + if (_isVisible) { 131 + _controller?.play(); 132 + } 133 + }); 134 + }); 135 + 136 + // Add listener for video completion 137 + _controller?.addListener(() { 138 + if (_controller!.value.position >= _controller!.value.duration) { 139 + // Loop video 140 + _controller?.seekTo(Duration.zero); 141 + _controller?.play(); 142 + } 143 + }); 144 + } 145 + } 146 + 147 + @override 148 + void dispose() { 149 + _controller?.dispose(); 150 + super.dispose(); 151 + } 85 152 86 153 @override 87 154 Widget build(BuildContext context) { 88 155 // Sample data for the video item 89 - final String username = 'username'; 90 - final String description = 'Video caption goes here'; 91 - final List<String> hashtags = ['spark', 'viral', 'trending']; 156 + final String username = 'username${widget.index + 1}'; 157 + final String description = widget.videoUrl != null 158 + ? 'Sample video ${widget.index + 1}: This is a horizontally shot video that demonstrates proper fitting on the screen without cutting off content.' 159 + : 'This is a placeholder for video ${widget.index + 1}'; 160 + final List<String> hashtags = ['spark', 'sample', 'video${widget.index + 1}']; 92 161 93 162 return Container( 94 - // Use constraints to ensure the video fits within available space 95 - constraints: BoxConstraints( 96 - maxHeight: MediaQuery.of(context).size.height - 97 - MediaQuery.of(context).padding.top - 98 - 50 - // Top navigation height 99 - (MediaQuery.of(context).padding.bottom + 50), // Bottom nav height + safe area 100 - ), 101 - child: Stack( 102 - fit: StackFit.expand, 103 - children: [ 104 - // Video placeholder 105 - Container( 106 - color: index % 2 == 0 ? CupertinoColors.systemIndigo : CupertinoColors.systemPurple, 107 - child: Center( 108 - child: Icon( 109 - Ionicons.play_circle_outline, 110 - size: 80, 111 - color: CupertinoColors.white.withOpacity(0.7), 112 - ), 163 + height: MediaQuery.of(context).size.height, 164 + width: MediaQuery.of(context).size.width, 165 + color: CupertinoColors.black, 166 + child: VisibilityDetector( 167 + key: Key(_videoKey), 168 + onVisibilityChanged: (visibilityInfo) { 169 + final isVisible = visibilityInfo.visibleFraction > 0.8; 170 + 171 + // Only take action if visibility state changed 172 + if (isVisible != _isVisible) { 173 + _isVisible = isVisible; 174 + 175 + if (_controller != null && _isInitialized) { 176 + if (isVisible) { 177 + _controller?.play(); 178 + } else { 179 + _controller?.pause(); 180 + } 181 + } 182 + } 183 + }, 184 + child: Stack( 185 + fit: StackFit.expand, 186 + children: [ 187 + // Video content 188 + Center( 189 + child: _buildVideoContent(), 113 190 ), 114 - ), 115 191 116 - // Video info - now using the modular component 117 - Positioned( 118 - bottom: 20, 119 - left: 10, 120 - right: 70, // Give space for the side action bar 121 - child: VideoInfoBar( 122 - username: username, 123 - description: description, 124 - hashtags: hashtags, 125 - onUsernameTap: () { 126 - // Handle username tap 127 - }, 128 - onHashtagTap: () { 129 - // Handle hashtag tap 130 - }, 192 + // Video info - now using the modular component 193 + Positioned( 194 + bottom: 20, 195 + left: 10, 196 + right: 70, // Give space for the side action bar 197 + child: VideoInfoBar( 198 + username: username, 199 + description: description, 200 + hashtags: hashtags, 201 + onUsernameTap: () { 202 + // Handle username tap 203 + }, 204 + onHashtagTap: () { 205 + // Handle hashtag tap 206 + }, 207 + ), 131 208 ), 132 - ), 133 209 134 - // Right side actions 135 - Positioned( 136 - right: 10, 137 - bottom: 100, 138 - child: VideoSideActionBar( 139 - likeCount: '250,5K', 140 - commentCount: '100K', 141 - bookmarkCount: '89K', 142 - shareCount: '132,5K', 143 - // Add any callbacks as needed 144 - onLikePressed: () { 145 - // Handle like action 146 - }, 147 - onCommentPressed: () { 148 - // Handle comment action 149 - }, 150 - onBookmarkPressed: () { 151 - // Handle bookmark action 152 - }, 153 - onSharePressed: () { 154 - // Handle share action 155 - }, 156 - onProfilePressed: () { 157 - // Handle profile action 158 - }, 210 + // Right side actions 211 + Positioned( 212 + right: 10, 213 + bottom: 100, 214 + child: VideoSideActionBar( 215 + likeCount: '${(widget.index + 1) * 35}K', 216 + commentCount: '${(widget.index + 1) * 12}K', 217 + bookmarkCount: '${(widget.index + 1) * 8}K', 218 + shareCount: '${(widget.index + 1) * 20}K', 219 + onLikePressed: () { 220 + // Handle like action 221 + }, 222 + onCommentPressed: () { 223 + // Handle comment action 224 + }, 225 + onBookmarkPressed: () { 226 + // Handle bookmark action 227 + }, 228 + onSharePressed: () { 229 + // Handle share action 230 + }, 231 + onProfilePressed: () { 232 + // Handle profile action 233 + }, 234 + ), 159 235 ), 160 - ), 161 - ], 236 + 237 + // Loading indicator 238 + if (widget.videoUrl != null && !_isInitialized) 239 + const Center( 240 + child: CupertinoActivityIndicator( 241 + color: CupertinoColors.white, 242 + radius: 20, 243 + ), 244 + ), 245 + 246 + // Tap to play/pause overlay 247 + Positioned.fill( 248 + child: GestureDetector( 249 + onTap: () { 250 + if (_controller != null && _isInitialized) { 251 + setState(() { 252 + if (_controller!.value.isPlaying) { 253 + _controller!.pause(); 254 + } else { 255 + _controller!.play(); 256 + } 257 + }); 258 + } 259 + }, 260 + // Make the entire area tappable but transparent 261 + child: Container( 262 + color: Colors.transparent, 263 + ), 264 + ), 265 + ), 266 + ], 267 + ), 162 268 ), 163 269 ); 270 + } 271 + 272 + Widget _buildVideoContent() { 273 + if (widget.videoUrl != null && _controller != null && _isInitialized) { 274 + // Calculate the appropriate size for the video while maintaining aspect ratio 275 + final videoSize = _controller!.value.size; 276 + 277 + double videoWidth = videoSize.width; 278 + double videoHeight = videoSize.height; 279 + 280 + // Calculate the scaling factor to fit the video properly 281 + double aspectRatio = videoWidth / videoHeight; 282 + 283 + Widget videoWidget; 284 + 285 + if (aspectRatio > 1) { 286 + // Horizontal video - use FittedBox with BoxFit.contain 287 + // This ensures the entire video is visible and centered 288 + videoWidget = FittedBox( 289 + fit: BoxFit.contain, 290 + child: SizedBox( 291 + width: videoWidth, 292 + height: videoHeight, 293 + child: VideoPlayer(_controller!), 294 + ), 295 + ); 296 + } else { 297 + // Vertical video - fit height to screen 298 + videoWidget = AspectRatio( 299 + aspectRatio: aspectRatio, 300 + child: VideoPlayer(_controller!), 301 + ); 302 + } 303 + 304 + return videoWidget; 305 + } else { 306 + // Placeholder for videos without a URL or while loading 307 + return Container( 308 + color: widget.index % 2 == 0 ? CupertinoColors.systemIndigo : CupertinoColors.systemPurple, 309 + child: Center( 310 + child: Column( 311 + mainAxisSize: MainAxisSize.min, 312 + children: [ 313 + Icon( 314 + Ionicons.play_circle_outline, 315 + size: 80, 316 + color: CupertinoColors.white.withOpacity(0.7), 317 + ), 318 + const SizedBox(height: 16), 319 + Text( 320 + 'Video ${widget.index + 1}', 321 + style: const TextStyle( 322 + color: CupertinoColors.white, 323 + fontSize: 18, 324 + fontWeight: FontWeight.bold, 325 + ), 326 + ), 327 + ], 328 + ), 329 + ), 330 + ); 331 + } 164 332 } 165 333 }
+8
pubspec.lock
··· 917 917 url: "https://pub.dev" 918 918 source: hosted 919 919 version: "2.3.4" 920 + visibility_detector: 921 + dependency: "direct main" 922 + description: 923 + name: visibility_detector 924 + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 925 + url: "https://pub.dev" 926 + source: hosted 927 + version: "0.4.0+2" 920 928 vm_service: 921 929 dependency: transitive 922 930 description:
+2 -1
pubspec.yaml
··· 11 11 sdk: flutter 12 12 ionicons: ^0.2.2 13 13 cupertino_icons: ^1.0.6 14 - video_player: ^2.8.2 14 + video_player: ^2.9.3 15 15 cached_network_image: ^3.3.1 16 16 camera: ^0.10.5+9 17 17 path_provider: ^2.1.2 ··· 22 22 bluesky: ^0.18.10 23 23 http: ^1.1.0 24 24 shared_preferences: ^2.5.2 25 + visibility_detector: ^0.4.0+2 25 26 26 27 dev_dependencies: 27 28 flutter_test: