From a11cff8b2834e6191373e6a4243e2187a7e97930 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Jul 2026 21:06:05 +0700 Subject: [PATCH] fix(player): pause motion header video when its screen is hidden The banner only reacted to app lifecycle, so the HEVC decoder and HLS stream kept running behind covered routes and inactive tabs. It now follows TickerMode: the Navigator already disables it for covered routes, and the shell disables it for non-current tabs (which also mutes stray animations on kept-alive pages). --- lib/screens/main_shell.dart | 8 ++++- lib/widgets/motion_header_banner.dart | 46 +++++++++++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) 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();