18 KiB
🔴 Melo App — Vollständiges Code-Review
Geprüft: 20.07.2026 | Projekt: ~/Projects/melo_app/ | Dustin (Baka) — Kiel Prinzip: Ponytail — minimaler Code, maximale Wirkung, max 500 Zeilen pro Datei
📋 Kritische Bugs (MÜSSEN SOFORT GEFIXT WERDEN)
🔴 [CRIT-1] Memory Leak: MiniPlayer-Streams werden nie gecancelled
Datei: widgets/mini_player.dart · Zeilen 20–32
// initState abonniert Streams, aber speichert kein StreamSubscription
_player.positionStream.listen((pos) { ... });
_player.stateStream.listen((state) { ... });
Problem: initState abonniert positionStream und stateStream via .listen(), aber es gibt kein dispose()-Override. Die StreamSubscription-Objekte werden nirgends gespeichert, also können sie nie gecancelled werden. Jedes Mal wenn der MiniPlayer neu gebaut wird (z.B. bei setState im Parent), leakt ein neues Paar Subscriptions.
Fix:
StreamSubscription? _posSub;
StreamSubscription? _stateSub;
@override
void initState() {
super.initState();
_posSub = _player.positionStream.listen((pos) { ... });
_stateSub = _player.stateStream.listen((state) { ... });
}
@override
void dispose() {
_posSub?.cancel();
_stateSub?.cancel();
super.dispose();
}
🔴 [CRIT-2] Null-Crash: widget.song.id! kann explodieren
Datei: widgets/metadaten_dialog.dart · Zeile 88
await _db.metadatenAktualisieren(
widget.song.id!, // ← CRASH wenn id null ist!
...
);
Problem: Song.id ist int?. Wenn ein Song (z.B. frisch gescannter oder Beispielsong ohne DB-ID) den Metadaten-Editor öffnet, crasht die App mit null check used on null value.
Fix:
final id = widget.song.id;
if (id == null) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Song-ID fehlt — bitte App neustarten')),
);
return;
}
await _db.metadatenAktualisieren(id, ...);
🔴 [CRIT-3] dauerSekunden ist IMMER 0 (kein Metadaten-Parsing)
Datei: services/musik_scanner.dart · Zeilen 34–44
final song = Song(
titel: _dateiNameOhneEndung(pfad),
kuenstler: 'Unbekannt', // ← keinerlei Tag-Parsing
dauerSekunden: 0, // ← IMMER 0!
...
);
Problem: Es wird nie die echte Audio-Dauer ausgelesen. Weder ID3-Tags noch MediaStore-Metadaten. Alle gescannten Songs haben dauerSekunden: 0 → dauerFormatiert zeigt "0:00".
Fix: Nutze eine Audio-Metadaten-Bibliothek wie flutter_media_metadata oder audio_metadata_reader, oder implementiere einen nativen Method-Channel für MediaStore-Query. Minimal-Fix: verwende just_audio kurz zum Öffnen der Datei um Duration zu lesen.
🔴 [CRIT-4] positionAktualisieren flutet DB mit immer neuen Einträgen
Datei: database/db_helper.dart · Zeilen 126–135
Future<void> positionAktualisieren(int songId, int position) async {
final d = await db;
await d.update('songs', {'zuletzt_position': position},
where: 'id = ?', whereArgs: [songId]);
await d.insert('wiedergabe_verlauf', { // ← IMMER INSERT!
'song_id': songId,
'position': position,
'zuletzt_abgespielt': DateTime.now().toIso8601String(),
});
}
Problem: Bei jedem Positions-Update wird ein NEUER History-Eintrag inserted, ohne den alten zu löschen. Nach 30 Minuten Musik hören mit 1-Sekunden-Takt → 1800 Einträge pro Song.
Fix Variante A (einfach): Lösche alten Eintrag vor neuem Insert:
await d.delete('wiedergabe_verlauf', where: 'song_id = ?', whereArgs: [songId]);
await d.insert('wiedergabe_verlauf', { ... });
Fix Variante B (besser): Nur bei signifikanten Änderungen speichern (alle 30 Sekunden statt bei jedem Tick).
🔴 [CRIT-5] FlutterErrorBoundary tut GAR NICHTS
Datei: main.dart · Zeilen 48–62
class _FlutterErrorBoundaryState extends State<FlutterErrorBoundary> {
@override
Widget build(BuildContext context) {
return widget.child; // ← gibt einfach child zurück, kein Error Handling!
}
}
Problem: Das "Error Boundary" hat keinen Error-Catcher (FlutterError.onError oder ErrorWidget.builder oder runZonedGuarded). Es fängt genau null Fehler. Wird aber zum Glück auch nicht verwendet (der AppWrapper wird nie benutzt).
Fix: Entweder korrekt implementieren (mit runZonedGuarded oder ErrorWidget.builder) oder — besser — den ganzen AppWrapper + FlutterErrorBoundary rauswerfen (Ponytail!).
📋 Schwere Probleme
🟠 [MAJ-1] Download-Fehlermeldungen werden brutal abgeschnitten
Datei: services/download_service.dart · Zeilen 56, 75, 105, 127
_fehlermeldung = 'Timeout/Fehler bei Video-Info: ${e.toString().substring(0, 80)}';
Problem: .substring(0, 80) und .substring(0, 100) zerstören Fehlerdetails. YouTube-Fehler (Rate Limits, Region-Locks, Copyright Claims) werden sinnlos.
Fix: Entweder gar nicht truncaten, oder erst auf UI-Ebene truncaten. Die Fehlermeldung sollte vollständig geloggt werden:
debugPrint('Download-Fehler (vollständig): $e');
_fehlermeldung = e.toString(); // UI zeigt nur ersten Teil, Log hat alles
🟠 [MAJ-2] MiniPlayer dispose() tut nichts (lebt als Memory Leak II)
Datei: widgets/mini_player.dart · Zeile 131
Kein dispose()-Override vorhanden! Die Klasse endet mit Widget _playBtn() { ... }. Dadurch werden nicht nur Streams nicht gecancelled (CRIT-1), sondern auch keine Ressourcen freigegeben.
🟠 [MAJ-3] _scanneViaMediaStore() gibt IMMER [] zurück
Datei: services/musik_scanner.dart · Zeilen 121–126
Future<List<String>> _scanneViaMediaStore() async {
// Nutzt Android's MediaStore Query
// Wird über Method Channel in native Android implementiert
// Für v1: Fallback auf Dateisystem-Suche
return []; // ← TODO seit Version 1
}
Problem: Der MediaStore-Pfad ist nie implementiert. Der Fallback auf Dateisystem-Suche ist ineffizient und findet keine Musik in App-spezifischen Verzeichnissen. Auf Android 11+ (API 30+) wird der Dateisystem-Zugriff zudem stark eingeschränkt.
🟠 [MAJ-4] _p.positionStream referenziert möglicherweise alten Player
Datei: services/player_service.dart · Zeilen 32–33
Stream<Duration> get positionStream => _p.positionStream; // _p initiiert lazy
Stream<PlayerState> get stateStream => _p.playerStateStream;
Problem: _p initialisiert den AudioPlayer lazy beim ersten Zugriff. Wenn die Streams vor dem ersten spiele()-Aufruf abonniert werden, hängen sie an einem Player, der später ersetzt/reinitialisiert werden könnte. Der getter erzeugt keinen neuen Player, also ist das nur ein Problem wenn _player jemals auf null gesetzt wird (was nie passiert). Aber: _p wird von diesen Gettern aufgerufen → bei jedem Stream-Zugriff wird der _player != null Check gemacht. Das ist ok, aber es ist ein wartungsintensives Pattern.
🟠 [MAJ-5] AppWrapper ist totes Code-Gewebe
Datei: main.dart · Zeilen 34–46
AppWrapper wird nirgends verwendet. MeloApp geht direkt zu Scaffold(body: MeloHome()). Der Wrapper wurde offenbar geplant aber nie aktiviert.
🟠 [MAJ-6] Tag-Filter wird in Song-Liste komplett ignoriert
Datei: screens/home_screen.dart · Zeilen 374–393 + 431–434
_aktiverTag wird zwar gesetzt (Zeile 382), aber in _songListe() (Zeile 431–434) wird die Song-Liste ungefiltert angezeigt:
itemCount: _songs.length, // ← immer alle Songs, egal welcher Tag aktiv
Die _beispielTags sind zudem hardcoded (nicht aus der DB) — das Tag-System hat keinen echten Filter.
📋 Code-Qualität & Anti-Patterns
🟡 [QUAL-1] Singleton-Overkill (5 von 6 Klassen sind Singletons)
| Klasse | Singleton? | Warum problematisch |
|---|---|---|
DbHelper |
✅ | Testen unmöglich (kein Mock) |
PlayerService |
✅ | State hält zwischen Tests |
FavoritenService |
✅ | s.o. |
DownloadService |
✅ | s.o. |
MusikScanner |
✅ | s.o. |
Fix: Dependency Injection via Konstruktor. Services sollten das DB-Objekt injiziert bekommen statt DbHelper() direkt aufzurufen.
🟡 [QUAL-2] HomeScreen Monolith — 519 Zeilen in einer Datei
Datei: screens/home_screen.dart · 519 Zeilen
Enthält:
- State Management
- Header-Widget
- Statistik-Widget
- Tag-Leiste
- Song-Liste
- Song-Tile
- Bottom Nav
- Download-Dialog (komplette UI + Timer-Logik!)
- Such-Dialog (komplette UI!)
- Scan-Logik
- Beispieldaten/Seed-Daten
Ponytail-Prinzip verletzt! Max 500 Zeilen pro Datei fast erreicht, aber die Verantwortlichkeiten sind vermischt.
Fix:
- Extrahiere
MeloHeader,StatistikCard,TagLeiste,SongTilein eigene Widget-Dateien (widgets/) - Extrahiere Such- und Download-Dialoge in eigene Methoden oder Dateien
- Entferne Beispiel-Song-Seeding (gehört in einen dev-only Seeder)
🟡 [QUAL-3] loeschen() löscht in falscher Reihenfolge
Datei: database/db_helper.dart · Zeilen 221–229
await d.delete('wiedergabe_verlauf');
await d.delete('song_tags');
await d.delete('playlist_songs');
await d.delete('playlists');
await d.delete('tags');
await d.delete('songs');
Problem: Die Tabellen mit ON DELETE CASCADE werden vor den Eltern-Tabellen gelöscht. Theoretisch korrekt (CASCADE ist auf FK definiert), aber die Reihenfolge ist inkonsistent: playlists wird vor tags gelöscht, obwohl beide Eltern sind. Außerdem: keine Transaktion! Wenn ein Löschen fehlschlägt, hat man eine korrupte Datenbank.
Fix: Alles in eine Transaktion packen:
await d.transaction((txn) async {
await txn.delete('wiedergabe_verlauf');
await txn.delete('song_tags');
await txn.delete('playlist_songs');
await txn.delete('playlists');
await txn.delete('tags');
await txn.delete('songs');
});
🟡 [QUAL-4] Doppelter try-catch in musik_scanner.dart
Datei: services/musik_scanner.dart · Zeilen 29–65
Zwei verschachtelte try-catch Blöcke die fast identischen Code enthalten. Der äußere try-catch fängt alles, und der innere ist ein "vielleicht klappt es beim zweiten Versuch" — ohne ersichtlichen Grund.
Fix: Einfach einen try-catch:
try {
final file = File(pfad);
final stat = await file.stat();
gefunden.add(/* Song erstellen */);
} catch (e) {
debugPrint('Datei nicht lesbar: $pfad — $e');
}
🟡 [QUAL-5] favoritenIds() ist ineffizient (lädt alle Songs nur für IDs)
Datei: services/favoriten_service.dart · Zeilen 45–49
Future<Set<int>> favoritenIds() async {
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
return songs.where((s) => s.id != null).map((s) => s.id!).toSet();
}
Problem: Lädt komplette Song-Objekte (mit allen Feldern) aus der DB, nur um die IDs zu bekommen. Bei 1000 Songs werden 1000 Song.fromMap()-Aufrufe gemacht.
Fix: Dedizierte DB-Query:
Future<Set<int>> favoritenIds() async {
final d = await _db.db;
final rows = await d.rawQuery(
'SELECT song_id FROM playlist_songs WHERE playlist_id = ?',
[_favoritenPlaylistId],
);
return rows.map((r) => r['song_id'] as int).toSet();
}
📋 Überschüssiger Code (Ponytail-Prinzip)
🧹 [PONY-1] 4 redundante Zeilen im _p Getter
Datei: services/player_service.dart · Zeilen 16–25
AudioPlayer get _p {
if (_player == null) {
try {
_player = AudioPlayer();
} catch (e) {
debugPrint('AudioPlayer Init Fehler: $e');
_player = AudioPlayer(); // ← gleicher Code nochmal??
}
}
return _player!;
}
Der doppelte AudioPlayer()-Aufruf im catch-Block ist sinnlos. Wenn der erste fehlschlägt, schlägt der zweite auch fehl. Einfach:
AudioPlayer get _p => _player ??= AudioPlayer();
🧹 [PONY-2] Beispiel-Songs + Tags aus home_screen.dart raus
Datei: screens/home_screen.dart · Zeilen 32–40, 52–67
Die _beispielTags (hardcoded) und die Beispiel-Song-Seeding (Zeilen 53–67) gehören nicht in den produktiven Screen. Das ist Dev-Code.
Fix: Entweder in einen DevDataSeeder() auslagern oder per --dart-define steuern.
🧹 [PONY-3] Download-Dialog als StatefulBuilder im HomeScreen
Datei: screens/home_screen.dart · Zeilen 183–245
Der komplette Download-Dialog mit Timer-Polling ist im HomeScreen vergraben. Das sind ~60 Zeilen UI + Logik.
Fix: Eigene Widget-Datei widgets/download_dialog.dart mit state-haltendem Widget, das den Timer managed.
🧹 [PONY-4] _header() + _statistik() + _tagLeiste() → eigene Widgets
Datei: screens/home_screen.dart · Zeilen 280–393
Diese drei Methoden sind alle UI-only und könnten als eigene Widgets in widgets/ leben.
📋 Security & Robustheit
🔒 [SEC-1] Permission.storage ist auf Android 33+ deprecated
Datei: services/musik_scanner.dart · Zeile 19
final status = await Permission.storage.request();
Problem: Ab API 33 (Android 13) gibt es READ_MEDIA_AUDIO statt READ_EXTERNAL_STORAGE. Permission.storage fragt nach der alten Permission, die auf neueren Geräten ignoriert wird.
Fix:
import 'dart:io' show Platform;
// Oder besser über permission_handler Android-specific
await Permission.audio.request();
🔒 [SEC-2] Kein Retry-Mechanismus bei Netzwerkfehlern
Datei: services/download_service.dart · Zeilen 32–131
Bei Timeout oder Verbindungsabbruch wird einfach null zurückgegeben. Kein Retry, kein exponentielles Backoff.
Fix: 2-3 Retry-Versuche mit steigendem Timeout:
for (int versuch = 0; versuch < 3; versuch++) {
try {
return await _downloadAttempt(url);
} catch (e) {
if (versuch == 2) rethrow;
await Future.delayed(Duration(seconds: 2 * (versuch + 1)));
}
}
🔒 [SEC-3] Kein Cancel-Mechanismus beim YouTube-Download
Datei: services/download_service.dart · Zeilen 90–108
Einmal gestartet, kann der Download nicht abgebrochen werden. Der Nutzer muss warten oder die App killen.
Fix: StreamSubscription speichern und cancel() anbieten:
StreamSubscription? _downloadSub;
void cancelDownload() => _downloadSub?.cancel();
📋 Missing Features
📌 [FEAT-1] 4 von 5 Bottom-Nav-Tabs sind leer
home_screen.dart Zeilen 509–515: Navigation hat 5 Einträge, aber keine _selectedIndex und keine IndexedStack oder switch-Logik. Alle Tabs außer "Musik" zeigen denselben Screen.
Zu implementieren:
Downloads→ Zeige heruntergeladene Songs + Download-ButtonsTags→ Tag-Verwaltung (erstellen, löschen, Songs zuweisen)Favoriten→ Zeige Favoriten-PlaylistEinstellungen→ Theme, Cache, Info
📌 [FEAT-2] Keine Audio-Service Integration
audio_service: ^0.18.15 ist als Dependency eingetragen, aber wird nirgends importiert oder verwendet. PlayerService ist standalone ohne Background-Playback.
📌 [FEAT-3] setWarteschlange ohne Automatische Wiedergabe
player_service.dart Zeilen 83–87: Die Warteschlange wird gesetzt, aber nach dem letzten Song stoppt die Wiedergabe (kein Loop, kein Shuffle, keine Queue-Weiterverarbeitung).
📋 Zusammenfassung: Priority-TODO-Liste
🔴 MUST FIX (sofort — vor Release)
| # | Datei | Zeile | Issue |
|---|---|---|---|
| 1 | widgets/mini_player.dart |
20-32 | Memory Leak: Streams nie gecancelled |
| 2 | widgets/metadaten_dialog.dart |
88 | Null-Crash: song.id! kann crashen |
| 3 | services/musik_scanner.dart |
38 | dauerSekunden: 0 — alle Songs haben Dauer 0 |
| 4 | database/db_helper.dart |
126-135 | DB-Flut: positionAktualisieren inserted immer neu |
| 5 | main.dart |
48-62 | FlutterErrorBoundary tut nichts (entfernen oder fixen) |
| 6 | widgets/mini_player.dart |
131 | Kein dispose() — Leak #2 |
🟠 SHOULD FIX (nächster Sprint)
| # | Datei | Zeile | Issue |
|---|---|---|---|
| 7 | services/download_service.dart |
56,75,105,127 | Fehlermeldungen abgeschnitten (substring) |
| 8 | services/musik_scanner.dart |
121-126 | _scanneViaMediaStore() gibt [] zurück |
| 9 | main.dart |
34-46 | AppWrapper totes Gewebe (entfernen) |
| 10 | screens/home_screen.dart |
382 | Tag-Filter tut nichts |
| 11 | screens/home_screen.dart |
32-40 | Hardcoded Beispiel-Tags (aus DB laden!) |
| 12 | services/download_service.dart |
90-108 | Kein Cancel-Mechanismus |
| 13 | services/download_service.dart |
— | Kein Retry bei Netzwerkfehlern |
| 14 | services/musik_scanner.dart |
19-20 | Permission.storage deprecated (API 33+) |
| 15 | screens/home_screen.dart |
509-515 | 4/5 Bottom-Nav-Tabs leer |
| 16 | services/favoriten_service.dart |
45-49 | favoritenIds() lädt unnötig alle Songs |
🟡 NICE TO IMPROVE (Code-Qualität)
| # | Datei | Zeile | Issue |
|---|---|---|---|
| 17 | screens/home_screen.dart |
1-519 | Monolith — in Einzeldateien aufteilen |
| 18 | services/player_service.dart |
16-25 | Redundanter try-catch im _p Getter |
| 19 | database/db_helper.dart |
221-229 | loeschen() ohne Transaktion |
| 20 | services/musik_scanner.dart |
29-65 | Doppelter try-catch Block |
| 21 | — | — | Singleton-Overkill → Dependency Injection |
| 22 | screens/home_screen.dart |
183-245 | Download-Dialog im HomeScreen (auslagern) |
| 23 | services/player_service.dart |
83-87 | setWarteschlange ohne Weiterschaltung |
| 24 | — | — | audio_service nie verwendet (Background-Playback) |
📊 Statistik
| Metrik | Wert |
|---|---|
| Dateien | 11 (12 mit pubspec.yaml) |
| Gesamt-LOC (Dart) | 1.613 |
| Größte Datei | home_screen.dart — 519 Zeilen (32%) |
| Singleton-Klassen | 5/6 (83%) |
| Kritische Bugs | 6 |
| Schwere Probleme | 6 |
| Code-Qualität | 5 |
| Ponytail-Verletzungen | 4 (→ extrahieren) |
| Security | 3 |
| Fehlende Features | 4 (davon 1 komplett leer) |
Fazit: Die App hat ein solides Grundgerüst, aber leidet unter klassischen "Solo-Dev"-Problemen: Memory Leaks, Null-Safety-Lücken, Singleton-Overkill und ein Monolith-Screen. Der größte Hebel ist die Aufteilung von
home_screen.dart(→ 3-4 eigene Widgets) und das Fixen der 6 kritischen Bugs. Danach: MediaStore-Integration für echte Song-Dauer und die 4 leeren Tabs befüllen.
"Weniger Code, mehr Wirkung" — Ponytail-Prinzip. Der MiniPlayer könnte mit dispose-Fix und gekürztem Code locker 30 Zeilen verlieren. Der HomeScreen sollte bei ~250 Zeilen landen.