fix(lyrics): align pronunciation phrases and improve readability

This commit is contained in:
zarzet
2026-09-25 18:34:35 +07:00
parent 2ab4aaa8dc
commit 6598a360b5
4 changed files with 496 additions and 34 deletions
+140
View File
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
const _primaryStyle = TextStyle(fontSize: 34, height: 1.3);
const _pronunciationStyle = TextStyle(fontSize: 22, height: 1.35);
LyricWord _word(String text, int start, [int? end]) => LyricWord(
text: text,
time: Duration(milliseconds: start),
end: end == null ? null : Duration(milliseconds: end),
);
LyricLine _line({int pronunciationOffset = 0}) => LyricLine(
time: Duration.zero,
text: '今日も歌う',
words: [
_word('今', 0),
_word('日', 200),
_word('も', 500),
_word('歌う', 800, 1500),
],
romanization: 'kyou mo utau',
romanizationWords: [
_word('kyo', pronunciationOffset, 200),
_word('u ', 200, 400),
_word('mo ', 500, 750),
_word('utau', 800, 1500),
],
);
LyricPronunciationLayout? _layout(
LyricLine line, {
double width = 320,
double scale = 1,
}) => LyricPronunciationLayout.measure(
line: line,
primaryStyle: _primaryStyle,
pronunciationStyle: _pronunciationStyle,
maxWidth: width,
textScaler: TextScaler.linear(scale),
textDirection: TextDirection.ltr,
);
void main() {
test('does not guess alignment without matching word timing', () {
expect(_layout(_line(pronunciationOffset: 100)), isNull);
expect(
_layout(
const LyricLine(
time: Duration.zero,
text: 'Original',
romanization: 'Pronunciation',
),
),
isNull,
);
});
for (final width in [320.0, 170.0]) {
for (final scale in [1.0, 1.8]) {
testWidgets(
'phrases stay paired when wrapping and hiding ($width, $scale)',
(tester) async {
final layout = _layout(_line(), width: width, scale: scale)!;
final renderedWords = <String, List<LyricWord>>{};
Future<void> pump(double visibility) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width,
child: AlignedLyricPronunciation(
layout: layout,
visibility: visibility,
primaryStyle: _primaryStyle,
pronunciationStyle: _pronunciationStyle,
textBuilder: (text, words, style) {
renderedWords[text] = words;
return Text(text, style: style);
},
),
),
),
),
),
);
await pump(1);
final block = find.byType(AlignedLyricPronunciation);
final bounds = tester.getRect(block);
expect(
bounds.height,
closeTo(layout.primaryHeight + layout.pronunciationHeight, 0.01),
);
final primaryPositions = <Offset>[];
for (final (original, pronunciation) in [
('今日', 'kyou'),
('も', 'mo'),
('歌う', 'utau'),
]) {
final primary = tester.getRect(find.text(original));
final secondary = tester.getRect(find.text(pronunciation));
primaryPositions.add(primary.topLeft);
expect(primary.left, closeTo(secondary.left, 0.01));
expect(secondary.top, greaterThanOrEqualTo(primary.bottom));
expect(secondary.right, lessThanOrEqualTo(bounds.right + 0.01));
}
// Syllables stay together, and the end of a sliced phrase still
// points to the next original word, not the end of the whole line.
expect(renderedWords['kyou']!.map((word) => word.text), ['kyo', 'u']);
expect(renderedWords['今日']!.last.end!.inMilliseconds, 500);
expect(renderedWords['kyou']!.last.end!.inMilliseconds, 400);
expect(renderedWords['utau']!.single.end!.inMilliseconds, 1500);
final firstPosition = primaryPositions.first;
for (final visibility in [0.5, 0.0]) {
await pump(visibility);
expect(
tester.getSize(block).height,
closeTo(
layout.primaryHeight + layout.pronunciationHeight * visibility,
0.01,
),
);
expect(tester.getTopLeft(find.text('今日')), firstPosition);
expect(
tester.getTopLeft(find.text('も')).dx,
primaryPositions[1].dx,
);
expect(tester.takeException(), isNull);
}
expect(find.text('kyou'), findsNothing);
},
);
}
}
}
+42 -25
View File
@@ -2025,6 +2025,13 @@ void main() {
for (final text in ['Original text', 'Romanized text', 'English text']) {
expect(find.text(text), findsOneWidget);
}
if (mornye) {
final pronunciation = tester.widget<Text>(find.text('Romanized text'));
final translation = tester.widget<Text>(find.text('English text'));
expect(pronunciation.style?.fontSize, 22);
expect(pronunciation.style?.fontWeight, FontWeight.bold);
expect(translation.style?.fontSize, 18);
}
expect(
tester.getTopLeft(find.text('Romanized text')).dy,
greaterThan(tester.getBottomLeft(find.text('Original text')).dy),
@@ -2166,32 +2173,42 @@ void main() {
),
);
await tester.pumpAndSettle();
final paint = find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == text,
),
matching: find.byType(CustomPaint),
);
expect(paint, findsOneWidget, reason: '$text at $milliseconds ms');
final painter = tester.widget<CustomPaint>(paint).painter!;
final size = tester.getSize(paint);
return (await tester.runAsync(() async {
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), size);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.ceil(),
size.height.ceil(),
final pixels = <int>[];
for (final phrase in mornye ? text.split(' ') : [text]) {
final paint = find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == phrase,
),
matching: find.byType(CustomPaint),
);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final pixels = bytes.buffer.asUint8List().toList();
image.dispose();
picture.dispose();
return pixels;
}))!;
expect(
paint,
findsOneWidget,
reason: '$phrase at $milliseconds ms',
);
final painter = tester.widget<CustomPaint>(paint).painter!;
final size = tester.getSize(paint);
pixels.addAll(
(await tester.runAsync(() async {
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), size);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.ceil(),
size.height.ceil(),
);
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final result = bytes.buffer.asUint8List().toList();
image.dispose();
picture.dispose();
return result;
}))!,
);
}
return pixels;
}
for (final text in ['First second', 'Firsu secondu']) {