diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f76af672..5ab7982e 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -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, 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 [] : line.romanizationWords, translation, @@ -3369,6 +3385,13 @@ Iterable<(String, TextStyle, List, 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, 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 { diff --git a/lib/widgets/aligned_lyric_pronunciation.dart b/lib/widgets/aligned_lyric_pronunciation.dart new file mode 100644 index 00000000..e2f4d6b2 --- /dev/null +++ b/lib/widgets/aligned_lyric_pronunciation.dart @@ -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 _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; + + 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, + ), + ), + ), + ), + ), + ), + ], + ), + ); +} diff --git a/test/aligned_lyric_pronunciation_test.dart b/test/aligned_lyric_pronunciation_test.dart new file mode 100644 index 00000000..c446f206 --- /dev/null +++ b/test/aligned_lyric_pronunciation_test.dart @@ -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 = >{}; + Future 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 = []; + 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); + }, + ); + } + } +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 3924996d..471d7fb9 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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(find.text('Romanized text')); + final translation = tester.widget(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(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 = []; + 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(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']) {