From 514e229fc87a22a46e49a247f9eb63c3b0806001 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:20 +0700 Subject: [PATCH] perf(metadata): parse lyrics and encode ID3 artwork in native workers Port LRC, enhanced LRC, and TTML parsing plus ID3v2.3 lyrics writing and picture encoding. Keep legacy contracts and deep XML fallback; guard asynchronous player updates against stale track results. --- .../player_metadata_controller.dart | 89 +- lib/screens/now_playing_screen.dart | 7 +- lib/services/ffmpeg_metadata.dart | 21 + lib/services/id3v23_lyrics.dart | 21 + lib/utils/lyrics_parser.dart | 77 + rust_backend/crates/mobile/src/data_jobs.rs | 4 + rust_backend/crates/mobile/src/lib.rs | 2 + rust_backend/crates/mobile/src/native_id3.rs | 261 ++++ .../crates/mobile/src/native_lyrics.rs | 1046 +++++++++++++ test/fixtures/native_lyrics_parsers.json | 1317 +++++++++++++++++ test/id3v23_lyrics_file_test.dart | 1 + test/native_lyrics_parser_test.dart | 126 ++ test/now_playing_lyrics_transition_test.dart | 537 ++++--- test/player_metadata_controller_test.dart | 93 ++ 14 files changed, 3372 insertions(+), 230 deletions(-) create mode 100644 rust_backend/crates/mobile/src/native_id3.rs create mode 100644 rust_backend/crates/mobile/src/native_lyrics.rs create mode 100644 test/fixtures/native_lyrics_parsers.json create mode 100644 test/native_lyrics_parser_test.dart diff --git a/lib/controllers/player_metadata_controller.dart b/lib/controllers/player_metadata_controller.dart index 47eb06a6..5fd33eae 100644 --- a/lib/controllers/player_metadata_controller.dart +++ b/lib/controllers/player_metadata_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:spotiflac_android/services/playback_metadata.dart'; @@ -10,14 +12,21 @@ final _log = AppLogger('PlayerMetadata'); /// Owns file metadata and lyrics for the full player. Requests are scoped to /// the current queue item so a late probe cannot replace another track's data. class PlayerMetadataController extends ChangeNotifier { - PlayerMetadataController({PlaybackMetadataReader? readMetadata}) - : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry; + PlayerMetadataController({ + PlaybackMetadataReader? readMetadata, + Future Function(String?)? parseLyrics, + }) : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry, + _parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative; final PlaybackMetadataReader _readMetadata; + final Future Function(String?) _parseLyrics; (String, String, String?)? _requestKey; String? _loadedPath; Map _fallback = const {}; int _generation = 0; + int _metadataRevision = 0; + String _lyricsRaw = ''; + bool _probePending = false; bool _disposed = false; String? get source => _requestKey?.$2; @@ -37,9 +46,11 @@ class PlayerMetadataController extends ChangeNotifier { if (item == null || source.isEmpty) { if (_requestKey == null) return; _generation++; + _probePending = false; _requestKey = null; _loadedPath = null; - _replaceMetadata(const {}); + final pending = _replaceMetadata(const {}); + if (pending != null) await pending; return; } final resolvedSource = item.extras?['resolvedSource']?.toString().trim(); @@ -48,12 +59,14 @@ class PlayerMetadataController extends ChangeNotifier { _fallback = Map.unmodifiable(playbackAudioMetadataFromMediaItem(item)); if (!sameItem) { _generation++; + _probePending = false; _requestKey = key; _loadedPath = null; } if (const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme)) { _loadedPath = source; - _replaceMetadata(_fallback); + final pending = _replaceMetadata(_fallback); + if (pending != null) await pending; return; } final path = _metadataPath(source, resolvedSource); @@ -65,30 +78,86 @@ class PlayerMetadataController extends ChangeNotifier { } if (loading || _loadedPath == path) return; } else { - _replaceMetadata(_fallback); + final pending = _replaceMetadata(_fallback); + // A fallback parse must never postpone the file probe. Its revision + // guard discards it as soon as richer metadata supersedes it. + if (pending != null) unawaited(pending); } // Opening the player must not copy an unresolved SAF document. Lyrics // requests may inspect it until playback publishes its resolved source. if (unresolved && !inspectUnresolvedContentUri) return; + _probePending = true; _loading = true; notifyListeners(); final generation = _generation; try { final probed = await _readMetadata(path); if (_disposed || generation != _generation) return; + _probePending = false; _loadedPath = path; - _replaceMetadata(mergePlaybackFileMetadata(_fallback, probed)); + final pending = _replaceMetadata( + mergePlaybackFileMetadata(_fallback, probed), + ); + if (pending != null) await pending; } catch (error) { if (_disposed || generation != _generation) return; + _probePending = false; _log.w('Failed to read metadata: $error'); - _replaceMetadata(_fallback); + final pending = _replaceMetadata(_fallback); + if (pending != null) await pending; } } - void _replaceMetadata(Map value) { + Future? _replaceMetadata(Map value) { + final revision = ++_metadataRevision; + final raw = (value['lyrics'] ?? '').toString(); + // Empty fallback metadata stays synchronous, so file probes start at the + // same point and changing tracks clears the previous lyrics immediately. + if (raw.isEmpty) { + _publishMetadata(value, ParsedLyrics.empty); + return null; + } _metadata = value.isEmpty ? null : Map.unmodifiable(value); - _lyrics = LyricsParser.parse((value['lyrics'] ?? '').toString()); - _loading = false; + if (raw != _lyricsRaw) _lyrics = ParsedLyrics.empty; + _loading = true; + notifyListeners(); + return _parseAndReplaceMetadata(value, raw, _generation, revision); + } + + Future _parseAndReplaceMetadata( + Map value, + String raw, + int generation, + int revision, + ) async { + try { + final parsed = await _parseLyrics(raw); + if (_disposed || + generation != _generation || + revision != _metadataRevision) { + return; + } + _publishMetadata(value, parsed, raw: raw); + } catch (error) { + if (_disposed || + generation != _generation || + revision != _metadataRevision) { + return; + } + _log.w('Failed to parse lyrics: $error'); + _publishMetadata(value, ParsedLyrics.empty); + } + } + + void _publishMetadata( + Map value, + ParsedLyrics parsed, { + String raw = '', + }) { + _metadata = value.isEmpty ? null : Map.unmodifiable(value); + _lyrics = parsed; + _lyricsRaw = raw; + _loading = _probePending; notifyListeners(); } diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 28ae3a17..2410cefa 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -63,7 +63,9 @@ class _FadingAppBar extends StatelessWidget implements PreferredSizeWidget { } class NowPlayingScreen extends ConsumerStatefulWidget { - const NowPlayingScreen({super.key}); + const NowPlayingScreen({super.key, this.metadataControllerFactory}); + + final PlayerMetadataController Function()? metadataControllerFactory; @override ConsumerState createState() => _NowPlayingScreenState(); @@ -72,7 +74,8 @@ class NowPlayingScreen extends ConsumerStatefulWidget { class _NowPlayingScreenState extends ConsumerState { ProviderSubscription>? _mediaItemSub; ProviderSubscription? _lyricsPlayingSub; - final _trackMetadata = PlayerMetadataController(); + late final _trackMetadata = + widget.metadataControllerFactory?.call() ?? PlayerMetadataController(); int _currentPage = 0; bool _landscape = false; bool _queueSheetShowing = false; diff --git a/lib/services/ffmpeg_metadata.dart b/lib/services/ffmpeg_metadata.dart index 198bebe1..bdc5b7fd 100644 --- a/lib/services/ffmpeg_metadata.dart +++ b/lib/services/ffmpeg_metadata.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; +import 'package:flutter/services.dart'; import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; import 'package:spotiflac_android/services/ffmpeg_models.dart'; import 'package:spotiflac_android/services/ffmpeg_support.dart'; @@ -379,6 +381,25 @@ abstract final class FFmpegMetadata { } static Future _createMetadataBlockPicture(String imagePath) async { + try { + final result = await PlatformBridge.runNativeDataJob({ + 'operation': 'build_metadata_picture', + 'file_path': imagePath, + }); + return result['picture'] as String?; + } on MissingPluginException { + return Isolate.run( + () => _createMetadataBlockPictureInBackground(imagePath), + ); + } catch (e) { + _log.e('Error creating METADATA_BLOCK_PICTURE: $e'); + return null; + } + } + + static Future _createMetadataBlockPictureInBackground( + String imagePath, + ) async { try { final file = File(imagePath); if (!await file.exists()) { diff --git a/lib/services/id3v23_lyrics.dart b/lib/services/id3v23_lyrics.dart index 47d175df..cefe57b1 100644 --- a/lib/services/id3v23_lyrics.dart +++ b/lib/services/id3v23_lyrics.dart @@ -1,7 +1,11 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:typed_data'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + /// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding. /// /// It intentionally supports only plain ID3v2.3 tags without extended, @@ -18,6 +22,23 @@ class Id3v23Lyrics { static Future writeUnsyncedLyricsToFile( File file, String lyrics, + ) async { + try { + final result = await PlatformBridge.runNativeDataJob({ + 'operation': 'write_id3v23_lyrics', + 'file_path': file.path, + 'lyrics': lyrics, + }); + return result['updated'] == true; + } on MissingPluginException { + final path = file.path; + return Isolate.run(() => _writeUnsyncedLyricsToFile(File(path), lyrics)); + } + } + + static Future _writeUnsyncedLyricsToFile( + File file, + String lyrics, ) async { final input = await file.open(); late final Uint8List updatedTag; diff --git a/lib/utils/lyrics_parser.dart b/lib/utils/lyrics_parser.dart index 44a783f6..c55e94cc 100644 --- a/lib/utils/lyrics_parser.dart +++ b/lib/utils/lyrics_parser.dart @@ -1,7 +1,63 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:xml/xml.dart'; +Uint8List _encodeLyricsInBackground(String raw) => + Uint8List.fromList(utf8.encode(raw)); + +ParsedLyrics _parseLyricsInBackground(String raw) => LyricsParser.parse(raw); + +ParsedLyrics _hydrateParsedLyricsInBackground(Map result) { + LyricWord word(Map value) => LyricWord( + time: Duration(milliseconds: value['timeMs'] as int), + end: value['endMs'] == null + ? null + : Duration(milliseconds: value['endMs'] as int), + text: value['text'] as String, + ); + List words(dynamic value) => (value as List) + .map((entry) => word(entry as Map)) + .toList(growable: false); + final lines = []; + for (final value in result['lines'] as List) { + final row = value as Map; + final voice = row['voice'] as Map?; + lines.add( + LyricLine( + time: Duration(milliseconds: row['timeMs'] as int), + end: row['endMs'] == null + ? null + : Duration(milliseconds: row['endMs'] as int), + text: row['text'] as String, + words: words(row['words']), + romanization: row['romanization'] as String?, + romanizationWords: words(row['romanizationWords']), + translation: row['translation'] as String?, + voice: voice == null + ? null + : LyricVoice( + id: voice['id'] as String, + index: voice['index'] as int, + isGroup: voice['isGroup'] as bool, + ), + isBackground: row['isBackground'] as bool, + vocalGroup: row['vocalGroup'] as int?, + ), + ); + } + return ParsedLyrics( + synced: result['synced'] as bool, + wordSynced: result['wordSynced'] as bool, + lines: lines, + plainText: result['plainText'] as String, + writers: result['writers'] as String?, + provider: result['provider'] as String?, + ); +} + class LyricWord { final Duration time; final Duration? end; @@ -139,6 +195,27 @@ class LyricsParser { return _withCredits(text, _parseLrcOrPlain(text)); } + /// Production parsing runs away from rendering. The synchronous parser + /// remains the compatibility contract for old native builds and test hosts. + static Future parseAsyncNative(String? raw) async { + if (raw == null || raw.isEmpty) return ParsedLyrics.empty; + Map result; + try { + final bytes = await compute(_encodeLyricsInBackground, raw); + result = await PlatformBridge.runNativeDataJob({ + 'operation': 'parse_lyrics', + }, bytes: bytes); + } on MissingPluginException { + return compute(_parseLyricsInBackground, raw); + } on PlatformException catch (error) { + // Rust bounds its recursive XML tokenizer. The isolated compatibility + // parser retains support for valid documents beyond that native limit. + if (error.message?.contains('XML nesting exceeds') != true) rethrow; + return compute(_parseLyricsInBackground, raw); + } + return compute(_hydrateParsedLyricsInBackground, result); + } + static ParsedLyrics _withCredits(String raw, ParsedLyrics lyrics) { String? tag(String name) => RegExp( '^\\[$name:([^\\]\\r\\n]*)\\]\\s*\$', diff --git a/rust_backend/crates/mobile/src/data_jobs.rs b/rust_backend/crates/mobile/src/data_jobs.rs index 59876468..46958c6d 100644 --- a/rust_backend/crates/mobile/src/data_jobs.rs +++ b/rust_backend/crates/mobile/src/data_jobs.rs @@ -80,6 +80,10 @@ fn execute( check, ), "parse_network_listing" => crate::native_listing::execute(request, Some(bytes)), + "write_id3v23_lyrics" | "build_metadata_picture" => { + crate::native_id3::execute(request, Some(bytes), check) + } + "parse_lyrics" => crate::native_lyrics::execute(request, Some(bytes), check), "palette" => { let colors = spotiflac_core::native_palette::quantize_rgba(bytes, check)?; Ok(json!({"colors": colors})) diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index b54c3e4d..29c46623 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -11,7 +11,9 @@ mod logging; mod lyrics; mod manager; mod metadata; +mod native_id3; mod native_listing; +mod native_lyrics; mod native_xml; mod network_tags; mod progress; diff --git a/rust_backend/crates/mobile/src/native_id3.rs b/rust_backend/crates/mobile/src/native_id3.rs new file mode 100644 index 00000000..82001c09 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_id3.rs @@ -0,0 +1,261 @@ +//! Compatibility byte operations used only by the FFmpeg fallback. These keep +//! unsupported ID3 headers unchanged instead of broadening the tag writer. +use base64::Engine; +use serde_json::{Value, json}; +use std::{ + fs, + io::{Read, Seek, SeekFrom, Write}, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub(crate) fn execute( + request: &Value, + _bytes: Option<&[u8]>, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + check()?; + let path = request["file_path"] + .as_str() + .ok_or("Missing byte-operation file path")?; + match request["operation"].as_str().unwrap_or_default() { + "write_id3v23_lyrics" => { + let lyrics = request["lyrics"].as_str().ok_or("Missing lyrics")?; + write_lyrics(Path::new(path), lyrics, check) + .map(|updated| json!({"updated": updated, "committed": updated})) + } + "build_metadata_picture" => { + let data = fs::read(path).map_err(|e| e.to_string())?; + let lower_path = path.to_lowercase(); + let png = data.len() >= 8 && data.starts_with(&[0x89, 0x50, 0x4e, 0x47]); + let mime = if lower_path.ends_with(".png") + || (!lower_path.ends_with(".jpg") && !lower_path.ends_with(".jpeg") && png) + { + "image/png" + } else { + "image/jpeg" + }; + let length = u32::try_from(data.len()).map_err(|_| "Artwork is too large")?; + let mut picture = Vec::with_capacity(32 + mime.len() + data.len()); + picture.extend_from_slice(&3_u32.to_be_bytes()); + picture.extend_from_slice(&(mime.len() as u32).to_be_bytes()); + picture.extend_from_slice(mime.as_bytes()); + picture.extend_from_slice(&[0; 20]); + picture.extend_from_slice(&length.to_be_bytes()); + picture.extend_from_slice(&data); + check()?; + Ok(json!({"picture": base64::engine::general_purpose::STANDARD.encode(picture)})) + } + _ => Err("Unknown byte-operation".into()), + } +} + +fn write_lyrics( + path: &Path, + lyrics: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + let mut input = fs::File::open(path).map_err(|e| e.to_string())?; + let size = input.metadata().map_err(|e| e.to_string())?.len(); + let mut header = [0; 10]; + let read = input.read(&mut header).map_err(|e| e.to_string())?; + let mut audio_offset = 0; + let updated = if read >= 10 && &header[..3] == b"ID3" { + let Some(tag_size) = synchsafe(&header[6..10]) else { + return Ok(false); + }; + if header[3] != 3 + || header[5] & (0x80 | 0x40 | 0x20) != 0 + || tag_size > 32 << 20 + || tag_size as u64 + 10 > size + { + return Ok(false); + } + let mut payload = vec![0; tag_size]; + input.read_exact(&mut payload).map_err(|e| e.to_string())?; + audio_offset = tag_size as u64 + 10; + make_tag(&payload, lyrics) + } else { + make_tag(&[], lyrics) + }; + let parent = path.parent().ok_or("Missing audio parent directory")?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_nanos(); + let staging = parent.join(format!(".spotiflac-lyrics-{}-{nonce}", std::process::id())); + fs::create_dir(&staging).map_err(|e| e.to_string())?; + let output_path = staging.join("audio.mp3"); + let result = (|| { + let mut output = fs::File::create(&output_path).map_err(|e| e.to_string())?; + output.write_all(&updated).map_err(|e| e.to_string())?; + input + .seek(SeekFrom::Start(audio_offset)) + .map_err(|e| e.to_string())?; + let mut buffer = [0; 64 * 1024]; + loop { + check()?; + let read = input.read(&mut buffer).map_err(|e| e.to_string())?; + if read == 0 { + break; + } + output + .write_all(&buffer[..read]) + .map_err(|e| e.to_string())?; + } + output.sync_all().map_err(|e| e.to_string())?; + drop(output); + check()?; + fs::rename(&output_path, path).map_err(|e| e.to_string())?; + Ok(true) + })(); + let _ = fs::remove_dir_all(staging); + result +} + +fn synchsafe(bytes: &[u8]) -> Option { + if bytes.len() != 4 || bytes.iter().any(|byte| byte & 0x80 != 0) { + return None; + } + Some( + bytes + .iter() + .fold(0, |value, byte| value << 7 | usize::from(*byte)), + ) +} + +fn make_tag(payload: &[u8], lyrics: &str) -> Vec { + let mut preserved = Vec::with_capacity(payload.len()); + let mut offset = 0; + while offset + 10 <= payload.len() { + let id = &payload[offset..offset + 4]; + if !id + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + break; + } + let size = u32::from_be_bytes( + payload[offset + 4..offset + 8] + .try_into() + .expect("frame size"), + ) as usize; + if size == 0 || size > payload.len() - offset - 10 { + break; + } + if id != b"USLT" { + preserved.extend_from_slice(&payload[offset..offset + 10 + size]); + } + offset += 10 + size; + } + let mut lyrics_payload = vec![1, b'e', b'n', b'g', 0xff, 0xfe, 0, 0, 0xff, 0xfe]; + for unit in lyrics.encode_utf16() { + lyrics_payload.extend_from_slice(&unit.to_le_bytes()); + } + preserved.extend_from_slice(b"USLT"); + preserved.extend_from_slice(&(lyrics_payload.len() as u32).to_be_bytes()); + preserved.extend_from_slice(&[0, 0]); + preserved.extend_from_slice(&lyrics_payload); + let mut tag = vec![b'I', b'D', b'3', 3, 0, 0]; + tag.extend( + (0..4) + .rev() + .map(|index| ((preserved.len() >> (index * 7)) & 0x7f) as u8), + ); + tag.extend(preserved); + tag +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uslt_bytes_preserve_utf16_bom_language_and_surrogate_pairs() { + let result = make_tag(&[], "A😀"); + assert_eq!(&result[..6], &[b'I', b'D', b'3', 3, 0, 0]); + assert_eq!(&result[10..14], b"USLT"); + assert_eq!( + &result[20..], + &[ + 1, b'e', b'n', b'g', 0xff, 0xfe, 0, 0, 0xff, 0xfe, 65, 0, 0x3d, 0xd8, 0, 0xde + ] + ); + assert_eq!(synchsafe(&result[6..10]).unwrap(), result.len() - 10); + } + + #[test] + fn lyric_replacement_preserves_other_frames_and_audio_tail() { + let directory = std::env::temp_dir().join(format!("native-id3-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("track.mp3"); + let frame = [b'T', b'I', b'T', b'2', 0, 0, 0, 2, 0, 0, 0, b'A']; + let initial = make_tag(&frame, "Old"); + let mut original = initial.clone(); + original.extend([0xff, 0xfb, 1, 2, 3]); + fs::write(&path, original).unwrap(); + assert!(write_lyrics(&path, "New", &|| Ok(())).unwrap()); + let result = fs::read(&path).unwrap(); + assert_eq!(&result[10..22], &frame); + assert_eq!(&result[result.len() - 5..], &[0xff, 0xfb, 1, 2, 3]); + let mut unsupported = result.clone(); + unsupported[3] = 4; + fs::write(&path, &unsupported).unwrap(); + assert!(!write_lyrics(&path, "Other", &|| Ok(())).unwrap()); + assert_eq!(fs::read(&path).unwrap(), unsupported); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cancellation_during_audio_copy_leaves_original_and_no_staging() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("track.mp3"); + let original = vec![0x55; 256 * 1024]; + fs::write(&path, &original).unwrap(); + let calls = std::cell::Cell::new(0); + let check = || { + let count = calls.get() + 1; + calls.set(count); + if count == 3 { + Err("cancelled".into()) + } else { + Ok(()) + } + }; + assert_eq!(write_lyrics(&path, "New", &check).unwrap_err(), "cancelled"); + assert_eq!(fs::read(path).unwrap(), original); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); + } + + #[test] + fn artwork_block_has_exact_mime_header_and_preserves_large_payloads() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cover.bin"); + let image = [0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0x42]; + fs::write(&path, image).unwrap(); + let request = json!({"operation":"build_metadata_picture", "file_path": path}); + let result = execute(&request, None, &|| Ok(())).unwrap(); + let block = base64::engine::general_purpose::STANDARD + .decode(result["picture"].as_str().unwrap()) + .unwrap(); + assert_eq!(&block[..8], &[0, 0, 0, 3, 0, 0, 0, 9]); + assert_eq!(&block[8..17], b"image/png"); + assert_eq!(&block[17..37], &[0; 20]); + assert_eq!(&block[37..41], &[0, 0, 0, 9]); + assert_eq!(&block[41..], &image); + fs::File::create(&path) + .unwrap() + .set_len((4 << 20) + 1) + .unwrap(); + let result = execute(&request, None, &|| Ok(())).unwrap(); + let block = base64::engine::general_purpose::STANDARD + .decode(result["picture"].as_str().unwrap()) + .unwrap(); + assert_eq!(&block[8..18], b"image/jpeg"); + assert_eq!( + u32::from_be_bytes(block[38..42].try_into().unwrap()), + (4 << 20) + 1 + ); + assert_eq!(block.len() - 42, (4 << 20) + 1); + } +} diff --git a/rust_backend/crates/mobile/src/native_lyrics.rs b/rust_backend/crates/mobile/src/native_lyrics.rs new file mode 100644 index 00000000..be879628 --- /dev/null +++ b/rust_backend/crates/mobile/src/native_lyrics.rs @@ -0,0 +1,1046 @@ +//! The player's existing LRC/TTML contract, parsed on the bounded data worker. +//! Milliseconds cross the bridge; playback timeline lookup stays in Flutter. +use base64::Engine; +use regex::{Captures, Regex}; +use roxmltree::{Document, Node}; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{HashMap, HashSet}, + sync::LazyLock, +}; + +const META_NS: &str = "http://www.w3.org/ns/ttml#metadata"; +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +const WS: &str = r"[\t\n\v\f\r \u{00a0}\u{1680}\u{2000}-\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}]"; +static LINE_TIME: LazyLock = + LazyLock::new(|| Regex::new(r"\[([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?\]").unwrap()); +static WORD_TIME: LazyLock = + LazyLock::new(|| Regex::new(r"<([0-9]{1,3}):([0-9]{1,2})(?:[.:]([0-9]{1,3}))?>").unwrap()); +static WHITE_SPACE: LazyLock = LazyLock::new(|| Regex::new(&format!("{WS}+")).unwrap()); +static VOICE_PREFIX: LazyLock = LazyLock::new(|| { + Regex::new(&format!(r"(?i)^({WS}*(?:<[0-9]{{1,3}}:[0-9]{{1,2}}(?:[.:][0-9]{{1,3}})?>{WS}*)*)(v[1-9][0-9]*):[ \t]*")).unwrap() +}); +static BACKGROUND: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^\[bg:(.*)\]$").unwrap()); +static ID_TAG: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\[(ti|ar|al|by|offset|length|re|ve|tool|au|la|encoder|instrumental|x-[a-z0-9_-]+):.*\]$").unwrap() +}); +static SUPPLEMENT: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\[x-(romaji-words|romaji|translation):([0-9]+):([^\]]*)\]$").unwrap() +}); +static NEW_LINE: LazyLock = LazyLock::new(|| Regex::new(r"\r\n|\r|\n").unwrap()); + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Word { + time_ms: i64, + end_ms: Option, + text: String, +} +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Voice { + id: String, + index: i64, + is_group: bool, +} +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Line { + time_ms: i64, + end_ms: Option, + text: String, + words: Vec, + romanization: Option, + romanization_words: Vec, + translation: Option, + voice: Option, + is_background: bool, + vocal_group: Option, +} +#[derive(Default, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Parsed { + synced: bool, + word_synced: bool, + lines: Vec, + plain_text: String, + writers: Option, + provider: Option, +} +impl Line { + fn new( + time_ms: i64, + end_ms: Option, + text: String, + words: Vec, + voice: Option, + background: bool, + group: Option, + ) -> Self { + Self { + time_ms, + end_ms, + text, + words, + voice, + is_background: background, + vocal_group: group, + romanization: None, + romanization_words: vec![], + translation: None, + } + } +} + +pub(crate) fn execute( + request: &Value, + bytes: Option<&[u8]>, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + check()?; + let raw = match bytes.filter(|b| !b.is_empty()) { + Some(data) => std::str::from_utf8(data).map_err(|e| e.to_string())?, + None => request["raw"].as_str().unwrap_or_default(), + }; + let text = trim(raw); + let mut parsed = if text.is_empty() { + Parsed::default() + } else { + match if looks_ttml(text) { + parse_ttml(text, check)? + } else { + None + } { + Some(parsed) if !parsed.lines.is_empty() => parsed, + _ => parse_lrc(text, check)?, + } + }; + if !text.is_empty() { + with_credits(text, &mut parsed)?; + } + check()?; + serde_json::to_value(parsed).map_err(|e| e.to_string()) +} + +fn whitespace(c: char) -> bool { + matches!( + c, + '\t' | '\n' + | '\u{000b}' + | '\u{000c}' + | '\r' + | ' ' + | '\u{0085}' + | '\u{00a0}' + | '\u{1680}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) || ('\u{2000}'..='\u{200a}').contains(&c) +} +fn trim(s: &str) -> &str { + s.trim_matches(whitespace) +} +fn trim_end(s: &str) -> &str { + s.trim_end_matches(whitespace) +} +fn parse_int(s: &str) -> Option { + let s = trim(s); + let (negative, rest) = if let Some(s) = s.strip_prefix('-') { + (true, s) + } else { + (false, s.strip_prefix('+').unwrap_or(s)) + }; + let unsigned = if rest.starts_with("0x") || rest.starts_with("0X") { + i64::from_str_radix(&rest[2..], 16).ok()? + } else { + rest.parse::().ok()? + }; + if negative { + unsigned.checked_neg() + } else { + Some(unsigned) + } +} +fn looks_ttml(s: &str) -> bool { + let head = s.trim_start_matches(whitespace); + head.starts_with(") -> i64 { + let fraction = c + .get(3) + .map(|v| { + format!("{v:0<3}", v = v.as_str())[..3] + .parse::() + .unwrap_or(0) + }) + .unwrap_or(0); + parse_int(&c[1]).unwrap_or(0) * 60_000 + parse_int(&c[2]).unwrap_or(0) * 1000 + fraction +} +fn vocal_text(content: &str) -> (String, Option) { + let Some(c) = VOICE_PREFIX.captures(content) else { + return (content.into(), None); + }; + let id = c[2].to_ascii_lowercase(); + let voice = Voice { + index: parse_int(&id[1..]).unwrap_or(1) - 1, + is_group: id == "v3", + id, + }; + ( + format!( + "{}{}", + WHITE_SPACE.replace_all(&c[1], ""), + &content[c.get(0).unwrap().end()..] + ), + Some(voice), + ) +} +fn parse_words(content: &str) -> Vec { + let matches: Vec<_> = WORD_TIME.captures_iter(content).collect(); + let mut words: Vec = Vec::new(); + for (i, c) in matches.iter().enumerate() { + let time_ms = duration(c); + let start = c.get(0).unwrap().end(); + let end = matches + .get(i + 1) + .map(|c| c.get(0).unwrap().start()) + .unwrap_or(content.len()); + let text = &content[start..end]; + if trim(text).is_empty() { + if let Some(previous) = words.last_mut() + && previous.end_ms.is_none() + && time_ms >= previous.time_ms + { + previous.end_ms = Some(time_ms); + } + } else { + words.push(Word { + time_ms, + end_ms: None, + text: text.into(), + }); + } + } + words +} +fn decode_supplement(encoded: &str) -> Option { + // Base64Decoder accepts URL-safe characters and %3D padding, but does + // not synthesize missing padding or decode other percent escapes. + let mut normalized = String::new(); + let bytes = encoded.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let byte = if bytes[index] == b'%' { + let value = u8::from_str_radix( + std::str::from_utf8(bytes.get(index + 1..index + 3)?).ok()?, + 16, + ) + .ok()?; + if value != b'=' { + return None; + } + index += 3; + value + } else { + let value = bytes[index]; + index += 1; + value + }; + normalized.push(match byte { + b'-' => '+', + b'_' => '/', + _ => char::from(byte), + }); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(normalized) + .ok()?; + Some(trim(&String::from_utf8(bytes).ok()?).into()) +} +fn romanization_words(raw: &str) -> Vec { + let Ok(Value::Array(values)) = serde_json::from_str::(raw) else { + return vec![]; + }; + let mut words: Vec = Vec::new(); + for value in values { + let (Some(text), Some(start), Some(end)) = ( + value["text"].as_str(), + value["startTimeMs"].as_i64(), + value["endTimeMs"].as_i64(), + ) else { + return vec![]; + }; + if trim(text).is_empty() + || start < 0 + || end < start + || words.last().is_some_and(|w| w.time_ms > start) + { + return vec![]; + } + words.push(Word { + time_ms: start, + end_ms: Some(end), + text: text.into(), + }); + } + words +} +struct Supplements { + entries: Vec<(i64, T)>, + seen: HashSet, +} +impl Supplements { + fn new() -> Self { + Self { + entries: Vec::new(), + seen: HashSet::new(), + } + } +} +fn insert_first(supplements: &mut Supplements, time: i64, value: T) { + if supplements.seen.insert(time) { + supplements.entries.push((time, value)); + } +} +fn align(lines: &[Line], supplements: Supplements) -> HashMap { + let mut matched: HashMap = HashMap::new(); + for (time, value) in supplements.entries { + let lo = lines.partition_point(|line| line.time_ms < time); + let mut nearest = None; + let mut distance = 11; + for index in [lo.checked_sub(1), Some(lo)].into_iter().flatten() { + let Some(line) = lines.get(index) else { + continue; + }; + let delta = line.time_ms.abs_diff(time); + if delta < distance as u64 { + nearest = Some(line.time_ms); + distance = delta as i64; + } + } + if let Some(nearest) = nearest + && matched.get(&nearest).is_none_or(|(old, _)| distance < *old) + { + matched.insert(nearest, (distance, value)); + } + } + matched + .into_iter() + .map(|(key, (_, value))| (key, value)) + .collect() +} +fn shift(time: i64, offset: i64) -> i64 { + time.saturating_sub(offset).max(0) +} +fn shift_words(words: &mut [Word], offset: i64) { + if offset == 0 { + return; + } + for word in words { + word.time_ms = shift(word.time_ms, offset); + word.end_ms = word.end_ms.map(|time| shift(time, offset)); + } +} +fn parse_lrc(text: &str, check: &dyn Fn() -> Result<(), String>) -> Result { + let mut parsed: Vec = Vec::new(); + let mut plain = Vec::new(); + let (mut romaji, mut romaji_words, mut translation) = + (Supplements::new(), Supplements::new(), Supplements::new()); + let (mut saw_timestamp, mut saw_words, mut offset) = (false, false, 0); + for raw in NEW_LINE.split(text) { + check()?; + let mut line = trim_end(raw); + if trim(line).is_empty() { + continue; + } + let background = BACKGROUND.captures(trim(line)); + if let Some(ref c) = background { + line = trim(c.get(1).unwrap().as_str()); + } + if let Some(c) = SUPPLEMENT.captures(trim(line)) { + if let (Some(time), Some(value)) = (parse_int(&c[2]), decode_supplement(&c[3])) + && !value.is_empty() + { + match c[1].to_ascii_lowercase().as_str() { + "romaji-words" => { + let words = romanization_words(&value); + if !words.is_empty() { + insert_first(&mut romaji_words, time, words); + } + } + "romaji" => insert_first(&mut romaji, time, value), + _ => insert_first(&mut translation, time, value), + } + } + continue; + } + if let Some(c) = ID_TAG.captures(trim(line)) { + if c[1].eq_ignore_ascii_case("offset") { + offset = + parse_int(&line[line.find(':').unwrap() + 1..].replace(']', "")).unwrap_or(0); + } + continue; + } + let times: Vec<_> = LINE_TIME.captures_iter(line).collect(); + if times.is_empty() { + let (content, voice) = vocal_text(trim(line)); + let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); + plain.push(clean.clone()); + if background.is_some() && !parsed.is_empty() && !clean.is_empty() { + let words = parse_words(&content); + let previous = parsed.last().unwrap(); + let time = words.first().map(|w| w.time_ms).unwrap_or(previous.time_ms); + let end = words.last().and_then(|w| w.end_ms).or(previous.end_ms); + let voice = voice.or_else(|| previous.voice.clone()); + saw_words |= !words.is_empty(); + parsed.push(Line::new( + time, + end, + clean, + words, + voice, + true, + previous.vocal_group, + )); + } + continue; + } + saw_timestamp = true; + let (content, voice) = + vocal_text(trim(&line[times.last().unwrap().get(0).unwrap().end()..])); + let words = parse_words(&content); + saw_words |= !words.is_empty(); + let clean = trim(&WORD_TIME.replace_all(&content, "")).to_string(); + plain.push(clean.clone()); + for time in times { + let background = background.is_some(); + let previous = parsed.last(); + let line_voice = voice.clone().or_else(|| { + if background { + previous.and_then(|line| line.voice.clone()) + } else { + None + } + }); + let group = if background { + previous.and_then(|line| line.vocal_group) + } else { + Some(parsed.len() as i64) + }; + parsed.push(Line::new( + duration(&time), + words.last().and_then(|w| w.end_ms), + clean.clone(), + words.clone(), + line_voice, + background, + group, + )); + } + } + let plain_text = plain + .into_iter() + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + if !saw_timestamp { + return Ok(Parsed { + plain_text, + ..Parsed::default() + }); + } + parsed.sort_by_key(|line| line.time_ms); + let romaji = align(&parsed, romaji); + let romaji_words = align(&parsed, romaji_words); + let translation = align(&parsed, translation); + if offset != 0 || !romaji.is_empty() || !translation.is_empty() { + for line in &mut parsed { + check()?; + line.romanization = romaji.get(&line.time_ms).cloned(); + let words = romaji_words.get(&line.time_ms).cloned().unwrap_or_default(); + if line.romanization.as_deref() + == Some( + words + .iter() + .map(|w| w.text.as_str()) + .collect::() + .as_str(), + ) + { + line.romanization_words = words; + } + line.translation = translation.get(&line.time_ms).cloned(); + line.time_ms = shift(line.time_ms, offset); + line.end_ms = line.end_ms.map(|time| shift(time, offset)); + shift_words(&mut line.words, offset); + shift_words(&mut line.romanization_words, offset); + } + } + Ok(Parsed { + synced: true, + word_synced: saw_words, + lines: parsed, + plain_text, + ..Parsed::default() + }) +} + +#[derive(Debug)] +struct Run { + voice: Option, + background: bool, + fragments: Vec<(String, Option, Option)>, +} +impl Run { + fn add(&mut self, value: &str, begin: Option, end: Option) { + let normalized = WHITE_SPACE.replace_all(value, " "); + let text = if self + .fragments + .last() + .is_none_or(|(text, _, _)| text.ends_with(' ')) + { + normalized.trim_start_matches(whitespace) + } else { + &normalized + }; + if text.is_empty() { + return; + } + if let Some((previous, old_begin, old_end)) = self.fragments.last_mut() + && (trim(text).is_empty() || (begin == *old_begin && end == *old_end)) + { + previous.push_str(text); + return; + } + self.fragments.push((text.into(), begin, end)); + } + fn text(&self) -> String { + trim( + &self + .fragments + .iter() + .map(|(text, _, _)| text.as_str()) + .collect::(), + ) + .into() + } + fn words(&self) -> Vec { + if self.fragments.iter().any(|(_, begin, _)| begin.is_none()) { + return vec![]; + } + self.fragments + .iter() + .enumerate() + .map(|(i, (text, begin, end))| Word { + time_ms: begin.unwrap(), + end_ms: end.filter(|end| *end >= begin.unwrap()), + text: if i + 1 == self.fragments.len() { + trim_end(text).into() + } else { + text.clone() + }, + }) + .collect() + } +} +fn voice_of( + element: Node<'_, '_>, + inherited: Option, + voices: &mut HashMap, + next: &mut i64, +) -> Option { + let Some(id) = element + .attribute((META_NS, "agent")) + .filter(|id| !id.is_empty()) + else { + return inherited; + }; + Some( + voices + .entry(id.into()) + .or_insert_with(|| { + let voice = Voice { + id: id.into(), + index: *next, + is_group: id.eq_ignore_ascii_case("v3"), + }; + *next += 1; + voice + }) + .clone(), + ) +} +fn rounded_ms(seconds: &str) -> Option { + let seconds = trim(seconds).parse::().ok()?; + let ms = seconds * 1000.0; + if !ms.is_finite() || ms < i64::MIN as f64 || ms >= -(i64::MIN as f64) { + return None; + } + Some(ms.round() as i64) +} +fn clock(value: Option<&str>) -> Option { + let value = trim(value?); + if let Some(seconds) = value.strip_suffix('s').filter(|_| !value.contains(':')) { + return rounded_ms(seconds); + } + let parts: Vec<_> = value.split(':').collect(); + match parts.as_slice() { + [hours, minutes, seconds] => parse_int(hours)? + .checked_mul(3_600_000)? + .checked_add(parse_int(minutes)?.checked_mul(60_000)?)? + .checked_add(rounded_ms(seconds)?), + [minutes, seconds] => parse_int(minutes)? + .checked_mul(60_000)? + .checked_add(rounded_ms(seconds)?), + _ => None, + } +} +#[allow(clippy::too_many_arguments)] +fn visit( + node: Node<'_, '_>, + paragraph: Node<'_, '_>, + voice: Option, + background: bool, + begin: Option, + end: Option, + voices: &mut HashMap, + next: &mut i64, + runs: &mut Vec, + check: &dyn Fn() -> Result<(), String>, +) -> Result<(), String> { + // Use a heap stack so a deeply nested span tree cannot exhaust the native + // worker's call stack. Reverse pushes preserve the original document order. + let mut pending = vec![(node, voice, background, begin, end)]; + while let Some((node, voice, background, begin, end)) = pending.pop() { + check()?; + if node.is_text() { + let value = node.text().unwrap_or_default(); + if trim(value).is_empty() && runs.is_empty() { + continue; + } + let index = runs.iter().position(|run| { + run.voice.as_ref().map(|v| &v.id) == voice.as_ref().map(|v| &v.id) + && run.background == background + }); + let index = match index { + Some(index) => index, + None => { + if trim(value).is_empty() { + continue; + } + runs.push(Run { + voice, + background, + fragments: vec![], + }); + runs.len() - 1 + } + }; + runs[index].add(value, begin, end); + } else if node.is_element() { + let voice = voice_of(node, voice, voices, next); + let background = background || node.attribute((META_NS, "role")) == Some("x-bg"); + let begin = if node == paragraph { + None + } else { + clock(node.attribute("begin")) + } + .or(begin); + let end = if node == paragraph { + None + } else { + clock(node.attribute("end")) + } + .or(end); + for child in node.children().rev() { + pending.push((child, voice.clone(), background, begin, end)); + } + } + } + Ok(()) +} +fn parse_ttml( + text: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result, String> { + crate::native_xml::check_depth(text)?; + let normalized = xml_source(text, true); + let Ok(doc) = Document::parse_with_options( + &normalized, + roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }, + ) else { + return Ok(None); + }; + let elements: Vec<_> = doc.descendants().filter(Node::is_element).collect(); + let paragraphs: Vec<_> = elements + .iter() + .copied() + .filter(|n| n.tag_name().name() == "p") + .collect(); + if paragraphs.is_empty() { + return Ok(None); + } + let mut voices = HashMap::new(); + let mut next = 0; + for agent in elements.iter().filter(|node| { + node.tag_name().name() == "agent" && node.tag_name().namespace() == Some(META_NS) + }) { + let Some(id) = agent.attribute((XML_NS, "id")).filter(|id| !id.is_empty()) else { + continue; + }; + let group = agent.attribute("type") == Some("group") || id.eq_ignore_ascii_case("v3"); + voices.insert( + id.into(), + Voice { + id: id.into(), + index: next, + is_group: group, + }, + ); + if !group { + next += 1; + } + } + let (mut lines, mut plain, mut saw_words, mut next_group) = + (Vec::::new(), Vec::new(), false, 0); + for p in paragraphs { + check()?; + let begin = clock(p.attribute("begin")); + let end = clock(p.attribute("end")); + let mut voice = None; + let ancestors: Vec<_> = p.ancestors().skip(1).filter(Node::is_element).collect(); + for ancestor in ancestors.into_iter().rev() { + voice = voice_of(ancestor, voice, &mut voices, &mut next); + } + let mut runs = Vec::new(); + visit( + p, + p, + voice, + false, + None, + None, + &mut voices, + &mut next, + &mut runs, + check, + )?; + let mut lead_groups = Vec::new(); + for (i, run) in runs.iter().enumerate() { + if !run.background && !run.text().is_empty() { + lead_groups.push((i, next_group)); + next_group += 1; + } + } + for (i, run) in runs.iter().enumerate() { + let text = run.text(); + if text.is_empty() { + continue; + } + plain.push(text.clone()); + let words = run.words(); + let Some(begin) = (if runs.len() == 1 { + begin + } else { + words.first().map(|word| word.time_ms).or(begin) + }) else { + continue; + }; + saw_words |= !words.is_empty(); + let lead = if run.background { + lead_groups + .iter() + .find(|(index, _)| { + runs[*index].voice.as_ref().map(|v| &v.id) + == run.voice.as_ref().map(|v| &v.id) + }) + .or(lead_groups.first()) + .map(|(index, _)| *index) + } else { + Some(i) + }; + let group = lead + .and_then(|index| { + lead_groups + .iter() + .find(|(i, _)| *i == index) + .map(|(_, group)| *group) + }) + .or_else(|| { + if run.background { + lines.last().and_then(|line| line.vocal_group) + } else { + None + } + }); + let end = if runs.len() == 1 { + end + } else { + words.last().and_then(|word| word.end_ms).or(end) + }; + lines.push(Line::new( + begin, + end, + text, + words, + run.voice.clone(), + run.background, + group, + )); + } + } + lines.sort_by_key(|line| line.time_ms); + Ok(Some(Parsed { + synced: !lines.is_empty(), + word_synced: saw_words, + lines, + plain_text: plain.join("\n"), + ..Parsed::default() + })) +} + +fn xml_source(input: &str, drop_cdata: bool) -> String { + // Dart's default entity mapping leaves bare '&' and unknown references + // literal, including entities declared in a DOCTYPE. Escape those before + // using the stricter XML reader; never resolve external resources. + let mut result = String::with_capacity(input.len()); + let mut index = 0; + while index < input.len() { + let tail = &input[index..]; + let opaque = if tail.starts_with("", drop_cdata)) + } else if tail.starts_with("", false)) + } else if tail.starts_with("", false)) + } else { + None + }; + if let Some((ending, drop)) = opaque { + if let Some(end) = tail.find(ending) { + let end = end + ending.len(); + if !drop { + result.push_str(&tail[..end]); + } + index += end; + continue; + } + // A missing terminator is a syntax error; preserve it for the + // parser without repeatedly scanning the remainder of the input. + result.push_str(tail); + break; + } + if tail.starts_with("().ok()) }; + number.is_some_and(|value| matches!(value, 9 | 10 | 13 | 0x20..=0xd7ff | 0xe000..=0xfffd | 0x10000..=0x10ffff)) + }); + if known { + result.push('&'); + } else { + result.push_str("&"); + } + } else { + result.push(character); + } + index += character.len_utf8(); + } + result +} + +fn with_credits(raw: &str, lyrics: &mut Parsed) -> Result<(), String> { + let tag = |name: &str| { + Regex::new(&format!(r"(?im)^\[{name}:([^\]\r\n]*)\]{WS}*$")) + .unwrap() + .captures(raw) + .map(|c| trim(&c[1]).to_string()) + }; + let mut writers = tag("au"); + let mut provider = tag("x-provider"); + let credit = tag("by").unwrap_or_default(); + if provider.is_none() + && let Some(c) = Regex::new(&format!(r"(?i)\bvia{WS}+([^\(]+)")) + .unwrap() + .captures(&credit) + { + provider = Some( + Regex::new(&format!(r"(?i){WS}+API$")) + .unwrap() + .replace(trim(&c[1]), "") + .into(), + ); + } + if provider.is_none() { + provider = Regex::new(&format!(r"(?i)\(source:{WS}*([^\)]+)\)")) + .unwrap() + .captures(&credit) + .map(|c| trim(&c[1]).into()); + } + provider = provider.map(|value| { + Regex::new(r"(?i)^extension:") + .unwrap() + .replace(&value, "") + .into() + }); + if looks_ttml(raw) { + crate::native_xml::check_depth(raw)?; + let normalized = xml_source(raw, false); + match Document::parse_with_options( + &normalized, + roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }, + ) { + Ok(doc) => { + let mut names: Vec = Vec::new(); + for node in doc + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "songwriter") + { + let name = node + .descendants() + .filter(Node::is_text) + .filter_map(|node| node.text()) + .collect::(); + let name = trim(&name); + if !name.is_empty() && !names.iter().any(|old| old == name) { + names.push(name.into()); + } + } + if !names.is_empty() { + writers = Some(names.join(", ")); + } + } + // XmlTagException/XmlNamespaceException propagate in the existing + // parser; syntax-only XmlParserException falls back to plain text. + Err( + error @ (roxmltree::Error::UnclosedRootNode + | roxmltree::Error::UnexpectedCloseTag(..) + | roxmltree::Error::UnknownNamespace(..)), + ) => return Err(error.to_string()), + Err(_) => {} + } + } + if let Some(line) = lyrics.lines.last() + && let Some(c) = Regex::new(&format!(r"(?i)^Written{WS}+by{WS}*:{WS}*(.+)$")) + .unwrap() + .captures(trim(&line.text)) + { + if writers.is_none() { + writers = Some(trim(&c[1]).into()); + } + lyrics.lines.pop(); + lyrics.plain_text = lyrics + .lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n"); + } + lyrics.writers = writers.filter(|s| !s.is_empty()); + lyrics.provider = provider.filter(|s| !s.is_empty()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_dart_parser_goldens() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/native_lyrics_parsers.json" + )) + .unwrap(); + for fixture in fixtures.as_array().unwrap() { + let actual = execute(&fixture["request"], None, &|| Ok(())).unwrap(); + assert_eq!(actual, fixture["expected"], "{}", fixture["name"]); + } + } + #[test] + fn cancellation_stops_before_result_publication() { + assert_eq!( + execute(&serde_json::json!({"raw":"[00:01]Line"}), None, &|| Err( + "cancelled".into() + )) + .unwrap_err(), + "cancelled" + ); + } + #[test] + fn malformed_structural_ttml_remains_an_error() { + for raw in [ + "

Unclosed", + "

Mismatch", + "Unknown namespace", + ] { + assert!(execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).is_err()); + } + } + #[test] + fn deeply_nested_vocals_preserve_timing_and_document_order() { + std::thread::Builder::new().stack_size(512 << 10).spawn(|| { + let depth = 12; // Including tt/body/p and each word: the 16-level limit. + let raw = format!( + "

{}First second{}

", + "".repeat(depth), + "".repeat(depth), + ); + let parsed = execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap(); + assert_eq!(parsed["lines"][0]["text"], "First second"); + assert_eq!(parsed["lines"][0]["words"][0]["timeMs"], 1000); + assert_eq!(parsed["lines"][0]["words"][1]["timeMs"], 2000); + }).unwrap().join().unwrap(); + } + #[test] + fn pathological_ttml_depth_is_rejected_before_recursive_tokenization() { + let raw = format!( + "

{}Line{}

", + "".repeat(10_000), + "".repeat(10_000), + ); + assert_eq!( + execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap_err(), + "XML nesting exceeds 16 elements" + ); + } + #[test] + fn depth_guard_ignores_opaque_markup_and_quoted_tag_boundaries() { + let opaque = "".repeat(1_000); + let raw = format!( + " {opaque} --> ]>

\">Line

" + ); + crate::native_xml::check_depth(&raw).unwrap(); + let parsed = execute(&serde_json::json!({"raw":raw}), None, &|| Ok(())).unwrap(); + assert_eq!(parsed["plainText"], "Line"); + } +} diff --git a/test/fixtures/native_lyrics_parsers.json b/test/fixtures/native_lyrics_parsers.json new file mode 100644 index 00000000..da73ca70 --- /dev/null +++ b/test/fixtures/native_lyrics_parsers.json @@ -0,0 +1,1317 @@ +[ + { + "name": "empty", + "request": { + "operation": "parse_lyrics", + "raw": "" + }, + "expected": { + "synced": false, + "wordSynced": false, + "plainText": "", + "writers": null, + "provider": null, + "lines": [] + } + }, + { + "name": "plain IDs and Unicode", + "request": { + "operation": "parse_lyrics", + "raw": "[ar:Performer]\r[by:Uploader]\r\nV2: 日本語\n\nPlain text…" + }, + "expected": { + "synced": false, + "wordSynced": false, + "plainText": "日本語\nPlain text", + "writers": null, + "provider": null, + "lines": [] + } + }, + { + "name": "voices words supplements offset", + "request": { + "operation": "parse_lyrics", + "raw": "[offset:100]\n[x-romaji:2000:U2Vjb25kIHJlYWRpbmc=]\n[x-translation:2000:U2Vjb25kIHRyYW5zbGF0aW9u]\n[00:01.00]v1:<00:01.00>First<00:03.00>\n[00:02.00]<00:02.00> V2: Second<00:04.00>\n[00:04.00]v3:Third\n[00:06.00]v12:Twelfth\n[00:08.00]The v2: label stays inside a sentence" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "First\nSecond\nThird\nTwelfth\nThe v2: label stays inside a sentence", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 900, + "endMs": 2900, + "text": "First", + "words": [ + { + "timeMs": 900, + "endMs": 2900, + "text": "First" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 1900, + "endMs": 3900, + "text": "Second", + "words": [ + { + "timeMs": 1900, + "endMs": 3900, + "text": "Second" + } + ], + "romanization": "Second reading", + "romanizationWords": [], + "translation": "Second translation", + "isBackground": false, + "vocalGroup": 1, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 3900, + "endMs": null, + "text": "Third", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 2, + "voice": { + "id": "v3", + "index": 2, + "isGroup": true + } + }, + { + "timeMs": 5900, + "endMs": null, + "text": "Twelfth", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 3, + "voice": { + "id": "v12", + "index": 11, + "isGroup": false + } + }, + { + "timeMs": 7900, + "endMs": null, + "text": "The v2: label stays inside a sentence", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 4, + "voice": null + } + ] + } + }, + { + "name": "background groups sorting", + "request": { + "operation": "parse_lyrics", + "raw": "[offset:100]\n[00:01.00]v2:<00:01.00>Lead<00:06.00>\n[bg:<00:04.00>Echo<00:05.00>]\n[bg:[00:05.00]<00:05.00>Again<00:06.00>]\n[00:03.00]v1:<00:03.00>Other lead<00:06.00>" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "Lead\nEcho\nAgain\nOther lead", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 900, + "endMs": 5900, + "text": "Lead", + "words": [ + { + "timeMs": 900, + "endMs": 5900, + "text": "Lead" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 2900, + "endMs": 5900, + "text": "Other lead", + "words": [ + { + "timeMs": 2900, + "endMs": 5900, + "text": "Other lead" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 3, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 3900, + "endMs": 4900, + "text": "Echo", + "words": [ + { + "timeMs": 3900, + "endMs": 4900, + "text": "Echo" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": true, + "vocalGroup": 0, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 4900, + "endMs": 5900, + "text": "Again", + "words": [ + { + "timeMs": 4900, + "endMs": 5900, + "text": "Again" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": true, + "vocalGroup": 0, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + } + ] + } + }, + { + "name": "repeated line and empty end tags", + "request": { + "operation": "parse_lyrics", + "raw": "[00:08.10][00:01.9]<00:01.00>First <00:00.90><00:01.30><00:01.40><00:02.00>second<00:02.00>" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "First second", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1900, + "endMs": 2000, + "text": "First second", + "words": [ + { + "timeMs": 1000, + "endMs": 1300, + "text": "First " + }, + { + "timeMs": 2000, + "endMs": 2000, + "text": "second" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": null + }, + { + "timeMs": 8100, + "endMs": 2000, + "text": "First second", + "words": [ + { + "timeMs": 1000, + "endMs": 1300, + "text": "First " + }, + { + "timeMs": 2000, + "endMs": 2000, + "text": "second" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "stable simultaneous voice order", + "request": { + "operation": "parse_lyrics", + "raw": "[00:02]v2:Guest\n[00:01]Lead\n[00:02]v1:Lead again\n[bg:echo]" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Guest\nLead\nLead again\necho", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Lead", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": null + }, + { + "timeMs": 2000, + "endMs": null, + "text": "Guest", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": null, + "text": "Lead again", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 2, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": null, + "text": "echo", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": true, + "vocalGroup": 2, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + } + ] + } + }, + { + "name": "boundary collision supplements", + "request": { + "operation": "parse_lyrics", + "raw": "[x-romaji:1009:TmVhcg==]\n[x-romaji:1010:RXhhY3Q=]\n[x-romaji:1001:RnVydGhlcg==]\n[x-translation:2005:VGll]\n[x-translation:2990:Qm91bmRhcnk=]\n[x-translation:4011:VG9vIGZhcg==]\n[00:01.010]A\n[00:02.000]B\n[00:02.010]C\n[00:03.000]D\n[00:04]E" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "A\nB\nC\nD\nE", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1010, + "endMs": null, + "text": "A", + "words": [], + "romanization": "Exact", + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + }, + { + "timeMs": 2000, + "endMs": null, + "text": "B", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": "Tie", + "isBackground": false, + "vocalGroup": 1, + "voice": null + }, + { + "timeMs": 2010, + "endMs": null, + "text": "C", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 2, + "voice": null + }, + { + "timeMs": 3000, + "endMs": null, + "text": "D", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": "Boundary", + "isBackground": false, + "vocalGroup": 3, + "voice": null + }, + { + "timeMs": 4000, + "endMs": null, + "text": "E", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 4, + "voice": null + } + ] + } + }, + { + "name": "romanized timed words offset", + "request": { + "operation": "parse_lyrics", + "raw": "[offset:109]\n[x-romaji-words:1009:W3sidGV4dCI6IlJvIiwic3RhcnRUaW1lTXMiOjEwMDksImVuZFRpbWVNcyI6MTEwN30seyJ0ZXh0IjoibWEgIiwic3RhcnRUaW1lTXMiOjExMDcsImVuZFRpbWVNcyI6MTMwN30seyJ0ZXh0Ijoibml6ZWQiLCJzdGFydFRpbWVNcyI6MjAwMywiZW5kVGltZU1zIjoyNDk3fV0=]\n[x-romaji:1009:Um9tYSBuaXplZA==]\n[00:01.01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 901, + "endMs": null, + "text": "Original", + "words": [], + "romanization": "Roma nized", + "romanizationWords": [ + { + "timeMs": 900, + "endMs": 998, + "text": "Ro" + }, + { + "timeMs": 998, + "endMs": 1198, + "text": "ma " + }, + { + "timeMs": 1894, + "endMs": 2388, + "text": "nized" + } + ], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "romanization mismatched words", + "request": { + "operation": "parse_lyrics", + "raw": "[x-romaji:1000:Um9tYW5pemVk]\n[x-romaji-words:1000:W3sidGV4dCI6Ik90aGVyIiwic3RhcnRUaW1lTXMiOjEwMDAsImVuZFRpbWVNcyI6MTMwMH1d]\n[00:01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Original", + "words": [], + "romanization": "Romanized", + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "only romanization timings remain hidden", + "request": { + "operation": "parse_lyrics", + "raw": "[x-romaji-words:1000:W3sidGV4dCI6IlJvIiwic3RhcnRUaW1lTXMiOjEwMDAsImVuZFRpbWVNcyI6MTMwMH1d]\n[00:01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Original", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "corrupt optional metadata", + "request": { + "operation": "parse_lyrics", + "raw": "[x-romaji:1000:%%%]\n[x-romaji:1000:/w==]\n[x-translation:bad:YQ==]\n[x-translation:1000:]\n[x-romaji-words:1000:W251bGxd]\n[00:01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Original", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "supplement base64 normalization", + "request": { + "operation": "parse_lyrics", + "raw": "[x-romaji:1000:Um8]\n[x-translation:1000:44GT44KT44Gr44Gh44Gv]\n[00:01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Original", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": "こんにちは", + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "unicode newline supplement first wins", + "request": { + "operation": "parse_lyrics", + "raw": "[x-translation:1000:5pel5pys6KqeIF0KU2Vjb25kIGxpbmU=]\n[x-translation:1000:SWdub3JlZA==]\n[00:01]Original" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Original", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Original", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": "日本語 ]\nSecond line", + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "negative offset and clamped timing", + "request": { + "operation": "parse_lyrics", + "raw": "[offset:2500]\n[00:01]<00:01>Word<00:02>\n[00:03]Next" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "Word\nNext", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 0, + "endMs": 0, + "text": "Word", + "words": [ + { + "timeMs": 0, + "endMs": 0, + "text": "Word" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + }, + { + "timeMs": 500, + "endMs": null, + "text": "Next", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": null + } + ] + } + }, + { + "name": "credits explicit trailer", + "request": { + "operation": "parse_lyrics", + "raw": "[au:Existing Writer]\n[x-provider:extension:stored]\n[00:04]Written By: Ignored Writer\n[00:01]Last lyric" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Last lyric", + "writers": "Existing Writer", + "provider": "stored", + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Last lyric", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": null + } + ] + } + }, + { + "name": "credits via API", + "request": { + "operation": "parse_lyrics", + "raw": "[by:SpotiFLAC-Mobile via Example Lyrics API (source: upstream)]\n[00:01]First line\n[00:04]Written By: Example Writer" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "First line", + "writers": "Example Writer", + "provider": "Example Lyrics", + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "First line", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "credits source fallback", + "request": { + "operation": "parse_lyrics", + "raw": "[by:SpotiFLAC-Mobile (source: extension:example.lyrics)]\n[00:01]Lyric" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Lyric", + "writers": null, + "provider": "example.lyrics", + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Lyric", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML namespaced agent inheritance", + "request": { + "operation": "parse_lyrics", + "raw": "LeadGuestTogetherThird" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Lead\nGuest\nTogether\nThird", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": 3000, + "text": "Lead", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "lead", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 3000, + "endMs": 5000, + "text": "Guest", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": { + "id": "guest", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 5000, + "endMs": 7000, + "text": "Together", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 2, + "voice": { + "id": "v3", + "index": 2, + "isGroup": true + } + }, + { + "timeMs": 7000, + "endMs": 9000, + "text": "Third", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 3, + "voice": { + "id": "third", + "index": 2, + "isGroup": false + } + } + ] + } + }, + { + "name": "TTML runs voices syllable grouping", + "request": { + "operation": "parse_lyrics", + "raw": "

日本語ReplyEcho

" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "日本語\nReply\nEcho", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": 3000, + "text": "日本語", + "words": [ + { + "timeMs": 1000, + "endMs": 2000, + "text": "日本" + }, + { + "timeMs": 2000, + "endMs": 3000, + "text": "語" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": 4000, + "text": "Reply", + "words": [ + { + "timeMs": 2000, + "endMs": 4000, + "text": "Reply" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": 3000, + "text": "Echo", + "words": [ + { + "timeMs": 2000, + "endMs": 3000, + "text": "Echo" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": true, + "vocalGroup": 0, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + } + ] + } + }, + { + "name": "TTML partial timing retains text", + "request": { + "operation": "parse_lyrics", + "raw": "

Untimed timed ending

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Untimed timed ending", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": 5000, + "text": "Untimed timed ending", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML paragraph end and word end separate", + "request": { + "operation": "parse_lyrics", + "raw": "

\nFirst\nsecond\n

" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "First second", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1009, + "endMs": 5000, + "text": "First second", + "words": [ + { + "timeMs": 1009, + "endMs": 1307, + "text": "First " + }, + { + "timeMs": 2003, + "endMs": 2497, + "text": "second" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML duplicate songwriters nested entity text", + "request": { + "operation": "parse_lyrics", + "raw": "Example & WriterSecond WriterExample & Writer

Lyric

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Lyric", + "writers": "Example & Writer, Second Writer", + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Lyric", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML invalid missing paragraph timing falls to plain", + "request": { + "operation": "parse_lyrics", + "raw": "

No time

" + }, + "expected": { + "synced": false, + "wordSynced": false, + "plainText": "

No time

", + "writers": null, + "provider": null, + "lines": [] + } + }, + { + "name": "TTML CDATA vocals skipped but songwriter retained", + "request": { + "operation": "parse_lyrics", + "raw": "

Visible again

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "Visible again", + "writers": "Writer & guest", + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "Visible again", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML malformed syntax falls to plain", + "request": { + "operation": "parse_lyrics", + "raw": "Writer & Guest &unknown;

A & B &unknown; & C

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "A & B &unknown; & C", + "writers": "Writer & Guest &unknown;", + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "A & B &unknown; & C", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML DOCTYPE accepts declarations without expanding custom entities", + "request": { + "operation": "parse_lyrics", + "raw": "]>

&custom; & unchanged

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "&custom; & unchanged", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "&custom; & unchanged", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML Unicode whitespace regex retains NEL within vocals", + "request": { + "operation": "parse_lyrics", + "raw": "

A…B C

" + }, + "expected": { + "synced": true, + "wordSynced": false, + "plainText": "A…B C", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": null, + "text": "A…B C", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + } + ] + } + }, + { + "name": "TTML clocks negative and hours", + "request": { + "operation": "parse_lyrics", + "raw": "

Negative

Word

Rounded

" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "Negative\nWord\nRounded", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": -1250, + "endMs": 500, + "text": "Negative", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": null + }, + { + "timeMs": 7001, + "endMs": 8000, + "text": "Rounded", + "words": [], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 2, + "voice": null + }, + { + "timeMs": 3723456, + "endMs": 3724123, + "text": "Word", + "words": [ + { + "timeMs": 3723456, + "endMs": null, + "text": "Word" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": null + } + ] + } + }, + { + "name": "TTML same-voice fragments merge across interruption", + "request": { + "operation": "parse_lyrics", + "raw": "

A B CEcho

" + }, + "expected": { + "synced": true, + "wordSynced": true, + "plainText": "A C\nB\nEcho", + "writers": null, + "provider": null, + "lines": [ + { + "timeMs": 1000, + "endMs": 5000, + "text": "A C", + "words": [ + { + "timeMs": 1000, + "endMs": null, + "text": "A " + }, + { + "timeMs": 3000, + "endMs": null, + "text": "C" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 0, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": 5000, + "text": "B", + "words": [ + { + "timeMs": 2000, + "endMs": null, + "text": "B" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": false, + "vocalGroup": 1, + "voice": { + "id": "v2", + "index": 1, + "isGroup": false + } + }, + { + "timeMs": 2000, + "endMs": 5000, + "text": "Echo", + "words": [ + { + "timeMs": 2000, + "endMs": null, + "text": "Echo" + } + ], + "romanization": null, + "romanizationWords": [], + "translation": null, + "isBackground": true, + "vocalGroup": 0, + "voice": { + "id": "v1", + "index": 0, + "isGroup": false + } + } + ] + } + } +] diff --git a/test/id3v23_lyrics_file_test.dart b/test/id3v23_lyrics_file_test.dart index 2835c046..8bf04506 100644 --- a/test/id3v23_lyrics_file_test.dart +++ b/test/id3v23_lyrics_file_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/id3v23_lyrics.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); late Directory root; setUp(() async { root = await Directory.systemTemp.createTemp('id3-lyrics-test-'); diff --git a/test/native_lyrics_parser_test.dart b/test/native_lyrics_parser_test.dart new file mode 100644 index 00000000..1b1be3ea --- /dev/null +++ b/test/native_lyrics_parser_test.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; + +Map _word(LyricWord value) => { + 'timeMs': value.time.inMilliseconds, + 'endMs': value.end?.inMilliseconds, + 'text': value.text, +}; + +Map _dto(ParsedLyrics value) => { + 'synced': value.synced, + 'wordSynced': value.wordSynced, + 'plainText': value.plainText, + 'writers': value.writers, + 'provider': value.provider, + 'lines': [ + for (final line in value.lines) + { + 'timeMs': line.time.inMilliseconds, + 'endMs': line.end?.inMilliseconds, + 'text': line.text, + 'words': line.words.map(_word).toList(), + 'romanization': line.romanization, + 'romanizationWords': line.romanizationWords.map(_word).toList(), + 'translation': line.translation, + 'voice': line.voice == null + ? null + : { + 'id': line.voice!.id, + 'index': line.voice!.index, + 'isGroup': line.voice!.isGroup, + }, + 'isBackground': line.isBackground, + 'vocalGroup': line.vocalGroup, + }, + ], +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final fixtures = + (jsonDecode( + File( + 'test/fixtures/native_lyrics_parsers.json', + ).readAsStringSync(), + ) + as List) + .cast>(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + for (final fixture in fixtures) { + test('native DTO and fallback preserve ${fixture['name']}', () async { + final request = fixture['request'] as Map; + final raw = request['raw'] as String; + final expected = fixture['expected'] as Map; + expect(_dto(LyricsParser.parse(raw)), expected); + // With no native host, the production async caller uses its isolated + // compatibility parser and preserves the same public models. + expect(_dto(await LyricsParser.parseAsyncNative(raw)), expected); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'runNativeDataJob'); + final args = call.arguments as Map; + final request = jsonDecode(args['request_json'] as String) as Map; + expect(request['operation'], 'parse_lyrics'); + expect(args['bytes'] as Uint8List, utf8.encode(raw)); + calls++; + return jsonEncode(expected); + }); + try { + expect(_dto(await LyricsParser.parseAsyncNative(raw)), expected); + expect(calls, raw.isEmpty ? 0 : 1); + } finally { + messenger.setMockMethodCallHandler(channel, null); + } + }); + } + + test('deep valid TTML uses the isolated compatibility parser', () async { + final raw = + '

' + '${List.filled(18, '').join()}' + 'First second' + '${List.filled(18, '').join()}

'; + final expected = LyricsParser.parse(raw); + expect(expected.lines.single.text, 'First second'); + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'runNativeDataJob'); + calls++; + throw PlatformException( + code: 'data_job_error', + message: 'XML nesting exceeds 16 elements', + ); + }); + try { + expect(_dto(await LyricsParser.parseAsyncNative(raw)), _dto(expected)); + expect(calls, 1); + } finally { + messenger.setMockMethodCallHandler(channel, null); + } + }); + + test('a real native parser failure is not retried as Dart success', () async { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException( + code: 'data_job_error', + message: 'Invalid lyric document', + ); + }); + try { + await expectLater( + LyricsParser.parseAsyncNative('[00:01]Line'), + throwsA(isA()), + ); + } finally { + messenger.setMockMethodCallHandler(channel, null); + } + }); +} diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 87e9fc61..f0bfbeea 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; @@ -25,6 +26,7 @@ import 'package:video_player/video_player.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/expressive_seek_track.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; @@ -54,6 +56,7 @@ void main() { late List volumeWrites; late Map metadataOverrides; late List metadataReads; + late List<({String? raw, Completer completion})> lyricsRequests; setUp(() { SharedPreferences.setMockInitialValues({}); @@ -61,11 +64,20 @@ void main() { volumeWrites = []; metadataOverrides = {}; metadataReads = []; + lyricsRequests = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(secureStorageChannel, (_) async => null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(backendChannel, (call) async { if (call.method == 'setScreenAwake') return null; + if (call.method == 'runNativeDataJob') { + final arguments = (call.arguments as Map).cast(); + final request = + jsonDecode(arguments['request_json'] as String) as Map; + expect(request['operation'], 'parse_lyrics'); + final raw = utf8.decode(arguments['bytes'] as List); + return jsonEncode(_lyricsDto(LyricsParser.parse(raw))); + } if (call.method != 'readFileMetadata') { fail('Unexpected platform call: ${call.method}'); } @@ -127,6 +139,16 @@ void main() { ); } + NowPlayingScreen playerScreen() => NowPlayingScreen( + metadataControllerFactory: () => PlayerMetadataController( + parseLyrics: (raw) { + final completion = Completer(); + lyricsRequests.add((raw: raw, completion: completion)); + return completion.future; + }, + ), + ); + Future pumpNowPlaying( WidgetTester tester, { ThemeData? theme, @@ -184,14 +206,54 @@ void main() { localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: theme, - home: - wrapPlayer?.call(const NowPlayingScreen()) ?? - const NowPlayingScreen(), + home: wrapPlayer?.call(playerScreen()) ?? playerScreen(), ), ), ); } + Future completeLyrics(WidgetTester tester) async { + await tester.pump(); + // Start the actual encode/native DTO/hydration pipeline in the real async + // zone: futures started under FakeAsync cannot finish isolate messages. + while (lyricsRequests.isNotEmpty) { + final pending = List.of(lyricsRequests); + lyricsRequests.clear(); + await tester.runAsync(() async { + for (final request in pending) { + try { + request.completion.complete( + await LyricsParser.parseAsyncNative(request.raw), + ); + } catch (error, stack) { + request.completion.completeError(error, stack); + } + } + }); + await tester.pump(); + } + } + + Future settlePlayer( + WidgetTester tester, [ + Duration duration = const Duration(milliseconds: 100), + ]) async { + final deadline = tester.binding.clock.fromNowBy( + const Duration(minutes: 10), + ); + var frames = 0; + do { + if (tester.binding.clock.now().isAfter(deadline)) { + fail('Player animations did not settle'); + } + // A track change may start another metadata read during a later frame. + await completeLyrics(tester); + await tester.pump(duration); + frames++; + } while (tester.binding.hasScheduledFrame || lyricsRequests.isNotEmpty); + return frames; + } + testWidgets('Material seek wave follows playback, buffering and pause', ( tester, ) async { @@ -199,7 +261,7 @@ void main() { addTearDown(playback.close); await pumpNowPlaying(tester, playbackEvents: playback.stream); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); for (final state in [ PlaybackState(playing: true, processingState: AudioProcessingState.ready), @@ -224,7 +286,7 @@ void main() { state.playing && state.processingState == AudioProcessingState.ready, ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.takeException(), isNull); }); @@ -245,7 +307,7 @@ void main() { playback: PlaybackState(queueIndex: 0), ); mediaItems.add(queue.first); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (page != 'player') { await tester.tap( find.byIcon( @@ -254,7 +316,7 @@ void main() { : CupertinoIcons.list_bullet, ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); } final cover = find .byKey( @@ -271,11 +333,11 @@ void main() { await tester.pump(); expect(tester.getRect(cover), rect); await gesture.up(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(controller.selected, [1]); expect(find.text('Second').hitTestable(), findsOneWidget); await tester.drag(cover, const Offset(110, 0)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(controller.selected, [1, 0]); expect(find.text('First').hitTestable(), findsOneWidget); expect(tester.takeException(), isNull); @@ -293,7 +355,7 @@ void main() { motionArtwork: artwork, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final title = tester.getTopLeft(find.text('First').hitTestable()).dy; final transport = tester .getCenter( @@ -343,9 +405,9 @@ void main() { updatePosition: const Duration(seconds: 30), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final list = find.byType(ListView); final bounds = tester.getRect(list); final scroll = tester.widget(list).controller!; @@ -416,9 +478,9 @@ void main() { ), ); mediaItems.add(item('first').copyWith(title: longTitle)); - await tester.pumpAndSettle(); + await settlePlayer(tester); playbackEvents.add(PlaybackState(playing: true)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final cover = find.byType(MornyePlayerArtwork); final playingCover = tester.getRect(cover); @@ -450,7 +512,7 @@ void main() { inExclusiveRange(playingCover.width * 0.73, playingCover.width), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); final pausedCover = tester.getRect(cover); expect(pausedCover.width, closeTo(playingCover.width * 0.73, 0.01)); expect(pausedCover.center.dx, closeTo(playingCover.center.dx, 0.01)); @@ -459,7 +521,7 @@ void main() { expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); playbackEvents.add(PlaybackState(playing: true)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(cover).width, closeTo(playingCover.width, 0.01)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -480,7 +542,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final background = tester.widget( find.byType(MornyePlayerBackground), ); @@ -543,9 +605,9 @@ void main() { ); mediaItems.add(item('many')); playback.add(PlaybackState(updatePosition: const Duration(seconds: 140))); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final current = find.text( 'Line 70 with enough words to wrap across several rows', ); @@ -559,15 +621,15 @@ void main() { expectUpperFocus(current); await tester.drag(list, const Offset(0, 200)); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectUpperFocus(current); for (final index in [99, 0]) { playback.add(PlaybackState(updatePosition: Duration(seconds: index * 2))); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectUpperFocus( find.text('Line $index with enough words to wrap across several rows'), ); @@ -595,9 +657,9 @@ void main() { playback: PlaybackState(updatePosition: const Duration(seconds: 2)), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final track = seekTrackBounds(tester); for (final text in [original, pronunciation, translation]) { @@ -664,9 +726,9 @@ void main() { playback.add( PlaybackState(updatePosition: const Duration(seconds: 1)), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final slider = find.descendant( of: find.byType(PlaybackSeekSlider), matching: find.byType(Slider), @@ -696,7 +758,7 @@ void main() { } final gesture = await tester.startGesture(point(0.02)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final initial = offset(); await gesture.moveTo(point(0.82)); await tester.pump(); @@ -704,7 +766,7 @@ void main() { await tester.pump(const Duration(milliseconds: 70)); final intermediate = offset(); expect(intermediate, greaterThan(initial)); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (!reducedMotion) expect(offset(), greaterThan(intermediate)); expectFocus(); expect(controller.seeks, isEmpty); @@ -715,7 +777,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 70)); expect(offset(), lessThan(forward)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectFocus(); final held = offset(); // Neither playback ticks nor the automatic next-line timer may @@ -739,7 +801,7 @@ void main() { playback.add(PlaybackState(updatePosition: controller.seeks.single)); await tester.pump(); controller.completions.single.complete(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(offset(), closeTo(held, 0.01)); expectFocus(); expect(tester.takeException(), isNull); @@ -769,9 +831,9 @@ void main() { ); mediaItems.add(item('first')); playback.add(PlaybackState(updatePosition: const Duration(seconds: 1))); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final sliderFinder = find.descendant( of: find.byType(PlaybackSeekSlider), matching: find.byType(Slider), @@ -782,7 +844,7 @@ void main() { slider().onChangeStart!(1000); Future preview(int milliseconds) async { slider().onChanged!(milliseconds.toDouble()); - await tester.pumpAndSettle(); + await settlePlayer(tester); } Future> wordPixels() async { @@ -846,7 +908,7 @@ void main() { metadataOverrides['lyrics'] = '[00:02.00]New track start\n[00:14.00]Later'; mediaItems.add(item('second')); playback.add(PlaybackState(updatePosition: Duration.zero)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(slider().value, 0); expect(find.text('New track start'), findsOneWidget); expect(find.byType(LyricGapIndicator), findsNothing); @@ -856,7 +918,7 @@ void main() { final list = tester.widget(find.byType(ListView)); expect(list.controller!.offset, 0); controller.completions.single.complete(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(slider().value, 0); expect( tester @@ -867,7 +929,7 @@ void main() { ); slider().onChangeEnd!(0); controller.completions.last.complete(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.takeException(), isNull); }); @@ -882,7 +944,7 @@ void main() { size: const Size(900, 420), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final bar = tester.widget(find.byType(AppBar)); expect(bar.toolbarHeight, 0); expect(bar.title, isNull); @@ -914,9 +976,9 @@ void main() { ), ); mediaItems.add(item('many')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final list = find.byType(ListView); final bounds = tester.getRect(list); final scrollable = find.descendant( @@ -982,11 +1044,11 @@ void main() { expect(tester.getTopLeft(find.text('Second')).dy, headerTop); expect(transport.hitTestable(), findsNWidgets(3)); await touch.up(); - await tester.pumpAndSettle(); + await settlePlayer(tester); final releasedOffset = state.position.pixels; clarity.value = 0.5; await tester.pump(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(state.position.pixels, closeTo(releasedOffset, 0.01)); await tester.pump(const Duration(seconds: 3)); @@ -1004,9 +1066,9 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); expect(state.position.pixels, closeTo(nextOffset, 0.01)); await nextTouch.up(); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 4)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(state.position.pixels, closeTo(initialOffset, 0.01)); await tester.pumpWidget(const SizedBox()); expect(tester.takeException(), isNull); @@ -1035,7 +1097,7 @@ void main() { ), ); mediaItems.add(item('many')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); final queueButton = find.byIcon(CupertinoIcons.list_bullet); final play = find.widgetWithIcon( @@ -1045,6 +1107,7 @@ void main() { final volume = find.byType(MornyeVolumeControl); await tester.tap(lyricsButton); await tester.pump(); + await completeLyrics(tester); await tester.pump(const Duration(milliseconds: 4900)); expect(play.hitTestable(), findsOneWidget); final list = find.byType(ListView); @@ -1058,7 +1121,7 @@ void main() { closeTo(originalTop, 1), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(play.hitTestable(), findsNothing); expect(volume.hitTestable(), findsNothing); expect(lyricsButton.hitTestable(), findsNothing); @@ -1079,7 +1142,7 @@ void main() { closeTo(originalTop, 1), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(play.hitTestable(), findsOneWidget); expect(lyricsButton.hitTestable(), findsOneWidget); } @@ -1096,17 +1159,17 @@ void main() { await tester.pump(const Duration(milliseconds: 4900)); expect(volume.hitTestable(), findsOneWidget); await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(volume.hitTestable(), findsNothing); await reveal(); await tester.tap(queueButton.hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(play.hitTestable(), findsOneWidget); expect(queueButton.hitTestable(), findsOneWidget); await tester.tap(queueButton.hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(play.hitTestable(), findsOneWidget); expect(tester.takeException(), isNull); @@ -1128,10 +1191,10 @@ void main() { ); playback.add(PlaybackState(playing: false)); mediaItems.add(item('many')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); await tester.tap(lyricsButton); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(lyricsButton.hitTestable(), findsOneWidget); @@ -1141,11 +1204,11 @@ void main() { await tester.pump(const Duration(milliseconds: 4900)); expect(lyricsButton.hitTestable(), findsOneWidget); await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(lyricsButton.hitTestable(), findsNothing); playback.add(PlaybackState(playing: false)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(lyricsButton.hitTestable(), findsOneWidget); await tester.pump(const Duration(seconds: 6)); expect(lyricsButton.hitTestable(), findsOneWidget); @@ -1156,7 +1219,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(seconds: 3)); playback.add(PlaybackState(playing: false)); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(lyricsButton.hitTestable(), findsOneWidget); @@ -1166,7 +1229,7 @@ void main() { await tester.pump(const Duration(milliseconds: 4900)); expect(lyricsButton.hitTestable(), findsOneWidget); await tester.pump(const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(lyricsButton.hitTestable(), findsNothing); expect(tester.takeException(), isNull); }, @@ -1189,9 +1252,9 @@ void main() { ), ); mediaItems.add(item('many')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(find.byType(MornyeVolumeControl).hitTestable(), findsOneWidget); expect( @@ -1228,7 +1291,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); Future?> edgePixel() => tester.runAsync(() async { final boundary = tester.renderObject( @@ -1246,7 +1309,7 @@ void main() { final onWhite = await edgePixel(); background.value = Colors.red; - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(await edgePixel(), onWhite); expect(tester.takeException(), isNull); }); @@ -1260,7 +1323,7 @@ void main() { size: const Size(393, 780), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final moreButton = find.byIcon(CupertinoIcons.ellipsis).hitTestable(); final anchor = tester.getRect(moreButton); await tester.tap(moreButton); @@ -1268,7 +1331,7 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); final panel = find.byType(MornyePlayerActionsSheet); final enteringWidth = tester.getSize(panel).width; - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getSize(panel).width, enteringWidth); expect(tester.getRect(panel).bottom, lessThan(anchor.top)); expect(tester.getRect(panel).width, 280); @@ -1293,7 +1356,7 @@ void main() { expect(tester.widget(find.text('Go to Album')).style?.fontSize, 16); await tester.ensureVisible(find.text('Sleep timer')); await tester.tap(find.text('Sleep timer')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( Theme.of(tester.element(find.text('15 minutes'))).brightness, Brightness.dark, @@ -1311,9 +1374,9 @@ void main() { size: const Size(393, 780), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('First').hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Go to Artist'), findsOneWidget); expect(find.text('Go to Album'), findsOneWidget); expect( @@ -1331,7 +1394,7 @@ void main() { findsOneWidget, ); await tester.tapAt(const Offset(5, 770)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(MornyePlayerNavigationMenu), findsNothing); expect(tester.takeException(), isNull); }, @@ -1355,13 +1418,13 @@ void main() { size: const Size(393, 852), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(metadataReads, isEmpty); await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Details')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(metadataReads, ['content://library/first.flac']); final details = tester.widget( @@ -1406,11 +1469,11 @@ void main() { mediaItems.add( item('first').copyWith(extras: {'source': '/library/first.flac'}), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Details')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final sheet = find.byType(MornyePlayerDetailsSheet); expect(sheet, findsOneWidget); expect(Theme.of(tester.element(sheet)).brightness, Brightness.dark); @@ -1442,7 +1505,7 @@ void main() { expect(tester.getSize(find.text(rights)).height, greaterThan(40)); expect(find.text('Done').hitTestable(), findsOneWidget); await tester.tap(find.text('Done')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(sheet, findsNothing); expect(tester.takeException(), isNull); }, @@ -1457,14 +1520,14 @@ void main() { size: const Size(393, 780), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final container = ProviderScope.containerOf( tester.element(find.byType(NowPlayingScreen)), ); final star = find.byIcon(CupertinoIcons.star).hitTestable(); expect(star, findsOneWidget); await tester.tap(star); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.star_fill).hitTestable(), findsOneWidget, @@ -1474,30 +1537,30 @@ void main() { 'first', ); await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Favorited')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(container.read(libraryCollectionsProvider).loved, isEmpty); expect(star, findsOneWidget); await tester.tap(find.byIcon(CupertinoIcons.ellipsis).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Favorite')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( container.read(libraryCollectionsProvider).loved.single.track.id, 'first', ); await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.star_fill).hitTestable(), findsOneWidget, ); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.star_fill).hitTestable(), findsOneWidget, @@ -1512,7 +1575,7 @@ void main() { await container .read(libraryCollectionsProvider.notifier) .toggleLoved(track); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byIcon(CupertinoIcons.star).hitTestable(), findsOneWidget); expect( find.byType(MornyePlayerFavoriteButton).hitTestable(), @@ -1543,13 +1606,13 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (layout == 'material') { await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); } else { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } - await tester.pumpAndSettle(); + await settlePlayer(tester); Future positionAt( int seconds, { @@ -1564,7 +1627,7 @@ void main() { ), ); if (state == AudioProcessingState.ready && !playing) { - await tester.pumpAndSettle(); + await settlePlayer(tester); } else { await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); @@ -1600,7 +1663,7 @@ void main() { expect(top - before, lessThan(56)); previous = top; } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getTopLeft(firstVocal).dy - before, closeTo(56, 0.1)); } @@ -1683,7 +1746,7 @@ void main() { expect(find.byType(LyricGapIndicator), findsOneWidget); metadataOverrides.clear(); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(LyricGapIndicator), findsNothing); expect(tester.takeException(), isNull); }); @@ -1712,6 +1775,7 @@ void main() { await tester.pump(const Duration(milliseconds: 400)); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); await tester.pump(); + await completeLyrics(tester); await tester.pump(const Duration(milliseconds: 400)); void expectLine(String text, {required bool active}) { @@ -1731,7 +1795,7 @@ void main() { expectLine('Third line', active: false); playback.add(PlaybackState(processingState: AudioProcessingState.ready)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectLine('First line', active: false); playback.add( @@ -1755,7 +1819,7 @@ void main() { updatePosition: const Duration(seconds: 2), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectLine('Second line', active: true); playback.add( @@ -1785,9 +1849,9 @@ void main() { playback: PlaybackState(updatePosition: const Duration(seconds: 2)), ); mediaItems.add(item('many')); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); final active = find.text('Second line'); final inactive = find.text('Third line'); final activeText = tester.widget(active); @@ -1824,13 +1888,13 @@ void main() { size: const Size(393, 780), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.text('First lyric').hitTestable(), findsOneWidget); final volume = tester.getRect(find.byType(MornyeVolumeControl)); await tester.tap(find.byIcon(CupertinoIcons.list_bullet)); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.byType(MornyePlayerQueue), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); @@ -1842,7 +1906,7 @@ void main() { expect(find.text('First lyric'), findsNothing); expect(find.byType(MornyePlayerQueue), findsOneWidget); expect(find.byType(MornyePlayerQueue).hitTestable(), findsNothing); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); expect(find.byType(MornyePlayerQueue), findsNothing); expect(tester.getRect(find.byType(MornyeVolumeControl)), volume); }, @@ -1862,7 +1926,7 @@ void main() { ); playback.add(PlaybackState(playing: true)); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final pause = find.widgetWithIcon( MornyePlaybackButton, CupertinoIcons.pause_fill, @@ -1878,7 +1942,7 @@ void main() { final volumeBounds = tester.getRect(find.byType(MornyeVolumeControl)); playback.add(PlaybackState(playing: false)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final play = find.widgetWithIcon( MornyePlaybackButton, CupertinoIcons.play_fill, @@ -1892,7 +1956,7 @@ void main() { expect(tester.getRect(find.byType(MornyeVolumeControl)), volumeBounds); playback.add(PlaybackState(playing: true)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( tester.getSize(find.byIcon(CupertinoIcons.pause_fill)), pauseSize, @@ -1911,7 +1975,7 @@ void main() { size: Size(width, 900), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(ExpressiveIconButton), findsNWidgets(6)); for (final element in find.byType(ExpressiveIconButton).evaluate()) { final bounds = tester.getRect(find.byWidget(element.widget)); @@ -1956,7 +2020,7 @@ void main() { extras: {'source': '/music/first.flac'}, ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(PageView), findsNothing); final slider = find.byType(PlaybackSeekSlider); final play = find.byTooltip('Play'); @@ -1975,7 +2039,7 @@ void main() { expect(tester.getRect(play), playBounds); expect(tester.state(slider), same(sliderState)); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('First lyric').hitTestable(), findsOneWidget); expect( find.byKey(const ValueKey('material-player-cover')), @@ -1987,10 +2051,10 @@ void main() { expect(controller.seeks, hasLength(1)); expect(controller.seeks.single.inSeconds, closeTo(90, 1)); controller.completions.single.complete(); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(toggle); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byKey(const ValueKey('material-player-cover')), findsOneWidget, @@ -2024,9 +2088,9 @@ void main() { ), ); mediaItems.add(queue.first); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(Icons.queue_music)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final rows = find.descendant( of: find.byType(ReorderableListView), matching: find.byType(ListTile), @@ -2059,7 +2123,7 @@ void main() { await tester.tap( find.descendant(of: rows, matching: find.text('Second')), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(controller.selected, [1]); expect(tester.takeException(), isNull); }); @@ -2070,7 +2134,7 @@ void main() { ) async { await pumpNowPlaying(tester, theme: MornyeTheme.build(Brightness.light)); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byIcon(CupertinoIcons.play_fill), findsOneWidget); expect(find.byIcon(CupertinoIcons.backward_fill), findsOneWidget); @@ -2084,7 +2148,7 @@ void main() { ); final nextState = tester.state(nextButton); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.state(nextButton), same(nextState)); }); @@ -2131,7 +2195,7 @@ void main() { } await positionAt(34200); - await tester.pumpAndSettle(); + await settlePlayer(tester); expectTimes(34, 146); final transport = tester .widgetList(find.byType(MornyePlaybackButton)) @@ -2172,7 +2236,7 @@ void main() { expectTimes(35, 145); await positionAt(36000); expectTimes(36, 144); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.takeException(), isNull); }); @@ -2185,7 +2249,7 @@ void main() { size: const Size(393, 700), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final volume = find.byType(MornyeVolumeControl); final lyricsButton = find.byIcon(CupertinoIcons.quote_bubble); @@ -2201,7 +2265,7 @@ void main() { expect(volumeWrites, isNotEmpty); expect(volumeWrites.last, greaterThan(0.5)); await gesture.up(); - await tester.pumpAndSettle(const Duration(milliseconds: 16)); + await settlePlayer(tester, const Duration(milliseconds: 16)); expect(volumeWrites, isNotEmpty); expect(volumeWrites.last, greaterThan(0.5)); expect(tester.takeException(), isNull); @@ -2217,12 +2281,12 @@ void main() { playback: PlaybackState(playing: true), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); tester.view.physicalSize = const Size(852, 393); tester.view.padding = FakeViewPadding(left: 59, right: 59, bottom: 21); addTearDown(tester.view.resetPadding); - await tester.pumpAndSettle(); + await settlePlayer(tester); final artwork = find.byType(Hero); final volume = find.byType(MornyeVolumeControl); @@ -2261,11 +2325,11 @@ void main() { expect(volumeWrites, isNotEmpty); await drag.up(); await tester.pump(const Duration(seconds: 4)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(volume.hitTestable(), findsOneWidget); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(volume, findsNothing); expect(find.text('First lyric').hitTestable(), findsOneWidget); expect(tester.getRect(lyric).left, greaterThan(artRect.right)); @@ -2274,7 +2338,7 @@ void main() { greaterThan(tester.getRect(header).bottom), ); await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), findsNothing, @@ -2284,25 +2348,25 @@ void main() { findsNothing, ); await tester.tap(find.byKey(const ValueKey('landscape-actions-reveal'))); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), findsOneWidget, ); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(volume.hitTestable(), findsOneWidget); expect(lyric, findsNothing); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble).hitTestable()); - await tester.pumpAndSettle(); + await settlePlayer(tester); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Second lyric').hitTestable(), findsOneWidget); expect(find.text('First lyric'), findsNothing); tester.view.physicalSize = const Size(393, 852); tester.view.resetPadding(); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( find.byIcon(CupertinoIcons.quote_bubble).hitTestable(), findsOneWidget, @@ -2340,7 +2404,7 @@ void main() { child: TextButton( onPressed: () => Navigator.of( context, - ).push(NowPlayingRoute(child: const NowPlayingScreen())), + ).push(NowPlayingRoute(child: playerScreen())), child: const Hero( tag: kNowPlayingArtworkHeroTag, child: SizedBox.square(dimension: 38, child: Text('Open')), @@ -2352,7 +2416,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Open')); await tester.pump(); @@ -2374,7 +2438,7 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); expectAttached(); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expectAttached(); Navigator.of(tester.element(find.byType(NowPlayingScreen))).pop(); await tester.pump(); @@ -2386,7 +2450,7 @@ void main() { greaterThan(0), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(NowPlayingScreen), findsNothing); expect(tester.takeException(), isNull); }, @@ -2416,7 +2480,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final mini = find.byType(MiniPlayer); final miniCover = find.descendant( of: mini, @@ -2426,7 +2490,7 @@ void main() { await tester.tap( find.descendant(of: mini, matching: find.text('First')), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); final player = find.byType(NowPlayingScreen); final route = ModalRoute.of(tester.element(player))! as NowPlayingRoute; @@ -2475,7 +2539,7 @@ void main() { ); previousDistance = distance; } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(player, findsNothing); expect(tester.getRect(miniCover), destination); expect(tester.takeException(), isNull); @@ -2502,7 +2566,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final fullCover = find.descendant( of: find.byType(MornyePlayerBackground), matching: find.byType(MornyePlayerArtwork), @@ -2528,7 +2592,7 @@ void main() { expect(playback.hitTestable(), findsOneWidget); previous = bounds; } - await tester.pumpAndSettle(); + await settlePlayer(tester); final compactBounds = tester.getRect(compact); expect(compactBounds.size, const Size(72, 72)); final header = find.byKey(const ValueKey('player-track-header')); @@ -2546,7 +2610,7 @@ void main() { expect(playback.hitTestable(), findsOneWidget); previous = bounds; } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(compact, findsNothing); expect(tester.getRect(fullCover), fullBounds); expect(tester.takeException(), isNull); @@ -2562,7 +2626,7 @@ void main() { size: const Size(393, 852), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final toggle = find.byIcon( lyrics ? CupertinoIcons.quote_bubble : CupertinoIcons.list_bullet, ); @@ -2592,7 +2656,7 @@ void main() { expect(tester.getRect(play), controlsBounds); previous = bounds; } - await tester.pumpAndSettle(); + await settlePlayer(tester); final cover = tester.getRect( find.byKey(const ValueKey('compact-player-artwork')), ); @@ -2626,7 +2690,7 @@ void main() { expect(tester.getRect(play), controlsBounds); previous = bounds; } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(header), expandedBounds); expect( find.byKey(const ValueKey('full-player-artwork')), @@ -2653,13 +2717,13 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final artwork = find.byType(MornyePlayerArtwork); final restingBounds = tester.getRect(artwork); final lyrics = find.byIcon(CupertinoIcons.quote_bubble); final compact = find.byKey(const ValueKey('compact-player-artwork')); await tester.tap(lyrics); - await tester.pumpAndSettle(); + await settlePlayer(tester); final compactBounds = tester.getRect(compact); await tester.tap(compact); await tester.pump(); @@ -2673,7 +2737,7 @@ void main() { expect(tester.getRect(compact), bounds); previous = bounds; } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(artwork), restingBounds); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -2700,9 +2764,9 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final scrollable = find .ancestor( of: find.text('Line 10'), @@ -2720,7 +2784,7 @@ void main() { expect(state.position.pixels, offset); await tester.pump(const Duration(milliseconds: 50)); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(state.mounted, isFalse); expect(tester.takeException(), isNull); }, @@ -2736,7 +2800,7 @@ void main() { size: const Size(393, 852), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final header = find.byKey(const ValueKey('player-track-header')); final element = tester.element(header); final expandedBounds = tester.getRect(header); @@ -2752,18 +2816,18 @@ void main() { await tester.pump(const Duration(milliseconds: 80)); expect(tester.getRect(header).top, greaterThan(interruptedBounds.top)); expect(tester.getRect(header).left, lessThan(interruptedBounds.left)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(header), expandedBounds); await tester.tap(lyrics); - await tester.pumpAndSettle(); + await settlePlayer(tester); final compactBounds = tester.getRect(header); await tester.tap(queue); await tester.pump(); await tester.pump(const Duration(milliseconds: 160)); expect(tester.getRect(header), compactBounds); expect(tester.element(header), same(element)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(header), compactBounds); expect(tester.takeException(), isNull); }); @@ -2786,7 +2850,7 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final header = find.byKey(const ValueKey('player-track-header')); final expandedBounds = tester.getRect(header); final lyrics = find.byIcon(CupertinoIcons.quote_bubble); @@ -2814,7 +2878,7 @@ void main() { size: const Size(393, 700), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); final artwork = find.byType(Hero); final fullArtwork = tester.getRect(artwork); @@ -2838,7 +2902,7 @@ void main() { .value; expect(fullCover, findsOneWidget); expect(fullCoverOpacity(), inExclusiveRange(0.0, 1.0)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.byType(PageView), findsNothing); expect(find.text('First lyric').hitTestable(), findsOneWidget); @@ -2849,7 +2913,7 @@ void main() { expect(tester.getRect(play), playRect); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Second lyric').hitTestable(), findsOneWidget); expect(find.text('First lyric'), findsNothing); @@ -2861,7 +2925,7 @@ void main() { find.descendant(of: fullCover, matching: find.byType(Hero)), findsOneWidget, ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(tester.getRect(artwork), fullArtwork); expect(find.text('Second lyric').hitTestable(), findsNothing); expect(tester.takeException(), isNull); @@ -2899,9 +2963,9 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('First lyric'), findsOneWidget); expect(metadataReads, hasLength(1)); expect(tester.takeException(), isNull); @@ -2913,13 +2977,13 @@ void main() { await pumpNowPlaying(tester); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('First lyric'), findsOneWidget); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Second lyric'), findsOneWidget); expect(find.text('First lyric'), findsNothing); @@ -2938,9 +3002,9 @@ void main() { size: const Size(390, 844), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final credit = find.text( 'Written by: Example Composer\nLyrics: Example Lyrics', ); @@ -2951,7 +3015,7 @@ void main() { ); metadataOverrides.clear(); mediaItems.add(item('second')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(credit, findsNothing); expect(find.text('Second lyric'), findsOneWidget); expect(tester.takeException(), isNull); @@ -2978,9 +3042,9 @@ void main() { ), ); mediaItems.add(item('anchored-supplements')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final original = find.text('Original 3'); final anchor = tester.getTopLeft(original).dy; for (final action in [ @@ -2990,7 +3054,7 @@ void main() { 'Show Translation', ]) { await tester.tap(find.byKey(const ValueKey('lyrics-language-options'))); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text(action)); await tester.pump(); for (var frame = 0; frame < 32; frame++) { @@ -3019,15 +3083,15 @@ void main() { size: const Size(390, 844), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final options = find.byKey(const ValueKey('lyrics-language-options')); if (supplement == 'none') { expect(options, findsNothing); } else { await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); final glass = tester.widget( find.byType(MornyeGlassPanel).last, ); @@ -3072,12 +3136,12 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final options = find.byKey(const ValueKey('lyrics-language-options')); await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.pump(const Duration(seconds: 6)); expect(find.text('Hide Pronunciation').hitTestable(), findsOneWidget); await tester.tap(find.text('Hide Pronunciation')); @@ -3087,7 +3151,7 @@ void main() { expect(options.hitTestable(), findsOneWidget); await tester.pump(const Duration(seconds: 5)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(options.hitTestable(), findsNothing); if (landscape) { await tester.tap( @@ -3097,9 +3161,9 @@ void main() { final bounds = tester.getRect(find.byType(ListView)); await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10)); } - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Show Pronunciation')); await tester.pump(); await tester.pump(); @@ -3127,7 +3191,7 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { @@ -3135,7 +3199,7 @@ void main() { find.byKey(const ValueKey('material-lyrics-toggle')), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); for (final seconds in [0, 2, 4, 7, 4]) { playback.add( PlaybackState( @@ -3144,7 +3208,7 @@ void main() { updatePosition: Duration(seconds: seconds), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( tester.widget(find.text('Together')).textAlign, TextAlign.left, @@ -3182,7 +3246,7 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { @@ -3190,7 +3254,7 @@ void main() { find.byKey(const ValueKey('material-lyrics-toggle')), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); final lead = tester.widget(find.text('Lead')); expect(find.text('Echo'), findsNothing); expect(find.textContaining('[bg:'), findsNothing); @@ -3211,7 +3275,7 @@ void main() { updatePosition: Duration(milliseconds: milliseconds), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); } double offset() => @@ -3291,13 +3355,13 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap( mornye ? find.byIcon(CupertinoIcons.quote_bubble) : find.byKey(const ValueKey('material-lyrics-toggle')), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); playback.add( PlaybackState( processingState: AudioProcessingState.ready, @@ -3305,7 +3369,7 @@ void main() { updatePosition: const Duration(seconds: 2), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); final scrollable = find .ancestor(of: find.text(lead), matching: find.byType(Scrollable)) .first; @@ -3345,7 +3409,7 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { @@ -3353,7 +3417,7 @@ void main() { find.byKey(const ValueKey('material-lyrics-toggle')), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); expect( tester.widget(find.text('Lead')).textAlign, TextAlign.left, @@ -3380,7 +3444,7 @@ void main() { updatePosition: const Duration(seconds: 3), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); for (final text in ['Lead', 'Guest']) { final paint = timed(text); expect( @@ -3432,7 +3496,7 @@ void main() { updatePosition: const Duration(seconds: 7), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(timed('Guest'), findsOneWidget); expect(find.text('Lead'), findsNothing); playback.add( @@ -3442,7 +3506,7 @@ void main() { updatePosition: const Duration(seconds: 9), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Lead'), findsOneWidget); expect(timed('Guest'), findsOneWidget); expect(tester.takeException(), isNull); @@ -3462,13 +3526,13 @@ void main() { size: const Size(390, 844), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); } - await tester.pumpAndSettle(); + await settlePlayer(tester); for (final text in ['Original text', 'Romanized text', 'English text']) { expect(find.text(text), findsOneWidget); } @@ -3508,7 +3572,7 @@ void main() { ), ); mediaItems.add(item(id)); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { @@ -3516,13 +3580,13 @@ void main() { find.byKey(const ValueKey('material-lyrics-toggle')), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); } final options = find.byKey(const ValueKey('lyrics-language-options')); Future choose(String label) async { await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text(label)); await tester.pump(); } @@ -3548,7 +3612,7 @@ void main() { expect(pronunciationOpacity(), inExclusiveRange(0, 1)); final intermediateGap = gap(); expect(intermediateGap, lessThan(expandedGap)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Romanized text'), findsNothing); expect(find.text('English text'), findsOneWidget); expect(gap(), lessThan(intermediateGap)); @@ -3556,14 +3620,14 @@ void main() { await choose('Show Pronunciation'); await tester.pump(const Duration(milliseconds: 100)); expect(pronunciationOpacity(), inExclusiveRange(0, 1)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(gap(), closeTo(expandedGap, 1)); await choose('Hide Translation'); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('English text'), findsNothing); expect(find.text('Romanized text'), findsOneWidget); await choose('Hide Pronunciation'); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Original text'), findsOneWidget); expect(find.text('Romanized text'), findsNothing); expect(options.hitTestable(), findsOneWidget); @@ -3573,7 +3637,7 @@ void main() { expect(find.text('English text'), findsNothing); expect(find.text('Romanized text'), findsNothing); await choose('Show Translation'); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('English text'), findsOneWidget); expect(find.text('Romanized text'), findsNothing); expect(tester.takeException(), isNull); @@ -3595,13 +3659,13 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap( mornye ? find.byIcon(CupertinoIcons.quote_bubble) : find.byKey(const ValueKey('material-lyrics-toggle')), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); Finder timed(String text) => find.descendant( of: find.byWidgetPredicate( @@ -3617,7 +3681,7 @@ void main() { updatePosition: Duration(milliseconds: milliseconds), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); final text = milliseconds < 7000 ? 'Held' : 'Next'; expect(timed(text), findsOneWidget); final opacity = tester.widget( @@ -3656,7 +3720,7 @@ void main() { playbackEvents: playback.stream, ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); if (mornye) { await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); } else { @@ -3664,7 +3728,7 @@ void main() { find.byKey(const ValueKey('material-lyrics-toggle')), ); } - await tester.pumpAndSettle(); + await settlePlayer(tester); Future> pixelsAt(int milliseconds, String text) async { playback.add( @@ -3674,7 +3738,7 @@ void main() { updatePosition: Duration(milliseconds: milliseconds), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); final pixels = []; for (final phrase in mornye ? text.split(' ') : [text]) { final paint = find.descendant( @@ -3737,21 +3801,21 @@ void main() { if (mornye && options.hitTestable().evaluate().isEmpty) { final bounds = tester.getRect(find.byType(ListView)); await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10)); - await tester.pumpAndSettle(); + await settlePlayer(tester); } await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Hide Pronunciation')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.bySemanticsLabel('Firsu secondu'), findsNothing); expect( await pixelsAt(1100, 'First second'), orderedEquals(originalPixels), ); await tester.tap(options); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Show Pronunciation')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await pixelsAt(2150, 'Firsu secondu'); expect(tester.takeException(), isNull); }, @@ -3783,9 +3847,9 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final paintFinder = find.descendant( of: find.bySemanticsLabel(text), matching: find.byType(CustomPaint), @@ -3849,7 +3913,7 @@ void main() { updatePosition: Duration(milliseconds: milliseconds), ), ); - await tester.pumpAndSettle(); + await settlePlayer(tester); return paintedHeights(); } @@ -3913,9 +3977,9 @@ void main() { ), ); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); - await tester.pumpAndSettle(); + await settlePlayer(tester); final paintFinder = find.descendant( of: find.bySemanticsLabel(lyricText), @@ -3961,9 +4025,9 @@ void main() { await pumpNowPlaying(tester); mediaItems.add(item('timed')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); - await tester.pumpAndSettle(); + await settlePlayer(tester); final lyric = find.bySemanticsLabel('Short'); expect(lyric, findsOneWidget); @@ -3975,10 +4039,10 @@ void main() { ) async { await pumpNowPlaying(tester); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(Icons.more_vert)); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('Go to Album'), findsOneWidget); expect(find.byIcon(Icons.album_outlined), findsOneWidget); @@ -3989,12 +4053,12 @@ void main() { ) async { await pumpNowPlaying(tester); mediaItems.add(item('first')); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.byIcon(Icons.more_vert)); - await tester.pumpAndSettle(); + await settlePlayer(tester); await tester.tap(find.text('Sleep timer')); - await tester.pumpAndSettle(); + await settlePlayer(tester); expect(find.text('15 minutes'), findsOneWidget); expect(find.text('30 minutes'), findsOneWidget); @@ -4004,6 +4068,43 @@ void main() { }); } +Map _lyricsDto(ParsedLyrics value) => { + 'synced': value.synced, + 'wordSynced': value.wordSynced, + 'plainText': value.plainText, + 'writers': value.writers, + 'provider': value.provider, + 'lines': [ + for (final line in value.lines) + { + 'timeMs': line.time.inMilliseconds, + 'endMs': line.end?.inMilliseconds, + 'text': line.text, + 'words': [for (final word in line.words) _wordDto(word)], + 'romanization': line.romanization, + 'romanizationWords': [ + for (final word in line.romanizationWords) _wordDto(word), + ], + 'translation': line.translation, + 'voice': line.voice == null + ? null + : { + 'id': line.voice!.id, + 'index': line.voice!.index, + 'isGroup': line.voice!.isGroup, + }, + 'isBackground': line.isBackground, + 'vocalGroup': line.vocalGroup, + }, + ], +}; + +Map _wordDto(LyricWord word) => { + 'timeMs': word.time.inMilliseconds, + 'endMs': word.end?.inMilliseconds, + 'text': word.text, +}; + class _QueueController extends MusicPlayerController { _QueueController(this.onSelected); final void Function(int) onSelected; diff --git a/test/player_metadata_controller_test.dart b/test/player_metadata_controller_test.dart index 8a3ed10d..28afd623 100644 --- a/test/player_metadata_controller_test.dart +++ b/test/player_metadata_controller_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; MediaItem _item( String id, { @@ -22,6 +23,98 @@ MediaItem _item( ); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'new metadata is visible immediately while lyrics parse is pending', + () async { + final parse = Completer(); + final controller = PlayerMetadataController( + readMetadata: (path) async => path.endsWith('a.flac') + ? {'lyrics': '[00:01]Old', 'format': 'MP3'} + : {'lyrics': '[00:01]New', 'format': 'FLAC', 'bit_depth': 24}, + parseLyrics: (raw) => raw == '[00:01]Old' + ? Future.value(LyricsParser.parse(raw)) + : parse.future, + ); + addTearDown(controller.dispose); + await controller.load(_item('a')); + expect(controller.lyrics.lines.single.text, 'Old'); + final pending = controller.load(_item('b', metadata: {'format': 'AAC'})); + expect(controller.lyrics.isEmpty, isTrue); + expect(controller.qualityLabel, 'AAC'); + await Future.delayed(Duration.zero); + expect(controller.qualityLabel, 'FLAC · 24-bit'); + expect(controller.lyrics.isEmpty, isTrue); + expect(controller.loading, isTrue); + parse.complete(LyricsParser.parse('[00:01]New')); + await pending; + expect(controller.lyrics.lines.single.text, 'New'); + expect(controller.loading, isFalse); + }, + ); + + test( + 'failed native lyrics retain new quality metadata and clear loading', + () async { + final controller = PlayerMetadataController( + readMetadata: (_) async => {'lyrics': '[00:01]Text', 'format': 'FLAC'}, + parseLyrics: (_) async => throw StateError('Invalid native lyrics'), + ); + addTearDown(controller.dispose); + await controller.load(_item('a')); + expect(controller.qualityLabel, 'FLAC'); + expect(controller.lyrics.isEmpty, isTrue); + expect(controller.loading, isFalse); + }, + ); + + test( + 'late native lyrics cannot publish after switching away and back', + () async { + final parses = >{}; + final controller = PlayerMetadataController( + readMetadata: (path) async => {'lyrics': path}, + parseLyrics: (raw) => (parses[raw!] = Completer()).future, + ); + addTearDown(controller.dispose); + final first = controller.load(_item('a')); + await Future.delayed(Duration.zero); + final oldParse = parses['/music/a.flac']!; + final second = controller.load(_item('b')); + await Future.delayed(Duration.zero); + final current = controller.load(_item('a')); + await Future.delayed(Duration.zero); + parses['/music/a.flac']!.complete(LyricsParser.parse('[00:01]Current A')); + await current; + oldParse.complete(LyricsParser.parse('[00:01]Old A')); + parses['/music/b.flac']!.complete(LyricsParser.parse('[00:01]Old B')); + await Future.wait([first, second]); + expect(controller.lyrics.lines.single.text, 'Current A'); + expect(controller.loading, isFalse); + }, + ); + + test( + 'disposal while native lyrics parse is pending never publishes', + () async { + final parse = Completer(); + final controller = PlayerMetadataController( + readMetadata: (_) async => {'lyrics': '[00:01]Late'}, + parseLyrics: (_) => parse.future, + ); + var notifications = 0; + controller.addListener(() => notifications++); + final pending = controller.load(_item('a')); + await Future.delayed(Duration.zero); + final beforeDispose = notifications; + controller.dispose(); + parse.complete(LyricsParser.parse('[00:01]Late')); + await pending; + expect(notifications, beforeDispose); + expect(controller.lyrics.isEmpty, isTrue); + }, + ); test('late result cannot overwrite a track switched away and back', () async { final reads = >>[]; final controller = PlayerMetadataController(