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:
Dustin
2026-08-02 16:10:58 +02:00
parent ae11a85872
commit a483436490
5 changed files with 410 additions and 54 deletions
+230 -20
View File
@@ -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);
}