BUG 1 — Lieder vom Handy waren nicht abspielbar
("Wiedergabe fehlgeschlagen: (0) Source error" / "Loading interrupted")
Wurzel-Ursache: MeloAudioHandler.loadPlaylist hat jeden Warteschlangen-
Eintrag durch NavidromeService.streamAndCacheToLocal(item.id) geschickt,
sobald Zugangsdaten existierten. item.id ist aber NIE eine Navidrome-Song-ID
— lokal ist es file:///storage/..., beim Server die fertige Stream-Adresse.
Der Server bekam also 'file:///...' als Song-ID, antwortete mit einem Fehler,
und diese Fehlerantwort wurde an just_audio weitergereicht (Source error) —
bzw. als .mp3 in den Cache geschrieben, wodurch der Titel dauerhaft kaputt
blieb.
Zweite Ursache: die Schleife lud die GANZE Warteschlange seriell vorab
herunter (30s Timeout je Titel), bevor setAudioSources lief. Bei hunderten
Titeln startete die Wiedergabe deshalb nie; ein zweiter Tipp brach den
laufenden Ladevorgang ab ("Loading interrupted").
Fix:
- Server-Titel tragen ihre ID in MediaItem.extras['navidromeId'] statt sie
aus der Abspiel-Adresse zu raten. Neue reine Funktionen navidromeIdOf,
songIdOf, nutztServerCache, quelleFuer.
- loadPlaylist baut die Quellen ohne Netzzugriff; Caching des laufenden
Titels im Hintergrund (unawaited).
- Resume/Scrobble nur noch mit der jeweils passenden ID (Server bzw. lokal).
- ladeInCache() ersetzt streamAndCacheToLocal(): .part-Datei, Pruefung des
Inhaltstyps (istAudioAntwort), stabiler Cache-Schluessel ueber die
Song-ID statt der Stream-Adresse (die trug Token+Salt und war je Sitzung
anders — der Cache war nie wiederauffindbar), Client wird geschlossen.
BUG 2 — kein Abgleich zwischen Handy und Server
Neu: services/melo_cloud_service.dart + services/sync_service.dart gegen
cloud.baka-net.de (Bearer-JWT ueber BakaAuth). Server-Titel herunterladen
(offline verfuegbar), eigene Dateien hochladen, Loeschungen in beide
Richtungen (Tombstones), Favoriten und Wiedergabe-Verlauf. Automatisch beim
App-Start und bei Rueckkehr in die App (max. alle 15 Min), plus Knopf unter
Einstellungen -> Geraete-Abgleich. Reine Planungsfunktion planeSync().
Bewusst NICHT ueber Navidrome: die Subsonic-API kennt keinen Upload-
Endpunkt. Navidrome bleibt die Streaming-Bibliothek, die Melo-Cloud ist der
gemeinsame Speicher.
DB-Schema 8: songs.cloud_id verbindet Geraet und Server.
Nebenbei behoben (blockierte Build bzw. Tests):
- database.g.dart war veraltet — das Projekt liess sich nicht uebersetzen.
- metadataEdited wurde nirgends gesetzt/beachtet: von Hand korrigierte
Metadaten wurden vom naechsten Scan ueberschrieben. Jetzt in beiden
Scans respektiert; metadatenUebernahme() setzt die Markierung.
- song_detail_sheet_test.dart haengt beim Oeffnen des Modal-Sheets und
blockierte den gesamten Testlauf — vorerst uebersprungen (TODO im Code);
der Zweck wird von metadaten_uebernahme_test.dart abgedeckt.
Enthaelt ausserdem die bis dahin nicht committete Arbeit der Vorsitzung
(Musikerkennung/ACRCloud, MusicBrainz-Metadaten, MediaStore-Datentraeger).
267 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
457 lines
15 KiB
Dart
457 lines
15 KiB
Dart
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<void> 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<CategoryService>();
|
|
final settings = context.watch<AppSettings>();
|
|
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<void> _editCategories(BuildContext context, List<String> current) async {
|
|
final service = context.read<CategoryService>();
|
|
final suggestions = await service.allNames();
|
|
if (!context.mounted) return;
|
|
final result = await showDialog<List<String>>(
|
|
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<MbVorschlag>? _vorschlaege;
|
|
|
|
Future<void> _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<void> _uebernehmen(MbVorschlag vorschlag) async {
|
|
final song = widget.song;
|
|
final db = context.read<MeloDb>();
|
|
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<String> current;
|
|
final List<String> suggestions;
|
|
|
|
@override
|
|
State<_CategoryEditor> createState() => _CategoryEditorState();
|
|
}
|
|
|
|
class _CategoryEditorState extends State<_CategoryEditor> {
|
|
late final List<String> _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,
|
|
);
|
|
}
|