diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 4ba80f3b..acaf739e 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -4390,25 +4390,18 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { var segmentOffset = 0; for (final segment in widget.segments) { final segmentEnd = segmentOffset + segment.length; - 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: offset, - extentOffset: offset + fragment.length, - ), - boxHeightStyle: BoxHeightStyle.max, - ) - .map((box) => box.toRect()), - ); - offset += fragment.length; - } + // Select the whole timed word, keeping paragraph shaping and wrapping + // intact. All of its text runs share the same vertical movement. + final boxes = highlightedPainter + .getBoxesForSelection( + TextSelection( + baseOffset: segmentOffset, + extentOffset: segmentEnd, + ), + boxHeightStyle: BoxHeightStyle.max, + ) + .map((box) => box.toRect()) + .toList(); boxes.sort((a, b) { final row = a.top.compareTo(b.top); return row == 0 ? a.left.compareTo(b.left) : row; @@ -4478,23 +4471,22 @@ class _TimedLyricSweepPainter extends CustomPainter { : 0.0; final boxes = segmentBoxes[index]; final width = boxes.fold(0, (sum, box) => sum + box.width); + final lift = highlightLift > 0 && timed + ? highlightLift * + syncedLyricSegmentLift( + position: position, + start: starts[index], + end: ends[index], + ) + : 0.0; 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. + // Keep the color sweep continuous across wrapping and font fallback, + // independently of the movement shared by the whole word. final revealWidth = width * value - consumed; final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp( 3.0, @@ -4523,8 +4515,8 @@ class _TimedLyricSweepPainter extends CustomPainter { } 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. + // Feather the leading edge so the highlight flows through each letter + // while the word rises as a single unit. final boundary = box.left + box.width * value; final revealRight = (boundary + feather).clamp(box.left, box.right); final revealRect = Rect.fromLTRB( diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart index 540677c5..87f3dc45 100644 --- a/lib/utils/synced_lyrics_scroll.dart +++ b/lib/utils/synced_lyrics_scroll.dart @@ -89,7 +89,6 @@ 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; @@ -102,15 +101,11 @@ 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. - // 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); + // The whole timed word shares this movement, independent of the color sweep. + 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 - 400) / math.min(1200, (duration - 400) * 0.6)); final settle = ease((duration - elapsed) / 600); - final wordLift = ease((elapsed + 180) / 720); - final emphasis = wordLift > 0 ? normalLift / wordLift : 0; - return normalLift + 1.3 * heldStrength * rise * settle * emphasis; + return normalLift + 1.3 * heldStrength * rise * settle; } diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index c12ba199..480d4e4c 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -3551,15 +3551,11 @@ void main() { final bounds = tester.getRect(paintFinder); 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', - ); - } + expect( + letters.$1, + letters.$2, + reason: 'Letters in one timed word must rise together', + ); final firstEnded = await seek(1900); expect(firstEnded.$2, pending.$2); if (reducedMotion) { diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart index 801ec88d..f6fa1e1a 100644 --- a/test/synced_lyrics_scroll_test.dart +++ b/test/synced_lyrics_scroll_test.dart @@ -99,31 +99,6 @@ 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(