# 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 3–5 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().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.