CRIT: - Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats - Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr) HIGH: - CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel) - StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt) - CloudService als Singleton (konsistenter Token-Zustand in allen Services) MED/LOW: - Playlist-Erkennung nur noch via list= Parameter - Player: fehlende Quelle wird geloggt statt still - song_tile: null-ID-Guard vor Tag-Dialog - Scanner-Log mit Exception-Objekt - FavoritenService: anzahlFavoriten() fuer StatistikCard
92 lines
3.2 KiB
Dart
92 lines
3.2 KiB
Dart
import 'dart:io';
|
|
|
|
/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien
|
|
class Id3Reader {
|
|
/// Gibt Metadaten zurück: titel, kuenstler, album, coverBytes
|
|
static Map<String, dynamic> lesen(String filepath) {
|
|
final result = <String, dynamic>{
|
|
'titel': '',
|
|
'kuenstler': '',
|
|
'album': '',
|
|
'cover': null,
|
|
};
|
|
|
|
try {
|
|
final file = File(filepath);
|
|
if (!file.existsSync()) return result;
|
|
|
|
final bytes = file.readAsBytesSync();
|
|
// ID3v1 (letzte 128 Bytes)
|
|
if (bytes.length > 128) {
|
|
final tag = bytes.sublist(bytes.length - 128);
|
|
if (String.fromCharCodes(tag.sublist(0, 3)) == 'TAG') {
|
|
result['titel'] = _trimNull(tag.sublist(3, 33)).trim();
|
|
result['kuenstler'] = _trimNull(tag.sublist(33, 63)).trim();
|
|
result['album'] = _trimNull(tag.sublist(63, 93)).trim();
|
|
}
|
|
}
|
|
|
|
// ID3v2 Header (Anfang der Datei) für Cover
|
|
if (bytes.length > 10 && String.fromCharCodes(bytes.sublist(0, 3)) == 'ID3') {
|
|
final size = _synchSafeInt(bytes, 6);
|
|
var pos = 10;
|
|
// Frame-Header lesen
|
|
while (pos < size && pos + 10 < bytes.length) {
|
|
final frameId = String.fromCharCodes(bytes.sublist(pos, pos + 4));
|
|
final frameSize = _frameSize(bytes, pos + 4);
|
|
pos += 10;
|
|
if (frameId == 'APIC' && pos + frameSize <= bytes.length) {
|
|
// APIC = Attached Picture
|
|
var p = pos;
|
|
// Encoding (1 Byte) + MIME-Type
|
|
final enc = bytes[p]; p += 1;
|
|
var mimeEnd = p;
|
|
while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) mimeEnd++;
|
|
final mime = String.fromCharCodes(bytes.sublist(p, mimeEnd));
|
|
p = mimeEnd + 1;
|
|
// Picture Type (1 Byte)
|
|
p += 1;
|
|
// Description (null-terminated)
|
|
var descEnd = p;
|
|
while (descEnd < bytes.length) {
|
|
if (enc == 1 || enc == 2) {
|
|
if (descEnd + 1 < bytes.length && bytes[descEnd] == 0 && bytes[descEnd + 1] == 0) break;
|
|
descEnd += 2;
|
|
} else {
|
|
if (bytes[descEnd] == 0) break;
|
|
descEnd += 1;
|
|
}
|
|
}
|
|
p = descEnd + (enc == 1 || enc == 2 ? 2 : 1);
|
|
final remaining = pos + frameSize - p;
|
|
if (remaining > 0 && p + remaining <= bytes.length) {
|
|
result['cover'] = bytes.sublist(p, p + remaining);
|
|
}
|
|
break;
|
|
}
|
|
pos += frameSize;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Fallback: Dateiname als Titel
|
|
if (result['titel'].isEmpty) {
|
|
result['titel'] = filepath.split('/').last.replaceAll('.mp3', '').replaceAll('.m4a', '');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static String _trimNull(List<int> bytes) {
|
|
final end = bytes.indexWhere((b) => b == 0);
|
|
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
|
|
}
|
|
|
|
static int _synchSafeInt(List<int> bytes, int offset) {
|
|
return (bytes[offset] << 21) | (bytes[offset + 1] << 14) | (bytes[offset + 2] << 7) | bytes[offset + 3];
|
|
}
|
|
|
|
static int _frameSize(List<int> bytes, int offset) {
|
|
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
|
}
|
|
}
|