feat(lyrics): add subtle lift to sustained word highlights

This commit is contained in:
zarzet committed 2026-09-25 19:31:09 +07:00
1 parent 6598a360b5
commit 4aad7f03c1
4 files changed
+232 -12

No files matched your search

@@ -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<PlaybackState>.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<CustomPaint>(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 {
+31
View File
@@ -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(