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();
|
||||
return openDatabase(
|
||||
p.join(pfad, 'melo.db'),
|
||||
version: 5,
|
||||
version: 6,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE songs (
|
||||
@@ -38,7 +38,10 @@ class DbHelper {
|
||||
download_quelle TEXT DEFAULT 'local',
|
||||
stream_url TEXT,
|
||||
yt_url TEXT,
|
||||
zuletzt_position INTEGER
|
||||
zuletzt_position INTEGER,
|
||||
jahr TEXT,
|
||||
genre TEXT,
|
||||
track TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute('''
|
||||
@@ -134,6 +137,11 @@ class DbHelper {
|
||||
''');
|
||||
} 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 update = <String, dynamic>{};
|
||||
if (titel != null) update['titel'] = titel;
|
||||
if (kuenstler != null) update['kuenstler'] = kuenstler;
|
||||
if (album != null) update['album'] = album;
|
||||
if (jahr != null) update['jahr'] = jahr;
|
||||
if (genre != null) update['genre'] = genre;
|
||||
if (update.isNotEmpty) {
|
||||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ class Song {
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final String album;
|
||||
final String? jahr;
|
||||
final String? genre;
|
||||
final String? track;
|
||||
final int dauerSekunden;
|
||||
final String dateiPfad;
|
||||
final String? coverPfad;
|
||||
@@ -21,6 +24,9 @@ class Song {
|
||||
required this.titel,
|
||||
required this.kuenstler,
|
||||
this.album = '',
|
||||
this.jahr,
|
||||
this.genre,
|
||||
this.track,
|
||||
required this.dauerSekunden,
|
||||
required this.dateiPfad,
|
||||
this.coverPfad,
|
||||
@@ -39,6 +45,9 @@ class Song {
|
||||
'titel': titel,
|
||||
'kuenstler': kuenstler,
|
||||
'album': album,
|
||||
'jahr': jahr,
|
||||
'genre': genre,
|
||||
'track': track,
|
||||
'dauer_sekunden': dauerSekunden,
|
||||
'datei_pfad': dateiPfad,
|
||||
'cover_pfad': coverPfad,
|
||||
@@ -57,6 +66,9 @@ class Song {
|
||||
titel: m['titel'] as String,
|
||||
kuenstler: m['kuenstler'] 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,
|
||||
dateiPfad: m['datei_pfad'] as String,
|
||||
coverPfad: m['cover_pfad'] as String?,
|
||||
@@ -80,4 +92,15 @@ class Song {
|
||||
if (groesseBytes < 1048576) return '${(groesseBytes / 1024).toStringAsFixed(0)} KB';
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ class MusikScanner {
|
||||
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
|
||||
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
|
||||
album: tags['album'] ?? '',
|
||||
jahr: _nichtLeer(tags['jahr']),
|
||||
genre: _nichtLeer(tags['genre']),
|
||||
track: _nichtLeer(tags['track']),
|
||||
dauerSekunden: await _ermittleDauer(player, pfad),
|
||||
dateiPfad: pfad,
|
||||
coverPfad: coverPfad,
|
||||
@@ -207,6 +210,12 @@ class MusikScanner {
|
||||
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)
|
||||
Future<void> _sucheYtUrls() async {
|
||||
const maxSuchanfragen = 50;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
import '../services/id3_reader.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MetadatenDialog extends StatefulWidget {
|
||||
@@ -15,7 +17,10 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
late TextEditingController _titelCtrl;
|
||||
late TextEditingController _kuenstlerCtrl;
|
||||
late TextEditingController _albumCtrl;
|
||||
late TextEditingController _jahrCtrl;
|
||||
late TextEditingController _genreCtrl;
|
||||
final DbHelper _db = DbHelper();
|
||||
bool _id3Geladen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -23,6 +28,35 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
_titelCtrl = TextEditingController(text: widget.song.titel);
|
||||
_kuenstlerCtrl = TextEditingController(text: widget.song.kuenstler);
|
||||
_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
|
||||
@@ -30,6 +64,8 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
_titelCtrl.dispose();
|
||||
_kuenstlerCtrl.dispose();
|
||||
_albumCtrl.dispose();
|
||||
_jahrCtrl.dispose();
|
||||
_genreCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -37,47 +73,216 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
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(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_field('Titel', _titelCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Künstler', _kuenstlerCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Album', _albumCtrl),
|
||||
// ── 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),
|
||||
_field('Künstler', _kuenstlerCtrl, Icons.person),
|
||||
const SizedBox(height: 12),
|
||||
_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: [
|
||||
TextButton(
|
||||
OutlinedButton(
|
||||
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)),
|
||||
),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
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) {
|
||||
return TextField(
|
||||
controller: ctrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: Colors.grey),
|
||||
border: const OutlineInputBorder(),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: MeloTheme.rot),
|
||||
// ── Cover-Bild ────────────────────────────────────────────────────────
|
||||
|
||||
Widget _coverWidget() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
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 {
|
||||
final id = widget.song.id;
|
||||
if (id == null) {
|
||||
@@ -87,6 +292,9 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
final titel = _titelCtrl.text.trim();
|
||||
final kuenstler = _kuenstlerCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
final jahr = _jahrCtrl.text.trim();
|
||||
final genre = _genreCtrl.text.trim();
|
||||
|
||||
if (titel.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Titel darf nicht leer sein')),
|
||||
@@ -99,6 +307,8 @@ class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
titel: titel,
|
||||
kuenstler: kuenstler,
|
||||
album: album,
|
||||
jahr: jahr.isEmpty ? null : jahr,
|
||||
genre: genre.isEmpty ? null : genre,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user