Files
Melo/docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md
Hermes (Server) dbaec47faf Plan/Spec: Review-Panel-Befunde eingearbeitet
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.
2026-08-29 14:07:59 +02:00

12 KiB
Raw Permalink Blame History

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.darts 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 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). 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. MiniPlayerOpacity(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.

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.