Budget-Adversarial-Panel (3 Reviewer, unabhängig konvergent) fand einen P0 (AnimatedBuilder.child-Fehlgebrauch macht Blur-Layer nicht live) und mehrere P1s (State-Persistenz-Widerspruch zur Spec, fehlende Hit-Test-/Accessibility- Isolation, verworfene Loslass-Geschwindigkeit, fehlendes extendBody, Tests nur gegen Test-Doppelgänger). Alle behoben: eigene AnimatedBuilder pro reaktiver Ebene (Reaktivitäts-Regel in Global Constraints), Visibility statt Conditional-Unmount, IgnorePointer/ExcludeSemantics für Hintergrund-Layer, echte Fling-Geschwindigkeit, extendBody + BottomNavigationBar-Fade, neuer Integrationstest gegen das echte HomeShell, State-Persistenz-Test.
12 KiB
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 vonNowPlayingScreen(Warteschlange, Songtext-Sheet) — die bleiben echte Routen/Modals über demselben Navigator. - Keine Änderung an
wischen.darts bestehender Entscheidungslogik (oeffnetVollbildMitStrecke,titelWechselMitStrecke,wischSchwelle/wischStreckeSchwelle) — die wird unverändert wiederverwendet, nur um einen rein optischenprogress-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 HomeShells 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). Bildetprogress = (aufsummierte Zugstrecke / referenzHoehe).clamp(0, 1).referenzHoeheist ein fester Wert (300px) — bewusst entkoppelt von der Entscheidungs-Schwelle inwischen.dart; er bestimmt nur, wie "weit" sich der optische Übergang bei einer bestimmten Zugstrecke anfühlt.dragEnd(double geschwindigkeit, double strecke)— ruft unverändertoeffnetVollbildMitStrecke/die Schließ-Variante auf, um zu entscheiden, obprogresszu 1.0 oder 0.0 animiert wird (AnimationController.flingmit der Loslass-Geschwindigkeit als Startimpuls, danachMeloMotion.curvebis zum jeweiligen Ziel).open()— animiertprogress0→1 überMeloMotion.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 springtprogresssofort 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:
IndexedStack(Tabs) +BottomNavigationBar— unverändertAnimatedBuilderauf den Controller: nur wennprogress > 0, einBackdropFilter(filter: ImageFilter.blur(sigmaX: progress * maxSigma, sigmaY: progress * maxSigma))über Punkt 1 — Widget wird beiprogress == 0komplett aus dem Baum entfernt (kein Performance-Overhead im Ruhezustand).maxSigma = 20.0als Startwert (spürbarer Weichzeichner, ohne die Tab-Umrisse völlig zu verlieren) — im Review/auf echtem Gerät nachjustierbar, siehe „Offene Risiken“.MiniPlayer—Opacity(opacity: 1 - progress), beiprogress == 1ebenfalls aus dem Baum entfernt (IgnorePointer/Kein Hit-Testing mehr, sonst blockiert eine unsichtbare Leiste Gesten im Vollbild-Player)NowPlayingScreen-Inhalt — Größe/Position perRect.lerpzwischen dem Mini-Player-Rechteck (volle Breite,MiniPlayer.hoehe+ Fortschrittsbalken hoch, am unteren Rand über derBottomNavigationBar) und dem Vollbild-Rechteck; Detail-Inhalt (Titel, Steuerung, Songtext-Icon etc.) blendet erst abprogress > 0.3ein (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 durchcontext.read<PlayerExpansionController>().open()ersetzt — nur im Erfolgspfad nachplaySongs(...), Fehlerpfad/Snackbar unverändert. DerNowPlayingScreen-Import entfällt an diesen 3 Stellen vollständig.lib/player/mini_player.dart:_oeffne()ruft.open()statt zu pushen;onVerticalDragUpdate/onVerticalDragEndrufen.dragBy()/.dragEnd()statt lokal_versatzzu verwalten (die bisherige rein visuelle Mitzieh-Logik der Leiste geht im gemeinsamenprogress-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 inwischen.dartund wird dort NICHT erneut getestet (unverändert). - Widget (
test/player/home_shell_expansion_test.darto.ä.):- Ziehen am Mini-Player erhöht
progress, Blur-Layer erscheint abprogress > 0 - Tap aus
song_list.dart(bzw. Stellvertreter-Widget im Test) ruft.open()auf, keinNavigator.pushmehr - Zurück-Taste bei
progress > 0schließt den Player statt zu poppen/die App zu verlassen - Ziehen nach unten im Vollbild-Player reduziert
progresssymmetrisch bis zum Schließen
- Ziehen am Mini-Player erhöht
- Cover-Interpolation: Test, dass
_WanderndesCoverbeiprogress = 0 / 0.5 / 1die erwarteten Rect/Radius-Werte liefert (Rect.lerp-Ergebnis direkt prüfbar, kein Golden-Test nötig)
Offene Risiken
BackdropFilterist in Flutter performance-sensibel (GPU-Kosten pro Frame) — bewusst nur im Baum, wennprogress > 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).PlayerExpansionControllermuss denmounted-Zustand vonHomeShellrespektieren (keinnotifyListeners()/Controller-Zugriff nachdispose()) — 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/ExcludeSemanticsauf Tab-Inhalt undBottomNavigationBarbeiprogress > 0). - Der Wegfall der bisher über
Scaffold/AppBarimpliziten Status-Icon- Helligkeit war nicht bedacht — jetzt Teil von Task 5 (AnnotatedRegion). - Ohne
extendBody: truehätte das "Vollbild"-Rechteck nicht zur tatsächlichenScaffold.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 inmini_player.dart, der bei Erreichen der Öffnen-Schwelle in einen normalenNavigator.pushmit unverändertem, Hero-basiertemNowPlayingScreenü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.