diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 47e118d..16800b6 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -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 metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album}) async { + Future metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album, String? jahr, String? genre}) async { final d = await db; final update = {}; 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]); } diff --git a/lib/models/song.dart b/lib/models/song.dart index fe0eb84..ee8375d 100644 --- a/lib/models/song.dart +++ b/lib/models/song.dart @@ -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(); + } } diff --git a/lib/services/id3_reader.dart b/lib/services/id3_reader.dart index 42e8266..692adc8 100644 --- a/lib/services/id3_reader.dart +++ b/lib/services/id3_reader.dart @@ -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 lesen(String filepath) { final result = { '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 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 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 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 _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', + ]; } diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index ea7e751..f84f2bb 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -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 _sucheYtUrls() async { const maxSuchanfragen = 50; diff --git a/lib/widgets/metadaten_dialog.dart b/lib/widgets/metadaten_dialog.dart index 39659d4..48a6797 100644 --- a/lib/widgets/metadaten_dialog.dart +++ b/lib/widgets/metadaten_dialog.dart @@ -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 { 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 { _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 { _titelCtrl.dispose(); _kuenstlerCtrl.dispose(); _albumCtrl.dispose(); + _jahrCtrl.dispose(); + _genreCtrl.dispose(); super.dispose(); } @@ -37,47 +73,216 @@ class _MetadatenDialogState extends State { 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 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 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 _speichern() async { final id = widget.song.id; if (id == null) { @@ -87,6 +292,9 @@ class _MetadatenDialogState extends State { 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 { titel: titel, kuenstler: kuenstler, album: album, + jahr: jahr.isEmpty ? null : jahr, + genre: genre.isEmpty ? null : genre, ); if (mounted) Navigator.pop(context, true); }