diff --git a/.hermes/plans/2026-07-23_180000-melo-server-sync.md b/.hermes/plans/2026-07-23_180000-melo-server-sync.md new file mode 100644 index 0000000..116c581 --- /dev/null +++ b/.hermes/plans/2026-07-23_180000-melo-server-sync.md @@ -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 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 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> fetchServerList()` – ruft `musik.baka-net.de/api/songs.json` ab + - `Future downloadSong(ServerSong s)` – lädt runter, speichert lokal + in DB + - `Future 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) diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md index 7c39e1b..8cbfea1 100644 --- a/CODE_REVIEW.md +++ b/CODE_REVIEW.md @@ -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 +> **Gesamt-Score:** **A-** (Hervorragend verbessert!) --- -## 📋 Kritische Bugs (MÜSSEN SOFORT GEFIXT WERDEN) - -### 🔴 [CRIT-1] Memory Leak: MiniPlayer-Streams werden nie gecancelled - -**Datei:** `widgets/mini_player.dart` · Zeilen 20–32 - -```dart -// 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(); -} -``` +## ⭐️ Status der Altschulden (Review-Vergleich) +Alle kritischen Probleme aus dem vorherigen Review wurden **vollständig und sauber gelöst**: +- ✅ **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. +- ✅ **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. +- ✅ **CRIT-5 (ErrorBoundary-Deko)**: Der tote `AppWrapper` und `FlutterErrorBoundary` in `main.dart` wurden komplett entfernt (Ponytail-Prinzip!). --- -### 🔴 [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 -await _db.metadatenAktualisieren( - widget.song.id!, // ← CRASH wenn id null ist! - ... -); -``` +### 2. READABILITY (Lesbarkeit) — **Score: A** +- **Konsistente Namensgebung**: Großartiger, verständlicher Code. Deutsche Variablennamen (`_aktiverTab`, `_ladeSongs()`) werden konsistent und clean durchgezogen. +- **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:** -```dart -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, ...); -``` +### 4. PERFORMANCE (Laufzeit & Ressourcen) — **Score: B** +- **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! +- **🚨 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. + - *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. + - *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. + +### 5. SECURITY (Sicherheit) — **Score: A** +- **Berechtigungen**: Vorbildliche Handhabung von Android Scoped Storage Permissions über `Permission.audio` statt der veralteten `Permission.storage` (deprecating ab Android 13). --- -### 🔴 [CRIT-3] `dauerSekunden` ist IMMER 0 (kein Metadaten-Parsing) +## 🛠 Top 3 Quick Wins -**Datei:** `services/musik_scanner.dart` · Zeilen 34–44 - -```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 126–135 - -```dart -Future 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 48–62 - -```dart -class _FlutterErrorBoundaryState extends State { - @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 121–126 - -```dart -Future> _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 - -```dart -Stream get positionStream => _p.positionStream; // _p initiiert lazy -Stream 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: -```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 221–229 - -```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 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: -```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 45–49 - -```dart -Future> 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> 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 - -```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 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 - -```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 32–131 - -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 90–108 - -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 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-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 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. +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. +3. **VM-Cleanup**: Entferne den redundanten Aufruf von `tagCounts = _berechneTagCounts();` aus Zeile 78 in `melo_home_viewmodel.dart`. diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index b5521d4..74d5079 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -82,6 +82,9 @@ class DbHelper { ) '''); }, + onUpgrade: (db, oldVersion, newVersion) async { + // Hier zukünftige DB-Migrationen einpflegen (z.B. if (oldVersion < 2) ...) + }, ); } diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index b369f19..eb242d7 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -100,7 +100,7 @@ class DownloadService { await for (final chunk in fileStream) { sink.add(chunk); downloaded += chunk.length; - _fortschritt = downloaded / total; + _fortschritt = total > 0 ? downloaded / total : 0.0; } await sink.flush(); await sink.close(); diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index cd1effe..5ed5507 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -26,6 +26,8 @@ class MusikScanner { final gefunden = []; final pfade = await _sammleMusikPfade(); + final player = AudioPlayer(); + for (final pfad in pfade) { try { final file = File(pfad); @@ -35,7 +37,7 @@ class MusikScanner { titel: _dateiNameOhneEndung(pfad), kuenstler: 'Unbekannt', album: '', - dauerSekunden: await _ermittleDauer(pfad), + dauerSekunden: await _ermittleDauer(player, pfad), dateiPfad: pfad, coverPfad: null, groesseBytes: stat.size, @@ -47,6 +49,10 @@ class MusikScanner { } } + try { + await player.dispose(); + } catch (_) {} + // In DB speichern final vorhandene = await _db.alleSongs(); final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet(); @@ -126,12 +132,10 @@ class MusikScanner { } catch (_) {} } - Future _ermittleDauer(String pfad) async { + Future _ermittleDauer(AudioPlayer player, String pfad) async { try { - final player = AudioPlayer(); await player.setFilePath(pfad); final dauer = player.duration; - await player.dispose(); return dauer?.inSeconds ?? 0; } catch (_) { return 0; diff --git a/lib/viewmodels/melo_home_viewmodel.dart b/lib/viewmodels/melo_home_viewmodel.dart index 2529e2f..33a24f7 100644 --- a/lib/viewmodels/melo_home_viewmodel.dart +++ b/lib/viewmodels/melo_home_viewmodel.dart @@ -75,7 +75,6 @@ class MeloHomeViewModel extends ChangeNotifier { songs = alle; favoritenIds = await favoriten.favoritenIds(); - tagCounts = _berechneTagCounts(); letzteSongs = await db.letzteWiedergaben(); await ladeTags(); ladt = false; diff --git a/lib/widgets/navidrome_browser.dart b/lib/widgets/navidrome_browser.dart index db63dce..a386f06 100644 --- a/lib/widgets/navidrome_browser.dart +++ b/lib/widgets/navidrome_browser.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import '../services/navidrome_service.dart'; import '../utils/farb_theme.dart'; +import '../viewmodels/melo_home_viewmodel.dart'; /// Navidrome-Browser als Bottom-Sheet. /// Manuell in home_screen.dart einbaubar. class NavidromeBrowser extends StatefulWidget { - final dynamic vm; + final MeloHomeViewModel vm; const NavidromeBrowser({super.key, required this.vm}); @override diff --git a/lib/widgets/playlist_sheet.dart b/lib/widgets/playlist_sheet.dart index 44f5820..1d97693 100644 --- a/lib/widgets/playlist_sheet.dart +++ b/lib/widgets/playlist_sheet.dart @@ -2,10 +2,11 @@ import 'package:flutter/material.dart'; import '../models/song.dart'; import '../models/playlist.dart'; import '../utils/farb_theme.dart'; +import '../viewmodels/melo_home_viewmodel.dart'; /// Bottom-Sheet zum Durchstöbern von Playlists. class PlaylistSheet extends StatefulWidget { - final dynamic vm; + final MeloHomeViewModel vm; const PlaylistSheet({super.key, required this.vm}); @override @@ -174,7 +175,7 @@ class _PlaylistSheetState extends State { class _PlaylistDetail extends StatelessWidget { final Playlist p; final List songs; - final dynamic vm; + final MeloHomeViewModel vm; final VoidCallback onChanged; const _PlaylistDetail({