Files
SpotiFLAC-Mobile/lib/services/id3v23_lyrics.dart
T

167 lines
4.5 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
/// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding.
///
/// It intentionally supports only plain ID3v2.3 tags without extended,
/// compressed, or unsynchronized headers. Unsupported tags are left unchanged.
class Id3v23Lyrics {
const Id3v23Lyrics._();
static Uint8List? writeUnsyncedLyrics(Uint8List bytes, String lyrics) {
final lyricsFrame = _buildUnsyncedLyricsFrame(lyrics);
if (!_hasId3Header(bytes)) {
final builder = BytesBuilder(copy: false)
..add(_buildTag(lyricsFrame))
..add(bytes);
return builder.toBytes();
}
if (bytes.length < 10 || bytes[3] != 3) {
return null;
}
final flags = bytes[5];
const unsupportedFlags = 0x80 | 0x40 | 0x20;
if ((flags & unsupportedFlags) != 0) {
return null;
}
final tagSize = _readSynchsafeInt(bytes, 6);
if (tagSize == null) return null;
final tagEnd = 10 + tagSize;
if (tagEnd < 10 || tagEnd > bytes.length) {
return null;
}
final tagPayload = bytes.sublist(10, tagEnd);
final preservedFrames = _removeFrames(tagPayload, {'USLT'});
final newPayload = BytesBuilder(copy: false)
..add(preservedFrames)
..add(lyricsFrame);
final newTag = _buildTag(newPayload.toBytes());
final builder = BytesBuilder(copy: false)
..add(newTag)
..add(bytes.sublist(tagEnd));
return builder.toBytes();
}
static bool _hasId3Header(Uint8List bytes) {
return bytes.length >= 10 &&
bytes[0] == 0x49 &&
bytes[1] == 0x44 &&
bytes[2] == 0x33;
}
static Uint8List _removeFrames(Uint8List tagPayload, Set<String> frameIds) {
final builder = BytesBuilder(copy: false);
var offset = 0;
while (offset + 10 <= tagPayload.length) {
final idBytes = tagPayload.sublist(offset, offset + 4);
if (idBytes.every((byte) => byte == 0)) break;
final frameId = ascii.decode(idBytes, allowInvalid: true);
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
final frameSize = _readUint32(tagPayload, offset + 4);
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
break;
}
if (!frameIds.contains(frameId)) {
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
}
offset += 10 + frameSize;
}
return builder.toBytes();
}
static Uint8List _buildTag(Uint8List payload) {
final header = Uint8List(10)
..[0] = 0x49
..[1] = 0x44
..[2] = 0x33
..[3] = 3;
final size = _writeSynchsafeInt(payload.length);
header.setRange(6, 10, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _buildUnsyncedLyricsFrame(String lyrics) {
final payload = BytesBuilder(copy: false)
..add(const [0x01, 0x65, 0x6e, 0x67])
..add(const [0xff, 0xfe, 0x00, 0x00])
..add(_utf16LeWithBom(lyrics));
return _buildFrame('USLT', payload.toBytes());
}
static Uint8List _buildFrame(String frameId, Uint8List payload) {
final header = Uint8List(10);
header.setRange(0, 4, ascii.encode(frameId));
final size = _writeUint32(payload.length);
header.setRange(4, 8, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _utf16LeWithBom(String value) {
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
for (final codeUnit in value.codeUnits) {
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
}
return bytes.toBytes();
}
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
if (offset + 4 > bytes.length) return null;
final b0 = bytes[offset];
final b1 = bytes[offset + 1];
final b2 = bytes[offset + 2];
final b3 = bytes[offset + 3];
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
}
static Uint8List _writeSynchsafeInt(int value) {
return Uint8List.fromList([
(value >> 21) & 0x7f,
(value >> 14) & 0x7f,
(value >> 7) & 0x7f,
value & 0x7f,
]);
}
static int _readUint32(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
static Uint8List _writeUint32(int value) {
return Uint8List.fromList([
(value >> 24) & 0xff,
(value >> 16) & 0xff,
(value >> 8) & 0xff,
value & 0xff,
]);
}
}