mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
fix(lyrics): align pronunciation phrases and improve readability
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']) {
|
||||
|
||||
Reference in New Issue
Block a user