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

mf said caching (#21)

hell yeah

authored by

C3B and committed by
GitHub
0ffe4c0d 4b69cfa1

+844 -548
+3 -3
lib/models/feed_post.dart
··· 57 57 String? videoAlt; 58 58 59 59 if (post.embed?.data is EmbedVideoView) { 60 - videoUrl = (post.embed?.data as EmbedVideoView).playlist; 61 - videoAlt = (post.embed?.data as EmbedVideoView).alt; 60 + final videoEmbed = post.embed?.data as EmbedVideoView; 61 + videoUrl = videoEmbed.playlist; 62 + videoAlt = videoEmbed.alt; 62 63 hasMedia = true; 63 64 } else if (post.embed?.data is EmbedViewImages) { 64 65 hasMedia = true; ··· 66 67 imageUrls = embedImages.images.map((img) => img.fullsize).toList(); 67 68 imageAlts = embedImages.images.map((img) => img.alt).toList(); 68 69 } 69 - if (!hasMedia) {} 70 70 71 71 // Check if the post is a reply 72 72 bool isReply = post.record.reply != null;
+425
lib/screens/feed_screen.dart
··· 1 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:provider/provider.dart'; 4 + import 'package:video_player/video_player.dart'; 5 + 6 + import '../models/feed_post.dart'; 7 + import '../screens/profile_screen.dart'; 8 + import '../services/actions_service.dart'; 9 + import '../services/auth_service.dart'; 10 + import '../services/feed_manager.dart'; 11 + import '../services/feed_settings_service.dart'; 12 + import '../services/media_manager.dart'; 13 + 14 + import '../widgets/image/image_post_item.dart'; 15 + import '../widgets/video/preloaded_video_item.dart'; 16 + import '../widgets/video/video_item.dart'; 17 + 18 + class FeedScreen extends StatefulWidget { 19 + final int feedType; 20 + final List<FeedPost>? initialPosts; 21 + final int? initialIndex; 22 + final bool showBackButton; 23 + 24 + const FeedScreen({super.key, required this.feedType, this.initialPosts, this.initialIndex, this.showBackButton = false}); 25 + 26 + @override 27 + State<FeedScreen> createState() => _FeedScreenState(); 28 + } 29 + 30 + class _FeedScreenState extends State<FeedScreen> { 31 + final PageController _pageController = PageController(); 32 + final FeedManager _feedManager = FeedManager(); 33 + final MediaManager _mediaManager = MediaManager(); 34 + 35 + List<FeedPost>? _feedPosts; 36 + int _currentIndex = 0; 37 + bool _isLoading = true; 38 + String? _errorMessage; 39 + 40 + @override 41 + void initState() { 42 + super.initState(); 43 + _initializeScreen(); 44 + } 45 + 46 + Future<void> _initializeScreen() async { 47 + if (widget.initialPosts != null) { 48 + setState(() { 49 + _feedPosts = widget.initialPosts; 50 + _isLoading = false; 51 + _currentIndex = widget.initialIndex ?? 0; 52 + }); 53 + _preloadInitialMedia(); 54 + WidgetsBinding.instance.addPostFrameCallback((_) { 55 + if (_pageController.hasClients) { 56 + _pageController.jumpToPage(_currentIndex); 57 + } 58 + }); 59 + } else { 60 + await _fetchFeed(); 61 + } 62 + } 63 + 64 + @override 65 + void dispose() { 66 + _mediaManager.dispose(); 67 + _pageController.dispose(); 68 + super.dispose(); 69 + } 70 + 71 + Future<void> _fetchFeed() async { 72 + if (!mounted) return; 73 + 74 + try { 75 + setState(() { 76 + _isLoading = true; 77 + _errorMessage = null; 78 + _currentIndex = 0; 79 + }); 80 + 81 + _mediaManager.clearAllMedia(); 82 + 83 + final authService = context.read<AuthService>(); 84 + final posts = await _feedManager.fetchFeed(widget.feedType, authService); 85 + 86 + if (!mounted) return; 87 + 88 + final uniquePosts = _removeDuplicatePosts(posts); 89 + 90 + setState(() { 91 + _feedPosts = uniquePosts; 92 + _isLoading = false; 93 + _resetPageController(); 94 + _preloadInitialMedia(); 95 + }); 96 + } catch (e) { 97 + if (!mounted) return; 98 + 99 + setState(() { 100 + _isLoading = false; 101 + _errorMessage = e.toString(); 102 + }); 103 + } 104 + } 105 + 106 + List<FeedPost> _removeDuplicatePosts(List<FeedPost> posts) { 107 + if (posts.isEmpty) return []; 108 + 109 + final uniquePosts = <FeedPost>[]; 110 + 111 + for (final post in posts) { 112 + final isDuplicate = uniquePosts.any((uniquePost) => uniquePost.isDuplicateOf(post)); 113 + if (!isDuplicate) { 114 + uniquePosts.add(post); 115 + } 116 + } 117 + 118 + return uniquePosts; 119 + } 120 + 121 + void _resetPageController() { 122 + WidgetsBinding.instance.addPostFrameCallback((_) { 123 + if (_pageController.hasClients) { 124 + _pageController.jumpToPage(0); 125 + } 126 + }); 127 + } 128 + 129 + Future<void> _preloadMedia(int index) async { 130 + if (index < 0 || index >= (_feedPosts?.length ?? 0)) return; 131 + 132 + final post = _feedPosts![index]; 133 + if (post.videoUrl != null) { 134 + // Force preload for the first video 135 + if (index == 0) { 136 + await _mediaManager.preloadMedia(index, post.videoUrl, post.imageUrls, context); 137 + if (mounted) { 138 + setState(() {}); // Trigger rebuild to show preloaded video 139 + } 140 + } else { 141 + _mediaManager.preloadMedia(index, post.videoUrl, post.imageUrls, context); 142 + } 143 + } else if (post.imageUrls.isNotEmpty) { 144 + _mediaManager.preloadMedia(index, null, post.imageUrls, context); 145 + } 146 + } 147 + 148 + Future<void> _preloadInitialMedia() async { 149 + if (_feedPosts == null || _feedPosts!.isEmpty) return; 150 + 151 + // Preload the first video immediately 152 + await _preloadMedia(0); 153 + 154 + // Preload next videos in the background 155 + for (int i = 1; i <= 5 && i < _feedPosts!.length; i++) { 156 + _preloadMedia(i); 157 + } 158 + } 159 + 160 + @override 161 + Widget build(BuildContext context) { 162 + return Material( 163 + color: Colors.black, 164 + child: Stack( 165 + children: [ 166 + _buildMainContent(), 167 + if (widget.showBackButton) 168 + Positioned( 169 + top: MediaQuery.of(context).padding.top + 10, 170 + left: 10, 171 + child: IconButton( 172 + icon: const Icon(FluentIcons.arrow_left_24_regular, color: Colors.white), 173 + onPressed: () => Navigator.of(context).pop(), 174 + ), 175 + ), 176 + ], 177 + ), 178 + ); 179 + } 180 + 181 + Widget _buildMainContent() { 182 + return SizedBox( 183 + height: MediaQuery.of(context).size.height, 184 + width: MediaQuery.of(context).size.width, 185 + child: 186 + _isLoading 187 + ? const Center(child: CircularProgressIndicator()) 188 + : _errorMessage != null 189 + ? Center(child: Text('Error: $_errorMessage', style: const TextStyle(color: Colors.white))) 190 + : _feedPosts == null || _feedPosts!.isEmpty 191 + ? const Center(child: Text('No media available', style: TextStyle(color: Colors.white))) 192 + : _buildFeedPageView(), 193 + ); 194 + } 195 + 196 + Widget _buildFeedPageView() { 197 + final feedSettings = Provider.of<FeedSettingsService>(context); 198 + final disableBackgroundBlur = feedSettings.disableVideoBackgroundBlur; 199 + 200 + return PageView.builder( 201 + controller: _pageController, 202 + scrollDirection: Axis.vertical, 203 + itemCount: _feedPosts?.length ?? 0, 204 + onPageChanged: (newIndex) { 205 + if (_currentIndex != newIndex) { 206 + setState(() { 207 + _mediaManager.updateLoadedMedia(newIndex, _currentIndex, _feedPosts?.length ?? 0); 208 + _currentIndex = newIndex; 209 + }); 210 + 211 + final totalPosts = _feedPosts?.length ?? 0; 212 + 213 + // Unload videos that are more than 10 positions away 214 + for (int i = 0; i < totalPosts; i++) { 215 + if (i < newIndex - 10 || i > newIndex + 10) { 216 + _mediaManager.unloadVideo(i); 217 + } 218 + } 219 + 220 + // Preload videos within 5 positions 221 + for (int i = newIndex - 5; i <= newIndex + 5; i++) { 222 + if (i >= 0 && i < totalPosts) { 223 + _preloadMedia(i); 224 + } 225 + } 226 + } 227 + }, 228 + itemBuilder: (context, index) { 229 + final post = _feedPosts![index]; 230 + final isLiked = post.isLiked; 231 + 232 + if (post.videoUrl != null) { 233 + final isPreloaded = _mediaManager.isVideoPreloaded(index); 234 + final preloadedVideo = _mediaManager.getPreloadedVideo(index); 235 + 236 + if (isPreloaded && preloadedVideo != null) { 237 + return PreloadedVideoItem( 238 + key: ValueKey('video_$index'), 239 + index: index, 240 + controller: preloadedVideo.controller, 241 + username: post.username, 242 + description: post.description, 243 + hashtags: post.hashtags, 244 + likeCount: post.likeCount, 245 + commentCount: post.commentCount, 246 + bookmarkCount: 0, 247 + shareCount: post.shareCount, 248 + profileImageUrl: post.profileImageUrl, 249 + authorDid: post.authorDid, 250 + isVisible: index == _currentIndex, 251 + isLiked: isLiked, 252 + isSprk: post.isSprk, 253 + postUri: post.uri, 254 + postCid: post.cid, 255 + disableBackgroundBlur: disableBackgroundBlur, 256 + videoAlt: post.videoAlt, 257 + onLikePressed: () => _handleLikePress(post), 258 + onBookmarkPressed: () {}, 259 + onSharePressed: () {}, 260 + onProfilePressed: () { 261 + Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 262 + error, 263 + ) { 264 + ScaffoldMessenger.of( 265 + context, 266 + ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 267 + return null; 268 + }); 269 + }, 270 + onUsernameTap: () { 271 + Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 272 + error, 273 + ) { 274 + ScaffoldMessenger.of( 275 + context, 276 + ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 277 + return null; 278 + }); 279 + }, 280 + onHashtagTap: (String hashtag) {}, 281 + onPostDeleted: () { 282 + _fetchFeed(); 283 + }, 284 + ); 285 + } else { 286 + return VideoItem( 287 + key: ValueKey('video_$index'), 288 + index: index, 289 + videoUrl: post.videoUrl, 290 + videoAlt: post.videoAlt, 291 + preloadedController: isPreloaded ? preloadedVideo?.controller : null, 292 + localVideoPath: isPreloaded ? _mediaManager.getLocalVideoPath(index) : null, 293 + username: post.username, 294 + description: post.description, 295 + hashtags: post.hashtags, 296 + likeCount: post.likeCount, 297 + commentCount: post.commentCount, 298 + bookmarkCount: 0, 299 + shareCount: post.shareCount, 300 + profileImageUrl: post.profileImageUrl, 301 + authorDid: post.authorDid, 302 + isLiked: isLiked, 303 + isSprk: post.isSprk, 304 + postUri: post.uri, 305 + postCid: post.cid, 306 + disableBackgroundBlur: disableBackgroundBlur, 307 + onLikePressed: () => _handleLikePress(post), 308 + onBookmarkPressed: () {}, 309 + onSharePressed: () {}, 310 + onProfilePressed: () { 311 + Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 312 + error, 313 + ) { 314 + ScaffoldMessenger.of( 315 + context, 316 + ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 317 + return null; 318 + }); 319 + }, 320 + onUsernameTap: () { 321 + Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 322 + error, 323 + ) { 324 + ScaffoldMessenger.of( 325 + context, 326 + ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 327 + return null; 328 + }); 329 + }, 330 + onHashtagTap: (String hashtag) {}, 331 + onPostDeleted: () { 332 + _fetchFeed(); 333 + }, 334 + ); 335 + } 336 + } else if (post.imageUrls.isNotEmpty) { 337 + return ImagePostItem( 338 + key: ValueKey('image_$index'), 339 + index: index, 340 + imageUrls: post.imageUrls, 341 + imageAlts: post.imageAlts, 342 + username: post.username, 343 + description: post.description, 344 + hashtags: post.hashtags, 345 + likeCount: post.likeCount, 346 + commentCount: post.commentCount, 347 + bookmarkCount: 0, 348 + shareCount: post.shareCount, 349 + profileImageUrl: post.profileImageUrl, 350 + authorDid: post.authorDid, 351 + isLiked: isLiked, 352 + isSprk: post.isSprk, 353 + postUri: post.uri, 354 + postCid: post.cid, 355 + isVisible: index == _currentIndex, 356 + disableBackgroundBlur: disableBackgroundBlur, 357 + onLikePressed: () => _handleLikePress(post), 358 + onBookmarkPressed: () {}, 359 + onSharePressed: () {}, 360 + onUsernameTap: () {}, 361 + onHashtagTap: (String hashtag) {}, 362 + ); 363 + } else { 364 + return const Center(child: Text('Unsupported media type', style: TextStyle(color: Colors.white))); 365 + } 366 + }, 367 + ); 368 + } 369 + 370 + Future<void> _handleLikePress(FeedPost post) async { 371 + final actionsService = Provider.of<ActionsService>(context, listen: false); 372 + 373 + try { 374 + final newLikeUri = await actionsService.toggleLike(post); 375 + 376 + if (!mounted) return; 377 + 378 + setState(() { 379 + final index = _feedPosts?.indexWhere((p) => p.uri == post.uri) ?? -1; 380 + if (index >= 0 && _feedPosts != null) { 381 + _feedPosts![index] = FeedPost( 382 + username: post.username, 383 + authorDid: post.authorDid, 384 + profileImageUrl: post.profileImageUrl, 385 + description: post.description, 386 + videoUrl: post.videoUrl, 387 + videoAlt: post.videoAlt, 388 + likeCount: 389 + post.likeCount + 390 + (newLikeUri != null 391 + ? 1 392 + : post.isLiked 393 + ? -1 394 + : 0), 395 + commentCount: post.commentCount, 396 + shareCount: post.shareCount, 397 + hashtags: post.hashtags, 398 + uri: post.uri, 399 + cid: post.cid, 400 + imageAlts: post.imageAlts, 401 + isSprk: post.isSprk, 402 + likeUri: newLikeUri, 403 + hasMedia: post.hasMedia, 404 + isReply: post.isReply, 405 + imageUrls: post.imageUrls, 406 + ); 407 + } 408 + }); 409 + } catch (e) { 410 + if (!mounted) return; 411 + 412 + ScaffoldMessenger.of( 413 + context, 414 + ).showSnackBar(SnackBar(content: Text('Error liking post: ${e.toString()}'), backgroundColor: Colors.red)); 415 + } 416 + } 417 + } 418 + 419 + class PreloadedVideo { 420 + final VideoPlayerController controller; 421 + final bool isInitialized; 422 + final String? videoUrl; 423 + 424 + PreloadedVideo({required this.controller, required this.isInitialized, required this.videoUrl}); 425 + }
+51 -333
lib/screens/home_screen.dart
··· 1 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 2 import 'package:flutter/material.dart'; 3 - import 'package:provider/provider.dart'; 4 3 import 'package:video_player/video_player.dart'; 4 + import 'package:provider/provider.dart'; 5 5 6 - import '../models/feed_post.dart'; 7 - import '../screens/profile_screen.dart'; 8 - import '../services/actions_service.dart'; 9 - import '../services/auth_service.dart'; 10 - import '../services/feed_manager.dart'; 11 6 import '../services/feed_settings_service.dart'; 12 - import '../services/media_manager.dart'; 13 7 import '../utils/app_colors.dart'; 14 8 import '../widgets/feed/feed_selector.dart'; 15 9 import '../widgets/feed_settings/feed_settings_sheet.dart'; 16 - import '../widgets/image/image_post_item.dart'; 17 - import '../widgets/video/preloaded_video_item.dart'; 18 - import '../widgets/video/video_item.dart'; 10 + import 'feed_screen.dart'; 19 11 20 12 class HomeScreen extends StatefulWidget { 21 13 const HomeScreen({super.key}); ··· 25 17 } 26 18 27 19 class _HomeScreenState extends State<HomeScreen> { 28 - final PageController _pageController = PageController(); 29 - final FeedManager _feedManager = FeedManager(); 30 20 final FeedSettingsService _feedSettings = FeedSettingsService(); 31 - final MediaManager _mediaManager = MediaManager(); 32 - 33 - List<FeedPost>? _feedPosts; 34 - int _currentIndex = 0; 35 - bool _isLoading = true; 36 - String? _errorMessage; 21 + final PageController _pageController = PageController(); 22 + int _selectedTabIndex = 0; 37 23 38 24 @override 39 25 void initState() { 40 26 super.initState(); 41 27 _initializeScreen(); 42 - } 43 - 44 - Future<void> _initializeScreen() async { 45 - await _feedSettings.loadPreferences(); 46 - await _fetchFeed(); 28 + _feedSettings.addListener(_onFeedSettingsChanged); 47 29 } 48 30 49 31 @override 50 32 void dispose() { 51 - _mediaManager.dispose(); 33 + _pageController.removeListener(_onPageChanged); 34 + _feedSettings.removeListener(_onFeedSettingsChanged); 52 35 _pageController.dispose(); 53 36 super.dispose(); 54 37 } 55 38 56 - Future<void> _fetchFeed() async { 57 - if (!mounted) return; 58 - 59 - try { 60 - setState(() { 61 - _isLoading = true; 62 - _errorMessage = null; 63 - _currentIndex = 0; // Reset current index when changing feeds 64 - }); 65 - 66 - // Clear all preloaded media to avoid mixing videos between feeds 67 - _mediaManager.clearAllMedia(); 68 - 69 - // Get new feed content 70 - final authService = context.read<AuthService>(); 71 - final posts = await _feedManager.fetchFeed(_feedSettings.selectedFeedType, authService); 72 - 73 - if (!mounted) return; 74 - 75 - // Filter out duplicate posts 76 - final uniquePosts = _removeDuplicatePosts(posts); 77 - 78 - setState(() { 79 - _feedPosts = uniquePosts; 80 - _isLoading = false; 81 - 82 - // Reset page controller safely after new content is loaded 83 - _resetPageController(); 84 - 85 - // Preload media for the current feed 86 - _preloadInitialMedia(); 87 - }); 88 - } catch (e) { 89 - if (!mounted) return; 90 - 91 - setState(() { 92 - _isLoading = false; 93 - _errorMessage = e.toString(); 94 - }); 39 + void _onFeedSettingsChanged() { 40 + if (mounted) { 41 + setState(() {}); 95 42 } 96 43 } 97 44 98 - /// Remove duplicate posts from the feed while preserving order 99 - List<FeedPost> _removeDuplicatePosts(List<FeedPost> posts) { 100 - if (posts.isEmpty) return []; 101 - 102 - final uniquePosts = <FeedPost>[]; 103 - 104 - for (final post in posts) { 105 - // Check if this post is a duplicate of any post we've already added 106 - final isDuplicate = uniquePosts.any((uniquePost) => uniquePost.isDuplicateOf(post)); 45 + Future<void> _initializeScreen() async { 46 + await _feedSettings.loadPreferences(); 47 + _pageController.addListener(_onPageChanged); 107 48 108 - if (!isDuplicate) { 109 - uniquePosts.add(post); 110 - } 49 + // Initialize selected tab index and ensure page controller is at the correct position 50 + final feedOptions = _buildFeedOptions(); 51 + _selectedTabIndex = feedOptions.indexWhere((option) => option.value == _feedSettings.selectedFeedType); 52 + if (_selectedTabIndex == -1) { 53 + _selectedTabIndex = 0; // Fallback to first tab if not found 111 54 } 112 55 113 - return uniquePosts; 114 - } 115 - 116 - void _resetPageController() { 117 - // Schedule this for the next frame when the PageView will be built 56 + // Ensure page controller is at the correct position 118 57 WidgetsBinding.instance.addPostFrameCallback((_) { 119 58 if (_pageController.hasClients) { 120 - _pageController.jumpToPage(0); 59 + _pageController.jumpToPage(_selectedTabIndex); 121 60 } 122 61 }); 123 62 } 124 63 125 - void _preloadInitialMedia() { 126 - if (_feedPosts == null || _feedPosts!.isEmpty) return; 127 - 128 - // Start with current item 129 - _preloadMedia(0); 130 - 131 - // Then preload the next few items 132 - for (int i = 1; i <= 5 && i < _feedPosts!.length; i++) { 133 - _preloadMedia(i); 64 + void _onPageChanged() { 65 + if (_pageController.page == null) return; 66 + final currentPage = _pageController.page!.round(); 67 + final feedOptions = _buildFeedOptions(); 68 + if (currentPage < feedOptions.length) { 69 + _selectedTabIndex = currentPage; 70 + _feedSettings.setSelectedFeedType(feedOptions[currentPage].value); 134 71 } 135 72 } 136 73 137 - void _preloadMedia(int index) { 138 - if (index < 0 || index >= (_feedPosts?.length ?? 0)) return; 139 - 140 - final post = _feedPosts![index]; 141 - _mediaManager.preloadMedia(index, post.videoUrl, post.imageUrls, context); 142 - } 143 - 144 74 @override 145 75 Widget build(BuildContext context) { 146 76 final topPadding = MediaQuery.of(context).padding.top; 147 77 final isDarkMode = MediaQuery.of(context).platformBrightness == Brightness.dark; 148 - 149 - // Get available feed options based on enabled status 150 78 final List<FeedOption> feedOptions = _buildFeedOptions(); 151 79 152 80 return Scaffold( ··· 174 102 } 175 103 176 104 Widget _buildMainContent() { 177 - return SizedBox( 178 - height: MediaQuery.of(context).size.height, 179 - width: MediaQuery.of(context).size.width, 180 - child: 181 - _isLoading 182 - ? const Center(child: CircularProgressIndicator()) 183 - : _errorMessage != null 184 - ? Center(child: Text('Error: $_errorMessage', style: const TextStyle(color: Colors.white))) 185 - : _feedPosts == null || _feedPosts!.isEmpty 186 - ? const Center(child: Text('No media available', style: TextStyle(color: Colors.white))) 187 - : _buildFeedPageView(), 105 + final feedOptions = _buildFeedOptions(); 106 + return PageView.builder( 107 + controller: _pageController, 108 + itemCount: feedOptions.length, 109 + onPageChanged: (index) { 110 + if (index < feedOptions.length) { 111 + _feedSettings.setSelectedFeedType(feedOptions[index].value); 112 + } 113 + }, 114 + itemBuilder: (context, index) { 115 + final feedType = feedOptions[index].value; 116 + return ChangeNotifierProvider<FeedSettingsService>.value(value: _feedSettings, child: FeedScreen(feedType: feedType)); 117 + }, 188 118 ); 189 119 } 190 120 ··· 198 128 child: Row( 199 129 mainAxisAlignment: MainAxisAlignment.center, 200 130 children: [ 201 - const SizedBox(width: 30), // For balance 131 + const SizedBox(width: 30), 202 132 Expanded( 203 133 child: Center( 204 134 child: Container( ··· 208 138 feedOptions.isNotEmpty 209 139 ? FeedSelector( 210 140 options: feedOptions, 211 - selectedValue: _feedSettings.selectedFeedType, 141 + selectedValue: feedOptions[_selectedTabIndex].value, 212 142 onOptionSelected: _onFeedSelected, 213 143 ) 214 144 : const SizedBox(), ··· 228 158 } 229 159 230 160 Future<void> _onFeedSelected(int value) async { 231 - // Only jump to page if controller is attached 232 - if (_pageController.hasClients) { 233 - _pageController.jumpToPage(0); 234 - } 235 - 236 - await _feedSettings.setSelectedFeedType(value); 237 - if (mounted) { 238 - _fetchFeed(); 239 - } 240 - } 241 - 242 - Widget _buildFeedPageView() { 243 - return PageView.builder( 244 - controller: _pageController, 245 - scrollDirection: Axis.vertical, 246 - itemCount: _feedPosts?.length ?? 0, 247 - onPageChanged: (newIndex) { 248 - if (_currentIndex != newIndex) { 249 - setState(() { 250 - _mediaManager.updateLoadedMedia(newIndex, _currentIndex, _feedPosts?.length ?? 0); 251 - _currentIndex = newIndex; 252 - }); 253 - 254 - // Preload a few more items ahead if we're getting close to the end 255 - if (newIndex + 3 >= (_feedPosts?.length ?? 0)) { 256 - for (int i = newIndex + 1; i < (_feedPosts?.length ?? 0); i++) { 257 - _preloadMedia(i); 258 - } 259 - } 260 - } 261 - }, 262 - itemBuilder: (context, index) { 263 - final post = _feedPosts![index]; 264 - final isLiked = post.isLiked; 265 - 266 - // For video posts 267 - if (post.videoUrl != null) { 268 - final isPreloaded = _mediaManager.isVideoPreloaded(index); 269 - final preloadedVideo = _mediaManager.getPreloadedVideo(index); 270 - 271 - if (isPreloaded && preloadedVideo != null) { 272 - return PreloadedVideoItem( 273 - key: ValueKey('video_$index'), 274 - index: index, 275 - controller: preloadedVideo.controller, 276 - username: post.username, 277 - description: post.description, 278 - hashtags: post.hashtags, 279 - likeCount: post.likeCount, 280 - commentCount: post.commentCount, 281 - bookmarkCount: 0, 282 - shareCount: post.shareCount, 283 - profileImageUrl: post.profileImageUrl, 284 - authorDid: post.authorDid, 285 - isVisible: index == _currentIndex, 286 - isLiked: isLiked, 287 - isSprk: post.isSprk, 288 - postUri: post.uri, 289 - postCid: post.cid, 290 - disableBackgroundBlur: _feedSettings.disableVideoBackgroundBlur, 291 - videoAlt: post.videoAlt, 292 - onLikePressed: () => _handleLikePress(post), 293 - onBookmarkPressed: () {}, 294 - onSharePressed: () {}, 295 - onProfilePressed: () { 296 - Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 297 - error, 298 - ) { 299 - ScaffoldMessenger.of( 300 - context, 301 - ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 302 - return null; 303 - }); 304 - }, 305 - onUsernameTap: () { 306 - Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 307 - error, 308 - ) { 309 - ScaffoldMessenger.of( 310 - context, 311 - ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 312 - return null; 313 - }); 314 - }, 315 - onHashtagTap: (String hashtag) {}, 316 - onPostDeleted: () { 317 - _fetchFeed(); 318 - }, 319 - ); 320 - } else { 321 - return VideoItem( 322 - key: ValueKey('video_$index'), 323 - index: index, 324 - videoUrl: post.videoUrl, 325 - videoAlt: post.videoAlt, 326 - username: post.username, 327 - description: post.description, 328 - hashtags: post.hashtags, 329 - likeCount: post.likeCount, 330 - commentCount: post.commentCount, 331 - bookmarkCount: 0, 332 - shareCount: post.shareCount, 333 - profileImageUrl: post.profileImageUrl, 334 - authorDid: post.authorDid, 335 - isLiked: isLiked, 336 - isSprk: post.isSprk, 337 - postUri: post.uri, 338 - postCid: post.cid, 339 - disableBackgroundBlur: _feedSettings.disableVideoBackgroundBlur, 340 - onLikePressed: () => _handleLikePress(post), 341 - onBookmarkPressed: () {}, 342 - onSharePressed: () {}, 343 - onProfilePressed: () { 344 - Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 345 - error, 346 - ) { 347 - ScaffoldMessenger.of( 348 - context, 349 - ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 350 - return null; 351 - }); 352 - }, 353 - onUsernameTap: () { 354 - Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 355 - error, 356 - ) { 357 - ScaffoldMessenger.of( 358 - context, 359 - ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); 360 - return null; 361 - }); 362 - }, 363 - onHashtagTap: (String hashtag) {}, 364 - onPostDeleted: () { 365 - _fetchFeed(); 366 - }, 367 - ); 368 - } 369 - } 370 - // For image posts 371 - else if (post.imageUrls.isNotEmpty) { 372 - return ImagePostItem( 373 - key: ValueKey('image_$index'), 374 - index: index, 375 - imageUrls: post.imageUrls, 376 - imageAlts: post.imageAlts, 377 - username: post.username, 378 - description: post.description, 379 - hashtags: post.hashtags, 380 - likeCount: post.likeCount, 381 - commentCount: post.commentCount, 382 - bookmarkCount: 0, 383 - shareCount: post.shareCount, 384 - profileImageUrl: post.profileImageUrl, 385 - authorDid: post.authorDid, 386 - isLiked: isLiked, 387 - isSprk: post.isSprk, 388 - postUri: post.uri, 389 - postCid: post.cid, 390 - isVisible: index == _currentIndex, 391 - disableBackgroundBlur: _feedSettings.disableVideoBackgroundBlur, 392 - onLikePressed: () => _handleLikePress(post), 393 - onBookmarkPressed: () {}, 394 - onSharePressed: () {}, 395 - onUsernameTap: () {}, 396 - onHashtagTap: (String hashtag) {}, 397 - ); 398 - } 399 - // Fallback for any other post type 400 - else { 401 - return const Center(child: Text('Unsupported media type', style: TextStyle(color: Colors.white))); 402 - } 403 - }, 404 - ); 405 - } 406 - 407 - Future<void> _handleLikePress(FeedPost post) async { 408 - final actionsService = Provider.of<ActionsService>(context, listen: false); 409 - 410 - try { 411 - final newLikeUri = await actionsService.toggleLike(post); 412 - 413 - if (!mounted) return; 414 - 161 + final feedOptions = _buildFeedOptions(); 162 + final index = feedOptions.indexWhere((option) => option.value == value); 163 + if (index != -1) { 415 164 setState(() { 416 - final index = _feedPosts?.indexWhere((p) => p.uri == post.uri) ?? -1; 417 - if (index >= 0 && _feedPosts != null) { 418 - _feedPosts![index] = FeedPost( 419 - username: post.username, 420 - authorDid: post.authorDid, 421 - profileImageUrl: post.profileImageUrl, 422 - description: post.description, 423 - videoUrl: post.videoUrl, 424 - videoAlt: post.videoAlt, 425 - likeCount: 426 - post.likeCount + 427 - (newLikeUri != null 428 - ? 1 429 - : post.isLiked 430 - ? -1 431 - : 0), 432 - commentCount: post.commentCount, 433 - shareCount: post.shareCount, 434 - hashtags: post.hashtags, 435 - uri: post.uri, 436 - cid: post.cid, 437 - imageAlts: post.imageAlts, 438 - isSprk: post.isSprk, 439 - likeUri: newLikeUri, 440 - hasMedia: post.hasMedia, 441 - isReply: post.isReply, 442 - imageUrls: post.imageUrls, 443 - ); 444 - } 165 + _selectedTabIndex = index; 445 166 }); 446 - } catch (e) { 447 - if (!mounted) return; 448 - 449 - ScaffoldMessenger.of( 450 - context, 451 - ).showSnackBar(SnackBar(content: Text('Error liking post: ${e.toString()}'), backgroundColor: Colors.red)); 167 + await _feedSettings.setSelectedFeedType(value); 168 + if (_pageController.hasClients) { 169 + await _pageController.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); 170 + } 452 171 } 453 172 } 454 173 ··· 501 220 if (mounted) { 502 221 setState(() {}); 503 222 504 - // If the current feed was disabled, fetch the new feed 505 223 if (!isEnabled && _feedSettings.getFeedTypeFromSetting(settingType) == _feedSettings.selectedFeedType) { 506 - _fetchFeed(); 224 + _pageController.jumpToPage(0); 507 225 } 508 226 } 509 227 }
+9 -1
lib/services/feed_settings_service.dart
··· 1 + import 'package:flutter/material.dart'; 1 2 import 'package:shared_preferences/shared_preferences.dart'; 2 3 3 4 class FeedType { ··· 6 7 static const int latest = 2; 7 8 } 8 9 9 - class FeedSettingsService { 10 + class FeedSettingsService extends ChangeNotifier { 10 11 // Singleton instance 11 12 static final FeedSettingsService _instance = FeedSettingsService._internal(); 12 13 factory FeedSettingsService() => _instance; ··· 47 48 if (!isSelectedFeedEnabled()) { 48 49 selectFirstEnabledFeed(); 49 50 } 51 + notifyListeners(); 50 52 } catch (e) { 51 53 // If preferences fail to load, use defaults 52 54 resetToDefaults(); ··· 61 63 await prefs.setBool(_keyLatestFeed, _latestFeedEnabled); 62 64 await prefs.setBool(_keyDisableBlur, _disableVideoBackgroundBlur); 63 65 await prefs.setInt(_keySelectedFeed, _selectedFeedType); 66 + notifyListeners(); 64 67 } catch (e) { 65 68 // Silently handle preference save errors 66 69 } ··· 72 75 _latestFeedEnabled = true; 73 76 _disableVideoBackgroundBlur = false; 74 77 _selectedFeedType = FeedType.forYou; 78 + notifyListeners(); 75 79 } 76 80 77 81 bool isSelectedFeedEnabled() { ··· 94 98 _forYouFeedEnabled = true; 95 99 _selectedFeedType = FeedType.forYou; 96 100 } 101 + notifyListeners(); 97 102 } 98 103 99 104 bool canDisableFeed(String settingType) { ··· 126 131 } 127 132 128 133 await savePreferences(); 134 + notifyListeners(); 129 135 } 130 136 131 137 Future<void> setBackgroundBlur(bool disabled) async { 132 138 _disableVideoBackgroundBlur = disabled; 133 139 await savePreferences(); 140 + notifyListeners(); 134 141 } 135 142 136 143 Future<void> setSelectedFeedType(int feedType) async { 137 144 _selectedFeedType = feedType; 138 145 await savePreferences(); 146 + notifyListeners(); 139 147 } 140 148 141 149 int getFeedTypeFromSetting(String settingType) {
+130 -27
lib/services/media_manager.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:video_player/video_player.dart'; 3 3 import 'package:cached_network_image/cached_network_image.dart'; 4 + import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 5 + import 'dart:io'; 6 + import 'package:http/http.dart' as http; 7 + import 'dart:convert'; 4 8 5 9 class PreloadedVideo { 6 10 final VideoPlayerController controller; 7 11 final bool isInitialized; 8 12 final String? videoUrl; 13 + final String? localPath; 9 14 10 - PreloadedVideo({required this.controller, required this.isInitialized, required this.videoUrl}); 15 + PreloadedVideo({required this.controller, required this.isInitialized, required this.videoUrl, this.localPath}); 11 16 12 17 void dispose() { 13 18 controller.dispose(); ··· 25 30 // Track which image URLs have been preloaded 26 31 final Set<String> _preloadedImageUrls = {}; 27 32 33 + // Track local video paths 34 + final Map<int, String> _localVideoPaths = {}; 35 + 36 + // Cache manager for videos 37 + final DefaultCacheManager _cacheManager = DefaultCacheManager(); 38 + 39 + Future<String?> _downloadAndCacheVideo(String videoUrl) async { 40 + try { 41 + // For Bluesky videos, we need to get the actual video file URL 42 + if (videoUrl.contains('bsky.app') || videoUrl.contains('bluesky')) { 43 + // Extract the video ID from the URL 44 + final uri = Uri.parse(videoUrl); 45 + final segments = uri.pathSegments; 46 + if (segments.length >= 3) { 47 + final did = segments[1]; 48 + final cid = segments[2]; 49 + final directVideoUrl = 'https://media.sprk.so/video/$did/$cid'; 50 + 51 + final file = await _cacheManager.getSingleFile(directVideoUrl); 52 + return file.path; 53 + } 54 + } 55 + 56 + // For other videos, use the original URL 57 + final file = await _cacheManager.getSingleFile(videoUrl); 58 + return file.path; 59 + } catch (e) { 60 + print('Error caching video: $e'); 61 + return null; 62 + } 63 + } 64 + 65 + String _normalizeVideoUrl(String url) { 66 + try { 67 + // Handle relative URLs (starting with '/') 68 + if (url.startsWith('/')) { 69 + // Construct full URL for Bluesky videos 70 + return 'https://bsky.app$url'; 71 + } 72 + 73 + final uri = Uri.parse(url); 74 + 75 + // For Bluesky videos, use the path as the cache key 76 + if (uri.host.contains('bsky.app') || uri.host.contains('bluesky')) { 77 + return uri.path; 78 + } 79 + 80 + // For Spark videos, ensure consistent URL format 81 + if (uri.host.contains('sprk.so')) { 82 + return Uri(scheme: uri.scheme, host: uri.host, path: uri.path).toString(); 83 + } 84 + 85 + // For other URLs, use as is 86 + return url; 87 + } catch (e) { 88 + print('Error normalizing URL: $e'); 89 + return url; 90 + } 91 + } 92 + 28 93 void dispose() { 29 94 clearAllMedia(); 30 95 } ··· 35 100 for (final video in _preloadedVideos.values) { 36 101 try { 37 102 video.dispose(); 103 + if (video.localPath != null) { 104 + try { 105 + final file = File(video.localPath!); 106 + if (file.existsSync()) { 107 + file.deleteSync(); 108 + } 109 + } catch (e) { 110 + print('Error cleaning up cached video: $e'); 111 + } 112 + } 38 113 } catch (e) { 39 114 // Silently handle any disposal errors 40 115 } 41 116 } 42 117 _preloadedVideos.clear(); 43 118 _preloadedImageUrls.clear(); 119 + _localVideoPaths.clear(); 120 + 121 + // Clear the cache manager's cache 122 + _cacheManager.emptyCache(); 44 123 } 45 124 46 - void preloadMedia(int index, String? videoUrl, List<String> imageUrls, BuildContext context) { 125 + Future<void> preloadMedia(int index, String? videoUrl, List<String> imageUrls, BuildContext context) async { 47 126 if (videoUrl != null) { 48 - _preloadVideo(index, videoUrl); 127 + await _preloadVideo(index, videoUrl); 49 128 } else if (imageUrls.isNotEmpty) { 50 129 _preloadImages(imageUrls, context); 51 130 } ··· 67 146 _preloadedVideos.remove(index); 68 147 } 69 148 70 - // Create a new controller 71 - final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); 72 - bool isRegistered = false; 149 + // Try to download and cache the video 150 + final localPath = await _downloadAndCacheVideo(videoUrl); 151 + VideoPlayerController controller; 73 152 74 153 try { 154 + if (localPath != null) { 155 + // Always use local file if available 156 + controller = VideoPlayerController.file(File(localPath)); 157 + } else { 158 + // Fall back to network only if caching fails 159 + controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); 160 + } 161 + 75 162 // Register it as non-initialized first 76 - _preloadedVideos[index] = PreloadedVideo(controller: controller, isInitialized: false, videoUrl: videoUrl); 77 - isRegistered = true; 163 + _preloadedVideos[index] = PreloadedVideo( 164 + controller: controller, 165 + isInitialized: false, 166 + videoUrl: videoUrl, 167 + localPath: localPath, 168 + ); 78 169 79 170 // Set video to loop automatically 80 171 controller.setLooping(true); ··· 88 179 // Only proceed if video is still needed and the URL hasn't changed 89 180 if (_preloadedVideos.containsKey(index) && _preloadedVideos[index]!.videoUrl == videoUrl) { 90 181 // Update the preloaded status 91 - _preloadedVideos[index] = PreloadedVideo(controller: controller, isInitialized: true, videoUrl: videoUrl); 182 + _preloadedVideos[index] = PreloadedVideo( 183 + controller: controller, 184 + isInitialized: true, 185 + videoUrl: videoUrl, 186 + localPath: localPath, 187 + ); 92 188 93 189 // Set playback speed to 1.0 (normal) 94 190 await controller.setPlaybackSpeed(1.0); 191 + 192 + // Store the local path 193 + if (localPath != null) { 194 + _localVideoPaths[index] = localPath; 195 + } 95 196 } else { 96 197 // If this video is no longer needed or URL changed, dispose it 97 198 try { ··· 101 202 } 102 203 } 103 204 } catch (e) { 104 - // Handle initialization error by cleaning up 105 - if (isRegistered && _preloadedVideos.containsKey(index) && _preloadedVideos[index]!.videoUrl == videoUrl) { 205 + print('Error preloading video: $e'); 206 + // Clean up if there was an error 207 + if (_preloadedVideos.containsKey(index)) { 106 208 try { 107 209 _preloadedVideos[index]!.dispose(); 108 210 } catch (disposeError) { 109 211 // Silently handle any disposal errors 110 212 } 111 213 _preloadedVideos.remove(index); 112 - } else { 113 - try { 114 - controller.dispose(); 115 - } catch (disposeError) { 116 - // Silently handle any disposal errors 117 - } 118 - } 119 - 120 - // Try again after a short delay for network errors 121 - if (e.toString().contains('network')) { 122 - Future.delayed(const Duration(seconds: 2), () { 123 - // Only retry if the index is not already loaded with a different URL 124 - if (!_preloadedVideos.containsKey(index) || _preloadedVideos[index]!.videoUrl == videoUrl) { 125 - _preloadVideo(index, videoUrl); 126 - } 127 - }); 128 214 } 129 215 } 130 216 } ··· 142 228 if (_preloadedVideos.containsKey(index)) { 143 229 _preloadedVideos[index]!.dispose(); 144 230 _preloadedVideos.remove(index); 231 + _localVideoPaths.remove(index); 232 + 233 + // Clean up cached file if it exists 234 + if (_preloadedVideos[index]?.localPath != null) { 235 + try { 236 + final file = File(_preloadedVideos[index]!.localPath!); 237 + if (file.existsSync()) { 238 + file.deleteSync(); 239 + } 240 + } catch (e) { 241 + print('Error cleaning up cached video: $e'); 242 + } 243 + } 145 244 } 146 245 } 147 246 ··· 197 296 198 297 PreloadedVideo? getPreloadedVideo(int index) { 199 298 return _preloadedVideos[index]; 299 + } 300 + 301 + String? getLocalVideoPath(int index) { 302 + return _localVideoPaths[index]; 200 303 } 201 304 }
+18 -9
lib/widgets/feed/feed_selector.dart
··· 9 9 const FeedOption({required this.label, required this.value}); 10 10 } 11 11 12 - class FeedSelector extends StatelessWidget { 12 + class FeedSelector extends StatefulWidget { 13 13 final List<FeedOption> options; 14 14 final int selectedValue; 15 15 final ValueChanged<int> onOptionSelected; ··· 26 26 }); 27 27 28 28 @override 29 + State<FeedSelector> createState() => _FeedSelectorState(); 30 + } 31 + 32 + class _FeedSelectorState extends State<FeedSelector> { 33 + @override 29 34 Widget build(BuildContext context) { 30 35 return Container( 31 - height: height, 36 + height: widget.height, 32 37 decoration: BoxDecoration(color: Colors.transparent, borderRadius: BorderRadius.circular(20)), 33 38 child: Row( 34 39 mainAxisAlignment: MainAxisAlignment.center, 35 - children: List.generate(options.length, (index) { 36 - final option = options[index]; 37 - final isSelected = option.value == selectedValue; 40 + children: List.generate(widget.options.length, (index) { 41 + final option = widget.options[index]; 42 + final isSelected = option.value == widget.selectedValue; 38 43 39 44 return Expanded( 40 45 child: GestureDetector( 41 - onTap: () => onOptionSelected(option.value), 46 + onTap: () { 47 + widget.onOptionSelected(option.value); 48 + setState(() {}); // Force a rebuild when tapped 49 + }, 42 50 child: Container( 43 - height: height, 51 + height: widget.height, 44 52 decoration: BoxDecoration( 45 - color: isSelected ? AppColors.white.withAlpha(100) : Colors.transparent, 53 + color: isSelected ? AppColors.pink.withOpacity(0.2) : Colors.transparent, 54 + border: isSelected ? Border.all(color: AppColors.pink, width: 1.5) : null, 46 55 borderRadius: BorderRadius.circular(50), 47 56 ), 48 57 alignment: Alignment.center, 49 58 child: Text( 50 59 option.label, 51 60 style: TextStyle( 52 - color: isSelected ? AppColors.white : AppColors.lightLavender, 61 + color: AppColors.lightLavender, 53 62 fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 54 63 fontSize: 14, 55 64 ),
+52 -54
lib/widgets/profile/tabs/photos_tab.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:provider/provider.dart'; 4 4 5 - import '../../../screens/profile_player_screen.dart'; 5 + import '../../../models/feed_post.dart'; 6 + import '../../../screens/feed_screen.dart'; 6 7 import '../../../services/auth_service.dart'; 7 8 import '../../../services/profile_service.dart'; 8 9 import '../../../widgets/video/video_item.dart'; 9 10 import '../../profile/profile_video_tile.dart'; 11 + import '../../../services/feed_settings_service.dart'; 10 12 11 13 class PhotosTab extends StatefulWidget { 12 14 final String? did; ··· 152 154 debugPrint('Image post clicked at index $index'); 153 155 } 154 156 155 - void _openImageViewer(int index, Map<String, dynamic> post, List<Map<String, dynamic>> allPosts) { 156 - // Extract post data 157 - final username = post['post']?['author']?['handle'] as String? ?? 'username'; 158 - final authorDid = post['post']?['author']?['did'] as String? ?? ''; 159 - final profileImageUrl = post['post']?['author']?['avatar'] as String? ?? ''; 160 - final postUri = post['post']?['uri'] as String? ?? ''; 161 - final postCid = post['post']?['cid'] as String? ?? ''; 162 - final likeCount = post['post']?['likeCount'] as int? ?? 0; 163 - final commentCount = post['post']?['replyCount'] as int? ?? 0; 164 - final shareCount = post['post']?['repostCount'] as int? ?? 0; 157 + void _openMediaViewer(int index, Map<String, dynamic> post, List<Map<String, dynamic>> allPosts) { 158 + // Convert all posts to FeedPost format 159 + final feedPosts = 160 + allPosts.map((post) { 161 + final embedType = post['post']?['embed']?['\$type'] as String?; 162 + final isImage = embedType == 'so.sprk.embed.images#view'; 165 163 166 - String description = ''; 167 - if (post['post']?['record']?['text'] != null) { 168 - description = post['post']['record']['text'] as String? ?? ''; 169 - } else if (post['post']?['text'] != null) { 170 - description = post['post']['text'] as String? ?? ''; 171 - } 164 + String description = ''; 165 + if (post['post']?['record']?['text'] != null) { 166 + description = post['post']['record']['text'] as String? ?? ''; 167 + } else if (post['post']?['text'] != null) { 168 + description = post['post']['text'] as String? ?? ''; 169 + } 172 170 173 - final List<String> hashtags = []; 174 - for (final word in description.split(' ')) { 175 - if (word.startsWith('#') && word.length > 1) { 176 - hashtags.add(word.substring(1)); 177 - } 178 - } 179 - 180 - final isSprk = postUri.contains('so.sprk.feed.post'); 171 + final List<String> hashtags = []; 172 + for (final word in description.split(' ')) { 173 + if (word.startsWith('#') && word.length > 1) { 174 + hashtags.add(word.substring(1)); 175 + } 176 + } 181 177 182 - // Create a VideoItem but with empty videoUrl (ProfilePlayerScreen will handle it as an image) 183 - final videoItem = VideoItem( 184 - key: ValueKey('image_item_${postUri}_$index'), 185 - index: index, 186 - videoUrl: '', // Empty video URL for image posts 187 - username: username, 188 - description: description, 189 - hashtags: hashtags, 190 - likeCount: likeCount, 191 - commentCount: commentCount, 192 - bookmarkCount: 0, 193 - shareCount: shareCount, 194 - profileImageUrl: profileImageUrl, 195 - authorDid: authorDid, 196 - isLiked: false, 197 - isSprk: isSprk, 198 - postUri: postUri, 199 - postCid: postCid, 200 - disableBackgroundBlur: false, 201 - onLikePressed: () {}, 202 - onBookmarkPressed: () {}, 203 - onSharePressed: () {}, 204 - onUsernameTap: () {}, 205 - onHashtagTap: (String hashtag) {}, 206 - ); 178 + return FeedPost( 179 + username: post['post']?['author']?['handle'] as String? ?? 'username', 180 + authorDid: post['post']?['author']?['did'] as String? ?? '', 181 + profileImageUrl: post['post']?['author']?['avatar'] as String? ?? '', 182 + description: description, 183 + videoUrl: isImage ? null : (post['post']?['embed']?['playlist'] as String?), 184 + imageUrls: isImage ? [post['post']?['embed']?['images']?[0]?['fullsize'] as String? ?? ''] : [], 185 + likeCount: post['post']?['likeCount'] as int? ?? 0, 186 + commentCount: post['post']?['replyCount'] as int? ?? 0, 187 + shareCount: post['post']?['repostCount'] as int? ?? 0, 188 + hashtags: hashtags, 189 + uri: post['post']?['uri'] as String? ?? '', 190 + cid: post['post']?['cid'] as String? ?? '', 191 + isSprk: post['post']?['uri']?.contains('so.sprk.feed.post') ?? false, 192 + hasMedia: true, 193 + isReply: false, 194 + imageAlts: [], 195 + videoAlt: null, 196 + ); 197 + }).toList(); 207 198 208 199 Navigator.of(context).push( 209 200 PageRouteBuilder( 210 201 opaque: true, 211 202 pageBuilder: 212 - (BuildContext context, _, __) => 213 - ProfilePlayerScreen(initialVideoItem: videoItem, allVideos: allPosts, initialIndex: index), 203 + (BuildContext context, _, __) => ChangeNotifierProvider<FeedSettingsService>.value( 204 + value: FeedSettingsService(), 205 + child: FeedScreen( 206 + feedType: 0, // Use a custom feed type for profile photos 207 + initialPosts: feedPosts, 208 + initialIndex: index, 209 + showBackButton: true, 210 + ), 211 + ), 214 212 transitionsBuilder: (_, Animation<double> animation, __, Widget child) { 215 213 const begin = Offset(0.0, 1.0); 216 214 const end = Offset.zero; ··· 275 273 return SliverPadding( 276 274 padding: const EdgeInsets.all(1), 277 275 sliver: SliverGrid( 278 - key: PageStorageKey<String>('photo_grid_${widget.did ?? 'current'}'), 276 + key: PageStorageKey<String>('photos_grid_${widget.did ?? 'current'}_${DateTime.now().millisecondsSinceEpoch}'), 279 277 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 280 278 crossAxisCount: 3, 281 279 childAspectRatio: 2 / 3, ··· 325 323 index: index, 326 324 isImage: true, 327 325 likeCount: likeCount, 328 - onTap: () => _openImageViewer(index, post, validPosts), 326 + onTap: () => _openMediaViewer(index, post, validPosts), 329 327 isSprk: isSprk, 330 328 ); 331 329 }, childCount: itemCount),
+50 -58
lib/widgets/profile/tabs/videos_tab.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:provider/provider.dart'; 4 4 5 - import '../../../screens/profile_player_screen.dart'; 5 + import '../../../models/feed_post.dart'; 6 + import '../../../screens/feed_screen.dart'; 6 7 import '../../../services/auth_service.dart'; 7 8 import '../../../services/profile_service.dart'; 8 9 import '../../../widgets/video/video_item.dart'; 9 10 import '../../profile/profile_video_tile.dart'; 11 + import '../../../services/feed_settings_service.dart'; 10 12 11 13 class VideosTab extends StatefulWidget { 12 14 final String? did; ··· 147 149 } 148 150 149 151 void _openMediaViewer(int index, Map<String, dynamic> post, List<Map<String, dynamic>> allPosts) { 150 - // Simple approach - just create the VideoItem for the clicked post 151 - final embedType = post['post']?['embed']?['\$type'] as String?; 152 - final isImage = embedType == 'so.sprk.embed.images#view'; 153 - 154 - // Extract post data 155 - final username = post['post']?['author']?['handle'] as String? ?? 'username'; 156 - final authorDid = post['post']?['author']?['did'] as String? ?? ''; 157 - final profileImageUrl = post['post']?['author']?['avatar'] as String? ?? ''; 158 - final postUri = post['post']?['uri'] as String? ?? ''; 159 - final postCid = post['post']?['cid'] as String? ?? ''; 160 - final likeCount = post['post']?['likeCount'] as int? ?? 0; 161 - final commentCount = post['post']?['replyCount'] as int? ?? 0; 162 - final shareCount = post['post']?['repostCount'] as int? ?? 0; 163 - 164 - String description = ''; 165 - if (post['post']?['record']?['text'] != null) { 166 - description = post['post']['record']['text'] as String? ?? ''; 167 - } else if (post['post']?['text'] != null) { 168 - description = post['post']['text'] as String? ?? ''; 169 - } 170 - 171 - final List<String> hashtags = []; 172 - for (final word in description.split(' ')) { 173 - if (word.startsWith('#') && word.length > 1) { 174 - hashtags.add(word.substring(1)); 175 - } 176 - } 152 + // Convert all posts to FeedPost format 153 + final feedPosts = 154 + allPosts.map((post) { 155 + final embedType = post['post']?['embed']?['\$type'] as String?; 156 + final isImage = embedType == 'so.sprk.embed.images#view'; 177 157 178 - final isSprk = postUri.contains('so.sprk.feed.post'); 158 + String description = ''; 159 + if (post['post']?['record']?['text'] != null) { 160 + description = post['post']['record']['text'] as String? ?? ''; 161 + } else if (post['post']?['text'] != null) { 162 + description = post['post']['text'] as String? ?? ''; 163 + } 179 164 180 - // For video posts, use the playlist URL, for image posts, use empty string 181 - final videoUrl = isImage ? '' : (post['post']?['embed']?['playlist'] as String? ?? ''); 165 + final List<String> hashtags = []; 166 + for (final word in description.split(' ')) { 167 + if (word.startsWith('#') && word.length > 1) { 168 + hashtags.add(word.substring(1)); 169 + } 170 + } 182 171 183 - final videoItem = VideoItem( 184 - key: ValueKey('video_item_${postUri}_$index'), 185 - index: index, 186 - videoUrl: videoUrl, 187 - username: username, 188 - description: description, 189 - hashtags: hashtags, 190 - likeCount: likeCount, 191 - commentCount: commentCount, 192 - bookmarkCount: 0, 193 - shareCount: shareCount, 194 - profileImageUrl: profileImageUrl, 195 - authorDid: authorDid, 196 - isLiked: false, 197 - isSprk: isSprk, 198 - postUri: postUri, 199 - postCid: postCid, 200 - disableBackgroundBlur: false, 201 - onLikePressed: () {}, 202 - onBookmarkPressed: () {}, 203 - onSharePressed: () {}, 204 - onUsernameTap: () {}, 205 - onHashtagTap: (String hashtag) {}, 206 - ); 172 + return FeedPost( 173 + username: post['post']?['author']?['handle'] as String? ?? 'username', 174 + authorDid: post['post']?['author']?['did'] as String? ?? '', 175 + profileImageUrl: post['post']?['author']?['avatar'] as String? ?? '', 176 + description: description, 177 + videoUrl: isImage ? null : (post['post']?['embed']?['playlist'] as String?), 178 + imageUrls: isImage ? [post['post']?['embed']?['images']?[0]?['fullsize'] as String? ?? ''] : [], 179 + likeCount: post['post']?['likeCount'] as int? ?? 0, 180 + commentCount: post['post']?['replyCount'] as int? ?? 0, 181 + shareCount: post['post']?['repostCount'] as int? ?? 0, 182 + hashtags: hashtags, 183 + uri: post['post']?['uri'] as String? ?? '', 184 + cid: post['post']?['cid'] as String? ?? '', 185 + isSprk: post['post']?['uri']?.contains('so.sprk.feed.post') ?? false, 186 + hasMedia: true, 187 + isReply: false, 188 + imageAlts: [], 189 + videoAlt: null, 190 + ); 191 + }).toList(); 207 192 208 193 Navigator.of(context).push( 209 194 PageRouteBuilder( 210 195 opaque: true, 211 196 pageBuilder: 212 - (BuildContext context, _, __) => 213 - ProfilePlayerScreen(initialVideoItem: videoItem, allVideos: allPosts, initialIndex: index), 197 + (BuildContext context, _, __) => ChangeNotifierProvider<FeedSettingsService>.value( 198 + value: FeedSettingsService(), 199 + child: FeedScreen( 200 + feedType: 0, // Use a custom feed type for profile videos 201 + initialPosts: feedPosts, 202 + initialIndex: index, 203 + showBackButton: true, 204 + ), 205 + ), 214 206 transitionsBuilder: (_, Animation<double> animation, __, Widget child) { 215 207 const begin = Offset(0.0, 1.0); 216 208 const end = Offset.zero; ··· 275 267 return SliverPadding( 276 268 padding: const EdgeInsets.all(1), 277 269 sliver: SliverGrid( 278 - key: PageStorageKey<String>('media_grid_${widget.did ?? 'current'}'), 270 + key: PageStorageKey<String>('videos_grid_${widget.did ?? 'current'}_${DateTime.now().millisecondsSinceEpoch}'), 279 271 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 280 272 crossAxisCount: 3, 281 273 childAspectRatio: 2 / 3,
+86 -61
lib/widgets/video/video_item.dart
··· 1 1 import 'dart:developer'; 2 2 import 'dart:ui'; // For ImageFilter 3 - 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:video_player/video_player.dart'; 6 5 import 'package:visibility_detector/visibility_detector.dart'; 6 + import 'dart:io'; 7 7 8 8 import '../../main.dart'; 9 9 import '../../utils/app_colors.dart'; ··· 14 14 final String? videoUrl; 15 15 @override 16 16 final String? videoAlt; 17 + final VideoPlayerController? preloadedController; 18 + final String? localVideoPath; 17 19 18 20 const VideoItem({ 19 21 super.key, 20 22 required super.index, 21 23 required this.videoUrl, 22 24 this.videoAlt, 25 + this.preloadedController, 26 + this.localVideoPath, 23 27 super.username = '', 24 28 super.description = '', 25 29 super.hashtags = const [], ··· 78 82 } 79 83 80 84 @override 85 + void dispose() { 86 + routeObserver.unsubscribe(this); 87 + if (_controller != widget.preloadedController) { 88 + _controller?.dispose(); 89 + } 90 + super.dispose(); 91 + } 92 + 93 + @override 81 94 void didPushNext() { 82 - // Route was pushed on top of this one - pause the video 83 95 pauseMedia(); 84 96 } 85 97 86 98 @override 87 99 void didPopNext() { 88 - // Returned to this route - play video if visible 89 100 if (_isVisible && isInitialized) { 90 101 playMedia(); 91 102 } 92 103 } 93 104 94 - void _initializeVideoPlayer() { 95 - if (widget.videoUrl == null) return; 105 + Future<void> _initializeVideoPlayer() async { 106 + if (widget.videoUrl == null && widget.localVideoPath == null) return; 96 107 97 - _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl!)) 98 - ..initialize().then((_) { 108 + if (widget.preloadedController != null) { 109 + _controller = widget.preloadedController; 110 + if (_controller!.value.isInitialized) { 111 + setState(() { 112 + _isInitialized = true; 113 + }); 114 + if (_isVisible) { 115 + playMedia(); 116 + } 117 + return; 118 + } 119 + } 120 + 121 + if (widget.localVideoPath != null) { 122 + try { 123 + _controller = VideoPlayerController.file(File(widget.localVideoPath!)); 124 + await _controller?.initialize(); 125 + 99 126 if (!mounted) return; 127 + 128 + _controller?.setLooping(true); 100 129 setState(() { 101 130 _isInitialized = true; 102 - if (isVisible) { 103 - playMedia(); 104 - } 105 131 }); 132 + 133 + if (_isVisible) { 134 + playMedia(); 135 + } 136 + return; 137 + } catch (e) { 138 + log('Failed to load local video: $e'); 139 + } 140 + } 141 + 142 + if (widget.videoUrl != null) { 143 + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl!)); 144 + await _controller?.initialize(); 145 + 146 + if (!mounted) return; 147 + 148 + _controller?.setLooping(true); 149 + setState(() { 150 + _isInitialized = true; 106 151 }); 107 152 108 - _controller?.addListener(() { 109 - if (_controller == null || !_controller!.value.isInitialized) return; 110 - if (_controller!.value.position >= _controller!.value.duration) { 111 - _controller?.seekTo(Duration.zero); 153 + if (_isVisible) { 112 154 playMedia(); 113 155 } 156 + } 157 + } 158 + 159 + void _onVisibilityChanged(VisibilityInfo info) { 160 + final visible = info.visibleFraction > 0.8; 161 + if (!mounted || visible == _isVisible) return; 162 + 163 + setState(() { 164 + _isVisible = visible; 114 165 }); 115 - } 116 166 117 - @override 118 - void dispose() { 119 - routeObserver.unsubscribe(this); 120 - _controller?.dispose(); 121 - super.dispose(); 167 + if (_isInitialized) { 168 + if (_isVisible) { 169 + playMedia(); 170 + } else { 171 + pauseMedia(); 172 + } 173 + } 122 174 } 123 175 124 176 @override 125 177 Widget build(BuildContext context) { 126 178 return VisibilityDetector( 127 179 key: Key(_videoKey), 128 - onVisibilityChanged: (visibilityInfo) { 129 - log('visibilityInfo: $visibilityInfo'); 130 - final newVisibility = visibilityInfo.visibleFraction > 0.8; 131 - 132 - if (newVisibility == _isVisible || !mounted) return; 133 - 134 - setState(() { 135 - _isVisible = newVisibility; 136 - }); 137 - 138 - if (_isInitialized) { 139 - if (_isVisible) { 140 - playMedia(); 141 - } else { 142 - pauseMedia(); 143 - } 144 - } 145 - }, 180 + onVisibilityChanged: _onVisibilityChanged, 146 181 child: Stack( 147 182 fit: StackFit.expand, 148 183 children: [ 149 184 super.build(context), 150 - 151 185 if (widget.videoUrl != null && !_isInitialized) const Center(child: CircularProgressIndicator(color: AppColors.white)), 152 186 ], 153 187 ), ··· 162 196 163 197 @override 164 198 Widget buildContent(BuildContext context) { 165 - if (widget.videoUrl != null && _controller != null && _isInitialized) { 199 + if (_controller != null && _isInitialized) { 166 200 return _buildVideoContent(); 167 201 } 168 202 return _buildPlaceholderContent(context); 169 203 } 170 204 171 205 Widget _buildBlurredBackground(bool isDarkMode) { 206 + if (!_isInitialized || widget.disableBackgroundBlur) { 207 + return Container(color: isDarkMode ? Colors.black : AppColors.darkBackground); 208 + } 209 + 172 210 return Container( 173 211 color: isDarkMode ? Colors.black : AppColors.darkBackground, 174 212 child: Stack( 175 213 fit: StackFit.expand, 176 214 children: [ 177 - if (!widget.disableBackgroundBlur && _controller != null && _isInitialized) 178 - ClipRect( 179 - child: ImageFiltered( 180 - imageFilter: ImageFilter.blur(sigmaX: 25.0, sigmaY: 25.0), 181 - child: Transform.scale(scale: 1.2, child: Opacity(opacity: 0.5, child: VideoPlayer(_controller!))), 182 - ), 215 + ClipRect( 216 + child: ImageFiltered( 217 + imageFilter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), 218 + child: Transform.scale(scale: 1.1, child: Opacity(opacity: 0.4, child: VideoPlayer(_controller!))), 183 219 ), 184 - Container(color: isDarkMode ? Colors.black.withAlpha(120) : AppColors.darkBackground.withAlpha(120)), 220 + ), 221 + Container(color: isDarkMode ? Colors.black.withAlpha(100) : AppColors.darkBackground.withAlpha(100)), 185 222 ], 186 223 ), 187 224 ); 188 225 } 189 226 190 227 Widget _buildVideoContent() { 191 - if (_controller == null || !_isInitialized) { 192 - return _buildPlaceholderContent(context); 193 - } 194 - 195 228 final videoSize = _controller!.value.size; 196 229 if (videoSize.width <= 0 || videoSize.height <= 0) { 197 230 return _buildPlaceholderContent(context); 198 231 } 199 - double aspectRatio = videoSize.width / videoSize.height; 200 - 201 - if (aspectRatio > 1) { 202 - return FittedBox( 203 - fit: BoxFit.contain, 204 - child: SizedBox(width: videoSize.width, height: videoSize.height, child: VideoPlayer(_controller!)), 205 - ); 206 - } 207 232 208 233 return SizedBox.expand( 209 234 child: FittedBox( 210 - fit: BoxFit.contain, 235 + fit: BoxFit.cover, 211 236 child: SizedBox(width: videoSize.width, height: videoSize.height, child: VideoPlayer(_controller!)), 212 237 ), 213 238 ); ··· 217 242 final isDarkMode = MediaQuery.of(context).platformBrightness == Brightness.dark; 218 243 return Container( 219 244 color: 220 - widget.index % 2 == 0 245 + widget.index.isEven 221 246 ? (isDarkMode ? Colors.indigo.shade900 : Colors.indigo.shade200) 222 247 : (isDarkMode ? Colors.purple.shade900 : Colors.purple.shade200), 223 248 child: const SizedBox.shrink(),
+17 -1
pubspec.lock
··· 257 257 url: "https://pub.dev" 258 258 source: hosted 259 259 version: "1.0.1" 260 + dio: 261 + dependency: "direct main" 262 + description: 263 + name: dio 264 + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" 265 + url: "https://pub.dev" 266 + source: hosted 267 + version: "5.8.0+1" 268 + dio_web_adapter: 269 + dependency: transitive 270 + description: 271 + name: dio_web_adapter 272 + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" 273 + url: "https://pub.dev" 274 + source: hosted 275 + version: "2.1.1" 260 276 fake_async: 261 277 dependency: transitive 262 278 description: ··· 335 351 source: sdk 336 352 version: "0.0.0" 337 353 flutter_cache_manager: 338 - dependency: transitive 354 + dependency: "direct main" 339 355 description: 340 356 name: flutter_cache_manager 341 357 sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
+3 -1
pubspec.yaml
··· 22 22 flutter_svg: ^2.0.7 23 23 atproto: ^0.13.3 24 24 bluesky: ^0.18.10 25 - http: ^1.1.0 25 + http: ^1.2.0 26 + dio: ^5.4.0 26 27 shared_preferences: ^2.5.3 27 28 visibility_detector: ^0.4.0+2 28 29 fvp: ^0.30.0 ··· 34 35 preload_page_view: ^0.2.0 35 36 mime: ^1.0.6 36 37 image: ^4.5.4 38 + flutter_cache_manager: ^3.3.1 37 39 38 40 dev_dependencies: 39 41 flutter_test: