[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(profile): improve autoplay & author link

Introduces a profileFeedIndexProvider to track the visible post index in standalone profile feeds, enabling correct video autoplay/pause behavior. Updates ProfileFeedPostWidget and PostVideoPlayer to use the new provider, and ensures the index is initialized and updated in StandaloneProfileFeedPage. This improves video playback consistency when navigating profile feeds.

+108 -14
+28 -3
lib/src/features/feed/ui/widgets/videos/video_player.dart
··· 12 12 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_progress_bar.dart'; 13 13 import 'package:sparksocial/src/features/home/providers/feed_settings_visibility_provider.dart'; 14 14 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 15 + import 'package:sparksocial/src/features/profile/providers/profile_feed_index_provider.dart'; 15 16 16 17 class PostVideoPlayer extends ConsumerStatefulWidget { 17 - const PostVideoPlayer({required this.videoUrl, required this.thumbnail, super.key, this.feed, this.index}); 18 + const PostVideoPlayer({ 19 + required this.videoUrl, 20 + required this.thumbnail, 21 + super.key, 22 + this.feed, 23 + this.index, 24 + this.profileFeedUri, 25 + }); 18 26 19 27 final String videoUrl; 20 28 final String thumbnail; 21 29 final Feed? feed; 22 30 final int? index; 31 + 32 + /// The profile URI for standalone profile feed visibility tracking. 33 + /// When provided along with [index], uses profile feed index provider instead of feed provider. 34 + final String? profileFeedUri; 23 35 24 36 @override 25 37 ConsumerState<PostVideoPlayer> createState() => PostVideoPlayerState(); ··· 167 179 else if (!isFeedSettingsVisible) { 168 180 final wasPlaying = _wasPlayingWhenMenuOpened; 169 181 _wasPlayingWhenMenuOpened = false; 170 - 182 + 171 183 if (wasPlaying && !isPlaying) { 172 184 // Resume if it was playing when menu opened 173 185 videoController?.play(); ··· 192 204 final feedSettingsVisible = ref.watch(feedSettingsVisibilityProvider); 193 205 194 206 final feedState = widget.feed != null ? ref.watch(feedProvider(widget.feed!)) : null; 207 + final profileFeedIndex = widget.profileFeedUri != null ? ref.watch(profileFeedIndexProvider(widget.profileFeedUri!)) : null; 195 208 196 209 if (_lastNavigationIndex != navigationState.currentIndex) { 197 210 _lastNavigationIndex = navigationState.currentIndex; ··· 220 233 _handleAutoPlayPause(shouldPlay, isFeedSettingsVisible: feedSettingsVisible); 221 234 } 222 235 }); 223 - } else if (widget.feed == null && widget.index == null) { 236 + } else if (profileFeedIndex != null && widget.index != null) { 237 + // Profile feed visibility check 238 + if (_lastFeedIndex != profileFeedIndex) { 239 + _lastFeedIndex = profileFeedIndex; 240 + WidgetsBinding.instance.addPostFrameCallback((_) { 241 + if (mounted && !_userInteracted) { 242 + final shouldPlay = profileFeedIndex == widget.index; 243 + _handleAutoPlayPause(shouldPlay, isFeedSettingsVisible: feedSettingsVisible); 244 + } 245 + }); 246 + } 247 + } else if (widget.feed == null && widget.index == null && widget.profileFeedUri == null) { 248 + // True standalone mode (no feed tracking at all) 224 249 _handleAutoPlayPause(true, isFeedSettingsVisible: feedSettingsVisible); 225 250 } 226 251 });
+17
lib/src/features/profile/providers/profile_feed_index_provider.dart
··· 1 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 2 + 3 + part 'profile_feed_index_provider.g.dart'; 4 + 5 + /// Tracks the currently visible post index in a standalone profile feed. 6 + /// Keyed by profile URI string to support multiple profiles. 7 + @riverpod 8 + class ProfileFeedIndex extends _$ProfileFeedIndex { 9 + @override 10 + int build(String profileUri) { 11 + return 0; 12 + } 13 + 14 + void setIndex(int index) { 15 + state = index; 16 + } 17 + }
+13
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 9 9 import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 10 10 import 'package:sparksocial/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 11 11 import 'package:sparksocial/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; 12 + import 'package:sparksocial/src/features/profile/providers/profile_feed_index_provider.dart'; 12 13 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 13 14 import 'package:sparksocial/src/features/profile/ui/widgets/profile_feed_post_widget.dart'; 14 15 ··· 32 33 late final PageController pageController; 33 34 late final AtUri profileAtUri; 34 35 int _currentIndex = 0; 36 + bool _hasInitializedIndex = false; 35 37 36 38 @override 37 39 void initState() { ··· 49 51 50 52 @override 51 53 Widget build(BuildContext context) { 54 + // Initialize the index provider SYNCHRONOUSLY before any child widgets build. 55 + // This prevents race conditions where video widgets see the default index (0) 56 + // before the correct initial index is set. 57 + if (!_hasInitializedIndex) { 58 + _hasInitializedIndex = true; 59 + ref.read(profileFeedIndexProvider(widget.profileUri).notifier).setIndex(widget.initialPostIndex); 60 + } 61 + 52 62 final feedState = ref.watch(profileFeedProvider(profileAtUri, widget.videosOnly)); 53 63 final bottomPadding = MediaQuery.of(context).padding.bottom; 54 64 ··· 79 89 setState(() { 80 90 _currentIndex = index; 81 91 }); 92 + // Update the profile feed index provider for video visibility tracking 93 + ref.read(profileFeedIndexProvider(widget.profileUri).notifier).setIndex(index); 82 94 // Load more posts when approaching the end 83 95 if (index >= filteredUris.length - 3 && !state.isEndOfNetwork) { 84 96 ref.read(profileFeedProvider(profileAtUri, widget.videosOnly).notifier).loadMore(); ··· 92 104 profileUri: profileAtUri, 93 105 videosOnly: widget.videosOnly, 94 106 post: post, 107 + index: index, 95 108 ); 96 109 }, 97 110 );
+50 -11
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 16 16 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 17 17 18 18 class ProfileFeedPostWidget extends ConsumerStatefulWidget { 19 - const ProfileFeedPostWidget({required this.postUri, required this.profileUri, required this.videosOnly, super.key, this.post}); 19 + const ProfileFeedPostWidget({ 20 + required this.postUri, 21 + required this.profileUri, 22 + required this.videosOnly, 23 + super.key, 24 + this.post, 25 + this.index, 26 + }); 20 27 final AtUri postUri; 21 28 final AtUri profileUri; 22 29 final bool videosOnly; 23 30 final PostView? post; 31 + 32 + /// The index of this post in the profile feed, used for video visibility tracking. 33 + final int? index; 24 34 25 35 @override 26 36 ConsumerState<ProfileFeedPostWidget> createState() => _ProfileFeedPostWidgetState(); ··· 169 179 behavior: HitTestBehavior.opaque, 170 180 onDoubleTap: () => _handleDoubleTapLike(post), 171 181 child: switch (post.media) { 172 - MediaViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 173 - MediaViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 182 + MediaViewVideo() => PostVideoPlayer( 183 + videoUrl: post.videoUrl, 184 + thumbnail: post.thumbnailUrl, 185 + profileFeedUri: widget.index != null ? widget.profileUri.toString() : null, 186 + index: widget.index, 187 + ), 188 + MediaViewBskyVideo() => PostVideoPlayer( 189 + videoUrl: post.videoUrl, 190 + thumbnail: post.thumbnailUrl, 191 + profileFeedUri: widget.index != null ? widget.profileUri.toString() : null, 192 + index: widget.index, 193 + ), 174 194 MediaViewImages() || MediaViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 175 195 MediaViewBskyRecordWithMedia(:final media) => switch (media) { 176 - MediaViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 177 - MediaViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 196 + MediaViewVideo() => PostVideoPlayer( 197 + videoUrl: post.videoUrl, 198 + thumbnail: post.thumbnailUrl, 199 + profileFeedUri: widget.index != null ? widget.profileUri.toString() : null, 200 + index: widget.index, 201 + ), 202 + MediaViewBskyVideo() => PostVideoPlayer( 203 + videoUrl: post.videoUrl, 204 + thumbnail: post.thumbnailUrl, 205 + profileFeedUri: widget.index != null ? widget.profileUri.toString() : null, 206 + index: widget.index, 207 + ), 178 208 MediaViewImages() || MediaViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 179 209 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 180 210 }, ··· 193 223 // No special handling needed for profile navigation in standalone feed 194 224 }, 195 225 onUsernameTap: () { 196 - context.router.push( 197 - ProfileRoute( 198 - did: post.author.did, 199 - initialProfile: post.author, 200 - ), 201 - ); 226 + // Extract DID from the profile URI (format: at://did:plc:...) 227 + final currentProfileDid = widget.profileUri.hostname; 228 + 229 + // If clicking on the same profile we're viewing, navigate back 230 + if (post.author.did == currentProfileDid) { 231 + context.router.maybePop(); 232 + } else { 233 + // Otherwise, navigate to the new profile 234 + context.router.push( 235 + ProfileRoute( 236 + did: post.author.did, 237 + initialProfile: post.author, 238 + ), 239 + ); 240 + } 202 241 }, 203 242 ), 204 243 ),