diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 063935d3..5a68d7e7 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -594,9 +594,15 @@ class _MainShellState extends ConsumerState itemCount: tabs.length, onPageChanged: _onPageChanged, physics: const NeverScrollableScrollPhysics(), + // TickerMode mutes animations and lets visibility-aware widgets + // (e.g. MotionHeaderBanner) pause when their tab is hidden — + // kept-alive pages otherwise keep running offscreen. itemBuilder: (context, index) => _KeepAliveTabPage( key: ValueKey('page-$index'), - child: tabs[index], + child: TickerMode( + enabled: index == _currentIndex, + child: tabs[index], + ), ), ), builder: (context, child) { diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 793f8993..001043b0 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -27,6 +28,7 @@ class _MotionHeaderBannerState extends State VideoPlayerController? _controller; bool _ready = false; bool _failed = false; + ValueListenable? _tickerMode; @override void initState() { @@ -35,6 +37,38 @@ class _MotionHeaderBannerState extends State _initialize(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 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. + final notifier = TickerMode.getValuesNotifier(context); + if (!identical(notifier, _tickerMode)) { + _tickerMode?.removeListener(_syncPlayback); + _tickerMode = notifier; + notifier.addListener(_syncPlayback); + } + _syncPlayback(); + } + + bool get _visible { + final lifecycle = WidgetsBinding.instance.lifecycleState; + final appVisible = + lifecycle == null || lifecycle == AppLifecycleState.resumed; + return appVisible && (_tickerMode?.value.enabled ?? true); + } + + void _syncPlayback() { + final controller = _controller; + if (controller == null || !_ready) return; + if (_visible) { + controller.play(); + } else { + controller.pause(); + } + } + @override void didUpdateWidget(MotionHeaderBanner oldWidget) { super.didUpdateWidget(oldWidget); @@ -67,8 +101,8 @@ class _MotionHeaderBannerState extends State } await controller.setVolume(0); await controller.setLooping(true); - await controller.play(); setState(() => _ready = true); + _syncPlayback(); } catch (e) { _log.w('Failed to play motion banner: $e'); if (!mounted) return; @@ -84,18 +118,12 @@ class _MotionHeaderBannerState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - final controller = _controller; - if (controller == null || !_ready) return; - if (state == AppLifecycleState.resumed) { - controller.play(); - } else if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive) { - controller.pause(); - } + _syncPlayback(); } @override void dispose() { + _tickerMode?.removeListener(_syncPlayback); WidgetsBinding.instance.removeObserver(this); _disposeController(); super.dispose();