From 60c3e4d857ebe54b68c1a6e0ea73f11b45f597d4 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:33:49 +0700 Subject: [PATCH] feat(lyrics): add staggered spring motion to visible rows --- lib/screens/now_playing_screen.dart | 60 ++++++++++++++++++++-- lib/widgets/lyric_scroll_motion.dart | 57 +++++++++++++++++++++ test/lyric_scroll_motion_test.dart | 75 ++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 lib/widgets/lyric_scroll_motion.dart create mode 100644 test/lyric_scroll_motion_test.dart diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 37e21598..3d6c23f2 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -6,7 +6,8 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/foundation.dart' show ValueListenable, listEquals; -import 'package:flutter/rendering.dart' show OverflowBoxFit, ScrollDirection; +import 'package:flutter/rendering.dart' + show OverflowBoxFit, RenderAbstractViewport, ScrollDirection; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; @@ -40,6 +41,7 @@ import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart'; import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; +import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; @@ -3094,7 +3096,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { } class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final ScrollController _scroll = ScrollController(); ProviderSubscription? _positionSubscription; ProviderSubscription? _playingSubscription; @@ -3127,6 +3129,32 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> List? _layoutRowVisibility; List? _targetLineExtents; bool _preserveRevealAnchor = false; + late final AnimationController _scrollMotion = AnimationController( + vsync: this, + duration: LyricScrollMotion.duration, + value: 1, + ); + double _scrollMotionDistance = 0; + int _scrollMotionFocus = 0; + + void _stopScrollMotion() { + _scrollMotion.stop(); + _scrollMotion.value = 1; + } + + void _startScrollMotion(int row, double distance) { + if (distance.abs() < 0.5 || + widget.seekPreview.value != null || + MediaQuery.disableAnimationsOf(context)) { + _stopScrollMotion(); + return; + } + setState(() { + _scrollMotionDistance = distance; + _scrollMotionFocus = row; + }); + _scrollMotion.forward(from: 0); + } void _revealTick() { if (mounted) setState(() {}); @@ -3212,6 +3240,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> } void _resetLineKeys() { + _stopScrollMotion(); _hasStarted = false; _lineExtents = null; _lineLayoutKey = null; @@ -3239,7 +3268,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> _positionSubscription = null; _playingSubscription = null; _loadingSubscription = null; - if (!widget.isActive) return; + if (!widget.isActive) { + _stopScrollMotion(); + return; + } _userScrollIdleTimer?.cancel(); _userScrolling = false; @@ -3400,6 +3432,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> _userScrollIdleTimer?.cancel(); _scroll.dispose(); _rowReveal.dispose(); + _scrollMotion.dispose(); super.dispose(); } @@ -3486,6 +3519,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> final duration = immediate || MediaQuery.disableAnimationsOf(context) ? Duration.zero : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); + if (duration == Duration.zero) _stopScrollMotion(); final extents = _targetLineExtents; if (context.isMornye && extents != null && row < extents.length) { final position = _scroll.position; @@ -3497,6 +3531,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> if (duration == Duration.zero) { _scroll.jumpTo(offset); } else { + _startScrollMotion(row, offset - position.pixels); await _scroll.animateTo( offset, duration: duration, @@ -3508,6 +3543,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> if (index < _lineKeys.length) { final lineContext = _lineKeys[index].currentContext; if (lineContext != null) { + final object = lineContext.findRenderObject(); + final viewport = object == null + ? null + : RenderAbstractViewport.maybeOf(object); + if (duration != Duration.zero && viewport != null) { + _startScrollMotion( + row, + viewport.getOffsetToReveal(object!, 0.5).offset - _scroll.offset, + ); + } await Scrollable.ensureVisible( lineContext, alignment: 0.5, @@ -3531,6 +3576,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> if (duration == Duration.zero) { _scroll.jumpTo(clamped.toDouble()); } else { + _startScrollMotion(row, clamped - position.pixels); await _scroll.animateTo( clamped.toDouble(), duration: duration, @@ -3602,6 +3648,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> (notification is UserScrollNotification && notification.direction != ScrollDirection.idle); if (dragging) { + _stopScrollMotion(); _userScrolling = true; _userScrollIdleTimer?.cancel(); } else if (notification is ScrollEndNotification && _userScrolling) { @@ -3880,6 +3927,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> ); } + content = LyricScrollMotion( + progress: _scrollMotion, + distance: _scrollMotionDistance, + rowsAfterFocus: row - _scrollMotionFocus, + enabled: !isActive, + child: content, + ); return _revealRow( index, reveal, diff --git a/lib/widgets/lyric_scroll_motion.dart b/lib/widgets/lyric_scroll_motion.dart new file mode 100644 index 00000000..1daa188c --- /dev/null +++ b/lib/widgets/lyric_scroll_motion.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +/// One shared clock adds a small, staggered spring to upcoming visible rows. +/// Only painting moves: lyric timings and list extents stay fixed. +class LyricScrollMotion extends StatelessWidget { + const LyricScrollMotion({ + super.key, + required this.progress, + required this.distance, + required this.rowsAfterFocus, + required this.child, + this.enabled = true, + }); + + final Animation progress; + final double distance; + final int rowsAfterFocus; + final Widget child; + final bool enabled; + + static const duration = Duration(milliseconds: 700); + static final _spring = SpringSimulation( + SpringDescription.withDampingRatio(mass: 1, stiffness: 180, ratio: 0.82), + 0, + 1, + 0, + ); + + @override + Widget build(BuildContext context) { + if (!enabled || + rowsAfterFocus <= 0 || + distance == 0 || + MediaQuery.disableAnimationsOf(context)) { + return child; + } + return AnimatedBuilder( + animation: progress, + child: child, + builder: (context, child) { + final t = progress.value * 0.7; + final delay = rowsAfterFocus.clamp(1, 6) * 0.035; + final scroll = Curves.easeOutCubic.transform((t / 0.38).clamp(0, 1)); + final following = t <= delay ? 0.0 : _spring.x(t - delay); + final offset = progress.value >= 1 + ? 0.0 + : distance.clamp(-96, 96) * (scroll - following); + return Transform.translate( + offset: Offset(0, offset), + transformHitTests: true, + child: child, + ); + }, + ); + } +} diff --git a/test/lyric_scroll_motion_test.dart b/test/lyric_scroll_motion_test.dart new file mode 100644 index 00000000..705accb9 --- /dev/null +++ b/test/lyric_scroll_motion_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart'; + +void main() { + testWidgets( + 'later lyric rows trail earlier rows and settle to their layout', + (tester) async { + final clock = AnimationController( + vsync: tester, + duration: LyricScrollMotion.duration, + ); + addTearDown(clock.dispose); + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + for (final row in [1, 3]) + LyricScrollMotion( + progress: clock, + distance: 64, + rowsAfterFocus: row, + child: Text('Row $row'), + ), + ], + ), + ), + ); + final first = tester.getTopLeft(find.text('Row 1')).dy; + final third = tester.getTopLeft(find.text('Row 3')).dy; + clock.forward(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + final firstLag = tester.getTopLeft(find.text('Row 1')).dy - first; + final thirdLag = tester.getTopLeft(find.text('Row 3')).dy - third; + expect(thirdLag, greaterThan(firstLag)); + expect(thirdLag, greaterThan(0)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Row 1')).dy, first); + expect(tester.getTopLeft(find.text('Row 3')).dy, third); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('reduced motion and the focused lyric never acquire visual lag', ( + tester, + ) async { + for (final reduced in [false, true]) { + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Column( + children: [ + for (final row in [0, 3]) + LyricScrollMotion( + progress: const AlwaysStoppedAnimation(0.3), + distance: 64, + rowsAfterFocus: row, + child: Text('Row $row'), + ), + ], + ), + ), + ), + ); + final first = find.descendant( + of: find.byType(LyricScrollMotion).first, + matching: find.byType(Transform), + ); + expect(first, findsNothing); + if (reduced) expect(find.byType(Transform), findsNothing); + } + }); +}