From f639e4cc6b4762b1c5ba79e3729338dc615a5d41 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:03:09 +0700 Subject: [PATCH] fix(player): loop marquee forward with longer reading pauses --- lib/widgets/overflow_marquee.dart | 56 ++++++++++++++++++++----------- test/overflow_marquee_test.dart | 39 ++++++++++++++------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/lib/widgets/overflow_marquee.dart b/lib/widgets/overflow_marquee.dart index e990ba0c..36238d53 100644 --- a/lib/widgets/overflow_marquee.dart +++ b/lib/widgets/overflow_marquee.dart @@ -19,12 +19,17 @@ class OverflowMarquee extends StatefulWidget { 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; @@ -69,10 +74,18 @@ class _OverflowMarqueeState extends State if (!mounted || !_scroll.hasClients) return; // Also cancels an in-flight animation after a track/width change. _scroll.jumpTo(0); - if (_motionEnabled && - _appActive && - _scroll.position.maxScrollExtent > 0) { - _scheduleLeg(towardEnd: true, generation: _generation); + 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(); @@ -85,28 +98,22 @@ class _OverflowMarqueeState extends State _appActive && _scroll.hasClients; - void _scheduleLeg({required bool towardEnd, required int generation}) { - _pause = Timer( - Duration(milliseconds: towardEnd ? 1500 : 1200), - () => _scrollLeg(towardEnd: towardEnd, generation: generation), - ); + void _scheduleCycle(int generation) { + _pause = Timer(const Duration(seconds: 3), () => _scrollCycle(generation)); } - Future _scrollLeg({ - required bool towardEnd, - required int generation, - }) async { + Future _scrollCycle(int generation) async { if (!_canScroll(generation)) return; - final target = towardEnd ? _scroll.position.maxScrollExtent : 0.0; - final distance = (target - _scroll.offset).abs(); - if (distance < 0.5) return; await _scroll.animateTo( - target, - duration: Duration(milliseconds: (distance / 28 * 1000).round()), + _loopDistance, + duration: Duration(milliseconds: (_loopDistance / 28 * 1000).round()), curve: Curves.linear, ); if (_canScroll(generation)) { - _scheduleLeg(towardEnd: !towardEnd, generation: 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); } } @@ -138,7 +145,16 @@ class _OverflowMarqueeState extends State controller: _scroll, scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), - child: widget.child, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + KeyedSubtree(key: _contentKey, child: widget.child), + if (_looping) ...[ + const SizedBox(width: _gap), + ExcludeSemantics(child: IgnorePointer(child: widget.child)), + ], + ], + ), ), ), ); diff --git a/test/overflow_marquee_test.dart b/test/overflow_marquee_test.dart index f5dd1b1f..76bc3ea2 100644 --- a/test/overflow_marquee_test.dart +++ b/test/overflow_marquee_test.dart @@ -53,6 +53,7 @@ void main() { ), ); await tester.pump(); + await tester.pump(); } ScrollController controller(WidgetTester tester) => tester @@ -60,7 +61,7 @@ void main() { .controller!; Future startScrolling(WidgetTester tester) async { - await tester.pump(const Duration(milliseconds: 1500)); + await tester.pump(const Duration(seconds: 3)); await tester.pump(const Duration(seconds: 1)); } @@ -75,25 +76,37 @@ void main() { expect(tester.binding.transientCallbackCount, 0); }); - testWidgets('long titles pause, reveal the end, and return smoothly', ( + testWidgets('long titles loop forward without reversing or a visible jump', ( tester, ) async { await pumpTitle(tester, explicit: true); final scroll = controller(tester); - await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 2500)); expect(scroll.offset, 0); await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(seconds: 1)); expect(scroll.offset, closeTo(28, 1)); - await tester.pump(const Duration(minutes: 1)); - final end = scroll.position.maxScrollExtent; - expect(scroll.offset, end); - expect(find.byType(ExplicitBadge), findsOneWidget); + final titles = find.byType(ExplicitTrackTitle); + expect(titles, findsNWidgets(2)); + final distance = + tester.getTopLeft(titles.last).dx - tester.getTopLeft(titles.first).dx; + final travelMs = (distance / 28 * 1000).round(); + await tester.pump(Duration(milliseconds: travelMs - 1000 - 10)); + expect(scroll.offset, greaterThan(distance - 1)); + final nextCopyPosition = tester.getTopLeft(titles.last); + await tester.pump(const Duration(milliseconds: 11)); + await tester.pump(); + expect(scroll.offset, 0); + expect( + (tester.getTopLeft(titles.first) - nextCopyPosition).distance, + lessThan(0.5), + ); + expect(find.byType(ExplicitBadge), findsNWidgets(2)); + await tester.pump(const Duration(milliseconds: 2500)); + expect(scroll.offset, 0); + await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(seconds: 1)); - expect(scroll.offset, end); - await tester.pump(const Duration(milliseconds: 200)); - await tester.pump(const Duration(seconds: 1)); - expect(scroll.offset, closeTo(end - 28, 1)); + expect(scroll.offset, closeTo(28, 1)); await tester.pumpWidget(const SizedBox.shrink()); }); @@ -207,6 +220,8 @@ void main() { ); await tester.pump(); await tester.pump(); + await tester.pump(); + await tester.pump(); await startScrolling(tester); final scrollViews = tester.widgetList( find.descendant( @@ -218,7 +233,7 @@ void main() { for (final view in scrollViews) { expect(view.controller!.offset, greaterThan(0)); } - expect(find.byType(ExplicitBadge), findsOneWidget); + expect(find.byType(ExplicitBadge), findsNWidgets(2)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); },