import 'dart:io'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/musicbrainz_service.dart'; import '../settings/app_settings.dart'; import '../shared/cover.dart'; import '../shared/theme.dart'; import 'category_service.dart'; import 'database.dart'; /// Metadaten eines Songs: Cover, Titel, Künstler und Kategorien (bearbeitbar), /// darunter der ausklappbare Expertenmodus mit allen weiteren Angaben. class SongDetailSheet extends StatelessWidget { const SongDetailSheet({super.key, required this.song}); final Song song; static Future show(BuildContext context, Song song) { return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: MeloTheme.surface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => SongDetailSheet(song: song), ); } @override Widget build(BuildContext context) { final categories = context.watch(); final settings = context.watch(); final names = categories.of(song.id); final cover = categories.coverFor(song, groupByCategory: settings.groupCoversByCategory); return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ CoverImage( artUri: cover != null ? Uri.file(cover) : null, size: 88, radius: 10, ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(song.title, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700)), const SizedBox(height: 4), Text(song.artist ?? 'Unbekannter Künstler', style: const TextStyle(color: Colors.white70)), if (song.album != null) ...[ const SizedBox(height: 2), Text(song.album!, style: const TextStyle( color: Colors.white38, fontSize: 12)), ], ], ), ), ], ), const SizedBox(height: 20), Row( children: [ const Text('Kategorien', style: TextStyle(fontWeight: FontWeight.w600)), const Spacer(), TextButton.icon( icon: const Icon(Icons.edit, size: 16), label: const Text('Bearbeiten'), onPressed: () => _editCategories(context, names), ), ], ), if (names.isEmpty) const Text('Keine Kategorie', style: TextStyle(color: Colors.white38, fontSize: 13)) else Wrap( spacing: 8, runSpacing: 4, children: [ for (var i = 0; i < names.length; i++) Chip( label: Text(names[i]), backgroundColor: MeloTheme.surfaceHigh, side: BorderSide.none, // Die erste Kategorie liefert das Coverbild. avatar: i == 0 ? const Icon(Icons.image, size: 16, color: MeloTheme.red) : null, ), ], ), const SizedBox(height: 12), _ExpertSection(song: song), ], ), ), ); } Future _editCategories(BuildContext context, List current) async { final service = context.read(); final suggestions = await service.allNames(); if (!context.mounted) return; final result = await showDialog>( context: context, builder: (_) => _CategoryEditor(current: current, suggestions: suggestions), ); if (result == null) return; await service.setCategories(song.id, result); } } /// Ausklappbarer Bereich mit allen weiteren Metadaten. class _ExpertSection extends StatelessWidget { const _ExpertSection({required this.song}); final Song song; @override Widget build(BuildContext context) { return Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( tilePadding: EdgeInsets.zero, childrenPadding: const EdgeInsets.only(bottom: 8), title: const Text('Expertenmodus', style: TextStyle(fontWeight: FontWeight.w600)), subtitle: const Text('Weitere Metadaten', style: TextStyle(color: Colors.white38, fontSize: 12)), children: [ _Row('Titel', song.title), _Row('Künstler', song.artist ?? '—'), _Row('Album', song.album ?? '—'), _Row('Dauer', _formatDuration(song.durationMs)), _Row('Wiedergaben', '${song.playCount}'), _Row('Hinzugefügt', _formatDate(song.dateAddedMs)), _Row('Kategorien', song.categoriesEdited ? 'von Hand gesetzt (Scan überschreibt nicht)' : 'aus dem Genre-Tag der Datei'), _Row('Format', _extension(song.path)), _Row('Dateigröße', _fileSize(song.path)), _Row('Pfad', song.path), _OnlineLookup(song: song), ], ), ); } } /// Holt zum Song passende Metadaten von MusicBrainz. Ein Tipp auf einen /// Vorschlag übernimmt Titel, Künstler und Album — gespeichert wird über /// [MeloDb.upsertSongs], denselben Weg, den auch der Scan nimmt. class _OnlineLookup extends StatefulWidget { const _OnlineLookup({required this.song}); final Song song; @override State<_OnlineLookup> createState() => _OnlineLookupState(); } class _OnlineLookupState extends State<_OnlineLookup> { final _dienst = MusicBrainzService(); bool _laeuft = false; List? _vorschlaege; Future _nachschlagen() async { setState(() => _laeuft = true); try { final gefunden = await _dienst.suche( titel: widget.song.title, kuenstler: widget.song.artist, ); if (!mounted) return; setState(() { _vorschlaege = gefunden; _laeuft = false; }); } catch (e) { debugPrint('MusicBrainz nicht erreichbar: $e'); if (!mounted) return; setState(() => _laeuft = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('MusicBrainz nicht erreichbar')), ); } } /// Übernimmt [vorschlag]; leere Angaben lassen den bisherigen Wert stehen. Future _uebernehmen(MbVorschlag vorschlag) async { final song = widget.song; final db = context.read(); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); await db.upsertSongs([metadatenUebernahme(song, vorschlag)]); if (!mounted) return; // Das Sheet zeigt eine Kopie des Songs — geschlossen wirkt die Änderung // sofort in der Liste darunter. navigator.pop(); messenger.showSnackBar( const SnackBar(content: Text('Metadaten übernommen')), ); } @override Widget build(BuildContext context) { final vorschlaege = _vorschlaege; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ TextButton.icon( icon: const Icon(Icons.travel_explore, size: 16), label: const Text('Online nachschlagen'), onPressed: _laeuft ? null : _nachschlagen, ), if (_laeuft) ...[ const SizedBox(width: 8), const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: MeloTheme.red), ), ], ], ), if (vorschlaege != null && vorschlaege.isEmpty) const Text('Nichts gefunden', style: TextStyle(color: Colors.white38, fontSize: 13)), if (vorschlaege != null && vorschlaege.isNotEmpty) ...[ const Text('Tippen übernimmt Titel, Künstler und Album', style: TextStyle(color: Colors.white38, fontSize: 12)), for (final vorschlag in vorschlaege) ListTile( contentPadding: EdgeInsets.zero, dense: true, title: Text(vorschlag.titel, style: const TextStyle(fontSize: 14)), subtitle: Text( [vorschlag.kuenstler, vorschlag.album] .where((t) => t.isNotEmpty) .join(' — '), style: const TextStyle(color: Colors.white38, fontSize: 12), ), onTap: () => _uebernehmen(vorschlag), ), ], ], ); } } class _Row extends StatelessWidget { const _Row(this.label, this.value); final String label; final String value; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 110, child: Text(label, style: const TextStyle(color: Colors.white38, fontSize: 13)), ), Expanded( child: Text(value, style: const TextStyle(fontSize: 13)), ), ], ), ); } } String _formatDuration(int? ms) { if (ms == null) return '—'; final d = Duration(milliseconds: ms); final minutes = d.inMinutes; final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } String _formatDate(int ms) { final d = DateTime.fromMillisecondsSinceEpoch(ms); String two(int v) => v.toString().padLeft(2, '0'); return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}'; } String _extension(String path) { final dot = path.lastIndexOf('.'); return dot == -1 ? '—' : path.substring(dot + 1).toUpperCase(); } String _fileSize(String path) { try { final bytes = File(path).lengthSync(); if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } catch (_) { return '—'; } } /// Dialog zum Hinzufügen und Entfernen von Kategorien. class _CategoryEditor extends StatefulWidget { const _CategoryEditor({required this.current, required this.suggestions}); final List current; final List suggestions; @override State<_CategoryEditor> createState() => _CategoryEditorState(); } class _CategoryEditorState extends State<_CategoryEditor> { late final List _names = [...widget.current]; final _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } void _add(String raw) { final name = raw.trim(); if (name.isEmpty) return; if (_names.any((n) => n.toLowerCase() == name.toLowerCase())) return; setState(() => _names.add(name)); _controller.clear(); } @override Widget build(BuildContext context) { final offen = widget.suggestions .where((s) => !_names.any((n) => n.toLowerCase() == s.toLowerCase())) .toList(); return AlertDialog( backgroundColor: MeloTheme.surface, title: const Text('Kategorien'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Die erste Kategorie liefert das Coverbild.', style: TextStyle(color: Colors.white38, fontSize: 12), ), const SizedBox(height: 12), if (_names.isEmpty) const Text('Noch keine Kategorie', style: TextStyle(color: Colors.white38, fontSize: 13)) else Wrap( spacing: 8, children: [ for (final name in _names) InputChip( label: Text(name), backgroundColor: MeloTheme.surfaceHigh, side: BorderSide.none, onDeleted: () => setState(() => _names.remove(name)), ), ], ), const SizedBox(height: 12), TextField( controller: _controller, decoration: const InputDecoration( labelText: 'Kategorie hinzufügen', border: OutlineInputBorder(), ), onSubmitted: _add, ), if (offen.isNotEmpty) ...[ const SizedBox(height: 12), const Text('Bereits vergeben', style: TextStyle(color: Colors.white38, fontSize: 12)), const SizedBox(height: 4), Wrap( spacing: 8, children: [ for (final name in offen.take(12)) ActionChip( label: Text(name), backgroundColor: MeloTheme.surfaceHigh, side: BorderSide.none, onPressed: () => _add(name), ), ], ), ], ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'), ), FilledButton( onPressed: () { // Ein noch nicht bestätigter Text im Feld soll nicht verloren gehen. _add(_controller.text); Navigator.pop(context, _names); }, child: const Text('Speichern'), ), ], ); } } /// Baut den Datenbank-Eintrag für einen übernommenen Online-Vorschlag. /// Leere Angaben lassen den bisherigen Wert stehen. /// /// Setzt [Songs.metadataEdited] — ohne diese Markierung holt der nächste /// Bibliotheks-Scan die falschen Tags der Datei zurück und die Korrektur /// wäre wieder weg. SongsCompanion metadatenUebernahme(Song song, MbVorschlag vorschlag) { return SongsCompanion.insert( id: song.id, path: song.path, title: vorschlag.titel.isEmpty ? song.title : vorschlag.titel, artist: Value(vorschlag.kuenstler.isEmpty ? song.artist : vorschlag.kuenstler), album: Value(vorschlag.album.isEmpty ? song.album : vorschlag.album), metadataEdited: const Value(true), dateAddedMs: song.dateAddedMs, updatedAtMs: DateTime.now().millisecondsSinceEpoch, ); }