From ba44bafa929c00bc05a2d3022860a2b0ee5d05ce Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:42:08 +0700 Subject: [PATCH] fix: sync lyric romanization and clear stale selection menus Preserve romanization and translation metadata through Rust exports and cache, match rounded line times, and retain word endings. Highlight romanization with the main lyric clock in Material and Mornye. Clear selection overlay ownership safely when collection screens are disposed. Add parser, rendering, cache and overlay lifecycle regression coverage. --- .../spotiflac/NativeFinalizationPolicy.kt | 2 +- .../test/resources/lyrics_usability_cases.tsv | 2 + lib/screens/now_playing_screen.dart | 134 +++++-- lib/utils/lyrics_metadata_helper.dart | 2 +- lib/utils/lyrics_parser.dart | 209 +++++++++-- lib/widgets/selection_bottom_bar.dart | 24 +- rust_backend/crates/core/src/lyrics.rs | 23 ++ rust_backend/crates/core/src/lyrics/lrc.rs | 72 +++- .../crates/core/src/lyrics/payloads.rs | 348 +++++++++++++++++- .../providers/src/lyrics/builtin/apple.rs | 4 +- .../crates/providers/src/lyrics/cache.rs | 61 ++- test/lyrics_parser_test.dart | 172 +++++++++ test/now_playing_lyrics_transition_test.dart | 121 ++++++ test/selection_overlay_lifecycle_test.dart | 179 +++++++++ 14 files changed, 1286 insertions(+), 67 deletions(-) create mode 100644 test/lyrics_parser_test.dart create mode 100644 test/selection_overlay_lifecycle_test.dart 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 b7f4668c..df89b7a3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -30,7 +30,7 @@ internal object NativeFinalizationPolicy { } private val lyricsMetadataLinePattern = Regex( - "^\\[[a-z][a-z0-9_]*:.*]$", + "^\\[[a-z][a-z0-9_-]*:.*]$", RegexOption.IGNORE_CASE, ) private val lyricsBackgroundPattern = Regex( diff --git a/android/app/src/test/resources/lyrics_usability_cases.tsv b/android/app/src/test/resources/lyrics_usability_cases.tsv index 852bcc0a..344c12d9 100644 --- a/android/app/src/test/resources/lyrics_usability_cases.tsv +++ b/android/app/src/test/resources/lyrics_usability_cases.tsv @@ -21,3 +21,5 @@ timed_background_text true [BG:[00:00.00]<00:00.01> v1: Backing vocal] multiple_timed_text true [00:01.00][00:02.00]Repeated line metadata_and_text true [ti:Title]\n[00:00.00]Actual lyric unicode_text true [00:00.00]<00:00.01> v1: 日本語の歌詞 +supplements_only false [x-romaji:1000:YQ==]\n[x-translation:1000:Yg==] +supplements_and_text true [x-romaji:1000:YQ==]\n[x-translation:1000:Yg==]\n[00:01.00]Original diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 2e8c5088..13a2a719 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2324,7 +2324,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { style: style, ); painter.layout(maxWidth: width); - extents.add(painter.height + 32); + var height = painter.height + 32; + for (final (text, style, _) in _lyricSupplements(context, line)) { + painter.text = TextSpan(text: text, style: style); + painter.layout(maxWidth: width); + height += 6 + painter.height; + } + extents.add(height); } _lineExtents = extents; painter.dispose(); @@ -2470,8 +2476,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { ? '\u00b7\u00b7\u00b7' : line.text; + final timed = + isActive && + (line.hasWordTiming || line.romanizationWords.isNotEmpty); Widget content; - if (isActive && line.hasWordTiming) { + if (timed) { content = _WordHighlightedLyricLine( line: line, colorScheme: widget.colorScheme, @@ -2495,6 +2504,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { color: color, ), ); + content = _withLyricSupplements(context, line, content, color); } content = AnimatedSwitcher( duration: const Duration(milliseconds: 320), @@ -2502,7 +2512,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( - key: ValueKey(isActive && line.hasWordTiming), + key: ValueKey(timed), child: mornye ? SizedBox(width: double.infinity, child: content) : content, @@ -2560,6 +2570,61 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } } +Iterable<(String, TextStyle, List)> _lyricSupplements( + BuildContext context, + LyricLine line, +) sync* { + final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); + for (final (text, translation) in [ + (line.romanization, false), + (line.translation, true), + ]) { + if (text == null || text.trim().isEmpty) continue; + yield ( + text, + base.copyWith( + fontSize: context.isMornye + ? (translation ? 15 : 17) + : (translation ? 14 : 16), + height: 1.35, + fontWeight: FontWeight.w500, + ), + translation ? const [] : line.romanizationWords, + ); + } +} + +Widget _withLyricSupplements( + BuildContext context, + LyricLine line, + Widget primary, + Color color, { + Widget Function(String, List, TextStyle)? timedText, +}) { + if (line.romanization == null && line.translation == null) return primary; + return Column( + crossAxisAlignment: context.isMornye + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + primary, + for (final (text, style, words) in _lyricSupplements(context, line)) ...[ + const SizedBox(height: 6), + if (words.isNotEmpty && timedText != null) + timedText(text, words, style) + else + Text( + text, + textAlign: context.isMornye ? TextAlign.start : TextAlign.center, + style: style.copyWith( + color: color.withValues(alpha: color.a * 0.8), + ), + ), + ], + ], + ); +} + class _WordHighlightedLyricLine extends ConsumerStatefulWidget { final LyricLine line; final ColorScheme colorScheme; @@ -2657,10 +2722,12 @@ class _WordHighlightedLyricLineState if (!mounted) return; final predicted = _currentPosition(); _anchorAt( - reconcileSyncedLyricsPosition( - predictedPosition: predicted, - reportedPosition: position, - ), + _shouldAnimate + ? reconcileSyncedLyricsPosition( + predictedPosition: predicted, + reportedPosition: position, + ) + : position, ); setState(() {}); } @@ -2670,7 +2737,7 @@ class _WordHighlightedLyricLineState final position = _currentPosition(); if (playing != null) _playing = playing; if (loading != null) _loading = loading; - _anchorAt(position); + _anchorAt(_shouldAnimate ? position : ref.read(playbackPositionProvider)); _syncAnimationClock(); setState(() {}); } @@ -2685,10 +2752,12 @@ class _WordHighlightedLyricLineState } } - Duration _segmentEnd(int index) { - final start = widget.line.words[index].time; - if (index + 1 < widget.line.words.length) { - final next = widget.line.words[index + 1].time; + Duration _segmentEnd(List words, int index) { + final word = words[index]; + final start = word.time; + if (word.end != null && word.end! >= start) return word.end!; + if (index + 1 < words.length) { + final next = words[index + 1].time; if (next > start) return next; } final lineEnd = widget.line.end; @@ -2712,6 +2781,32 @@ class _WordHighlightedLyricLineState } Widget _buildHighlightedLine(BuildContext context) { + final style = + (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) + .copyWith( + fontSize: context.isMornye ? 28 : null, + height: context.isMornye ? 1.3 : 1.4, + fontWeight: FontWeight.bold, + ); + final primary = widget.line.hasWordTiming + ? _buildTimedText(widget.line.text, widget.line.words, style) + : Text( + widget.line.text, + textAlign: context.isMornye ? TextAlign.start : TextAlign.center, + style: style.copyWith(color: widget.colorScheme.onSurface), + ); + // Both scripts share this state's position interpolation and animation + // clock, including pause, seek and track changes. + return _withLyricSupplements( + context, + widget.line, + primary, + widget.colorScheme.onSurface, + timedText: _buildTimedText, + ); + } + + Widget _buildTimedText(String text, List words, TextStyle style) { final highlightedColor = widget.colorScheme.onSurface; final mornye = context.isMornye; final pendingColor = mornye @@ -2720,11 +2815,11 @@ class _WordHighlightedLyricLineState final segments = []; final starts = []; final ends = []; - for (var index = 0; index < widget.line.words.length; index++) { - final word = widget.line.words[index]; + for (var index = 0; index < words.length; index++) { + final word = words[index]; segments.add(word.text); starts.add(word.time); - ends.add(_segmentEnd(index)); + ends.add(_segmentEnd(words, index)); } return _SweepingTimedLyricText( @@ -2733,16 +2828,11 @@ class _WordHighlightedLyricLineState ends: ends, currentPosition: _currentPosition, repaint: _animationClock, - style: (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) - .copyWith( - fontSize: mornye ? 28 : null, - height: mornye ? 1.3 : 1.4, - fontWeight: FontWeight.bold, - ), + style: style, textAlign: mornye ? TextAlign.start : TextAlign.center, pendingColor: pendingColor, highlightedColor: highlightedColor, - semanticsLabel: widget.line.text, + semanticsLabel: text, ); } } diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index 243f7bb4..e9df133a 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -8,7 +8,7 @@ final RegExp _lrcDisplayTimestampPattern = RegExp( r'^\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]', ); final RegExp _lrcDisplayMetadataPattern = RegExp( - r'^\[[a-zA-Z][a-zA-Z0-9_]*:.*\]$', + r'^\[[a-zA-Z][a-zA-Z0-9_-]*:.*\]$', ); final RegExp _lrcDisplayInlineTimestampPattern = RegExp( r'<\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?>', diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index 415686a5..41d74930 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -1,10 +1,13 @@ +import 'dart:convert'; + import 'package:xml/xml.dart'; class LyricWord { final Duration time; + final Duration? end; final String text; - const LyricWord({required this.time, required this.text}); + const LyricWord({required this.time, this.end, required this.text}); } class LyricLine { @@ -12,12 +15,18 @@ class LyricLine { final Duration? end; final String text; final List words; + final String? romanization; + final List romanizationWords; + final String? translation; const LyricLine({ required this.time, this.end, required this.text, this.words = const [], + this.romanization, + this.romanizationWords = const [], + this.translation, }); bool get hasWordTiming => words.isNotEmpty; @@ -61,7 +70,11 @@ class LyricsParser { // ID tags such as [ti:..], [ar:..], [offset:..]. static final RegExp _idTag = RegExp( - r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder):.*\]$', + r'^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$', + caseSensitive: false, + ); + static final RegExp _supplementTag = RegExp( + r'^\[x-(romaji-words|romaji|translation):(\d+):([^\]]*)\]$', caseSensitive: false, ); @@ -102,6 +115,9 @@ class LyricsParser { final rawLines = text.split(RegExp(r'\r\n|\r|\n')); final parsed = []; final plainBuffer = []; + final romanization = {}; + final romanizationWords = >{}; + final translation = {}; var sawTimestamp = false; var sawWordTiming = false; var offsetMs = 0; @@ -110,6 +126,29 @@ class LyricsParser { final line = rawLine.trimRight(); if (line.trim().isEmpty) continue; + final supplement = _supplementTag.firstMatch(line.trim()); + if (supplement != null) { + final time = int.tryParse(supplement.group(2)!); + try { + final value = utf8.decode(base64.decode(supplement.group(3)!)).trim(); + if (time != null && value.isNotEmpty) { + final kind = supplement.group(1)!.toLowerCase(); + if (kind == 'romaji-words') { + final words = _parseRomanizationWords(value); + if (words.isNotEmpty) { + romanizationWords.putIfAbsent(time, () => words); + } + } else { + final target = kind == 'romaji' ? romanization : translation; + target.putIfAbsent(time, () => value); + } + } + } on FormatException { + // Optional corrupt metadata must not hide the original lyrics. + } + continue; + } + // Capture [offset:] for timing correction, drop other ID tags. final idMatch = _idTag.firstMatch(line.trim()); if (idMatch != null) { @@ -147,7 +186,14 @@ class LyricsParser { for (final tm in timeMatches) { final d = _toDuration(tm.group(1), tm.group(2), tm.group(3)); if (d == null) continue; - parsed.add(LyricLine(time: d, text: cleanContent, words: words)); + parsed.add( + LyricLine( + time: d, + end: words.lastOrNull?.end, + text: cleanContent, + words: words, + ), + ); } } @@ -157,34 +203,44 @@ class LyricsParser { synced: false, wordSynced: false, lines: const [], - plainText: rawLines - .map((l) => l.trim()) - .where((l) => l.isNotEmpty && _idTag.firstMatch(l) == null) - .join('\n'), + plainText: plainBuffer.where((l) => l.isNotEmpty).join('\n'), ); } parsed.sort((a, b) => a.time.compareTo(b.time)); + final alignedRomanization = _alignSupplements(parsed, romanization); + final alignedRomanizationWords = _alignSupplements( + parsed, + romanizationWords, + ); + final alignedTranslation = _alignSupplements(parsed, translation); - final adjusted = offsetMs == 0 + final adjusted = + offsetMs == 0 && + alignedRomanization.isEmpty && + alignedTranslation.isEmpty ? parsed - : parsed - .map( - (l) => LyricLine( - time: _shift(l.time, offsetMs), - end: l.end, - text: l.text, - words: l.words - .map( - (w) => LyricWord( - time: _shift(w.time, offsetMs), - text: w.text, - ), - ) - .toList(), - ), - ) - .toList(); + : parsed.map((l) { + final romanization = alignedRomanization[l.time.inMilliseconds]; + final words = + alignedRomanizationWords[l.time.inMilliseconds] ?? + const []; + // Keep readable text when optional timings are incomplete or + // belong to a different revision of the transliteration. + final validWords = + words.map((word) => word.text).join() == romanization + ? words + : const []; + return LyricLine( + time: _shift(l.time, offsetMs), + end: l.end == null ? null : _shift(l.end!, offsetMs), + text: l.text, + romanization: romanization, + romanizationWords: _shiftWords(validWords, offsetMs), + translation: alignedTranslation[l.time.inMilliseconds], + words: _shiftWords(l.words, offsetMs), + ); + }).toList(); return ParsedLyrics( synced: true, @@ -194,6 +250,88 @@ class LyricsParser { ); } + /// Older files may have centisecond line times alongside millisecond + /// supplement times. Resolve each supplement to one nearest line, keeping + /// exact matches when multiple metadata entries compete for that line. + static Map _alignSupplements( + List lines, + Map supplements, + ) { + final matched = {}; + for (final entry in supplements.entries) { + var lo = 0; + var hi = lines.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (lines[mid].time.inMilliseconds < entry.key) { + lo = mid + 1; + } else { + hi = mid; + } + } + int? nearest; + var distance = 11; + for (final index in [lo - 1, lo]) { + if (index < 0 || index >= lines.length) continue; + final time = lines[index].time.inMilliseconds; + final delta = (time - entry.key).abs(); + if (delta < distance) { + nearest = time; + distance = delta; + } + } + if (nearest == null) continue; + final previous = matched[nearest]; + if (previous == null || distance < previous.$1) { + matched[nearest] = (distance, entry.value); + } + } + return matched.map((time, match) => MapEntry(time, match.$2)); + } + + static List _parseRomanizationWords(String raw) { + final value = jsonDecode(raw); + if (value is! List) return const []; + final words = []; + for (final item in value) { + if (item case { + 'text': final String text, + 'startTimeMs': final int start, + 'endTimeMs': final int end, + }) { + if (text.trim().isEmpty || + start < 0 || + end < start || + (words.isNotEmpty && words.last.time.inMilliseconds > start)) { + return const []; + } + words.add( + LyricWord( + time: Duration(milliseconds: start), + end: Duration(milliseconds: end), + text: text, + ), + ); + } else { + return const []; + } + } + return words; + } + + static List _shiftWords(List words, int offsetMs) { + if (offsetMs == 0 || words.isEmpty) return words; + return words + .map( + (word) => LyricWord( + time: _shift(word.time, offsetMs), + end: word.end == null ? null : _shift(word.end!, offsetMs), + text: word.text, + ), + ) + .toList(growable: false); + } + static Duration _shift(Duration d, int offsetMs) { // LRC offset: positive value shifts lyrics earlier. final ms = d.inMilliseconds - offsetMs; @@ -214,7 +352,17 @@ class LyricsParser { ? matches[i + 1].start : content.length; final word = content.substring(start, end); - if (word.trim().isEmpty) continue; + if (word.trim().isEmpty) { + final previous = words.lastOrNull; + if (previous != null && previous.end == null && d >= previous.time) { + words[words.length - 1] = LyricWord( + time: previous.time, + end: d, + text: previous.text, + ); + } + continue; + } words.add(LyricWord(time: d, text: word)); } return words; @@ -242,7 +390,14 @@ class LyricsParser { final sBegin = _parseClock(span.getAttribute('begin')); final spanText = span.innerText; if (sBegin != null && spanText.trim().isNotEmpty) { - words.add(LyricWord(time: sBegin, text: '$spanText ')); + final sEnd = _parseClock(span.getAttribute('end')); + words.add( + LyricWord( + time: sBegin, + end: sEnd != null && sEnd >= sBegin ? sEnd : null, + text: '$spanText ', + ), + ); } } } diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index c7db3c00..770c6220 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/scheduler.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -29,21 +30,36 @@ class _SelectionOverlayHostState extends State { _owner == owner && _builder != null; void show(SelectionOverlayController owner, WidgetBuilder builder) { - if (!mounted) return; - setState(() { + _update(() { _owner = owner; _builder = builder; }); } void hide(SelectionOverlayController owner) { - if (!mounted || _owner != owner) return; - setState(() { + if (_owner != owner) return; + _update(() { _owner = null; _builder = null; }); } + /// A screen can remove its bar from dispose while Flutter has locked the + /// tree. Clear ownership immediately, then rebuild after that frame so the + /// bar cannot outlive the screen or retain its disposed callbacks. + void _update(VoidCallback change) { + if (!mounted) return; + if (SchedulerBinding.instance.schedulerPhase != + SchedulerPhase.persistentCallbacks) { + setState(change); + return; + } + change(); + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + @override Widget build(BuildContext context) { final owner = _owner; diff --git a/rust_backend/crates/core/src/lyrics.rs b/rust_backend/crates/core/src/lyrics.rs index dadb78f1..bbf63a3c 100644 --- a/rust_backend/crates/core/src/lyrics.rs +++ b/rust_backend/crates/core/src/lyrics.rs @@ -41,18 +41,41 @@ pub fn text_from_bytes(bytes: &[u8]) -> String { crate::text::utf8(bytes) } +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LyricsWord { + pub text: String, + pub start_time_ms: i64, + pub end_time_ms: i64, +} + +json::go_deserialize!(LyricsWord { + "text" => text, + "starttimems" => start_time_ms, + "endtimems" => end_time_ms, +}); + #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LyricsLine { pub start_time_ms: i64, pub words: String, pub end_time_ms: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub romanization: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub romanization_words: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub translation: Option, } json::go_deserialize!(LyricsLine { "starttimems" => start_time_ms, "words" => words, "endtimems" => end_time_ms, + "romanization" => romanization, + "romanizationwords" => romanization_words, + "translation" => translation, }); #[derive(Clone, Debug, Default, PartialEq, Serialize)] diff --git a/rust_backend/crates/core/src/lyrics/lrc.rs b/rust_backend/crates/core/src/lyrics/lrc.rs index 0fc59c68..c3bc6444 100644 --- a/rust_backend/crates/core/src/lyrics/lrc.rs +++ b/rust_backend/crates/core/src/lyrics/lrc.rs @@ -1,12 +1,13 @@ use super::{LyricsLine, LyricsResponse}; use crate::matching::lowercase; +use base64::{Engine, engine::general_purpose::STANDARD}; use regex::Regex; use std::sync::LazyLock; static TIMED_LINE: LazyLock = LazyLock::new(|| Regex::new(r"\[([0-9]{2}):([0-9]{2})\.([0-9]{2,3})\](.*)").unwrap()); static METADATA: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)^\[[a-z][a-z0-9_]*:.*\]$").unwrap()); + LazyLock::new(|| Regex::new(r"(?i)^\[[a-z][a-z0-9_-]*:.*\]$").unwrap()); static BACKGROUND: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\[bg:(.*)\]$").unwrap()); static LEADING_TIME: LazyLock = LazyLock::new(|| Regex::new(r"^\[[0-9]{1,3}:[0-9]{1,2}(?:[.:][0-9]{1,3})?\]").unwrap()); @@ -78,6 +79,7 @@ pub fn parse_synced(raw: &str) -> Option> { start_time_ms: minutes * 60_000 + seconds * 1000 + fraction, words: words.into(), end_time_ms: 0, + ..LyricsLine::default() }); } } @@ -113,13 +115,14 @@ pub fn plain_from_timed_lines(lines: &[LyricsLine]) -> String { } pub fn timestamp_inline(ms: i64) -> String { + let ms = ms.max(0); let seconds = ms / 1000; - format!( - "{:02}:{:02}.{:02}", - seconds / 60, - seconds % 60, - ms % 1000 / 10 - ) + let fraction = if ms % 10 == 0 { + format!("{:02}", ms % 1000 / 10) + } else { + format!("{:03}", ms % 1000) + }; + format!("{:02}:{:02}.{fraction}", seconds / 60, seconds % 60) } pub fn timestamp(ms: i64) -> String { @@ -175,6 +178,32 @@ pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> Stri output.push_str(&format!(" (source: {source})")); } output.push_str("]\n\n"); + if lyrics.sync_type == "LINE_SYNCED" { + for line in lyrics.lines() { + if let Some(words) = &line.romanization_words + && !words.is_empty() + && let Ok(json) = serde_json::to_vec(words) + { + output.push_str(&format!( + "[x-romaji-words:{}:{}]\n", + line.start_time_ms, + STANDARD.encode(json) + )); + } + for (tag, text) in [ + ("x-romaji", &line.romanization), + ("x-translation", &line.translation), + ] { + if let Some(text) = text.as_deref().filter(|text| !text.trim().is_empty()) { + output.push_str(&format!( + "[{tag}:{}:{}]\n", + line.start_time_ms, + STANDARD.encode(text.trim()) + )); + } + } + } + } for line in lyrics.lines() { if line.words.is_empty() { continue; @@ -187,3 +216,32 @@ pub fn with_metadata(lyrics: &LyricsResponse, track: &str, artist: &str) -> Stri } output } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writing_timestamps_never_discards_milliseconds() { + for time in [0, 1000, 1009, 12345, 59999, 60000, 3599999] { + let raw = format!("{}Original", timestamp(time)); + assert_eq!(parse_synced(&raw).unwrap()[0].start_time_ms, time); + } + assert_eq!(timestamp_inline(1000), "00:01.00"); + assert_eq!(timestamp_inline(1009), "00:01.009"); + } + + #[test] + fn optional_text_is_safe_metadata_not_lyric_content() { + let raw = "[x-romaji:1000:YQ==]\n[x-translation:1000:Yg==]"; + assert!(!has_usable_content(raw)); + let mut lyrics = + LyricsResponse::from_text("[00:01.009]Original", "Apple Music", "Apple Music"); + lyrics.lines.as_mut().unwrap()[0].translation = Some("Text ]\nnext line 日本語".into()); + let lrc = with_metadata(&lyrics, "Track", "Artist"); + let encoded = STANDARD.encode("Text ]\nnext line 日本語"); + assert!(lrc.contains(&format!("[x-translation:1009:{encoded}]"))); + assert!(lrc.contains("[00:01.009]Original")); + assert!(has_usable_content(&lrc)); + } +} diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index e4081ee5..1a823749 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -1,7 +1,7 @@ -use super::{LyricsResponse, json, lrc}; +use super::{LyricsResponse, LyricsWord, json, lrc}; use serde::Serialize; use serde_json::value::RawValue; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Debug, Default, Serialize)] pub struct PaxDetail { @@ -166,6 +166,153 @@ pub fn format_apple(raw: &str, multi_person: bool, word_timing: bool) -> Result< Err("failed to parse pax lyrics response".into()) } +/// Attach optional Apple text to the actual output lines once. The proxy's +/// eLRC can round in either direction, so metadata timestamps need not be +/// identical to the selected line timestamps. Exact matches win collisions. +pub fn apple_supplements(raw: &str, lyrics: &mut LyricsResponse) { + if lyrics.sync_type != "LINE_SYNCED" { + return; + } + #[derive(Default)] + struct Payload { + metadata: Option>, + } + json::go_deserialize!(Payload { "metadata" => metadata, }); + + let metadata = json::decode::(raw) + .ok() + .and_then(|payload| payload.metadata) + .and_then(|raw| json::decode::(raw.get()).ok()); + let Some(metadata) = metadata else { return }; + let starts: BTreeSet<_> = lyrics + .lines() + .iter() + .map(|line| line.start_time_ms) + .collect(); + let language = metadata["language"].as_str().unwrap_or_default(); + let romanization = + apple_supplement_lines(&metadata["transliterations"], &starts, language, |lang| { + lang.split('-') + .any(|part| part.eq_ignore_ascii_case("Latn")) + }); + let translation = apple_supplement_lines(&metadata["translations"], &starts, "en", |lang| { + lang.split('-') + .next() + .is_some_and(|part| part.eq_ignore_ascii_case("en")) + }); + for line in lyrics.lines.iter_mut().flatten() { + if let Some(supplement) = romanization.get(&line.start_time_ms) { + line.romanization = Some(supplement.text.clone()); + line.romanization_words = supplement.words.clone(); + } + line.translation = translation + .get(&line.start_time_ms) + .map(|line| line.text.clone()); + } +} + +struct AppleSupplement { + text: String, + words: Option>, +} + +// Spans are syllables, not necessarily words: "utsu" + "kushii" must remain +// joined. Recover spaces from the full text instead of adding one per span. +fn apple_supplement_words(text: &str, spans: &serde_json::Value) -> Option> { + let mut words: Vec = Vec::new(); + let mut cursor = 0; + for span in spans.as_array()? { + let part = span["text"].as_str()?.trim(); + if part.is_empty() { + continue; + } + let start = span["begin"].as_i64()?; + let end = span["end"].as_i64()?; + if start < 0 || end < start || words.last().is_some_and(|word| word.start_time_ms > start) { + return None; + } + let offset = text[cursor..].find(part)?; + let gap = &text[cursor..cursor + offset]; + if !gap.chars().all(char::is_whitespace) { + return None; + } + if let Some(previous) = words.last_mut() { + previous.text.push_str(gap); + } + words.push(LyricsWord { + text: part.into(), + start_time_ms: start, + end_time_ms: end, + }); + cursor += offset + part.len(); + } + if words.is_empty() || !text[cursor..].trim().is_empty() { + return None; + } + Some(words) +} + +fn apple_supplement_lines( + groups: &serde_json::Value, + starts: &BTreeSet, + language: &str, + accepts_language: impl Fn(&str) -> bool, +) -> BTreeMap { + let mut best = BTreeMap::new(); + let mut best_rank = (false, 0); + for group in groups.as_array().into_iter().flatten() { + let Some(lang) = group["lang"].as_str().filter(|lang| accepts_language(lang)) else { + continue; + }; + let mut matched: BTreeMap = BTreeMap::new(); + for line in group["lines"].as_array().into_iter().flatten() { + let (Some(time), Some(text)) = (line["timestamp"].as_i64(), line["text"].as_str()) + else { + continue; + }; + if time < 0 || text.trim().is_empty() { + continue; + } + let Some(&start) = starts + .range(time.saturating_sub(10)..=time.saturating_add(10)) + .min_by_key(|&&start| (start.abs_diff(time), start)) + else { + continue; + }; + let distance = start.abs_diff(time); + if matched.get(&start).is_none_or(|(old, _)| distance < *old) { + let text = text.trim(); + matched.insert( + start, + ( + distance, + AppleSupplement { + text: text.into(), + words: apple_supplement_words(text, &line["spans"]), + }, + ), + ); + } + } + // Prefer the song's language, then coverage. Empty or malformed + // optional groups never suppress a usable later alternative. + let same_language = lang + .split('-') + .next() + .unwrap_or_default() + .eq_ignore_ascii_case(language.split('-').next().unwrap_or_default()); + let rank = (same_language, matched.len()); + if !matched.is_empty() && (best.is_empty() || rank > best_rank) { + best_rank = rank; + best = matched + .into_iter() + .map(|(start, (_, text))| (start, text)) + .collect(); + } + } + best +} + /// Failure strings describe unavailable proxy payloads, not an absent track. pub fn parse_proxy( raw: &str, @@ -310,3 +457,200 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo } lines.join("\n").trim().into() } + +#[cfg(test)] +mod supplement_tests { + use super::*; + + fn payload(lang: &str) -> serde_json::Value { + serde_json::json!({ + "type": "Syllable", + "elrc": "[00:01.010]<00:01.009>Original <00:01.307>\n[00:02.000]Next", + "content": [{"timestamp": 1009, "text": [ + {"text": "Original", "timestamp": 1009, "endtime": 1307} + ]}, {"timestamp": 2001, "text": [{"text": "Next"}]}], + "metadata": { + "language": lang, + "transliterations": [{"lang": format!("{lang}-Latn"), "lines": [ + {"timestamp": 1009, "text": "Romanized"}, + {"timestamp": 2001, "text": "Next romanized"} + ]}], + "translations": [{"lang": "en-US", "lines": [ + {"timestamp": 1009, "text": "English"}, + {"timestamp": 2001, "text": "Next English"} + ]}] + } + }) + } + + #[test] + fn supplements_follow_output_timestamps_in_both_apple_modes() { + for lang in ["ja", "ko", "zh-Hans"] { + for word_timing in [false, true] { + let raw = payload(lang).to_string(); + let text = format_apple(&raw, false, word_timing).unwrap(); + let mut lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music"); + apple_supplements(&raw, &mut lyrics); + assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized")); + assert_eq!( + lyrics.lines()[1].translation.as_deref(), + Some("Next English") + ); + let start = if word_timing { 1010 } else { 1009 }; + assert_eq!(lyrics.lines()[0].start_time_ms, start); + let exported = lrc::with_metadata(&lyrics, "Track", "Artist"); + assert!(exported.contains(&format!("[x-romaji:{start}:Um9tYW5pemVk]"))); + assert!(exported.contains(&lrc::timestamp(start))); + if word_timing { + assert!(exported.contains("<00:01.307>")); + } + // Persistence uses this same JSON response contract. + let restored: LyricsResponse = + serde_json::from_str(&serde_json::to_string(&lyrics).unwrap()).unwrap(); + assert_eq!(restored, lyrics); + } + } + } + + #[test] + fn nearest_matching_is_bounded_and_exact_matches_win() { + let mut raw = payload("ja"); + raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([ + {"timestamp": 1009, "text": "Rounded"}, + {"timestamp": 1010, "text": "Exact"}, + {"timestamp": 1001, "text": "Less accurate"}, + {"timestamp": 2011, "text": "Too far"}, + {"timestamp": 2990, "text": "Boundary"}, + {"timestamp": 4005, "text": "Tie"} + ]); + let mut lyrics = LyricsResponse::from_text( + "[00:01.010]A\n[00:02.00]B\n[00:03.00]C\n[00:04.00]D\n[00:04.010]E", + "", + "", + ); + apple_supplements(&raw.to_string(), &mut lyrics); + let actual: Vec<_> = lyrics + .lines() + .iter() + .map(|line| line.romanization.as_deref()) + .collect(); + assert_eq!( + actual, + [Some("Exact"), None, Some("Boundary"), Some("Tie"), None] + ); + } + + #[test] + fn malformed_optional_lines_do_not_hide_other_supplements() { + let mut raw = payload("ja"); + raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([ + null, {"text": "Missing time"}, {"timestamp": "1009", "text": "Bad time"}, + {"timestamp": 1009, "text": []}, {"timestamp": -1, "text": "Negative"}, + {"timestamp": 2001, "text": " Next romanized "} + ]); + let mut lyrics = LyricsResponse::from_text("[00:01.01]A\n[00:02.00]B", "", ""); + apple_supplements(&raw.to_string(), &mut lyrics); + assert!(lyrics.lines()[0].romanization.is_none()); + assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English")); + assert_eq!( + lyrics.lines()[1].romanization.as_deref(), + Some("Next romanized") + ); + } + + #[test] + fn language_selection_prefers_song_language_and_usable_groups() { + let mut raw = payload("ja"); + raw["metadata"]["transliterations"] = serde_json::json!([ + {"lang": "ja-Latn", "lines": []}, + {"lang": "ko-Latn", "lines": [{"timestamp": 1009, "text": "Other language"}]}, + {"lang": "ja-Kana", "lines": [{"timestamp": 1009, "text": "Not Latin"}]}, + {"lang": "ja-Latn", "lines": [{"timestamp": 1009, "text": "Romanized"}]} + ]); + raw["metadata"]["translations"] = serde_json::json!([ + {"lang": "en", "lines": null}, + {"lang": "fr", "lines": [{"timestamp": 1009, "text": "French"}]}, + {"lang": "en-GB", "lines": [{"timestamp": 1009, "text": "English"}]} + ]); + let mut lyrics = LyricsResponse::from_text("[00:01.01]Original", "", ""); + apple_supplements(&raw.to_string(), &mut lyrics); + assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized")); + assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English")); + } + + #[test] + fn absent_or_invalid_metadata_preserves_original_lyrics() { + for raw in [ + "{}", + "not json", + r#"{"metadata":null}"#, + r#"{"metadata":{"translations":5}}"#, + ] { + let mut lyrics = LyricsResponse::from_text("[00:01.00]Original", "", ""); + let original = lyrics.clone(); + apple_supplements(raw, &mut lyrics); + assert_eq!(lyrics, original); + } + let mut plain = LyricsResponse::from_text("Original", "", ""); + apple_supplements(&payload("ja").to_string(), &mut plain); + assert!(plain.lines()[0].romanization.is_none()); + } + + #[test] + fn romanization_spans_keep_syllables_spaces_and_original_word_times() { + let mut raw = payload("ja"); + raw["metadata"]["transliterations"][0]["lines"][0] = serde_json::json!({ + "timestamp": 1009, "text": "Roma nized", "spans": [ + {"text": "Ro", "begin": 1009, "end": 1107}, + {"text": "ma", "begin": 1107, "end": 1307}, + {"text": "nized", "begin": 2003, "end": 2497} + ] + }); + let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", ""); + apple_supplements(&raw.to_string(), &mut lyrics); + let words = lyrics.lines()[0].romanization_words.as_ref().unwrap(); + assert_eq!( + words + .iter() + .map(|word| word.text.as_str()) + .collect::>(), + ["Ro", "ma ", "nized"] + ); + assert_eq!(words[0].start_time_ms, 1009); + assert_eq!(words[1].end_time_ms, 1307); + assert_eq!(words[2].start_time_ms, 2003); + assert_eq!(words[2].end_time_ms, 2497); + + let output = lrc::with_metadata(&lyrics, "Track", "Artist"); + let encoded = output + .lines() + .find_map(|line| { + line.strip_prefix("[x-romaji-words:1010:") + .and_then(|line| line.strip_suffix(']')) + }) + .unwrap(); + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let restored: Vec = serde_json::from_slice(&decoded).unwrap(); + assert_eq!(&restored, words); + } + + #[test] + fn invalid_romanization_timing_falls_back_to_readable_text() { + for spans in [ + serde_json::json!([{"text": "Other", "begin": 1009, "end": 1307}]), + serde_json::json!([{"text": "Romanized", "begin": 1009, "end": 900}]), + serde_json::json!([{"text": "Romanized", "begin": 1009}]), + serde_json::json!([{"text": "Ro", "begin": 1009, "end": 1307}]), + ] { + let mut raw = payload("ja"); + raw["metadata"]["transliterations"][0]["lines"][0]["spans"] = spans; + let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", ""); + apple_supplements(&raw.to_string(), &mut lyrics); + assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized")); + assert!(lyrics.lines()[0].romanization_words.is_none()); + } + } +} diff --git a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs index 65055d9f..03457f69 100644 --- a/rust_backend/crates/providers/src/lyrics/builtin/apple.rs +++ b/rust_backend/crates/providers/src/lyrics/builtin/apple.rs @@ -166,7 +166,9 @@ impl BuiltinLyricsClient { Err(error) if raw.starts_with(['{', '[']) => return Err(LyricsError::Other(error)), Err(_) => raw.into(), }; - from_text(&text, "Apple Music", "Apple Music") + let mut lyrics = from_text(&text, "Apple Music", "Apple Music")?; + payloads::apple_supplements(raw, &mut lyrics); + Ok(lyrics) } } diff --git a/rust_backend/crates/providers/src/lyrics/cache.rs b/rust_backend/crates/providers/src/lyrics/cache.rs index 58dbe964..c0a7eb89 100644 --- a/rust_backend/crates/providers/src/lyrics/cache.rs +++ b/rust_backend/crates/providers/src/lyrics/cache.rs @@ -13,6 +13,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub const MAX_ENTRIES: usize = 500; pub const TTL: Duration = Duration::from_secs(24 * 60 * 60); const MAX_PERSISTED_BYTES: u64 = 64 << 20; +const SNAPSHOT_VERSION: u32 = 3; #[derive(Clone)] struct Entry { @@ -143,7 +144,7 @@ impl LyricsCache { let loaded = read_snapshot(path).unwrap_or_default(); let mut state = self.inner.state.lock().expect("lyrics cache lock"); state.path = Some(path.to_owned()); - if loaded.version == 1 { + if (1..=SNAPSHOT_VERSION).contains(&loaded.version) { for (key, entry) in loaded.entries { if state.entries.len() >= MAX_ENTRIES { break; @@ -151,6 +152,11 @@ impl LyricsCache { let Some(response) = entry.response else { continue; }; + // Older versions discarded Apple text or romanization timing. + // Refetch once, preserving other providers' caches. + if loaded.version < SNAPSHOT_VERSION && response.provider == "Apple Music" { + continue; + } let Some(expires_at) = u64::try_from(entry.expires_at) .ok() .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds))) @@ -267,7 +273,7 @@ fn write_snapshot(path: &Path, entries: &BTreeMap) -> std::io::Re entries: BTreeMap<&'a str, BorrowedEntry<'a>>, } let snapshot = BorrowedSnapshot { - version: 1, + version: SNAPSHOT_VERSION, entries: entries .iter() .map(|(key, entry)| { @@ -305,3 +311,54 @@ fn write_snapshot(path: &Path, entries: &BTreeMap) -> std::io::Re temporary.persist(path).map_err(|error| error.error)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_snapshot_refetches_apple_but_preserves_other_providers() { + let file = tempfile::NamedTempFile::new().unwrap(); + let now = UNIX_EPOCH + Duration::from_secs(100); + let apple = LyricsResponse::from_text("[00:01.00]Original", "Apple Music", "Apple Music"); + let other = LyricsResponse::from_text("[00:01.00]Original", "LRCLIB", "LRCLIB"); + for version in 1..SNAPSHOT_VERSION { + let snapshot = serde_json::json!({"version": version, "entries": { + "apple": {"response": apple, "expires_at": 200}, + "other": {"response": other, "expires_at": 200} + }}); + fs::write(file.path(), serde_json::to_vec(&snapshot).unwrap()).unwrap(); + let cache = LyricsCache::default(); + cache.set_persistence_path(file.path(), now); + assert!(cache.get("apple", now).is_none()); + assert_eq!(cache.get("other", now).unwrap().provider, "LRCLIB"); + } + } + + #[test] + fn current_snapshot_restores_supplementary_text() { + let file = tempfile::NamedTempFile::new().unwrap(); + let now = UNIX_EPOCH + Duration::from_secs(100); + let mut lyrics = + LyricsResponse::from_text("[00:01.009]Original", "Apple Music", "Apple Music"); + let line = &mut lyrics.lines.as_mut().unwrap()[0]; + line.romanization = Some("Romanized".into()); + line.romanization_words = Some(vec![spotiflac_core::lyrics::LyricsWord { + text: "Romanized".into(), + start_time_ms: 1009, + end_time_ms: 1307, + }]); + line.translation = Some("English".into()); + let entries = BTreeMap::from([( + "apple".into(), + Entry { + response: Arc::new(lyrics.clone()), + expires_at: now + Duration::from_secs(100), + }, + )]); + write_snapshot(file.path(), &entries).unwrap(); + let cache = LyricsCache::default(); + cache.set_persistence_path(file.path(), now); + assert_eq!(cache.get("apple", now), Some(lyrics)); + } +} diff --git a/test/lyrics_parser_test.dart b/test/lyrics_parser_test.dart new file mode 100644 index 00000000..f3291b10 --- /dev/null +++ b/test/lyrics_parser_test.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; + +String _tag(String kind, int time, String text) => + '[x-$kind:$time:${base64.encode(utf8.encode(text))}]'; + +void main() { + test('retains all three texts, word ends and millisecond timing', () { + final lyrics = LyricsParser.parse(''' +${_tag('romaji', 1009, 'Romanized')} +${_tag('translation', 1009, 'English')} +[00:01.009]<00:01.009>First <00:01.307><00:02.003>second<00:02.497> +'''); + final line = lyrics.lines.single; + expect(lyrics.wordSynced, isTrue); + expect(line.text, 'First second'); + expect(line.romanization, 'Romanized'); + expect(line.translation, 'English'); + expect(line.time.inMilliseconds, 1009); + expect(line.end?.inMilliseconds, 2497); + expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]); + expect(lyrics.plainText, 'First second'); + }); + + test('matches legacy and rounded eLRC tags within ten milliseconds', () { + final lyrics = LyricsParser.parse(''' +${_tag('romaji', 1009, 'Rounded up')} +${_tag('translation', 2009, 'Truncated')} +${_tag('romaji', 2990, 'Boundary')} +${_tag('translation', 4011, 'Too far')} +[00:01.01]A +[00:02.00]B +[00:03.00]C +[00:04.00]D +'''); + expect(lyrics.lines[0].romanization, 'Rounded up'); + expect(lyrics.lines[1].translation, 'Truncated'); + expect(lyrics.lines[2].romanization, 'Boundary'); + expect(lyrics.lines[3].translation, isNull); + }); + + test( + 'romanization timing aligns with rounded lines and applies the offset', + () { + final words = [ + {'text': 'Ro', 'startTimeMs': 1009, 'endTimeMs': 1107}, + {'text': 'ma ', 'startTimeMs': 1107, 'endTimeMs': 1307}, + {'text': 'nized', 'startTimeMs': 2003, 'endTimeMs': 2497}, + ]; + final line = LyricsParser.parse(''' +[offset:109] +${_tag('romaji-words', 1009, jsonEncode(words))} +${_tag('romaji', 1009, 'Roma nized')} +[00:01.01]Original +''').lines.single; + expect(line.romanization, 'Roma nized'); + expect(line.romanizationWords.map((word) => word.text), [ + 'Ro', + 'ma ', + 'nized', + ]); + expect(line.romanizationWords.first.time.inMilliseconds, 900); + expect(line.romanizationWords[1].end?.inMilliseconds, 1198); + expect(line.romanizationWords.last.time.inMilliseconds, 1894); + expect(line.romanizationWords.last.end?.inMilliseconds, 2388); + }, + ); + + test( + 'missing or corrupt romanization timings preserve plain romanization', + () { + for (final timing in [ + 'bad json', + '{}', + '[null]', + '[{"text":"Other","startTimeMs":1000,"endTimeMs":1300}]', + '[{"text":"Romanized","startTimeMs":1000,"endTimeMs":900}]', + ]) { + final line = LyricsParser.parse(''' +${_tag('romaji', 1000, 'Romanized')} +${_tag('romaji-words', 1000, timing)} +[00:01.00]Original +''').lines.single; + expect(line.romanization, 'Romanized'); + expect(line.romanizationWords, isEmpty); + } + }, + ); + + test('exact matches win collisions and a tie goes to one earlier line', () { + final lyrics = LyricsParser.parse(''' +${_tag('romaji', 1009, 'Near')} +${_tag('romaji', 1010, 'Exact')} +${_tag('romaji', 1001, 'Further')} +${_tag('translation', 2005, 'Tie')} +[00:01.010]A +[00:02.000]B +[00:02.010]C +'''); + expect(lyrics.lines[0].romanization, 'Exact'); + expect(lyrics.lines[1].translation, 'Tie'); + expect(lyrics.lines[2].translation, isNull); + }); + + test('offset moves word ends and line end after supplement matching', () { + final lyrics = LyricsParser.parse(''' +[offset:109] +${_tag('romaji', 1009, 'Romanized')} +[00:01.009]<00:01.009>Word<00:01.307> +'''); + final line = lyrics.lines.single; + expect(line.romanization, 'Romanized'); + expect(line.time.inMilliseconds, 900); + expect(line.end?.inMilliseconds, 1198); + expect(line.words.single.time.inMilliseconds, 900); + expect(line.words.single.end?.inMilliseconds, 1198); + }); + + test('invalid optional tags do not appear or hide the original text', () { + const tags = ''' +[x-romaji:1000:%%%] +[x-romaji:1000:/w==] +[x-translation:bad:YQ==] +[x-translation:1000:] +'''; + expect(LyricsParser.parse(tags).isEmpty, isTrue); + final lyrics = LyricsParser.parse('$tags[00:01.00]Original'); + expect(lyrics.lines.single.text, 'Original'); + expect(lyrics.lines.single.romanization, isNull); + expect(lyrics.lines.single.translation, isNull); + expect(lyrics.plainText, 'Original'); + expect(LyricsParser.parse('${tags}Plain text').plainText, 'Plain text'); + }); + + test('supplement text safely retains Unicode and embedded newlines', () { + const text = '日本語 ]\nSecond line'; + final lyrics = LyricsParser.parse( + '${_tag('translation', 1000, text)}\n[00:01.00]Original', + ); + expect(lyrics.lines.single.translation, text); + }); + + test('start-only eLRC retains its existing fallback timing', () { + final line = LyricsParser.parse( + '[00:01.00]<00:01.00>First <00:02.00>second', + ).lines.single; + expect(line.words.map((word) => word.end), [null, null]); + expect(line.end, isNull); + }); + + test('empty tags retain the first valid end and reject backward ends', () { + final line = LyricsParser.parse( + '[00:01.00]<00:01.00>First <00:00.90><00:01.30><00:01.40>' + '<00:02.00>second<00:02.00>', + ).lines.single; + expect(line.words.map((word) => word.end?.inMilliseconds), [1300, 2000]); + }); + + test('TTML span ends survive independently of the paragraph end', () { + final line = LyricsParser.parse(''' +
+

+First +second +

+''').lines.single; + expect(line.end?.inMilliseconds, 5000); + expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]); + }); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 6b83d360..15342472 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1165,6 +1165,127 @@ void main() { }, ); + for (final mornye in [false, true]) { + testWidgets('player shows original, romanization and English ($mornye)', ( + tester, + ) async { + metadataOverrides['lyrics'] = + '[x-romaji:1009:${base64.encode(utf8.encode('Romanized text'))}]\n' + '[x-translation:1009:${base64.encode(utf8.encode('English text'))}]\n' + '[00:01.01]Original text'; + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 844), + ); + 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(); + for (final text in ['Original text', 'Romanized text', 'English text']) { + expect(find.text(text), findsOneWidget); + } + expect( + tester.getTopLeft(find.text('Romanized text')).dy, + greaterThan(tester.getBottomLeft(find.text('Original text')).dy), + ); + expect( + tester.getTopLeft(find.text('English text')).dy, + greaterThan(tester.getBottomLeft(find.text('Romanized text')).dy), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'original and romanization follow word timing and pauses ($mornye)', + (tester) async { + final romanizationWords = base64.encode( + utf8.encode( + jsonEncode([ + {'text': 'Firsu ', 'startTimeMs': 1009, 'endTimeMs': 1307}, + {'text': 'secondu', 'startTimeMs': 2003, 'endTimeMs': 2497}, + ]), + ), + ); + metadataOverrides['lyrics'] = + '[x-romaji:1009:${base64.encode(utf8.encode('Firsu secondu'))}]\n' + '[x-romaji-words:1009:$romanizationWords]\n' + '[00:01.009]<00:01.009>First <00:01.307>' + '<00:02.003>second<00:02.497>'; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + await pumpNowPlaying( + tester, + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + size: const Size(390, 844), + 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(); + + Future> pixelsAt(int milliseconds, String text) async { + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: false, + updatePosition: Duration(milliseconds: milliseconds), + ), + ); + 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 bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final pixels = bytes.buffer.asUint8List().toList(); + image.dispose(); + picture.dispose(); + return pixels; + }))!; + } + + for (final text in ['First second', 'Firsu secondu']) { + final singingFirst = await pixelsAt(1100, text); + final firstEnded = await pixelsAt(1307, text); + expect(firstEnded, isNot(orderedEquals(singingFirst))); + expect(await pixelsAt(1800, text), orderedEquals(firstEnded)); + final singingLast = await pixelsAt(2150, text); + final lastEnded = await pixelsAt(2497, text); + expect(lastEnded, isNot(orderedEquals(singingLast))); + expect(await pixelsAt(2900, text), orderedEquals(lastEnded)); + expect(await pixelsAt(1100, text), orderedEquals(singingFirst)); + } + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('timed lyric fills text fragments in reading order', ( tester, ) async { diff --git a/test/selection_overlay_lifecycle_test.dart b/test/selection_overlay_lifecycle_test.dart new file mode 100644 index 00000000..f3196403 --- /dev/null +++ b/test/selection_overlay_lifecycle_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/collection_scaffold.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; + +Widget _app(ThemeData theme, GlobalKey navigator) { + return ProviderScope( + child: MaterialApp( + theme: theme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SelectionOverlayHost( + child: Navigator( + key: navigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Library')), + ), + ), + ), + ), + ); +} + +void main() { + for (final mornye in [false, true]) { + final name = mornye ? 'Mornye' : 'Material'; + final theme = mornye + ? MornyeTheme.build(Brightness.light) + : AppTheme.light(); + + testWidgets('$name: leaving a collection removes its selection bar', ( + tester, + ) async { + final navigator = GlobalKey(); + final scroll = ScrollController(); + addTearDown(scroll.dispose); + await tester.pumpWidget(_app(theme, navigator)); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => CollectionScaffold( + scrollController: scroll, + isSelectionMode: true, + onExitSelectionMode: () {}, + appBar: const SliverAppBar(title: Text('Album')), + slivers: const [], + selectionBar: const Text('Delete 1 track'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Delete 1 track'), findsOneWidget); + + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Library'), findsOneWidget); + expect(find.text('Delete 1 track'), findsNothing); + }); + + testWidgets('$name: an empty album removes its old selection bar', ( + tester, + ) async { + final navigator = GlobalKey(); + final scroll = ScrollController(); + final hasTracks = ValueNotifier(true); + addTearDown(scroll.dispose); + addTearDown(hasTracks.dispose); + await tester.pumpWidget(_app(theme, navigator)); + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => ValueListenableBuilder( + valueListenable: hasTracks, + builder: (_, hasTracks, _) => hasTracks + ? CollectionScaffold( + scrollController: scroll, + isSelectionMode: true, + onExitSelectionMode: () {}, + appBar: const SliverAppBar(title: Text('Album')), + slivers: const [], + selectionBar: const Text('Delete 1 track'), + ) + : const Scaffold(body: Text('No tracks found for this album')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Delete 1 track'), findsOneWidget); + + hasTracks.value = false; + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('No tracks found for this album'), findsOneWidget); + expect(find.text('Delete 1 track'), findsNothing); + navigator.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text('Library'), findsOneWidget); + }); + + testWidgets( + '$name: leaving Songs clears the bar and allows selecting again', + (tester) async { + final navigator = GlobalKey(); + await tester.pumpWidget(_app(theme, navigator)); + for (var visit = 0; visit < 2; visit++) { + navigator.currentState!.push( + MaterialPageRoute( + builder: (_) => const _SongsSelectionPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.longPress(find.text('Song')); + await tester.pumpAndSettle(); + expect(find.byType(SelectionBottomBar), findsOneWidget); + + if (visit == 1) { + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.byType(SelectionBottomBar), findsNothing); + } + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Library'), findsOneWidget); + expect(find.byType(SelectionBottomBar), findsNothing); + } + }, + ); + } +} + +// Songs owns a controller directly and hides it in dispose, whereas album +// screens delegate that lifecycle to CollectionScaffold. +class _SongsSelectionPage extends StatefulWidget { + const _SongsSelectionPage(); + + @override + State<_SongsSelectionPage> createState() => _SongsSelectionPageState(); +} + +class _SongsSelectionPageState extends State<_SongsSelectionPage> { + final _selectionOverlay = SelectionOverlayController(); + + @override + void dispose() { + _selectionOverlay.hide(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Songs'), + leading: BackButton(onPressed: () => Navigator.pop(context)), + ), + body: ListTile( + title: const Text('Song'), + onLongPress: () => _selectionOverlay.show( + context, + (_) => SelectionBottomBar( + selectedCount: 1, + allSelected: true, + onClose: _selectionOverlay.hide, + onToggleSelectAll: _selectionOverlay.hide, + bottomPadding: 0, + children: const [Text('Delete 1 track')], + ), + ), + ), + ); + } +}