Author SHA1 Message Date
Hermes (Server) 824f6364e2 Baka-Konto-Dialog: Passwort wird jetzt auch getrimmt (wie im Navidrome-Dialog)
Das Passwortfeld im Baka-Konto-Anmelde-Dialog wurde vor dem Absenden und
vor dem Speichern nicht getrimmt — ein durch Autofill eingefügtes
Leerzeichen ließ korrekte Logins scheitern. Gleicher Fix wie bereits in
settings_screen.dart (Commit 3d1e00646a) für denselben Symptom-Typ.
2026-08-29 18:08:19 +02:00
25 changed files with 324 additions and 3543 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).
+21 -60
View File
@@ -5,67 +5,28 @@ 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)
### 🔑 Baka-Konto-Dialog trimmte das Passwort nicht — Autofill-Leerzeichen ließ korrekte Logins scheitern (2026-08-29)
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.
- 🐛 **Im "Baka-Konto"-Anmelde-Dialog (`_AnmeldeDialogState._anmelden()` in
`downloads_screen.dart`) wurde der Benutzername vor dem Absenden getrimmt
(`_benutzer.text.trim()`), das Passwort aber nicht** (`_passwort.text`
roh, an beiden Stellen: beim `BakaAuth.anmelden(...)`-Aufruf UND beim
anschließenden `NavidromeService.speichereZugangsdaten(...)`). Ein durch
Autofill/Tastatur-Vorschläge im `obscureText`-Passwortfeld eingefügtes
unsichtbares Leerzeichen landete dadurch im gesendeten UND dauerhaft
gespeicherten Passwort — der Login schlug fehl, obwohl der Nutzer sein
Passwort korrekt kannte.
- 🔧 **Fix:** `_passwort.text.trim()` an beiden Stellen, mit demselben
Kommentar wie beim strukturell identischen, bereits behobenen Fall im
Navidrome-Login-Dialog (`settings_screen.dart`, Commit `3d1e00646ac`) —
das war schon einmal exakt dasselbe Symptom in einem zweiten
Passwort-Dialog. Falls das Muster ein drittes Mal auftaucht: derselbe
Trim-Fix, derselbe Kommentar.
-**Getestet (RED→GREEN):** neuer Widget-Test in `online_screen_test.dart`
gibt im Passwortfeld `'geheim '` (mit Leerzeichen) ein und bestätigt, dass
sowohl der gesendete `POST /auth/login`-Body als auch
`nav.passwort` das getrimmte `'geheim'` enthalten. Volle Suite (605 Tests)
grün, `flutter analyze` ohne Befund.
### ✨ Staggered List-Enter-Animation: Listen faden beim ersten Erscheinen gestaffelt ein (2026-08-29)
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.
+5 -2
View File
@@ -900,7 +900,10 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
_fehler = null;
});
final auth = context.read<BakaAuth>();
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
// Randständige Leerzeichen entfernen (verdeckte
// Fehlerquelle im obscureText-Feld / bei Autofill).
final passwort = _passwort.text.trim();
final fehler = await auth.anmelden(_benutzer.text.trim(), passwort);
if (!mounted) return;
if (fehler == null) {
// Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe
@@ -909,7 +912,7 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
// Passwort scheitern.
final nav = context.read<DownloadService>().navidrome;
await nav.speichereZugangsdaten(
nav.serverUrl, _benutzer.text.trim(), _passwort.text);
nav.serverUrl, _benutzer.text.trim(), passwort);
if (!mounted) return;
Navigator.pop(context);
return;
+4 -2
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 '../player/now_playing_screen.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -79,7 +79,9 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (!mounted) return;
context.read<PlayerExpansionController>().open(context);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+4 -2
View File
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../player/player_expansion_controller.dart';
import '../player/now_playing_screen.dart';
import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
@@ -384,7 +384,9 @@ class SongZeile extends StatelessWidget {
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+10 -189
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';
@@ -115,35 +112,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 +132,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',
@@ -253,136 +222,24 @@ 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,
return PopScope(
// Tab-Wechsel laufen nur über setState, nie über Navigator.push — die
// Zurück-Geste auf einem Nicht-Start-Tab hätte sonst nichts zum
// Poppen und würde die App minimieren statt zu "Meine Musik" zu gehen.
canPop: _index == 0,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (expansion.progress > 0) {
expansion.close(context);
} else {
setState(() => _index = 0);
}
if (!didPop) 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(
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)),
),
@@ -409,42 +266,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;
}
}
+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(
+109 -213
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,32 @@ 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(
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),
);
},
),
@@ -267,6 +70,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,
),
),
],
),
);
},
);
},
),
),
),
),
);
}
}
@@ -414,6 +289,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();
}
}
+4 -2
View File
@@ -7,7 +7,7 @@ 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 '../player/now_playing_screen.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
@@ -135,7 +135,9 @@ class _Zeile extends StatelessWidget {
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
context.read<PlayerExpansionController>().open(context);
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
+34
View File
@@ -301,6 +301,40 @@ void main() {
expect(auth.istAngemeldet, isTrue);
});
testWidgets(
'Anmelden trimmt das Passwort — ein unsichtbares Leerzeichen durch '
'Autofill im obscureText-Feld darf den Login nicht scheitern lassen '
'(wie im Navidrome-Dialog, settings_screen.dart)', (tester) async {
late String gesendetesPasswort;
final auth = BakaAuth(
client: MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
gesendetesPasswort = body['password'] as String;
if (body['password'] == 'geheim') {
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, 'geheim ');
await tester.tap(find.text('Anmelden'));
await tester.pumpAndSettle();
expect(gesendetesPasswort, 'geheim');
expect(nav.passwort, 'geheim');
});
testWidgets(
'Server-User meldet sich im Hintergrund mit denselben Zugangsdaten '
'beim Baka-Konto an', (tester) async {
@@ -9,7 +9,7 @@ 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/player/now_playing_screen.dart';
import 'package:melo/services/navidrome_service.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
@@ -57,13 +57,10 @@ void main() {
handler.dispose();
});
Widget wrap(Widget child, PlayerExpansionController expansion) =>
MultiProvider(
Widget wrap(Widget child) => MultiProvider(
providers: [
Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(home: child),
);
@@ -71,26 +68,21 @@ void main() {
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(
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);
expect(find.byType(NowPlayingScreen), findsNothing);
await tester.tap(find.text('Feuer'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.
+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));
-9
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,7 +70,6 @@ void main() {
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose();
expansion.dispose();
await db.close();
});
@@ -85,7 +80,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: [
@@ -106,8 +100,6 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -145,7 +137,6 @@ void main() {
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
+3 -7
View File
@@ -11,7 +11,7 @@ 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/player/now_playing_screen.dart';
import 'package:melo/settings/app_settings.dart';
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff:
@@ -91,8 +91,6 @@ void main() {
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: [
@@ -101,8 +99,6 @@ void main() {
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: MaterialApp(
home: Scaffold(
@@ -116,12 +112,12 @@ void main() {
),
);
expect(expansion.progress, 0.0);
expect(find.byType(NowPlayingScreen), findsNothing);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo (Favoriten-Herz)
// beim Abbau des Baums als offener Fake-Timer stehen — dieselbe
-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));
});
}
+14 -57
View File
@@ -12,7 +12,6 @@ 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';
@@ -89,30 +88,20 @@ void main() {
metadataEdited: false,
);
// Startet die Wiedergabe über denselben Weg wie in
// Öffnet den Vollbild-Player ü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 = [
// dort nachweislich unproblematisch, im Unterschied zu einem direkten
// Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream.
Future<void> oeffnePlayer(WidgetTester tester) async {
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),
];
await tester.pumpWidget(
MultiProvider(
providers: providers,
],
child: MaterialApp(
home: Scaffold(
body: SongZeile(
@@ -127,18 +116,6 @@ void main() {
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 {
@@ -156,11 +133,12 @@ void main() {
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);
// Nicht mehr in der AppBar:
final herzInAppBar = find.descendant(
of: find.byType(AppBar),
matching: find.byType(FavoriteButton),
);
expect(herzInAppBar, findsNothing);
// Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel.
final titelRow = find
@@ -174,25 +152,4 @@ void main() {
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,
),
);
});
}
@@ -10,7 +10,7 @@ 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/player/now_playing_screen.dart';
import 'package:melo/playlists/playlist_detail_screen.dart';
import 'package:melo/settings/app_settings.dart';
@@ -90,8 +90,6 @@ void main() {
deleted: false,
);
final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
@@ -100,8 +98,6 @@ void main() {
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),
@@ -110,12 +106,12 @@ void main() {
);
await tester.pumpAndSettle();
expect(expansion.progress, 0.0);
expect(find.byType(NowPlayingScreen), findsNothing);
await tester.tap(find.text('Roses'));
await tester.pumpAndSettle();
expect(expansion.progress, 1.0);
expect(find.byType(NowPlayingScreen), findsOneWidget);
// Ohne diesen Abschluss bleibt ein drift-Stream-Abo beim Abbau des
// Baums als offener Fake-Timer stehen.