diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index df89b7a3..0d81d2bf 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -43,6 +43,7 @@ internal object NativeFinalizationPolicy { private val lyricsInlineTimestampPattern = Regex( "<\\d{1,3}:\\d{1,2}(?:[.:]\\d{1,3})?>", ) + private val lyricsVoicePrefixPattern = Regex("^v[1-9][0-9]*:", RegexOption.IGNORE_CASE) fun hasUsableLyricsContent(raw: String?): Boolean { val lyrics = raw.orEmpty().trim() @@ -63,12 +64,7 @@ internal object NativeFinalizationPolicy { cleaned = lyricsTimestampPattern.replaceFirst(cleaned, "").trim() } cleaned = lyricsInlineTimestampPattern.replace(cleaned, "").trim() - if ( - cleaned.startsWith("v1:", ignoreCase = true) || - cleaned.startsWith("v2:", ignoreCase = true) - ) { - cleaned = cleaned.drop(3).trim() - } + cleaned = lyricsVoicePrefixPattern.replaceFirst(cleaned, "").trim() if (cleaned.isNotEmpty()) return true } return false diff --git a/android/app/src/test/resources/lyrics_usability_cases.tsv b/android/app/src/test/resources/lyrics_usability_cases.tsv index 344c12d9..c1948923 100644 --- a/android/app/src/test/resources/lyrics_usability_cases.tsv +++ b/android/app/src/test/resources/lyrics_usability_cases.tsv @@ -1,4 +1,4 @@ -# Shared Dart/Go/Kotlin lyric-usability cases. +# Shared Dart/Rust/Kotlin lyric-usability cases. # Columns: nameexpected booleanlyrics. Decode literal \n, \r, \t in lyrics. empty false whitespace false \t\r\n @@ -8,6 +8,8 @@ instrumental true [Instrumental:TRUE] timestamps_only false [00:01.00]\n<00:01.10> multiple_timestamps false [00:01.00][01:02:500] speaker_only false v1:\n V2: +multiple_singers_only false [00:01.00]v3:\n[00:02.00]<00:02.00>V12: +third_singer_text true [00:01.00]v3:Third singer inline_before_spaced_speaker false [00:00.00]<00:00.01> v1: inline_before_tabbed_speaker false [00:00.00]<00:00.01>\tV2:\t multiple_inline_speaker false [00:00.00]<00:00.01> <00:00.02> v2: diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 2cc2bb4b..0e6e9f50 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2841,6 +2841,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { late List _lines; late List _lineKeys; int _active = -1; + Set _activeLines = {}; Duration _activeTransitionPosition = Duration.zero; bool _playing = false; bool _loading = false; @@ -2915,6 +2916,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _playing = ref.read(playbackPlayingProvider); _loading = ref.read(playbackLoadingProvider); _active = _activeIndexAt(position); + _activeLines = activeLyricIndices(_lines, position, _active); _activeTransitionPosition = position; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true)); @@ -2925,7 +2927,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { (previous, next) { if (widget.seekPreview.value != null) return; final active = _activeIndexAt(next); - if (active != _active) _setActiveLine(active, position: next); + _setActiveLine(active, position: next); _scheduleNextLine(next); }, ); @@ -2987,11 +2989,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } void _setActiveLine(int active, {required Duration position}) { - if (!mounted || active == _active) return; + if (!mounted) return; + final activeLines = activeLyricIndices(_lines, position, active); + final indexChanged = active != _active; + if (!indexChanged && + activeLines.length == _activeLines.length && + activeLines.containsAll(_activeLines)) { + return; + } setState(() { _active = active; + _activeLines = activeLines; _activeTransitionPosition = position; }); + if (!indexChanged) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && active == _active) unawaited(_maybeAutoScroll(active)); }); @@ -3012,13 +3023,25 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { currentIndex: _active, position: position, ); - if (dueIndex != _active) { - _setActiveLine(dueIndex, position: position); - } + _setActiveLine(dueIndex, position: position); final nextIndex = dueIndex + 1; - if (nextIndex >= lines.length) return; - final boundary = lines[nextIndex].time; + Duration? nextBoundary = nextIndex < lines.length + ? lines[nextIndex].time + : null; + for (final index in _activeLines) { + final end = lines[index].end; + if ((index != dueIndex || + lines[index].voice != null || + lines[index].isBackground) && + end != null && + end > position && + (nextBoundary == null || end < nextBoundary)) { + nextBoundary = end; + } + } + if (nextBoundary == null) return; + final boundary = nextBoundary; _lineBoundaryTimer = Timer(boundary - position, () { if (!mounted || !widget.isActive || !_playing || _loading) return; _scheduleNextLine(boundary); @@ -3275,7 +3298,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { itemBuilder: (context, index) { if (index == lines.length) return widget.credits!; final line = lines[index]; - final isActive = index == active; + final textAlign = _lyricTextAlign(context, line); + final isActive = _activeLines.contains(index); final isPast = index < active; final color = mornye @@ -3331,11 +3355,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { initialPosition: _activeTransitionPosition, seekPreview: widget.seekPreview, supplementVisibility: visibility, + textAlign: textAlign, ); } else { content = Text( line.text, - textAlign: mornye ? TextAlign.start : TextAlign.center, + textAlign: textAlign, style: (mornye || isActive ? Theme.of(context).textTheme.headlineSmall @@ -3355,6 +3380,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { content, color, visibility: visibility, + textAlign: textAlign, ); } content = AnimatedSwitcher( @@ -3364,9 +3390,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( key: ValueKey(timed), - child: mornye - ? SizedBox(width: double.infinity, child: content) - : content, + child: SizedBox(width: double.infinity, child: content), ), ); if (mornye) { @@ -3496,21 +3520,34 @@ TextStyle _mornyeLyricStyle(BuildContext context) => fontWeight: FontWeight.bold, ); +TextAlign _lyricTextAlign(BuildContext context, LyricLine line) { + final voice = line.voice; + if (voice == null) { + return context.isMornye ? TextAlign.start : TextAlign.center; + } + if (voice.isGroup) return TextAlign.center; + return voice.index.isEven ? TextAlign.left : TextAlign.right; +} + Widget _withLyricSupplements( BuildContext context, LyricLine line, Widget primary, Color color, { required Offset visibility, + required TextAlign textAlign, Widget Function(String, List, TextStyle)? timedText, Widget Function(String, List, TextStyle)? timedSupplementText, }) { if (line.romanization == null && line.translation == null) return primary; + final alignment = switch (textAlign) { + TextAlign.right => Alignment.topRight, + TextAlign.center => Alignment.topCenter, + _ => Alignment.topLeft, + }; final supplements = _lyricSupplements(context, line).toList(); Widget withSupplements(Widget primary, {bool aligned = false}) => Column( - crossAxisAlignment: context.isMornye - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ primary, for (final (text, style, words, translation) in supplements) @@ -3518,9 +3555,7 @@ Widget _withLyricSupplements( (translation ? visibility.dy : visibility.dx) > 0) ClipRect( child: Align( - alignment: context.isMornye - ? Alignment.topLeft - : Alignment.topCenter, + alignment: alignment, heightFactor: translation ? visibility.dy : visibility.dx, child: Opacity( opacity: translation ? visibility.dy : visibility.dx, @@ -3530,9 +3565,7 @@ Widget _withLyricSupplements( ? timedSupplementText(text, words, style) : Text( text, - textAlign: context.isMornye - ? TextAlign.start - : TextAlign.center, + textAlign: textAlign, style: style.copyWith( color: color.withValues(alpha: color.a * 0.8), ), @@ -3570,11 +3603,15 @@ Widget _withLyricSupplements( visibility: visibility.dx, primaryStyle: primaryStyle, pronunciationStyle: pronunciationStyle, + textAlign: textAlign, pronunciationBuilder: timedSupplementText, textBuilder: timedText ?? - (text, words, style) => - Text(text, style: style.copyWith(color: color)), + (text, words, style) => Text( + text, + textAlign: textAlign, + style: style.copyWith(color: color), + ), ), aligned: true, ); @@ -3589,6 +3626,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget { final Duration initialPosition; final ValueListenable seekPreview; final Offset supplementVisibility; + final TextAlign textAlign; const _WordHighlightedLyricLine({ required this.line, @@ -3597,6 +3635,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget { required this.initialPosition, required this.seekPreview, required this.supplementVisibility, + required this.textAlign, }); @override @@ -3774,7 +3813,7 @@ class _WordHighlightedLyricLineState ? _buildTimedText(widget.line.text, widget.line.words, style) : Text( widget.line.text, - textAlign: context.isMornye ? TextAlign.start : TextAlign.center, + textAlign: widget.textAlign, style: style.copyWith(color: widget.colorScheme.onSurface), ); // Both scripts share this state's position interpolation and animation @@ -3788,6 +3827,7 @@ class _WordHighlightedLyricLineState timedSupplementText: (text, words, style) => _buildTimedText(text, words, style, lift: false), visibility: widget.supplementVisibility, + textAlign: widget.textAlign, ); } @@ -3819,7 +3859,7 @@ class _WordHighlightedLyricLineState currentPosition: _currentPosition, repaint: _animationClock, style: style, - textAlign: mornye ? TextAlign.start : TextAlign.center, + textAlign: widget.textAlign, pendingColor: pendingColor, highlightedColor: highlightedColor, semanticsLabel: text, diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index e9df133a..39cbcd24 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -14,7 +14,7 @@ final RegExp _lrcDisplayInlineTimestampPattern = RegExp( r'<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>', ); final RegExp _lrcDisplaySpeakerPrefixPattern = RegExp( - r'^(v1|v2):\s*', + r'^v[1-9]\d*:\s*', caseSensitive: false, ); final RegExp _lrcDisplayBackgroundLinePattern = RegExp( diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index e2761934..f1f70c8f 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -10,6 +10,18 @@ class LyricWord { const LyricWord({required this.time, this.end, required this.text}); } +class LyricVoice { + final String id; + final int index; + final bool isGroup; + + const LyricVoice({ + required this.id, + required this.index, + this.isGroup = false, + }); +} + class LyricLine { final Duration time; final Duration? end; @@ -18,6 +30,8 @@ class LyricLine { final String? romanization; final List romanizationWords; final String? translation; + final LyricVoice? voice; + final bool isBackground; const LyricLine({ required this.time, @@ -27,6 +41,8 @@ class LyricLine { this.romanization, this.romanizationWords = const [], this.translation, + this.voice, + this.isBackground = false, }); bool get hasWordTiming => words.isNotEmpty; @@ -71,6 +87,29 @@ class LyricsParser { static final RegExp _wordTimeTag = RegExp( r'<(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?>', ); + static final RegExp _voicePrefix = RegExp( + r'^(\s*(?:<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>\s*)*)(v[1-9]\d*):[ \t]*', + caseSensitive: false, + ); + static final RegExp _backgroundLine = RegExp( + r'^\[bg:(.*)\]$', + caseSensitive: false, + ); + + static (String, LyricVoice?) _vocalText(String content) { + final match = _voicePrefix.firstMatch(content); + if (match == null) return (content, null); + final id = match.group(2)!.toLowerCase(); + final number = int.tryParse(id.substring(1)); + return ( + content.replaceRange( + 0, + match.end, + match.group(1)!.replaceAll(RegExp(r'\s+'), ''), + ), + LyricVoice(id: id, index: (number ?? 1) - 1), + ); + } // ID tags such as [ti:..], [ar:..], [offset:..]. static final RegExp _idTag = RegExp( @@ -188,9 +227,12 @@ class LyricsParser { var offsetMs = 0; for (final rawLine in rawLines) { - final line = rawLine.trimRight(); + var line = rawLine.trimRight(); if (line.trim().isEmpty) continue; + final background = _backgroundLine.firstMatch(line.trim()); + if (background != null) line = background.group(1)!.trim(); + final supplement = _supplementTag.firstMatch(line.trim()); if (supplement != null) { final time = int.tryParse(supplement.group(2)!); @@ -230,8 +272,24 @@ class LyricsParser { final timeMatches = _lineTimeTag.allMatches(line).toList(); if (timeMatches.isEmpty) { - // No timestamp: treat as plain text line. - plainBuffer.add(line.trim()); + final (content, voice) = _vocalText(line.trim()); + final clean = content.replaceAll(_wordTimeTag, '').trim(); + plainBuffer.add(clean); + if (background != null && parsed.isNotEmpty && clean.isNotEmpty) { + final words = _parseWords(content); + final previous = parsed.last; + parsed.add( + LyricLine( + time: words.firstOrNull?.time ?? previous.time, + end: words.lastOrNull?.end ?? previous.end, + text: clean, + words: words, + voice: voice ?? previous.voice, + isBackground: true, + ), + ); + sawWordTiming |= words.isNotEmpty; + } continue; } @@ -239,7 +297,7 @@ class LyricsParser { // Strip leading line timestamps to obtain the lyric content. final lastTag = timeMatches.last; - final content = line.substring(lastTag.end).trim(); + final (content, voice) = _vocalText(line.substring(lastTag.end).trim()); // Enhanced LRC word timestamps inside the content. final words = _parseWords(content); @@ -257,6 +315,8 @@ class LyricsParser { end: words.lastOrNull?.end, text: cleanContent, words: words, + voice: voice, + isBackground: background != null, ), ); } @@ -272,7 +332,7 @@ class LyricsParser { ); } - parsed.sort((a, b) => a.time.compareTo(b.time)); + _sortLines(parsed); final alignedRomanization = _alignSupplements(parsed, romanization); final alignedRomanizationWords = _alignSupplements( parsed, @@ -300,6 +360,8 @@ class LyricsParser { time: _shift(l.time, offsetMs), end: l.end == null ? null : _shift(l.end!, offsetMs), text: l.text, + voice: l.voice, + isBackground: l.isBackground, romanization: romanization, romanizationWords: _shiftWords(validWords, offsetMs), translation: alignedTranslation[l.time.inMilliseconds], @@ -436,9 +498,40 @@ class LyricsParser { static ParsedLyrics? _parseTtml(String text) { try { final doc = XmlDocument.parse(text); - final paragraphs = doc.findAllElements('p').toList(); + const metadataNamespace = 'http://www.w3.org/ns/ttml#metadata'; + final elements = doc.descendants.whereType().toList(); + final paragraphs = elements.where((node) => node.name.local == 'p'); if (paragraphs.isEmpty) return null; + final voices = {}; + var individualIndex = 0; + for (final agent in elements.where( + (node) => + node.name.local == 'agent' && + node.name.namespaceUri == metadataNamespace, + )) { + final id = agent.getAttribute( + 'id', + namespaceUri: 'http://www.w3.org/XML/1998/namespace', + ); + if (id == null || id.isEmpty) continue; + final group = agent.getAttribute('type') == 'group'; + voices[id] = LyricVoice(id: id, index: individualIndex, isGroup: group); + if (!group) individualIndex++; + } + + LyricVoice? voiceOf(XmlElement element, LyricVoice? inherited) { + final id = element.getAttribute( + 'agent', + namespaceUri: metadataNamespace, + ); + if (id == null || id.isEmpty) return inherited; + return voices.putIfAbsent( + id, + () => LyricVoice(id: id, index: individualIndex++), + ); + } + final lines = []; final plain = []; var sawWords = false; @@ -447,34 +540,78 @@ class LyricsParser { final begin = _parseClock(p.getAttribute('begin')); final end = _parseClock(p.getAttribute('end')); - // Word/syllable spans carry their own begin attribute. - final spans = p.findElements('span').toList(); - final words = []; - if (spans.isNotEmpty) { - for (final span in spans) { - final sBegin = _parseClock(span.getAttribute('begin')); - final spanText = span.innerText; - if (sBegin != null && spanText.trim().isNotEmpty) { - final sEnd = _parseClock(span.getAttribute('end')); - words.add( - LyricWord( - time: sBegin, - end: sEnd != null && sEnd >= sBegin ? sEnd : null, - text: '$spanText ', - ), + LyricVoice? inheritedVoice; + for (final ancestor + in p.ancestors.whereType().toList().reversed) { + inheritedVoice = voiceOf(ancestor, inheritedVoice); + } + final runs = <_TtmlVocalRun>[]; + void visit( + XmlNode node, + LyricVoice? voice, + bool background, + Duration? wordBegin, + Duration? wordEnd, + ) { + if (node is XmlText) { + if (node.value.trim().isEmpty && runs.isEmpty) return; + var run = runs + .where( + (run) => + run.voice?.id == voice?.id && + run.background == background, + ) + .firstOrNull; + if (run == null) { + if (node.value.trim().isEmpty) return; + run = _TtmlVocalRun(voice, background); + runs.add(run); + } + run.add(node.value, wordBegin, wordEnd); + } else if (node is XmlElement) { + final nextVoice = voiceOf(node, voice); + final nextBackground = + background || + node.getAttribute('role', namespaceUri: metadataNamespace) == + 'x-bg'; + final spanBegin = node == p + ? null + : _parseClock(node.getAttribute('begin')); + final spanEnd = node == p + ? null + : _parseClock(node.getAttribute('end')); + for (final child in node.children) { + visit( + child, + nextVoice, + nextBackground, + spanBegin ?? wordBegin, + spanEnd ?? wordEnd, ); } } } - if (words.isNotEmpty) sawWords = true; - final lineText = p.innerText.replaceAll(RegExp(r'\s+'), ' ').trim(); - if (lineText.isEmpty && words.isEmpty) continue; - plain.add(lineText); - - if (begin != null) { + visit(p, inheritedVoice, false, null, null); + for (final run in runs) { + final lineText = run.text; + if (lineText.isEmpty) continue; + plain.add(lineText); + final words = run.words; + final lineBegin = runs.length == 1 + ? begin + : words.firstOrNull?.time ?? begin; + if (lineBegin == null) continue; + sawWords |= words.isNotEmpty; lines.add( - LyricLine(time: begin, end: end, text: lineText, words: words), + LyricLine( + time: lineBegin, + end: runs.length == 1 ? end : words.lastOrNull?.end ?? end, + text: lineText, + words: words, + voice: run.voice, + isBackground: run.background, + ), ); } } @@ -488,7 +625,7 @@ class LyricsParser { ); } - lines.sort((a, b) => a.time.compareTo(b.time)); + _sortLines(lines); return ParsedLyrics( synced: true, wordSynced: sawWords, @@ -545,4 +682,57 @@ class LyricsParser { } return result; } + + static void _sortLines(List lines) { + // Simultaneous singers retain their document order. + final order = {for (var i = 0; i < lines.length; i++) lines[i]: i}; + lines.sort((a, b) { + final timing = a.time.compareTo(b.time); + return timing != 0 ? timing : order[a]!.compareTo(order[b]!); + }); + } +} + +class _TtmlVocalRun { + final LyricVoice? voice; + final bool background; + final List<(String, Duration?, Duration?)> _fragments = []; + + _TtmlVocalRun(this.voice, this.background); + + void add(String value, Duration? begin, Duration? end) { + var text = value.replaceAll(RegExp(r'\s+'), ' '); + if (_fragments.isEmpty || _fragments.last.$1.endsWith(' ')) { + text = text.trimLeft(); + } + if (text.isEmpty) return; + if (_fragments.isNotEmpty && + (text.trim().isEmpty || + (begin == _fragments.last.$2 && end == _fragments.last.$3))) { + final previous = _fragments.removeLast(); + _fragments.add(('${previous.$1}$text', previous.$2, previous.$3)); + } else { + _fragments.add((text, begin, end)); + } + } + + String get text => _fragments.map((fragment) => fragment.$1).join().trim(); + + List get words { + // Never drop untimed text from a partially timed paragraph. + if (_fragments.any((fragment) => fragment.$2 == null)) return const []; + return [ + for (var i = 0; i < _fragments.length; i++) + LyricWord( + time: _fragments[i].$2!, + end: + _fragments[i].$3 != null && _fragments[i].$3! >= _fragments[i].$2! + ? _fragments[i].$3 + : null, + text: i == _fragments.length - 1 + ? _fragments[i].$1.trimRight() + : _fragments[i].$1, + ), + ]; + } } diff --git a/lib/utils/lyrics_timeline.dart b/lib/utils/lyrics_timeline.dart index 8aa4f0be..339ad913 100644 --- a/lib/utils/lyrics_timeline.dart +++ b/lib/utils/lyrics_timeline.dart @@ -1,5 +1,26 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart'; +/// Keep overlapping vocal parts lit until their explicit end. Untimed lines +/// retain the normal single-line behavior instead of guessing a vocal length. +Set activeLyricIndices( + List lines, + Duration position, + int currentIndex, +) { + final active = {}; + for (var i = 0; i <= currentIndex; i++) { + final line = lines[i]; + final timedVoice = + (i < currentIndex || line.voice != null || line.isBackground) && + line.text.isNotEmpty && + line.end != null; + // Known ends also apply to the most recently started singer, so a short + // reply does not stay lit over a longer lead vocal. + if (timedVoice ? line.end! > position : i == currentIndex) active.add(i); + } + return active; +} + /// Inserts empty, bounded rows for instrumental countdowns in the player. /// Unmarked gaps in plain LRC cannot be distinguished from held vocals: only /// use explicit empty timestamps or known line/word ends, never an estimate. diff --git a/lib/widgets/aligned_lyric_pronunciation.dart b/lib/widgets/aligned_lyric_pronunciation.dart index ec21129d..766dcc55 100644 --- a/lib/widgets/aligned_lyric_pronunciation.dart +++ b/lib/widgets/aligned_lyric_pronunciation.dart @@ -171,6 +171,7 @@ class AlignedLyricPronunciation extends StatelessWidget { final double visibility; final TextStyle primaryStyle; final TextStyle pronunciationStyle; + final TextAlign textAlign; final Widget Function(String, List, TextStyle) textBuilder; final Widget Function(String, List, TextStyle)? pronunciationBuilder; @@ -183,6 +184,7 @@ class AlignedLyricPronunciation extends StatelessWidget { required this.pronunciationStyle, required this.textBuilder, this.pronunciationBuilder, + this.textAlign = TextAlign.start, }); @override @@ -194,6 +196,15 @@ class AlignedLyricPronunciation extends StatelessWidget { 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) @@ -214,6 +225,7 @@ class AlignedLyricPronunciation extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( + width: double.infinity, height: row.primaryHeight, child: textBuilder( group.original.map((word) => word.text).join(), @@ -233,6 +245,7 @@ class AlignedLyricPronunciation extends StatelessWidget { top: LyricPronunciationLayout._pronunciationGap, ), child: SizedBox( + width: double.infinity, height: row.pronunciationHeight, child: (pronunciationBuilder ?? textBuilder)( group.pronunciation.map((word) => word.text).join(), diff --git a/rust_backend/crates/core/src/lyrics/lrc.rs b/rust_backend/crates/core/src/lyrics/lrc.rs index c3bc6444..6f9d0cb1 100644 --- a/rust_backend/crates/core/src/lyrics/lrc.rs +++ b/rust_backend/crates/core/src/lyrics/lrc.rs @@ -13,6 +13,7 @@ static LEADING_TIME: LazyLock = LazyLock::new(|| Regex::new(r"^\[[0-9]{1,3}:[0-9]{1,2}(?:[.:][0-9]{1,3})?\]").unwrap()); static INLINE_TIME: LazyLock = LazyLock::new(|| Regex::new(r"<[0-9]{1,3}:[0-9]{1,2}(?:[.:][0-9]{1,3})?>").unwrap()); +static VOICE_PREFIX: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^v[1-9][0-9]*:").unwrap()); static INSTRUMENTAL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\[instrumental:true\]$").unwrap()); @@ -40,9 +41,8 @@ pub fn has_usable_content(raw: &str) -> bool { } let without_inline = INLINE_TIME.replace_all(cleaned, ""); cleaned = without_inline.trim(); - let lower = lowercase(cleaned); - if lower.starts_with("v1:") || lower.starts_with("v2:") { - cleaned = cleaned[3..].trim(); + if let Some(voice) = VOICE_PREFIX.find(cleaned) { + cleaned = cleaned[voice.end()..].trim(); } if !cleaned.is_empty() { return true; @@ -221,6 +221,30 @@ pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> Stri mod tests { use super::*; + #[test] + fn usability_matches_the_app_and_native_finalizer() { + let cases = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../android/app/src/test/resources/lyrics_usability_cases.tsv" + )); + for line in cases + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#')) + { + let fields: Vec<_> = line.splitn(3, '\t').collect(); + let raw = fields[2] + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + assert_eq!( + has_usable_content(&raw), + fields[1] == "true", + "{}", + fields[0] + ); + } + } + #[test] fn writing_timestamps_never_discards_milliseconds() { for time in [0, 1000, 1009, 12345, 59999, 60000, 3599999] { diff --git a/test/aligned_lyric_pronunciation_test.dart b/test/aligned_lyric_pronunciation_test.dart index c446f206..939dcb63 100644 --- a/test/aligned_lyric_pronunciation_test.dart +++ b/test/aligned_lyric_pronunciation_test.dart @@ -44,6 +44,38 @@ LyricPronunciationLayout? _layout( ); void main() { + testWidgets('right-aligned voices move original and pronunciation together', ( + tester, + ) async { + Future pump(TextAlign alignment) => tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: AlignedLyricPronunciation( + layout: _layout(_line(), width: 400)!, + visibility: 1, + textAlign: alignment, + primaryStyle: _primaryStyle, + pronunciationStyle: _pronunciationStyle, + textBuilder: (text, words, style) => Text(text, style: style), + ), + ), + ), + ), + ); + await pump(TextAlign.left); + final original = tester.getTopLeft(find.text('今日')); + final reading = tester.getTopLeft(find.text('kyou')); + await pump(TextAlign.right); + final shift = tester.getTopLeft(find.text('今日')) - original; + expect(shift.dx, greaterThan(0)); + expect(shift.dy, 0); + expect(tester.getTopLeft(find.text('kyou')) - reading, shift); + expect(tester.takeException(), isNull); + }); + test('does not guess alignment without matching word timing', () { expect(_layout(_line(pronunciationOffset: 100)), isNull); expect( diff --git a/test/lyrics_metadata_helper_test.dart b/test/lyrics_metadata_helper_test.dart index dd90b192..e1ae639c 100644 --- a/test/lyrics_metadata_helper_test.dart +++ b/test/lyrics_metadata_helper_test.dart @@ -45,11 +45,13 @@ void main() { [00:01.25]Lead line [bg:Background line] [00:02:500]<00:02.500>v2: Harmony line +[00:03.00]v3:Third voice +[00:04.00]V12:Twelfth voice '''; expect( cleanLyricsForDisplay(raw), - 'Lead line\nBackground line\nHarmony line', + 'Lead line\nBackground line\nHarmony line\nThird voice\nTwelfth voice', ); expect(hasUsableLyricsContent(raw), isTrue); }); diff --git a/test/lyrics_parser_test.dart b/test/lyrics_parser_test.dart index aee32404..5ebbd0ad 100644 --- a/test/lyrics_parser_test.dart +++ b/test/lyrics_parser_test.dart @@ -7,6 +7,104 @@ String _tag(String kind, int time, String text) => '[x-$kind:$time:${base64.encode(utf8.encode(text))}]'; void main() { + test('all eLRC voices retain timing and supplements without visible IDs', () { + final lyrics = LyricsParser.parse(''' +[offset:100] +${_tag('romaji', 2000, 'Second reading')} +${_tag('translation', 2000, 'Second translation')} +[00:01.00]v1:<00:01.00>First<00:03.00> +[00:02.00]<00:02.00> V2: Second<00:04.00> +[00:04.00]v3:Third +[00:06.00]v12:Twelfth +[00:08.00]The v2: label stays inside a sentence +'''); + expect(lyrics.lines.map((line) => line.voice?.id), [ + 'v1', + 'v2', + 'v3', + 'v12', + null, + ]); + expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 11, null]); + final second = lyrics.lines[1]; + expect(second.text, 'Second'); + expect(second.words.single.text, 'Second'); + expect(second.words.single.time.inMilliseconds, 1900); + expect(second.end?.inMilliseconds, 3900); + expect(second.romanization, 'Second reading'); + expect(second.translation, 'Second translation'); + expect(lyrics.lines.last.text, 'The v2: label stays inside a sentence'); + }); + + test('background eLRC retains the parent voice and its own word times', () { + final lyrics = LyricsParser.parse(''' +[00:01.00]v2:<00:01.00>Main<00:05.00> +[bg:<00:02.00>Backing<00:04.00>] +'''); + expect(lyrics.lines.map((line) => line.text), ['Main', 'Backing']); + final backing = lyrics.lines.last; + expect(backing.voice?.id, 'v2'); + expect(backing.isBackground, isTrue); + expect(backing.time.inMilliseconds, 2000); + expect(backing.end?.inMilliseconds, 4000); + }); + + test('TTML resolves inherited voices and explicit groups by namespace', () { + final lyrics = LyricsParser.parse(''' + + + + + + + + +Lead +Guest +Together +Third + +'''); + expect(lyrics.lines.map((line) => line.voice?.id), [ + 'lead', + 'guest', + 'v3', + 'third', + ]); + expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 2]); + expect(lyrics.lines.map((line) => line.voice?.isGroup), [ + false, + false, + true, + false, + ]); + }); + + test( + 'TTML splits span voices and backing parts without adding syllable gaps', + () { + final lyrics = LyricsParser.parse(''' + + +

日本語ReplyEcho

+'''); + expect(lyrics.lines.map((line) => line.text), ['日本語', 'Reply', 'Echo']); + expect(lyrics.lines.first.words.map((word) => word.text), ['日本', '語']); + expect(lyrics.lines.map((line) => line.time.inSeconds), [1, 2, 2]); + expect(lyrics.lines.map((line) => line.end?.inSeconds), [3, 4, 3]); + expect(lyrics.lines.last.isBackground, isTrue); + expect(lyrics.lines.last.voice?.id, 'v1'); + }, + ); + + test('TTML partial word timing never loses untimed text', () { + final line = LyricsParser.parse(''' +

Untimed timed ending

+''').lines.single; + expect(line.text, 'Untimed timed ending'); + expect(line.words, isEmpty); + }); + test( 'writer tags and stored provider attribution remain separate from lyric rows', () { diff --git a/test/lyrics_timeline_test.dart b/test/lyrics_timeline_test.dart index e1e702c1..47bf70d8 100644 --- a/test/lyrics_timeline_test.dart +++ b/test/lyrics_timeline_test.dart @@ -3,6 +3,46 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/lyrics_timeline.dart'; void main() { + test( + 'backing vocals do not dim an unlabelled lead that is still singing', + () { + final lines = LyricsParser.parse(''' +[00:01.00]<00:01.00>Main<00:05.00> +[bg:<00:02.00>Echo<00:03.00>] +''').lines; + expect(activeLyricIndices(lines, const Duration(seconds: 2), 1), {0, 1}); + expect(activeLyricIndices(lines, const Duration(seconds: 4), 1), {0}); + }, + ); + + test( + 'overlapping singers stay active until their ends, including seek back', + () { + final lines = LyricsParser.parse(''' +[00:01.00]v1:<00:01.00>Lead<00:05.00> +[00:02.00]v2:<00:02.00>Guest<00:04.00> +[00:06.00]v3:Third +''').lines; + Set active(int seconds) { + final position = Duration(seconds: seconds); + return activeLyricIndices( + lines, + position, + LyricsParser.activeIndex(lines, position), + ); + } + + expect(active(0), isEmpty); + expect(active(1), {0}); + expect(active(3), {0, 1}); + expect(active(4), {0}); + expect(active(5), isEmpty); + expect(active(6), {2}); + expect(active(3), {0, 1}); + expect(active(1), {0}); + }, + ); + List<(int, int)> gaps(String text) => lyricsTimelineWithGaps(LyricsParser.parse(text).lines) .where((line) => line.text.isEmpty) diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index d2c4e79b..1788ab45 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2491,6 +2491,124 @@ void main() { } for (final mornye in [false, true]) { + testWidgets( + 'singers keep their side before and during overlapping vocals ($mornye)', + (tester) async { + metadataOverrides['lyrics'] = + '[x-romaji:2000:${base64.encode(utf8.encode('Guest reading'))}]\n' + '[x-translation:2000:${base64.encode(utf8.encode('Guest translation'))}]\n' + '[00:01.00]v1:<00:01.00>Lead<00:08.00>\n' + '[00:02.00]v2:<00:02.00>Guest<00:06.00>'; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 1100), + playbackEvents: playback.stream, + ); + mediaItems.add(item('first')); + await tester.pumpAndSettle(); + if (mornye) { + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + } else { + await tester.drag(find.byType(PageView), const Offset(-350, 0)); + } + await tester.pumpAndSettle(); + expect( + tester.widget(find.text('Lead')).textAlign, + TextAlign.left, + ); + for (final text in ['Guest', 'Guest reading', 'Guest translation']) { + expect( + tester.widget(find.text(text)).textAlign, + TextAlign.right, + ); + } + expect(find.textContaining('v1:'), findsNothing); + expect(find.textContaining('v2:'), findsNothing); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 3), + ), + ); + await tester.pumpAndSettle(); + for (final text in ['Lead', 'Guest']) { + final paint = find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == text, + ), + matching: find.byType(CustomPaint), + ); + expect( + paint, + findsOneWidget, + reason: 'Both overlapping parts stay highlighted', + ); + final painter = tester.widget(paint).painter!; + final size = tester.getSize(paint); + final bounds = 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, + ))!; + var left = image.width; + var right = 0; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + if (bytes.getUint8((y * image.width + x) * 4 + 3) > 10) { + if (x < left) left = x; + if (x > right) right = x; + } + } + } + image.dispose(); + picture.dispose(); + return (left, right); + }); + if (text == 'Lead') { + expect(bounds!.$1, lessThan(5)); + } else { + expect(bounds!.$2, greaterThan(size.width - 5)); + } + } + expect( + tester.widget(find.text('Guest translation')).textAlign, + TextAlign.right, + ); + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 7), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Guest'), findsOneWidget); + expect(find.text('Lead'), findsNothing); + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: const Duration(seconds: 9), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Lead'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('player shows original, romanization and English ($mornye)', ( tester, ) async {