diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 001043b0..3d4b60f4 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -28,6 +28,7 @@ class _MotionHeaderBannerState extends State VideoPlayerController? _controller; bool _ready = false; bool _failed = false; + bool _headerVisible = true; ValueListenable? _tickerMode; @override @@ -40,6 +41,11 @@ class _MotionHeaderBannerState extends State @override void didChangeDependencies() { super.didChangeDependencies(); + final header = context + .dependOnInheritedWidgetOfExactType(); + // A collapsed FlexibleSpaceBar hides its background with opacity only; + // its TickerMode remains enabled while the pinned toolbar is visible. + _headerVisible = header == null || header.currentExtent > header.minExtent; // TickerMode is off while this subtree is hidden: covered by an opaque // route (Navigator offstages it) or on an inactive shell tab. Follow it // so the decoder doesn't keep running behind other screens. @@ -56,13 +62,15 @@ class _MotionHeaderBannerState extends State final lifecycle = WidgetsBinding.instance.lifecycleState; final appVisible = lifecycle == null || lifecycle == AppLifecycleState.resumed; - return appVisible && (_tickerMode?.value.enabled ?? true); + return appVisible && _headerVisible && (_tickerMode?.value.enabled ?? true); } void _syncPlayback() { final controller = _controller; if (controller == null || !_ready) return; - if (_visible) { + final shouldPlay = _visible; + if (controller.value.isPlaying == shouldPlay) return; + if (shouldPlay) { controller.play(); } else { controller.pause(); diff --git a/test/motion_header_banner_test.dart b/test/motion_header_banner_test.dart new file mode 100644 index 00000000..509080d4 --- /dev/null +++ b/test/motion_header_banner_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/motion_header_banner.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +class _VideoPlatform extends VideoPlayerPlatform { + bool playing = false; + int playCalls = 0; + + @override + Future init() async {} + + @override + Future createWithOptions(VideoCreationOptions options) async => 1; + + @override + Stream videoEventsFor(int playerId) => Stream.value( + VideoEvent( + eventType: VideoEventType.initialized, + duration: const Duration(seconds: 30), + size: const Size(320, 180), + ), + ); + + @override + Future play(int playerId) async { + playing = true; + playCalls++; + } + + @override + Future pause(int playerId) async => playing = false; + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future setMixWithOthers(bool mixWithOthers) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Widget buildViewWithOptions(VideoViewOptions options) => const SizedBox(); + + @override + Future dispose(int playerId) async {} +} + +void main() { + testWidgets('collapsed headers pause video and visible headers resume', ( + tester, + ) async { + final previousPlatform = VideoPlayerPlatform.instance; + final platform = _VideoPlatform(); + VideoPlayerPlatform.instance = platform; + addTearDown(() => VideoPlayerPlatform.instance = previousPlatform); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + + Widget app({bool active = true}) => MaterialApp( + home: Scaffold( + body: TickerMode( + enabled: active, + child: CustomScrollView( + controller: scroll, + slivers: const [ + SliverAppBar( + pinned: true, + expandedHeight: 300, + flexibleSpace: FlexibleSpaceBar( + background: MotionHeaderBanner( + videoUrl: 'https://example.com/banner.m3u8', + fallback: ColoredBox(color: Colors.blue), + ), + ), + ), + SliverToBoxAdapter(child: SizedBox(height: 2000)), + ], + ), + ), + ), + ); + + await tester.pumpWidget(app()); + await tester.pump(); + expect(platform.playing, isTrue); + final initialPlayCalls = platform.playCalls; + + scroll.jumpTo(50); + await tester.pump(); + expect(platform.playCalls, initialPlayCalls); + scroll.jumpTo(400); + await tester.pump(); + expect(platform.playing, isFalse); + + scroll.jumpTo(0); + await tester.pump(); + expect(platform.playing, isTrue); + await tester.pumpWidget(app(active: false)); + await tester.pump(); + expect(platform.playing, isFalse); + await tester.pumpWidget(app()); + await tester.pump(); + expect(platform.playing, isTrue); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(platform.playing, isFalse); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(platform.playing, isTrue); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); +}