Files
SpotiFLAC-Mobile/lib/widgets/aligned_lyric_pronunciation.dart
T

264 lines
8.8 KiB
Dart

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 TextAlign textAlign;
final Widget Function(String, List<LyricWord>, TextStyle) textBuilder;
final Widget Function(String, List<LyricWord>, TextStyle)?
pronunciationBuilder;
const AlignedLyricPronunciation({
super.key,
required this.layout,
required this.visibility,
required this.primaryStyle,
required this.pronunciationStyle,
required this.textBuilder,
this.pronunciationBuilder,
this.textAlign = TextAlign.start,
});
@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,
mainAxisAlignment: textAlign == TextAlign.center
? MainAxisAlignment.center
: (textAlign == TextAlign.right &&
Directionality.of(context) == TextDirection.ltr) ||
(textAlign == TextAlign.left &&
Directionality.of(context) == TextDirection.rtl) ||
textAlign == TextAlign.end
? MainAxisAlignment.end
: MainAxisAlignment.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(
width: double.infinity,
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(
width: double.infinity,
height: row.pronunciationHeight,
child: (pronunciationBuilder ?? textBuilder)(
group.pronunciation.map((word) => word.text).join(),
group.pronunciation,
pronunciationStyle,
),
),
),
),
),
),
],
),
);
}