Author SHA1 Message Date
Hermes (Server)andClaude Sonnet 5 8a5bbf6433 Sync-Ausbau abgeschlossen: SYNC_PLAN.md entfernen (kein Dauerdokument)
Alle 13 Tasks + Whole-Branch-Review + zwei Fix-Wellen fertig, PR #5 gegen
fix/p0-vollwertigkeit erstellt. Übergabe-Datei laut eigener Kopfzeile
löschen, sobald die Implementierung fertig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 14:20:01 +02:00
Hermes (Server) 8126a16520 Race behoben: Playlist-Wiederherstellung nur nach abgeschlossenem Sync-Lauf 2026-08-27 14:11:36 +02:00
Hermes (Server) b7c8be6f0f Review-Funde: Playlist-Wiederherstellung nach erstem Sync, CHANGELOG korrigiert, Dedup-sicheres songByCloudId 2026-08-27 14:04:28 +02:00
Hermes (Server) b13ed6271d CHANGELOG: Sync-Ausbau (Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Bericht) 2026-08-27 13:50:58 +02:00
Hermes (Server)andClaude Sonnet 5 faabf28abc Einseitige Playlist-Sicherung: melden, merken, bei leerer Tabelle holen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 13:43:20 +02:00
Hermes (Server) 0519c68b26 Playlisten-Endpunkte fuer die einseitige Sicherung (ohne DELETE) 2026-08-27 13:37:37 +02:00
Hermes (Server) 3cd88565ec Schema 11: Playlists.cloudId fuer die einseitige Sicherung 2026-08-27 13:31:33 +02:00
Hermes (Server)andClaude Sonnet 5 11a01d1a09 Sync-Ausbau: Stand nach Task 1-9 sichern (Übergabe für neue Session)
Tasks 1-9 fertig, reviewed, committed. Vor Task 10-12 (Playlist-Sicherung)
wartet die Umsetzung auf Dustins Test-Playlist am Server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-27 13:23:22 +02:00
Hermes (Server) 676f2c5f29 Was-ist-neu-Bericht als In-App-Dialog nach dem Abgleich 2026-08-27 12:40:21 +02:00
Hermes (Server) 9de61bbf94 Erfolgs-Flag, Zeitstempel-Snapshot und Zaehler fuer den Sync-Bericht 2026-08-27 12:34:32 +02:00
Hermes (Server) 46ea9ef344 CHANGELOG-Eintrag zurücknehmen (Sammel-Eintrag folgt in Task 13) 2026-08-27 12:29:17 +02:00
Hermes (Server) 8e3c986d13 Einzelne Server-Titel offline nehmen und wieder entfernen 2026-08-27 12:24:56 +02:00
Hermes (Server) fcf53dcb6c Auswahl-Modus: Auf den Server laden (nur in Meine Musik) 2026-08-27 12:14:07 +02:00
Hermes (Server) 78968fd6b8 Gezielter Upload mit Abbrechen aus dem Auswahl-Modus 2026-08-27 11:31:37 +02:00
Hermes (Server) 8d84a88a9c Herz-Tipp meldet den Favoriten sofort deterministisch an die Cloud 2026-08-27 11:23:38 +02:00
Hermes (Server) 5dc80931e2 Favoriten additiv abgleichen statt ersetzen; Upload-Timeout ist Einzelfehler 2026-08-27 10:55:00 +02:00
Hermes (Server) 09d24abf4d Favoriten-GET gehaertet + deterministisches setzeFavorit 2026-08-27 10:47:46 +02:00
Hermes (Server) 6250fb78c1 Merge-Entscheidungen als reine Funktionen (sync_merge.dart) 2026-08-27 10:41:44 +02:00
60 changed files with 2926 additions and 4943 deletions
-111
View File
@@ -1,111 +0,0 @@
# Animations-Serie (HyperOS-inspiriert) — Stand bei Pause
Projekt pausiert am 2026-08-31. Dieses Dokument fasst zusammen, wo die
Animations-Serie steht, damit eine neue Session nahtlos weitermachen kann.
## Herkunft
Ausgangspunkt: Analyse-Auftrag zur Xiaomi-Musik-App/HyperOS-Design-Sprache,
daraus ein Bericht mit 5 Animations-Empfehlungen für Mello, priorisiert nach
Aufwand/Wirkung. Punkte 1+2 sind umgesetzt, Punkt 3 ist im Design fertig
abgestimmt, aber **noch nicht implementiert**. Punkte 4+5 sind offen.
## Status je Punkt
### ✅ Punkt 1 — Staggered List-Enter-Animation (fertig, gemerged-bereit)
`EinblendItem`-Widget (`lib/shared/einblend_item.dart`), eingebunden in
`song_list.dart`/`artist_list.dart`. Fertig, getestet, committet+gepusht auf
Branch `fix/tinker-feedback` (im Worktree `~/mello-dev/app`). Noch nicht in
`main` gemerged.
### ✅ Punkt 2 — Vollbild-Player als Live-Blur-Overlay (fertig, gepusht)
NowPlayingScreen von Navigator-Route zu persistentem Overlay in HomeShell
umgebaut (`PlayerExpansionController`, Live-Blur/Cover-Interpolation während
der Wischgeste). Vollständig umgesetzt (10-Task-Plan + 3 Bugfixes aus dem
finalen Review), 626 Tests grün, `flutter analyze` sauber.
- **Branch:** `feature/blur-oeffnen-transition` (Basis: `fix/tinker-feedback`)
- **Worktree:** `~/mello-dev/worktrees/blur-transition` (dieser hier)
- **Gepusht**, PR noch nicht erstellt/gemerged:
https://git.baka-net.de/dustin/Melo/pulls/new/feature/blur-oeffnen-transition
- **Spec:** `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md`
- **Plan:** `docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md`
- Durchlief ein Adversarial-Review-Panel (Budget-Mode, 3 Reviewer) vor der
Umsetzung — fand einen echten P0 (falsches `AnimatedBuilder.child`-Muster).
Ein zweites Review NACH der Umsetzung, vor dem Push, fand nochmal 3 echte
Bugs (Provider-Scope-Crash bei per `Navigator.push` geöffneten Screens,
RenderFlex-Overflow während der Öffnen-Geste, asymmetrischer
`dragEnd`-Bug) — alle behoben, siehe Commit-History auf dem Branch.
- **Wichtige Lektion für künftige Overlay-artige Provider:** Ein
`ChangeNotifierProvider`, der nur innerhalb eines Screens (nicht oberhalb
des Navigators) bereitgestellt wird, ist für per `Navigator.push` geöffnete
Screens unsichtbar (die sind Geschwister im selben Navigator/Overlay,
keine Nachfahren). `PlayerExpansionController` sitzt deshalb jetzt in
`MeloApp`s `MultiProvider` (`lib/main.dart`), nicht in `HomeShell`.
### 🔜 Punkt 3 — Karaoke-Glow in der Songtext-Ansicht (Design fertig, NICHT umgesetzt)
**Nächster Schritt beim Wiederaufnehmen.** Brainstorming abgeschlossen,
Design mit Dustin abgestimmt und freigegeben — direkt umsetzbar ohne erneute
Rückfrage:
- **Datei:** `lib/player/now_playing_screen.dart`, Klasse `_Mitlaufend`
(aktuell Zeile ~759-810 auf diesem Branch), nur der `AnimatedDefaultTextStyle`
darin (~Zeile 793-800).
- **Änderung:**
- `style:` bekommt zusätzlich `shadows:`, animiert über den bestehenden
`AnimatedDefaultTextStyle`-Mechanismus (kein neuer Controller nötig,
`TextStyle.lerp` interpoliert `shadows` automatisch):
- Inaktive Zeile: `shadows: const []`
- Aktive Zeile: `shadows: [Shadow(color: MeloTheme.red.withValues(alpha: 0.55), blurRadius: 10)]`
- Textfarbe bleibt wie bisher (`Colors.white`/`MeloTheme.text3`) — bewusst
**kein** rot eingefärbter Text (Kontrast/Lesbarkeit auf dunklem Grund),
stattdessen weißer Text mit rotem Glow dahinter ("Spotlight"-Effekt).
Diese Frage wurde Dustin explizit gestellt, Antwort: Glow statt
Rot-Einfärbung.
- `duration:` von `MeloMotion.fast` (120ms) auf `MeloMotion.normal` (220ms)
korrigiert — laut `theme.dart`s eigener Doku ist `fast` für
Touch-Feedback gedacht, ein Zeilenwechsel ist aber ein Inhalts-Wechsel
(`normal`). Passt auch besser zum "fließenden" Karaoke-Gefühl.
- **Testing:** bestehenden Widget-Test für `_Mitlaufend`/`_LyricsSheet`
(falls vorhanden) auf den neuen `shadows`-Wert prüfen; neuer Test bestätigt
aktive Zeile hat `MeloTheme.red`-farbenen Schatten, inaktive `shadows: []`.
- **Workflow ab hier:** TDD-Umsetzung (bounded, kein Plan-Dokument nötig) →
`@code-reviewer`-Skill (`/code-review`) vor dem Commit → committen+pushen
auf `feature/blur-oeffnen-transition` (baut direkt auf Punkt 2 auf, gleiche
Datei).
### ⬜ Punkt 4 — Advanced-Blur-Kopplung am Farbverlauf-Hintergrund (offen)
Aus dem ursprünglichen Bericht: `_CoverGrund`s Farbverlauf-Hintergrund
zusätzlich leicht auf Wisch-Distanz reagieren lassen (Blur-Radius steigt
beim Öffnen/Schließen). Noch nicht brainstormed/geplant. Mittlerer Aufwand,
baut auf Punkt 2s `progress`-Wert auf (`PlayerExpansionController`).
### ⬜ Punkt 5 — Equalizer-Visualizer (offen, niedrige Priorität)
Spektrum-Visualizer in `equalizer_screen.dart`, analog zu Xiaomis
Musik-App. Braucht Audio-Frequenzanalyse (aktuell nicht vorhanden) —
deutlich höherer Aufwand, im ursprünglichen Bericht selbst als
"nice-to-have, niedrige Priorität" eingestuft. Noch nicht brainstormed.
## Wichtige Rahmenbedingungen fürs Wiederaufnehmen
- **Worktree-Disziplin:** `~/mello-dev/app` ist ein GETEILTER Worktree
(andere Session, aktuell "Hermes"/`mello-dev-80`, arbeitet dort an
YouTube-Gast-Zugang o.ä.) — vor jedem `git`-Befehl dort `git status`
prüfen, nicht blind committen. Für neue, eigenständige Punkte (4/5) einen
eigenen Worktree anlegen (`git worktree add -b <branch> ~/mello-dev/worktrees/<name> <basis>`),
wie bei Punkt 2 geschehen.
- **Flutter-Binary:** IMMER `/home/dustin/development/flutter/bin/flutter`
verwenden, NICHT das über PATH auffindbare Snap-`flutter` — letzteres
wirft "Invalid kernel binary format version" (Toolchain-Mismatch mit den
gecachten Native-Hooks).
- **Pläne/fertige Designs gehen an Hermes** (Peer-Session `mello-dev-80`),
nicht direkt an Dustin im Chat — Hermes bespricht mit Dustin und gibt dann
die Freigabe zurück. Ausnahme: reine Werkzeug-/Ziel-Klärungen.
- **Skill-Workflow laut Projekt-CLAUDE.md:** brainstorming → planning (nur
bei Architectural-Einstufung) → agent-review-panel → TDD → Debugging.
Für Punkt 2 wurde ein Budget-Mode-Adversarial-Panel genutzt (3 Reviewer:
Code-Quality-Auditor, Feasibility-Analyst, Devil's Advocate + Synthese
ohne separaten Richter-Agent, da die Befunde stark konvergent/eindeutig
waren) — hat sich gelohnt, fand echte Bugs vor UND nach der Umsetzung.
- **Subagent-Profile aktiv nutzen:** `@melo-specialist` für alle
App-Änderungen (nicht general-purpose).
+47 -229
View File
@@ -5,236 +5,54 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased]
### 🐛 Vollbild-Player Live-Blur-Übergang: 3 Bugfixes aus dem finalen Code-Review (2026-08-29)
### 💾❤️ Sync-Ausbau: Favoriten gehen nicht mehr verloren (2026-08-27)
Vor dem Push ein Review über den gesamten Diff der Blur-Übergang-Serie
(siehe Eintrag darunter) — 3 echte Bugs gefunden und per TDD behoben:
- 🐛 **Player öffnete sich nicht, wenn er per `Navigator.push` erreicht
wurde** (z. B. Song in einer Playlist oder in Server-Album/Künstler-
Ansicht antippen): `PlayerExpansionController` wurde nur INNERHALB von
`HomeShell.build()` bereitgestellt — ein per `Navigator.push` geöffneter
Screen ist im Baum aber ein Geschwister von `HomeShell`, kein Nachfahre,
und sah den Provider deshalb nicht (`ProviderNotFoundException`,
abgefangen zu einer "Wiedergabe fehlgeschlagen"-Snackbar). Fix: `MeloApp`
stellt den Controller jetzt oberhalb von `MaterialApp`/Navigator bereit,
wie alle anderen App-weiten Provider.
- 🐛 **"RenderFlex overflowed"-Fehler während fast jeder Öffnen-Geste**:
Titel/Künstler/Fortschritt/Controls brauchten mehr Höhe, als das noch
kleine, interpolierte Rechteck zwischen Mini- und Vollbild-Player während
der Übergangsphase hatte. Fix: der Inhalt steckt jetzt in einer
`ClipRect`/`OverflowBox`-Kapselung — er behält seine natürliche Größe,
der überschüssige Teil wird während der kurzen, ohnehin noch
durchsichtigen Übergangsphase einfach nicht gemalt statt eine
Fehlermeldung auszulösen. Der Endzustand bei vollständig geöffnetem
Player sieht unverändert aus.
- 🐛 **Player klappte bei einem schwachen Fingerzittern ungewollt zu**: War
der Player bereits vollständig offen und der Finger verrutschte beim
Versuch, einen Knopf zu treffen, leicht nach oben, wählte die
Wisch-Auswertung fälschlich den ÖFFNEN-Zweig (geraten aus dem Vorzeichen
der Zugstrecke statt aus dem tatsächlichen Aufrufer-Kontext) — der klappte
bei einer so schwachen Geste zu, statt gar nichts zu tun. Fix:
`PlayerExpansionController.dragEnd()` bekommt jetzt den Pflichtparameter
`ausVollbild`, der explizit sagt, ob die Geste am Mini-Player (öffnend)
oder am Vollbild-Player (schließend) passiert.
-**Getestet (RED→GREEN je Fix, eigener Commit):**
`test/player/provider_scope_navigator_push_test.dart` (echtes `HomeShell`
+ echter `Navigator.push`, reproduziert die reale
`ProviderNotFoundException`), `test/player/now_playing_screen_overflow_test.dart`
(reproduziert "RenderFlex overflowed by 13 pixels" in einem echten,
interpolierten Zwischenrechteck), neuer Testfall in
`test/player/player_expansion_controller_test.dart`. Volle Suite
(626 Tests) grün, `flutter analyze` ohne Befund.
### ✨ Vollbild-Player: Live-Blur-Übergang statt fester Animation (2026-08-29)
-**Teil 2 der Animations-Serie**: Übergang Mini-Player ↔ Vollbild-Player
hängt jetzt live an der Zugdistanz der Wischgeste (Blur + Cover-Wanderung),
nicht erst nach Abschluss einer festen Animation. Symmetrisch beim Öffnen
und Schließen.
- 🔧 `NowPlayingScreen` ist keine per `Navigator.push` geöffnete Route mehr,
sondern ein persistentes Overlay in `HomeShell` (`PlayerExpansionController`,
`lib/player/player_expansion_controller.dart`). Der bisherige
`Hero`-Cover-Übergang ist durch `WanderndesCover` ersetzt (Rect-Interpolation
statt Navigator-Flight).
-**Getestet (RED→GREEN):** neue Unit-Tests für `PlayerExpansionController`
(`test/player/player_expansion_controller_test.dart`), Integrationstests
gegen ein Test-Double und das echte `HomeShell`
(`test/player/home_shell_expansion_test.dart`,
`test/player/home_shell_real_expansion_test.dart` — Letzterer beweist
Live-Reaktivität ohne fremden Rebuild-Trigger sowie State-Persistenz von
`_CoverGrundState` über mehrere Öffnen/Schließen-Zyklen), Cover-Interpolation
(`test/player/wanderndes_cover_test.dart`). Volle Suite (623 Tests) grün,
`flutter analyze` ohne Befund.
### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29)
-**Teil 1 einer Animations-Serie** (inspiriert von Xiaomis HyperOS-
Design-Sprache — fließende statt abrupte Bewegung): neues Widget
`EinblendItem` (`lib/shared/einblend_item.dart`) blendet ein Listenelement
beim ersten Erscheinen mit Opacity 0→1 und leichtem Versatz (`MeloSpace.sm`
→ 0) ein, Dauer `MeloMotion.normal` (220ms), Kurve `MeloMotion.curve`.
Jedes Element startet um `min(index, 12) × 25ms` versetzt — ab Index 12
ohne zusätzliche Verzögerung, damit lange Listen nicht träge wirken. Läuft
genau einmal je Element-Lebenszeit (nicht bei jedem Rebuild) und
respektiert Reduce-Motion (`MeloMotion.ruhig`): dann erscheint das Element
sofort im Endzustand, ohne Verzögerung oder Bewegung.
- 🔧 Eingebunden in `SongList` (`lib/library/song_list.dart`) und in beide
Listen von `artist_list.dart` (Künstler-Übersicht sowie die Zeilen der
Künstler-Detailseite — Alben, Titel und Zwischenüberschriften). Die
wiederverwendete `SongZeile` selbst bleibt unverändert, damit Suche und
Playlist-Detail (die den Wrapper nicht bekommen sollen) unangetastet
bleiben. `my_music_screen.dart` rendert keine eigene Liste (nur
eingebettete `SortableSongList`/`ArtistListScreen`/`CategoryListScreen`)
— dort war nichts einzubinden.
-**Getestet (RED→GREEN):** neuer Widget-Test
`test/shared/einblend_item_test.dart` prüft Start bei Opacity 0, Ziel bei
1,0 nach `pumpAndSettle()`, sofortigen Endzustand bei
`disableAnimations: true` sowie den Deckel bei Index 12 (Index 50 startet
nicht später als Index 12). Bestehende Tests zu `song_list.dart` liefen
bereits mit `pumpAndSettle()` und blieben unverändert grün. Volle Suite
(604 Tests) grün, `flutter analyze` ohne Befund.
### 🔑 Manuelle Baka-Anmeldung übernahm das neue Passwort nie dauerhaft; Auto-Login-Fehler wurde verworfen (2026-08-29)
- 🐛 **Wer sich nach einer fehlgeschlagenen Auto-Anmeldung manuell im
"Baka-Konto"-Dialog mit dem korrekten (neuen) Passwort anmeldete, bekam
denselben Fehler beim nächsten App-Start (nach Ablauf des 7-Tage-Tokens)
garantiert wieder** — unabhängig vom Server-Zustand. Grund: Navidrome-
und Baka-Konto teilen sich laut Design dieselben Zugangsdaten
(`navidrome_service.dart`), aber der manuelle Anmelde-Dialog
(`_AnmeldeDialogState._anmelden()` in `downloads_screen.dart`) schrieb
das eingegebene Passwort nur ins `BakaAuth`-Token — nie zurück in die
lokale Navidrome-Zugangsdaten-Ablage (`NavidromeService`), aus der
`BakaAuth.autoAnmelden()` sich beim nächsten Start bedient. Der manuelle
Login "half" also nur bis zum nächsten Token-Ablauf.
- 🔧 **Fix:** Nach erfolgreichem manuellem Login persistiert der Dialog die
neuen Zugangsdaten jetzt zusätzlich über
`NavidromeService.speichereZugangsdaten(...)`. Dafür bekam
`NavidromeService` einen fehlenden öffentlichen Getter `serverUrl`.
- 🐛 **`BakaAuth.autoAnmelden()` verwarf den Fehlertext von `anmelden()`
komplett** — aus der App war nicht diagnostizierbar, ob ein Auto-Login
an falschem Passwort, Rate-Limit oder Verbindungsfehler scheiterte.
- 🔧 **Fix:** neues Feld `BakaAuth.letzterAutoLoginFehler` hält den
Fehlertext des letzten Auto-Login-Versuchs fest (zusätzlich per
`debugPrint` geloggt) — die bestehende Race-Condition-Absicherung über
`_laufenderAutoLogin` bleibt unverändert erhalten.
-**Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart`
bestätigt, dass `nav.passwort`/`nav.benutzer` nach erfolgreichem
manuellem Login den neu eingegebenen Wert zeigen; neuer Unit-Test in
`baka_auth_test.dart` bestätigt, dass `letzterAutoLoginFehler` nach
einem fehlgeschlagenen `autoAnmelden()` den Fehlertext trägt statt
`null` zu bleiben. Volle Suite (601 Tests) grün, `flutter analyze` ohne
Befund.
### 🎧 "Zuletzt" zeigte neu Hinzugefügtes statt zuletzt Gehörtem; Herz saß fern vom Songnamen (2026-08-29)
- 🐛 **Der Schnellzugriff "Zuletzt" auf "Meine Musik" zeigte "zuletzt hinzugefügte" Songs,
nicht "zuletzt gehörte"** — von Tinker gemeldet. Sowohl die Karte als auch der
Vollbild-Screen dahinter nutzten `db.watchRecent()`, das nach `dateAddedMs`
(Hinzufüge-Datum) sortiert, nicht nach dem tatsächlichen Abspielen.
- 🔧 **Fix:** neue Methode `MeloDb.watchRecentlyPlayed()` (`database.dart`) wertet
stattdessen die `PlaybackHistory`-Tabelle aus — eine Subquery ermittelt je Song per
`GROUP BY songId` / `MAX(playedAtMs)` nur den jüngsten Play-Zeitpunkt, damit ein
mehrfach gehörter Song nur einmal erscheint (an der Position seines letzten
Abspielens), zurückverknüpft auf `songs` (ohne getombstonte). "Zuletzt" in
`my_music_screen.dart` (Karte + Vollbild-Screen, `RecentlyAddedScreen` dafür in
`RecentlyPlayedScreen` umbenannt) nutzt jetzt diese Methode.
- 🐛 **Das Favoriten-Herz im Vollbild-Player saß oben in der AppBar**, weit weg vom
Songnamen — ebenfalls von Tinker gemeldet.
- 🔧 **Fix:** `_Herz` aus der AppBar-`actions`-Row entfernt und stattdessen direkt
neben den Titel in `_Angaben` gesetzt (`now_playing_screen.dart`).
-**Getestet:** 3 neue Unit-Tests für `watchRecentlyPlayed()` (RED→GREEN:
Mehrfach-Play erscheint nur einmal an der neuesten Position, mehrere Songs
absteigend sortiert, getombstonter Song mit Historie fehlt) sowie ein neuer
Widget-Test, der bestätigt, dass das Herz nicht mehr in der AppBar, sondern in
derselben Row wie der Songtitel sitzt. `flutter analyze` ohne Befund.
### 🔑 Auto-Anmeldung schlug beim App-Start fehl — betraf ALLE drei Nutzer (2026-08-29)
- 🐛 **Die automatische Baka-Anmeldung klappte beim App-Start oft nicht**
die App fragte dann nach dem "Baka-Konto", obwohl die Zugangsdaten
korrekt waren. Betraf **alle drei Nutzer** (Dustin, Baka, Tinker), nicht
nur einen — von Dustin selbst gemeldet und bei allen drei bestätigt.
Grund: `main.dart` baut die 5 Haupt-Tabs über `IndexedStack` alle sofort
beim ersten Frame, nicht erst beim Umschalten. Der Download-Tab und die
YT-Suche lösen dabei beide unabhängig voneinander denselben Auto-Login
aus (`pruefeServerUser()``BakaAuth.autoAnmelden()`) — praktisch
gleichzeitig, mit denselben Zugangsdaten. `autoAnmelden()` hatte keine
Schutzvorkehrung gegen so einen doppelten Aufruf (der `istAngemeldet`-
Check lief synchron, bevor die erste Anfrage zurückkam), also gingen
zwei fast-gleichzeitige Login-Anfragen an denselben Server raus — der
lehnte vermutlich eine davon ab (Session-/Lock-Konflikt).
- 🔧 **Fix:** `BakaAuth.autoAnmelden()` in `baka_auth.dart` hängt einen
überlappenden Aufruf jetzt an den bereits laufenden Login-Versuch an
(`_laufenderAutoLogin`), statt einen zweiten Request loszuschicken.
- 🔧 **Nebenbei zwei kleine Anmelde-Dialoge korrigiert:** Der Anmelde-
Dialog im Download-Tab (`downloads_screen.dart`) zeigte das
Benutzername-Feld immer mit "Baka" vorausgefüllt, egal wer die App
nutzt — zeigt jetzt den tatsächlich bekannten Navidrome-Benutzernamen
(leer, falls keiner bekannt). Der Navidrome-Dialog in den Einstellungen
(`settings_screen.dart`) wählte unter Baka/Tinker/Dustin immer "Baka"
vor — wählt jetzt den zuletzt bekannten Benutzer vor, falls bekannt.
-**Getestet:** neuer Unit-Test für `autoAnmelden()` (RED→GREEN, zwei
gleichzeitige Aufrufe lösen nur noch einen HTTP-Request aus statt
zwei) sowie ein neuer Widget-Test für die Benutzername-Vorbelegung im
Download-Tab-Dialog (RED→GREEN). Volle Suite grün, `flutter analyze`
ohne Befund.
### ⬅️ Zurück-Geste auf Nicht-Start-Tabs minimierte die App (2026-08-27)
- 🐛 **Auf "Favoriten" (oder einem anderen Tab außer "Meine Musik") tippen und
dann die System-Zurück-Geste nutzen minimierte die App**, statt zur
vorherigen Ansicht zu navigieren. Grund: Tab-Wechsel in `HomeShell`
laufen nur über `setState` (`IndexedStack` + `BottomNavigationBar`), nie
über `Navigator.push` — es lag also nie eine Route auf dem
Navigator-Stack, die eine Zurück-Geste hätte poppen können.
- 🔧 **Fix:** `build()` in `main.dart` in ein `PopScope` gewickelt
(`canPop: _index == 0`), das bei einem Nicht-Start-Tab zuerst zu Tab 0
("Meine Musik") zurückspringt, statt die App zu verlassen.
-**Getestet:** neuer Widget-Test in `home_shell_test.dart` (RED→GREEN,
simuliert die System-Zurück-Geste über `WidgetsApp.didPopRoute()`), volle
Suite (593 Tests) grün, `flutter analyze` ohne Befund.
### 🔄 Doppelten Scan-Button auf Android entfernt (2026-08-27)
- 🐛 **In den Einstellungen gab es auf Android zwei Buttons für dieselbe
Aktion** ("Musik scannen" und "Erneut scannen") — beide riefen dort
denselben geräteweiten MediaStore-Scan auf (`LibraryService.
pickFolderAndScan()` und `.rescan()` sind auf Android identisch, da
Scoped Storage keine Ordnerwahl erlaubt). Nur auf Desktop unterscheiden
sie sich echt (Ordner hinzufügen vs. bekannte Ordner erneut scannen).
- 🔧 **Fix:** Der zweite `ListTile` ("Erneut scannen") wird in
`settings_screen.dart` nur noch angezeigt, wenn `zeigeErneutScannenButton
(Platform.isAndroid)` das zulässt. Die Sichtbarkeits-Entscheidung ist als
pure Funktion in `library_service.dart` ausgelagert (statt direkt
`Platform.isAndroid` in der UI abzufragen), weil sich die echte Plattform
in Flutter-Tests nicht sauber auf "Android" umschalten lässt.
-**Getestet:** 2 neue Unit-Tests für `zeigeErneutScannenButton` (RED→
GREEN), volle Suite (592 Tests) grün, `flutter analyze` ohne Befund.
### 🎧 Song antippen öffnet den Vollbild-Player (2026-08-27)
- 🐛 **Ein Song antippen öffnete früher nur die Wiedergabe, ohne zum
Vollbild-Player mit großem Cover zu wechseln** — man landete weiter auf
der Bibliotheks-/Playlist-/Server-Liste und musste erst den Mini-Player
antippen oder hochwischen, um das große Cover zu sehen. Behoben an den
drei Stellen, an denen ein Song EXPLIZIT zum Abspielen angetippt wird:
Bibliothek (`song_list.dart`), Wiedergabeliste
(`playlist_detail_screen.dart`) und Server-Titel
(`server_titel_screen.dart`, dort auch für die Knöpfe "Abspielen" und
"Zufällig", weil sie über dieselbe zentrale `_spiele`-Methode laufen).
Nutzt dasselbe, im Projekt etablierte Navigationsmuster wie der
Mini-Player (`Navigator.push(MaterialPageRoute(builder: (_) =>
NowPlayingScreen()))`), nach erfolgreicher Wiedergabe und vor einer
eventuellen Offline-Snackbar, damit die Meldung auf dem neuen Screen
sichtbar bleibt.
- ↩️ **Bewusst unverändert:** `queue_screen.dart` (Song innerhalb der
bereits offenen Warteschlange antippen) — dort ist man schon im
Player-Kontext, ein erneutes Aufspringen des Vollbild-Players bei jedem
Sprung wäre aufdringlich.
-**Getestet:** 3 neue Widget-Tests (RED→GREEN, je einer pro Stelle),
volle Suite (590 Tests) grün, `flutter analyze` ohne Befund.
- ❤️ **Der Favoriten-Datenverlust ist behoben.** Bisher hat jedes Gerät beim
Abgleich seine eigene Favoritenliste als Komplett-Ersatz zum Server
geschickt — ein frisch installiertes Handy löschte damit beim allerersten
Abgleich sämtliche Server-Favoriten. Neu wird nur noch **hinzugefügt**:
Was hier Favorit ist und dort fehlt, wird einzeln gemeldet; was dort
Favorit ist und hier fehlt, wird hier gesetzt. Entfernt wird in keiner
Richtung etwas. Der alte Weg (`POST /favorites`) existiert im Code nicht
mehr — der Fehler kann also nicht zurückkommen.
- 💔 **Bewusster Preis:** Ein entferntes Herz wirkt sofort auf diesem Gerät
und (online) auch am Server, ist aber **nicht geräteübergreifend
garantiert**: Hält ein zweites Gerät den Favoriten noch, bringt dessen
nächster Abgleich ihn zurück. Kein Datenverlust ist uns wichtiger als
verlässliches Löschen.
-**Herz antippen meldet sofort.** Wer online ein Herz setzt oder entfernt,
schickt den Wunsch direkt zum Server — eindeutig als „setze auf ja/nein",
nicht als Umschalten. Geht das schief, passiert nichts Schlimmes: der
nächste Abgleich holt es nach.
- 🛡️ **Der Server darf sich nicht mehr missverständlich ausdrücken.** Kommt
auf die Favoriten-Abfrage eine Antwort ohne Favoritenliste, gilt das jetzt
als Fehler und die Favoriten-Runde wird übersprungen — vorher wurde daraus
stillschweigend „keine Favoriten".
- ☁️ **Neu: „Auf den Server laden".** Im Auswahl-Modus von „Meine Musik"
(langes Drücken) lassen sich einzelne Titel markieren und gezielt
hochladen. Titel, die schon oben sind, werden übersprungen; einzelne
Fehlschläge stoppen den Rest nicht. Die Aktion erscheint bewusst nur dort
und nicht bei Favoriten, Wiedergabelisten oder Titellisten.
- ⬇️ **Neu: einzelne Server-Titel offline nehmen.** In der Album- und
Künstler-Ansicht hat jede Zeile jetzt einen Knopf — bisher ging nur „ganzes
Album". Schon geladene Titel lassen sich dort auch wieder entfernen.
*Bekannte Einschränkung:* Ein gerade gehörter Titel liegt danach kurzzeitig
doppelt (Zwischenspeicher + Download), bis der Zwischenspeicher aufräumt.
- 🗂️ **Playlisten werden einseitig am Server gesichert** (kein Rück-Merge,
Umbenennungen propagieren nicht).
- 📰 **Neu: „Willkommen zurück".** War der letzte **erfolgreiche** Abgleich
mehr als 24 Stunden her, zeigt die App danach einmalig, was dazugekommen
und was verschwunden ist. Beim allerersten Start nach einer Neuinstallation
erscheint er absichtlich **nicht**.
- 🔧 **Unter der Haube:** Der Abgleichs-Zeitstempel wird jetzt **vor** dem
Abfragen der Serverliste genommen (sonst fallen Änderungen während des Laufs
durchs Raster); ein hängender Upload (Zeitüberschreitung) reißt nicht mehr
den ganzen Abgleich ab; ausgefallene Teilschritte verschieben die
24-Stunden-Uhr des Berichts nicht mehr.
- 🧪 Neue Tests für die Merge-Regeln, den additiven Abgleich, den Sofort-Push,
den Auswahl-Upload samt Abbrechen und den Bericht. Zwei bestehende Tests
wurden bewusst umgeschrieben, weil sie das alte (fehlerhafte) Verhalten
festschrieben.
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
-88
View File
@@ -1,88 +0,0 @@
# Sync-Ausbau — Kontext-Sicherung (2026-08-27)
Diese Datei ist eine Übergabe für eine neue Session. Sie ersetzt nicht die
Spec/den Plan, sondern sagt: wo stehen wir, was ist als Nächstes dran.
**Wenn diese Datei erledigt ist (Implementierung fertig oder Plan überholt):
löschen, nicht liegen lassen — sie ist kein Dauerdokument.**
## Die zwei maßgeblichen Dokumente
1. **Spec:** `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md`
Status: **Freigegeben, umsetzungsbereit.** Durch ein 5-Reviewer-Panel
(agent-review-panel) geprüft, nachgeschärft, gegengelesen.
2. **Implementierungsplan:** `docs/superpowers/plans/2026-08-27-sync-ausbau.md`
13 Tasks nach TDD (echter Code in jedem Schritt, kein Platzhalter),
geprüft und korrigiert.
Beide sind committed und gepusht auf Branch `fix/p0-vollwertigkeit`
(bis Commit `1940e2b7909`).
## Was das Feature ist (kurz)
Sync-Ausbau der Melo-App: Favoriten-Merge-Fix (Datenverlust-Bug),
gezielter Song-Upload im Auswahl-Modus, Einzel-Song-Offline,
Playlist-Sicherung (einseitig), „Was ist neu"-Bericht. SSE, persistente
Notification und beidseitiger Playlist-Merge sind bewusst spätere Stufen,
nicht Teil dieser Runde.
## Aktueller Stand — was schon passiert ist
1. Bestandsaufnahme (6 parallele Leser über App/Server/Navidrome/Cross-Platform/Melo-v2).
2. Brainstorming + Spec geschrieben, mit Dustin abgestimmt.
3. **agent-review-panel** (5 Reviewer, 2 Debattenrunden, Audit, Verifikation,
Richterurteil): Score 6/10, Verdikt „nachschärfen dann freigeben". Bericht:
`docs/reviews/2026-08-27-sync-ausbau/review_panel_report.md`.
4. Spec nach den 17 Aktionspunkten des Urteils überarbeitet, gegengelesen,
freigegeben.
5. Implementierungsplan geschrieben, geprüft (fand 3 echte
Server-Vertragsfehler in Task 11 — korrigiert), Korrekturrunde committed.
6. **Der einzige echte Blocker ist behoben:** Ein irreversibler Löschpfad in
`melo_cloud.py` (verschwindet eine lokale Datei, wurde sie serverseitig
endgültig gelöscht, erneutes Hochladen reparierte es nicht). Hermes hat
den Fix eingebaut (Datei-Wiederherstellung im Dedup-Zweig von `upload()`),
ich habe ihn end-to-end getestet (eigene Testdatei, Fake-Nutzer, danach
restlos aufgeräumt — Registry vor/nach Test bei 325 Titeln): Upload →
Löschung (Bug reproduziert, Download 404) → erneuter Upload → **beide
Dateien bitgenau wiederhergestellt.** Spec und Plan sind entsprechend
aktualisiert (§ERLEDIGT in der Spec, ENTBLOCKT-Marker im Plan).
## Nächster Schritt
**Implementierungsplan, Task 1: „Reine Merge-Funktionen (`sync_merge.dart`)“**
(Zeile ~104 im Plan). Tasks 19 sind vollständig entblockt und können sofort
starten, in der im Plan festgelegten Reihenfolge (Favoriten-Fix zuerst).
Empfohlener Einstieg für die neue Session: die Skill
`superpowers:subagent-driven-development` (Pflicht-Sub-Skill laut Plankopf)
für die task-weise Umsetzung nutzen, wie schon beim vorherigen Feature
(YouTube-Gast-Zugang) erfolgreich gemacht.
## Was noch offen ist (kein Blocker, aber zu klären)
- **Tasks 1012 (Playlist-Sicherung, Feature 2):** bauen oder streichen?
Heute 0 Playlisten auf dem Server. Plan empfiehlt: vertagen, bis eine
echte Playlist existiert. Steht im Plan unter „Offene Punkte, die vor
bzw. während der Umsetzung an Dustin gehen".
- **Server-Hygiene (an Hermes, eilt nicht):** `_link_user()` im Dedup-Zweig
von `upload()` legt einen dritten Hardlink unter `users/<user>/<sid>.mp3`
an, den `_entferne_datei_wenn_verwaist()` beim Löschen nicht mit abräumt.
Bisher folgenlos, aber seit dem Wiederherstellungs-Fix hinterlässt jeder
Dedup-Upload einen verwaisten Hardlink.
- **App-Seite, bewusst unangetastet:** `markMissing` tombstoned großzügig,
die Lösch-Bremse greift erst ab 109 von 325 Titeln. Der Schaden ist jetzt
reparabel (siehe oben), also kein Blocker mehr — ob die App trotzdem
vorsichtiger werden soll, ist eine eigene, spätere Entscheidung.
## Regeln, die für die neue Session gelten (aus Erfahrung dieser Session)
- Worktree-Lock beachten: `.claude-worktree-lock` im Repo-Root prüfen/setzen/
entfernen (siehe `melo-app-workflow`-Skill).
- `flutter test` im Hintergrund laufen lassen (600+ Tests, mehrere Minuten,
kein kurzer Foreground-Timeout).
- Vor jedem Commit: Qualitäts-Gate (analyze + test) läuft automatisch als
Pre-Commit-Hook — das ist schon eingerichtet, nichts zu tun.
- CHANGELOG.md nach jeder Aufgabe aktualisieren (Projektregel).
- Bei fertigem Arbeitspaket: melden und stoppen, nicht autonom weitermachen
(Dustin startet dann bewusst eine frische Session — siehe
Memory `feedback_melden-und-stoppen`).
File diff suppressed because it is too large Load Diff
@@ -1,224 +0,0 @@
# Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang
Status: Approved (Dustin, 2026-08-29) — bereit für Implementierungsplan.
## Kontext
Punkt 2 aus dem Animations-Bericht zu Xiaomis HyperOS-Design-Sprache
(fließende, physikalisch wirkende Bewegung statt starrer, linearer
Animation): Der Übergang vom Mini-Player zum Vollbild-Player
(`NowPlayingScreen`) soll sich beim Öffnen/Schließen per Wischgeste
reaktiv anfühlen — der Hintergrund soll live mit der Zugdistanz des
Fingers verschwimmen (Blur), nicht erst nach Abschluss einer festen
Animation.
Heute (`lib/player/mini_player.dart`, `lib/player/now_playing_screen.dart`,
`lib/player/wischen.dart`) ist die Wischgeste rein **entscheidungsbasiert**:
Der Mini-Player folgt beim Ziehen sichtbar dem Finger (`_versatz` +
`AnimatedContainer`/`Matrix4.translationValues`), aber die eigentliche
Navigation passiert erst nach `onVerticalDragEnd` als normaler
`Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))`.
Während des Ziehens existiert `NowPlayingScreen` im Widget-Baum noch gar
nicht — es gibt keine live-Verbindung zwischen Zugdistanz und einer
Bildschirm-Transition. Dieselbe Route wird an drei weiteren, gestenlosen
Stellen per einfachem Tap gepusht: `lib/library/song_list.dart`,
`lib/playlists/playlist_detail_screen.dart`,
`lib/downloads/server_titel_screen.dart`.
Zwei Architektur-Ansätze wurden im Brainstorming verglichen (interaktive
`PageRoute` mit geteiltem Fortschritts-Notifier vs. persistentes Overlay).
Dustin hat sich für das **persistente Overlay** entschieden (Ansatz 2) —
näher an der Bauweise nativer Musik-Apps (Spotify/Apple Music), auch wenn
der Eingriff größer ist als bei der interaktiven-Route-Variante.
## Ziel
`NowPlayingScreen` wird Teil der dauerhaften App-Hülle (`HomeShell`) statt
einer über den Navigator gepushten Route. Ein geteilter Fortschritts-Wert
(0.0 = eingeklappt/Mini-Player, 1.0 = Vollbild) steuert live:
- die Größe/Position des Vollbild-Inhalts (Interpolation Mini-Player-Rechteck
→ Vollbild)
- die Blur-Intensität des darunterliegenden Tab-Inhalts
- das Ein-/Ausblenden des Mini-Players
Sowohl das Öffnen (Ziehen am Mini-Player, Tap an allen 4 bisherigen
Öffnen-Stellen) als auch das Schließen (Ziehen im Vollbild-Player nach
unten) sollen sich darüber gleich, live und symmetrisch anfühlen.
## Nicht-Ziele
- Keine Änderung an der Wiedergabe-Logik selbst (`playSongs`, `audio_handler.dart`).
- Keine Änderung an internen `Navigator.push`-Aufrufen INNERHALB von
`NowPlayingScreen` (Warteschlange, Songtext-Sheet) — die bleiben echte
Routen/Modals über demselben Navigator.
- Keine Änderung an `wischen.dart`s bestehender Entscheidungslogik
(`oeffnetVollbildMitStrecke`, `titelWechselMitStrecke`,
`wischSchwelle`/`wischStreckeSchwelle`) — die wird unverändert
wiederverwendet, nur um einen rein optischen `progress`-Wert ergänzt.
- Keine Änderung an Punkt 1 (`EinblendItem`, bereits umgesetzt/gemergt).
- Punkte 35 des Animations-Berichts (Karaoke-Highlight, Advanced-Blur-
Kopplung am Farbverlauf, Equalizer-Visualizer) sind nicht Teil dieses
Specs.
## Entscheidungen (aus dem Brainstorming, mit Dustin abgestimmt)
| Frage | Entscheidung |
|---|---|
| Architektur-Ansatz | Persistentes Overlay in `HomeShell` statt interaktiver `PageRoute` |
| Tap-Only-Stellen (song_list.dart, playlist_detail_screen.dart, server_titel_screen.dart) | Bekommen denselben Übergang (automatischer `progress` 0→1), nicht nur die Mini-Player-Geste |
| Schließgeste im Vollbild-Player | Wird symmetrisch umgebaut — live am Finger, nicht nur Entscheidung bei Loslassen |
| Cover-Übergang (bisher `Hero`) | Wird von Hand nachgebaut (Rect-Interpolation über `progress`), da `Hero` nur bei echten Navigator-Transitions feuert |
| Android-Zurück-Taste | `PopScope` in `HomeShell`: bei `progress > 0` schließt Zurück den Player statt die Route/App zu verlassen — notwendiger Teil des Designs, keine Alternative |
## Architektur
### Neue Komponente: `PlayerExpansionController`
Datei: `lib/player/player_expansion_controller.dart`. `ChangeNotifier`,
lebt in `HomeShell`s `State` (die `TickerProviderStateMixin` bekommt, um
als `vsync` für einen internen `AnimationController` zu dienen), bereit-
gestellt per `ChangeNotifierProvider.value` oberhalb von `IndexedStack`,
`MiniPlayer` und `NowPlayingScreen`.
Zustand:
- `double progress` — 0.0…1.0, rein optisch, öffentlich lesbar.
Methoden:
- `dragBy(double dy)` — während des Ziehens aufgerufen (Mini-Player beim
Öffnen, Vollbild-Player beim Schließen, jeweils mit passendem Vorzeichen).
Bildet `progress = (aufsummierte Zugstrecke / referenzHoehe).clamp(0, 1)`.
`referenzHoehe` ist ein fester Wert (300px) — bewusst **entkoppelt** von
der Entscheidungs-Schwelle in `wischen.dart`; er bestimmt nur, wie "weit"
sich der optische Übergang bei einer bestimmten Zugstrecke anfühlt.
- `dragEnd(double geschwindigkeit, double strecke)` — ruft unverändert
`oeffnetVollbildMitStrecke`/die Schließ-Variante auf, um zu entscheiden,
ob `progress` zu 1.0 oder 0.0 animiert wird (`AnimationController.fling`
mit der Loslass-Geschwindigkeit als Startimpuls, danach `MeloMotion.curve`
bis zum jeweiligen Ziel).
- `open()` — animiert `progress` 0→1 über `MeloMotion.ruhig(context,
MeloMotion.normal)` (Tap-Auslöser, kein Ziehen beteiligt).
- `close()` — spiegelbildlich 1→0 (z.B. Zurück-Taste, `X`-artiger Schließen-
Button falls später gewünscht).
- Reduce-Motion: alle Übergänge über `MeloMotion.ruhig` — bei deaktivierten
Animationen springt `progress` sofort auf das Ziel.
### Widget-Baum in `HomeShell`
Heute: `Scaffold` mit `body`-Bereich, der `IndexedStack` (5 Tabs) + darunter
`MiniPlayer` + `BottomNavigationBar` anordnet. Neu: der `body`-Bereich wird
ein `Stack`, von unten nach oben:
1. `IndexedStack` (Tabs) + `BottomNavigationBar` — unverändert
2. `AnimatedBuilder` auf den Controller: nur wenn `progress > 0`, ein
`BackdropFilter(filter: ImageFilter.blur(sigmaX: progress * maxSigma,
sigmaY: progress * maxSigma))` über Punkt 1 — Widget wird bei
`progress == 0` komplett aus dem Baum entfernt (kein Performance-Overhead
im Ruhezustand). `maxSigma = 20.0` als Startwert (spürbarer Weichzeichner,
ohne die Tab-Umrisse völlig zu verlieren) — im Review/auf echtem Gerät
nachjustierbar, siehe „Offene Risiken“.
3. `MiniPlayer` — `Opacity(opacity: 1 - progress)`, bei `progress == 1`
ebenfalls aus dem Baum entfernt (`IgnorePointer`/Kein Hit-Testing mehr,
sonst blockiert eine unsichtbare Leiste Gesten im Vollbild-Player)
4. `NowPlayingScreen`-Inhalt — Größe/Position per `Rect.lerp` zwischen dem
Mini-Player-Rechteck (volle Breite, `MiniPlayer.hoehe` + Fortschrittsbalken
hoch, am unteren Rand über der `BottomNavigationBar`) und dem
Vollbild-Rechteck; Detail-Inhalt (Titel, Steuerung, Songtext-Icon etc.)
blendet erst ab `progress > 0.3` ein (Crossfade), darunter ist nur das
wandernde Cover sichtbar — vermeidet, dass die volle Player-UI in eine
72px hohe Box gequetscht wird
### Cover-Übergang ohne `Hero`
`_Cover`/`Hero(tag: coverHeldenName, ...)` entfällt an beiden Stellen.
Stattdessen: eine neue `_WanderndesCover`-Komponente, die ihr Rechteck
(Größe + Position) direkt aus `progress` berechnet (`Rect.lerp` zwischen
Mini- und Vollbild-Cover-Rect) und denselben `CoverImage`-Widget-Typ mit
interpolierendem `radius` (6 → 16) rendert. Lebt als Teil der `NowPlayingScreen`-
Inhalts-Ebene (Punkt 4 oben) — der `MiniPlayer` selbst zeigt kein eigenes
Cover mehr, sobald `progress > 0`, um Doppel-Rendering zu vermeiden.
### Migration der 4 Öffnen-Stellen
- `lib/library/song_list.dart` (`SongZeile._zeile.onTap`),
`lib/playlists/playlist_detail_screen.dart`,
`lib/downloads/server_titel_screen.dart`: `Navigator.push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen()))` wird durch
`context.read<PlayerExpansionController>().open()` ersetzt — nur im
Erfolgspfad nach `playSongs(...)`, Fehlerpfad/Snackbar unverändert. Der
`NowPlayingScreen`-Import entfällt an diesen 3 Stellen vollständig.
- `lib/player/mini_player.dart`: `_oeffne()` ruft `.open()` statt zu pushen;
`onVerticalDragUpdate`/`onVerticalDragEnd` rufen `.dragBy()`/`.dragEnd()`
statt lokal `_versatz` zu verwalten (die bisherige rein visuelle
Mitzieh-Logik der Leiste geht im gemeinsamen `progress`-Wert auf).
### Zurück-Taste (Android)
`HomeShell` bekommt ein `PopScope(canPop: controller.progress == 0,
onPopInvokedWithResult: (did, _) { if (!did) controller.close(); })` (oder
Äquivalent je nach Flutter-Version im Projekt) um den `Scaffold`/`Stack`.
Bei offenem oder halb gezogenem Player schließt Zurück den Player; erst bei
`progress == 0` verhält sich Zurück wie heute (App verlassen/vorherige
Route).
## Verhaltensänderung (positiv, aber erwähnenswert)
`_CoverGrundState` (Farbverlauf-Hintergrund aus dem Cover) läuft heute bei
jedem Öffnen neu, weil jede Route ein frischer State ist. Als dauerhaftes
Overlay bleibt der State über mehrere Öffnen/Schließen-Zyklen hinweg
erhalten — die Cover-Farbe ist beim erneuten Öffnen sofort da, kein
wiederholtes Bild-Dekodieren pro Öffnen. Reine Verbesserung, aber ein
beobachtbarer Unterschied gegenüber heute.
## Testing
- **Unit** (`test/player/player_expansion_controller_test.dart`):
`dragBy`/`dragEnd`-Übergänge, Clamping auf [0,1], Reduce-Motion-Sofortsprung,
`open()`/`close()`-Zielwerte. Entscheidungslogik selbst bleibt in
`wischen.dart` und wird dort NICHT erneut getestet (unverändert).
- **Widget** (`test/player/home_shell_expansion_test.dart` o.ä.):
- Ziehen am Mini-Player erhöht `progress`, Blur-Layer erscheint ab
`progress > 0`
- Tap aus `song_list.dart` (bzw. Stellvertreter-Widget im Test) ruft
`.open()` auf, kein `Navigator.push` mehr
- Zurück-Taste bei `progress > 0` schließt den Player statt zu poppen/die
App zu verlassen
- Ziehen nach unten im Vollbild-Player reduziert `progress` symmetrisch
bis zum Schließen
- **Cover-Interpolation**: Test, dass `_WanderndesCover` bei
`progress = 0 / 0.5 / 1` die erwarteten Rect/Radius-Werte liefert
(`Rect.lerp`-Ergebnis direkt prüfbar, kein Golden-Test nötig)
## Offene Risiken
- `BackdropFilter` ist in Flutter performance-sensibel (GPU-Kosten pro
Frame) — bewusst nur im Baum, wenn `progress > 0`, um Idle-Kosten zu
vermeiden; bei sehr low-end Geräten ggf. später Sigma-Obergrenze
reduzieren, falls sich das Ziehen ruckelig anfühlt (kein Blocker für die
erste Umsetzung, aber im Review beobachten).
- `PlayerExpansionController` muss den `mounted`-Zustand von `HomeShell`
respektieren (kein `notifyListeners()`/Controller-Zugriff nach `dispose()`)
— analog zum bestehenden Muster in `_CoverGrundState`.
## Nachtrag: Review-Panel (2026-08-29, vor Umsetzung)
Ein Budget-Adversarial-Panel (3 Reviewer + Synthese) hat den Implementierungsplan
gegen diese Spec geprüft und dabei Lücken gefunden, die der Plan direkt behebt
(Details im Implementierungsplan-Dokument, nicht hier wiederholt):
- Hit-Test- und Screenreader-Isolation zum Hintergrund waren in der ersten
Planfassung nicht vorgesehen — jetzt Teil von Task 4 (`IgnorePointer`/
`ExcludeSemantics` auf Tab-Inhalt und `BottomNavigationBar` bei `progress > 0`).
- Der Wegfall der bisher über `Scaffold`/`AppBar` impliziten Status-Icon-
Helligkeit war nicht bedacht — jetzt Teil von Task 5 (`AnnotatedRegion`).
- Ohne `extendBody: true` hätte das "Vollbild"-Rechteck nicht zur tatsächlichen
`Scaffold.body`-Höhe gepasst — jetzt Teil von Task 4.
- Erwogene, aber verworfene Alternative: ein nur *während der Geste*
eingeblendeter `OverlayEntry` (Blur + Cover-Vorschau) direkt in
`mini_player.dart`, der bei Erreichen der Öffnen-Schwelle in einen normalen
`Navigator.push` mit unverändertem, Hero-basiertem `NowPlayingScreen`
übergeht — deutlich weniger Code, behält Route-Semantik (Accessibility-
Grenze, einfacheres Zurück-Verhalten) vollständig. Nicht gewählt, weil
Dustin sich für die Overlay-Variante (näher an Spotify/Apple Music, siehe
oben) entschieden hat — hier dokumentiert, damit die Abwägung nachvollziehbar
bleibt, falls der Wartungsaufwand des Overlay-Ansatzes sich später als zu
hoch erweist.
+3 -16
View File
@@ -679,10 +679,9 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
}
Future<void> _anmelden() async {
final nav = context.read<DownloadService>().navidrome;
await showDialog(
context: context,
builder: (_) => _AnmeldeDialog(benutzer: nav.benutzer),
builder: (_) => const _AnmeldeDialog(),
);
}
@@ -870,18 +869,14 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
/// Fragt Benutzername und Passwort ab und gibt beides zurück.
class _AnmeldeDialog extends StatefulWidget {
const _AnmeldeDialog({this.benutzer = ''});
/// Bereits bekannter Navidrome-Benutzername (leer, falls keiner bekannt) —
/// vorausgefüllt statt immer "Baka" zu zeigen, egal wer die App nutzt.
final String benutzer;
const _AnmeldeDialog();
@override
State<_AnmeldeDialog> createState() => _AnmeldeDialogState();
}
class _AnmeldeDialogState extends State<_AnmeldeDialog> {
late final _benutzer = TextEditingController(text: widget.benutzer);
final _benutzer = TextEditingController(text: 'Baka');
final _passwort = TextEditingController();
bool _sichtbar = false;
bool _verbindet = false;
@@ -903,14 +898,6 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
if (!mounted) return;
if (fehler == null) {
// Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe
// navidrome_service.dart) — ohne diese Übernahme würde der nächste
// Auto-Login nach Ablauf des Baka-Tokens wieder mit dem alten
// Passwort scheitern.
final nav = context.read<DownloadService>().navidrome;
await nav.speichereZugangsdaten(
nav.serverUrl, _benutzer.text.trim(), _passwort.text);
if (!mounted) return;
Navigator.pop(context);
return;
}
+60 -8
View File
@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../services/download_service.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -70,6 +70,45 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
}
}
/// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet
/// — hier wird er nachgezogen.
Future<void> _aktualisiereGeladen() async {
final geladen = await context.read<MeloDb>().downloadIds();
if (mounted) setState(() => _geladen = geladen);
}
Future<void> _offlineNehmen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
if (dienst.laeuft) {
messenger.showSnackBar(
const SnackBar(content: Text('Es läuft schon ein Download')),
);
return;
}
final neu = await dienst.ladeEinzelnenTitel(song);
await _aktualisiereGeladen();
if (!mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(neu
? 'Offline: ${song.titel}'
: (dienst.fehler ?? 'War schon heruntergeladen')),
));
}
Future<void> _offlineEntfernen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
final messenger = ScaffoldMessenger.of(context);
final weg = await dienst.entferne(song.id);
await _aktualisiereGeladen();
if (!mounted) return;
messenger.showSnackBar(SnackBar(
content: Text(weg
? 'Vom Gerät entfernt: ${song.titel}'
: (dienst.fehler ?? 'Ließ sich nicht entfernen')),
));
}
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
@@ -78,8 +117,6 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
];
try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (!mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
@@ -133,6 +170,8 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
nummer: i,
geladen: _geladen.contains(song.id),
onTap: () => _spiele(songs, i - 1),
onOffline: () => _offlineNehmen(song),
onEntfernen: () => _offlineEntfernen(song),
);
},
),
@@ -233,12 +272,16 @@ class _Zeile extends StatelessWidget {
required this.nummer,
required this.geladen,
required this.onTap,
required this.onOffline,
required this.onEntfernen,
});
final SubsonicSong song;
final int nummer;
final bool geladen;
final VoidCallback onTap;
final VoidCallback onOffline;
final VoidCallback onEntfernen;
static String _dauer(int sekunden) {
final m = (sekunden ~/ 60).toString();
@@ -289,11 +332,20 @@ class _Zeile extends StatelessWidget {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (geladen)
const Padding(
padding: EdgeInsets.only(right: MeloSpace.sm),
child: Icon(Icons.download_done,
size: 16, color: MeloTheme.text3),
// Bisher gab es den Lade-Knopf nur je Album — einen einzelnen
// Titel mitzunehmen ging gar nicht.
geladen
? IconButton(
tooltip: 'Vom Gerät entfernen',
icon: const Icon(Icons.download_done,
size: 20, color: MeloTheme.red),
onPressed: onEntfernen,
)
: IconButton(
tooltip: 'Offline nehmen',
icon: const Icon(Icons.download_outlined,
size: 20, color: MeloTheme.text3),
onPressed: onOffline,
),
Text(_dauer(song.dauerSekunden),
style: const TextStyle(color: MeloTheme.text3)),
+2 -12
View File
@@ -5,7 +5,6 @@ import '../settings/app_settings.dart';
import '../shared/auswahl.dart';
import '../shared/auswahl_leiste.dart';
import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
@@ -45,10 +44,7 @@ class _ArtistListScreenState extends State<ArtistListScreen> {
itemCount: artists.length,
itemBuilder: (context, i) {
final artist = artists[i];
return EinblendItem(
index: i,
child: _KuenstlerZeile(name: artist, songs: grouped[artist]!),
);
return _KuenstlerZeile(name: artist, songs: grouped[artist]!);
},
);
},
@@ -171,16 +167,13 @@ class _KuenstlerScreenState extends State<KuenstlerScreen> {
body: ListView.builder(
itemCount: vorspann + widget.songs.length,
itemBuilder: (context, i) {
Widget zeile() {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
}
if (i == albumListe.length + 1) {
return const Divider(height: 1);
}
if (i == albumListe.length + 1) return const Divider(height: 1);
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
}
final index = i - vorspann;
@@ -193,9 +186,6 @@ class _KuenstlerScreenState extends State<KuenstlerScreen> {
ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
);
}
return EinblendItem(index: i, child: zeile());
},
),
),
+36 -30
View File
@@ -72,6 +72,10 @@ class Playlists extends Table {
IntColumn get updatedAtMs => integer()();
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
/// wurde. `null` heißt: nur auf diesem Gerät.
TextColumn get cloudId => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -148,7 +152,7 @@ class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override
int get schemaVersion => 10;
int get schemaVersion => 11;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -190,6 +194,9 @@ class MeloDb extends _$MeloDb {
if (from < 10) {
await m.createTable(downloads);
}
if (from < 11) {
await m.addColumn(playlists, playlists.cloudId);
}
},
);
@@ -212,35 +219,6 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Überwacht die zuletzt GEHÖRTEN Songs (nicht getombstonte), aus
/// [PlaybackHistory]. Ein mehrfach gespielter Song erscheint nur einmal —
/// an der Position seines jüngsten Abspielens. Dafür wird pro [songId]
/// zuerst der jüngste `playedAtMs`-Eintrag ermittelt (Subquery mit
/// `GROUP BY`), und darüber zurück auf [songs] verknüpft.
Stream<List<Song>> watchRecentlyPlayed({int limit = 50}) {
final letzterPlay = playbackHistory.playedAtMs.max();
final letztePlaysProSong = selectOnly(playbackHistory)
..addColumns([playbackHistory.songId, letzterPlay])
..groupBy([playbackHistory.songId]);
final subquery = Subquery(letztePlaysProSong, 'letzte_plays');
final letzterPlayRef = subquery.ref(letzterPlay);
final query = select(songs).join([
innerJoin(
subquery,
subquery.ref(playbackHistory.songId).equalsExp(songs.id),
useColumns: false,
),
])
..where(songs.deleted.equals(false))
..orderBy([
OrderingTerm(expression: letzterPlayRef, mode: OrderingMode.desc)
])
..limit(limit);
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
@@ -313,6 +291,26 @@ class MeloDb extends _$MeloDb {
);
}
Future<Playlist?> playlistById(String id) =>
(select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull();
Future<void> setPlaylistCloudId(String id, String cloudId) async {
await (update(playlists)..where((p) => p.id.equals(id)))
.write(PlaylistsCompanion(cloudId: Value(cloudId)));
}
/// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**.
///
/// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt
/// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie
/// nicht vom Server zurückbekommen.
Future<int> countPlaylists() async {
final zaehler = playlists.id.count();
final zeile = await (selectOnly(playlists)..addColumns([zaehler]))
.getSingle();
return zeile.read(zaehler) ?? 0;
}
// === PlaylistSongs ===
Stream<List<Song>> watchPlaylistSongs(String playlistId) {
final query = select(songs).join([
@@ -548,6 +546,14 @@ class MeloDb extends _$MeloDb {
.write(SongsCompanion(cloudId: Value(cloudId)));
}
Future<Song?> songByCloudId(String cloudId) async {
final treffer = await (select(songs)
..where((s) => s.cloudId.equals(cloudId))
..limit(1))
.get();
return treffer.isEmpty ? null : treffer.single;
}
/// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
/// sie wieder an.
+81 -4
View File
@@ -1322,6 +1322,17 @@ class $PlaylistsTable extends Playlists
),
defaultValue: const Constant(false),
);
static const VerificationMeta _cloudIdMeta = const VerificationMeta(
'cloudId',
);
@override
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -1330,6 +1341,7 @@ class $PlaylistsTable extends Playlists
createdAtMs,
updatedAtMs,
deleted,
cloudId,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -1393,6 +1405,12 @@ class $PlaylistsTable extends Playlists
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
);
}
if (data.containsKey('cloud_id')) {
context.handle(
_cloudIdMeta,
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
);
}
return context;
}
@@ -1426,6 +1444,10 @@ class $PlaylistsTable extends Playlists
DriftSqlType.bool,
data['${effectivePrefix}deleted'],
)!,
cloudId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
);
}
@@ -1442,6 +1464,10 @@ class Playlist extends DataClass implements Insertable<Playlist> {
final int createdAtMs;
final int updatedAtMs;
final bool deleted;
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
/// wurde. `null` heißt: nur auf diesem Gerät.
final String? cloudId;
const Playlist({
required this.id,
required this.name,
@@ -1449,6 +1475,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
required this.createdAtMs,
required this.updatedAtMs,
required this.deleted,
this.cloudId,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -1461,6 +1488,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
map['created_at_ms'] = Variable<int>(createdAtMs);
map['updated_at_ms'] = Variable<int>(updatedAtMs);
map['deleted'] = Variable<bool>(deleted);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = Variable<String>(cloudId);
}
return map;
}
@@ -1474,6 +1504,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: Value(createdAtMs),
updatedAtMs: Value(updatedAtMs),
deleted: Value(deleted),
cloudId: cloudId == null && nullToAbsent
? const Value.absent()
: Value(cloudId),
);
}
@@ -1489,6 +1522,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
deleted: serializer.fromJson<bool>(json['deleted']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
);
}
@override
@@ -1501,6 +1535,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
'createdAtMs': serializer.toJson<int>(createdAtMs),
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
'deleted': serializer.toJson<bool>(deleted),
'cloudId': serializer.toJson<String?>(cloudId),
};
}
@@ -1511,6 +1546,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
int? createdAtMs,
int? updatedAtMs,
bool? deleted,
Value<String?> cloudId = const Value.absent(),
}) => Playlist(
id: id ?? this.id,
name: name ?? this.name,
@@ -1518,6 +1554,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
createdAtMs: createdAtMs ?? this.createdAtMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
);
Playlist copyWithCompanion(PlaylistsCompanion data) {
return Playlist(
@@ -1533,6 +1570,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
? data.updatedAtMs.value
: this.updatedAtMs,
deleted: data.deleted.present ? data.deleted.value : this.deleted,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
);
}
@@ -1544,14 +1582,22 @@ class Playlist extends DataClass implements Insertable<Playlist> {
..write('description: $description, ')
..write('createdAtMs: $createdAtMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted')
..write('deleted: $deleted, ')
..write('cloudId: $cloudId')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, name, description, createdAtMs, updatedAtMs, deleted);
int get hashCode => Object.hash(
id,
name,
description,
createdAtMs,
updatedAtMs,
deleted,
cloudId,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -1561,7 +1607,8 @@ class Playlist extends DataClass implements Insertable<Playlist> {
other.description == this.description &&
other.createdAtMs == this.createdAtMs &&
other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted);
other.deleted == this.deleted &&
other.cloudId == this.cloudId);
}
class PlaylistsCompanion extends UpdateCompanion<Playlist> {
@@ -1571,6 +1618,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
final Value<int> createdAtMs;
final Value<int> updatedAtMs;
final Value<bool> deleted;
final Value<String?> cloudId;
final Value<int> rowid;
const PlaylistsCompanion({
this.id = const Value.absent(),
@@ -1579,6 +1627,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
this.createdAtMs = const Value.absent(),
this.updatedAtMs = const Value.absent(),
this.deleted = const Value.absent(),
this.cloudId = const Value.absent(),
this.rowid = const Value.absent(),
});
PlaylistsCompanion.insert({
@@ -1588,6 +1637,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
required int createdAtMs,
required int updatedAtMs,
this.deleted = const Value.absent(),
this.cloudId = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
name = Value(name),
@@ -1600,6 +1650,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
Expression<int>? createdAtMs,
Expression<int>? updatedAtMs,
Expression<bool>? deleted,
Expression<String>? cloudId,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -1609,6 +1660,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
if (createdAtMs != null) 'created_at_ms': createdAtMs,
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
if (deleted != null) 'deleted': deleted,
if (cloudId != null) 'cloud_id': cloudId,
if (rowid != null) 'rowid': rowid,
});
}
@@ -1620,6 +1672,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
Value<int>? createdAtMs,
Value<int>? updatedAtMs,
Value<bool>? deleted,
Value<String?>? cloudId,
Value<int>? rowid,
}) {
return PlaylistsCompanion(
@@ -1629,6 +1682,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
createdAtMs: createdAtMs ?? this.createdAtMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
deleted: deleted ?? this.deleted,
cloudId: cloudId ?? this.cloudId,
rowid: rowid ?? this.rowid,
);
}
@@ -1654,6 +1708,9 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
if (deleted.present) {
map['deleted'] = Variable<bool>(deleted.value);
}
if (cloudId.present) {
map['cloud_id'] = Variable<String>(cloudId.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -1669,6 +1726,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
..write('createdAtMs: $createdAtMs, ')
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ')
..write('cloudId: $cloudId, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -4216,6 +4274,7 @@ typedef $$PlaylistsTableCreateCompanionBuilder =
required int createdAtMs,
required int updatedAtMs,
Value<bool> deleted,
Value<String?> cloudId,
Value<int> rowid,
});
typedef $$PlaylistsTableUpdateCompanionBuilder =
@@ -4226,6 +4285,7 @@ typedef $$PlaylistsTableUpdateCompanionBuilder =
Value<int> createdAtMs,
Value<int> updatedAtMs,
Value<bool> deleted,
Value<String?> cloudId,
Value<int> rowid,
});
@@ -4291,6 +4351,11 @@ class $$PlaylistsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) {
@@ -4355,6 +4420,11 @@ class $$PlaylistsTableOrderingComposer
column: $table.deleted,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => ColumnOrderings(column),
);
}
class $$PlaylistsTableAnnotationComposer
@@ -4390,6 +4460,9 @@ class $$PlaylistsTableAnnotationComposer
GeneratedColumn<bool> get deleted =>
$composableBuilder(column: $table.deleted, builder: (column) => column);
GeneratedColumn<String> get cloudId =>
$composableBuilder(column: $table.cloudId, builder: (column) => column);
Expression<T> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) {
@@ -4450,6 +4523,7 @@ class $$PlaylistsTableTableManager
Value<int> createdAtMs = const Value.absent(),
Value<int> updatedAtMs = const Value.absent(),
Value<bool> deleted = const Value.absent(),
Value<String?> cloudId = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion(
id: id,
@@ -4458,6 +4532,7 @@ class $$PlaylistsTableTableManager
createdAtMs: createdAtMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
cloudId: cloudId,
rowid: rowid,
),
createCompanionCallback:
@@ -4468,6 +4543,7 @@ class $$PlaylistsTableTableManager
required int createdAtMs,
required int updatedAtMs,
Value<bool> deleted = const Value.absent(),
Value<String?> cloudId = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => PlaylistsCompanion.insert(
id: id,
@@ -4476,6 +4552,7 @@ class $$PlaylistsTableTableManager
createdAtMs: createdAtMs,
updatedAtMs: updatedAtMs,
deleted: deleted,
cloudId: cloudId,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
-7
View File
@@ -13,13 +13,6 @@ import 'scan_service.dart';
String get addMusicLabel =>
Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen';
/// Ob der "Erneut scannen"-Button zusätzlich zum [addMusicLabel]-Button
/// angezeigt wird. Auf Android tun [LibraryService.pickFolderAndScan] und
/// [LibraryService.rescan] dasselbe (kompletter MediaStore-Scan) — dort
/// reicht ein Button. Pure Funktion statt direkter [Platform.isAndroid]-
/// Abfrage, damit sie unabhängig von der echten Plattform testbar ist.
bool zeigeErneutScannenButton(bool isAndroid) => !isAndroid;
/// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI.
/// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen
/// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner.
+10 -9
View File
@@ -120,6 +120,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
return SortableSongList(
songs: songs,
storeKey: SortStore.meineMusik,
serverUpload: true,
empty:
lib.scanning ? const SizedBox.shrink() : const _Empty(),
);
@@ -234,17 +235,17 @@ class _QuickAccessRow extends StatelessWidget {
),
if (sektionen.contains(AppSettings.sektionZuletzt))
StreamBuilder<List<Song>>(
stream: db.watchRecentlyPlayed(limit: 50),
stream: db.watchRecent(limit: 50),
builder: (context, snapshot) {
final recent = snapshot.data ?? const <Song>[];
return _QuickCard(
icon: Icons.schedule,
label: 'Zuletzt',
subtitle: 'Zuletzt gehört',
subtitle: 'Neu hinzugefügt',
coverPath: recent.isEmpty ? null : recent.first.coverPath,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RecentlyPlayedScreen()),
builder: (_) => const RecentlyAddedScreen()),
),
);
},
@@ -363,22 +364,22 @@ class _Empty extends StatelessWidget {
}
}
/// Vollbild-Liste der zuletzt gehörten Songs (Schnellzugriff "Zuletzt").
class RecentlyPlayedScreen extends StatelessWidget {
const RecentlyPlayedScreen({super.key});
/// Vollbild-Liste der zuletzt hinzugefügten Songs (Schnellzugriff "Zuletzt").
class RecentlyAddedScreen extends StatelessWidget {
const RecentlyAddedScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Zuletzt gehört')),
appBar: AppBar(title: const Text('Zuletzt hinzugefügt')),
body: StreamBuilder<List<Song>>(
stream: db.watchRecentlyPlayed(limit: 100),
stream: db.watchRecent(limit: 100),
builder: (context, snapshot) {
final songs = snapshot.data ?? const <Song>[];
if (songs.isEmpty) {
return const Center(
child: Text('Noch nichts abgespielt',
child: Text('Noch nichts hinzugefügt',
style: TextStyle(color: MeloTheme.text2)),
);
}
+121 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import '../services/melo_cloud_service.dart';
import '../services/navidrome_service.dart';
import 'database.dart';
@@ -6,14 +7,22 @@ import 'database.dart';
/// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst
/// beobachten UIs direkt über die watch()-Streams von [MeloDb]).
class PlaylistService extends ChangeNotifier {
PlaylistService(this.db, {NavidromeService? navidrome})
: _navidrome = navidrome ?? NavidromeService();
// `this._cloud` würde den öffentlichen Parameternamen `cloud` zu `_cloud`
// (privat) ändern und die API brechen.
PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud})
: _navidrome = navidrome ?? NavidromeService(),
// ignore: prefer_initializing_formals
_cloud = cloud;
final MeloDb db;
final NavidromeService _navidrome;
/// Optional: ohne Cloud-Zugang entfällt der Sofort-Push stillschweigend.
final MeloCloudService? _cloud;
Future<String> createPlaylist(String name, {String? description}) async {
final id = await db.createPlaylist(name, description: description);
notifyListeners();
await _sichereNeuePlaylist(id, name);
return id;
}
@@ -22,29 +31,64 @@ class PlaylistService extends ChangeNotifier {
notifyListeners();
}
Future<void> addSongToPlaylist(String playlistId, String songId, int position) async {
Future<void> addSongToPlaylist(
String playlistId, String songId, int position) async {
await db.addSongToPlaylist(playlistId, songId, position);
notifyListeners();
final cloudId = await _cloudIdDerPlaylist(playlistId);
final songCloudId = (await db.songById(songId))?.cloudId;
if (cloudId == null || songCloudId == null) return;
await _still(() =>
_cloud!.fuegePlaylistSongsHinzu(cloudId, [songCloudId]));
}
Future<void> removeSongFromPlaylist(String playlistId, String songId) async {
Future<void> removeSongFromPlaylist(
String playlistId, String songId) async {
final songCloudId = (await db.songById(songId))?.cloudId;
await db.removeSongFromPlaylist(playlistId, songId);
notifyListeners();
final cloudId = await _cloudIdDerPlaylist(playlistId);
if (cloudId == null || songCloudId == null) return;
await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId));
}
Future<void> reorderSong(String playlistId, String songId, int newPosition) async {
Future<void> reorderSong(
String playlistId, String songId, int newPosition) async {
await db.reorderPlaylistSong(playlistId, songId, newPosition);
notifyListeners();
await _sichereReihenfolge(playlistId);
}
Future<void> reorderAll(String playlistId, List<String> orderedSongIds) async {
Future<void> reorderAll(
String playlistId, List<String> orderedSongIds) async {
await db.reorderAllPlaylistSongs(playlistId, orderedSongIds);
notifyListeners();
await _sichereReihenfolge(playlistId);
}
Future<void> toggleFavorite(String songId) async {
await db.toggleFavorite(songId);
notifyListeners();
await _meldeFavorit(songId);
}
/// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud.
///
/// Deterministisch (`set:true/false`), nicht als Umschalten: ein
/// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur für
/// Titel mit cloudId, und Fehler werden still geschluckt — der nächste
/// Abgleich pusht additiv nach.
Future<void> _meldeFavorit(String songId) async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return;
final cloudId = (await db.songById(songId))?.cloudId;
if (cloudId == null) return;
final gesetzt = await db.watchIsFavorite(songId).first;
try {
await cloud.setzeFavorit(cloudId, gesetzt);
} catch (e) {
debugPrint('Favorit nicht gemeldet: $e');
}
}
/// Lädt alle Favoriten vom Navidrome-Server und importiert sie lokal.
@@ -107,4 +151,75 @@ class PlaylistService extends ChangeNotifier {
return 0;
}
}
/// Führt [aktion] aus und verwirft jeden Fehler.
///
/// Die Sicherung ist einseitig und ohne Rollback: schlägt sie fehl, bleibt
/// der lokale Stand, wie er ist, und die nächste Änderung versucht es
/// erneut.
Future<void> _still(Future<void> Function() aktion) async {
try {
await aktion();
} catch (e) {
debugPrint('Playlist-Sicherung übersprungen: $e');
}
}
Future<String?> _cloudIdDerPlaylist(String playlistId) async {
if (_cloud == null || !_cloud.istAngemeldet) return null;
return (await db.playlistById(playlistId))?.cloudId;
}
Future<void> _sichereNeuePlaylist(String id, String name) async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return;
await _still(() async {
final cloudId = await cloud.legePlaylistAn(name);
await db.setPlaylistCloudId(id, cloudId);
});
}
Future<void> _sichereReihenfolge(String playlistId) async {
final cloudId = await _cloudIdDerPlaylist(playlistId);
if (cloudId == null) return;
final songs = await db.watchPlaylistSongs(playlistId).first;
final ids = [
for (final s in songs)
if (s.cloudId != null) s.cloudId!,
];
if (ids.isEmpty) return;
await _still(() => _cloud!.setzePlaylistReihenfolge(cloudId, ids));
}
/// Holt die Playlisten des Servers **nur** auf ein Gerät ohne eigene:
/// Neuinstallation oder Wiederherstellung.
///
/// Es gibt bewusst keinen Rück-Merge — Umbenennungen und Änderungen eines
/// zweiten Geräts erscheinen hier nicht. Der beidseitige Abgleich ist eine
/// eigene, spätere Spec.
Future<int> stelleWiederHer() async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return 0;
if (await db.countPlaylists() > 0) return 0;
var angelegt = 0;
try {
for (final vomServer in await cloud.playlisten()) {
final id = await db.createPlaylist(vomServer.name);
await db.setPlaylistCloudId(id, vomServer.id);
final songCloudIds = await cloud.playlistSongs(vomServer.id);
var position = 0;
for (final songCloudId in songCloudIds) {
final song = await db.songByCloudId(songCloudId);
if (song == null) continue;
await db.addSongToPlaylist(id, song.id, position++);
}
angelegt++;
}
} catch (e) {
debugPrint('Playlist-Wiederherstellung abgebrochen: $e');
}
notifyListeners();
return angelegt;
}
}
+1 -8
View File
@@ -3,11 +3,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/favorite_button.dart';
import '../shared/lauf_balken.dart';
import 'category_service.dart';
@@ -95,9 +93,7 @@ class SongList extends StatelessWidget {
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length,
itemBuilder: (context, i) => EinblendItem(
index: i,
child: SongZeile(
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
@@ -107,7 +103,6 @@ class SongList extends StatelessWidget {
? null
: () => onAuswahlWechsel!(songs[i].id),
),
),
);
}
}
@@ -383,8 +378,6 @@ class SongZeile extends StatelessWidget {
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+42 -196
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
@@ -20,8 +19,6 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart';
import 'player/audio_handler.dart';
import 'player/mini_player.dart';
import 'player/now_playing_screen.dart';
import 'player/player_expansion_controller.dart';
import 'services/baka_auth.dart';
import 'services/logger_service.dart';
import 'services/download_service.dart';
@@ -33,6 +30,7 @@ import 'services/sync_service.dart';
import 'services/yt_download_service.dart';
import 'services/yt_search_service.dart';
import 'settings/app_settings.dart';
import 'shared/sync_bericht_dialog.dart';
import 'shared/theme.dart';
late final MeloAudioHandler _handler;
@@ -54,7 +52,6 @@ Future<void> main() async {
await logger.init();
_db = MeloDb();
_library = LibraryService(_db);
_playlists = PlaylistService(_db);
_offlineMode = OfflineMode();
await _offlineMode.init();
_categories = CategoryService(_db);
@@ -86,6 +83,12 @@ Future<void> main() async {
cloud: MeloCloudService(auth: _bakaAuth),
);
await _sync.laden();
// Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und
// _bakaAuth entsteht weiter oben.
_playlists = PlaylistService(
_db,
cloud: MeloCloudService(auth: _bakaAuth),
);
final navidrome = NavidromeService();
await navidrome.ladeGespeicherteZugangsdaten();
_downloads = DownloadService(db: _db, navidrome: navidrome);
@@ -115,35 +118,9 @@ Future<void> main() async {
runApp(const MeloApp());
}
class MeloApp extends StatefulWidget {
class MeloApp extends StatelessWidget {
const MeloApp({super.key});
@override
State<MeloApp> createState() => _MeloAppState();
}
class _MeloAppState extends State<MeloApp> with TickerProviderStateMixin {
// Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch
// per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen,
// ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen
// Provider steckte der Controller nur innerhalb von HomeShell.build() — für
// eine gepushte Route (ein Geschwister von HomeShell im selben
// Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und
// context.read<PlayerExpansionController>() warf ProviderNotFoundException.
late final PlayerExpansionController _expansion;
@override
void initState() {
super.initState();
_expansion = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_expansion.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiProvider(
@@ -161,8 +138,6 @@ class _MeloAppState extends State<MeloApp> with TickerProviderStateMixin {
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
ChangeNotifierProvider<SyncService>.value(value: _sync),
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: _expansion),
],
child: MaterialApp(
title: 'Melo',
@@ -203,7 +178,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this);
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
// Geräten sind dann sofort da.
context.read<SyncService>().automatisch();
unawaited(_gleicheAbUndZeigeBericht());
unawaited(context.read<LibraryService>().ensureStartupPermissions());
unawaited(_pruefeGeteilteAdresse());
}
@@ -231,13 +206,41 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
// auf dem Gerät wie am Server.
if (state == AppLifecycleState.resumed) {
if (Platform.isAndroid) _autoScan.pruefe();
context.read<SyncService>().automatisch();
unawaited(_gleicheAbUndZeigeBericht());
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
unawaited(_pruefeGeteilteAdresse());
}
}
/// Gleicht ab und zeigt danach höchstens einmal den „Was ist neu"-Bericht.
Future<void> _gleicheAbUndZeigeBericht() async {
final sync = context.read<SyncService>();
final playlists = context.read<PlaylistService>();
await sync.automatisch();
// Nur auf einem Gerät ohne eigene Playlisten, und nur nach einem
// tatsächlich abgeschlossenen Sync-Lauf: läuft parallel schon ein
// echter Sync (z. B. weil ein Berechtigungsdialog während des
// Erststarts einen App-Resume auslöst und diese Methode ein zweites
// Mal anstößt), kehrt automatisch() oben wegen des _laeuft-Guards
// sofort zurück — dann liefe jede songByCloudId-Suche noch ins Leere,
// legte leere Playlisten an, und der countPlaylists()-Riegel würde
// jeden weiteren Versuch für immer blockieren.
if (!sync.laeuft) {
unawaited(playlists.stelleWiederHer());
}
if (!mounted) return;
final bericht = sync.bericht;
if (bericht == null) return;
// Zuerst quittieren: ein zweites Zurückkehren in die App soll denselben
// Bericht nicht erneut zeigen.
sync.berichtGesehen();
await showDialog<void>(
context: context,
builder: (_) => SyncBerichtDialog(bericht: bericht),
);
}
@override
Widget build(BuildContext context) {
final tabs = <Widget>[
@@ -253,136 +256,16 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
const FavoritesScreen(),
const YoutubeSearchScreen(),
];
// Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
// Scaffold-weiten AnimatedBuilder unten hören direkt am Controller
// (animation: expansion) und aktualisieren sich selbst live. Ein
// context.watch hier würde stattdessen bei JEDEM _expansion-Tick den
// kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen.
final expansion = context.read<PlayerExpansionController>();
// Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
// laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das
// ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen
// Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine
// Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini-
// Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und
// bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt
// an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global
// Constraints.
return Consumer<PlayerExpansionController>(
builder: (context, expansion, child) => PopScope(
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
// den Tab wechseln.
canPop: expansion.progress == 0 && _index == 0,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (expansion.progress > 0) {
expansion.close(context);
} else {
setState(() => _index = 0);
}
},
child: child!,
),
child: Scaffold(
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
body: Stack(
children: [
AnimatedBuilder(
animation: expansion,
builder: (context, child) => IgnorePointer(
ignoring: expansion.progress > 0,
child: ExcludeSemantics(
excluding: expansion.progress > 0,
child: child,
),
),
child: Column(
return Scaffold(
body: Column(
children: [
Expanded(child: IndexedStack(index: _index, children: tabs)),
const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player
],
),
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) {
final p = expansion.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
child: Container(color: Colors.transparent),
),
);
},
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
child: IgnorePointer(
ignoring: expansion.progress > 0,
child: child,
),
),
),
child: const MiniPlayer(),
),
AnimatedBuilder(
animation: expansion,
builder: (context, child) {
final p = expansion.progress;
return Positioned.fromRect(
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
child: Visibility(
visible: p > 0,
maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec
maintainAnimation: true,
child: Opacity(
// Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec.
opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0),
child: child,
),
),
);
},
child: const NowPlayingScreen(),
),
StreamBuilder<MediaItem?>(
stream: context.read<MeloAudioHandler>().mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: expansion,
builder: (context, child) => WanderndesCover(
progress: expansion.progress,
artUri: item.artUri,
miniRect: _miniCoverRect(context),
vollbildRect: _vollbildCoverRect(context),
),
);
},
),
const MiniPlayer(),
],
),
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
bottomNavigationBar: AnimatedBuilder(
animation: expansion,
builder: (context, child) => Opacity(
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
child: IgnorePointer(
ignoring: expansion.progress > 0,
child: child,
),
),
child: DecoratedBox(
bottomNavigationBar: DecoratedBox(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: MeloTheme.border)),
),
@@ -408,43 +291,6 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
],
),
),
),
),
);
}
Rect _miniRect(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight;
return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe);
}
Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context);
/// Feste 52×52-Größe, linksbündig mit MeloSpace.md-Abstand, vertikal
/// mittig in MiniPlayer.hoehe — Werte aus mini_player.dart übernommen
/// (dortiges Cover entfällt, siehe Task 6 Step 5).
Rect _miniCoverRect(BuildContext context) {
final mini = _miniRect(context);
const seite = 52.0;
final top = mini.top + (MiniPlayer.hoehe - seite) / 2;
return Rect.fromLTWH(mini.left + MeloSpace.md, top, seite, seite);
}
/// Nicht per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter`
/// sitzt in NowPlayingScreens eigenem responsivem LayoutBuilder — die
/// tatsächliche Position wird stattdessen gemessen, siehe
/// coverPlatzhalterKey.
Rect _vollbildCoverRect(BuildContext context) {
final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) {
// Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) —
// Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis
// der erste Frame mit progress > 0 eine echte Messung liefert.
final voll = _vollbildRect(context);
final seite = voll.width - 48;
return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite);
}
return box.localToGlobal(Offset.zero) & box.size;
}
}
-19
View File
@@ -130,22 +130,6 @@ int startIndexNachFilter(List<bool> behalten, int startIndex) {
return davor.clamp(0, gesamt - 1);
}
/// Übersetzung des Player-Shuffle-Zustands in den `audio_service`-Wert für
/// `PlaybackState.shuffleMode`. Als reine Funktion ausgelagert, damit sie
/// ohne laufenden `AudioPlayer` testbar ist — `_transformEvent()` läuft bei
/// jedem Player-Event und lässt sich nicht isoliert aufrufen.
AudioServiceShuffleMode toAudioServiceShuffleMode(bool enabled) =>
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none;
/// Übersetzung des Player-Loop-Modus in den `audio_service`-Wert für
/// `PlaybackState.repeatMode`. Siehe [toAudioServiceShuffleMode].
AudioServiceRepeatMode toAudioServiceRepeatMode(LoopMode loopMode) =>
switch (loopMode) {
LoopMode.one => AudioServiceRepeatMode.one,
LoopMode.all => AudioServiceRepeatMode.all,
LoopMode.off => AudioServiceRepeatMode.none,
};
/// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar.
class NichtsOfflineVerfuegbar implements Exception {
const NichtsOfflineVerfuegbar();
@@ -267,7 +251,6 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// das in den Einstellungen erlaubt hat.
Future<void> _watchHeadphones() async {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
session.devicesChangedEventStream.listen((event) {
final added = event.devicesAdded.map((d) => d.type).toSet();
if (shouldAutoPlay(
@@ -650,8 +633,6 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
}[_player.processingState] ??
AudioProcessingState.idle,
playing: _player.playing,
shuffleMode: toAudioServiceShuffleMode(_player.shuffleModeEnabled),
repeatMode: toAudioServiceRepeatMode(_player.loopMode),
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
+47 -36
View File
@@ -2,9 +2,10 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'player_expansion_controller.dart';
import 'now_playing_screen.dart';
import 'wischen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
@@ -52,20 +53,23 @@ class _Leiste extends StatefulWidget {
}
class _LeisteState extends State<_Leiste> {
/// Für die horizontale Titel-Wechsel-Geste (unverändert).
double _horizontalVersatz = 0;
bool _ziehtHorizontalGerade = false;
/// Wie weit der Finger die Leiste gerade zieht — nur zur Anzeige, die
/// eigentliche Entscheidung fällt erst bei [onVerticalDragEnd]/
/// [onHorizontalDragEnd]. `dy` bleibt <= 0: nach unten ist unbelegt, ein
/// Mitführen dorthin würde Wirkung vortäuschen, die es nicht gibt.
Offset _versatz = Offset.zero;
bool _ziehtGerade = false;
/// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie
/// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt
/// im PlayerExpansionController). Wird für [PlayerExpansionController.
/// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet;
/// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt
/// zurückrechnen.
double _vertikaleStrecke = 0;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
void _oeffne(BuildContext context) =>
context.read<PlayerExpansionController>().open(context);
void _zurueckschnappen() {
setState(() {
_ziehtGerade = false;
_versatz = Offset.zero;
});
}
@override
Widget build(BuildContext context) {
@@ -84,27 +88,30 @@ class _LeisteState extends State<_Leiste> {
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onVerticalDragStart: (_) => _vertikaleStrecke = 0,
onVerticalDragUpdate: (details) {
_vertikaleStrecke += details.delta.dy;
context.read<PlayerExpansionController>().dragBy(details.delta.dy);
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
// Vollbild-Wiedergabe, seitwärts wechselt den Titel. Die Leiste
// folgt dabei sichtbar dem Finger — sonst blieb ein
// bedächtiges, aber weites Wischen (unter der Schwung-Schwelle)
// ganz ohne Reaktion.
onVerticalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
final dy = _versatz.dy + details.delta.dy;
_versatz = Offset(_versatz.dx, dy > 0 ? 0 : dy);
}),
onVerticalDragEnd: (details) {
final oeffnet = oeffnetVollbildMitStrecke(
_versatz.dy, details.velocity.pixelsPerSecond.dy);
_zurueckschnappen();
if (oeffnet) _oeffne(context);
},
onVerticalDragEnd: (details) => context
.read<PlayerExpansionController>()
.dragEnd(context, _vertikaleStrecke,
details.velocity.pixelsPerSecond.dy,
ausVollbild: false),
onHorizontalDragUpdate: (details) => setState(() {
_ziehtHorizontalGerade = true;
_horizontalVersatz += details.delta.dx;
_ziehtGerade = true;
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy);
}),
onHorizontalDragEnd: (details) {
final wechsel = titelWechselMitStrecke(
_horizontalVersatz, details.velocity.pixelsPerSecond.dx);
setState(() {
_ziehtHorizontalGerade = false;
_horizontalVersatz = 0;
});
_versatz.dx, details.velocity.pixelsPerSecond.dx);
_zurueckschnappen();
switch (wechsel) {
case Wechsel.naechster:
handler.skipToNext();
@@ -117,20 +124,24 @@ class _LeisteState extends State<_Leiste> {
child: InkWell(
onTap: () => _oeffne(context),
child: AnimatedContainer(
duration: _ziehtHorizontalGerade
// Während des Ziehens sofort folgen (keine Verzögerung);
// beim Loslassen ohne Wirkung sichtbar zurückschnappen.
duration: _ziehtGerade
? Duration.zero
: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
transform: Matrix4.translationValues(_horizontalVersatz, 0, 0),
transform:
Matrix4.translationValues(_versatz.dx, _versatz.dy, 0),
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
// Das Cover rendert WanderndesCover in HomeShell in
// einer eigenen Ebene über allem (deckt exakt diese
// Position bei progress == 0 ab) — hier nur Platz
// freihalten, damit Titel/Bedienung nicht springen.
const SizedBox(width: 52),
// Dasselbe Bild wie im Vollbild: es wandert beim Öffnen
// dorthin, statt dass ein neuer Bildschirm aufspringt.
Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, size: 52),
),
const SizedBox(width: MeloSpace.md),
Expanded(
child: Column(
+120 -226
View File
@@ -1,10 +1,7 @@
import 'dart:async';
import 'dart:ui' show lerpDouble;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
@@ -16,11 +13,10 @@ import '../shared/theme.dart';
import 'audio_handler.dart';
import 'cover_farbe.dart';
import 'lrc.dart';
import 'player_expansion_controller.dart';
import 'queue_screen.dart';
import 'wischen.dart';
/// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress
/// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route.
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key});
@@ -35,225 +31,41 @@ class NowPlayingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
// Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor:
// transparent) auf dem durchgehend dunklen Theme lief (siehe
// MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die
// Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die
// Icons je nach Systemzustand zufällig dunkel auf dunklem Grund.
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: _CoverGrund(
return _CoverGrund(
handler: handler,
child: Material(
color: Colors.transparent,
child: SafeArea(
child: Column(
children: [
_VollbildLeiste(handler: handler, onLyrics: _showLyrics),
Expanded(
child: _SchliessGeste(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return LayoutBuilder(
builder: (context, raum) {
final nebeneinander = raum.maxWidth > raum.maxHeight;
// Während der Öffnen-Geste ist das interpolierte
// Rechteck (main.dart, Rect.lerp Mini-/Vollbild-
// Player) oft noch kleiner, als _Angaben an
// Mindesthöhe braucht — ohne diese Kapselung meldet
// Flutter dann "RenderFlex overflowed" (im Row-Zweig
// unten bekäme _Angaben selbst eine zu knappe Höhe
// zugeteilt, im Column-Zweig überschreitet Lücke +
// _Angaben-Eigenhöhe zusammen die verfügbare Höhe).
// ConstrainedBox AUSSEN um OverflowBox deckelt, was
// OverflowBox nach außen als eigene Größe meldet,
// hart auf das von raum bekannte Maß; OverflowBox
// selbst gibt ihrem Kind (_Angaben) über
// maxHeight: infinity davon UNABHÄNGIG weiterhin
// unbegrenzte Höhe — _Angaben behält so seine
// natürliche Größe, der überschüssige Teil wird
// vom ClipRect einfach nicht gemalt statt eine
// Overflow-Warnung auszulösen. Läge die Deckelung
// stattdessen INNEN (zwischen OverflowBox und
// _Angaben), bekäme _Angaben selbst zu wenig Höhe
// und liefe intern über. Bei ausreichend Platz
// (Endzustand progress==1) ist raum.maxHeight groß
// genug, dass nichts geklemmt wird — die Optik
// bleibt dort unverändert.
final inhalt = ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: (raum.maxHeight - MeloSpace.lg)
.clamp(0.0, double.infinity),
),
child: OverflowBox(
alignment: Alignment.center,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
child: _Angaben(item: item, handler: handler),
),
),
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: nebeneinander
? Row(children: [
Expanded(child: Center(child: _CoverPlatzhalter())),
const SizedBox(width: MeloSpace.lg),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: inhalt,
),
),
),
])
: Column(children: [
Expanded(child: Center(child: _CoverPlatzhalter())),
const SizedBox(height: MeloSpace.lg),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: inhalt,
),
),
]),
);
},
);
},
),
),
),
],
),
),
),
),
);
}
}
/// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche
/// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt
/// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio)
/// von außen nachzurechnen.
final coverPlatzhalterKey = GlobalKey();
/// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover`
/// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom
/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann.
class _CoverPlatzhalter extends StatelessWidget {
const _CoverPlatzhalter();
@override
Widget build(BuildContext context) =>
AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox());
}
/// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert.
///
/// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten
/// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau
/// keine Route mehr. Positionierung/Größe kommen direkt aus [progress],
/// live an die Zugstrecke der Wischgeste gekoppelt.
class WanderndesCover extends StatelessWidget {
const WanderndesCover({
super.key,
required this.progress,
required this.artUri,
required this.miniRect,
required this.vollbildRect,
});
final double progress;
final Uri? artUri;
final Rect miniRect;
final Rect vollbildRect;
static const _miniRadius = 6.0;
static const _vollbildRadius = 16.0;
@override
Widget build(BuildContext context) {
final rect = Rect.lerp(miniRect, vollbildRect, progress)!;
final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!;
return Positioned.fromRect(
rect: rect,
child: CoverImage(artUri: artUri, radius: radius),
);
}
}
/// Schließt den Vollbild-Player per Wischgeste nach unten — symmetrisch zum
/// Öffnen am Mini-Player, live über denselben PlayerExpansionController.
/// Eigenes StatefulWidget, weil eine lokale Rohstrecke über die Gestendauer
/// mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in
/// mini_player.dart).
class _SchliessGeste extends StatefulWidget {
const _SchliessGeste({required this.child});
final Widget child;
@override
State<_SchliessGeste> createState() => _SchliessGesteState();
}
class _SchliessGesteState extends State<_SchliessGeste> {
double _strecke = 0;
@override
Widget build(BuildContext context) {
final controller = context.read<PlayerExpansionController>();
return GestureDetector(
// Ohne opaque behavior fängt die Geste keine Berührungen über den
// großflächigen, unbemalten Bereichen des Inhalts ab (z.B. über dem
// Cover-Platzhalter, der nur ein leeres SizedBox ist) — GestureDetector
// reagiert per Default (deferToChild) nur dort, wo ein Nachfahre
// tatsächlich etwas zeichnet (per Testlauf verifiziert).
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => _strecke = 0,
onVerticalDragUpdate: (details) {
_strecke += details.delta.dy;
controller.dragBy(details.delta.dy);
},
onVerticalDragEnd: (details) => controller.dragEnd(
context, _strecke, details.velocity.pixelsPerSecond.dy,
ausVollbild: true),
child: widget.child,
);
}
}
/// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als
/// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier
/// nicht mehr.
class _VollbildLeiste extends StatelessWidget {
const _VollbildLeiste({required this.handler, required this.onLyrics});
final MeloAudioHandler handler;
final void Function(BuildContext, String) onLyrics;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
child: Scaffold(
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
actions: [
StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
// Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse:
// mit der fragte die App den Server nach einem Titel namens
// "https://…" und bekam nie einen Text.
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return IconButton(
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext',
icon: const Icon(Icons.lyrics),
onPressed: songId.isEmpty ? null : () => onLyrics(context, songId),
onPressed: songId.isEmpty
? null
: () => _showLyrics(context, songId),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
);
},
),
@@ -267,6 +79,78 @@ class _VollbildLeiste extends StatelessWidget {
),
),
],
),
body: GestureDetector(
// Wie bei Apple Music/Spotify: den Vollbild-Player von praktisch
// jeder Stelle nach unten wegziehen schließt ihn wieder — dieselbe
// Schwelle wie beim Mini-Player, damit kein zaghaftes Scrollen die
// Ansicht versehentlich zuklappt.
onVerticalDragEnd: (details) {
final geschwindigkeit = details.primaryVelocity ?? 0;
if (geschwindigkeit > wischSchwelle) Navigator.maybePop(context);
},
child: SafeArea(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return LayoutBuilder(
builder: (context, raum) {
// Im Querformat ist die Höhe knapp: Cover und Bedienung
// untereinander ließen vom Cover einen Streifen übrig oder
// schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides
// benutzbar. Dieselbe Aufteilung hilft auf einem Tablet.
final nebeneinander = raum.maxWidth > raum.maxHeight;
final inhalt = _Angaben(item: item, handler: handler);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: nebeneinander
? Row(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(width: MeloSpace.lg),
// Der Inhalt bekommt eine feste Obergrenze: auf
// einem breiten Bildschirm zerrte er sonst über
// die ganze Breite und wäre schlecht lesbar.
Expanded(
child: Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 420),
child: inhalt,
),
),
),
],
)
: Column(
children: [
Expanded(
child: Center(child: _Cover(item: item)),
),
const SizedBox(height: MeloSpace.lg),
Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: 520),
child: inhalt,
),
),
],
),
);
},
);
},
),
),
),
),
);
}
}
@@ -356,24 +240,13 @@ class _Angaben extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
// Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
],
),
const SizedBox(height: MeloSpace.sm),
Text(
item.artist ?? 'Unbekannt',
@@ -414,6 +287,27 @@ class _Herz extends StatelessWidget {
}
}
/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran
/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden
/// wandern statt einen neuen Bildschirm aufspringen zu lassen.
const coverHeldenName = 'melo-cover';
class _Cover extends StatelessWidget {
const _Cover({required this.item});
final MediaItem item;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, radius: 16),
),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.handler});
final MeloAudioHandler handler;
@@ -1,89 +0,0 @@
import 'package:flutter/material.dart';
import '../shared/theme.dart';
import 'wischen.dart';
/// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht —
/// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in
/// [wischen.dart].
const oeffnenReferenzHoehe = 300.0;
/// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1).
///
/// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert
/// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang
/// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt).
class PlayerExpansionController extends ChangeNotifier {
PlayerExpansionController({required TickerProvider vsync})
: _animation = AnimationController(vsync: vsync) {
_animation.addListener(notifyListeners);
}
final AnimationController _animation;
double get progress => _animation.value;
/// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes
/// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv)
/// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste
/// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf
/// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke):
/// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn
/// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde.
void dragBy(double dy) {
_animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0);
}
/// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben).
/// [ausVollbild] benennt den Aufrufer-Kontext explizit, statt ihn aus dem
/// Vorzeichen von [strecke] zu raten (früherer Bug: bei bereits offenem
/// Player klappte ein schwaches, versehentliches Aufwärtszittern — negative
/// Strecke, weit unter der Schwelle — den Player zu, weil das Vorzeichen
/// allein in den ÖFFNEN-Zweig führte, der bei so einer schwachen Geste
/// `false` liefert). `false` (Mini-Player, Öffnen-Geste) entscheidet direkt
/// über [oeffnetVollbildMitStrecke]; `true` (Vollbild-Player, Schließen-
/// Geste) über dieselbe Funktion mit gespiegeltem Vorzeichen — "stark genug
/// nach unten" ist spiegelbildlich zu "stark genug nach oben". Fliegt
/// danach mit der echten Loslass-Geschwindigkeit als Startimpuls zum
/// entschiedenen Ziel.
void dragEnd(BuildContext context, double strecke, double geschwindigkeit,
{required bool ausVollbild}) {
final zielIstOffen = ausVollbild
? !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit)
: oeffnetVollbildMitStrecke(strecke, geschwindigkeit);
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = zielIstOffen ? 1.0 : 0.0;
return;
}
// -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy
// (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung
// rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum
// entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal
// zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung,
// statt fling() mit widersprüchlichem Vorzeichen zu verwirren.
final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe;
final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0;
final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0);
_animation.fling(velocity: impuls);
}
void open(BuildContext context) => _animiereZu(context, 1.0);
void close(BuildContext context) => _animiereZu(context, 0.0);
/// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders
/// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling.
void _animiereZu(BuildContext context, double ziel) {
if (MediaQuery.disableAnimationsOf(context)) {
_animation.value = ziel;
return;
}
_animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve);
}
@override
void dispose() {
_animation.dispose();
super.dispose();
}
}
@@ -7,7 +7,6 @@ import '../library/database.dart';
import '../library/playlist_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -134,8 +133,6 @@ class _Zeile extends StatelessWidget {
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+1 -21
View File
@@ -48,16 +48,6 @@ class BakaAuth extends ChangeNotifier {
String? _token;
String _benutzer = '';
/// Laufender Login-Versuch aus [autoAnmelden] — solange er offen ist,
/// hängt sich ein überlappender Aufruf daran statt einen zweiten
/// Request loszuschicken (Race Condition: mehrere Tabs im IndexedStack
/// lösen beim App-Start gleichzeitig autoAnmelden aus).
Future<void>? _laufenderAutoLogin;
/// Fehlertext des letzten automatischen Anmeldeversuchs — für Diagnose,
/// da autoAnmelden() (anders als anmelden()) keinen Rückgabewert hat.
String? letzterAutoLoginFehler;
bool get istAngemeldet => _token != null && _token!.isNotEmpty;
String get benutzer => _benutzer;
@@ -118,17 +108,7 @@ class BakaAuth extends ChangeNotifier {
/// authentifizierten Aufruf ohnehin automatisch.
Future<void> autoAnmelden(String benutzer, String passwort) async {
if (istAngemeldet || benutzer.isEmpty || passwort.isEmpty) return;
_laufenderAutoLogin ??= _autoAnmeldenIntern(benutzer, passwort)
.whenComplete(() => _laufenderAutoLogin = null);
await _laufenderAutoLogin;
}
Future<void> _autoAnmeldenIntern(String benutzer, String passwort) async {
final fehler = await anmelden(benutzer, passwort);
letzterAutoLoginFehler = fehler;
if (fehler != null) {
debugPrint('Auto-Anmeldung fehlgeschlagen: $fehler');
}
await anmelden(benutzer, passwort);
}
Future<void> abmelden() async {
+14
View File
@@ -110,6 +110,20 @@ class DownloadService extends ChangeNotifier {
return neu;
}
/// Nimmt einen einzelnen Server-Titel offline.
///
/// Bewusst über [lade]: das bringt Doppel-Lauf-Schutz, Verbindungsprüfung
/// und Fortschritts-Buchführung mit. Gibt zurück, ob der Titel neu
/// dazugekommen ist.
///
/// **Bekannte Einschränkung:** Liegt der Titel schon im Abspiel-Zwischen-
/// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen —
/// bis die Verdrängung greift, belegt er doppelten Platz. Der Gegenschutz
/// kostete einen eigenen Cache-Zugang in diesem Dienst; für drei Nutzer ist
/// das der schlechtere Tausch.
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async =>
await lade([song]) > 0;
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
try {
// Datei und Buchführung entstehen nacheinander. Bricht die App
+151 -5
View File
@@ -42,6 +42,14 @@ class CloudSong {
);
}
/// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die
/// einseitige Sicherung nicht.
class CloudPlaylist {
const CloudPlaylist({required this.id, required this.name});
final String id;
final String name;
}
/// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird.
class CloudVerlauf {
const CloudVerlauf({
@@ -114,10 +122,22 @@ class MeloCloudService {
}
/// Liest die Favoriten-IDs aus einer Server-Antwort.
///
/// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere
/// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und
/// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine
/// fälschlich leere Antwort wäre sonst von einer echten nicht zu
/// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird
/// deshalb geworfen.
@visibleForTesting
static List<String> parseFavoriten(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
final liste = daten['favorites'] as List? ?? const [];
final fehler = daten['error'] as String?;
if (fehler != null) throw CloudException(fehler);
final liste = daten['favorites'];
if (liste is! List) {
throw CloudException('Antwort ohne Favoritenliste');
}
return [
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
];
@@ -264,19 +284,145 @@ class MeloCloudService {
return parseFavoriten(antwort.body);
}
/// Ersetzt die Favoriten am Server durch [cloudIds].
Future<void> setzeFavoriten(List<String> cloudIds) async {
/// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend,
/// aber immer **deterministisch**.
///
/// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand,
/// kehrte ein Toggle den Wunsch des Nutzers um.
Future<void> setzeFavorit(String cloudId, bool gesetzt) async {
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/favorites'),
Uri.parse('$basisUrl/favorites/toggle'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': cloudIds}),
body: jsonEncode({'song_id': cloudId, 'set': gesetzt}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
}
/// Der Körper einer Playlisten-Antwort, oder `CloudException`.
///
/// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes
/// `{"status":"not_found"}` **ohne** `error` (so antworten
/// `handle_playlist_remove_song` und `handle_playlist_update_positions`)
/// geht hier bewusst durch: die Sicherung ist einseitig und
/// fire-and-forget, sie verwirft jeden Fehler ohnehin.
Map<String, dynamic> _json(http.Response antwort) {
final daten = jsonDecode(antwort.body) as Map<String, dynamic>;
final fehler = daten['error'] as String?;
if (fehler != null) throw CloudException(fehler);
return daten;
}
/// Legt eine Playlist am Server an und gibt deren ID zurück.
///
/// Die Identität stammt **immer** von hier: `user_playlists.id` ist
/// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es
/// nicht — sie zerbräche beim ersten Umbenennen.
Future<String> legePlaylistAn(String name) async {
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/playlists'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'name': name}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
// handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} —
// die ID liegt eine Ebene tiefer, nicht auf oberster Ebene.
final playlist = _json(antwort)['playlist'];
if (playlist is! Map<String, dynamic>) {
throw CloudException('Antwort ohne Playlist');
}
return '${playlist['id']}';
}
Future<void> fuegePlaylistSongsHinzu(
String playlistCloudId, List<String> songCloudIds) async {
if (songCloudIds.isEmpty) return;
_pruefeAnmeldung();
final antwort = await _client
.post(
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songCloudIds}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
Future<void> entfernePlaylistSong(
String playlistCloudId, String songCloudId) async {
_pruefeAnmeldung();
final antwort = await _client
.delete(
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'),
headers: _kopf,
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
/// Schreibt die Reihenfolge einer Playlist am Server fest.
///
/// Der Körper heißt `positions` und trägt Paare aus `id` und `position`:
/// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag
/// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als
/// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und
/// änderte nichts.
Future<void> setzePlaylistReihenfolge(
String playlistCloudId, List<String> songCloudIds) async {
_pruefeAnmeldung();
final antwort = await _client
.put(
Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'),
headers: {..._kopf, 'Content-Type': 'application/json'},
body: jsonEncode({
'positions': [
for (var i = 0; i < songCloudIds.length; i++)
{'id': songCloudIds[i], 'position': i},
],
}),
)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
_json(antwort);
}
Future<List<CloudPlaylist>> playlisten() async {
_pruefeAnmeldung();
final antwort = await _client
.get(Uri.parse('$basisUrl/playlists'), headers: _kopf)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
final liste = _json(antwort)['playlists'];
if (liste is! List) throw CloudException('Antwort ohne Playlisten');
return [
for (final j in liste)
CloudPlaylist(
id: '${(j as Map<String, dynamic>)['id']}',
name: j['name'] as String? ?? 'Ohne Namen',
),
];
}
Future<List<String>> playlistSongs(String playlistCloudId) async {
_pruefeAnmeldung();
final antwort = await _client
.get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf)
.timeout(const Duration(seconds: 30));
_pruefeStatus(antwort);
final liste = _json(antwort)['songs'];
if (liste is! List) throw CloudException('Antwort ohne Titel');
return [
for (final j in liste) '${(j as Map<String, dynamic>)['id']}',
];
}
/// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
-1
View File
@@ -145,7 +145,6 @@ class NavidromeService {
/// Zugangsdaten.
String get benutzer => _user;
String get passwort => _password;
String get serverUrl => _serverUrl;
/// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException],
/// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) —
+65
View File
@@ -0,0 +1,65 @@
/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne
/// Datenbank, ohne Plattform-Kanäle.
///
/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lässt sich
/// mit einer Handvoll Mengen prüfen statt mit einem halben Server.
library;
/// Wie viele Favoriten je Lauf höchstens gepusht werden.
///
/// Der Rest kommt im nächsten Lauf dran. Die Pushes sind idempotent, ein
/// Teilausfall heilt sich dadurch von selbst.
const int maxFavoritenPushes = 200;
/// Die cloudIds, die zum Server gepusht werden müssen: `lokal \ server`.
///
/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre
/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in
/// keiner Richtung im Abgleich auf.
List<String> zuPushendeFavoriten({
required Set<String> lokaleFavoriten,
required Map<String, String> cloudIdVon,
required Set<String> amServer,
int deckel = maxFavoritenPushes,
}) {
final offen = <String>[];
for (final songId in lokaleFavoriten) {
final cloudId = cloudIdVon[songId];
if (cloudId == null) continue;
if (amServer.contains(cloudId)) continue;
offen.add(cloudId);
if (offen.length >= deckel) break;
}
return offen;
}
/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen:
/// `server \ lokal`.
///
/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht
/// geladen) wird **übersprungen, nicht gelöscht**: ein Favorit ohne Song wäre
/// über den Join unsichtbar, würde aber weiter mitgeschleppt.
List<String> lokalZuSetzendeFavoriten({
required Set<String> amServer,
required Map<String, String> songIdVonCloudId,
required Set<String> lokaleFavoriten,
}) {
final offen = <String>[];
for (final cloudId in amServer) {
final songId = songIdVonCloudId[cloudId];
if (songId == null) continue;
if (lokaleFavoriten.contains(songId)) continue;
offen.add(songId);
}
return offen;
}
/// Ob der „Was ist neu"-Bericht fällig ist.
///
/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist
/// er **nicht** fällig, sonst begrüßt ein frisch eingerichtetes Gerät den
/// Nutzer mit „Willkommen zurück! 325 neue Songs". `sollAutoSync` entscheidet
/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild.
bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) =>
letzterErfolg != null &&
jetzt.difference(letzterErfolg) >= const Duration(hours: 24);
+286 -19
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
@@ -9,6 +10,7 @@ import 'package:uuid/uuid.dart';
import '../library/database.dart';
import 'media_store.dart';
import 'melo_cloud_service.dart';
import 'sync_merge.dart';
/// Was beim Abgleich mit welchen Titeln zu tun ist.
///
@@ -120,6 +122,48 @@ const autoSyncAbstand = Duration(minutes: 15);
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
/// Was ein gezielter Upload erledigt hat — Grundlage der Meldung an den
/// Nutzer. Ein einzelner Fehlschlag darf den Erfolg der übrigen nicht
/// verdecken, deshalb steht hier alles nebeneinander.
class UploadErgebnis {
const UploadErgebnis({
this.hochgeladen = 0,
this.schonDa = 0,
this.fehler = const [],
this.abgebrochen = false,
});
final int hochgeladen;
final int schonDa;
final List<String> fehler;
final bool abgebrochen;
String get meldung {
final teile = <String>[];
if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen');
if (schonDa > 0) teile.add('$schonDa waren schon da');
if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen');
if (abgebrochen) teile.add('abgebrochen');
return teile.isEmpty ? 'Nichts zu tun' : teile.join(', ');
}
}
/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des
/// „Willkommen zurück"-Dialogs.
class SyncBericht {
const SyncBericht({
required this.neueSongs,
required this.geloeschte,
required this.favoriten,
});
final int neueSongs;
final int geloeschte;
final int favoriten;
bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0;
}
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
@@ -132,6 +176,7 @@ class SyncService extends ChangeNotifier {
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
static const _uuid = Uuid();
@@ -146,6 +191,9 @@ class SyncService extends ChangeNotifier {
String? _fehler;
String? _status;
DateTime? _letzterLauf;
DateTime? _letzterErfolg;
SyncBericht? _bericht;
bool _abbruchGewuenscht = false;
bool get laeuft => _laeuft;
int get erledigt => _erledigt;
@@ -154,10 +202,23 @@ class SyncService extends ChangeNotifier {
String? get status => _status;
DateTime? get letzterLauf => _letzterLauf;
/// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal
/// abgeholt und dann mit [berichtGesehen] quittiert.
SyncBericht? get bericht => _bericht;
void berichtGesehen() {
_bericht = null;
notifyListeners();
}
Future<void> laden() async {
final prefs = await SharedPreferences.getInstance();
final ms = prefs.getInt(_letzterLaufKey);
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
final erfolg = prefs.getInt(_letzterErfolgKey);
if (erfolg != null) {
_letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg);
}
notifyListeners();
}
@@ -185,6 +246,14 @@ class SyncService extends ChangeNotifier {
_gesamt = 0;
_melde('Vergleiche mit dem Server …');
// Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am
// Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion,
// Tombstone-Race).
final laufBeginn = DateTime.now();
var vollstaendig = true;
var neueSongs = 0;
var geloeschte = 0;
try {
final amServer = await cloud.liste();
final plan = planeSync(
@@ -204,18 +273,38 @@ class SyncService extends ChangeNotifier {
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
'nichts gelöscht.';
vollstaendig = false;
}
// Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und
// der Erfolgs-Zeitstempel bleibt stehen.
geloeschte = plan.lokalLoeschen.length;
await _ziehLoeschungenNach(plan.lokalLoeschen);
if (!bremse) await _meldeLoeschungen(plan.serverLoeschen);
await _ladeHerunter(plan.herunterladen);
await _ladeHoch(plan.hochladen);
await _gleicheFavoritenAb();
if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) {
vollstaendig = false;
}
neueSongs = await _ladeHerunter(plan.herunterladen);
if (!await _ladeHoch(plan.hochladen)) vollstaendig = false;
if (!await _gleicheFavoritenAb()) vollstaendig = false;
await _meldeVerlauf();
_letzterLauf = DateTime.now();
_letzterLauf = laufBeginn;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch);
if (vollstaendig) {
// „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt.
if (berichtFaellig(_letzterErfolg, laufBeginn)) {
_bericht = SyncBericht(
neueSongs: neueSongs,
geloeschte: geloeschte,
favoriten: 0,
);
}
_letzterErfolg = laufBeginn;
await prefs.setInt(
_letzterErfolgKey, laufBeginn.millisecondsSinceEpoch);
}
} on CloudException catch (e) {
_fehler = e.message;
} catch (e) {
@@ -228,6 +317,104 @@ class SyncService extends ChangeNotifier {
}
}
/// Stoppt einen laufenden Auswahl-Upload zwischen zwei Titeln.
///
/// Nach dem Muster von `DownloadService.abbrechen`: wer versehentlich 60
/// statt 6 Titel markiert hat, soll nicht die App killen müssen.
void abbrechen() {
if (_laeuft) _abbruchGewuenscht = true;
}
/// Lädt genau [songs] zum Server — die ausdrückliche Nutzeraktion aus dem
/// Auswahl-Modus.
///
/// Sequenziell, weil der Server jeden Upload komplett im RAM hält. Titel mit
/// cloudId werden übersprungen, Einzelfehler vermerkt und übergangen.
/// Der Sync-Zeitstempel wird bewusst **nicht** geschrieben: ein Upload ist
/// kein Abgleich, und sonst unterdrückte er 15 Minuten den Auto-Sync und
/// verschöbe die 24-h-Uhr des Berichts.
///
/// Den Offline-Modus-Schalter beachtet er nicht — er ist eine ausdrückliche
/// Nutzeraktion.
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
if (_laeuft) {
_fehler = 'Es läuft gerade ein Abgleich — bitte kurz warten';
notifyListeners();
return const UploadErgebnis();
}
if (!cloud.istAngemeldet) {
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return const UploadErgebnis();
}
_laeuft = true;
_abbruchGewuenscht = false;
_fehler = null;
_erledigt = 0;
_gesamt = songs.length;
notifyListeners();
var hochgeladen = 0;
var schonDa = 0;
final fehler = <String>[];
var abgebrochen = false;
try {
for (final song in songs) {
if (_abbruchGewuenscht) {
abgebrochen = true;
break;
}
if (song.cloudId != null) {
schonDa++;
_erledigt++;
notifyListeners();
continue;
}
final datei = File(song.path);
if (!await datei.exists()) {
fehler.add('${song.title}: Datei nicht gefunden');
_erledigt++;
notifyListeners();
continue;
}
_melde('Sende „${song.title}" …');
try {
final cloudId = await cloud.hochladen(
datei,
dateiname:
'${_sichererDateiname(song.title)}${p.extension(song.path)}',
);
if (cloudId != null) {
await db.setCloudId(song.id, cloudId);
hochgeladen++;
} else {
fehler.add('${song.title}: keine Server-ID erhalten');
}
} on CloudException catch (e) {
fehler.add('${song.title}: ${e.message}');
} on TimeoutException {
fehler.add('${song.title}: Zeitüberschreitung');
}
_erledigt++;
notifyListeners();
}
} finally {
_laeuft = false;
_abbruchGewuenscht = false;
_status = null;
notifyListeners();
}
return UploadErgebnis(
hochgeladen: hochgeladen,
schonDa: schonDa,
fehler: fehler,
abgebrochen: abgebrochen,
);
}
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
if (songs.isEmpty) return;
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
@@ -236,7 +423,13 @@ class SyncService extends ChangeNotifier {
notifyListeners();
}
Future<void> _meldeLoeschungen(List<Song> songs) async {
/// Meldet die hier getombsteten Titel am Server.
///
/// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht
/// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der
/// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist.
Future<bool> _meldeLoeschungen(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) {
_melde('Melde Löschung von „${song.title}“ …');
try {
@@ -244,14 +437,19 @@ class SyncService extends ChangeNotifier {
} on CloudException catch (e) {
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
}
_erledigt++;
notifyListeners();
}
return vollstaendig;
}
Future<void> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return;
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
/// im „Was ist neu"-Bericht.
Future<int> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return 0;
var neu = 0;
final ordner = await _musikOrdner();
for (final cloudSong in songs) {
_melde('Lade „${cloudSong.titel}“ …');
@@ -291,12 +489,21 @@ class SyncService extends ChangeNotifier {
cloudId: Value(cloudSong.id),
),
]);
neu++;
_erledigt++;
notifyListeners();
}
return neu;
}
Future<void> _ladeHoch(List<Song> songs) async {
/// Lädt alle Titel ohne cloudId hoch.
///
/// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer
/// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst
/// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der
/// nächste Scan tombstoned ihn.
Future<bool> _ladeHoch(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) {
final datei = File(song.path);
if (!await datei.exists()) {
@@ -309,29 +516,89 @@ class SyncService extends ChangeNotifier {
datei,
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
);
if (cloudId != null) await db.setCloudId(song.id, cloudId);
if (cloudId != null) {
await db.setCloudId(song.id, cloudId);
} else {
// 200 ohne Server-ID: der Titel ist oben nicht angekommen.
vollstaendig = false;
}
} on CloudException catch (e) {
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
} on TimeoutException {
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
// einziger hängender Upload den ganzen Lauf ab.
debugPrint('Upload „${song.title}“: Zeitüberschreitung');
vollstaendig = false;
}
_erledigt++;
notifyListeners();
}
return vollstaendig;
}
Future<void> _gleicheFavoritenAb() async {
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
/// entfernt aber in **keiner**.
///
/// Damit ist der alte Datenverlust-Bug strukturell unmöglich: es gibt
/// keinen Codepfad mehr, der den Server-Stand ersetzen könnte. Der Preis
/// ist bekannt und bewusst: ein Ent-Favorisieren propagiert nicht
/// geräteübergreifend — hält ein zweites Gerät den Favoriten noch, bringt
/// dessen nächster Abgleich ihn zurück.
///
/// Gibt `true` zurück, wenn die Phase vollständig durchlief.
Future<bool> _gleicheFavoritenAb() async {
_melde('Gleiche Favoriten ab …');
final Set<String> amServer;
try {
amServer = (await cloud.favoriten()).toSet();
} catch (e) {
// Ohne Server-Stand ist nichts zu tun. Blindes Pushen wäre harmlos,
// aber nutzlos — die Phase wird übersprungen, der Lauf geht weiter.
debugPrint('Favoriten-Abgleich übersprungen: $e');
return false;
}
// Grabsteine bleiben außen vor: favoriteSongIds() liefert auch Favoriten
// getombsteter Titel, und die gehören nicht zurück auf den Server.
final lokal = await db.allSongs();
final cloudIdVon = {
for (final s in lokal)
if (s.cloudId != null) s.id: s.cloudId!,
if (s.cloudId != null && !s.deleted) s.id: s.cloudId!,
};
final favoritenIds = await db.favoriteSongIds();
final cloudFavoriten = [
for (final id in favoritenIds)
if (cloudIdVon[id] != null) cloudIdVon[id]!,
];
await cloud.setzeFavoriten(cloudFavoriten);
final songIdVonCloudId = {
for (final s in lokal)
if (s.cloudId != null && !s.deleted) s.cloudId!: s.id,
};
final favoriten = (await db.favoriteSongIds()).toSet();
var vollstaendig = true;
for (final cloudId in zuPushendeFavoriten(
lokaleFavoriten: favoriten,
cloudIdVon: cloudIdVon,
amServer: amServer,
)) {
try {
await cloud.setzeFavorit(cloudId, true);
} catch (e) {
debugPrint('Favorit $cloudId nicht gemeldet: $e');
vollstaendig = false;
}
}
for (final songId in lokalZuSetzendeFavoriten(
amServer: amServer,
songIdVonCloudId: songIdVonCloudId,
lokaleFavoriten: favoriten,
)) {
await db.setFavorite(songId, true);
}
return vollstaendig;
}
Future<void> _meldeVerlauf() async {
+1 -9
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
@@ -105,7 +103,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
enabled: !lib.scanning,
onTap: lib.pickFolderAndScan,
),
if (zeigeErneutScannenButton(Platform.isAndroid))
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Erneut scannen'),
@@ -346,12 +343,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void _zeigeNavidromeDialog() {
final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de');
final passCtrl = TextEditingController();
// Vorauswahl: der zuletzt bekannte Navidrome-Benutzer, falls er einer
// der drei Server-User ist — sonst Fallback "Baka".
const bekannteBenutzer = ['Baka', 'Tinker', 'Dustin'];
String selectedUser = bekannteBenutzer.contains(_navidrome.benutzer)
? _navidrome.benutzer
: 'Baka';
String selectedUser = 'Baka';
bool verbindet = false;
bool obscure = true;
String? fehler;
+27
View File
@@ -7,6 +7,7 @@ import '../library/playlist_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../playlists/create_playlist_dialog.dart';
import '../services/sync_service.dart';
import '../settings/app_settings.dart';
import 'auswahl.dart';
import 'theme.dart';
@@ -22,6 +23,7 @@ class AuswahlLeiste extends StatelessWidget {
required this.onAbbrechen,
required this.onWiedergabeliste,
required this.onWarteschlange,
this.onServerLaden,
});
final int anzahl;
@@ -29,6 +31,10 @@ class AuswahlLeiste extends StatelessWidget {
final VoidCallback onWiedergabeliste;
final VoidCallback onWarteschlange;
/// Nur gesetzt, wo der Upload hingehört („Meine Musik"). Sonst erschiene
/// die Aktion in allen fünf Ansichten, die diese Leiste benutzen.
final VoidCallback? onServerLaden;
@override
Widget build(BuildContext context) {
return Padding(
@@ -49,6 +55,12 @@ class AuswahlLeiste extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
if (onServerLaden != null)
IconButton(
tooltip: 'Auf den Server laden',
icon: const Icon(Icons.cloud_upload),
onPressed: onServerLaden,
),
IconButton(
tooltip: 'Zur Warteschlange hinzufügen',
icon: const Icon(Icons.queue),
@@ -159,3 +171,18 @@ Future<void> fuegeZuWarteschlangeHinzu(
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
));
}
/// Lädt [gewaehlte] zum Melo-Server und meldet das Ergebnis.
///
/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art
/// Rückmeldung.
Future<void> ladeAufServer(
BuildContext context, List<Song> gewaehlte) async {
final sync = context.read<SyncService>();
final messenger = ScaffoldMessenger.of(context);
final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte);
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)),
);
}
-91
View File
@@ -1,91 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'theme.dart';
/// Blendet [child] beim ersten Erscheinen leicht gestaffelt ein — statt
/// abrupt aufzupoppen, faded und rutscht es ein kleines Stück von unten
/// heran. Je weiter hinten in der Liste, desto später beginnt es, damit die
/// Liste als Ganzes fließt statt gleichzeitig aufzublitzen.
///
/// Läuft genau einmal je Element-Lebenszeit: ein Rebuild (etwa weil sich der
/// Song-Titel ändert) startet die Animation nicht erneut.
class EinblendItem extends StatefulWidget {
const EinblendItem({super.key, required this.child, required this.index});
final Widget child;
/// Position in der Liste — bestimmt die Verzögerung vor dem Start.
final int index;
@override
State<EinblendItem> createState() => _EinblendItemState();
}
class _EinblendItemState extends State<EinblendItem>
with SingleTickerProviderStateMixin {
/// Ab Index 12 keine zusätzliche Verzögerung mehr — sonst wirkte eine
/// lange Liste beim ersten Erscheinen träge, weil die letzten Zeilen erst
/// spät anfingen.
static const _deckel = 12;
static const _versatzProItem = Duration(milliseconds: 25);
late final AnimationController _controller = AnimationController(
vsync: this,
duration: MeloMotion.normal,
);
late final Animation<double> _kurve = CurvedAnimation(
parent: _controller,
curve: MeloMotion.curve,
);
bool _gestartet = false;
// Kein Start in initState: die Entscheidung hängt an MediaQuery
// (Reduce-Motion), und darauf darf man dort noch nicht zugreifen — siehe
// LaufBalken. didChangeDependencies läuft unmittelbar danach und ein
// _gestartet-Merker verhindert, dass ein späterer Aufruf (z. B. durch
// eine geänderte Textgröße) die Animation erneut anstößt.
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_gestartet) return;
_gestartet = true;
if (MeloMotion.ruhig(context, MeloMotion.normal) == Duration.zero) {
_controller.value = 1;
return;
}
final verzoegerung = _versatzProItem * math.min(widget.index, _deckel);
if (verzoegerung == Duration.zero) {
_controller.forward();
} else {
Future.delayed(verzoegerung, () {
if (mounted) _controller.forward();
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _kurve,
builder: (context, child) => Opacity(
opacity: _kurve.value,
child: Transform.translate(
offset: Offset(0, MeloSpace.sm * (1 - _kurve.value)),
child: child,
),
),
child: widget.child,
);
}
}
+13
View File
@@ -23,6 +23,7 @@ class SortableSongList extends StatefulWidget {
required this.songs,
required this.storeKey,
this.empty,
this.serverUpload = false,
});
final List<Song> songs;
@@ -31,6 +32,10 @@ class SortableSongList extends StatefulWidget {
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
final Widget? empty;
/// Ob der Auswahl-Modus „Auf den Server laden" anbietet. Standard `false`:
/// dieses Widget steckt in fünf Ansichten, gemeint ist nur „Meine Musik".
final bool serverUpload;
@override
State<SortableSongList> createState() => _SortableSongListState();
}
@@ -67,6 +72,11 @@ class _SortableSongListState extends State<SortableSongList> {
if (mounted) _beendeAuswahl();
}
Future<void> _aufServer(List<Song> gewaehlte) async {
await ladeAufServer(context, gewaehlte);
if (mounted) _beendeAuswahl();
}
@override
void dispose() {
_scroll.dispose();
@@ -196,6 +206,9 @@ class _SortableSongListState extends State<SortableSongList> {
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
onServerLaden: widget.serverUpload
? () => _aufServer(_gewaehlte(sorted))
: null,
)
else
Padding(
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../services/sync_service.dart';
/// „Willkommen zurück" — was sich seit dem letzten erfolgreichen Abgleich
/// getan hat. Rein in-App, ohne Benachrichtigungs-Kanal und ohne neue
/// Abhängigkeit.
class SyncBerichtDialog extends StatelessWidget {
const SyncBerichtDialog({super.key, required this.bericht});
final SyncBericht bericht;
static String textFuer(SyncBericht b) {
if (b.istLeer) return 'Nichts Neues seit dem letzten Abgleich.';
final teile = <String>[];
if (b.neueSongs > 0) teile.add('${b.neueSongs} neue Titel');
if (b.geloeschte > 0) teile.add('${b.geloeschte} entfernt');
if (b.favoriten > 0) teile.add('${b.favoriten} Favoriten geändert');
return '${teile.join(' · ')}.';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Willkommen zurück!'),
content: Text(textFuer(bericht)),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Alles klar'),
),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/library/database.dart';
import 'package:melo/downloads/server_titel_screen.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/navidrome_service.dart';
/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen
/// Server, und darum geht es hier nicht.
class _FakeDownloads extends DownloadService {
_FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService());
final geladen = <String>[];
final entfernt = <String>[];
@override
bool get laeuft => false;
@override
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async {
geladen.add(song.id);
return true;
}
@override
Future<bool> entferne(String navidromeId) async {
entfernt.add(navidromeId);
return true;
}
}
void main() {
final titel = [
const SubsonicSong(
id: 'nav-1',
titel: 'Nachtpuls',
kuenstler: 'Rotklang',
album: 'Nacht',
dauerSekunden: 200,
),
];
Future<({_FakeDownloads dienst, MeloAudioHandler handler})> pumpe(
WidgetTester tester,
{bool schonGeladen = false}) async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
if (schonGeladen) {
// Der Bildschirm liest den Zustand einmal per db.downloadIds().
await db.merkeDownload(DownloadsCompanion.insert(
navidromeId: 'nav-1',
titel: 'Nachtpuls',
groesseBytes: 1,
geladenAmMs: 0,
));
}
final dienst = _FakeDownloads(db);
// MeloAudioHandler startet einen periodischen Timer (Positions-
// Buchführung). Ohne handler.dispose() meldet der Testrahmen ihn als
// „pending" — dasselbe etablierte Muster wie in
// test/library/my_music_screen_test.dart: handler.dispose() explizit am
// Testende aufrufen (addTearDown greift zu spät, außerhalb der
// FakeAsync-Zone, in der der Timer geprüft wird).
final handler = MeloAudioHandler(db: db);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<DownloadService>.value(value: dienst),
Provider<MeloAudioHandler>.value(value: handler),
],
child: MaterialApp(
home: ServerTitelScreen(
titel: 'Nacht',
navidrome: NavidromeService(),
holeTitel: () async => titel,
),
),
),
);
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
return (dienst: dienst, handler: handler);
}
testWidgets('ein einzelner Titel lässt sich offline nehmen', (tester) async {
final (:dienst, :handler) = await pumpe(tester);
await tester.tap(find.byTooltip('Offline nehmen'));
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
expect(dienst.geladen, ['nav-1']);
expect(dienst.entfernt, isEmpty);
handler.dispose();
});
testWidgets('ein schon geladener Titel bietet den Gegenweg an',
(tester) async {
final (:dienst, :handler) =
await pumpe(tester, schonGeladen: true);
// Zustand „schon offline": statt „Offline nehmen" steht dort das
// Entfernen — ohne diesen Test wäre der halbe Knopf ungeprüft.
expect(find.byTooltip('Offline nehmen'), findsNothing);
await tester.tap(find.byTooltip('Vom Gerät entfernen'));
await tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
expect(dienst.entfernt, ['nav-1']);
expect(dienst.geladen, isEmpty);
handler.dispose();
});
}
-53
View File
@@ -248,59 +248,6 @@ void main() {
findsOneWidget);
});
testWidgets(
'Der Anmelde-Dialog schlägt den bekannten Navidrome-Benutzer vor, '
'nicht immer "Baka"', (tester) async {
final auth = BakaAuth(
client: MockClient((_) async => http.Response('', 500)),
speicher: _MemorySpeicher());
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Tinker', 'falsch');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
await tester.tap(find.text('Beim Baka-Konto anmelden'));
await tester.pumpAndSettle();
final feld = tester.widget<TextField>(find.byType(TextField).first);
expect(feld.controller!.text, 'Tinker');
});
testWidgets(
'Erfolgreiches manuelles Anmelden übernimmt das neue Passwort auch '
'für Navidrome — sonst kommt der Auto-Login-Fehler nach Ablauf des '
'Baka-Tokens garantiert wieder', (tester) async {
final auth = BakaAuth(
client: MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
if (body['password'] == 'neuesPasswort') {
return http.Response(jsonEncode({'token': 'jwt-neu'}), 200);
}
return http.Response('', 500);
}),
speicher: _MemorySpeicher(),
);
final nav = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Tinker', 'altesPasswort');
await tester.pumpWidget(_wrap(auth,
db: db, categories: categories, navidrome: nav));
await tester.pumpAndSettle();
await tester.tap(find.text('Beim Baka-Konto anmelden'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'neuesPasswort');
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(nav.passwort, 'neuesPasswort');
expect(nav.benutzer, 'Tinker');
expect(auth.istAngemeldet, isTrue);
});
testWidgets(
'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten '
'beim Baka-Konto an', (tester) async {
@@ -1,100 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/downloads/server_titel_screen.dart';
import 'package:melo/library/database.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/navidrome_service.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
handler = _FakeAudioHandler(db);
});
tearDown(() {
handler.dispose();
});
Widget wrap(Widget child, PlayerExpansionController expansion) =>
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(home: child),
);
testWidgets('Titel antippen öffnet den Vollbild-Player', (tester) async {
final navidrome = NavidromeService()
..setCredentials('https://musik.baka-net.de', 'Baka', 'geheim');
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(wrap(
ServerTitelScreen(
titel: 'Ablaze',
navidrome: navidrome,
holeTitel: () async => const [
SubsonicSong(id: 's1', titel: 'Feuer', kuenstler: 'X', dauerSekunden: 120),
],
),
expansion,
));
await tester.pumpAndSettle();
expect(expansion.progress, 0.0);
await tester.tap(find.text('Feuer'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
+5 -15
View File
@@ -7,7 +7,6 @@ import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
@@ -53,7 +52,6 @@ void main() {
late DownloadService downloads;
late SyncService sync;
MeloAudioHandler? handler;
PlayerExpansionController? expansion;
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -83,14 +81,10 @@ void main() {
// Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer
// von drift im Test-Rahmen hängen — und ein anschließendes db.close()
// käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt).
Widget bauen(WidgetTester tester) {
Widget bauen() {
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
handler ??= MeloAudioHandler(db: db);
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart)
// oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von
// HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut.
expansion ??= PlayerExpansionController(vsync: tester);
return MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
@@ -105,8 +99,6 @@ void main() {
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion!),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
);
@@ -121,8 +113,6 @@ void main() {
await tester.pumpWidget(const SizedBox());
handler?.dispose();
handler = null;
expansion?.dispose();
expansion = null;
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
@@ -136,7 +126,7 @@ void main() {
);
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
await tester.pumpWidget(bauen(tester));
await tester.pumpWidget(bauen());
await tester.pump();
final leiste =
@@ -148,7 +138,7 @@ void main() {
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async {
await tester.pumpWidget(bauen(tester));
await tester.pumpWidget(bauen());
await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
@@ -173,7 +163,7 @@ void main() {
final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async {
await tester.pumpWidget(bauen(tester));
await tester.pumpWidget(bauen());
await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel]));
@@ -192,7 +182,7 @@ void main() {
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
// Läge es woanders, wäre es kein Hauptmenü mehr.
await tester.pumpWidget(bauen(tester));
await tester.pumpWidget(bauen());
await tester.pump();
final menue = tester.getRect(find.byType(BottomNavigationBar));
-76
View File
@@ -7,7 +7,6 @@ import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
@@ -40,7 +39,6 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -61,8 +59,6 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -74,78 +70,6 @@ void main() {
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose();
expansion.dispose();
await db.close();
});
testWidgets('Zurück-Geste auf einem Nicht-Start-Tab wechselt zu '
'"Meine Musik" statt die App zu minimieren', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// Auf "Favoriten" wechseln (Index 3) — kein Navigator.push, nur der Tab.
await tester.tap(find.descendant(
of: find.byType(BottomNavigationBar), matching: find.text('Favoriten')));
await tester.pump();
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
3);
// System-Zurück-Geste simulieren — derselbe Weg, über den auch das
// echte Android-Zurück-Gesten-Signal bei WidgetsApp ankommt.
final widgetsAppState = tester.state(find.byType(WidgetsApp));
// ignore: avoid_dynamic_calls
await (widgetsAppState as dynamic).didPopRoute();
await tester.pump();
// Ohne Abfangen bliebe der Tab unverändert (Flutter minimiert dann die
// App statt zu navigieren) — die Geste muss stattdessen zu Tab 0 führen.
expect(
tester
.widget<BottomNavigationBar>(find.byType(BottomNavigationBar))
.currentIndex,
0);
// Baum abbauen, bevor der Test endet: der Positions-Timer des Audio-
// Handlers läuft sonst über das Testende hinaus weiter, und der
// Test-Rahmen meldet einen offenen Timer (wie in hauptmenue_test.dart
// dokumentiert). Aus demselben Grund bewusst kein db.close() nach
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
-21
View File
@@ -1,21 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/library_service.dart';
// Bug (Tinker): Auf Android zeigten die Einstellungen zwei Buttons mit
// identischer Aufgabe ("Musik scannen" und "Erneut scannen") — beide riefen
// auf Android denselben Geräte-weiten MediaStore-Scan auf. Die Sichtbarkeit
// des zweiten Buttons hängt eigentlich von Platform.isAndroid ab, das sich
// in Tests nicht sauber auf "Android" umschalten lässt — deshalb ist die
// Entscheidung hier als pure, plattformunabhängig testbare Funktion
// ausgelagert.
void main() {
group('zeigeErneutScannenButton', () {
test('auf Android ausgeblendet (identisch zu "Musik scannen")', () {
expect(zeigeErneutScannenButton(true), isFalse);
});
test('auf Desktop angezeigt (echter Unterschied: bekannte Ordner erneut scannen)', () {
expect(zeigeErneutScannenButton(false), isTrue);
});
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:drift/drift.dart' show Migrator;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
void main() {
test('Bestandsdaten überleben die neue Spalte', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
// Den Stand von Schema 10 nachbauen: Tabelle ohne cloud_id, mit Daten.
await db.customStatement('DROP TABLE playlist_songs');
await db.customStatement('DROP TABLE playlists');
await db.customStatement(
'CREATE TABLE playlists ('
'id TEXT NOT NULL, '
'name TEXT NOT NULL, '
'description TEXT NULL, '
'created_at_ms INTEGER NOT NULL, '
'updated_at_ms INTEGER NOT NULL, '
'deleted INTEGER NOT NULL DEFAULT 0, '
'PRIMARY KEY (id))',
);
await db.customStatement(
"INSERT INTO playlists (id, name, created_at_ms, updated_at_ms) "
"VALUES ('alt-1', 'Road Trip', 0, 0)",
);
await Migrator(db).addColumn(db.playlists, db.playlists.cloudId);
final rows = await db.select(db.playlists).get();
expect(rows.single.name, 'Road Trip');
expect(rows.single.cloudId, isNull);
});
test('setPlaylistCloudId merkt sich die Server-ID', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final id = await db.createPlaylist('Mix');
await db.setPlaylistCloudId(id, '42');
expect((await db.playlistById(id))!.cloudId, '42');
});
test('countPlaylists zählt auch Grabsteine', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final id = await db.createPlaylist('Mix');
await db.deletePlaylist(id);
// Sonst hielte die Wiederherstellung ein Gerät, auf dem der Nutzer alle
// Playlisten gelöscht hat, für eine Neuinstallation.
expect(await db.countPlaylists(), 1);
});
}
+131
View File
@@ -1,7 +1,32 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() {
late MeloDb db;
@@ -61,4 +86,110 @@ void main() {
expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']);
expect(notified, true);
});
group('Sofort-Push der Favoriten', () {
Future<void> legeSongAn(MeloDb db, {String? cloudId}) async {
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/a.mp3',
title: 'A',
dateAddedMs: 0,
updatedAtMs: 0,
));
if (cloudId != null) await db.setCloudId('song-1', cloudId);
}
test('setzt deterministisch true beim Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': true}
]);
});
test('setzt deterministisch false beim Ent-Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
await db2.setFavorite('song-1', true);
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': false}
]);
});
test('ohne cloudId wird nichts gemeldet', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2);
var anfragen = 0;
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((_) async {
anfragen++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(anfragen, 0);
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
test('ein Fehler des Servers ändert lokal nichts und wirft nicht',
() async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient(
(_) async => http.Response(jsonEncode({'error': 'weg'}), 500)),
),
);
// Der nächste Voll-Abgleich holt den Push additiv nach.
await dienst.toggleFavorite('song-1');
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
});
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<MeloCloudService> cloudMit(
Future<http.Response> Function(http.Request) antwort) async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return MeloCloudService(auth: auth, client: MockClient(antwort));
}
void main() {
test('eine neue Playlist wird gemeldet und ihre Server-ID gemerkt',
() async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((_) async => http.Response(
// Form von handle_playlist_create: die ID liegt unter „playlist".
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Road Trip'}
}),
200,
)),
);
final id = await dienst.createPlaylist('Road Trip');
expect((await db.playlistById(id))!.cloudId, '7');
});
test('ein Song ohne cloudId wird nicht mitgemeldet', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
var songMeldungen = 0;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/songs')) songMeldungen++;
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1', path: '/a.mp3', title: 'A',
dateAddedMs: 0, updatedAtMs: 0,
));
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
expect(songMeldungen, 0);
// Lokal ist er trotzdem drin — kein Fehler, nur nichts zu melden.
expect(await db.watchPlaylistSongs(id).first, hasLength(1));
});
/// Legt einen Titel mit Server-ID an. Ohne den fällt jeder Push aus, und
/// die Negativtests allein hätten den ganzen Vertrag nie berührt.
Future<void> legeSongAn(MeloDb db, String id, String cloudId) async {
await db.into(db.songs).insert(SongsCompanion.insert(
id: id, path: '/$id.mp3', title: id,
dateAddedMs: 0, updatedAtMs: 0,
));
await db.setCloudId(id, cloudId);
}
test('ein Song mit cloudId wird an die Server-Playlist gemeldet', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
Object? koerper;
String? pfad;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.method == 'POST' && anfrage.url.path.endsWith('/songs')) {
pfad = anfrage.url.path;
koerper = jsonDecode(anfrage.body);
}
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
expect(pfad, endsWith('/playlists/7/songs'));
expect(koerper, {
'song_ids': ['c1']
});
});
test('das Entfernen geht als DELETE auf den Song-Pfad', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
String? geloeschterPfad;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.method == 'DELETE') geloeschterPfad = anfrage.url.path;
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
await dienst.removeSongFromPlaylist(id, 'song-1');
expect(geloeschterPfad, endsWith('/playlists/7/songs/c1'));
expect(await db.watchPlaylistSongs(id).first, isEmpty);
});
test('eine neue Reihenfolge geht als positions-Paare raus', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
Object? koerper;
String? methode;
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/positions')) {
methode = anfrage.method;
koerper = jsonDecode(anfrage.body);
}
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Mix'}
}),
200,
);
}),
);
await legeSongAn(db, 'song-1', 'c1');
await legeSongAn(db, 'song-2', 'c2');
final id = await dienst.createPlaylist('Mix');
await dienst.addSongToPlaylist(id, 'song-1', 0);
await dienst.addSongToPlaylist(id, 'song-2', 1);
await dienst.reorderAll(id, ['song-2', 'song-1']);
// Unter „song_ids" hätte der Server eine leere Liste gelesen und stumm
// „ok" geantwortet — dieser Test ist der einzige Ort, an dem das auffällt.
expect(methode, 'PUT');
expect(koerper, {
'positions': [
{'id': 'c2', 'position': 0},
{'id': 'c1', 'position': 1},
]
});
});
test('ein Endpunkt-Fehler ändert lokal nichts', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit(
(_) async => http.Response(jsonEncode({'error': 'weg'}), 500)),
);
final id = await dienst.createPlaylist('Mix');
expect((await db.playlistById(id))!.cloudId, isNull);
expect(await db.watchPlaylists().first, hasLength(1));
});
test('Wiederherstellung greift nur bei leerer Playlisten-Tabelle', () async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((anfrage) async {
if (anfrage.url.path.endsWith('/playlists')) {
return http.Response(
jsonEncode({
'playlists': [
{'id': 7, 'name': 'Vom Server'}
]
}),
200,
);
}
return http.Response(jsonEncode({'songs': []}), 200);
}),
);
expect(await dienst.stelleWiederHer(), 1);
final angelegt = await db.watchPlaylists().first;
expect(angelegt.single.name, 'Vom Server');
expect(angelegt.single.cloudId, '7');
});
test('mit einer lokalen Playlist wird nichts angelegt (kein Rück-Merge)',
() async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = PlaylistService(
db,
cloud: await cloudMit((_) async => http.Response(
jsonEncode({
'playlists': [
{'id': 7, 'name': 'Vom Server'}
]
}),
200,
)),
);
await db.createPlaylist('Meine eigene');
expect(await dienst.stelleWiederHer(), 0);
expect(await db.watchPlaylists().first, hasLength(1));
});
}
-73
View File
@@ -1,73 +0,0 @@
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
Future<void> _insertSong(MeloDb db, String id, {bool deleted = false}) {
return db.into(db.songs).insert(SongsCompanion.insert(
id: id,
path: '/$id.mp3',
title: id,
dateAddedMs: 0,
updatedAtMs: 0,
deleted: Value(deleted),
));
}
Future<void> _play(MeloDb db, String songId, int playedAtMs) {
return db.into(db.playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
positionMs: 0,
playedAtMs: playedAtMs,
));
}
void main() {
test('ein mehrfach gespielter Song erscheint nur einmal, an der Position '
'seines jüngsten Abspielens', () async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _play(db, 'a', 1000);
await _play(db, 'b', 2000);
await _play(db, 'a', 3000); // a erneut gespielt, jetzt jüngster Play
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['a', 'b']);
await db.close();
});
test('mehrere Songs erscheinen absteigend nach ihrem letzten Abspielen',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a');
await _insertSong(db, 'b');
await _insertSong(db, 'c');
await _play(db, 'a', 1000);
await _play(db, 'b', 3000);
await _play(db, 'c', 2000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b', 'c', 'a']);
await db.close();
});
test('ein gelöschter Song mit Wiedergabe-Historie erscheint nicht',
() async {
final db = MeloDb(NativeDatabase.memory());
await _insertSong(db, 'a', deleted: true);
await _insertSong(db, 'b');
await _play(db, 'a', 2000);
await _play(db, 'b', 1000);
final result = await db.watchRecentlyPlayed().first;
expect(result.map((s) => s.id).toList(), ['b']);
await db.close();
});
}
-132
View File
@@ -1,132 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
testWidgets('Song antippen öffnet den Vollbild-Player mit großem Cover',
(tester) async {
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
// Aufräum-Zeile wie in song_zeile_lauf_test.dart.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
-30
View File
@@ -1,30 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:melo/player/audio_handler.dart';
void main() {
group('toAudioServiceShuffleMode', () {
test('true wird zu AudioServiceShuffleMode.all', () {
expect(toAudioServiceShuffleMode(true), AudioServiceShuffleMode.all);
});
test('false wird zu AudioServiceShuffleMode.none', () {
expect(toAudioServiceShuffleMode(false), AudioServiceShuffleMode.none);
});
});
group('toAudioServiceRepeatMode', () {
test('LoopMode.one wird zu AudioServiceRepeatMode.one', () {
expect(toAudioServiceRepeatMode(LoopMode.one), AudioServiceRepeatMode.one);
});
test('LoopMode.all wird zu AudioServiceRepeatMode.all', () {
expect(toAudioServiceRepeatMode(LoopMode.all), AudioServiceRepeatMode.all);
});
test('LoopMode.off wird zu AudioServiceRepeatMode.none', () {
expect(toAudioServiceRepeatMode(LoopMode.off), AudioServiceRepeatMode.none);
});
});
}
-171
View File
@@ -1,171 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/player/player_expansion_controller.dart';
// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell
// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen.
// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet,
// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test
// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double.
class _TestShell extends StatefulWidget {
const _TestShell({this.tabContent = const SizedBox.shrink()});
final Widget tabContent;
@override
State<_TestShell> createState() => _TestShellState();
}
class _TestShellState extends State<_TestShell> with TickerProviderStateMixin {
late final PlayerExpansionController _controller;
@override
void initState() {
super.initState();
_controller = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Eigener AnimatedBuilder um PopScope, damit canPop bei jedem
// _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem
// Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds
// eingefroren, weil sonst nichts hier ein Rebuild auslöst.
return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _controller,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => PopScope(
canPop: _controller.progress == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.close(context);
},
child: child!,
),
// Derselbe Stack-Aufbau wie HomeShell (Task 4): Blur-Layer und
// Tab-Blocker bekommen jeweils ihren eigenen AnimatedBuilder, NICHT
// nur den äußeren aus dem PopScope-Consumer oben (Reaktivitäts-Regel
// aus dem Plan) — sonst würde dieser Test den genau davor gefundenen
// Bug (Review-Panel) nicht abdecken.
child: Scaffold(
body: Stack(
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) => IgnorePointer(
ignoring: _controller.progress > 0,
child: child,
),
child: widget.tabContent,
),
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final p = _controller.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
child: Container(color: Colors.transparent),
),
);
},
),
],
),
),
),
);
}
}
void main() {
testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
});
testWidgets('Blur-Layer erscheint erst, wenn progress > 0', (tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
expect(find.byType(BackdropFilter), findsNothing);
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
// Ein zusätzlicher, leerer pump() startet den Ticker — sein erster Tick
// liefert laut Flutter-Konvention elapsed=0 (Baseline), erst der
// folgende pump(duration) rückt die Animation tatsächlich vorwärts.
// Ohne diesen Zwischenschritt bliebe progress nach nur einem
// pump(duration) fälschlich bei 0.0 (per Debug-Ausgabe verifiziert).
await tester.pump();
await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('Tab-Inhalt ist bei offenem Player nicht mehr antippbar',
(tester) async {
var getappt = false;
await tester.pumpWidget(MaterialApp(
home: _TestShell(
tabContent: GestureDetector(
onTap: () => getappt = true,
child: const SizedBox(width: 200, height: 200),
),
),
));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
await tester.tap(find.byType(GestureDetector), warnIfMissed: false);
expect(getappt, isFalse);
});
testWidgets(
'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final ctx = tester.element(find.byType(_TestShell));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.dragBy(-250);
await tester.pump();
expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01));
expect(find.byType(BackdropFilter), findsOneWidget);
controller.dragEnd(ctx, -250, 0, ausVollbild: false);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
expect(find.byType(BackdropFilter), findsNothing);
});
}
@@ -1,181 +0,0 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Dieselbe _MemorySpeicher/Provider-Verdrahtung wie test/home_shell_test.dart
// — von dort übernommen, nicht neu erfunden.
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'Echtes HomeShell: Blur-Layer reagiert live auf progress, nicht erst '
'beim nächsten fremden Rebuild', (tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
expect(find.byType(BackdropFilter), findsNothing);
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix oberhalb
// von MaterialApp/HomeShell (analog zu MeloApps MultiProvider in
// main.dart), nicht mehr innerhalb von HomeShell.build() — jedes Element
// im Baum sieht denselben Controller, hier über ein Element unterhalb von
// HomeShell (MiniPlayer, immer im Stack, auch ohne laufenden Titel).
final descendantContext = tester.element(find.byType(MiniPlayer));
final controller = Provider.of<PlayerExpansionController>(
descendantContext,
listen: false);
controller.open(descendantContext);
// Absichtlich NUR EINEN kurzen Zwischenschritt pumpen (nicht
// pumpAndSettle) — das ist der entscheidende Unterschied zum
// `_TestShell`-Test: er beweist, dass der Blur-Layer OHNE einen
// zusätzlichen, fremden setState()-Trigger (z.B. Tab-Wechsel) live
// erscheint.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle();
// Baum abbauen, bevor der Test endet — derselbe Grund wie in
// test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers).
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
testWidgets(
'State-Persistenz: _CoverGrundState bleibt über einen '
'open()/close()/open()-Zyklus erhalten (Spec-Vorteil ggü. dem früheren '
'Navigator.push, das die Route bei jedem Schließen disposte)',
(tester) async {
final db = MeloDb(NativeDatabase.memory());
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) =>
DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// _CoverGrund ist privat (now_playing_screen.dart) — von außen per
// runtimeType-Namen gefunden statt per find.byType, das den Typ
// importierbar/öffentlich voraussetzen würde. skipOffstage: false ist
// hier nötig: bei progress == 0 steckt NowPlayingScreen in einem
// Visibility(visible: false, maintainState: true) — genau das soll
// dieser Test ja beweisen —, und Flutters Standard-Finder überspringen
// Offstage-Elemente per Default (per Debug-Ausgabe verifiziert).
final coverGrundFinder = find.byWidgetPredicate(
(w) => w.runtimeType.toString() == '_CoverGrund',
skipOffstage: false);
expect(coverGrundFinder, findsOneWidget,
reason: 'NowPlayingScreen bleibt dank Visibility(maintainState: '
'true) (Task 5) von Anfang an im Baum, auch bei progress == 0.');
final zustandVorher = tester.state(coverGrundFinder);
final descendantContext = tester.element(find.byType(MiniPlayer));
final controller = Provider.of<PlayerExpansionController>(
descendantContext,
listen: false);
controller.open(descendantContext);
await tester.pumpAndSettle();
controller.close(descendantContext);
await tester.pumpAndSettle();
controller.open(descendantContext);
await tester.pumpAndSettle();
final zustandNachher = tester.state(coverGrundFinder);
expect(identical(zustandVorher, zustandNachher), isTrue,
reason: '_CoverGrundState wäre beim früheren Navigator.push-Ansatz '
'bei jedem Schließen disposed und neu erzeugt worden.');
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
-198
View File
@@ -1,198 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/library/song_list.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/now_playing_screen.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/favorite_button.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in song_list_navigation_test.dart, dort erprobt.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
Song derSong() => Song(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: 'Neoni',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
);
// Startet die Wiedergabe über denselben Weg wie in
// song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist
// dort nachweislich unproblematisch und übt die echte SongZeile→MediaItem
// -Umwandlung. Seit dem Umbau auf PlayerExpansionController (Plan
// 2026-08-29-blur-oeffnen-transition, Task 3/5) ist NowPlayingScreen kein
// per Navigator.push geöffneter Screen mehr, sondern reiner Inhalt — hier
// deshalb direkt in einen eigenen Scaffold gepumpt (Task 5 Step 3), statt
// eine Route zu pushen. Der Handler behält den zuvor gestarteten Titel in
// seinem mediaItem-Stream, den NowPlayingScreen darüber sofort sieht.
Future<PlayerExpansionController> oeffnePlayer(WidgetTester tester) async {
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
final providers = [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
];
await tester.pumpWidget(
MultiProvider(
providers: providers,
child: MaterialApp(
home: Scaffold(
body: SongZeile(
song: derSong(),
warteschlange: [derSong()],
index: 0,
),
),
),
),
);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
await tester.pumpWidget(
MultiProvider(
providers: providers,
child: MaterialApp(
home: Scaffold(body: const NowPlayingScreen()),
),
),
);
await tester.pumpAndSettle();
return expansion;
}
Future<void> raeumeAuf(WidgetTester tester) async {
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
// Aufräum-Zeile wie in song_list_navigation_test.dart.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
testWidgets(
'Herz sitzt neben dem Songnamen, nicht mehr in der AppBar',
(tester) async {
await oeffnePlayer(tester);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// NowPlayingScreen ist seit dem Overlay-Umbau (Task 5) kein Scaffold/
// keine Route mehr und hat gar keine AppBar mehr — die frühere
// "nicht mehr in der AppBar"-Prüfung ist jetzt trivial durch den Umbau
// selbst erfüllt.
expect(find.byType(AppBar), findsNothing);
// Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel.
final titelRow = find
.ancestor(of: find.text('Roses'), matching: find.byType(Row))
.first;
final herzBeimTitel = find.descendant(
of: titelRow,
matching: find.byType(FavoriteButton),
);
expect(herzBeimTitel, findsOneWidget);
await raeumeAuf(tester);
});
testWidgets('Ziehen nach unten im Vollbild-Player verringert progress live',
(tester) async {
// _TestShell (home_shell_expansion_test.dart) hat bewusst keinen echten
// MeloAudioHandler-Provider (siehe dortiger Kommentar) — ein echter
// NowPlayingScreen bräuchte den aber für context.read<MeloAudioHandler>().
// Dieser Test läuft deshalb hier, wo oeffnePlayer() bereits alle nötigen
// Provider bereitstellt (Plan Task 8 nennt now_playing_screen_test.dart
// selbst als Alternative zu home_shell_expansion_test.dart).
final expansion = await oeffnePlayer(tester);
expect(expansion.progress, 1.0);
await tester.drag(find.byType(NowPlayingScreen), const Offset(0, 150));
await tester.pump();
expect(expansion.progress, lessThan(1.0));
// dragEnd() (ausgelöst durch tester.drag) startet ein fling() — ohne
// pumpAndSettle bliebe dessen Ticker beim Testende aktiv.
await tester.pumpAndSettle();
await raeumeAuf(tester);
});
}
@@ -1,128 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/now_playing_screen.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in now_playing_heart_test.dart.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'NowPlayingScreen in einem während der Öffnen-Geste noch kleinen, '
'interpolierten Rechteck (progress ~0.4) wirft keinen '
'RenderFlex-Overflow', (tester) async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
stelleKanaeleRuhig();
final db = MeloDb(NativeDatabase.memory());
final kategorien = CategoryService(db);
final playlists = PlaylistService(db);
final einstellungen = AppSettings();
await einstellungen.init();
final handler = _FakeAudioHandler(db);
addTearDown(kategorien.dispose);
addTearDown(playlists.dispose);
// Ein echtes MediaItem, wie es beim Abspielen im mediaItem-Stream steht
// — NowPlayingScreen zeigt sonst nur "Nichts in Wiedergabe" und hat dann
// gar keinen Inhalt, der überlaufen könnte.
handler.mediaItem.add(const MediaItem(
id: 'song-1',
title: 'Roses',
artist: 'Neoni',
));
// Dieselbe Rect-Interpolation wie HomeShell._miniRect/_vollbildRect bei
// progress=0.4 auf der Standard-Testbildschirmgröße (800x600, siehe
// flutter_test-Default) — main.dart positioniert NowPlayingScreen
// während der Öffnen-Geste über genau so ein Positioned.fromRect mit
// Rect.lerp zwischen Mini- und Vollbild-Rechteck. Bei p>0.3 blendet der
// Inhalt laut Spec schon sichtbar ein (main.dart: Opacity((p-0.3)/0.7)),
// das Rechteck ist zu dem Zeitpunkt aber noch weit von der
// Vollbildgröße entfernt.
const p = 0.4;
const screenSize = Size(800, 600);
const miniHoehe = MiniPlayer.hoehe;
final unten = screenSize.height - miniHoehe - kBottomNavigationBarHeight;
final miniRect = Rect.fromLTWH(0, unten, screenSize.width, miniHoehe);
final vollbildRect =
Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
final rect = Rect.lerp(miniRect, vollbildRect, p)!;
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
body: Stack(
children: [
Positioned.fromRect(
rect: rect,
child: const NowPlayingScreen(),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Aufräum-Reihenfolge wie in den übrigen Tests dieser Datei-Familie:
// erst den Baum abbauen, dann den Handler disposen (offener Positions-
// Timer, siehe now_playing_heart_test.dart).
await tester.pumpWidget(const SizedBox());
handler.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
@@ -1,192 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/player_expansion_controller.dart';
void main() {
Widget mitMediaQuery(bool disableAnimations, WidgetBuilder builder) {
return MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: MaterialApp(home: Builder(builder: builder)),
);
}
testWidgets('progress startet bei 0', (tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('dragBy nach oben erhöht progress, nach unten verringert ihn',
(tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-150); // halbe Referenzhöhe nach oben
expect(controller.progress, closeTo(0.5, 0.001));
controller.dragBy(75); // ein Viertel zurück nach unten
expect(controller.progress, closeTo(0.25, 0.001));
controller.dispose();
});
testWidgets('dragBy clampt auf [0, 1]', (tester) async {
late PlayerExpansionController controller;
await tester.pumpWidget(mitMediaQuery(false, (context) {
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-1000);
expect(controller.progress, 1.0);
controller.dragBy(2000);
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('open() fliegt zu 1, close() fliegt zu 0', (tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.close(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets('open()/close() springen bei disableAnimations sofort',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(true, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
expect(controller.progress, 1.0); // ohne pumpAndSettle sofort da
controller.close(ctx);
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Öffnen: ausreichende Strecke nach oben fliegt zu 1',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-200);
controller.dragEnd(ctx, -200, 0, ausVollbild: false); // Strecke > wischStreckeSchwelle (80)
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Öffnen: zu kurze, langsame Geste fällt zurück auf 0',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.dragBy(-30);
controller.dragEnd(ctx, -30, 0, ausVollbild: false); // unter wischStreckeSchwelle
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Schließen: ausreichende Strecke nach unten fliegt zu 0',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
controller.dragBy(200); // nach unten, Betrag > Referenzhöhe/2
controller.dragEnd(ctx, 200, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
controller.dispose();
});
testWidgets(
'dragEnd beim Schließen: zu kurze, langsame Geste bleibt bei 1',
(tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
controller.dragBy(30);
controller.dragEnd(ctx, 30, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
testWidgets(
'dragEnd bei bereits vollem Player: schwaches Fingerzittern nach oben '
'klappt ihn NICHT zu (asymmetrischer Bug — dragEnd darf die '
'Entscheidungsrichtung nicht aus dem Vorzeichen von strecke raten, '
'sondern muss den Aufrufer-Kontext kennen)', (tester) async {
late PlayerExpansionController controller;
late BuildContext ctx;
await tester.pumpWidget(mitMediaQuery(false, (context) {
ctx = context;
controller = PlayerExpansionController(vsync: tester);
return const SizedBox();
}));
controller.open(ctx);
await tester.pumpAndSettle();
// Der Player ist voll offen (progress==1). Der Nutzer verrutscht beim
// Versuch, einen Knopf zu treffen, leicht nach oben — eine winzige,
// negative Strecke weit unter der Wisch-Schwelle. Das darf gar keine
// Zustandsänderung auslösen, weil die Geste viel zu schwach ist — schon
// gar nicht ein Zuklappen, das würde "nach oben ziehen schließt den
// offenen Player" bedeuten, was es nicht tut.
controller.dragBy(-5);
controller.dragEnd(ctx, -5, 0, ausVollbild: true);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
controller.dispose();
});
}
@@ -1,150 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/library_service.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/main.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/mini_player.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/navidrome_service.dart';
import 'package:melo/services/offline_mode.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/services/yt_download_service.dart';
import 'package:melo/services/yt_search_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _MemorySpeicher implements TokenSpeicher {
final Map<String, String> werte = {};
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
/// dieselbe Klasse wie in playlist_detail_navigation_test.dart.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets(
'Per Navigator.push geöffneter PlaylistDetailScreen (wie aus '
'playlists_screen.dart:120) sieht PlayerExpansionController — echtes '
'HomeShell, echter Navigator.push, kein direkt daneben geklebter '
'Test-Provider (Review-Fund: Provider war bisher nur INNERHALB von '
'HomeShell.build() bereitgestellt, eine gepushte Route ist im Baum '
'aber ein Geschwister von HomeShell, kein Nachfahre)', (tester) async {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
final db = MeloDb(NativeDatabase.memory());
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
final playlistId = await db.createPlaylist('Road Trip');
await db.addSongToPlaylist(playlistId, 'song-1', 0);
final playlist = Playlist(
id: playlistId,
name: 'Road Trip',
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
final lib = LibraryService(db);
final playlists = PlaylistService(db);
final handler = _FakeAudioHandler(db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
// Dieselbe Provider-Verdrahtung wie home_shell_real_expansion_test.dart,
// PlayerExpansionController aber jetzt OBERHALB von MaterialApp/HomeShell
// — genau wie MeloApps MultiProvider nach dem Fix (main.dart), nicht mehr
// nur INNERHALB von HomeShell.build() bereitgestellt.
await tester.pumpWidget(MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<LibraryService>.value(value: lib),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
await tester.pump();
// Navigator.push GENAU wie playlists_screen.dart:120 — von einem Kontext
// INNERHALB des echten HomeShell-Baums aus (MiniPlayer steckt immer im
// Stack, auch ohne laufenden Titel), nicht von einem manuell daneben
// gebauten Test-Baum.
final homeShellContext = tester.element(find.byType(MiniPlayer));
Navigator.of(homeShellContext).push(MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(playlist: playlist),
));
await tester.pumpAndSettle();
expect(find.text('Roses'), findsOneWidget);
// Tap wie ein echter Nutzer — löst intern playSongs() und danach
// context.read<PlayerExpansionController>().open(context) aus.
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
// Bug-Symptom: die ProviderNotFoundException landet im try/catch von
// PlaylistDetailScreen und zeigt eine "Wiedergabe fehlgeschlagen"-
// Snackbar, statt dass der Player öffnet.
expect(find.textContaining('Wiedergabe fehlgeschlagen'), findsNothing,
reason: 'context.read<PlayerExpansionController>() darf hier keine '
'ProviderNotFoundException werfen.');
// Beweis, dass .open() tatsächlich denselben (einzigen) Controller
// erreicht hat, nicht nur, dass keine Exception flog.
expect(expansion.progress, 1.0);
// Aufräum-Reihenfolge wie home_shell_real_expansion_test.dart: erst den
// Baum abbauen, dann den Handler disposen (offener Positions-Timer).
await tester.pumpWidget(const SizedBox());
handler.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/now_playing_screen.dart';
void main() {
// WanderndesCover.build() liefert ein Positioned zurück (siehe
// now_playing_screen.dart) — Positioned braucht laut Flutter zwingend
// einen Stack als Vorfahren, sonst wirft es "Incorrect use of
// ParentDataWidget" (per Testlauf verifiziert). Genau so wird es auch in
// main.dart verwendet (direkt als Kind des HomeShell-Stacks), deshalb hier
// ebenfalls in einen Stack gebettet statt direkt als Scaffold.body.
testWidgets('Rect bei progress=0 entspricht dem Mini-Player-Rechteck',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 0,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(box, const Rect.fromLTWH(0, 700, 400, 52));
});
testWidgets('Rect bei progress=1 entspricht dem Vollbild-Rechteck',
(tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 1,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(box, const Rect.fromLTWH(0, 0, 400, 800));
});
testWidgets('Rect bei progress=0.5 liegt in der Mitte', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Stack(
children: [
WanderndesCover(
progress: 0.5,
artUri: null,
miniRect: const Rect.fromLTWH(0, 700, 400, 52),
vollbildRect: const Rect.fromLTWH(0, 0, 400, 800),
),
],
),
),
));
final box = tester.getRect(find.byType(WanderndesCover));
expect(
box,
Rect.lerp(
const Rect.fromLTWH(0, 700, 400, 52),
const Rect.fromLTWH(0, 0, 400, 800),
0.5,
),
);
});
}
@@ -1,125 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/player/audio_handler.dart';
import 'package:melo/player/player_expansion_controller.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/settings/app_settings.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
/// der spricht über Plattform-Kanäle, die im Widget-Test nicht existieren
/// und mit `MissingPluginException` scheitern würden. Alles andere
/// (Warteschlange, Mediendaten) bleibt echtes Handler-Verhalten.
class _FakeAudioHandler extends MeloAudioHandler {
_FakeAudioHandler(MeloDb db) : super(db: db);
@override
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
return 0;
}
}
void main() {
late MeloDb db;
late CategoryService kategorien;
late PlaylistService playlists;
late AppSettings einstellungen;
late _FakeAudioHandler handler;
late String playlistId;
void stelleKanaeleRuhig() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp/melo-test',
);
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => null,
);
}
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
stelleKanaeleRuhig();
db = MeloDb(NativeDatabase.memory());
kategorien = CategoryService(db);
playlists = PlaylistService(db);
einstellungen = AppSettings();
await einstellungen.init();
handler = _FakeAudioHandler(db);
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/music/roses.mp3',
title: 'Roses',
artist: const Value('Neoni'),
dateAddedMs: 0,
updatedAtMs: 0,
));
playlistId = await db.createPlaylist('Road Trip');
await db.addSongToPlaylist(playlistId, 'song-1', 0);
});
tearDown(() {
handler.dispose();
kategorien.dispose();
playlists.dispose();
});
testWidgets(
'Song in einer Wiedergabeliste antippen öffnet den Vollbild-Player',
(tester) async {
final playlist = Playlist(
id: playlistId,
name: 'Road Trip',
createdAtMs: 0,
updatedAtMs: 0,
deleted: false,
);
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: PlaylistDetailScreen(playlist: playlist),
),
),
);
await tester.pumpAndSettle();
expect(expansion.progress, 0.0);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
});
}
-38
View File
@@ -115,44 +115,6 @@ void main() {
expect(auth.benutzer, 'Baka');
});
test(
'autoAnmelden löst bei gleichzeitigen Aufrufen nur EINEN HTTP-Request aus '
'(Race Condition durch IndexedStack, das mehrere Tabs gleichzeitig baut)',
() async {
var aufrufe = 0;
final client = MockClient((_) async {
aufrufe++;
// Künstliche Verzögerung: simuliert, dass der erste Request noch
// nicht zurück ist, wenn der zweite Aufruf startet.
await Future.delayed(const Duration(milliseconds: 50));
return http.Response(jsonEncode({'token': 'jwt-tinker'}), 200);
});
final auth = BakaAuth(client: client, speicher: _MemorySpeicher());
await Future.wait([
auth.autoAnmelden('Tinker', 'geheim'),
auth.autoAnmelden('Tinker', 'geheim'),
]);
expect(aufrufe, 1);
expect(auth.istAngemeldet, isTrue);
expect(auth.benutzer, 'Tinker');
});
test(
'autoAnmelden merkt sich den Fehlergrund für die Diagnose, da es '
'selbst keinen Rückgabewert liefert', () async {
final client = MockClient((_) async => http.Response(
jsonEncode({'status': 'error', 'message': 'Falsches Passwort'}),
200));
final auth = BakaAuth(client: client, speicher: _MemorySpeicher());
await auth.autoAnmelden('Tinker', 'falsch');
expect(auth.letzterAutoLoginFehler, 'Falsches Passwort');
expect(auth.istAngemeldet, isFalse);
});
test('autoAnmelden tut nichts ohne Zugangsdaten', () async {
var aufrufe = 0;
final client = MockClient((_) async {
+24
View File
@@ -1,6 +1,9 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/library/database.dart';
import 'package:melo/services/download_service.dart';
import 'package:melo/services/download_speicher.dart';
import 'package:melo/services/navidrome_service.dart';
void main() {
group('brauchtRueckfrage', () {
@@ -54,4 +57,25 @@ void main() {
expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3'));
});
});
group('ladeEinzelnenTitel', () {
test('ohne Serververbindung wird nichts geladen und der Grund steht da',
() async {
final db = MeloDb(NativeDatabase.memory());
addTearDown(db.close);
final dienst = DownloadService(db: db, navidrome: NavidromeService());
final neu = await dienst.ladeEinzelnenTitel(const SubsonicSong(
id: 'nav-1',
titel: 'Nachtpuls',
kuenstler: 'Rotklang',
album: 'Nacht',
dauerSekunden: 200,
));
expect(neu, isFalse);
expect(dienst.fehler, contains('Musikserver'));
expect(await db.downloadIds(), isEmpty);
});
});
}
@@ -0,0 +1,150 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<MeloCloudService> baue(
Future<http.Response> Function(http.Request) antwort) async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return MeloCloudService(auth: auth, client: MockClient(antwort));
}
void main() {
test('legePlaylistAn liefert die Server-ID', () async {
final dienst = await baue((anfrage) async {
expect(anfrage.method, 'POST');
expect(anfrage.url.path, endsWith('/playlists'));
expect(jsonDecode(anfrage.body), {'name': 'Road Trip'});
// handle_playlist_create verpackt die ID unter „playlist" — genau so
// antwortet der echte Server, und genau daran ist der Vertrag geknüpft.
return http.Response(
jsonEncode({
'status': 'ok',
'playlist': {'id': 7, 'name': 'Road Trip', 'song_count': 0}
}),
200,
);
});
expect(await dienst.legePlaylistAn('Road Trip'), '7');
});
test('eine Antwort ohne playlist-Block ist ein Fehler', () async {
// Die ID auf oberster Ebene zu suchen wäre der naheliegende Fehler; er
// fiele am echten Server als „null" auf und sonst nirgends.
final dienst = await baue(
(_) async => http.Response(jsonEncode({'status': 'ok'}), 200));
expect(() => dienst.legePlaylistAn('Road Trip'),
throwsA(isA<CloudException>()));
});
test('ein Fehler im 200er-Körper wird geworfen', () async {
final dienst = await baue((_) async =>
http.Response(jsonEncode({'error': 'kein Name'}), 200));
expect(() => dienst.legePlaylistAn(''),
throwsA(isA<CloudException>()));
});
test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async {
Map<String, dynamic>? gesendet;
final dienst = await baue((anfrage) async {
expect(anfrage.url.path, endsWith('/playlists/7/songs'));
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await dienst.fuegePlaylistSongsHinzu('7', ['c1', 'c2']);
expect(gesendet, {
'song_ids': ['c1', 'c2']
});
});
test('entfernePlaylistSong benutzt DELETE auf dem Song-Pfad', () async {
String? pfad;
String? methode;
final dienst = await baue((anfrage) async {
pfad = anfrage.url.path;
methode = anfrage.method;
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await dienst.entfernePlaylistSong('7', 'c1');
expect(methode, 'DELETE');
expect(pfad, endsWith('/playlists/7/songs/c1'));
});
test('setzePlaylistReihenfolge benutzt PUT auf /positions', () async {
String? methode;
Map<String, dynamic>? gesendet;
final dienst = await baue((anfrage) async {
methode = anfrage.method;
expect(anfrage.url.path, endsWith('/playlists/7/positions'));
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await dienst.setzePlaylistReihenfolge('7', ['c2', 'c1']);
expect(methode, 'PUT');
// Der Router liest „positions", der Handler erwartet Paare. Unter
// „song_ids" bekäme der Server eine leere Liste und antwortete stumm
// „ok" — die Reihenfolge käme nie an, ohne jede Fehlermeldung.
expect(gesendet, {
'positions': [
{'id': 'c2', 'position': 0},
{'id': 'c1', 'position': 1},
]
});
});
test('playlisten liest Name und ID', () async {
final dienst = await baue((_) async => http.Response(
jsonEncode({
'playlists': [
{'id': 7, 'name': 'Road Trip'}
]
}),
200,
));
final listen = await dienst.playlisten();
expect(listen.single.id, '7');
expect(listen.single.name, 'Road Trip');
});
test('playlistSongs liefert die Song-IDs in Reihenfolge', () async {
final dienst = await baue((_) async => http.Response(
jsonEncode({
'songs': [
{'id': 'c1'},
{'id': 'c2'},
]
}),
200,
));
expect(await dienst.playlistSongs('7'), ['c1', 'c2']);
});
}
+81 -3
View File
@@ -1,8 +1,30 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() {
group('parseListe', () {
test('liest Titel inklusive Grabsteinen', () {
@@ -95,9 +117,30 @@ void main() {
expect(MeloCloudService.parseFavoriten(body), ['a', 'b']);
});
test('ohne Favoriten leere Liste', () {
expect(MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
isEmpty);
test('eine leere Favoritenliste ist kein Fehler', () {
expect(
MeloCloudService.parseFavoriten(jsonEncode({'favorites': []})),
isEmpty,
);
});
test('fehlender Schlüssel ist ein Fehler, keine leere Menge', () {
// Der Router verdrahtet für GET /favorites hart HTTP 200; sechs Handler
// desselben Servers melden Fehler im 200er-Körper. Ein fälschlich
// leeres Ergebnis wäre von einer echten Leerantwort nicht zu
// unterscheiden — wie parseListe und parseUpload wird deshalb geworfen.
expect(
() => MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
throwsA(isA<CloudException>()),
);
});
test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () {
expect(
() => MeloCloudService.parseFavoriten(
jsonEncode({'status': 'error', 'error': 'Auth required'})),
throwsA(isA<CloudException>()),
);
});
});
@@ -134,4 +177,39 @@ void main() {
});
});
});
group('setzeFavorit', () {
test('meldet den Wunsch deterministisch, nicht als Umschalten', () async {
Map<String, dynamic>? gesendet;
String? pfad;
final dienst = MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
pfad = anfrage.url.path;
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
);
await dienst.setzeFavorit('c5', true);
expect(pfad, endsWith('/favorites/toggle'));
expect(gesendet, {'song_id': 'c5', 'set': true});
});
test('kann einen Favoriten auch ausdrücklich entfernen', () async {
Map<String, dynamic>? gesendet;
final dienst = MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
);
await dienst.setzeFavorit('c5', false);
expect(gesendet, {'song_id': 'c5', 'set': false});
});
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/services/sync_merge.dart';
void main() {
group('zuPushendeFavoriten', () {
test('leer gegen leer ergibt nichts', () {
expect(
zuPushendeFavoriten(
lokaleFavoriten: const {},
cloudIdVon: const {},
amServer: const {},
),
isEmpty,
);
});
test('was der Server noch nicht hat, geht hoch', () {
expect(
zuPushendeFavoriten(
lokaleFavoriten: const {'s1', 's2'},
cloudIdVon: const {'s1': 'c1', 's2': 'c2'},
amServer: const {'c1'},
),
['c2'],
);
});
test('Titel ohne cloudId kennt der Server nicht und bleiben liegen', () {
// Lokal-only: der Titel wurde nie hochgeladen, es gibt nichts zu melden.
expect(
zuPushendeFavoriten(
lokaleFavoriten: const {'s1'},
cloudIdVon: const {},
amServer: const {},
),
isEmpty,
);
});
test('was beidseitig steht, wird nicht erneut gepusht', () {
expect(
zuPushendeFavoriten(
lokaleFavoriten: const {'s1'},
cloudIdVon: const {'s1': 'c1'},
amServer: const {'c1'},
),
isEmpty,
);
});
test('der Deckel begrenzt einen Lauf', () {
final viele = {for (var i = 0; i < 250; i++) 's$i'};
final zuordnung = {for (var i = 0; i < 250; i++) 's$i': 'c$i'};
final offen = zuPushendeFavoriten(
lokaleFavoriten: viele,
cloudIdVon: zuordnung,
amServer: const {},
);
expect(offen, hasLength(maxFavoritenPushes));
});
});
group('lokalZuSetzendeFavoriten', () {
test('disjunkte Mengen: der Server-Favorit kommt lokal dazu', () {
expect(
lokalZuSetzendeFavoriten(
amServer: const {'c9'},
songIdVonCloudId: const {'c9': 's9'},
lokaleFavoriten: const {},
),
['s9'],
);
});
test('eine lokal unauflösbare cloudId wird übersprungen, nicht gelöscht', () {
// Der Titel ist hier (noch) nicht vorhanden. Ein Favorit ohne Song wäre
// unsichtbar, würde aber ewig mitgeschleppt.
expect(
lokalZuSetzendeFavoriten(
amServer: const {'c9'},
songIdVonCloudId: const {},
lokaleFavoriten: const {},
),
isEmpty,
);
});
test('was lokal schon Favorit ist, wird nicht noch einmal gesetzt', () {
expect(
lokalZuSetzendeFavoriten(
amServer: const {'c1'},
songIdVonCloudId: const {'c1': 's1'},
lokaleFavoriten: const {'s1'},
),
isEmpty,
);
});
});
group('berichtFaellig', () {
final jetzt = DateTime(2026, 8, 27, 12);
test('ohne vorherigen Erfolg nicht fällig', () {
// Neuinstallation: „Willkommen zurück! 325 neue Songs" wäre Unsinn.
expect(berichtFaellig(null, jetzt), isFalse);
});
test('unter 24 Stunden nicht fällig', () {
expect(berichtFaellig(jetzt.subtract(const Duration(hours: 23)), jetzt),
isFalse);
});
test('ab 24 Stunden fällig', () {
expect(berichtFaellig(jetzt.subtract(const Duration(hours: 24)), jetzt),
isTrue);
expect(berichtFaellig(jetzt.subtract(const Duration(days: 3)), jetzt),
isTrue);
});
});
}
+522 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -163,7 +164,7 @@ void main() {
expect(await db.watchSongs().first, isEmpty);
});
test('Favoriten werden mit ihren Server-IDs gemeldet', () async {
test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
@@ -176,7 +177,54 @@ void main() {
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
List<String>? gemeldet;
final gepusht = <Map<String, dynamic>>[];
var vollErsatz = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
gepusht.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
if (anfrage.method == 'POST') vollErsatz++;
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gepusht, [
{'song_id': 'c5', 'set': true}
]);
// Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr,
// der den Server-Stand ersetzen könnte.
expect(vollErsatz, 0);
expect(sync.fehler, isNull);
});
test('ein Server-Favorit wird lokal nachgezogen', () async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
@@ -190,15 +238,146 @@ void main() {
);
}
if (pfad.endsWith('/favorites')) {
final d = jsonDecode(anfrage.body) as Map<String, dynamic>;
gemeldet = (d['song_ids'] as List).cast<String>();
return http.Response(
jsonEncode({
'favorites': [
{'id': 'c5'}
]
}),
200,
);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(gemeldet, ['c5']);
expect(await db.favoriteSongIds(), ['lokal-1']);
});
test('200 mit Fehlerkörper überspringt die Favoriten-Phase ohne Push',
() async {
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 0);
// Nur diese Phase fällt aus, der Lauf geht weiter.
expect(sync.fehler, isNull);
});
test('200 mit leerer Favoritenliste läuft normal durch', () async {
// Gegenprobe zum Test darüber: eine echte Leerantwort darf NICHT als
// Fehler gelten, sonst wäre die Sicherheitsregel trivial erfüllt.
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/fav.mp3',
title: 'Lieblingslied',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true);
var pushes = 0;
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Lieblingslied'}
]
}),
200,
);
}
if (pfad.endsWith('/favorites/toggle')) {
pushes++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(pushes, 1);
});
test('eine Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab',
() async {
final datei = File('${tempDir.path}/haengt.mp3');
await datei.writeAsBytes([1]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Hängt',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/upload')) {
// Der 120-s-Timeout in melo_cloud_service wirft TimeoutException,
// nicht CloudException — ohne eigenen Zweig riss ein einziger
// hängender Upload den ganzen Lauf ab.
throw TimeoutException('zu lang');
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(sync.fehler, isNull);
expect((await db.songById('lokal-1'))!.cloudId, isNull);
});
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {
@@ -306,4 +485,342 @@ void main() {
expect(sync.fehler, contains('Anmeldung abgelaufen'));
expect(sync.laeuft, isFalse);
});
group('ladeAusgewaehlteHoch', () {
Future<List<Song>> dreiTitel(Directory ordner, MeloDb db) async {
for (var i = 0; i < 3; i++) {
final datei = File('${ordner.path}/auswahl$i.mp3');
await datei.writeAsBytes([i]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-$i',
path: datei.path,
title: 'Titel $i',
dateAddedMs: i,
updatedAtMs: 0,
),
]);
}
return db.allSongs();
}
test('lädt nur, was noch keine cloudId hat', () async {
final songs = await dreiTitel(tempDir, db);
await db.setCloudId('lokal-1', 'schon-da');
var uploads = 0;
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/upload')) {
uploads++;
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
expect(songs, hasLength(3));
expect(uploads, 2);
expect(ergebnis.hochgeladen, 2);
expect(ergebnis.schonDa, 1);
expect(ergebnis.fehler, isEmpty);
});
test('ein abgelehnter Titel stoppt die übrigen nicht', () async {
await dreiTitel(tempDir, db);
var uploads = 0;
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/upload')) {
uploads++;
// Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine
// lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu müssen.
if (uploads == 2) {
return http.Response(
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
}
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
expect(ergebnis.hochgeladen, 2);
expect(ergebnis.fehler, hasLength(1));
expect(ergebnis.fehler.single, contains('zu groß'));
});
test('eine Zeitüberschreitung ist ein Einzelfehler, kein Laufabbruch',
() async {
await dreiTitel(tempDir, db);
var uploads = 0;
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/upload')) {
uploads++;
if (uploads == 1) throw TimeoutException('zu lang');
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
expect(ergebnis.hochgeladen, 2);
expect(ergebnis.fehler, hasLength(1));
});
test('abbrechen() stoppt zwischen zwei Titeln', () async {
await dreiTitel(tempDir, db);
late SyncService sync;
var uploads = 0;
sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/upload')) {
uploads++;
sync.abbrechen();
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
expect(uploads, 1);
expect(ergebnis.abgebrochen, isTrue);
expect(sync.laeuft, isFalse);
});
test('schreibt den Sync-Zeitstempel nicht', () async {
await dreiTitel(tempDir, db);
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/upload')) {
return http.Response(
jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.ladeAusgewaehlteHoch(await db.allSongs());
// Ein Upload ist kein Abgleich: sonst unterdrückt er 15 Minuten den
// Auto-Sync und verschiebt die 24-h-Uhr des Berichts.
expect(sync.letzterLauf, isNull);
});
test('während eines laufenden Abgleichs wird abgewiesen', () async {
await dreiTitel(tempDir, db);
late SyncService sync;
UploadErgebnis? waehrendSync;
sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/list')) {
waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs());
return http.Response(jsonEncode({'songs': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
expect(waehrendSync, isNotNull);
expect(waehrendSync!.hochgeladen, 0);
expect(sync.fehler, contains('Abgleich'));
});
});
group('Sync-Bericht', () {
test('beim allerersten Lauf gibt es keinen Bericht', () async {
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
// letzterErfolg war null: „Willkommen zurück!" auf einem frisch
// eingerichteten Gerät wäre Unsinn.
expect(sync.bericht, isNull);
});
test('nach mehr als 24 Stunden kommt der Bericht mit Zählern', () async {
final vorgestern = DateTime.now().subtract(const Duration(days: 2));
SharedPreferences.setMockInitialValues({
'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch,
});
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100}
]
}),
200,
);
}
if (pfad.contains('/download/')) {
return http.Response.bytes([1], 200,
headers: {'content-type': 'audio/mpeg'});
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.laden();
await sync.synchronisiere();
expect(sync.bericht, isNotNull);
expect(sync.bericht!.neueSongs, 1);
sync.berichtGesehen();
expect(sync.bericht, isNull);
});
test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht',
() async {
SharedPreferences.setMockInitialValues({});
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/favorites')) {
// Fehler im 200er-Körper: die Favoriten-Phase fällt aus.
return http.Response(
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
// Die Drossel läuft trotzdem weiter — sonst rennt der Sync bei jedem
// Tab-Wechsel neu los.
expect(sync.letzterLauf, isNotNull);
});
test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel '
'nicht', () async {
SharedPreferences.setMockInitialValues({});
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: '${tempDir.path}/weg.mp3',
title: 'Weg',
dateAddedMs: 0,
updatedAtMs: 0,
deleted: const Value(true),
),
]);
await db.setCloudId('lokal-1', 'c5');
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(
jsonEncode({
'songs': [
{'id': 'c5', 'title': 'Weg'}
]
}),
200,
);
}
if (pfad.endsWith('/delete')) {
// _meldeLoeschungen schluckt die CloudException — ohne Rückgabe
// bis zum Flag hätte der Lauf trotzdem als erfolgreich gegolten.
return http.Response(jsonEncode({'error': 'kaputt'}), 500);
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
expect(sync.letzterLauf, isNotNull);
});
test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht',
() async {
SharedPreferences.setMockInitialValues({});
final datei = File('${tempDir.path}/zu-gross.mp3');
await datei.writeAsBytes([1]);
await db.upsertSongs([
SongsCompanion.insert(
id: 'lokal-1',
path: datei.path,
title: 'Zu groß',
dateAddedMs: 0,
updatedAtMs: 0,
),
]);
final sync = await baue((anfrage) async {
final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) {
return http.Response(jsonEncode({'songs': []}), 200);
}
if (pfad.endsWith('/upload')) {
return http.Response(
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
}
if (pfad.endsWith('/favorites')) {
return http.Response(jsonEncode({'favorites': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
await sync.synchronisiere();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
});
test('der Zeitstempel ist der Stand VOR dem Listen', () async {
final sync = await baue((anfrage) async {
if (anfrage.url.path.endsWith('/list')) {
// Während des Laufs vergeht Zeit — der Zeitstempel darf nicht
// danach genommen werden, sonst fallen zwischenzeitliche
// Änderungen durchs Raster (Tombstone-Race, v2-Lektion).
await Future<void>.delayed(const Duration(milliseconds: 50));
return http.Response(jsonEncode({'songs': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final vorher = DateTime.now();
await sync.synchronisiere();
final nachher = DateTime.now();
expect(sync.letzterLauf!.isBefore(nachher), isTrue);
expect(
sync.letzterLauf!
.isAfter(vorher.subtract(const Duration(milliseconds: 1))),
isTrue,
);
expect(
nachher.difference(sync.letzterLauf!) >=
const Duration(milliseconds: 50),
isTrue,
);
});
});
}
-76
View File
@@ -1,76 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/einblend_item.dart';
void main() {
Widget wrap(Widget child, {bool ruhig = false}) => MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: ruhig),
child: Scaffold(body: child),
),
);
double opacityVon(WidgetTester tester, Finder einblendItem) => tester
.widget<Opacity>(
find.descendant(of: einblendItem, matching: find.byType(Opacity)))
.opacity;
double versatzYVon(WidgetTester tester, Finder einblendItem) => tester
.widget<Transform>(
find.descendant(of: einblendItem, matching: find.byType(Transform)))
.transform
.getTranslation()
.y;
testWidgets('Opacity startet bei 0 und erreicht 1,0 nach dem Einblenden',
(tester) async {
await tester.pumpWidget(wrap(
const EinblendItem(index: 0, child: Text('Titel')),
));
final item = find.byType(EinblendItem);
expect(opacityVon(tester, item), 0);
await tester.pumpAndSettle();
expect(opacityVon(tester, item), 1);
expect(versatzYVon(tester, item), 0);
});
testWidgets(
'bei abgeschalteten Systemanimationen erscheint das Element sofort',
(tester) async {
await tester.pumpWidget(wrap(
const EinblendItem(index: 3, child: Text('Titel')),
ruhig: true,
));
final item = find.byType(EinblendItem);
// Bewusst kein weiterer pump() davor: die Reduce-Motion-Einstellung
// darf nicht erst nach einer Zeitverzögerung greifen.
expect(opacityVon(tester, item), 1);
expect(versatzYVon(tester, item), 0);
});
testWidgets('der Deckel bremst die Verzögerung ab Index 12',
(tester) async {
await tester.pumpWidget(wrap(
const Column(
children: [
EinblendItem(index: 12, child: Text('Zwölf')),
EinblendItem(index: 50, child: Text('Fünfzig')),
],
),
));
// Deckel: min(index, 12) * 25ms → beide starten spätestens nach 300ms.
// Ohne Deckel bräuchte Index 50 1250ms Verzögerung und wäre hier noch
// bei Opacity 0.
await tester.pump(const Duration(milliseconds: 300));
await tester.pump(const Duration(milliseconds: 220));
for (final item in find.byType(EinblendItem).evaluate()) {
expect(opacityVon(tester, find.byWidget(item.widget)), 1);
}
});
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:melo/library/category_service.dart';
import 'package:melo/library/database.dart';
import 'package:melo/library/playlist_service.dart';
import 'package:melo/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_service.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:melo/shared/sort_store.dart';
import 'package:melo/shared/sortable_song_list.dart';
class _MemorySpeicher implements TokenSpeicher {
_MemorySpeicher(this.werte);
final Map<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
/// Merkt sich nur, was hochgeladen werden sollte. Der echte Upload braucht
/// Dateien und einen Server — hier geht es um den Weg vom Knopf zum Dienst.
class _FakeSync extends SyncService {
_FakeSync(MeloDb db)
: super(
db: db,
cloud: MeloCloudService(
auth: BakaAuth(speicher: _MemorySpeicher({})),
client: MockClient(
(_) async => http.Response('{"status":"ok"}', 200)),
),
);
final hochgeladen = <String>[];
@override
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
hochgeladen.addAll([for (final s in songs) s.id]);
return const UploadErgebnis(hochgeladen: 1);
}
}
/// „Auf den Server laden" gehört in „Meine Musik" — und **nur** dorthin.
/// [SortableSongList] wird in fünf Ansichten benutzt; ohne Scoping erschiene
/// die Aktion auch bei Favoriten, Wiedergabelisten und Titellisten.
///
/// Aufbau bewusst im Testkörper, nicht in `setUp` (siehe auswahl_modus_test).
void main() {
final lieder = [
for (var i = 0; i < 3; i++)
Song(
id: 'song-$i',
path: '/music/$i.mp3',
title: 'Titel $i',
artist: 'Neoni',
dateAddedMs: i,
updatedAtMs: 0,
deleted: false,
playCount: 0,
categoriesEdited: false,
metadataEdited: false,
),
];
Future<void> beruhige(WidgetTester tester) => tester.pumpAndSettle(
const Duration(milliseconds: 100),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 5),
);
/// Räumt den Widget-Baum kontrolliert ab, statt es dem Testrahmen am
/// Testende zu überlassen: sonst bleibt ein von drift/SnackBar beim
/// Aufräumen geplanter Timer offen (siehe auswahl_modus_test.dart /
/// BACKLOG.md — derselbe Hänger, dieselbe Ursache).
Future<void> aufraeumen(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 1));
}
Future<_FakeSync> pumpe(WidgetTester tester,
{required bool serverUpload}) async {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
SharedPreferences.setMockInitialValues({});
final db = MeloDb(NativeDatabase.memory());
// Kein db.close(): drift plant dabei eine Aufräum-Aufgabe (Timer) ein,
// die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md und
// auswahl_modus_test.dart — derselbe Hänger, dieselbe Ursache).
final sync = _FakeSync(db);
final einstellungen = AppSettings();
await einstellungen.init();
for (final song in lieder) {
await db.into(db.songs).insert(SongsCompanion.insert(
id: song.id,
path: song.path,
title: song.title,
artist: Value(song.artist),
dateAddedMs: song.dateAddedMs,
updatedAtMs: 0,
));
}
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(
value: CategoryService(db)),
ChangeNotifierProvider<PlaylistService>.value(
value: PlaylistService(db)),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
// Ohne diesen Provider stürbe schon das erste Antippen der neuen
// Aktion in ladeAufServer mit ProviderNotFoundException.
ChangeNotifierProvider<SyncService>.value(value: sync),
],
child: MaterialApp(
home: Scaffold(
body: SortableSongList(
songs: lieder,
storeKey: SortStore.meineMusik,
serverUpload: serverUpload,
),
),
),
),
);
await beruhige(tester);
await tester.longPress(find.text('Titel 0'));
await beruhige(tester);
return sync;
}
testWidgets('in „Meine Musik" erscheint die Server-Aktion', (tester) async {
await pumpe(tester, serverUpload: true);
expect(find.byTooltip('Auf den Server laden'), findsOneWidget);
await aufraeumen(tester);
});
testWidgets('in den übrigen Ansichten erscheint sie nicht', (tester) async {
await pumpe(tester, serverUpload: false);
// Der Auswahl-Modus läuft, die beiden Bestands-Aktionen sind da …
expect(find.byTooltip('Zur Warteschlange hinzufügen'), findsOneWidget);
// … die neue nicht.
expect(find.byTooltip('Auf den Server laden'), findsNothing);
await aufraeumen(tester);
});
testWidgets('das Antippen reicht die Auswahl an den Upload weiter',
(tester) async {
final sync = await pumpe(tester, serverUpload: true);
await tester.tap(find.byTooltip('Auf den Server laden'));
await beruhige(tester);
// Ein sichtbarer Knopf ist noch keine Funktion: geprüft wird, dass genau
// der lang gedrückte Titel bei ladeAusgewaehlteHoch ankommt.
expect(sync.hochgeladen, ['song-0']);
expect(find.text('1 hochgeladen'), findsOneWidget);
await aufraeumen(tester);
});
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/services/sync_service.dart';
import 'package:melo/shared/sync_bericht_dialog.dart';
void main() {
Future<void> zeige(WidgetTester tester, SyncBericht bericht) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: SyncBerichtDialog(bericht: bericht)),
));
await tester.pump();
}
testWidgets('nennt neue und entfernte Titel', (tester) async {
await zeige(
tester,
const SyncBericht(neueSongs: 3, geloeschte: 1, favoriten: 0),
);
expect(find.text('Willkommen zurück!'), findsOneWidget);
expect(find.textContaining('3 neue Titel'), findsOneWidget);
expect(find.textContaining('1 entfernt'), findsOneWidget);
});
testWidgets('ohne Änderungen sagt er das auch', (tester) async {
await zeige(
tester,
const SyncBericht(neueSongs: 0, geloeschte: 0, favoriten: 0),
);
expect(find.textContaining('Nichts Neues'), findsOneWidget);
});
}