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.
This commit is contained in:
zarzet committed 2026-10-06 23:28:20 +07:00
1 parent fb21c5c390
commit 514e229fc8
14 files changed
+3372 -230

No files matched your search

+79 -10
View File
@@ -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<ParsedLyrics> Function(String?)? parseLyrics,
}) : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry,
_parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative;
final PlaybackMetadataReader _readMetadata;
final Future<ParsedLyrics> Function(String?) _parseLyrics;
(String, String, String?)? _requestKey;
String? _loadedPath;
Map<String, dynamic> _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<String, dynamic> value) {
Future<void>? _replaceMetadata(Map<String, dynamic> 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<void> _parseAndReplaceMetadata(
Map<String, dynamic> 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<String, dynamic> value,
ParsedLyrics parsed, {
String raw = '',
}) {
_metadata = value.isEmpty ? null : Map.unmodifiable(value);
_lyrics = parsed;
_lyricsRaw = raw;
_loading = _probePending;
notifyListeners();
}
+5 -2
View File
@@ -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<NowPlayingScreen> createState() => _NowPlayingScreenState();
@@ -72,7 +74,8 @@ class NowPlayingScreen extends ConsumerStatefulWidget {
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
ProviderSubscription<AsyncValue<MediaItem?>>? _mediaItemSub;
ProviderSubscription<bool>? _lyricsPlayingSub;
final _trackMetadata = PlayerMetadataController();
late final _trackMetadata =
widget.metadataControllerFactory?.call() ?? PlayerMetadataController();
int _currentPage = 0;
bool _landscape = false;
bool _queueSheetShowing = false;
+21
View File
@@ -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<String?> _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<String?> _createMetadataBlockPictureInBackground(
String imagePath,
) async {
try {
final file = File(imagePath);
if (!await file.exists()) {
+21
View File
@@ -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<bool> 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<bool> _writeUnsyncedLyricsToFile(
File file,
String lyrics,
) async {
final input = await file.open();
late final Uint8List updatedTag;
+77
View File
@@ -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<String, dynamic> result) {
LyricWord word(Map<dynamic, dynamic> 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<LyricWord> words(dynamic value) => (value as List)
.map((entry) => word(entry as Map))
.toList(growable: false);
final lines = <LyricLine>[];
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<ParsedLyrics> parseAsyncNative(String? raw) async {
if (raw == null || raw.isEmpty) return ParsedLyrics.empty;
Map<String, dynamic> 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*\$',
@@ -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}))
+2
View File
@@ -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;
@@ -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<Value, String> {
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<bool, String> {
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<usize> {
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<u8> {
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);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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-');
+126
View File
@@ -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<String, dynamic> _word(LyricWord value) => {
'timeMs': value.time.inMilliseconds,
'endMs': value.end?.inMilliseconds,
'text': value.text,
};
Map<String, dynamic> _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<Map<String, dynamic>>();
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<String, dynamic>;
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 =
'<tt xmlns="http://www.w3.org/ns/ttml"><body><p begin="1s" end="3s">'
'${List.filled(18, '<span>').join()}'
'<span begin="1s">First </span><span begin="2s">second</span>'
'${List.filled(18, '</span>').join()}</p></body></tt>';
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<PlatformException>()),
);
} finally {
messenger.setMockMethodCallHandler(channel, null);
}
});
}
File diff suppressed because it is too large. Load diff
+93
View File
@@ -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<ParsedLyrics>();
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<void>.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 = <String, Completer<ParsedLyrics>>{};
final controller = PlayerMetadataController(
readMetadata: (path) async => {'lyrics': path},
parseLyrics: (raw) => (parses[raw!] = Completer<ParsedLyrics>()).future,
);
addTearDown(controller.dispose);
final first = controller.load(_item('a'));
await Future<void>.delayed(Duration.zero);
final oldParse = parses['/music/a.flac']!;
final second = controller.load(_item('b'));
await Future<void>.delayed(Duration.zero);
final current = controller.load(_item('a'));
await Future<void>.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<ParsedLyrics>();
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<void>.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 = <Completer<Map<String, dynamic>>>[];
final controller = PlayerMetadataController(