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:
@@ -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<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`.
|
||||
Reference in New Issue
Block a user