From 8d496cc70b349ab1503170f615c4adc8c16c963d Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 13:40:19 +0200 Subject: [PATCH] =?UTF-8?q?Spec:=20Vollbild-Player=20als=20persistentes=20?= =?UTF-8?q?Overlay=20mit=20Live-Blur-=C3=9Cbergang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punkt 2 der HyperOS-Animations-Serie. Bereit zur Freigabe durch Dustin (via Hermes) vor der Umsetzung. --- ...26-08-29-blur-oeffnen-transition-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md diff --git a/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md b/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md new file mode 100644 index 0000000..cb4d454 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md @@ -0,0 +1,200 @@ +# 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`.