This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/widgets/metadaten_dialog.dart
T
Dustin a483436490 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
2026-08-02 16:10:58 +02:00

316 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}