From d785f9e74a6fbdbfd7199c9fb2bde36a909f0646 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Wed, 8 Jul 2026 21:47:47 -0400 Subject: [PATCH] refactor: gate feed playback on app focus --- lib/src/core/media/media_playback_gate.dart | 66 ++++++++++++++++++ .../feed/ui/pages/standalone_post_page.dart | 16 +++-- .../ui/widgets/post/post_media_viewer.dart | 68 ++++++++++--------- .../feed/ui/widgets/videos/video_player.dart | 33 +-------- .../core/media/media_playback_gate_test.dart | 55 +++++++++++++++ .../post/static_media_sound_player_test.dart | 43 ++++++++++++ 6 files changed, 211 insertions(+), 70 deletions(-) create mode 100644 lib/src/core/media/media_playback_gate.dart create mode 100644 test/src/core/media/media_playback_gate_test.dart diff --git a/lib/src/core/media/media_playback_gate.dart b/lib/src/core/media/media_playback_gate.dart new file mode 100644 index 00000000..477f1f22 --- /dev/null +++ b/lib/src/core/media/media_playback_gate.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spark/src/core/media/media_playback_suspension_provider.dart'; + +typedef MediaPlaybackGateBuilder = + Widget Function(BuildContext context, bool shouldPlay); + +class MediaPlaybackGate extends ConsumerStatefulWidget { + const MediaPlaybackGate({ + required this.isActive, + required this.builder, + super.key, + }); + + final bool isActive; + final MediaPlaybackGateBuilder builder; + + @override + ConsumerState createState() => _MediaPlaybackGateState(); +} + +class _MediaPlaybackGateState extends ConsumerState { + AppLifecycleListener? _lifecycleListener; + bool _isAppInForeground = true; + + @override + void initState() { + super.initState(); + _lifecycleListener = AppLifecycleListener( + onInactive: _markAppUnfocused, + onHide: _markAppUnfocused, + onPause: _markAppUnfocused, + onShow: _markAppFocused, + onResume: _markAppFocused, + ); + } + + @override + void dispose() { + _lifecycleListener?.dispose(); + super.dispose(); + } + + void _markAppUnfocused() { + if (!_isAppInForeground) return; + setState(() { + _isAppInForeground = false; + }); + } + + void _markAppFocused() { + if (_isAppInForeground) return; + setState(() { + _isAppInForeground = true; + }); + } + + @override + Widget build(BuildContext context) { + final mediaPlaybackSuspended = ref.watch(mediaPlaybackSuspendedProvider); + final shouldPlay = + widget.isActive && !mediaPlaybackSuspended && _isAppInForeground; + + return widget.builder(context, shouldPlay); + } +} diff --git a/lib/src/features/feed/ui/pages/standalone_post_page.dart b/lib/src/features/feed/ui/pages/standalone_post_page.dart index c95fbeeb..3cef35d8 100644 --- a/lib/src/features/feed/ui/pages/standalone_post_page.dart +++ b/lib/src/features/feed/ui/pages/standalone_post_page.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_overlay_back_button.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/media/media_playback_gate.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; @@ -215,12 +216,17 @@ class _StandalonePostPageState extends ConsumerState { // Main content Positioned.fill( child: postData!.videoUrl.isNotEmpty - ? PostVideoPlayer( - key: _videoPlayerKey, - videoUrl: postData.videoUrl, - thumbnail: postData.thumbnailUrl, + ? MediaPlaybackGate( isActive: true, - videoAspectRatio: postData.videoAspectRatio, + builder: (context, shouldPlay) { + return PostVideoPlayer( + key: _videoPlayerKey, + videoUrl: postData.videoUrl, + thumbnail: postData.thumbnailUrl, + isActive: shouldPlay, + videoAspectRatio: postData.videoAspectRatio, + ); + }, ) : postData.imageUrls.isNotEmpty ? ImageCarousel(imageUrls: postData.imageUrls) diff --git a/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart index 34fef381..cbf641d5 100644 --- a/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart +++ b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart @@ -1,9 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; -import 'package:spark/src/core/media/media_playback_suspension_provider.dart'; +import 'package:spark/src/core/media/media_playback_gate.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_video_aspect_ratio.dart'; import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; @@ -11,7 +10,7 @@ import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; import 'package:spark/src/features/feed/ui/widgets/post/static_media_sound_player.dart'; import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; -class PostMediaViewer extends ConsumerStatefulWidget { +class PostMediaViewer extends StatefulWidget { const PostMediaViewer({ required this.post, required this.isActive, @@ -22,10 +21,10 @@ class PostMediaViewer extends ConsumerStatefulWidget { final bool isActive; @override - ConsumerState createState() => PostMediaViewerState(); + State createState() => PostMediaViewerState(); } -class PostMediaViewerState extends ConsumerState { +class PostMediaViewerState extends State { final GlobalKey _videoPlayerKey = GlobalKey(); final StaticMediaSoundController _staticSoundController = @@ -45,37 +44,40 @@ class PostMediaViewerState extends ConsumerState { @override Widget build(BuildContext context) { final post = widget.post; - final mediaPlaybackSuspended = ref.watch(mediaPlaybackSuspendedProvider); - final isMediaActive = widget.isActive && !mediaPlaybackSuspended; - if (post.videoUrl.isNotEmpty) { - return PostVideoPlayer( - key: _videoPlayerKey, - videoUrl: post.videoUrl, - thumbnail: post.thumbnailUrl, - isActive: isMediaActive, - videoAspectRatio: post.videoAspectRatio, - ); - } + return MediaPlaybackGate( + isActive: widget.isActive, + builder: (context, shouldPlay) { + if (post.videoUrl.isNotEmpty) { + return PostVideoPlayer( + key: _videoPlayerKey, + videoUrl: post.videoUrl, + thumbnail: post.thumbnailUrl, + isActive: shouldPlay, + videoAspectRatio: post.videoAspectRatio, + ); + } - if (post.imageUrls.isNotEmpty) { - final sound = post.localSound; - return StaticMediaSoundPlayer( - audioUrl: sound == null ? null : playableAudioUrl(sound), - mimeType: sound == null ? null : audioMimeType(sound), - shouldPlay: isMediaActive, - controller: _staticSoundController, - child: ImageCarousel( - imageUrls: post.imageUrls, - hasKnownInteractions: - post.viewer?.knownInteractions != null && - post.viewer!.knownInteractions!.isNotEmpty, - ), - ); - } + if (post.imageUrls.isNotEmpty) { + final sound = post.localSound; + return StaticMediaSoundPlayer( + audioUrl: sound == null ? null : playableAudioUrl(sound), + mimeType: sound == null ? null : audioMimeType(sound), + shouldPlay: shouldPlay, + controller: _staticSoundController, + child: ImageCarousel( + imageUrls: post.imageUrls, + hasKnownInteractions: + post.viewer?.knownInteractions != null && + post.viewer!.knownInteractions!.isNotEmpty, + ), + ); + } - return const DecoratedBox( - decoration: BoxDecoration(color: AppColors.black), + return const DecoratedBox( + decoration: BoxDecoration(color: AppColors.black), + ); + }, ); } } diff --git a/lib/src/features/feed/ui/widgets/videos/video_player.dart b/lib/src/features/feed/ui/widgets/videos/video_player.dart index 9627c549..abbb4d69 100644 --- a/lib/src/features/feed/ui/widgets/videos/video_player.dart +++ b/lib/src/features/feed/ui/widgets/videos/video_player.dart @@ -41,8 +41,6 @@ class PostVideoPlayerState extends State bool _userInteracted = false; bool _showThumbnailOverlay = true; bool _showPlayButton = false; - AppLifecycleListener? _appLifecycleListener; - bool _isAppInForeground = true; bool _shouldResumeWhenEligible = false; late AnimationController _bounceController; @@ -66,7 +64,7 @@ class PostVideoPlayerState extends State videoSize: _playerVideoSize, aspectRatio: _resolvedVideoAspectRatio, ); - bool get _isPlaybackEligible => widget.isActive && _isAppInForeground; + bool get _isPlaybackEligible => widget.isActive; @override void initState() { @@ -78,13 +76,6 @@ class PostVideoPlayerState extends State _bounceAnimation = Tween(begin: 1, end: 1.3).animate( CurvedAnimation(parent: _bounceController, curve: Curves.elasticOut), ); - _appLifecycleListener = AppLifecycleListener( - onInactive: _pauseForLifecycle, - onHide: _pauseForLifecycle, - onPause: _pauseForLifecycle, - onShow: _resumeAfterLifecyclePause, - onResume: _resumeAfterLifecyclePause, - ); initVideoPlayer(); } @@ -100,7 +91,6 @@ class PostVideoPlayerState extends State @override void dispose() { - _appLifecycleListener?.dispose(); _bounceController.dispose(); videoController?.videoPlayerController?.removeListener(_videoValueListener); videoController?.dispose(); @@ -220,27 +210,6 @@ class PostVideoPlayerState extends State } } - void _pauseForLifecycle() { - _isAppInForeground = false; - _shouldResumeWhenEligible = - _shouldResumeWhenEligible || - isPlaying || - (!_userInteracted && widget.isActive); - if (isPlaying) { - videoController?.pause(); - } - } - - void _resumeAfterLifecyclePause() { - _isAppInForeground = true; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _syncPlaybackWithEligibility(); - } - }); - } - void _videoValueListener() { if (!mounted) return; final controller = videoController; diff --git a/test/src/core/media/media_playback_gate_test.dart b/test/src/core/media/media_playback_gate_test.dart new file mode 100644 index 00000000..ad04c40e --- /dev/null +++ b/test/src/core/media/media_playback_gate_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spark/src/core/media/media_playback_gate.dart'; +import 'package:spark/src/core/media/media_playback_suspension_provider.dart'; + +void main() { + testWidgets('combines active state, suspension, and app focus', ( + tester, + ) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final container = ProviderContainer(); + addTearDown(container.dispose); + + Future pumpGate({required bool isActive}) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: Directionality( + textDirection: TextDirection.ltr, + child: MediaPlaybackGate( + isActive: isActive, + builder: (context, shouldPlay) { + return Text(shouldPlay ? 'playing' : 'paused'); + }, + ), + ), + ), + ); + await tester.pump(); + } + + await pumpGate(isActive: true); + expect(find.text('playing'), findsOneWidget); + + final suspension = suspendMediaPlayback(container); + await tester.pump(); + expect(find.text('paused'), findsOneWidget); + + suspension.release(); + await tester.pump(); + expect(find.text('playing'), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect(find.text('paused'), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(find.text('playing'), findsOneWidget); + + await pumpGate(isActive: false); + expect(find.text('paused'), findsOneWidget); + }); +} diff --git a/test/src/features/feed/ui/widgets/post/static_media_sound_player_test.dart b/test/src/features/feed/ui/widgets/post/static_media_sound_player_test.dart index 7f51d426..7d86cfd5 100644 --- a/test/src/features/feed/ui/widgets/post/static_media_sound_player_test.dart +++ b/test/src/features/feed/ui/widgets/post/static_media_sound_player_test.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spark/src/core/media/media_playback_gate.dart'; import 'package:spark/src/features/feed/ui/widgets/post/static_media_sound_player.dart'; void main() { @@ -129,6 +132,46 @@ void main() { 'play:https://example.com/audio:audio/mp4', ]); }); + + testWidgets('pauses and resumes with app focus', (tester) async { + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + final player = _FakeStaticMediaAudioPlayer(); + final controller = StaticMediaSoundController(audioPlayer: player); + addTearDown(controller.dispose); + + await tester.pumpWidget( + ProviderScope( + child: MediaPlaybackGate( + isActive: true, + builder: (context, shouldPlay) { + return StaticMediaSoundPlayer( + audioUrl: 'https://example.com/a.mp3', + shouldPlay: shouldPlay, + controller: controller, + child: const SizedBox.shrink(), + ); + }, + ), + ), + ); + await tester.pump(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + + expect(player.calls, [ + 'play:https://example.com/a.mp3:', + 'pause', + 'resume', + ]); + + await tester.pumpWidget(const SizedBox.shrink()); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + }); }); } -- 2.51.2