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
+67 -9
View File
@@ -30,6 +30,7 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/audio_output_button.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -2997,7 +2998,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
style: style,
);
painter.layout(maxWidth: width);
final height = painter.height + 32;
var height = painter.height + 32;
var pronunciationHeight = 0.0;
var translationHeight = 0.0;
for (final (text, style, _, translation) in _lyricSupplements(
@@ -3010,6 +3011,19 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
translationHeight = 6 + painter.height;
} else {
pronunciationHeight = 6 + painter.height;
final aligned = LyricPronunciationLayout.measure(
line: line,
primaryStyle: _mornyeLyricStyle(context),
pronunciationStyle: style,
maxWidth: width,
textScaler: scaler,
textDirection: direction,
locale: locale,
);
if (aligned != null) {
height = aligned.primaryHeight + 32;
pronunciationHeight = aligned.pronunciationHeight;
}
}
}
measurements.add((height, pronunciationHeight, translationHeight));
@@ -3358,10 +3372,12 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
text,
base.copyWith(
fontSize: context.isMornye
? (translation ? 15 : 17)
? (translation ? 18 : 22)
: (translation ? 14 : 16),
height: 1.35,
fontWeight: FontWeight.w500,
fontWeight: context.isMornye
? (translation ? FontWeight.w600 : FontWeight.bold)
: FontWeight.w500,
),
translation ? const <LyricWord>[] : line.romanizationWords,
translation,
@@ -3369,6 +3385,13 @@ Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
}
}
TextStyle _mornyeLyricStyle(BuildContext context) =>
(Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith(
fontSize: _mornyeLyricFontSize,
height: context.tokens.lyricsLineHeight,
fontWeight: FontWeight.bold,
);
Widget _withLyricSupplements(
BuildContext context,
LyricLine line,
@@ -3378,17 +3401,16 @@ Widget _withLyricSupplements(
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
}) {
if (line.romanization == null && line.translation == null) return primary;
return Column(
final supplements = _lyricSupplements(context, line).toList();
Widget withSupplements(Widget primary, {bool aligned = false}) => Column(
crossAxisAlignment: context.isMornye
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
primary,
for (final (text, style, words, translation) in _lyricSupplements(
context,
line,
))
if ((translation ? visibility.dy : visibility.dx) > 0)
for (final (text, style, words, translation) in supplements)
if ((translation || !aligned) &&
(translation ? visibility.dy : visibility.dx) > 0)
ClipRect(
child: Align(
alignment: context.isMornye
@@ -3416,6 +3438,42 @@ Widget _withLyricSupplements(
),
],
);
if (!context.isMornye ||
line.romanizationWords.isEmpty ||
line.romanization?.trim().isNotEmpty != true) {
return withSupplements(primary);
}
final primaryStyle = _mornyeLyricStyle(context);
final pronunciationStyle = supplements
.firstWhere((supplement) => !supplement.$4)
.$2;
return LayoutBuilder(
builder: (context, constraints) {
final layout = LyricPronunciationLayout.measure(
line: line,
primaryStyle: primaryStyle,
pronunciationStyle: pronunciationStyle,
maxWidth: constraints.maxWidth,
textScaler: MediaQuery.textScalerOf(context),
textDirection: Directionality.of(context),
locale: Localizations.maybeLocaleOf(context),
);
if (layout == null) return withSupplements(primary);
return withSupplements(
AlignedLyricPronunciation(
layout: layout,
visibility: visibility.dx,
primaryStyle: primaryStyle,
pronunciationStyle: pronunciationStyle,
textBuilder:
timedText ??
(text, words, style) =>
Text(text, style: style.copyWith(color: color)),
),
aligned: true,
);
},
);
}
class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
@@ -0,0 +1,247 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
/// Keeps each pronunciation phrase under the lyric with the same start time.
/// Only shared word boundaries are used; line-only timing cannot identify
/// which original characters a pronunciation belongs to.
class LyricPronunciationLayout {
final List<_PronunciationRow> _rows;
const LyricPronunciationLayout._(this._rows);
static const _phraseGap = 4.0;
static const _pronunciationGap = 2.0;
static const _rowGap = 6.0;
double get primaryHeight =>
_rows.fold(0.0, (height, row) => height + row.primaryHeight) +
(_rows.length - 1) * _rowGap;
double get pronunciationHeight => _rows.fold(
0.0,
(height, row) => height + _pronunciationGap + row.pronunciationHeight,
);
static LyricPronunciationLayout? measure({
required LyricLine line,
required TextStyle primaryStyle,
required TextStyle pronunciationStyle,
required double maxWidth,
required TextScaler textScaler,
required TextDirection textDirection,
Locale? locale,
}) {
final original = line.words;
final pronunciation = line.romanizationWords;
if (original.isEmpty ||
pronunciation.isEmpty ||
!maxWidth.isFinite ||
maxWidth <= 0) {
return null;
}
String normalize(String text) => text.replaceAll(RegExp(r'\s+'), '');
if (normalize(original.map((word) => word.text).join()) !=
normalize(line.text) ||
normalize(pronunciation.map((word) => word.text).join()) !=
normalize(line.romanization ?? '') ||
(original.first.time - pronunciation.first.time).inMilliseconds.abs() >
10) {
return null;
}
final boundaries = <(int, int)>[(0, 0)];
var originalIndex = 1;
for (var i = 1; i < pronunciation.length; i++) {
// Preserve syllables inside a word. Providers may split a romanized
// word into several timed spans without adding whitespace.
if (!RegExp(r'\s$').hasMatch(pronunciation[i - 1].text) &&
!RegExp(r'^\s').hasMatch(pronunciation[i].text)) {
continue;
}
final start = pronunciation[i].time.inMilliseconds;
while (originalIndex < original.length &&
original[originalIndex].time.inMilliseconds < start - 10) {
originalIndex++;
}
if (originalIndex < original.length &&
(original[originalIndex].time.inMilliseconds - start).abs() <= 10) {
boundaries.add((originalIndex, i));
originalIndex++;
}
}
if (boundaries.length < 2) return null;
boundaries.add((original.length, pronunciation.length));
final painter = TextPainter(
textDirection: textDirection,
textScaler: textScaler,
locale: locale,
);
Size measureText(String text, TextStyle style) {
painter.text = TextSpan(text: text, style: style);
painter.layout(maxWidth: maxWidth);
return Size(painter.width.ceilToDouble(), painter.height);
}
final rows = <_PronunciationRow>[];
var groups = <_PronunciationGroup>[];
var rowWidth = 0.0;
var primaryHeight = 0.0;
var pronunciationHeight = 0.0;
for (var i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
final primaryWords = _sliceWords(original, start.$1, end.$1);
final pronunciationWords = _sliceWords(pronunciation, start.$2, end.$2);
final primarySize = measureText(
primaryWords.map((word) => word.text).join(),
primaryStyle,
);
final pronunciationSize = measureText(
pronunciationWords.map((word) => word.text).join(),
pronunciationStyle,
);
final width = math.min(
maxWidth,
math.max(primarySize.width, pronunciationSize.width),
);
if (groups.isNotEmpty && rowWidth + _phraseGap + width > maxWidth) {
rows.add(_PronunciationRow(groups, primaryHeight, pronunciationHeight));
groups = [];
rowWidth = primaryHeight = pronunciationHeight = 0;
}
if (groups.isNotEmpty) rowWidth += _phraseGap;
groups.add(_PronunciationGroup(primaryWords, pronunciationWords, width));
rowWidth += width;
primaryHeight = math.max(primaryHeight, primarySize.height);
pronunciationHeight = math.max(
pronunciationHeight,
pronunciationSize.height,
);
}
rows.add(_PronunciationRow(groups, primaryHeight, pronunciationHeight));
painter.dispose();
return LyricPronunciationLayout._(rows);
}
static List<LyricWord> _sliceWords(
List<LyricWord> words,
int start,
int end,
) => [
for (var i = start; i < end; i++)
LyricWord(
time: words[i].time,
end: words[i].end ?? words.elementAtOrNull(i + 1)?.time,
text: i == start && i == end - 1
? words[i].text.trim()
: i == start
? words[i].text.trimLeft()
: i == end - 1
? words[i].text.trimRight()
: words[i].text,
),
];
}
class _PronunciationGroup {
final List<LyricWord> original;
final List<LyricWord> pronunciation;
final double width;
const _PronunciationGroup(this.original, this.pronunciation, this.width);
}
class _PronunciationRow {
final List<_PronunciationGroup> groups;
final double primaryHeight;
final double pronunciationHeight;
const _PronunciationRow(
this.groups,
this.primaryHeight,
this.pronunciationHeight,
);
}
class AlignedLyricPronunciation extends StatelessWidget {
final LyricPronunciationLayout layout;
final double visibility;
final TextStyle primaryStyle;
final TextStyle pronunciationStyle;
final Widget Function(String, List<LyricWord>, TextStyle) textBuilder;
const AlignedLyricPronunciation({
super.key,
required this.layout,
required this.visibility,
required this.primaryStyle,
required this.pronunciationStyle,
required this.textBuilder,
});
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < layout._rows.length; i++) ...[
if (i > 0) const SizedBox(height: LyricPronunciationLayout._rowGap),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var j = 0; j < layout._rows[i].groups.length; j++) ...[
if (j > 0)
const SizedBox(width: LyricPronunciationLayout._phraseGap),
_buildGroup(layout._rows[i].groups[j], layout._rows[i]),
],
],
),
],
],
);
Widget _buildGroup(_PronunciationGroup group, _PronunciationRow row) =>
SizedBox(
width: group.width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: row.primaryHeight,
child: textBuilder(
group.original.map((word) => word.text).join(),
group.original,
primaryStyle,
),
),
if (visibility > 0)
ClipRect(
child: Align(
alignment: Alignment.topLeft,
heightFactor: visibility,
child: Opacity(
opacity: visibility,
child: Padding(
padding: const EdgeInsets.only(
top: LyricPronunciationLayout._pronunciationGap,
),
child: SizedBox(
height: row.pronunciationHeight,
child: textBuilder(
group.pronunciation.map((word) => word.text).join(),
group.pronunciation,
pronunciationStyle,
),
),
),
),
),
),
],
),
);
}
+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']) {