From 863d1536ddf121817421b496408209816a469df0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:05:34 +0700 Subject: [PATCH] fix(lyrics): smooth highlight lift and keep romanization still Ease the lift before each highlight and let it settle more gradually for held words. Keep timed pronunciation and translation lines stationary while preserving their color sweep. --- lib/screens/now_playing_screen.dart | 17 +++++++++++++---- lib/utils/synced_lyrics_scroll.dart | 12 ++++++------ lib/widgets/aligned_lyric_pronunciation.dart | 5 ++++- test/now_playing_lyrics_transition_test.dart | 12 ++++++++++-- test/synced_lyrics_scroll_test.dart | 19 ++++++++++++++++--- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 8e627cf5..1d21c015 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3501,6 +3501,7 @@ Widget _withLyricSupplements( Color color, { required Offset visibility, Widget Function(String, List, TextStyle)? timedText, + Widget Function(String, List, TextStyle)? timedSupplementText, }) { if (line.romanization == null && line.translation == null) return primary; final supplements = _lyricSupplements(context, line).toList(); @@ -3523,8 +3524,8 @@ Widget _withLyricSupplements( opacity: translation ? visibility.dy : visibility.dx, child: Padding( padding: const EdgeInsets.only(top: 6), - child: words.isNotEmpty && timedText != null - ? timedText(text, words, style) + child: words.isNotEmpty && timedSupplementText != null + ? timedSupplementText(text, words, style) : Text( text, textAlign: context.isMornye @@ -3567,6 +3568,7 @@ Widget _withLyricSupplements( visibility: visibility.dx, primaryStyle: primaryStyle, pronunciationStyle: pronunciationStyle, + pronunciationBuilder: timedSupplementText, textBuilder: timedText ?? (text, words, style) => @@ -3781,11 +3783,18 @@ class _WordHighlightedLyricLineState primary, widget.colorScheme.onSurface, timedText: _buildTimedText, + timedSupplementText: (text, words, style) => + _buildTimedText(text, words, style, lift: false), visibility: widget.supplementVisibility, ); } - Widget _buildTimedText(String text, List words, TextStyle style) { + Widget _buildTimedText( + String text, + List words, + TextStyle style, { + bool lift = true, + }) { final highlightedColor = widget.colorScheme.onSurface; final mornye = context.isMornye; final pendingColor = mornye @@ -3812,7 +3821,7 @@ class _WordHighlightedLyricLineState pendingColor: pendingColor, highlightedColor: highlightedColor, semanticsLabel: text, - liftEnabled: mornye && !MediaQuery.disableAnimationsOf(context), + liftEnabled: lift && mornye && !MediaQuery.disableAnimationsOf(context), ); } } diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart index c183f626..10f81a60 100644 --- a/lib/utils/synced_lyrics_scroll.dart +++ b/lib/utils/synced_lyrics_scroll.dart @@ -98,14 +98,14 @@ double syncedLyricSegmentLift({ return t * t * (3 - 2 * t); } - // Anticipate the highlight slightly without moving the list's layout. - final normalLift = ease( - (elapsed + 100) / (100 + math.min(160, duration * 0.35)), - ); + // Start gently before the color sweep and let the movement trail it. Keep + // this envelope independent of word length: fast syllables must not snap + // upwards just because their highlight finishes in a few frames. + final normalLift = ease((elapsed + 180) / 720); final heldStrength = ((duration - 1000) / 1500).clamp(0.0, 1.0); if (heldStrength == 0) return normalLift; - final rise = ease((elapsed - 200) / math.min(900, (duration - 200) * 0.5)); - final settle = ease((duration - elapsed) / 300); + final rise = ease((elapsed - 400) / math.min(1200, (duration - 400) * 0.6)); + final settle = ease((duration - elapsed) / 600); return normalLift + 1.3 * heldStrength * rise * settle; } diff --git a/lib/widgets/aligned_lyric_pronunciation.dart b/lib/widgets/aligned_lyric_pronunciation.dart index e2f4d6b2..ec21129d 100644 --- a/lib/widgets/aligned_lyric_pronunciation.dart +++ b/lib/widgets/aligned_lyric_pronunciation.dart @@ -172,6 +172,8 @@ class AlignedLyricPronunciation extends StatelessWidget { final TextStyle primaryStyle; final TextStyle pronunciationStyle; final Widget Function(String, List, TextStyle) textBuilder; + final Widget Function(String, List, TextStyle)? + pronunciationBuilder; const AlignedLyricPronunciation({ super.key, @@ -180,6 +182,7 @@ class AlignedLyricPronunciation extends StatelessWidget { required this.primaryStyle, required this.pronunciationStyle, required this.textBuilder, + this.pronunciationBuilder, }); @override @@ -231,7 +234,7 @@ class AlignedLyricPronunciation extends StatelessWidget { ), child: SizedBox( height: row.pronunciationHeight, - child: textBuilder( + child: (pronunciationBuilder ?? textBuilder)( group.pronunciation.map((word) => word.text).join(), group.pronunciation, pronunciationStyle, diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 2c6868e5..9275320d 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2572,11 +2572,19 @@ void main() { for (final text in ['First second', 'Firsu secondu']) { final singingFirst = await pixelsAt(1100, text); - final firstEnded = await pixelsAt(1307, text); + final moves = mornye && text == 'First second'; + final firstEnded = await pixelsAt(moves ? 1600 : 1307, text); expect(firstEnded, isNot(orderedEquals(singingFirst))); + if (moves) { + expect( + await pixelsAt(1307, text), + isNot(orderedEquals(firstEnded)), + reason: 'Movement trails the finished color sweep', + ); + } expect(await pixelsAt(1800, text), orderedEquals(firstEnded)); final singingLast = await pixelsAt(2150, text); - final lastEnded = await pixelsAt(2497, text); + final lastEnded = await pixelsAt(moves ? 2600 : 2497, text); expect(lastEnded, isNot(orderedEquals(singingLast))); expect(await pixelsAt(2900, text), orderedEquals(lastEnded)); expect(await pixelsAt(1100, text), orderedEquals(singingFirst)); diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart index a9ea1835..2692ea0d 100644 --- a/test/synced_lyrics_scroll_test.dart +++ b/test/synced_lyrics_scroll_test.dart @@ -65,14 +65,16 @@ void main() { expect(liftAt(800), 0); expect(liftAt(950), inExclusiveRange(0, 1)); expect(liftAt(1000), greaterThan(liftAt(950))); - expect(liftAt(1200), 1); - expect(liftAt(1500), 1); + expect(liftAt(1000), lessThan(0.2)); + expect(liftAt(1200), inExclusiveRange(0.4, 0.7)); + expect(liftAt(1500), lessThan(1)); + expect(liftAt(1540), 1); expect(liftAt(3000), 1); }); test('held words rise further then settle to the normal highlight', () { final peak = liftAt(2300, end: 4000); - expect(liftAt(1200, end: 4000), 1); + expect(liftAt(1200, end: 4000), inExclusiveRange(0.4, 0.7)); expect(peak, greaterThan(1)); expect(liftAt(3900, end: 4000), inExclusiveRange(1, peak)); expect(liftAt(4000, end: 4000), 1); @@ -81,6 +83,17 @@ void main() { expect(liftAt(800, end: 4000), 0); }); + test('short syllables keep the slow rise and trail their color sweep', () { + expect(liftAt(1100, end: 1100), lessThan(0.4)); + expect(liftAt(1200, end: 1100), lessThan(0.7)); + expect(liftAt(1540, end: 1100), 1); + for (var position = 820; position < 1540; position += 16) { + final before = liftAt(position, end: 1100); + final after = liftAt(position + 16, end: 1100); + expect(after - before, inInclusiveRange(0, 0.034)); + } + }); + test('invalid or zero-length timing does not move the text', () { expect(liftAt(1200, end: 1000), 0); expect(liftAt(1200, end: 500), 0);