Code Review Optimierungen: Typsicherheit, Scanner-Performance, DB Upgrade Template, Division Guard

This commit is contained in:
Hermes (Server)
2026-07-24 08:33:30 +02:00
parent c7828b851c
commit b43ccd4c0d
8 changed files with 225 additions and 525 deletions
@@ -0,0 +1,171 @@
# Melo App Server-Sync & Widgets Implementation Plan
> **Für Hermes:** Schritt-für-Schritt abarbeiten, keine Tasks springen.
**Goal:** Melo App um Widgets + Hidden Message "Seit 2008" erweitern, dann Server-Sync auf Aingrad aufsetzen (Musik zentral lagern, von jedem Gerät abrufbar).
**Architektur:**
- Phase 1+2: Reine App-Erweiterungen (Widgets + Easter Egg)
- Phase 3: Server als Caddy-File-Server + JSON-API, App holt Song-Liste und lädt runter
**Tech Stack:** Flutter/Dart, Caddy (läuft auf Aingrad), SQLite (bereits vorhanden)
---
## Phase 1: Widgets
### Task 1: Home-Widget (Recent Songs)
**Objective:** Widget zeigt die letzten 5 abgespielten Songs auf dem Home-Screen
**Files:**
- Create: `lib/widgets/recent_widget.dart`
- Modify: `lib/screens/home_screen.dart` (Widget einbauen)
- Modify: `lib/viewmodels/melo_home_viewmodel.dart` (letzte Songs laden)
**Schritte:**
1. `recent_widget.dart` erstellen kleiner Container mit den letzten 5 Songs, ähnlich wie StatistikCard aufgebaut (schwarz/rot Design)
2. ViewModel: `letzteSongs` laden aus `wiedergabe_verlauf`-Tabelle via `db.positionAktualisieren` die Tabelle existiert bereits
3. `home_screen.dart`: Widget unter StatistikCard einbauen
4. Nur sichtbar wenn `letzteSongs.isNotEmpty`
**Code-Skizze recent_widget.dart (~40 Zeilen):**
```dart
class RecentWidget extends StatelessWidget {
final List<Song> songs;
final void Function(Song) onPlay;
// UI: kleiner Container, 5 horizontale Einträge mit Cover + Titel
}
```
---
### Task 2: Tag-Statistik-Widget
**Objective:** Widget zeigt wie viele Songs pro Tag existieren (kleine Übersicht)
**Files:**
- Create: `lib/widgets/tag_stats_widget.dart`
- Modify: `lib/viewmodels/melo_home_viewmodel.dart` (Tag-Counts berechnen)
- Modify: `lib/screens/home_screen.dart`
**Schritte:**
1. ViewModel: `Map<String, int> tagCounts` zählt Songs pro Tag-Namen
2. `tag_stats_widget.dart` horizontale Chips mit Tag-Name + Count
3. In home_screen unter Tags-Leiste einbauen
---
## Phase 2: Hidden Message "Seit 2008"
### Task 3: Easter Egg auf dem Home-Screen
**Objective:** Versteckte Nachricht "Seit 2008" die nach 10 Song-Playbacks erscheint
**Files:**
- Modify: `lib/viewmodels/melo_home_viewmodel.dart` (Counter + Logik)
- Modify: `lib/screens/home_screen.dart` (Anzeige)
**Schritte:**
1. ViewModel: `int _playCount = 0; bool get zeigeBotschaft => _playCount >= 10;`
2. Bei jedem `spieleSong()``_playCount++`, bei 10 → `zeigeBotschaft = true`
3. UI: Wenn `zeigeBotschaft`, zeige ein rotes Herz mit "💌 Seit 2008" zwischen Header und StatistikCard
4. Nach einmaligem Anzeigen → `_playCount` zurücksetzen oder ausblendbar
**Code (~15 Zeilen ViewModel + ~10 Zeilen UI)**
---
## Phase 3: Server-Sync
### Task 4: Caddy-Subdomain einrichten
**Objective:** `musik.baka-net.de` served Musik-Ordner + JSON-Liste
**Files:**
- Modify: `/etc/caddy/Caddyfile` (auf Aingrad)
**Schritte:**
1. Ordner erstellen: `mkdir -p /home/dustin/musik/songs`
2. Caddyfile-Eintrag:
```
musik.baka-net.de {
root * /home/dustin/musik
file_server browse
@auth {
path /upload*
}
basicauth @auth {
melo HASH_PASSWORT
}
}
```
3. JSON-API Script: `/home/dustin/musik/api/songs.json` generiert Liste aller `.mp3`/`.m4a`/`.flac` Dateien mit Metadaten
4. `caddy reload`
---
### Task 5: ServerSyncService in der App
**Objective:** Neuer Service ruft Server-Liste ab und managed Downloads
**Files:**
- Create: `lib/services/server_sync_service.dart`
- Create: `lib/models/server_status.dart`
- Modify: `lib/screens/home_screen.dart` (neuer Tab)
**Schritte:**
1. `ServerSyncService` mit Methoden:
- `Future<List<ServerSong>> fetchServerList()` ruft `musik.baka-net.de/api/songs.json` ab
- `Future<Song?> downloadSong(ServerSong s)` lädt runter, speichert lokal + in DB
- `Future<bool> istBereitsLokal(ServerSong s)` checkt ob Datei existiert
2. Neuer Screen/Sheet: "📡 Server"-Button im Header öffnet Server-Browser
- Zeigt alle Songs auf dem Server
- Status: "Downloaded ✓" / "Download ⬇"
- Fortschrittsbalken wie beim YouTube-Download
---
### Task 6: Auto-Scan von Server-Songs
**Objective:** Einmal pro Session prüfen ob Server neue Songs hat
**Files:**
- Modify: `lib/viewmodels/melo_home_viewmodel.dart`
- Modify: `lib/services/server_sync_service.dart`
**Schritte:**
1. `ladeSongs()` → zusätzlich Server-Scan starten (im Hintergrund)
2. Neue Songs automatisch in DB eintragen (ohne Download nur Metadaten)
3. Badge im Server-Button: "3 neue Songs"
---
## Abhängigkeiten
```
Phase 1 (Widgets) → Phase 2 (Hidden Message) → Phase 3 (Server-Sync)
Alles unabhängig von Server
```
## Validation
- Widgets: Nach App-Neustart sichtbar, reagieren auf Song-Änderungen
- Hidden Message: Nach 10 Playbacks erscheint "Seit 2008"
- Server-Sync: `curl https://musik.baka-net.de/api/songs.json` liefert JSON
- App: Server-Tab zeigt Songs, Download funktioniert, Musik spielt ab
## Offene Fragen
- Soll Hidden Message persistiert werden (einmalig, nie wieder)?
- Auth für Download nötig oder öffentlich? (Server intern → öffentlich okay)
- Auto-Upload von lokal gescannten Songs zum Server? (Nur Download, kein Upload)
+37 -516
View File
@@ -1,532 +1,53 @@
# 🔴 Melo App — Vollständiges Code-Review # 🟢 Melo App — Aktuelles Code-Review (v2.5)
> **Geprüft:** 20.07.2026 | **Projekt:** ~/Projects/melo_app/ | **Dustin (Baka) — Kiel** > **Geprüft:** 24.07.2026 | **Projekt:** ~/Projects/melo_app/ | **Dustin (Baka) — Kiel**
> **Prinzip:** Ponytail — minimaler Code, maximale Wirkung, max 500 Zeilen pro Datei > **Prinzip:** Ponytail — minimaler Code, maximale Wirkung, max 500 Zeilen pro Datei
> **Gesamt-Score:** **A-** (Hervorragend verbessert!)
--- ---
## 📋 Kritische Bugs (MÜSSEN SOFORT GEFIXT WERDEN) ## ⭐️ Status der Altschulden (Review-Vergleich)
Alle kritischen Probleme aus dem vorherigen Review wurden **vollständig und sauber gelöst**:
### 🔴 [CRIT-1] Memory Leak: MiniPlayer-Streams werden nie gecancelled -**CRIT-1 (Memory Leak im MiniPlayer)**: `StreamSubscriptions` werden jetzt ordnungsgemäß in `dispose()` gecancelled.
-**CRIT-2 (Null-Crash im Metadaten-Editor)**: `widget.song.id!` wurde durch ein sicheres ID-Null-Guard ersetzt.
**Datei:** `widgets/mini_player.dart` · Zeilen 2032 -**CRIT-3 (Dauer-Sekunden = 0)**: Wird jetzt beim Scannen über den Player ermittelt. (Siehe Performance für Optimierungspotenzial).
-**CRIT-4 (Verlaufs-Flut)**: `positionAktualisieren` führt jetzt saubere Updates/Upserts statt endloser Inserts durch.
```dart -**CRIT-5 (ErrorBoundary-Deko)**: Der tote `AppWrapper` und `FlutterErrorBoundary` in `main.dart` wurden komplett entfernt (Ponytail-Prinzip!).
// 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:**
```dart
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 ## 📐 Five-Axis Quality Gate
**Datei:** `widgets/metadaten_dialog.dart` · Zeile 88 ### 1. CORRECTNESS (Korrektheits-Check) — **Score: A-**
- **Sicherheits-Guards**: Hervorragende null-safety Guards im Metadaten-Editor und beim Wiedergabestatus.
- **Division durch Null (Download)**: In `download_service.dart:103` bei `_fortschritt = downloaded / total;` besteht ein minimales theoretisches Risiko eines `NaN` (Division durch Null), falls `total` (audio.size.totalBytes) jemals `0` zurückgeben sollte.
- *Empfehlung:* Ein kurzer Ternary Guard: `_fortschritt = total > 0 ? downloaded / total : 0.0;`
- **Substring-Range**: `p.erstelltAm.substring(0, 10)` in `playlist_sheet.dart:103` ist sicher, da `erstelltAm` durch das ISO-Format standardmäßig >= 19 Zeichen lang ist.
```dart ### 2. READABILITY (Lesbarkeit) — **Score: A**
await _db.metadatenAktualisieren( - **Konsistente Namensgebung**: Großartiger, verständlicher Code. Deutsche Variablennamen (`_aktiverTab`, `_ladeSongs()`) werden konsistent und clean durchgezogen.
widget.song.id!, // ← CRASH wenn id null ist! - **Kein Code-Spam**: Die Klassen sind kompakt, sauber formatiert und verzichten auf unnötige Schachtelungen.
...
);
```
**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`. ### 3. ARCHITECTURE (Architektur & Struktur) — **Score: B+**
- **`dynamic vm` Anti-Pattern**: In `playlist_sheet.dart` und `navidrome_browser.dart` wird `final dynamic vm` verwendet. Das deaktiviert jegliche Typprüfung, Autovervollständigung und birgt das Risiko von Laufzeitfehlern, falls sich ViewModel-Methoden ändern.
- *Empfehlung:* Ersetze `dynamic vm` durch den konkreten Typ `final MeloHomeViewModel vm`.
- **Zukünftige DB-Migrationen**: `DbHelper.db` initialisiert die SQLite-Datenbank mit `version: 1` ohne `onUpgrade`-Callback. Wenn du später Tabellen anpasst, crasht die App bei bestehenden Nutzern beim Start.
- *Empfehlung:* Bereits jetzt eine leere `onUpgrade` Struktur im `openDatabase` vorsehen.
- **Redundanter Aufruf**: In `MeloHomeViewModel.ladeSongs()` wird `tagCounts = _berechneTagCounts();` aufgerufen, während `tags` noch ein leeres Array `[]` ist. Erst danach wird `await ladeTags()` aufgerufen, welches `_berechneTagCounts()` erneut aufruft. Der erste Aufruf ist also redundant.
**Fix:** ### 4. PERFORMANCE (Laufzeit & Ressourcen) — **Score: B**
```dart - **Granularer Rebuild (Großartig!)**: Die Implementierung des `MiniPlayer` ist **perfekt gelöst**. Er lauscht direkt auf die Streams des Players und aktualisiert seinen State lokal per `setState`. Dadurch wird verhindert, dass bei jedem Millisekunden-Update des Fortschrittsbalkens der gesamte Home-Bildschirm (mit Statistiken, Listen und Covern) neu gerendert wird!
final id = widget.song.id; - **🚨 Der Scanner-Flaschenhals**: In `lib/services/musik_scanner.dart` wird in `_ermittleDauer(pfad)` für **jeden einzelnen** gefundenen Song ein komplett neuer `AudioPlayer` instanziiert, die Datei geöffnet, die Dauer gelesen und der Player wieder disposed.
if (id == null) { - *Problem:* Wenn ein Ordner 200 Songs enthält, wird 200-mal nacheinander ein nativer Player erzeugt und zerstört. Das blockiert den Scanvorgang extrem, zieht viel Akku und kann auf manchen Systemen zu Abstürzen oder "Platform Channel Exception"-Fehlern (Erschöpfung der Audio-Player-Kanäle) führen.
if (mounted) ScaffoldMessenger.of(context).showSnackBar( - *Empfehlung:* Entweder eine extrem leichtgewichtige Metadata-Bibliothek (`flutter_media_metadata`) nutzen, oder eine einzige `AudioPlayer`-Instanz für den gesamten Scanvorgang wiederverwenden, anstatt sie in der Schleife ständig neu zu erstellen.
const SnackBar(content: Text('Song-ID fehlt — bitte App neustarten')),
); ### 5. SECURITY (Sicherheit) — **Score: A**
return; - **Berechtigungen**: Vorbildliche Handhabung von Android Scoped Storage Permissions über `Permission.audio` statt der veralteten `Permission.storage` (deprecating ab Android 13).
}
await _db.metadatenAktualisieren(id, ...);
```
--- ---
### 🔴 [CRIT-3] `dauerSekunden` ist IMMER 0 (kein Metadaten-Parsing) ## 🛠 Top 3 Quick Wins
**Datei:** `services/musik_scanner.dart` · Zeilen 3444 1. **Typsicherheit im UI**: Ändere `final dynamic vm` in `playlist_sheet.dart` und `navidrome_browser.dart` zu `final MeloHomeViewModel vm`. Das gibt dir volles Autocomplete und Compile-Safety.
2. **Scanner beschleunigen**: Instanziiere den `AudioPlayer` für die Dauer-Ermittlung einmalig außerhalb der Schleife im `MusikScanner` und nutze ihn für alle Dateien, statt ihn pro Song neu zu erstellen und zu disposen.
```dart 3. **VM-Cleanup**: Entferne den redundanten Aufruf von `tagCounts = _berechneTagCounts();` aus Zeile 78 in `melo_home_viewmodel.dart`.
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 126135
```dart
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:
```dart
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
```dart
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
```dart
_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:
```dart
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
```dart
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
```dart
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:
```dart
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
```dart
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:
```dart
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:
```dart
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
```dart
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:
```dart
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
```dart
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:
```dart
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
```dart
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:**
```dart
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:
```dart
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:
```dart
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.
+3
View File
@@ -82,6 +82,9 @@ class DbHelper {
) )
'''); ''');
}, },
onUpgrade: (db, oldVersion, newVersion) async {
// Hier zukünftige DB-Migrationen einpflegen (z.B. if (oldVersion < 2) ...)
},
); );
} }
+1 -1
View File
@@ -100,7 +100,7 @@ class DownloadService {
await for (final chunk in fileStream) { await for (final chunk in fileStream) {
sink.add(chunk); sink.add(chunk);
downloaded += chunk.length; downloaded += chunk.length;
_fortschritt = downloaded / total; _fortschritt = total > 0 ? downloaded / total : 0.0;
} }
await sink.flush(); await sink.flush();
await sink.close(); await sink.close();
+8 -4
View File
@@ -26,6 +26,8 @@ class MusikScanner {
final gefunden = <Song>[]; final gefunden = <Song>[];
final pfade = await _sammleMusikPfade(); final pfade = await _sammleMusikPfade();
final player = AudioPlayer();
for (final pfad in pfade) { for (final pfad in pfade) {
try { try {
final file = File(pfad); final file = File(pfad);
@@ -35,7 +37,7 @@ class MusikScanner {
titel: _dateiNameOhneEndung(pfad), titel: _dateiNameOhneEndung(pfad),
kuenstler: 'Unbekannt', kuenstler: 'Unbekannt',
album: '', album: '',
dauerSekunden: await _ermittleDauer(pfad), dauerSekunden: await _ermittleDauer(player, pfad),
dateiPfad: pfad, dateiPfad: pfad,
coverPfad: null, coverPfad: null,
groesseBytes: stat.size, groesseBytes: stat.size,
@@ -47,6 +49,10 @@ class MusikScanner {
} }
} }
try {
await player.dispose();
} catch (_) {}
// In DB speichern // In DB speichern
final vorhandene = await _db.alleSongs(); final vorhandene = await _db.alleSongs();
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet(); final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
@@ -126,12 +132,10 @@ class MusikScanner {
} catch (_) {} } catch (_) {}
} }
Future<int> _ermittleDauer(String pfad) async { Future<int> _ermittleDauer(AudioPlayer player, String pfad) async {
try { try {
final player = AudioPlayer();
await player.setFilePath(pfad); await player.setFilePath(pfad);
final dauer = player.duration; final dauer = player.duration;
await player.dispose();
return dauer?.inSeconds ?? 0; return dauer?.inSeconds ?? 0;
} catch (_) { } catch (_) {
return 0; return 0;
-1
View File
@@ -75,7 +75,6 @@ class MeloHomeViewModel extends ChangeNotifier {
songs = alle; songs = alle;
favoritenIds = await favoriten.favoritenIds(); favoritenIds = await favoriten.favoritenIds();
tagCounts = _berechneTagCounts();
letzteSongs = await db.letzteWiedergaben(); letzteSongs = await db.letzteWiedergaben();
await ladeTags(); await ladeTags();
ladt = false; ladt = false;
+2 -1
View File
@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../utils/farb_theme.dart'; import '../utils/farb_theme.dart';
import '../viewmodels/melo_home_viewmodel.dart';
/// Navidrome-Browser als Bottom-Sheet. /// Navidrome-Browser als Bottom-Sheet.
/// Manuell in home_screen.dart einbaubar. /// Manuell in home_screen.dart einbaubar.
class NavidromeBrowser extends StatefulWidget { class NavidromeBrowser extends StatefulWidget {
final dynamic vm; final MeloHomeViewModel vm;
const NavidromeBrowser({super.key, required this.vm}); const NavidromeBrowser({super.key, required this.vm});
@override @override
+3 -2
View File
@@ -2,10 +2,11 @@ import 'package:flutter/material.dart';
import '../models/song.dart'; import '../models/song.dart';
import '../models/playlist.dart'; import '../models/playlist.dart';
import '../utils/farb_theme.dart'; import '../utils/farb_theme.dart';
import '../viewmodels/melo_home_viewmodel.dart';
/// Bottom-Sheet zum Durchstöbern von Playlists. /// Bottom-Sheet zum Durchstöbern von Playlists.
class PlaylistSheet extends StatefulWidget { class PlaylistSheet extends StatefulWidget {
final dynamic vm; final MeloHomeViewModel vm;
const PlaylistSheet({super.key, required this.vm}); const PlaylistSheet({super.key, required this.vm});
@override @override
@@ -174,7 +175,7 @@ class _PlaylistSheetState extends State<PlaylistSheet> {
class _PlaylistDetail extends StatelessWidget { class _PlaylistDetail extends StatelessWidget {
final Playlist p; final Playlist p;
final List<Song> songs; final List<Song> songs;
final dynamic vm; final MeloHomeViewModel vm;
final VoidCallback onChanged; final VoidCallback onChanged;
const _PlaylistDetail({ const _PlaylistDetail({