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).
This commit is contained in:
zarzet
2026-07-14 09:09:52 +07:00
parent 23488a9c05
commit a11cff8b28
2 changed files with 44 additions and 10 deletions
+7 -1
View File
@@ -594,9 +594,15 @@ class _MainShellState extends ConsumerState<MainShell>
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) {
+37 -9
View File
@@ -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<MotionHeaderBanner>
VideoPlayerController? _controller;
bool _ready = false;
bool _failed = false;
ValueListenable<TickerModeData>? _tickerMode;
@override
void initState() {
@@ -35,6 +37,38 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
_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<MotionHeaderBanner>
}
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<MotionHeaderBanner>
@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();