Files
melo-app/CODE_REVIEW.md
T

18 KiB
Raw Blame History

🔴 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 2032

// 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 3444

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: 0dauerFormatiert 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 126135

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 4862

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 121126

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 3233

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 3446

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 374393 + 431434

_aktiverTag wird zwar gesetzt (Zeile 382), aber in _songListe() (Zeile 431434) 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, SongTile in 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 221229

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 2965

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 4549

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 1625

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 3240, 5267

Die _beispielTags (hardcoded) und die Beispiel-Song-Seeding (Zeilen 5367) 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 183245

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 280393

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 32131

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 90108

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 509515: 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-Buttons
  • Tags → Tag-Verwaltung (erstellen, löschen, Songs zuweisen)
  • Favoriten → Zeige Favoriten-Playlist
  • Einstellungen → 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 8387: 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.