v2.42 — Issue #7: ID3-Reader erweitern + Metadaten-Dialog verschönern
## ID3-Reader (lib/services/id3_reader.dart) - ID3v1: Jahr (Bytes 93-97) + Genre-Index (Byte 127, vollständige Genre-Liste 0-147) - ID3v2: Text-Frames TIT2 (Titel), TPE1 (Künstler), TALB (Album), TYER (Jahr), TCON (Genre), TRCK (Track) - ID3v2-Frames haben Vorrang vor ID3v1 (Füll-Logik) - UTF-16/UTF-8 Encoding-Unterstützung für Text-Frames - Genre-Bereinigung: Klammer-Präfixe entfernt, Track-Nummer extrahiert ## Song-Model (lib/models/song.dart) - Neue Felder: jahr, genre, track (nullable, optional) - Neue getter: dateiFormat (MP3/M4A/FLAC/WAV/OGG aus Dateiendung) - DB-Migration v5→v6: jahr, genre, track Spalten ## Metadaten-Dialog (lib/widgets/metadaten_dialog.dart) - Cover-Bild (128×128) mit rotem Glow-Rahmen oben falls vorhanden - Editable Felder mit Icons: Titel, Künstler, Album, Jahr, Genre - Read-Only-Info-Card: Dauer, Dateigröße, Format - Cards mit roten Akzenten + Melo-Design (schwarz/rot/dunkelgrau) - ID3-Fallback: Jahr/Genre aus Datei nachladen wenn nicht in DB - Speichern persistiert auch Jahr und Genre in die DB ## Musik-Scanner (lib/services/musik_scanner.dart) - jahr, genre, track aus ID3-Tags an Song-Model durchgereicht - _nichtLeer-Helfer für null-sichere String-Extraktion
This commit is contained in:
+135
-31
@@ -2,12 +2,15 @@ import 'dart:io';
|
||||
|
||||
/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien
|
||||
class Id3Reader {
|
||||
/// Gibt Metadaten zurück: titel, kuenstler, album, coverBytes
|
||||
/// Gibt Metadaten zurück: titel, kuenstler, album, jahr, genre, track, coverBytes
|
||||
static Map<String, dynamic> lesen(String filepath) {
|
||||
final result = <String, dynamic>{
|
||||
'titel': '',
|
||||
'kuenstler': '',
|
||||
'album': '',
|
||||
'jahr': '',
|
||||
'genre': '',
|
||||
'track': '',
|
||||
'cover': null,
|
||||
};
|
||||
|
||||
@@ -16,66 +19,87 @@ class Id3Reader {
|
||||
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
|
||||
// ─── ID3v2 Text-Frames vor ID3v1 parsen (ID3v2 hat Vorrang) ───
|
||||
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
|
||||
if (frameSize <= 0 || pos + frameSize > bytes.length) break;
|
||||
|
||||
if (frameId == 'APIC') {
|
||||
// ── Attached Picture (Cover) ──
|
||||
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));
|
||||
while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) {
|
||||
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 += 1; // Picture Type
|
||||
// Description (null-terminated, encoding-aware)
|
||||
if (enc == 1 || enc == 2) {
|
||||
while (p + 1 < bytes.length && !(bytes[p] == 0 && bytes[p + 1] == 0)) {
|
||||
p += 2;
|
||||
}
|
||||
p += 2;
|
||||
} else {
|
||||
while (p < bytes.length && bytes[p] != 0) {
|
||||
p++;
|
||||
}
|
||||
p += 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;
|
||||
} else if (_isTextFrame(frameId)) {
|
||||
// ── Text-Frames (TIT2, TPE1, TALB, TYER, TCON, TRCK) ──
|
||||
final text = _decodeTextFrame(bytes, pos, frameSize);
|
||||
if (text.isNotEmpty) {
|
||||
switch (frameId) {
|
||||
case 'TIT2': if (result['titel'].isEmpty) result['titel'] = text;
|
||||
case 'TPE1': if (result['kuenstler'].isEmpty) result['kuenstler'] = text;
|
||||
case 'TALB': if (result['album'].isEmpty) result['album'] = text;
|
||||
case 'TYER': if (result['jahr'].isEmpty) result['jahr'] = text;
|
||||
case 'TCON': if (result['genre'].isEmpty) result['genre'] = _cleanGenre(text);
|
||||
case 'TRCK': if (result['track'].isEmpty) result['track'] = _cleanTrack(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
pos += frameSize;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ID3v1 (letzte 128 Bytes) — füllt Lücken die ID3v2 nicht abdeckte ───
|
||||
if (bytes.length > 128) {
|
||||
final tag = bytes.sublist(bytes.length - 128);
|
||||
if (String.fromCharCodes(tag.sublist(0, 3)) == 'TAG') {
|
||||
if (result['titel'].isEmpty) result['titel'] = _trimNull(tag.sublist(3, 33)).trim();
|
||||
if (result['kuenstler'].isEmpty) result['kuenstler'] = _trimNull(tag.sublist(33, 63)).trim();
|
||||
if (result['album'].isEmpty) result['album'] = _trimNull(tag.sublist(63, 93)).trim();
|
||||
if (result['jahr'].isEmpty) result['jahr'] = _trimNull(tag.sublist(93, 97)).trim();
|
||||
// Genre-Index (Byte 127) — 0..147 definiert, <148 gültig
|
||||
final genreByte = tag[127];
|
||||
if (result['genre'].isEmpty && genreByte >= 0 && genreByte < _id3v1Genres.length) {
|
||||
result['genre'] = _id3v1Genres[genreByte];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Fallback: Dateiname als Titel
|
||||
if (result['titel'].isEmpty) {
|
||||
result['titel'] = filepath.split('/').last.replaceAll('.mp3', '').replaceAll('.m4a', '');
|
||||
result['titel'] = filepath.split('/').last.replaceAll(RegExp(r'\.(mp3|m4a|flac|wav|ogg)$', caseSensitive: false), '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Hilfsmethoden ───────────────────────────────────────────────────
|
||||
|
||||
static String _trimNull(List<int> bytes) {
|
||||
final end = bytes.indexWhere((b) => b == 0);
|
||||
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
|
||||
@@ -88,4 +112,84 @@ class Id3Reader {
|
||||
static int _frameSize(List<int> bytes, int offset) {
|
||||
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
}
|
||||
|
||||
static bool _isTextFrame(String id) {
|
||||
return id == 'TIT2' || id == 'TPE1' || id == 'TALB' || id == 'TYER' || id == 'TCON' || id == 'TRCK';
|
||||
}
|
||||
|
||||
/// Decodiert den Inhalt eines ID3v2-Text-Frames (1 Byte Encoding + Text)
|
||||
static String _decodeTextFrame(List<int> bytes, int offset, int frameSize) {
|
||||
if (frameSize < 2) return '';
|
||||
final enc = bytes[offset];
|
||||
final raw = bytes.sublist(offset + 1, offset + frameSize);
|
||||
// BOM / Null-Terminierung entfernen
|
||||
try {
|
||||
switch (enc) {
|
||||
case 0: // ISO-8859-1
|
||||
final end = raw.indexWhere((b) => b == 0);
|
||||
final str = String.fromCharCodes(end < 0 ? raw : raw.sublist(0, end));
|
||||
return str;
|
||||
case 1: // UTF-16 mit BOM
|
||||
if (raw.length < 2) return '';
|
||||
final bom = (raw[0] << 8) | raw[1];
|
||||
final strUtf16 = (bom == 0xFEFF || bom == 0xFFFE)
|
||||
? String.fromCharCodes(raw.sublist(2))
|
||||
: String.fromCharCodes(raw);
|
||||
return strUtf16.replaceAll('\x00', '');
|
||||
case 2: // UTF-16BE
|
||||
return String.fromCharCodes(raw).replaceAll('\x00', '');
|
||||
case 3: // UTF-8
|
||||
final end3 = raw.indexWhere((b) => b == 0);
|
||||
return String.fromCharCodes(end3 < 0 ? raw : raw.sublist(0, end3));
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Entfernt Klammer-Präfixe aus Genre-Strings (z.B. "(4)Disco" → "Disco")
|
||||
static String _cleanGenre(String raw) {
|
||||
final cleaned = raw.replaceAll(RegExp(r'^\(\d+\)\s*'), '').trim();
|
||||
return cleaned.isEmpty ? raw.trim() : cleaned;
|
||||
}
|
||||
|
||||
/// Extrahiert die erste Nummer aus einem Track-String (z.B. "3/12" → "3", "03" → "3")
|
||||
static String _cleanTrack(String raw) {
|
||||
final m = RegExp(r'^(\d+)').firstMatch(raw.trim());
|
||||
return m != null ? int.parse(m.group(1)!).toString() : raw.trim();
|
||||
}
|
||||
|
||||
/// ID3v1 Genre-Liste (Index 0-147)
|
||||
static const List<String> _id3v1Genres = [
|
||||
'Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge',
|
||||
'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other', 'Pop', 'R&B',
|
||||
'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial', 'Alternative', 'Ska',
|
||||
'Death Metal', 'Pranks', 'Soundtrack', 'Euro-Techno', 'Ambient', 'Trip-Hop',
|
||||
'Vocal', 'Jazz+Funk', 'Fusion', 'Trance', 'Classical', 'Instrumental',
|
||||
'Acid', 'House', 'Game', 'Sound Clip', 'Gospel', 'Noise', 'Alternative Rock',
|
||||
'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental Pop',
|
||||
'Instrumental Rock', 'Ethnic', 'Gothic', 'Darkwave', 'Techno-Industrial',
|
||||
'Electronic', 'Pop-Folk', 'Eurodance', 'Dream', 'Southern Rock', 'Comedy',
|
||||
'Cult', 'Gangsta', 'Top 40', 'Christian Rap', 'Pop/Funk', 'Jungle',
|
||||
'Native American', 'Cabaret', 'New Wave', 'Psychedelic', 'Rave',
|
||||
'Showtunes', 'Trailer', 'Lo-Fi', 'Tribal', 'Acid Punk', 'Acid Jazz',
|
||||
'Polka', 'Retro', 'Musical', 'Rock & Roll', 'Hard Rock', 'Folk',
|
||||
'Folk/Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebop', 'Latin',
|
||||
'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock',
|
||||
'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock',
|
||||
'Big Band', 'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech',
|
||||
'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass',
|
||||
'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba',
|
||||
'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', 'Duet',
|
||||
'Punk Rock', 'Drum Solo', 'A Cappella', 'Euro-House', 'Dance Hall',
|
||||
'Goa', 'Drum & Bass', 'Club-House', 'Hardcore Techno', 'Terror', 'Indie',
|
||||
'BritPop', 'Negerpunk', 'Polsk Punk', 'Beat', 'Christian Gangsta Rap',
|
||||
'Heavy Metal', 'Black Metal', 'Crossover', 'Contemporary Christian',
|
||||
'Christian Rock', 'Merengue', 'Salsa', 'Thrash Metal', 'Anime', 'JPop',
|
||||
'Synthpop', 'Abstract', 'Art Rock', 'Baroque', 'Bhangra', 'Big Beat',
|
||||
'Breakbeat', 'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro',
|
||||
'Electroclash', 'Emo', 'Experimental', 'Garage', 'Global',
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user