## 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
316 lines
11 KiB
Dart
316 lines
11 KiB
Dart
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 {
|
||
final Song song;
|
||
const MetadatenDialog({super.key, required this.song});
|
||
|
||
@override
|
||
State<MetadatenDialog> createState() => _MetadatenDialogState();
|
||
}
|
||
|
||
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() {
|
||
super.initState();
|
||
_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
|
||
void dispose() {
|
||
_titelCtrl.dispose();
|
||
_kuenstlerCtrl.dispose();
|
||
_albumCtrl.dispose();
|
||
_jahrCtrl.dispose();
|
||
_genreCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
backgroundColor: MeloTheme.dunkel1,
|
||
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: [
|
||
// ── 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: [
|
||
OutlinedButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: Colors.white54,
|
||
side: const BorderSide(color: Colors.white24),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||
),
|
||
child: const Text('Abbrechen'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
ElevatedButton(
|
||
onPressed: () => _speichern(),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: MeloTheme.rot,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||
),
|
||
child: const Text('Speichern'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── 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) {
|
||
if (mounted) Navigator.pop(context, false);
|
||
return;
|
||
}
|
||
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')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
await _db.metadatenAktualisieren(
|
||
id,
|
||
titel: titel,
|
||
kuenstler: kuenstler,
|
||
album: album,
|
||
jahr: jahr.isEmpty ? null : jahr,
|
||
genre: genre.isEmpty ? null : genre,
|
||
);
|
||
if (mounted) Navigator.pop(context, true);
|
||
}
|
||
}
|