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

routing go brrr (#25)

authored by

C3B and committed by
GitHub
8942a872 f47e4e50

+352 -423
assets/branding/intro.webp

This is a binary file and will not be displayed.

+17 -29
lib/main.dart
··· 14 14 import 'screens/profile_screen.dart'; 15 15 import 'screens/search_screen.dart'; 16 16 import 'screens/splash_screen.dart'; 17 - import 'screens/test_actions_screen.dart'; 18 17 import 'services/actions_service.dart'; 19 18 import 'services/auth_service.dart'; 20 19 import 'services/comments_service.dart'; ··· 115 114 '/login': (context) => const LoginScreen(), 116 115 '/auth': (context) => const AuthPromptScreen(), 117 116 '/onboarding': (context) => const OnboardingScreen(), 118 - '/test': (context) => const TestActionsScreen(), 119 117 }, 120 118 builder: (context, child) { 121 119 return Stack( ··· 164 162 } 165 163 166 164 class _MainScreenState extends State<MainScreen> { 167 - final List<Widget?> _screens = List.filled(5, null); 165 + final List<Widget> _screens = []; 168 166 169 - Widget _getScreen(int index, BuildContext context) { 170 - if (_screens[index] != null) { 171 - return _screens[index]!; 172 - } 167 + @override 168 + void initState() { 169 + super.initState(); 170 + _initializeScreens(); 171 + } 173 172 173 + void _initializeScreens() { 174 174 final authService = Provider.of<AuthService>(context, listen: false); 175 - 176 - switch (index) { 177 - case 0: 178 - _screens[0] = const HomeScreen(); 179 - break; 180 - case 1: 181 - _screens[1] = const SearchScreen(); 182 - break; 183 - case 2: 184 - _screens[2] = const SizedBox.shrink(); 185 - break; 186 - case 3: 187 - _screens[3] = const MessagesScreen(); 188 - break; 189 - case 4: 190 - _screens[4] = ProfileScreen(key: Key(authService.session?.did ?? ''), did: authService.session?.did); 191 - break; 192 - } 193 - 194 - return _screens[index]!; 175 + _screens.addAll([ 176 + const HomeScreen(), 177 + const SearchScreen(), 178 + const SizedBox.shrink(), // Placeholder for index 2 (Create) 179 + const MessagesScreen(), 180 + ProfileScreen(key: Key(authService.session?.did ?? ''), did: authService.session?.did), 181 + ]); 195 182 } 196 183 197 184 @override ··· 200 187 201 188 return Scaffold( 202 189 backgroundColor: Colors.black, 203 - body: _getScreen(navigationProvider.currentIndex, context), 190 + // Use IndexedStack to keep screens alive 191 + body: IndexedStack(index: navigationProvider.currentIndex, children: _screens), 204 192 bottomNavigationBar: NavigationBarTheme( 205 193 data: NavigationBarThemeData( 206 194 indicatorColor: Colors.transparent, ··· 215 203 }), 216 204 ), 217 205 child: NavigationBar( 218 - selectedIndex: navigationProvider.currentIndex == 2 ? 0 : navigationProvider.currentIndex, 206 + selectedIndex: navigationProvider.currentIndex, 219 207 onDestinationSelected: (index) { 220 208 if (index == 2) { 221 209 Navigator.of(
+57 -14
lib/screens/feed_screen.dart
··· 10 10 import '../services/feed_manager.dart'; 11 11 import '../services/feed_settings_service.dart'; 12 12 import '../services/media_manager.dart'; 13 - 14 13 import '../widgets/image/image_post_item.dart'; 15 14 import '../widgets/video/preloaded_video_item.dart'; 16 15 import '../widgets/video/video_item.dart'; ··· 20 19 final List<FeedPost>? initialPosts; 21 20 final int? initialIndex; 22 21 final bool showBackButton; 22 + final bool isParentFeedVisible; 23 23 24 - const FeedScreen({super.key, required this.feedType, this.initialPosts, this.initialIndex, this.showBackButton = false}); 24 + const FeedScreen({ 25 + super.key, 26 + required this.feedType, 27 + this.initialPosts, 28 + this.initialIndex, 29 + this.showBackButton = false, 30 + required this.isParentFeedVisible, 31 + }); 25 32 26 33 @override 27 34 State<FeedScreen> createState() => _FeedScreenState(); 28 35 } 29 36 30 - class _FeedScreenState extends State<FeedScreen> { 37 + class _FeedScreenState extends State<FeedScreen> with AutomaticKeepAliveClientMixin<FeedScreen> { 31 38 final PageController _pageController = PageController(); 32 39 final FeedManager _feedManager = FeedManager(); 33 40 final MediaManager _mediaManager = MediaManager(); ··· 36 43 int _currentIndex = 0; 37 44 bool _isLoading = true; 38 45 String? _errorMessage; 46 + bool _wasPlayingBeforePause = false; 47 + 48 + @override 49 + bool get wantKeepAlive => true; 39 50 40 51 @override 41 52 void initState() { ··· 43 54 _initializeScreen(); 44 55 } 45 56 57 + @override 58 + void didUpdateWidget(FeedScreen oldWidget) { 59 + super.didUpdateWidget(oldWidget); 60 + if (oldWidget.isParentFeedVisible != widget.isParentFeedVisible) { 61 + if (!widget.isParentFeedVisible) { 62 + final controller = _mediaManager.getPreloadedVideo(_currentIndex)?.controller; 63 + _wasPlayingBeforePause = controller?.value.isPlaying ?? false; 64 + _mediaManager.pauseVideo(_currentIndex); 65 + } else { 66 + if (_wasPlayingBeforePause) { 67 + _mediaManager.resumeVideo(_currentIndex); 68 + } 69 + _wasPlayingBeforePause = false; 70 + } 71 + } 72 + } 73 + 46 74 Future<void> _initializeScreen() async { 47 75 if (widget.initialPosts != null) { 48 76 setState(() { ··· 70 98 71 99 Future<void> _fetchFeed() async { 72 100 if (!mounted) return; 101 + 102 + _mediaManager.pauseVideo(_currentIndex); 73 103 74 104 try { 75 105 setState(() { ··· 78 108 _currentIndex = 0; 79 109 }); 80 110 81 - _mediaManager.clearAllMedia(); 82 - 83 111 final authService = context.read<AuthService>(); 84 112 final posts = await _feedManager.fetchFeed(widget.feedType, authService); 85 113 ··· 131 159 132 160 final post = _feedPosts![index]; 133 161 if (post.videoUrl != null) { 134 - // Force preload for the first video 135 162 if (index == 0) { 136 163 await _mediaManager.preloadMedia(index, post.videoUrl, post.imageUrls, context); 137 164 if (mounted) { 138 - setState(() {}); // Trigger rebuild to show preloaded video 165 + setState(() {}); 139 166 } 140 167 } else { 141 168 _mediaManager.preloadMedia(index, post.videoUrl, post.imageUrls, context); ··· 148 175 Future<void> _preloadInitialMedia() async { 149 176 if (_feedPosts == null || _feedPosts!.isEmpty) return; 150 177 151 - // Preload the first video immediately 152 178 await _preloadMedia(0); 153 179 154 - // Preload next videos in the background 155 180 for (int i = 1; i <= 5 && i < _feedPosts!.length; i++) { 156 181 _preloadMedia(i); 157 182 } ··· 159 184 160 185 @override 161 186 Widget build(BuildContext context) { 187 + super.build(context); 188 + 189 + // Optimization: Check if feed posts are available and parent is visible 190 + // before building the PageView. Reduces build calls when hidden. 191 + bool canBuildPageView = _feedPosts != null && _feedPosts!.isNotEmpty && !_isLoading && _errorMessage == null; 192 + 162 193 return Material( 163 194 color: Colors.black, 164 195 child: Stack( 165 196 children: [ 166 - _buildMainContent(), 197 + _buildMainContent(canBuildPageView), 167 198 if (widget.showBackButton) 168 199 Positioned( 169 200 top: MediaQuery.of(context).padding.top + 10, ··· 178 209 ); 179 210 } 180 211 181 - Widget _buildMainContent() { 212 + Widget _buildMainContent(bool canBuildPageView) { 182 213 return SizedBox( 183 214 height: MediaQuery.of(context).size.height, 184 215 width: MediaQuery.of(context).size.width, ··· 189 220 ? Center(child: Text('Error: $_errorMessage', style: const TextStyle(color: Colors.white))) 190 221 : _feedPosts == null || _feedPosts!.isEmpty 191 222 ? const Center(child: Text('No media available', style: TextStyle(color: Colors.white))) 192 - : _buildFeedPageView(), 223 + : canBuildPageView 224 + ? _buildFeedPageView() 225 + : const SizedBox.shrink(), 193 226 ); 194 227 } 195 228 ··· 200 233 return PageView.builder( 201 234 controller: _pageController, 202 235 scrollDirection: Axis.vertical, 236 + physics: widget.isParentFeedVisible ? const PageScrollPhysics() : const NeverScrollableScrollPhysics(), 203 237 itemCount: _feedPosts?.length ?? 0, 204 238 onPageChanged: (newIndex) { 205 239 if (_currentIndex != newIndex) { 240 + _mediaManager.pauseVideo(_currentIndex); 241 + 206 242 setState(() { 207 243 _mediaManager.updateLoadedMedia(newIndex, _currentIndex, _feedPosts?.length ?? 0); 208 244 _currentIndex = newIndex; ··· 229 265 final post = _feedPosts![index]; 230 266 final isLiked = post.isLiked; 231 267 268 + final bool isItemActuallyVisible = (index == _currentIndex) && widget.isParentFeedVisible; 269 + 232 270 if (post.videoUrl != null) { 233 271 final isPreloaded = _mediaManager.isVideoPreloaded(index); 234 272 final preloadedVideo = _mediaManager.getPreloadedVideo(index); ··· 238 276 key: ValueKey('video_$index'), 239 277 index: index, 240 278 controller: preloadedVideo.controller, 279 + isVisible: isItemActuallyVisible, 241 280 username: post.username, 242 281 description: post.description, 243 282 hashtags: post.hashtags, ··· 247 286 shareCount: post.shareCount, 248 287 profileImageUrl: post.profileImageUrl, 249 288 authorDid: post.authorDid, 250 - isVisible: index == _currentIndex, 251 289 isLiked: isLiked, 252 290 isSprk: post.isSprk, 253 291 postUri: post.uri, ··· 261 299 Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 262 300 error, 263 301 ) { 302 + if (!context.mounted) return; 264 303 ScaffoldMessenger.of( 265 304 context, 266 305 ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); ··· 271 310 Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 272 311 error, 273 312 ) { 313 + if (!context.mounted) return; 274 314 ScaffoldMessenger.of( 275 315 context, 276 316 ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); ··· 290 330 videoAlt: post.videoAlt, 291 331 preloadedController: isPreloaded ? preloadedVideo?.controller : null, 292 332 localVideoPath: isPreloaded ? _mediaManager.getLocalVideoPath(index) : null, 333 + isVisible: isItemActuallyVisible, 293 334 username: post.username, 294 335 description: post.description, 295 336 hashtags: post.hashtags, ··· 311 352 Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 312 353 error, 313 354 ) { 355 + if (!context.mounted) return; 314 356 ScaffoldMessenger.of( 315 357 context, 316 358 ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); ··· 321 363 Navigator.push(context, MaterialPageRoute(builder: (_) => ProfileScreen(did: post.authorDid))).catchError(( 322 364 error, 323 365 ) { 366 + if (!context.mounted) return; 324 367 ScaffoldMessenger.of( 325 368 context, 326 369 ).showSnackBar(SnackBar(content: Text('Could not load profile: ${error.toString()}'))); ··· 339 382 index: index, 340 383 imageUrls: post.imageUrls, 341 384 imageAlts: post.imageAlts, 385 + isVisible: isItemActuallyVisible, 342 386 username: post.username, 343 387 description: post.description, 344 388 hashtags: post.hashtags, ··· 352 396 isSprk: post.isSprk, 353 397 postUri: post.uri, 354 398 postCid: post.cid, 355 - isVisible: index == _currentIndex, 356 399 disableBackgroundBlur: disableBackgroundBlur, 357 400 onLikePressed: () => _handleLikePress(post), 358 401 onBookmarkPressed: () {},
+88 -39
lib/screens/home_screen.dart
··· 1 1 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 2 + import 'package:flutter/foundation.dart' show listEquals; 2 3 import 'package:flutter/material.dart'; 3 - import 'package:video_player/video_player.dart'; 4 4 import 'package:provider/provider.dart'; 5 + import 'package:video_player/video_player.dart'; 6 + import 'package:visibility_detector/visibility_detector.dart'; 5 7 6 8 import '../services/feed_settings_service.dart'; 7 9 import '../utils/app_colors.dart'; ··· 20 22 final FeedSettingsService _feedSettings = FeedSettingsService(); 21 23 final PageController _pageController = PageController(); 22 24 int _selectedTabIndex = 0; 25 + List<FeedOption> _currentFeedOptions = []; 26 + List<Widget> _feedScreens = []; 27 + bool _isHomeScreenVisible = true; 23 28 24 29 @override 25 30 void initState() { ··· 38 43 39 44 void _onFeedSettingsChanged() { 40 45 if (mounted) { 41 - setState(() {}); 46 + final oldSelectedFeedType = _currentFeedOptions.isNotEmpty ? _currentFeedOptions[_selectedTabIndex].value : -1; 47 + _buildFeedScreens(); 48 + final newIndex = _currentFeedOptions.indexWhere((option) => option.value == oldSelectedFeedType); 49 + 50 + int targetIndex = (newIndex != -1) ? newIndex : (_currentFeedOptions.isNotEmpty ? 0 : -1); 51 + 52 + setState(() { 53 + _selectedTabIndex = targetIndex; 54 + if (_pageController.hasClients && targetIndex != -1) { 55 + _pageController.jumpToPage(_selectedTabIndex); 56 + } 57 + }); 42 58 } 43 59 } 44 60 45 61 Future<void> _initializeScreen() async { 46 62 await _feedSettings.loadPreferences(); 63 + _buildFeedScreens(); 47 64 _pageController.addListener(_onPageChanged); 48 65 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 66 + _selectedTabIndex = _currentFeedOptions.indexWhere((option) => option.value == _feedSettings.selectedFeedType); 67 + if (_selectedTabIndex == -1 && _currentFeedOptions.isNotEmpty) { 68 + _selectedTabIndex = 0; 54 69 } 55 70 56 - // Ensure page controller is at the correct position 57 71 WidgetsBinding.instance.addPostFrameCallback((_) { 58 72 if (_pageController.hasClients) { 59 73 _pageController.jumpToPage(_selectedTabIndex); ··· 64 78 void _onPageChanged() { 65 79 if (_pageController.page == null) return; 66 80 final currentPage = _pageController.page!.round(); 67 - final feedOptions = _buildFeedOptions(); 68 - if (currentPage < feedOptions.length) { 69 - _selectedTabIndex = currentPage; 70 - _feedSettings.setSelectedFeedType(feedOptions[currentPage].value); 81 + if (currentPage < _currentFeedOptions.length && currentPage != _selectedTabIndex) { 82 + _feedSettings.setSelectedFeedType(_currentFeedOptions[currentPage].value); 71 83 } 72 84 } 73 85 ··· 75 87 Widget build(BuildContext context) { 76 88 final topPadding = MediaQuery.of(context).padding.top; 77 89 final isDarkMode = MediaQuery.of(context).platformBrightness == Brightness.dark; 78 - final List<FeedOption> feedOptions = _buildFeedOptions(); 79 90 80 - return Scaffold( 81 - backgroundColor: Colors.black, 82 - body: Stack(children: [_buildMainContent(), _buildTopBar(topPadding, isDarkMode, feedOptions)]), 91 + return VisibilityDetector( 92 + key: const Key('home_screen_visibility'), 93 + onVisibilityChanged: (visibilityInfo) { 94 + final isVisible = visibilityInfo.visibleFraction > 0; 95 + if (_isHomeScreenVisible != isVisible) { 96 + setState(() { 97 + _isHomeScreenVisible = isVisible; 98 + _buildFeedScreens(); 99 + }); 100 + } 101 + }, 102 + child: Scaffold( 103 + backgroundColor: Colors.black, 104 + body: Stack(children: [_buildMainContent(), _buildTopBar(topPadding, isDarkMode, _currentFeedOptions)]), 105 + ), 83 106 ); 84 107 } 85 108 86 - List<FeedOption> _buildFeedOptions() { 109 + void _buildFeedScreens() { 87 110 final options = <FeedOption>[]; 111 + final screens = <Widget>[]; 112 + int feedIndex = 0; 88 113 89 114 if (_feedSettings.followingFeedEnabled) { 115 + final bool isVisible = _isHomeScreenVisible && (_selectedTabIndex == feedIndex); 90 116 options.add(const FeedOption(label: 'Following', value: 0)); 117 + screens.add( 118 + ChangeNotifierProvider<FeedSettingsService>.value( 119 + key: const ValueKey('feed_0'), 120 + value: _feedSettings, 121 + child: FeedScreen(feedType: 0, isParentFeedVisible: isVisible), 122 + ), 123 + ); 124 + feedIndex++; 91 125 } 92 126 93 127 if (_feedSettings.forYouFeedEnabled) { 128 + final bool isVisible = _isHomeScreenVisible && (_selectedTabIndex == feedIndex); 94 129 options.add(const FeedOption(label: 'For You', value: 1)); 130 + screens.add( 131 + ChangeNotifierProvider<FeedSettingsService>.value( 132 + key: const ValueKey('feed_1'), 133 + value: _feedSettings, 134 + child: FeedScreen(feedType: 1, isParentFeedVisible: isVisible), 135 + ), 136 + ); 137 + feedIndex++; 95 138 } 96 139 97 140 if (_feedSettings.latestFeedEnabled) { 141 + final bool isVisible = _isHomeScreenVisible && (_selectedTabIndex == feedIndex); 98 142 options.add(const FeedOption(label: 'Latest', value: 2)); 143 + screens.add( 144 + ChangeNotifierProvider<FeedSettingsService>.value( 145 + key: const ValueKey('feed_2'), 146 + value: _feedSettings, 147 + child: FeedScreen(feedType: 2, isParentFeedVisible: isVisible), 148 + ), 149 + ); 150 + feedIndex++; 99 151 } 100 152 101 - return options; 153 + _currentFeedOptions = options; 154 + if (!listEquals(_feedScreens, screens)) { 155 + _feedScreens = screens; 156 + } 102 157 } 103 158 104 159 Widget _buildMainContent() { 105 - final feedOptions = _buildFeedOptions(); 106 - return PageView.builder( 160 + return PageView( 107 161 controller: _pageController, 108 - itemCount: feedOptions.length, 162 + children: _feedScreens, 109 163 onPageChanged: (index) { 110 - if (index < feedOptions.length) { 111 - _feedSettings.setSelectedFeedType(feedOptions[index].value); 164 + if (index < _currentFeedOptions.length) { 165 + setState(() { 166 + _selectedTabIndex = index; 167 + _buildFeedScreens(); 168 + }); 169 + _feedSettings.setSelectedFeedType(_currentFeedOptions[index].value); 112 170 } 113 171 }, 114 - itemBuilder: (context, index) { 115 - final feedType = feedOptions[index].value; 116 - return ChangeNotifierProvider<FeedSettingsService>.value(value: _feedSettings, child: FeedScreen(feedType: feedType)); 117 - }, 118 172 ); 119 173 } 120 174 ··· 138 192 feedOptions.isNotEmpty 139 193 ? FeedSelector( 140 194 options: feedOptions, 141 - selectedValue: feedOptions[_selectedTabIndex].value, 195 + selectedValue: feedOptions.isNotEmpty ? feedOptions[_selectedTabIndex].value : 0, 142 196 onOptionSelected: _onFeedSelected, 143 197 ) 144 198 : const SizedBox(), ··· 158 212 } 159 213 160 214 Future<void> _onFeedSelected(int value) async { 161 - final feedOptions = _buildFeedOptions(); 162 - final index = feedOptions.indexWhere((option) => option.value == value); 163 - if (index != -1) { 215 + final index = _currentFeedOptions.indexWhere((option) => option.value == value); 216 + if (index != -1 && index != _selectedTabIndex) { 164 217 setState(() { 165 218 _selectedTabIndex = index; 166 219 }); 167 - await _feedSettings.setSelectedFeedType(value); 220 + _buildFeedScreens(); 221 + 168 222 if (_pageController.hasClients) { 169 223 await _pageController.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); 170 224 } 225 + await _feedSettings.setSelectedFeedType(value); 171 226 } 172 227 } 173 228 ··· 217 272 218 273 await _feedSettings.toggleFeed(settingType, isEnabled); 219 274 220 - if (mounted) { 221 - setState(() {}); 222 - 223 - if (!isEnabled && _feedSettings.getFeedTypeFromSetting(settingType) == _feedSettings.selectedFeedType) { 224 - _pageController.jumpToPage(0); 225 - } 226 - } 275 + _onFeedSettingsChanged(); 227 276 } 228 277 } 229 278
+1
lib/screens/profile_player_screen.dart
··· 133 133 key: ValueKey('video_item_$index'), 134 134 index: index, 135 135 videoUrl: videoUrl, 136 + isVisible: index == _currentIndex, 136 137 username: postData.username, 137 138 description: postData.description, 138 139 hashtags: postData.hashtags,
+17 -41
lib/screens/splash_screen.dart
··· 2 2 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:provider/provider.dart'; 5 - import 'package:video_player/video_player.dart'; 6 5 7 6 import '../services/auth_service.dart'; 8 7 import '../services/onboarding_service.dart'; ··· 15 14 } 16 15 17 16 class _SplashScreenState extends State<SplashScreen> { 18 - late VideoPlayerController _videoController; 19 - bool _isVideoInitialized = false; 17 + final AssetImage _introImage = const AssetImage('assets/branding/intro.webp'); 18 + bool _isImageLoaded = false; 20 19 21 20 @override 22 21 void initState() { 23 22 super.initState(); 24 - 25 - _videoController = 26 - VideoPlayerController.asset('assets/branding/intro.mp4') 27 - ..setVolume(0.0) // Mute the audio 28 - ..setLooping(true) // Optional: loop the video if authentication takes time 29 - ..initialize().then((_) { 30 - setState(() { 31 - _isVideoInitialized = true; 32 - }); 33 - _videoController.play(); 34 - }); 35 - 36 23 _checkAuthentication(); 37 24 } 38 25 39 - Future<void> _checkAuthentication() async { 40 - await Future.delayed(const Duration(seconds: 2)); 41 - 42 - if (!mounted) return; 26 + @override 27 + void didChangeDependencies() { 28 + super.didChangeDependencies(); 29 + if (!_isImageLoaded) { 30 + precacheImage(_introImage, context).then((_) { 31 + if (!mounted) return; 32 + setState(() => _isImageLoaded = true); 33 + }); 34 + } 35 + } 43 36 37 + Future<void> _checkAuthentication() async { 44 38 final authService = Provider.of<AuthService>(context, listen: false); 45 39 46 40 while (authService.isLoading) { 47 - await Future.delayed(const Duration(milliseconds: 100)); 48 - if (!mounted) return; 41 + await Future.delayed(const Duration(milliseconds: 10)); 49 42 } 50 43 51 44 final bool isSessionValid = await authService.validateSession(); ··· 64 57 } 65 58 66 59 @override 67 - void dispose() { 68 - _videoController.dispose(); 69 - super.dispose(); 70 - } 71 - 72 - @override 73 60 Widget build(BuildContext context) { 74 - return Scaffold(backgroundColor: Colors.black, body: _isVideoInitialized ? _buildVideoPlayer() : _buildLoadingIndicator()); 61 + return Scaffold(backgroundColor: Colors.black, body: _isImageLoaded ? _buildIntroImage() : _buildLoadingIndicator()); 75 62 } 76 63 77 - Widget _buildVideoPlayer() { 78 - final videoSize = _videoController.value.size; 79 - 80 - return SizedBox.expand( 81 - child: FittedBox( 82 - fit: BoxFit.cover, // This ensures the video covers the whole screen 83 - child: SizedBox( 84 - width: videoSize.width, 85 - height: videoSize.height, 86 - child: AspectRatio(aspectRatio: _videoController.value.aspectRatio, child: VideoPlayer(_videoController)), 87 - ), 88 - ), 89 - ); 64 + Widget _buildIntroImage() { 65 + return SizedBox.expand(child: Image(image: _introImage, fit: BoxFit.cover)); 90 66 } 91 67 92 68 Widget _buildLoadingIndicator() {
-52
lib/screens/test_actions_screen.dart
··· 1 - import 'package:flutter/material.dart'; 2 - 3 - import '../utils/app_colors.dart'; 4 - import '../widgets/action_buttons/like_action_button.dart'; 5 - 6 - class TestActionsScreen extends StatefulWidget { 7 - const TestActionsScreen({super.key}); 8 - 9 - @override 10 - State<TestActionsScreen> createState() => _TestActionsScreenState(); 11 - } 12 - 13 - class _TestActionsScreenState extends State<TestActionsScreen> { 14 - bool _isLiked = false; 15 - bool _isBookmarked = false; 16 - 17 - void _toggleLike() { 18 - setState(() { 19 - _isLiked = !_isLiked; 20 - debugPrint('Like state changed to: $_isLiked'); 21 - }); 22 - } 23 - 24 - void _toggleBookmark() { 25 - setState(() { 26 - _isBookmarked = !_isBookmarked; 27 - debugPrint('Bookmark state changed to: $_isBookmarked'); 28 - }); 29 - } 30 - 31 - @override 32 - Widget build(BuildContext context) { 33 - return Scaffold( 34 - backgroundColor: AppColors.deepPurple, 35 - appBar: AppBar(title: const Text('Test Actions'), backgroundColor: AppColors.deepPurple, elevation: 0), 36 - body: Center( 37 - child: Column( 38 - mainAxisAlignment: MainAxisAlignment.center, 39 - children: [ 40 - const Text('Heart Animation Test', style: TextStyle(color: Colors.white, fontSize: 18)), 41 - const SizedBox(height: 20), 42 - LikeActionButton(count: '1.2K', isLiked: _isLiked, onPressed: _toggleLike), 43 - const SizedBox(height: 40), 44 - const Text('Bookmark Color Test', style: TextStyle(color: Colors.white, fontSize: 18)), 45 - const SizedBox(height: 20), 46 - // BookmarkActionButton(count: '425', isBookmarked: _isBookmarked, onPressed: _toggleBookmark), 47 - ], 48 - ), 49 - ), 50 - ); 51 - } 52 - }
+91 -145
lib/services/media_manager.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + import 'dart:math'; 4 + 1 5 import 'package:flutter/material.dart'; 2 - import 'package:video_player/video_player.dart'; 3 - import 'package:cached_network_image/cached_network_image.dart'; 4 6 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'; 7 + import 'package:video_player/video_player.dart'; 8 8 9 9 class PreloadedVideo { 10 10 final VideoPlayerController controller; ··· 20 20 } 21 21 22 22 class MediaManager { 23 - static final MediaManager _instance = MediaManager._internal(); 24 - factory MediaManager() => _instance; 25 - MediaManager._internal(); 26 - 27 23 // Pre-initialized VideoPlayerControllers mapped by index 28 24 final Map<int, PreloadedVideo> _preloadedVideos = {}; 29 25 ··· 36 32 // Cache manager for videos 37 33 final DefaultCacheManager _cacheManager = DefaultCacheManager(); 38 34 35 + final Set<int> _failedPreloads = {}; 36 + final int _maxPreloadAhead = 5; 37 + final int _maxPreloadBehind = 2; 38 + final int _maxLoadedVideos = 10; // Max controllers to keep loaded 39 + 39 40 Future<String?> _downloadAndCacheVideo(String videoUrl) async { 40 41 try { 41 42 // For Bluesky videos, we need to get the actual video file URL ··· 57 58 final file = await _cacheManager.getSingleFile(videoUrl); 58 59 return file.path; 59 60 } catch (e) { 60 - print('Error caching video: $e'); 61 + debugPrint('Error caching video: $e'); 61 62 return null; 62 63 } 63 64 } 64 65 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 - 93 66 void dispose() { 94 67 clearAllMedia(); 95 68 } ··· 107 80 file.deleteSync(); 108 81 } 109 82 } catch (e) { 110 - print('Error cleaning up cached video: $e'); 83 + debugPrint('Error cleaning up cached video: $e'); 111 84 } 112 85 } 113 86 } catch (e) { ··· 117 90 _preloadedVideos.clear(); 118 91 _preloadedImageUrls.clear(); 119 92 _localVideoPaths.clear(); 93 + _failedPreloads.clear(); 120 94 121 95 // Clear the cache manager's cache 122 96 _cacheManager.emptyCache(); ··· 126 100 if (videoUrl != null) { 127 101 await _preloadVideo(index, videoUrl); 128 102 } else if (imageUrls.isNotEmpty) { 129 - _preloadImages(imageUrls, context); 103 + _preloadImages(index, imageUrls, context); 130 104 } 131 105 } 132 106 133 107 Future<void> _preloadVideo(int index, String videoUrl) async { 134 - // Skip if already preloaded with the same URL 135 - if (_preloadedVideos.containsKey(index)) { 136 - if (_preloadedVideos[index]!.videoUrl == videoUrl) { 137 - return; 138 - } 139 - 140 - // If URL changed, dispose old controller first 141 - try { 142 - _preloadedVideos[index]!.dispose(); 143 - } catch (e) { 144 - // Silently handle any disposal errors 145 - } 146 - _preloadedVideos.remove(index); 108 + if (_preloadedVideos.containsKey(index) || _failedPreloads.contains(index)) { 109 + return; // Already loaded/preloading or failed before 147 110 } 148 111 149 - // Try to download and cache the video 150 - final localPath = await _downloadAndCacheVideo(videoUrl); 151 - VideoPlayerController controller; 112 + // Mark as preloading with a placeholder controller 113 + final placeholderController = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); 114 + _preloadedVideos[index] = PreloadedVideo(controller: placeholderController, isInitialized: false, videoUrl: videoUrl); 152 115 153 116 try { 117 + // Download and cache the video, returning a local file path if successful 118 + final localPath = await _downloadAndCacheVideo(videoUrl); 119 + 120 + VideoPlayerController controller; 154 121 if (localPath != null) { 155 - // Always use local file if available 122 + // Use the cached local file 156 123 controller = VideoPlayerController.file(File(localPath)); 124 + _localVideoPaths[index] = localPath; 157 125 } else { 158 - // Fall back to network only if caching fails 126 + // Fallback to network URL 159 127 controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); 160 128 } 161 129 162 - // Register it as non-initialized first 163 - _preloadedVideos[index] = PreloadedVideo( 164 - controller: controller, 165 - isInitialized: false, 166 - videoUrl: videoUrl, 167 - localPath: localPath, 168 - ); 169 - 170 - // Set video to loop automatically 171 - controller.setLooping(true); 172 - 173 - // Set volume to zero initially 130 + await controller.initialize(); 131 + await controller.setLooping(true); 132 + // Mute until visible 174 133 await controller.setVolume(0.0); 175 134 176 - // Try to initialize 177 - await controller.initialize(); 178 - 179 - // Only proceed if video is still needed and the URL hasn't changed 180 - if (_preloadedVideos.containsKey(index) && _preloadedVideos[index]!.videoUrl == videoUrl) { 181 - // Update the preloaded status 135 + // Only update if this index is still relevant 136 + if (_preloadedVideos.containsKey(index)) { 182 137 _preloadedVideos[index] = PreloadedVideo( 183 138 controller: controller, 184 139 isInitialized: true, 185 140 videoUrl: videoUrl, 186 141 localPath: localPath, 187 142 ); 188 - 189 - // Set playback speed to 1.0 (normal) 190 - await controller.setPlaybackSpeed(1.0); 191 - 192 - // Store the local path 193 - if (localPath != null) { 194 - _localVideoPaths[index] = localPath; 195 - } 196 143 } else { 197 - // If this video is no longer needed or URL changed, dispose it 198 - try { 199 - controller.dispose(); 200 - } catch (e) { 201 - // Silently handle any disposal errors 202 - } 144 + // If no longer needed, dispose 145 + await controller.dispose(); 203 146 } 204 147 } catch (e) { 205 - print('Error preloading video: $e'); 206 - // Clean up if there was an error 207 - if (_preloadedVideos.containsKey(index)) { 208 - try { 209 - _preloadedVideos[index]!.dispose(); 210 - } catch (disposeError) { 211 - // Silently handle any disposal errors 212 - } 213 - _preloadedVideos.remove(index); 214 - } 148 + debugPrint('Error preloading video at index $index: $e'); 149 + _failedPreloads.add(index); 150 + // Clean up placeholder on failure 151 + _preloadedVideos.remove(index); 215 152 } 216 153 } 217 154 218 - void _preloadImages(List<String> urls, BuildContext context) { 219 - for (final url in urls) { 220 - if (!_preloadedImageUrls.contains(url)) { 221 - _preloadedImageUrls.add(url); 222 - precacheImage(CachedNetworkImageProvider(url), context); 223 - } 155 + void _preloadImages(int index, List<String> imageUrls, BuildContext context) { 156 + for (final url in imageUrls) { 157 + precacheImage(NetworkImage(url), context); 224 158 } 225 159 } 226 160 ··· 238 172 file.deleteSync(); 239 173 } 240 174 } catch (e) { 241 - print('Error cleaning up cached video: $e'); 175 + debugPrint('Error cleaning up cached video: $e'); 242 176 } 243 177 } 244 178 } 245 179 } 246 180 247 - void updateLoadedMedia(int newIndex, int currentIndex, int totalItems) { 248 - if (newIndex != currentIndex) { 249 - // Handle video playback for the current and previous video 250 - if (_preloadedVideos.containsKey(currentIndex)) { 251 - try { 252 - // Mute and pause the previously playing video 253 - _preloadedVideos[currentIndex]!.controller.setVolume(0.0); 254 - _preloadedVideos[currentIndex]!.controller.pause(); 255 - } catch (e) { 256 - // If there's an issue with the controller, clean it up 257 - unloadVideo(currentIndex); 258 - } 259 - } 260 - 261 - if (_preloadedVideos.containsKey(newIndex)) { 262 - try { 263 - // Set volume and play the current video 264 - _preloadedVideos[newIndex]!.controller.setVolume(1.0); 265 - _preloadedVideos[newIndex]!.controller.play(); 266 - } catch (e) { 267 - // If there's an issue with the controller, clean it up 268 - unloadVideo(newIndex); 269 - } 270 - } 181 + void updateLoadedMedia(int newIndex, int oldIndex, int totalPosts) { 182 + final Set<int> indicesToKeep = {}; 183 + for (int i = max(0, newIndex - _maxPreloadBehind); i <= min(totalPosts - 1, newIndex + _maxPreloadAhead); i++) { 184 + indicesToKeep.add(i); 185 + } 271 186 272 - // Use a wider preloading range - 5 before and 5 after 273 - final toLoad = <int>{}; 187 + // Add currently playing video if it's outside the range (less likely but possible) 188 + indicesToKeep.add(newIndex); 274 189 275 - // Add 5 previous and 5 next items 276 - for (int i = newIndex - 5; i <= newIndex + 5; i++) { 277 - toLoad.add(i); 278 - } 190 + // Unload videos outside the keep range 191 + final indicesToUnload = _preloadedVideos.keys.where((idx) => !indicesToKeep.contains(idx)).toList(); 192 + for (final index in indicesToUnload) { 193 + unloadVideo(index); 194 + } 279 195 280 - // Remove indices that are out of bounds 281 - final validToLoad = toLoad.where((idx) => idx >= 0 && idx < totalItems).toSet(); 196 + // Limit total loaded videos if necessary (unload furthest first) 197 + if (_preloadedVideos.length > _maxLoadedVideos) { 198 + _unloadFurthestVideos(newIndex); 199 + } 200 + } 282 201 283 - // Find videos to unload (current loaded videos that aren't in the new set) 284 - final toUnload = _preloadedVideos.keys.toSet().difference(validToLoad); 202 + void _unloadFurthestVideos(int currentIndex) { 203 + final loadedIndices = _preloadedVideos.keys.toList(); 204 + loadedIndices.sort((a, b) => (a - currentIndex).abs().compareTo((b - currentIndex).abs())); // Sort by distance 285 205 286 - // Unload videos no longer needed 287 - for (final idx in toUnload) { 288 - unloadVideo(idx); 289 - } 206 + // Keep the closest _maxLoadedVideos 207 + final indicesToUnload = loadedIndices.sublist(min(_maxLoadedVideos, loadedIndices.length)); 208 + for (final index in indicesToUnload) { 209 + unloadVideo(index); 290 210 } 291 211 } 292 212 ··· 300 220 301 221 String? getLocalVideoPath(int index) { 302 222 return _localVideoPaths[index]; 223 + } 224 + 225 + Future<void> pauseVideo(int index) async { 226 + final controller = _getVideoController(index); 227 + if (controller != null && controller.value.isInitialized && controller.value.isPlaying) { 228 + try { 229 + await controller.pause(); 230 + } catch (e) { 231 + debugPrint("Error pausing video at index $index: $e"); 232 + } 233 + } 234 + } 235 + 236 + Future<void> resumeVideo(int index) async { 237 + final controller = _getVideoController(index); 238 + if (controller != null && controller.value.isInitialized && !controller.value.isPlaying) { 239 + try { 240 + await controller.play(); 241 + } catch (e) { 242 + debugPrint("Error resuming video at index $index: $e"); 243 + } 244 + } 245 + } 246 + 247 + VideoPlayerController? _getVideoController(int index) { 248 + return _preloadedVideos[index]?.controller; 303 249 } 304 250 }
+2 -8
lib/widgets/profile/tabs/photos_tab.dart
··· 5 5 import '../../../models/feed_post.dart'; 6 6 import '../../../screens/feed_screen.dart'; 7 7 import '../../../services/auth_service.dart'; 8 + import '../../../services/feed_settings_service.dart'; 8 9 import '../../../services/profile_service.dart'; 9 - import '../../../widgets/video/video_item.dart'; 10 10 import '../../profile/profile_video_tile.dart'; 11 - import '../../../services/feed_settings_service.dart'; 12 11 13 12 class PhotosTab extends StatefulWidget { 14 13 final String? did; ··· 148 147 } 149 148 } 150 149 151 - void _onImageTap(int index) { 152 - // For now, just display a simple message 153 - // This can be enhanced later to show a full-screen image viewer 154 - debugPrint('Image post clicked at index $index'); 155 - } 156 - 157 150 void _openMediaViewer(int index, Map<String, dynamic> post, List<Map<String, dynamic>> allPosts) { 158 151 // Convert all posts to FeedPost format 159 152 final feedPosts = ··· 207 200 initialPosts: feedPosts, 208 201 initialIndex: index, 209 202 showBackButton: true, 203 + isParentFeedVisible: true, // FeedScreen is immediately visible when pushed 210 204 ), 211 205 ), 212 206 transitionsBuilder: (_, Animation<double> animation, __, Widget child) {
+2 -2
lib/widgets/profile/tabs/videos_tab.dart
··· 5 5 import '../../../models/feed_post.dart'; 6 6 import '../../../screens/feed_screen.dart'; 7 7 import '../../../services/auth_service.dart'; 8 + import '../../../services/feed_settings_service.dart'; 8 9 import '../../../services/profile_service.dart'; 9 - import '../../../widgets/video/video_item.dart'; 10 10 import '../../profile/profile_video_tile.dart'; 11 - import '../../../services/feed_settings_service.dart'; 12 11 13 12 class VideosTab extends StatefulWidget { 14 13 final String? did; ··· 201 200 initialPosts: feedPosts, 202 201 initialIndex: index, 203 202 showBackButton: true, 203 + isParentFeedVisible: true, 204 204 ), 205 205 ), 206 206 transitionsBuilder: (_, Animation<double> animation, __, Widget child) {
+74 -91
lib/widgets/video/video_item.dart
··· 1 1 import 'dart:developer'; 2 + import 'dart:io'; 2 3 import 'dart:ui'; // For ImageFilter 4 + 3 5 import 'package:flutter/material.dart'; 4 6 import 'package:video_player/video_player.dart'; 5 - import 'package:visibility_detector/visibility_detector.dart'; 6 - import 'dart:io'; 7 7 8 - import '../../main.dart'; 9 8 import '../../utils/app_colors.dart'; 10 9 import 'video_player_base.dart'; 11 10 12 11 class VideoItem extends VideoPlayerBase { 13 - @override 14 - final String? videoUrl; 15 - @override 16 - final String? videoAlt; 17 12 final VideoPlayerController? preloadedController; 18 13 final String? localVideoPath; 14 + final bool isVisible; 19 15 20 16 const VideoItem({ 21 17 super.key, 22 18 required super.index, 23 - required this.videoUrl, 24 - this.videoAlt, 19 + required super.videoUrl, 20 + super.videoAlt, 25 21 this.preloadedController, 26 22 this.localVideoPath, 23 + required this.isVisible, 27 24 super.username = '', 28 25 super.description = '', 29 26 super.hashtags = const [], ··· 51 48 State<VideoItem> createState() => _VideoItemState(); 52 49 } 53 50 54 - class _VideoItemState extends VideoPlayerBaseState<VideoItem> with RouteAware { 51 + class _VideoItemState extends VideoPlayerBaseState<VideoItem> { 55 52 VideoPlayerController? _controller; 56 53 bool _isInitialized = false; 57 - bool _isVisible = false; 58 - final String _videoKey = UniqueKey().toString(); 59 54 60 55 @override 61 56 VideoPlayerController? get videoController => _controller; ··· 64 59 bool get isInitialized => _isInitialized && _controller != null; 65 60 66 61 @override 67 - bool get isVisible => _isVisible; 62 + bool get isVisible => widget.isVisible; 68 63 69 64 @override 70 65 void initState() { ··· 73 68 } 74 69 75 70 @override 76 - void didChangeDependencies() { 77 - super.didChangeDependencies(); 78 - ModalRoute<void>? route = ModalRoute.of(context); 79 - if (route != null) { 80 - routeObserver.subscribe(this, route); 71 + void didUpdateWidget(VideoItem oldWidget) { 72 + super.didUpdateWidget(oldWidget); 73 + if (oldWidget.isVisible != widget.isVisible) { 74 + _updatePlayState(); 75 + } 76 + if (oldWidget.preloadedController != widget.preloadedController || 77 + oldWidget.videoUrl != widget.videoUrl || 78 + oldWidget.localVideoPath != widget.localVideoPath) { 79 + _initializeVideoPlayer(); 81 80 } 82 81 } 83 82 84 83 @override 85 84 void dispose() { 86 - routeObserver.unsubscribe(this); 87 - if (_controller != widget.preloadedController) { 88 - _controller?.dispose(); 85 + if (_controller != null && _controller != widget.preloadedController) { 86 + _controller!.dispose(); 89 87 } 90 88 super.dispose(); 91 89 } 92 90 93 - @override 94 - void didPushNext() { 95 - pauseMedia(); 96 - } 91 + Future<void> _initializeVideoPlayer() async { 92 + if (_controller != null && _controller != widget.preloadedController) { 93 + await _controller!.dispose(); 94 + _controller = null; 95 + _isInitialized = false; 96 + } 97 97 98 - @override 99 - void didPopNext() { 100 - if (_isVisible && isInitialized) { 101 - playMedia(); 98 + if (widget.videoUrl == null && widget.localVideoPath == null) { 99 + setStateIfMounted(() {}); 100 + return; 102 101 } 103 - } 104 102 105 - Future<void> _initializeVideoPlayer() async { 106 - if (widget.videoUrl == null && widget.localVideoPath == null) return; 103 + bool controllerNeedsInit = false; 107 104 108 105 if (widget.preloadedController != null) { 109 106 _controller = widget.preloadedController; 110 - if (_controller!.value.isInitialized) { 111 - setState(() { 112 - _isInitialized = true; 113 - }); 114 - if (_isVisible) { 115 - playMedia(); 116 - } 117 - return; 107 + _isInitialized = _controller!.value.isInitialized; 108 + if (!_isInitialized) { 109 + debugPrint("VideoItem received uninitialized preloaded controller for index ${widget.index}"); 118 110 } 119 - } 120 - 121 - if (widget.localVideoPath != null) { 111 + } else if (widget.localVideoPath != null) { 122 112 try { 123 113 _controller = VideoPlayerController.file(File(widget.localVideoPath!)); 124 - await _controller?.initialize(); 125 - 126 - if (!mounted) return; 127 - 128 - _controller?.setLooping(true); 129 - setState(() { 130 - _isInitialized = true; 131 - }); 132 - 133 - if (_isVisible) { 134 - playMedia(); 135 - } 136 - return; 114 + controllerNeedsInit = true; 115 + } catch (e) { 116 + log('Failed to create local video controller: $e'); 117 + _controller = null; 118 + } 119 + } else if (widget.videoUrl != null) { 120 + try { 121 + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl!)); 122 + controllerNeedsInit = true; 137 123 } catch (e) { 138 - log('Failed to load local video: $e'); 124 + log('Failed to create network video controller: $e'); 125 + _controller = null; 139 126 } 140 127 } 141 128 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(() { 129 + if (_controller != null && controllerNeedsInit) { 130 + try { 131 + await _controller!.initialize(); 150 132 _isInitialized = true; 151 - }); 152 - 153 - if (_isVisible) { 154 - playMedia(); 133 + _controller!.setLooping(true); 134 + } catch (e) { 135 + log('Failed to initialize video controller for index ${widget.index}: $e'); 136 + _isInitialized = false; 137 + await _controller?.dispose(); 138 + _controller = null; 155 139 } 156 140 } 141 + 142 + setStateIfMounted(() {}); 143 + _updatePlayState(); 157 144 } 158 145 159 - void _onVisibilityChanged(VisibilityInfo info) { 160 - final visible = info.visibleFraction > 0.8; 161 - if (!mounted || visible == _isVisible) return; 146 + void _updatePlayState() { 147 + if (!mounted || !_isInitialized || _controller == null) return; 162 148 163 - setState(() { 164 - _isVisible = visible; 165 - }); 149 + if (widget.isVisible) { 150 + playMedia(); 151 + } else { 152 + pauseMedia(); 153 + } 154 + } 166 155 167 - if (_isInitialized) { 168 - if (_isVisible) { 169 - playMedia(); 170 - } else { 171 - pauseMedia(); 172 - } 156 + void setStateIfMounted(VoidCallback fn) { 157 + if (mounted) { 158 + setState(fn); 173 159 } 174 160 } 175 161 176 162 @override 177 163 Widget build(BuildContext context) { 178 - return VisibilityDetector( 179 - key: Key(_videoKey), 180 - onVisibilityChanged: _onVisibilityChanged, 181 - child: Stack( 182 - fit: StackFit.expand, 183 - children: [ 184 - super.build(context), 185 - if (widget.videoUrl != null && !_isInitialized) const Center(child: CircularProgressIndicator(color: AppColors.white)), 186 - ], 187 - ), 164 + return Stack( 165 + fit: StackFit.expand, 166 + children: [ 167 + super.build(context), 168 + if (widget.videoUrl != null && widget.localVideoPath == null && widget.preloadedController == null && !_isInitialized) 169 + const Center(child: CircularProgressIndicator(color: AppColors.white)), 170 + ], 188 171 ); 189 172 } 190 173
+3 -2
lib/widgets/video/video_player_base.dart
··· 34 34 super.postUri, // Accept postUri 35 35 super.postCid, // Accept postCid 36 36 super.disableBackgroundBlur, 37 - VoidCallback? onPostDeleted, // Add the callback 38 - }) : super(onPostDeleted: onPostDeleted); 37 + super.videoAlt, // Add super.videoAlt here 38 + super.onPostDeleted, 39 + }); 39 40 } 40 41 41 42 /// Base state class for video players, extending PostItemBaseState.