From 4aad7f03c1b1fa9f93164f44ec33665aea9bff50 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:30:17 +0700 Subject: [PATCH] feat(lyrics): add subtle lift to sustained word highlights --- lib/screens/now_playing_screen.dart | 68 +++++++++-- lib/utils/synced_lyrics_scroll.dart | 27 +++++ test/now_playing_lyrics_transition_test.dart | 118 +++++++++++++++++++ test/synced_lyrics_scroll_test.dart | 31 +++++ 4 files changed, 232 insertions(+), 12 deletions(-) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 5ab7982e..3244eb72 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3687,6 +3687,7 @@ class _WordHighlightedLyricLineState pendingColor: pendingColor, highlightedColor: highlightedColor, semanticsLabel: text, + liftEnabled: mornye && !MediaQuery.disableAnimationsOf(context), ); } } @@ -3702,6 +3703,7 @@ class _SweepingTimedLyricText extends StatefulWidget { final Color pendingColor; final Color highlightedColor; final String semanticsLabel; + final bool liftEnabled; const _SweepingTimedLyricText({ required this.segments, @@ -3714,6 +3716,7 @@ class _SweepingTimedLyricText extends StatefulWidget { required this.pendingColor, required this.highlightedColor, required this.semanticsLabel, + required this.liftEnabled, }); @override @@ -3851,6 +3854,10 @@ class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { repaint: widget.repaint, pendingPainter: pendingPainter, highlightedPainter: highlightedPainter, + highlightLift: widget.liftEnabled + ? (textScaler.scale(widget.style.fontSize ?? 24) * 0.05) + .clamp(0.0, 2.0) + : 0, ), ), ); @@ -3866,6 +3873,7 @@ class _TimedLyricSweepPainter extends CustomPainter { final Duration Function() currentPosition; final TextPainter pendingPainter; final TextPainter highlightedPainter; + final double highlightLift; _TimedLyricSweepPainter({ required this.segmentBoxes, @@ -3875,16 +3883,31 @@ class _TimedLyricSweepPainter extends CustomPainter { required Listenable repaint, required this.pendingPainter, required this.highlightedPainter, + required this.highlightLift, }) : super(repaint: repaint); @override void paint(Canvas canvas, Size size) { - pendingPainter.paint(canvas, Offset.zero); - - final completedPath = Path(); - final partialBoxes = <(Rect, double)>[]; + final pendingPaths = {}; + final completedPaths = {}; + final partialBoxes = <(Rect, 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 ? syncedLyricSegmentProgress( position: position, @@ -3903,23 +3926,29 @@ class _TimedLyricSweepPainter extends CustomPainter { if (revealWidth <= 0) break; if (box.width <= 0) continue; if (revealWidth >= box.width) { - completedPath.addRect(box); + completedPaths.putIfAbsent(lift, Path.new).addRect(box); } else { - partialBoxes.add((box, revealWidth / box.width)); + partialBoxes.add((box, revealWidth / box.width, lift)); } revealWidth -= box.width; } } } - if (!completedPath.getBounds().isEmpty) { - canvas.save(); - canvas.clipPath(completedPath); - highlightedPainter.paint(canvas, Offset.zero); - canvas.restore(); + // Move both colors together, so a raised highlight never leaves a dim + // duplicate behind. Most words share the resting or completed position. + if (highlightLift > 0) { + for (final entry in pendingPaths.entries) { + _paintLiftedText(canvas, pendingPainter, entry.value, entry.key); + } + } else { + pendingPainter.paint(canvas, Offset.zero); + } + for (final entry in completedPaths.entries) { + _paintLiftedText(canvas, highlightedPainter, entry.value, entry.key); } - for (final (box, value) in partialBoxes) { + for (final (box, value, lift) in partialBoxes) { final boundary = syncedLyricsLeftToRightBoundary( left: box.left, right: box.right, @@ -3936,6 +3965,7 @@ class _TimedLyricSweepPainter extends CustomPainter { final gradientStart = boundary.clamp(box.left, revealRight - 0.01); canvas.save(); + canvas.translate(0, -lift); canvas.clipRect(revealRect); canvas.saveLayer(revealRect, Paint()); highlightedPainter.paint(canvas, Offset.zero); @@ -3953,12 +3983,26 @@ class _TimedLyricSweepPainter extends CustomPainter { } } + void _paintLiftedText( + Canvas canvas, + TextPainter painter, + Path clip, + double lift, + ) { + canvas.save(); + canvas.translate(0, -lift); + canvas.clipPath(clip, doAntiAlias: false); + painter.paint(canvas, Offset.zero); + canvas.restore(); + } + @override bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) { return oldDelegate.segmentBoxes != segmentBoxes || oldDelegate.starts != starts || oldDelegate.ends != ends || oldDelegate.currentPosition != currentPosition || + oldDelegate.highlightLift != highlightLift || oldDelegate.pendingPainter != pendingPainter || oldDelegate.highlightedPainter != highlightedPainter; } diff --git a/lib/utils/synced_lyrics_scroll.dart b/lib/utils/synced_lyrics_scroll.dart index 852f9a50..c183f626 100644 --- a/lib/utils/synced_lyrics_scroll.dart +++ b/lib/utils/synced_lyrics_scroll.dart @@ -82,6 +82,33 @@ double syncedLyricSegmentProgress({ return (position - start).inMicroseconds / (end - start).inMicroseconds; } +/// Subtle vertical emphasis relative to the normal highlighted position. +/// Long timed segments rise further while held, then settle back to 1. This +/// follows lyric duration, not pitch, and is deterministic on pause or seek. +double syncedLyricSegmentLift({ + required Duration position, + required Duration start, + required Duration end, +}) { + if (end <= start) return 0; + final elapsed = (position - start).inMicroseconds / 1000; + final duration = (end - start).inMicroseconds / 1000; + double ease(double value) { + final t = value.clamp(0.0, 1.0); + 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)), + ); + 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); + return normalLift + 1.3 * heldStrength * rise * settle; +} + /// Horizontal leading edge for a highlight that fills left to right. double syncedLyricsLeftToRightBoundary({ required double left, diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 471d7fb9..b06192b2 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2248,6 +2248,124 @@ void main() { ); } + for (final reducedMotion in [false, true]) { + testWidgets( + 'timed words lift and settle without moving layout (reduced motion: $reducedMotion)', + (tester) async { + const text = 'AAAA BBBB'; + metadataOverrides['lyrics'] = + '[00:00.500]<00:01.000>AAAA <00:01.500>' + '<00:03.000>BBBB<00:06.000>'; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(390, 844), + playbackEvents: playback.stream, + wrapPlayer: (player) => Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: player, + ), + ), + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + final paintFinder = find.descendant( + of: find.bySemanticsLabel(text), + matching: find.byType(CustomPaint), + ); + + Future<(double, double)> paintedHeights() async { + final painter = tester.widget(paintFinder).painter!; + final size = tester.getSize(paintFinder); + return (await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder)..translate(0, 8); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.ceil(), + size.height.ceil() + 16, + ); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + double centerFor(int from, int to) { + var top = image.height; + var bottom = -1; + for (var y = 0; y < image.height; y++) { + for (var x = from; x < to; x++) { + if (bytes.getUint8((y * image.width + x) * 4 + 3) < 16) { + continue; + } + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + expect(bottom, greaterThan(top)); + return (top + bottom) / 2; + } + + final result = ( + centerFor(0, (image.width * 0.4).floor()), + centerFor((image.width * 0.6).ceil(), image.width), + ); + image.dispose(); + picture.dispose(); + return result; + }))!; + } + + Future<(double, double)> seek(int milliseconds) async { + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: Duration(milliseconds: milliseconds), + ), + ); + await tester.pumpAndSettle(); + return paintedHeights(); + } + + final pending = await seek(800); + final bounds = tester.getRect(paintFinder); + final firstEnded = await seek(1600); + 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 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(pending.$2 - settled.$2, inInclusiveRange(1, 2.5)); + } + expect(tester.getRect(paintFinder), bounds); + expect(await seek(4500), held); + await tester.pump(const Duration(seconds: 1)); + expect( + await paintedHeights(), + held, + reason: 'Paused words must stay still', + ); + expect(await seek(800), pending); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('timed lyric fills text fragments in reading order', ( tester, ) async { diff --git a/test/synced_lyrics_scroll_test.dart b/test/synced_lyrics_scroll_test.dart index 4fa3aa08..a9ea1835 100644 --- a/test/synced_lyrics_scroll_test.dart +++ b/test/synced_lyrics_scroll_test.dart @@ -55,6 +55,37 @@ void main() { }); group('smooth timed lyric highlight', () { + double liftAt(int position, {int end = 1500}) => syncedLyricSegmentLift( + position: Duration(milliseconds: position), + start: const Duration(seconds: 1), + end: Duration(milliseconds: end), + ); + + test('short words anticipate gently and remain raised after singing', () { + 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(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(peak, greaterThan(1)); + expect(liftAt(3900, end: 4000), inExclusiveRange(1, peak)); + expect(liftAt(4000, end: 4000), 1); + expect(liftAt(4200, end: 4000), 1); + expect(liftAt(2300, end: 4000), peak); + expect(liftAt(800, end: 4000), 0); + }); + + test('invalid or zero-length timing does not move the text', () { + expect(liftAt(1200, end: 1000), 0); + expect(liftAt(1200, end: 500), 0); + }); + test('interpolates position only while playback is advancing', () { expect( interpolatedSyncedLyricsPosition(