mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Ease the lift before each highlight and let it settle more gradually for held words. Keep timed pronunciation and translation lines stationary while preserving their color sweep.
251 lines
8.1 KiB
Dart
251 lines
8.1 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 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,
|
|
});
|
|
|
|
@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: (pronunciationBuilder ?? textBuilder)(
|
|
group.pronunciation.map((word) => word.text).join(),
|
|
group.pronunciation,
|
|
pronunciationStyle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|