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 _sliceWords( List 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 original; final List 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, TextStyle) textBuilder; final Widget Function(String, List, 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, ), ), ), ), ), ), ], ), ); }