feat(lyrics): add staggered spring motion to visible rows

This commit is contained in:
zarzet committed 2026-10-04 03:33:49 +07:00
1 parent dad84eb342
commit 60c3e4d857
3 files changed
+189 -3

No files matched your search

+57 -3
View File
@@ -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<Duration>? _positionSubscription;
ProviderSubscription<bool>? _playingSubscription;
@@ -3127,6 +3129,32 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
List<double>? _layoutRowVisibility;
List<double>? _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,
+57
View File
@@ -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<double> 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,
);
},
);
}
}
+75
View File
@@ -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);
}
});
}