From da5a0522caddf8e56e1eebba0f01a21893c3b317 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:41:33 +0700 Subject: [PATCH] fix(lyrics): stagger highlight lift across graphemes --- lib/screens/now_playing_screen.dart | 106 ++++++++++--------- lib/utils/synced_lyrics_scroll.dart | 10 +- test/now_playing_lyrics_transition_test.dart | 47 +++++--- test/synced_lyrics_scroll_test.dart | 25 +++++ 4 files changed, 121 insertions(+), 67 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 3b46dc66..4cf54eb9 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -4080,21 +4080,29 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { var segmentOffset = 0; for (final segment in widget.segments) { final segmentEnd = segmentOffset + segment.length; - final boxes = + final boxes = []; + // Keep paragraph shaping/wrapping intact. Select whole graphemes so + // accents, surrogate pairs and joined emoji never lift in pieces. + final fragments = widget.liftEnabled ? segment.characters : [segment]; + var offset = segmentOffset; + for (final fragment in fragments) { + boxes.addAll( highlightedPainter .getBoxesForSelection( TextSelection( - baseOffset: segmentOffset, - extentOffset: segmentEnd, + baseOffset: offset, + extentOffset: offset + fragment.length, ), boxHeightStyle: BoxHeightStyle.max, ) - .map((box) => box.toRect()) - .toList() - ..sort((a, b) { - final row = a.top.compareTo(b.top); - return row == 0 ? a.left.compareTo(b.left) : row; - }); + .map((box) => box.toRect()), + ); + offset += fragment.length; + } + boxes.sort((a, b) { + final row = a.top.compareTo(b.top); + return row == 0 ? a.left.compareTo(b.left) : row; + }); segmentBoxes.add(boxes); segmentOffset = segmentEnd; } @@ -4147,48 +4155,47 @@ class _TimedLyricSweepPainter extends CustomPainter { void paint(Canvas canvas, Size size) { final pendingPaths = {}; final completedPaths = {}; - final partialBoxes = <(Rect, double, double)>[]; + final partialBoxes = <(Rect, double, double, double)>[]; final position = currentPosition(); for (var index = 0; index < segmentBoxes.length; index++) { - final lift = - highlightLift > 0 && index < starts.length && index < ends.length - ? highlightLift * - syncedLyricSegmentLift( - position: position, - start: starts[index], - end: ends[index], - ) - : 0.0; - if (highlightLift > 0) { - final path = pendingPaths.putIfAbsent(lift, Path.new); - for (final box in segmentBoxes[index]) { - path.addRect(box); - } - } - final value = index < starts.length && index < ends.length + final timed = index < starts.length && index < ends.length; + final value = timed ? syncedLyricSegmentProgress( position: position, start: starts[index], end: ends[index], ) : 0.0; - if (value > 0) { - final boxes = segmentBoxes[index]; - // Font fallback and wrapping can split one timed segment into several - // boxes. Consume its progress once, instead of lighting every box at - // the same time (which starts highlights in the middle of the text). - var revealWidth = - boxes.fold(0, (width, box) => width + box.width) * value; - for (final box in boxes) { - if (revealWidth <= 0) break; - if (box.width <= 0) continue; - if (revealWidth >= box.width) { - completedPaths.putIfAbsent(lift, Path.new).addRect(box); - } else { - partialBoxes.add((box, revealWidth / box.width, lift)); - } - revealWidth -= box.width; + final boxes = segmentBoxes[index]; + final width = boxes.fold(0, (sum, box) => sum + box.width); + var consumed = 0.0; + for (final box in boxes) { + if (box.width <= 0) continue; + final lift = highlightLift > 0 && timed + ? highlightLift * + syncedLyricSegmentLift( + position: position, + start: starts[index], + end: ends[index], + progressOffset: consumed / width, + ) + : 0.0; + if (highlightLift > 0) { + pendingPaths.putIfAbsent(lift, Path.new).addRect(box); } + // Consume the same word progress across graphemes, wrapping and font + // fallback. The sweep timing never restarts at a glyph boundary. + final revealWidth = width * value - consumed; + final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp( + 3.0, + 10.0, + ); + if (value > 0 && revealWidth >= box.width) { + completedPaths.putIfAbsent(lift, Path.new).addRect(box); + } else if (value > 0 && revealWidth > -feather) { + partialBoxes.add((box, revealWidth / box.width, lift, feather)); + } + consumed += box.width; } } @@ -4205,13 +4212,10 @@ class _TimedLyricSweepPainter extends CustomPainter { _paintLiftedText(canvas, highlightedPainter, entry.value, entry.key); } - for (final (box, value, lift) in partialBoxes) { - final boundary = syncedLyricsLeftToRightBoundary( - left: box.left, - right: box.right, - progress: value, - ); - final feather = (box.width * 0.18).clamp(3.0, 10.0); + for (final (box, value, lift, feather) in partialBoxes) { + // The feather can cross into the following grapheme before its solid + // fill arrives, avoiding a hard flash at each letter boundary. + final boundary = box.left + box.width * value; final revealRight = (boundary + feather).clamp(box.left, box.right); final revealRect = Rect.fromLTRB( box.left, @@ -4219,8 +4223,6 @@ class _TimedLyricSweepPainter extends CustomPainter { revealRight, box.bottom, ); - final gradientStart = boundary.clamp(box.left, revealRight - 0.01); - canvas.save(); canvas.translate(0, -lift); canvas.clipRect(revealRect); @@ -4232,7 +4234,7 @@ class _TimedLyricSweepPainter extends CustomPainter { LinearGradient( colors: const [Colors.white, Colors.transparent], ).createShader( - Rect.fromLTRB(gradientStart, box.top, revealRight, box.bottom), + Rect.fromLTRB(boundary, box.top, boundary + feather, box.bottom), ); canvas.drawRect(revealRect, mask); canvas.restore(); diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart index 10f81a60..c03631c2 100644 --- a/lib/utils/synced_lyrics_scroll.dart +++ b/lib/utils/synced_lyrics_scroll.dart @@ -89,6 +89,7 @@ double syncedLyricSegmentLift({ required Duration position, required Duration start, required Duration end, + double progressOffset = 0, }) { if (end <= start) return 0; final elapsed = (position - start).inMicroseconds / 1000; @@ -101,12 +102,17 @@ double syncedLyricSegmentLift({ // 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); + // Each grapheme begins as the sweep approaches its own position. Retain the + // full word's held-note envelope rather than treating letters as syllables. + final offset = duration * progressOffset.clamp(0.0, 1.0); + final normalLift = ease((elapsed - offset + 180) / 720); final heldStrength = ((duration - 1000) / 1500).clamp(0.0, 1.0); if (heldStrength == 0) return normalLift; 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; + final wordLift = ease((elapsed + 180) / 720); + final emphasis = wordLift > 0 ? normalLift / wordLift : 0; + return normalLift + 1.3 * heldStrength * rise * settle * emphasis; } /// Horizontal leading edge for a highlight that fills left to right. diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index bb6dd191..3605d17a 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2919,7 +2919,7 @@ void main() { for (final text in ['First second', 'Firsu secondu']) { final singingFirst = await pixelsAt(1100, text); final moves = mornye && text == 'First second'; - final firstEnded = await pixelsAt(moves ? 1600 : 1307, text); + final firstEnded = await pixelsAt(moves ? 1800 : 1307, text); expect(firstEnded, isNot(orderedEquals(singingFirst))); if (moves) { expect( @@ -2928,11 +2928,11 @@ void main() { reason: 'Movement trails the finished color sweep', ); } - expect(await pixelsAt(1800, text), orderedEquals(firstEnded)); + expect(await pixelsAt(1820, text), orderedEquals(firstEnded)); final singingLast = await pixelsAt(2150, text); - final lastEnded = await pixelsAt(moves ? 2600 : 2497, text); + final lastEnded = await pixelsAt(moves ? 3100 : 2497, text); expect(lastEnded, isNot(orderedEquals(singingLast))); - expect(await pixelsAt(2900, text), orderedEquals(lastEnded)); + expect(await pixelsAt(3400, text), orderedEquals(lastEnded)); expect(await pixelsAt(1100, text), orderedEquals(singingFirst)); } final originalPixels = await pixelsAt(1100, 'First second'); @@ -2994,7 +2994,9 @@ void main() { matching: find.byType(CustomPaint), ); - Future<(double, double)> paintedHeights() async { + Future<(double, double)> paintedHeights({ + bool firstWordLetters = false, + }) async { final painter = tester.widget(paintFinder).painter!; final size = tester.getSize(paintFinder); return (await tester.runAsync(() async { @@ -3025,10 +3027,18 @@ void main() { return (top + bottom) / 2; } - final result = ( - centerFor(0, (image.width * 0.4).floor()), - centerFor((image.width * 0.6).ceil(), image.width), - ); + final result = firstWordLetters + ? ( + centerFor(0, (image.width * 0.1).floor()), + centerFor( + (image.width * 0.31).ceil(), + (image.width * 0.39).floor(), + ), + ) + : ( + centerFor(0, (image.width * 0.4).floor()), + centerFor((image.width * 0.6).ceil(), image.width), + ); image.dispose(); picture.dispose(); return result; @@ -3048,25 +3058,36 @@ void main() { final pending = await seek(800); final bounds = tester.getRect(paintFinder); - final firstEnded = await seek(1600); + await seek(1300); + final letters = await paintedHeights(firstWordLetters: true); + if (reducedMotion) { + expect(letters.$1, letters.$2); + } else { + expect( + letters.$1, + lessThan(letters.$2), + reason: 'Letters in one timed word must not rise as a block', + ); + } + final firstEnded = await seek(1900); expect(firstEnded.$2, pending.$2); if (reducedMotion) { expect(firstEnded, pending); } else { expect(pending.$1 - firstEnded.$1, inInclusiveRange(1, 2.5)); } - final held = await seek(4500); + final held = await seek(5500); final settled = await seek(6000); expect(held.$1, firstEnded.$1); if (reducedMotion) { expect(held, pending); expect(settled, pending); } else { - expect(settled.$2 - held.$2, greaterThan(1)); + expect(settled.$2 - held.$2, greaterThan(0)); expect(pending.$2 - settled.$2, inInclusiveRange(1, 2.5)); } expect(tester.getRect(paintFinder), bounds); - expect(await seek(4500), held); + expect(await seek(5500), held); await tester.pump(const Duration(seconds: 1)); expect( await paintedHeights(), diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart index 2692ea0d..0b3d4560 100644 --- a/test/synced_lyrics_scroll_test.dart +++ b/test/synced_lyrics_scroll_test.dart @@ -99,6 +99,31 @@ void main() { expect(liftAt(1200, end: 500), 0); }); + test('graphemes rise in sweep order and converge after the word', () { + double glyph(int time, double offset) => syncedLyricSegmentLift( + position: Duration(milliseconds: time), + start: const Duration(seconds: 1), + end: const Duration(milliseconds: 1500), + progressOffset: offset, + ); + expect(glyph(1300, 0), greaterThan(glyph(1300, 0.33))); + expect(glyph(1300, 0.33), greaterThan(glyph(1300, 0.66))); + expect(glyph(1500, 0.75), lessThan(1)); + expect(glyph(2100, 0), 1); + expect(glyph(2100, 0.75), 1); + for (var time = 800; time < 2100; time += 16) { + final change = glyph(time + 16, 0.75) - glyph(time, 0.75); + expect(change, inInclusiveRange(0, 0.034)); + } + final held = syncedLyricSegmentLift( + position: const Duration(milliseconds: 5500), + start: const Duration(seconds: 3), + end: const Duration(seconds: 6), + progressOffset: 0.75, + ); + expect(held, greaterThan(1)); + }); + test('interpolates position only while playback is advancing', () { expect( interpolatedSyncedLyricsPosition(