Spec: Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang

Punkt 2 der HyperOS-Animations-Serie. Bereit zur Freigabe durch Dustin
(via Hermes) vor der Umsetzung.
This commit is contained in:
Hermes (Server)
2026-08-29 13:40:19 +02:00
parent e2304b66cd
commit 8d496cc70b
@@ -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 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`.