mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
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:
1 parent
fb21c5c390
commit
514e229fc8
14 files changed
+3372
-230
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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
+1317
File diff suppressed because it is too large.
Load diff
@@ -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-');
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user