mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(lyrics): add subtle lift to sustained word highlights
This commit is contained in:
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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user