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')], + ), + ), + ), + ); + } +}