import 'dart:io'; 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'; final _log = AppLogger('MotionHeaderBanner'); class MotionHeaderBanner extends StatefulWidget { final String videoUrl; final Widget fallback; final BoxFit fit; final Alignment alignment; final ValueChanged? onAspectRatioChanged; final VoidCallback? onError; final VideoPlayerController? controller; final Duration fadeDuration; const MotionHeaderBanner({ super.key, required this.videoUrl, required this.fallback, this.fit = BoxFit.cover, this.alignment = Alignment.topCenter, this.onAspectRatioChanged, this.onError, this.controller, this.fadeDuration = const Duration(milliseconds: 400), }); @override State createState() => _MotionHeaderBannerState(); } class _MotionHeaderBannerState extends State with WidgetsBindingObserver { VideoPlayerController? _controller; bool _ownsController = true; bool _ready = false; bool _failed = false; bool _headerVisible = true; ValueListenable? _tickerMode; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _initialize(); } @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. 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 && _headerVisible && (_tickerMode?.value.enabled ?? true); } void _syncPlayback() { final controller = _controller; if (controller == null || !_ready) return; final shouldPlay = _visible; if (controller.value.isPlaying == shouldPlay) return; if (shouldPlay) { controller.play(); } else { controller.pause(); } } @override void didUpdateWidget(MotionHeaderBanner oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.videoUrl != widget.videoUrl || oldWidget.controller != widget.controller) { _disposeController(); _ready = false; _failed = false; _initialize(); } } Future _initialize() async { final prepared = widget.controller; if (prepared != null && prepared.value.isInitialized) { _ownsController = false; _controller = prepared; _ready = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !identical(prepared, _controller)) return; widget.onAspectRatioChanged?.call(prepared.value.aspectRatio); _syncPlayback(); }); return; } _ownsController = true; final url = widget.videoUrl.trim(); if (url.isEmpty) { setState(() => _failed = true); return; } final uri = Uri.tryParse(url); final options = VideoPlayerOptions(mixWithOthers: true); final controller = uri?.scheme == 'file' || url.startsWith('/') ? VideoPlayerController.file( uri?.scheme == 'file' ? File.fromUri(uri!) : File(url), videoPlayerOptions: options, ) : VideoPlayerController.networkUrl( Uri.parse(url), formatHint: uri?.path.toLowerCase().endsWith('.m3u8') == true ? VideoFormat.hls : null, videoPlayerOptions: options, ); _controller = controller; try { await controller.initialize(); if (!mounted || !identical(controller, _controller)) return; await controller.setVolume(0); await controller.setLooping(true); if (!mounted || !identical(controller, _controller)) return; setState(() => _ready = true); widget.onAspectRatioChanged?.call(controller.value.aspectRatio); _syncPlayback(); } catch (e) { _log.w('Failed to play motion banner: $e'); if (!mounted || !identical(controller, _controller)) return; setState(() => _failed = true); widget.onError?.call(); } } void _disposeController() { final controller = _controller; _controller = null; if (_ownsController) { controller?.dispose(); } else { controller?.pause(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { _syncPlayback(); } @override void dispose() { _tickerMode?.removeListener(_syncPlayback); WidgetsBinding.instance.removeObserver(this); _disposeController(); super.dispose(); } @override Widget build(BuildContext context) { final controller = _controller; final showVideo = _ready && !_failed && controller != null; return Stack( fit: StackFit.expand, children: [ widget.fallback, AnimatedOpacity( opacity: showVideo ? 1.0 : 0.0, duration: widget.fadeDuration, child: showVideo ? FittedBox( fit: widget.fit, alignment: widget.alignment, clipBehavior: Clip.hardEdge, child: SizedBox( width: controller.value.size.width, height: controller.value.size.height, child: VideoPlayer(controller), ), ) : const SizedBox.shrink(), ), ], ); } }