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:
@@ -20,7 +20,7 @@ class DbHelper {
|
|||||||
final pfad = await getDatabasesPath();
|
final pfad = await getDatabasesPath();
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
p.join(pfad, 'melo.db'),
|
p.join(pfad, 'melo.db'),
|
||||||
version: 5,
|
version: 6,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE songs (
|
CREATE TABLE songs (
|
||||||
@@ -38,7 +38,10 @@ class DbHelper {
|
|||||||
download_quelle TEXT DEFAULT 'local',
|
download_quelle TEXT DEFAULT 'local',
|
||||||
stream_url TEXT,
|
stream_url TEXT,
|
||||||
yt_url TEXT,
|
yt_url TEXT,
|
||||||
zuletzt_position INTEGER
|
zuletzt_position INTEGER,
|
||||||
|
jahr TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
track TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
@@ -134,6 +137,11 @@ class DbHelper {
|
|||||||
''');
|
''');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
try { await db.execute('ALTER TABLE songs ADD COLUMN jahr TEXT'); } catch (_) {}
|
||||||
|
try { await db.execute('ALTER TABLE songs ADD COLUMN genre TEXT'); } catch (_) {}
|
||||||
|
try { await db.execute('ALTER TABLE songs ADD COLUMN track TEXT'); } catch (_) {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -361,12 +369,14 @@ class DbHelper {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album}) async {
|
Future<void> metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album, String? jahr, String? genre}) async {
|
||||||
final d = await db;
|
final d = await db;
|
||||||
final update = <String, dynamic>{};
|
final update = <String, dynamic>{};
|
||||||
if (titel != null) update['titel'] = titel;
|
if (titel != null) update['titel'] = titel;
|
||||||
if (kuenstler != null) update['kuenstler'] = kuenstler;
|
if (kuenstler != null) update['kuenstler'] = kuenstler;
|
||||||
if (album != null) update['album'] = album;
|
if (album != null) update['album'] = album;
|
||||||
|
if (jahr != null) update['jahr'] = jahr;
|
||||||
|
if (genre != null) update['genre'] = genre;
|
||||||
if (update.isNotEmpty) {
|
if (update.isNotEmpty) {
|
||||||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ class Song {
|
|||||||
final String titel;
|
final String titel;
|
||||||
final String kuenstler;
|
final String kuenstler;
|
||||||
final String album;
|
final String album;
|
||||||
|
final String? jahr;
|
||||||
|
final String? genre;
|
||||||
|
final String? track;
|
||||||
final int dauerSekunden;
|
final int dauerSekunden;
|
||||||
final String dateiPfad;
|
final String dateiPfad;
|
||||||
final String? coverPfad;
|
final String? coverPfad;
|
||||||
@@ -21,6 +24,9 @@ class Song {
|
|||||||
required this.titel,
|
required this.titel,
|
||||||
required this.kuenstler,
|
required this.kuenstler,
|
||||||
this.album = '',
|
this.album = '',
|
||||||
|
this.jahr,
|
||||||
|
this.genre,
|
||||||
|
this.track,
|
||||||
required this.dauerSekunden,
|
required this.dauerSekunden,
|
||||||
required this.dateiPfad,
|
required this.dateiPfad,
|
||||||
this.coverPfad,
|
this.coverPfad,
|
||||||
@@ -39,6 +45,9 @@ class Song {
|
|||||||
'titel': titel,
|
'titel': titel,
|
||||||
'kuenstler': kuenstler,
|
'kuenstler': kuenstler,
|
||||||
'album': album,
|
'album': album,
|
||||||
|
'jahr': jahr,
|
||||||
|
'genre': genre,
|
||||||
|
'track': track,
|
||||||
'dauer_sekunden': dauerSekunden,
|
'dauer_sekunden': dauerSekunden,
|
||||||
'datei_pfad': dateiPfad,
|
'datei_pfad': dateiPfad,
|
||||||
'cover_pfad': coverPfad,
|
'cover_pfad': coverPfad,
|
||||||
@@ -57,6 +66,9 @@ class Song {
|
|||||||
titel: m['titel'] as String,
|
titel: m['titel'] as String,
|
||||||
kuenstler: m['kuenstler'] as String,
|
kuenstler: m['kuenstler'] as String,
|
||||||
album: m['album'] as String? ?? '',
|
album: m['album'] as String? ?? '',
|
||||||
|
jahr: m['jahr'] as String?,
|
||||||
|
genre: m['genre'] as String?,
|
||||||
|
track: m['track'] as String?,
|
||||||
dauerSekunden: m['dauer_sekunden'] as int,
|
dauerSekunden: m['dauer_sekunden'] as int,
|
||||||
dateiPfad: m['datei_pfad'] as String,
|
dateiPfad: m['datei_pfad'] as String,
|
||||||
coverPfad: m['cover_pfad'] as String?,
|
coverPfad: m['cover_pfad'] as String?,
|
||||||
@@ -80,4 +92,15 @@ class Song {
|
|||||||
if (groesseBytes < 1048576) return '${(groesseBytes / 1024).toStringAsFixed(0)} KB';
|
if (groesseBytes < 1048576) return '${(groesseBytes / 1024).toStringAsFixed(0)} KB';
|
||||||
return '${(groesseBytes / 1048576).toStringAsFixed(1)} MB';
|
return '${(groesseBytes / 1048576).toStringAsFixed(1)} MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dateiformat aus Dateiendung ableiten
|
||||||
|
String get dateiFormat {
|
||||||
|
final lower = dateiPfad.toLowerCase();
|
||||||
|
if (lower.endsWith('.mp3')) return 'MP3';
|
||||||
|
if (lower.endsWith('.m4a')) return 'M4A (AAC)';
|
||||||
|
if (lower.endsWith('.flac')) return 'FLAC';
|
||||||
|
if (lower.endsWith('.wav')) return 'WAV';
|
||||||
|
if (lower.endsWith('.ogg')) return 'OGG';
|
||||||
|
return lower.split('.').last.toUpperCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-29
@@ -2,12 +2,15 @@ import 'dart:io';
|
|||||||
|
|
||||||
/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien
|
/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien
|
||||||
class Id3Reader {
|
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) {
|
static Map<String, dynamic> lesen(String filepath) {
|
||||||
final result = <String, dynamic>{
|
final result = <String, dynamic>{
|
||||||
'titel': '',
|
'titel': '',
|
||||||
'kuenstler': '',
|
'kuenstler': '',
|
||||||
'album': '',
|
'album': '',
|
||||||
|
'jahr': '',
|
||||||
|
'genre': '',
|
||||||
|
'track': '',
|
||||||
'cover': null,
|
'cover': null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,66 +19,87 @@ class Id3Reader {
|
|||||||
if (!file.existsSync()) return result;
|
if (!file.existsSync()) return result;
|
||||||
|
|
||||||
final bytes = file.readAsBytesSync();
|
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') {
|
if (bytes.length > 10 && String.fromCharCodes(bytes.sublist(0, 3)) == 'ID3') {
|
||||||
final size = _synchSafeInt(bytes, 6);
|
final size = _synchSafeInt(bytes, 6);
|
||||||
var pos = 10;
|
var pos = 10;
|
||||||
// Frame-Header lesen
|
|
||||||
while (pos < size && pos + 10 < bytes.length) {
|
while (pos < size && pos + 10 < bytes.length) {
|
||||||
final frameId = String.fromCharCodes(bytes.sublist(pos, pos + 4));
|
final frameId = String.fromCharCodes(bytes.sublist(pos, pos + 4));
|
||||||
final frameSize = _frameSize(bytes, pos + 4);
|
final frameSize = _frameSize(bytes, pos + 4);
|
||||||
pos += 10;
|
pos += 10;
|
||||||
if (frameId == 'APIC' && pos + frameSize <= bytes.length) {
|
if (frameSize <= 0 || pos + frameSize > bytes.length) break;
|
||||||
// APIC = Attached Picture
|
|
||||||
|
if (frameId == 'APIC') {
|
||||||
|
// ── Attached Picture (Cover) ──
|
||||||
var p = pos;
|
var p = pos;
|
||||||
// Encoding (1 Byte) + MIME-Type
|
|
||||||
final enc = bytes[p]; p += 1;
|
final enc = bytes[p]; p += 1;
|
||||||
var mimeEnd = p;
|
var mimeEnd = p;
|
||||||
while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) mimeEnd++;
|
while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) {
|
||||||
final mime = String.fromCharCodes(bytes.sublist(p, mimeEnd));
|
mimeEnd++;
|
||||||
|
}
|
||||||
p = mimeEnd + 1;
|
p = mimeEnd + 1;
|
||||||
// Picture Type (1 Byte)
|
p += 1; // Picture Type
|
||||||
p += 1;
|
// Description (null-terminated, encoding-aware)
|
||||||
// Description (null-terminated)
|
|
||||||
var descEnd = p;
|
|
||||||
while (descEnd < bytes.length) {
|
|
||||||
if (enc == 1 || enc == 2) {
|
if (enc == 1 || enc == 2) {
|
||||||
if (descEnd + 1 < bytes.length && bytes[descEnd] == 0 && bytes[descEnd + 1] == 0) break;
|
while (p + 1 < bytes.length && !(bytes[p] == 0 && bytes[p + 1] == 0)) {
|
||||||
descEnd += 2;
|
p += 2;
|
||||||
|
}
|
||||||
|
p += 2;
|
||||||
} else {
|
} else {
|
||||||
if (bytes[descEnd] == 0) break;
|
while (p < bytes.length && bytes[p] != 0) {
|
||||||
descEnd += 1;
|
p++;
|
||||||
}
|
}
|
||||||
|
p += 1;
|
||||||
}
|
}
|
||||||
p = descEnd + (enc == 1 || enc == 2 ? 2 : 1);
|
|
||||||
final remaining = pos + frameSize - p;
|
final remaining = pos + frameSize - p;
|
||||||
if (remaining > 0 && p + remaining <= bytes.length) {
|
if (remaining > 0 && p + remaining <= bytes.length) {
|
||||||
result['cover'] = bytes.sublist(p, p + remaining);
|
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;
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Fallback: Dateiname als Titel
|
// Fallback: Dateiname als Titel
|
||||||
if (result['titel'].isEmpty) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Hilfsmethoden ───────────────────────────────────────────────────
|
||||||
|
|
||||||
static String _trimNull(List<int> bytes) {
|
static String _trimNull(List<int> bytes) {
|
||||||
final end = bytes.indexWhere((b) => b == 0);
|
final end = bytes.indexWhere((b) => b == 0);
|
||||||
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
|
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
|
||||||
@@ -88,4 +112,84 @@ class Id3Reader {
|
|||||||
static int _frameSize(List<int> bytes, int offset) {
|
static int _frameSize(List<int> bytes, int offset) {
|
||||||
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
|
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',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class MusikScanner {
|
|||||||
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
|
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
|
||||||
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
|
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
|
||||||
album: tags['album'] ?? '',
|
album: tags['album'] ?? '',
|
||||||
|
jahr: _nichtLeer(tags['jahr']),
|
||||||
|
genre: _nichtLeer(tags['genre']),
|
||||||
|
track: _nichtLeer(tags['track']),
|
||||||
dauerSekunden: await _ermittleDauer(player, pfad),
|
dauerSekunden: await _ermittleDauer(player, pfad),
|
||||||
dateiPfad: pfad,
|
dateiPfad: pfad,
|
||||||
coverPfad: coverPfad,
|
coverPfad: coverPfad,
|
||||||
@@ -207,6 +210,12 @@ class MusikScanner {
|
|||||||
return dot > 0 ? name.substring(0, dot) : name;
|
return dot > 0 ? name.substring(0, dot) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _nichtLeer(dynamic wert) {
|
||||||
|
if (wert == null) return null;
|
||||||
|
final s = wert.toString().trim();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
/// Sucht YouTube-Quell-URLs für Songs ohne ytUrl (max 50 pro Scan, 1/s Rate-Limit)
|
/// Sucht YouTube-Quell-URLs für Songs ohne ytUrl (max 50 pro Scan, 1/s Rate-Limit)
|
||||||
Future<void> _sucheYtUrls() async {
|
Future<void> _sucheYtUrls() async {
|
||||||
const maxSuchanfragen = 50;
|
const maxSuchanfragen = 50;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
|
import '../services/id3_reader.dart';
|
||||||
import '../utils/farb_theme.dart';
|
import '../utils/farb_theme.dart';
|
||||||
|
|
||||||
class MetadatenDialog extends StatefulWidget {
|
class MetadatenDialog extends StatefulWidget {
|
||||||
@@ -15,7 +17,10 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
late TextEditingController _titelCtrl;
|
late TextEditingController _titelCtrl;
|
||||||
late TextEditingController _kuenstlerCtrl;
|
late TextEditingController _kuenstlerCtrl;
|
||||||
late TextEditingController _albumCtrl;
|
late TextEditingController _albumCtrl;
|
||||||
|
late TextEditingController _jahrCtrl;
|
||||||
|
late TextEditingController _genreCtrl;
|
||||||
final DbHelper _db = DbHelper();
|
final DbHelper _db = DbHelper();
|
||||||
|
bool _id3Geladen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -23,6 +28,35 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
_titelCtrl = TextEditingController(text: widget.song.titel);
|
_titelCtrl = TextEditingController(text: widget.song.titel);
|
||||||
_kuenstlerCtrl = TextEditingController(text: widget.song.kuenstler);
|
_kuenstlerCtrl = TextEditingController(text: widget.song.kuenstler);
|
||||||
_albumCtrl = TextEditingController(text: widget.song.album);
|
_albumCtrl = TextEditingController(text: widget.song.album);
|
||||||
|
_jahrCtrl = TextEditingController(text: widget.song.jahr ?? '');
|
||||||
|
_genreCtrl = TextEditingController(text: widget.song.genre ?? '');
|
||||||
|
|
||||||
|
// Falls Song kein Jahr/Genre hat → aus ID3-Tags nachladen
|
||||||
|
if ((widget.song.jahr == null || widget.song.jahr!.isEmpty) ||
|
||||||
|
(widget.song.genre == null || widget.song.genre!.isEmpty)) {
|
||||||
|
_id3Nachladen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _id3Nachladen() {
|
||||||
|
try {
|
||||||
|
final tags = Id3Reader.lesen(widget.song.dateiPfad);
|
||||||
|
if (!_id3Geladen) {
|
||||||
|
final jahr = tags['jahr'] as String?;
|
||||||
|
final genre = tags['genre'] as String?;
|
||||||
|
if ((jahr != null && jahr.isNotEmpty) || (genre != null && genre.isNotEmpty)) {
|
||||||
|
setState(() {
|
||||||
|
if (jahr != null && jahr.isNotEmpty && _jahrCtrl.text.isEmpty) {
|
||||||
|
_jahrCtrl.text = jahr;
|
||||||
|
}
|
||||||
|
if (genre != null && genre.isNotEmpty && _genreCtrl.text.isEmpty) {
|
||||||
|
_genreCtrl.text = genre;
|
||||||
|
}
|
||||||
|
_id3Geladen = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,6 +64,8 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
_titelCtrl.dispose();
|
_titelCtrl.dispose();
|
||||||
_kuenstlerCtrl.dispose();
|
_kuenstlerCtrl.dispose();
|
||||||
_albumCtrl.dispose();
|
_albumCtrl.dispose();
|
||||||
|
_jahrCtrl.dispose();
|
||||||
|
_genreCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,47 +73,216 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: MeloTheme.dunkel1,
|
backgroundColor: MeloTheme.dunkel1,
|
||||||
title: const Text('✏️ Metadaten', style: TextStyle(color: Colors.white, fontSize: 18)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.edit_note, color: MeloTheme.rot, size: 24),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Metadaten', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_field('Titel', _titelCtrl),
|
// ── Cover-Bild (128×128) ──
|
||||||
|
if (widget.song.coverPfad != null && widget.song.coverPfad!.isNotEmpty)
|
||||||
|
_coverWidget(),
|
||||||
|
|
||||||
|
// ── Editierbare Felder ──
|
||||||
|
_sectionLabel('Bearbeiten'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_editCard(
|
||||||
|
children: [
|
||||||
|
_field('Titel', _titelCtrl, Icons.music_note),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_field('Künstler', _kuenstlerCtrl),
|
_field('Künstler', _kuenstlerCtrl, Icons.person),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_field('Album', _albumCtrl),
|
_field('Album', _albumCtrl, Icons.album),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field('Jahr', _jahrCtrl, Icons.calendar_today),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field('Genre', _genreCtrl, Icons.category),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── Read-Only Infos ──
|
||||||
|
_sectionLabel('Informationen'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_infoCard(
|
||||||
|
children: [
|
||||||
|
_infoZeile('Dauer', widget.song.dauerFormatiert, Icons.timer),
|
||||||
|
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||||||
|
_infoZeile('Dateigröße', widget.song.groesseFormatiert, Icons.storage),
|
||||||
|
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||||||
|
_infoZeile('Format', widget.song.dateiFormat, Icons.audio_file),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
OutlinedButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Abbrechen', style: TextStyle(color: Colors.white54)),
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white54,
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
),
|
),
|
||||||
TextButton(
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
onPressed: () => _speichern(),
|
onPressed: () => _speichern(),
|
||||||
child: const Text('Speichern', style: TextStyle(color: MeloTheme.rot)),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: MeloTheme.rot,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
child: const Text('Speichern'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _field(String label, TextEditingController ctrl) {
|
// ── Cover-Bild ────────────────────────────────────────────────────────
|
||||||
return TextField(
|
|
||||||
controller: ctrl,
|
Widget _coverWidget() {
|
||||||
style: const TextStyle(color: Colors.white),
|
return Padding(
|
||||||
decoration: InputDecoration(
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
labelText: label,
|
child: Center(
|
||||||
labelStyle: const TextStyle(color: Colors.grey),
|
child: Container(
|
||||||
border: const OutlineInputBorder(),
|
width: 128,
|
||||||
focusedBorder: const OutlineInputBorder(
|
height: 128,
|
||||||
borderSide: BorderSide(color: MeloTheme.rot),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: MeloTheme.rot, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: MeloTheme.rot.withValues(alpha: 0.25), blurRadius: 12, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Image.file(
|
||||||
|
File(widget.song.coverPfad!),
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
color: MeloTheme.dunkel2,
|
||||||
|
child: const Icon(Icons.broken_image, color: Colors.white38, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Section Label ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _sectionLabel(String text) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MeloTheme.rot,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _editCard({required List<Widget> children}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MeloTheme.dunkel2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Info Card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _infoCard({required List<Widget> children}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MeloTheme.schwarz,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eingabefeld ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl, IconData icon) {
|
||||||
|
return TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: Icon(icon, color: MeloTheme.rot, size: 18),
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Colors.grey),
|
||||||
|
hintStyle: TextStyle(color: Colors.grey.withValues(alpha: 0.4)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: MeloTheme.dunkel1,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: const BorderSide(color: Colors.white12),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: const BorderSide(color: Colors.white12),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: const BorderSide(color: MeloTheme.rot, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read-Only Info-Zeile ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _infoZeile(String label, String wert, IconData icon) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: MeloTheme.rot, size: 18),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
wert,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Speichern ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _speichern() async {
|
Future<void> _speichern() async {
|
||||||
final id = widget.song.id;
|
final id = widget.song.id;
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
@@ -87,6 +292,9 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
final titel = _titelCtrl.text.trim();
|
final titel = _titelCtrl.text.trim();
|
||||||
final kuenstler = _kuenstlerCtrl.text.trim();
|
final kuenstler = _kuenstlerCtrl.text.trim();
|
||||||
final album = _albumCtrl.text.trim();
|
final album = _albumCtrl.text.trim();
|
||||||
|
final jahr = _jahrCtrl.text.trim();
|
||||||
|
final genre = _genreCtrl.text.trim();
|
||||||
|
|
||||||
if (titel.isEmpty) {
|
if (titel.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Titel darf nicht leer sein')),
|
const SnackBar(content: Text('Titel darf nicht leer sein')),
|
||||||
@@ -99,6 +307,8 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
|||||||
titel: titel,
|
titel: titel,
|
||||||
kuenstler: kuenstler,
|
kuenstler: kuenstler,
|
||||||
album: album,
|
album: album,
|
||||||
|
jahr: jahr.isEmpty ? null : jahr,
|
||||||
|
genre: genre.isEmpty ? null : genre,
|
||||||
);
|
);
|
||||||
if (mounted) Navigator.pop(context, true);
|
if (mounted) Navigator.pop(context, true);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user