import 'dart:async'; import 'package:flutter/material.dart'; /// Reveals overflowing content without making the mini player's text draggable. class OverflowMarquee extends StatefulWidget { const OverflowMarquee({ super.key, required this.resetKey, required this.child, }); final Object resetKey; final Widget child; @override State createState() => _OverflowMarqueeState(); } class _OverflowMarqueeState extends State with WidgetsBindingObserver { static const _gap = 32.0; final _scroll = ScrollController(); final _contentKey = GlobalKey(); Timer? _pause; int _generation = 0; bool _restartQueued = false; bool _motionEnabled = false; bool _appActive = true; bool _looping = false; double _loopDistance = 0; double? _viewport; double? _extent; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); final lifecycle = WidgetsBinding.instance.lifecycleState; _appActive = lifecycle == null || lifecycle == AppLifecycleState.resumed; } @override void didChangeDependencies() { super.didChangeDependencies(); _motionEnabled = !MediaQuery.disableAnimationsOf(context) && TickerMode.valuesOf(context).enabled; _queueRestart(); } @override void didUpdateWidget(OverflowMarquee oldWidget) { super.didUpdateWidget(oldWidget); if (widget.resetKey != oldWidget.resetKey) { _queueRestart(); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { _appActive = state == AppLifecycleState.resumed; _queueRestart(); } void _queueRestart() { _pause?.cancel(); _generation++; if (_restartQueued) return; _restartQueued = true; WidgetsBinding.instance.addPostFrameCallback((_) { _restartQueued = false; if (!mounted || !_scroll.hasClients) return; // Also cancels an in-flight animation after a track/width change. _scroll.jumpTo(0); final content = _contentKey.currentContext?.findRenderObject(); if (content is! RenderBox || !content.hasSize) return; final overflowing = content.size.width > _scroll.position.viewportDimension + 0.5; _loopDistance = content.size.width + _gap; if (_looping != overflowing) { setState(() => _looping = overflowing); _queueRestart(); return; } if (_motionEnabled && _appActive && _looping) { _scheduleCycle(_generation); } }); WidgetsBinding.instance.ensureVisualUpdate(); } bool _canScroll(int generation) => mounted && generation == _generation && _motionEnabled && _appActive && _scroll.hasClients; void _scheduleCycle(int generation) { _pause = Timer(const Duration(seconds: 3), () => _scrollCycle(generation)); } Future _scrollCycle(int generation) async { if (!_canScroll(generation)) return; await _scroll.animateTo( _loopDistance, duration: Duration(milliseconds: (_loopDistance / 28 * 1000).round()), curve: Curves.linear, ); if (_canScroll(generation)) { // The next copy is now exactly where the first started, so resetting // the offset changes neither the visible text nor its direction. _scroll.jumpTo(0); _scheduleCycle(generation); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _pause?.cancel(); _scroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (!_motionEnabled) return widget.child; return NotificationListener( onNotification: (notification) { final metrics = notification.metrics; if (_viewport != metrics.viewportDimension || _extent != metrics.maxScrollExtent) { _viewport = metrics.viewportDimension; _extent = metrics.maxScrollExtent; _queueRestart(); } return false; }, child: RepaintBoundary( child: SingleChildScrollView( controller: _scroll, scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), child: Row( mainAxisSize: MainAxisSize.min, children: [ KeyedSubtree(key: _contentKey, child: widget.child), if (_looping) ...[ const SizedBox(width: _gap), ExcludeSemantics(child: IgnorePointer(child: widget.child)), ], ], ), ), ), ); } }