# Vollbild-Player als persistentes Overlay mit Live-Blur-Übergang — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `NowPlayingScreen` wird von einer per `Navigator.push` geöffneten Route zu einem persistenten Overlay in `HomeShell`, dessen Öffnen/Schließen live an der Zugdistanz der Wischgeste hängt (Blur, Cover-Wanderung) statt erst nach Abschluss einer festen Animation zu erscheinen. **Architecture:** Ein neuer `PlayerExpansionController` (ChangeNotifier um einen `AnimationController`) hält einen reinen Optik-Wert `progress` (0=Mini, 1=Vollbild). `HomeShell` stellt ihn bereit und rendert einen `Stack` aus Tab-Inhalt, bedingtem Blur-Layer, `MiniPlayer` und dem `NowPlayingScreen`-Inhalt, dessen Rechteck per `Rect.lerp` zwischen Mini- und Vollbild-Position interpoliert. Die bestehende Entscheidungslogik in `wischen.dart` bleibt unverändert und wird nur um den neuen, rein visuellen `progress`-Wert ergänzt. **Tech Stack:** Flutter (Dart), `provider` (State-Management, bereits im Projekt), reines Flutter-SDK für Animation (kein neues Package, Projekt-Konvention). **Spec:** `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md` ## Global Constraints - Kein neues Package — alle Animationen mit Flutter-SDK-Bordmitteln (`AnimationController`, `BackdropFilter`, `Rect.lerp`), wie im übrigen Projekt (`MeloMotion`-Konvention). - `wischen.dart` (`oeffnetVollbildMitStrecke`, `wischSchwelle`, `wischStreckeSchwelle`) bleibt **unverändert** — nur wiederverwendet. - Reduce-Motion: jede neue Animation muss `MediaQuery.disableAnimationsOf(context)` respektieren (Sofortsprung statt Animation), analog zum bestehenden `MeloMotion.ruhig`-Muster. - Deutsche Namenskonvention für neue Bezeichner, wie im übrigen Projekt (`SongZeile`, `LaufBalken`, `_CoverGrund`, …). - `flutter analyze` und `flutter test` müssen nach jedem Task grün sein (Pre-Commit-Hook des Projekts prüft das ohnehin automatisch). - **Reaktivitäts-Regel (nach Review-Panel ergänzt):** Jede Stelle, die `_expansion.progress` live liest, braucht ihren **eigenen, eng geschnittenen** `AnimatedBuilder(animation: _expansion, ...)`/`Consumer`, ausgewertet in dessen `builder:`-Callback — **niemals** im `child:`-Parameter eines fremden `AnimatedBuilder`/`Consumer`, und niemals in der Annahme, ein äußerer reaktiver Wrapper mache tiefer verschachtelte Nachfahren automatisch reaktiv. `AnimatedBuilder.child`/`Consumer.child` werden von Flutter bei jedem `notifyListeners()`-Tick bewusst **nicht** neu gebaut — das ist eine Performance-Optimierung, kein „wird schon mitgezogen". Diese Regel wurde nötig, weil eine erste Fassung dieses Plans genau diesen Fehler enthielt (Task 4 verließ sich auf Task 2s äußeren `AnimatedBuilder`, der Blur-Layer wäre nie live gewesen — siehe Review-Panel-Befund). --- ## Datei-Überblick | Datei | Änderung | |---|---| | `lib/player/player_expansion_controller.dart` | **Neu** — der Controller | | `test/player/player_expansion_controller_test.dart` | **Neu** — Unit-Tests | | `lib/main.dart` | `HomeShell`: Controller erzeugen/bereitstellen, `Stack`-Umbau, `PopScope` erweitern | | `lib/player/now_playing_screen.dart` | Von Scaffold/Route zu Overlay-Inhalt umgebaut, `Hero` entfernt, Schließgeste live | | `lib/player/mini_player.dart` | Lokale `_versatz`-Logik durch Controller-Aufrufe ersetzt, eigenes Cover entfernt | | `lib/library/song_list.dart` | `Navigator.push(NowPlayingScreen)` → `controller.open(context)` | | `lib/playlists/playlist_detail_screen.dart` | dito | | `lib/downloads/server_titel_screen.dart` | dito | | `test/player/home_shell_expansion_test.dart` | **Neu** — Integrationstests (gegen `_TestShell`) | | `test/player/home_shell_real_expansion_test.dart` | **Neu** — Integrationstest gegen das echte `HomeShell` (Task 9) | | `CHANGELOG.md` | Eintrag | --- ### Task 1: `PlayerExpansionController` **Files:** - Create: `lib/player/player_expansion_controller.dart` - Test: `test/player/player_expansion_controller_test.dart` **Interfaces:** - Produces: - `class PlayerExpansionController extends ChangeNotifier` - `PlayerExpansionController({required TickerProvider vsync})` - `double get progress` — 0.0…1.0 - `void dragBy(double dy)` — `dy` ist `details.delta.dy` einer Wischgeste, unverändertes Vorzeichen (negativ = nach oben) - `void dragEnd(BuildContext context, double strecke, double geschwindigkeit)` — `strecke`/`geschwindigkeit` wie in `wischen.dart` (negativ = nach oben) - `void open(BuildContext context)` - `void close(BuildContext context)` - `const double oeffnenReferenzHoehe` (top-level, Wert `300.0`) - [ ] **Step 1: Schreibe die fehlschlagenden Tests** ```dart // test/player/player_expansion_controller_test.dart import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:melo/player/player_expansion_controller.dart'; void main() { Widget mitMediaQuery(bool disableAnimations, WidgetBuilder builder) { return MediaQuery( data: MediaQueryData(disableAnimations: disableAnimations), child: MaterialApp(home: Builder(builder: builder)), ); } testWidgets('progress startet bei 0', (tester) async { late PlayerExpansionController controller; await tester.pumpWidget(mitMediaQuery(false, (context) { controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets('dragBy nach oben erhöht progress, nach unten verringert ihn', (tester) async { late PlayerExpansionController controller; await tester.pumpWidget(mitMediaQuery(false, (context) { controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.dragBy(-150); // halbe Referenzhöhe nach oben expect(controller.progress, closeTo(0.5, 0.001)); controller.dragBy(75); // ein Viertel zurück nach unten expect(controller.progress, closeTo(0.25, 0.001)); controller.dispose(); }); testWidgets('dragBy clampt auf [0, 1]', (tester) async { late PlayerExpansionController controller; await tester.pumpWidget(mitMediaQuery(false, (context) { controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.dragBy(-1000); expect(controller.progress, 1.0); controller.dragBy(2000); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets('open() fliegt zu 1, close() fliegt zu 0', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(false, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.open(ctx); await tester.pumpAndSettle(); expect(controller.progress, 1.0); controller.close(ctx); await tester.pumpAndSettle(); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets('open()/close() springen bei disableAnimations sofort', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(true, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.open(ctx); expect(controller.progress, 1.0); // ohne pumpAndSettle sofort da controller.close(ctx); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets( 'dragEnd beim Öffnen: ausreichende Strecke nach oben fliegt zu 1', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(false, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.dragBy(-200); controller.dragEnd(ctx, -200, 0); // Strecke > wischStreckeSchwelle (80) await tester.pumpAndSettle(); expect(controller.progress, 1.0); controller.dispose(); }); testWidgets( 'dragEnd beim Öffnen: zu kurze, langsame Geste fällt zurück auf 0', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(false, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.dragBy(-30); controller.dragEnd(ctx, -30, 0); // unter wischStreckeSchwelle await tester.pumpAndSettle(); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets( 'dragEnd beim Schließen: ausreichende Strecke nach unten fliegt zu 0', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(false, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.open(ctx); await tester.pumpAndSettle(); controller.dragBy(200); // nach unten, Betrag > Referenzhöhe/2 controller.dragEnd(ctx, 200, 0); await tester.pumpAndSettle(); expect(controller.progress, 0.0); controller.dispose(); }); testWidgets( 'dragEnd beim Schließen: zu kurze, langsame Geste bleibt bei 1', (tester) async { late PlayerExpansionController controller; late BuildContext ctx; await tester.pumpWidget(mitMediaQuery(false, (context) { ctx = context; controller = PlayerExpansionController(vsync: tester); return const SizedBox(); })); controller.open(ctx); await tester.pumpAndSettle(); controller.dragBy(30); controller.dragEnd(ctx, 30, 0); await tester.pumpAndSettle(); expect(controller.progress, 1.0); controller.dispose(); }); } ``` - [ ] **Step 2: Tests laufen lassen, sicherstellen dass sie fehlschlagen** Run: `flutter test test/player/player_expansion_controller_test.dart` Expected: FAIL — `player_expansion_controller.dart` existiert nicht (Import-Fehler). - [ ] **Step 3: Implementierung schreiben** ```dart // lib/player/player_expansion_controller.dart import 'package:flutter/material.dart'; import '../shared/theme.dart'; import 'wischen.dart'; /// Wie viel Zugstrecke (Pixel) einem vollen Öffnen-Fortschritt entspricht — /// rein darstellerisch, unabhängig von der Öffnen/Schließen-Entscheidung in /// [wischen.dart]. const oeffnenReferenzHoehe = 300.0; /// Optischer Fortschritt zwischen Mini-Player (0) und Vollbild-Player (1). /// /// Trennt bewusst zwei Dinge: WANN geöffnet/geschlossen wird (unverändert /// [oeffnetVollbildMitStrecke] aus wischen.dart) und WIE WEIT der Übergang /// gerade optisch ist (dieser Wert, live an die Zugstrecke gekoppelt). class PlayerExpansionController extends ChangeNotifier { PlayerExpansionController({required TickerProvider vsync}) : _animation = AnimationController(vsync: vsync) { _animation.addListener(notifyListeners); } final AnimationController _animation; double get progress => _animation.value; /// [dy] ist details.delta.dy einer laufenden Wischgeste, unverändertes /// Vorzeichen. Nach oben (negativ) erhöht progress, nach unten (positiv) /// verringert ihn — dieselbe Formel bedient Öffnen- und Schließen-Geste /// symmetrisch, je nachdem, wo progress gerade steht. Bewusst rekursiv auf /// dem bereits geclampten Wert (nicht auf der aufsummierten Rohstrecke): /// so reagiert eine Richtungsumkehr mitten in der Geste sofort, auch wenn /// die Geste vorher schon über [oeffnenReferenzHoehe] hinausgezogen wurde. void dragBy(double dy) { _animation.value = (_animation.value - dy / oeffnenReferenzHoehe).clamp(0.0, 1.0); } /// [strecke]/[geschwindigkeit] wie in wischen.dart (negativ = nach oben). /// Bei einer Öffnen-Geste (strecke <= 0) direkt über /// [oeffnetVollbildMitStrecke] entschieden; bei einer Schließen-Geste /// (strecke >= 0, im Vollbild-Player nach unten gezogen) über dieselbe /// Funktion mit gespiegeltem Vorzeichen — "stark genug nach unten" ist /// spiegelbildlich zu "stark genug nach oben". Fliegt danach mit der /// echten Loslass-Geschwindigkeit als Startimpuls zum entschiedenen Ziel. void dragEnd(BuildContext context, double strecke, double geschwindigkeit) { final zielIstOffen = strecke <= 0 ? oeffnetVollbildMitStrecke(strecke, geschwindigkeit) : !oeffnetVollbildMitStrecke(-strecke, -geschwindigkeit); if (MediaQuery.disableAnimationsOf(context)) { _animation.value = zielIstOffen ? 1.0 : 0.0; return; } // -geschwindigkeit/oeffnenReferenzHoehe, weil dragBy() ein negatives dy // (nach oben) in steigenden progress übersetzt — dieselbe Umrechnung // rückwärts. Zeigt die tatsächliche Geschwindigkeit nicht zum // entschiedenen Ziel (z.B. ein Aufwärtswisch, der am Ende minimal // zurückfedert), erzwingt ein moderater Mindestimpuls die Zielrichtung, // statt fling() mit widersprüchlichem Vorzeichen zu verwirren. final rohimpuls = -geschwindigkeit / oeffnenReferenzHoehe; final zeigtZumZiel = zielIstOffen ? rohimpuls > 0 : rohimpuls < 0; final impuls = zeigtZumZiel ? rohimpuls : (zielIstOffen ? 1.0 : -1.0); _animation.fling(velocity: impuls); } void open(BuildContext context) => _animiereZu(context, 1.0); void close(BuildContext context) => _animiereZu(context, 0.0); /// Für Tap-Auslöser (kein Ziehen, keine Loslass-Geschwindigkeit) — anders /// als [dragEnd] mit fester Kurve/Dauer aus [MeloMotion], nicht als Fling. void _animiereZu(BuildContext context, double ziel) { if (MediaQuery.disableAnimationsOf(context)) { _animation.value = ziel; return; } _animation.animateTo(ziel, duration: MeloMotion.normal, curve: MeloMotion.curve); } @override void dispose() { _animation.dispose(); super.dispose(); } } ``` - [ ] **Step 4: Tests laufen lassen, sicherstellen dass sie bestehen** Run: `flutter test test/player/player_expansion_controller_test.dart` Expected: PASS (alle 9 Tests) - [ ] **Step 5: `flutter analyze` laufen lassen** Run: `flutter analyze --no-pub` Expected: Keine Befunde - [ ] **Step 6: Commit** ```bash git add lib/player/player_expansion_controller.dart test/player/player_expansion_controller_test.dart git commit -m "PlayerExpansionController: optischer Öffnen-Fortschritt für den Vollbild-Player" ``` --- ### Task 2: Controller in `HomeShell` bereitstellen + Zurück-Taste **Files:** - Modify: `lib/main.dart:153-269` (`_HomeShellState`) - Test: `test/player/home_shell_expansion_test.dart` (neu, wächst in späteren Tasks weiter) **Interfaces:** - Consumes: `PlayerExpansionController` aus Task 1 (`progress`, `open`, `close`) - Produces: `PlayerExpansionController` ist über `context.read()`/`context.watch<...>()` überall unterhalb von `HomeShell` erreichbar. Diese Aufgabe ändert **nur** Bereitstellung + Zurück-Taste — der visuelle `Stack`-Umbau (Blur-Layer, Overlay-Inhalt) folgt in Task 4/5. Nach diesem Task sieht die App optisch noch unverändert aus. - [ ] **Step 1: Fehlschlagenden Test schreiben** ```dart // test/player/home_shell_expansion_test.dart import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:melo/player/player_expansion_controller.dart'; // Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell // nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen. // Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet, // sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test // sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double. class _TestShell extends StatefulWidget { const _TestShell(); @override State<_TestShell> createState() => _TestShellState(); } class _TestShellState extends State<_TestShell> with TickerProviderStateMixin { late final PlayerExpansionController _controller; @override void initState() { super.initState(); _controller = PlayerExpansionController(vsync: this); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: _controller, child: PopScope( canPop: _controller.progress == 0, onPopInvokedWithResult: (didPop, _) { if (!didPop) _controller.close(context); }, child: const Scaffold(body: SizedBox()), ), ); } } void main() { testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen', (tester) async { await tester.pumpWidget(const MaterialApp(home: _TestShell())); final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; controller.open(tester.element(find.byType(_TestShell))); await tester.pumpAndSettle(); expect(controller.progress, 1.0); await tester.pageBack(); // simuliert Zurück-Taste/-Geste über PopScope await tester.pumpAndSettle(); expect(controller.progress, 0.0); }); } ``` - [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** Run: `flutter test test/player/home_shell_expansion_test.dart` Expected: FAIL — `_TestShellState._controller` ist zwar vorhanden (Test-Double), aber `tester.pageBack()` popt hier noch nichts, weil `_TestShell` bislang ohne echte zweite Route existiert. **Hinweis für den Umsetzer:** Falls `pageBack()` in diesem isolierten Test-Double nicht greift (es gibt keine zweite Route zum Zurückgehen), stattdessen direkt `Navigator.maybePop(tester.element(find.byType(_TestShell)))` aufrufen, um denselben `PopScope`-Callback auszulösen. Passe den Test entsprechend an, bevor du fortfährst — das Ziel ist, dass `onPopInvokedWithResult` mit `didPop == false` durchläuft, wenn `progress > 0`. - [ ] **Step 3: `HomeShell` erweitern** In `lib/main.dart`, `_HomeShellState`: ```dart class _HomeShellState extends State with WidgetsBindingObserver, TickerProviderStateMixin { int _index = 0; late final AutoScan _autoScan; late final PlayerExpansionController _expansion; // ... bestehendes _geteilteAdresse-Feld unverändert ... @override void initState() { super.initState(); _expansion = PlayerExpansionController(vsync: this); // ... bestehender initState-Rumpf unverändert ... } @override void dispose() { _expansion.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } ``` `build()`s `PopScope` wird angepasst (bisheriger Kommentar zum reinen Tab-Fall bleibt erhalten, wird ergänzt): ```dart return ChangeNotifierProvider.value( value: _expansion, // Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird // laut Flutter-Semantik bei jedem _expansion-Tick NICHT neu gebaut, // das ist hier auch nicht nötig: PopScope selbst braucht nur einen // aktuellen Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste // gedrückt), keine Frame-genaue Aktualisierung während des Ziehens. // Der Blur-Layer/Mini-Player-Fade in Task 4 braucht dagegen echte // Live-Reaktivität und bekommt dafür einen eigenen, davon unabhängigen // AnimatedBuilder direkt an seiner Stelle im Stack — siehe // Reaktivitäts-Regel in Global Constraints. child: Consumer( builder: (context, expansion, child) => PopScope( // Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset: // Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch // den Tab wechseln. canPop: expansion.progress == 0 && _index == 0, onPopInvokedWithResult: (didPop, _) { if (didPop) return; if (expansion.progress > 0) { expansion.close(context); } else { setState(() => _index = 0); } }, child: child!, ), child: Scaffold( // ... bestehender Scaffold-Rumpf unverändert (Stack-Umbau folgt in Task 4) ... ), ), ); ``` Import ergänzen: `import 'player/player_expansion_controller.dart';` - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test test/player/home_shell_expansion_test.dart` Expected: PASS - [ ] **Step 5: Volle Suite + Analyze** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Alles grün (bestehende Tests unverändert, da `HomeShell` optisch identisch bleibt) - [ ] **Step 6: Commit** ```bash git add lib/main.dart test/player/home_shell_expansion_test.dart git commit -m "HomeShell: PlayerExpansionController bereitstellen, Zurück-Taste schließt offenen Player" ``` --- ### Task 3: Die 3 Tap-only-Öffnen-Stellen migrieren **Files:** - Modify: `lib/library/song_list.dart:382-396` (`SongZeile._zeile.onTap`) - Modify: `lib/playlists/playlist_detail_screen.dart:127-150` (Song-`ListTile.onTap`) - Modify: `lib/downloads/server_titel_screen.dart:73-97` (`_ServerTitelScreenState._spiele`) **Interfaces:** - Consumes: `PlayerExpansionController.open(BuildContext)` aus Task 1, bereitgestellt seit Task 2 Alle drei Stellen folgen demselben Muster: nach erfolgreichem Abspielen `Navigator.push(MaterialPageRoute(builder: (_) => const NowPlayingScreen()))` → `context.read().open(context)`. Fehlerpfad/Snackbar-Logik bleibt unverändert. **Bekannte Zwischenlücke (Review-Panel-Fund, akzeptiert):** Nach diesem Task tut ein Tap auf einen Song an allen drei Stellen sichtbar nichts mehr (Titel spielt bereits, aber weder die alte Route noch das neue Overlay erscheinen — Letzteres kommt erst in Task 4/5). Das ist wie bei Task 2 ein bewusster Zwischenstand ohne vollständige UI, kein Fehler in diesem Task selbst. `flutter test` bleibt trotzdem grün, weil die Tests in Step 4 exakt auf `controller.progress == 1` statt auf ein sichtbares Overlay prüfen (das Overlay-Rendering ist nicht Teil dieses Tasks). Wer die App zwischen Task 3 und Task 5 manuell auf dem Gerät testet, sieht diese Lücke — sie schließt sich mit Task 5s Commit. - [ ] **Step 1: `song_list.dart` ändern** Vorher (Zeile ~382-385): ```dart if (!context.mounted) return; Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NowPlayingScreen()), ); ``` Nachher: ```dart if (!context.mounted) return; context.read().open(context); ``` Import `now_playing_screen.dart` entfernen, falls dort sonst nicht mehr gebraucht (prüfen — `song_list.dart` importiert es aktuell nur für diesen Push). Import `../player/player_expansion_controller.dart` ergänzen. - [ ] **Step 2: `playlist_detail_screen.dart` analog ändern** Vorher (Zeile ~137-140): ```dart if (!context.mounted) return; Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NowPlayingScreen()), ); ``` Nachher: ```dart if (!context.mounted) return; context.read().open(context); ``` Importe analog anpassen. - [ ] **Step 3: `server_titel_screen.dart` analog ändern** Vorher (Zeile ~81-84): ```dart if (!mounted) return; Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NowPlayingScreen()), ); ``` Nachher: ```dart if (!mounted) return; context.read().open(context); ``` Importe analog anpassen. - [ ] **Step 4: Bestehende Tests der drei Dateien laufen lassen** Run: `flutter test test/library/song_list_navigation_test.dart test/playlists/ test/downloads/ --no-pub` (exakte Testdateipfade beim Umsetzer per `find test -iname "*song_list*" -o -iname "*playlist_detail*" -o -iname "*server_titel*"` verifizieren, da nicht alle drei zwingend eigene Tests haben) Expected: Tests, die bisher auf `Navigator.push`/`find.byType(NowPlayingScreen)` nach einem Tap prüften, müssen jetzt stattdessen prüfen, dass `PlayerExpansionController.progress` auf 1 geht (Provider im Testaufbau bereitstellen, analog zu Task 2s `_TestShell`-Muster). Fehlschlagende Tests hier anpassen, nicht überspringen. - [ ] **Step 5: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün - [ ] **Step 6: Commit** ```bash git add lib/library/song_list.dart lib/playlists/playlist_detail_screen.dart lib/downloads/server_titel_screen.dart test/ git commit -m "3 Tap-Stellen öffnen den Vollbild-Player jetzt über PlayerExpansionController statt Navigator.push" ``` --- ### Task 4: `HomeShell` — Blur-Layer + Mini-Player-Fade **Files:** - Modify: `lib/main.dart` (`_HomeShellState.build`, Scaffold-`body`) - Test: `test/player/home_shell_expansion_test.dart` (erweitert) **Interfaces:** - Consumes: `PlayerExpansionController.progress` (Task 1) - Produces: `Stack`-Struktur, auf die Task 5 den Overlay-Inhalt aufsetzt - [ ] **Step 1: Fehlschlagenden Test ergänzen** ```dart testWidgets('Blur-Layer erscheint erst, wenn progress > 0', (tester) async { await tester.pumpWidget(const MaterialApp(home: _TestShell())); expect(find.byType(BackdropFilter), findsNothing); final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; controller.open(tester.element(find.byType(_TestShell))); await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation expect(find.byType(BackdropFilter), findsOneWidget); await tester.pumpAndSettle(); }); testWidgets('Tab-Inhalt ist bei offenem Player nicht mehr antippbar', (tester) async { var getappt = false; await tester.pumpWidget(MaterialApp( home: _TestShell( tabContent: GestureDetector( onTap: () => getappt = true, child: const SizedBox(width: 200, height: 200), ), ), )); final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; controller.open(tester.element(find.byType(_TestShell))); await tester.pumpAndSettle(); await tester.tap(find.byType(GestureDetector), warnIfMissed: false); expect(getappt, isFalse); }); ``` **Hinweis für den Umsetzer:** `_TestShell` braucht dafür einen optionalen `tabContent`-Parameter (Default z.B. `SizedBox.shrink()`), der anstelle eines festen Platzhalters in die Stack-Struktur eingesetzt wird — kleine Erweiterung von `_TestShell` aus Task 2, kein neues Konzept. Ergänze in `_TestShell.build` denselben `Stack`-Aufbau wie unten für `HomeShell` vorgesehen — **mit dem eigenen, lokalen `AnimatedBuilder` um Blur-Layer und Mini-Player** (nicht nur den äußeren `Consumer` aus Task 2, der macht diese Ebene NICHT reaktiv, siehe Reaktivitäts-Regel in Global Constraints) — damit dieser Test unabhängig vom vollen `HomeShell` (Audio-Handler etc.) läuft UND tatsächlich beweist, dass der Blur bei laufender Geste live erscheint, nicht erst beim nächsten zufälligen Rebuild. - [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** Run: `flutter test test/player/home_shell_expansion_test.dart` Expected: FAIL — `_TestShell` hat noch keinen `BackdropFilter` - [ ] **Step 3: `HomeShell`s `Scaffold.body` umbauen** Vorher (`lib/main.dart`, im `Scaffold`): ```dart body: Column( children: [ Expanded(child: IndexedStack(index: _index, children: tabs)), const MiniPlayer(), ], ), ``` Nachher — **wichtig:** Blur-Layer und Mini-Player-Fade bekommen jeweils ihren **eigenen** `AnimatedBuilder`, ausgewertet in dessen `builder:`, nicht im `child:` eines fremden Wrappers (siehe Reaktivitäts-Regel). Der Tab-Inhalt bekommt zusätzlich `IgnorePointer`/`ExcludeSemantics` bei `progress > 0`, damit er unter dem Blur weder antippbar noch für TalkBack erreichbar bleibt: ```dart extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar body: Stack( children: [ AnimatedBuilder( animation: _expansion, builder: (context, child) => IgnorePointer( ignoring: _expansion.progress > 0, child: ExcludeSemantics( excluding: _expansion.progress > 0, child: child, ), ), child: Column( children: [ Expanded(child: IndexedStack(index: _index, children: tabs)), const SizedBox(height: MiniPlayer.hoehe), // Platz für den Overlay-Mini-Player ], ), ), AnimatedBuilder( animation: _expansion, builder: (context, child) { final p = _expansion.progress; if (p == 0) return const SizedBox.shrink(); return Positioned.fill( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0), child: Container(color: Colors.transparent), ), ); }, ), AnimatedBuilder( animation: _expansion, builder: (context, child) => Positioned( left: 0, right: 0, bottom: 0, child: Opacity( opacity: (1 - _expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( ignoring: _expansion.progress > 0, child: child, ), ), ), child: const MiniPlayer(), ), // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier. ], ), ``` Import ergänzen: `import 'dart:ui';` (für `ImageFilter`). Jede der drei Ebenen (Tab-Inhalt-Blocker, Blur, Mini-Player-Fade) trägt ihren eigenen `AnimatedBuilder` und reagiert damit unabhängig und live auf jeden `progress`-Tick — der äußere `Consumer` aus Task 2 dient ausschließlich `PopScope`, nicht diesen drei Ebenen (siehe Reaktivitäts-Regel in Global Constraints; das war der Kernfehler, den das Review-Panel an dieser Stelle in einer früheren Fassung gefunden hat). **Zusätzliche Folge von `extendBody: true` (Review-Panel-Nachtrag):** Ohne diese Zeile stimmte `MediaQuery.sizeOf(context)` (von Task 5s `_vollbildRect` verwendet) nicht mit der tatsächlichen `Scaffold.body`-Höhe überein — mit ihr erstreckt sich `body` jetzt aber unter die weiterhin fest sichtbare `bottomNavigationBar:`. Die soll bei vollständig geöffnetem Player nicht mehr sichtbar/antippbar sein (echtes "Vollbild"), also bekommt sie dieselbe Fade-Behandlung wie der Mini-Player. `bottomNavigationBar:` im `Scaffold` (unverändert an ihrer bisherigen Stelle, nicht Teil des `body`-`Stack`) wird umschlossen: ```dart bottomNavigationBar: AnimatedBuilder( animation: _expansion, builder: (context, child) => Opacity( opacity: (1 - _expansion.progress).clamp(0.0, 1.0), child: IgnorePointer( ignoring: _expansion.progress > 0, child: child, ), ), child: DecoratedBox( // ... bisheriger Inhalt (Haarlinie + BottomNavigationBar) unverändert ... ), ), ``` - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test test/player/home_shell_expansion_test.dart` Expected: PASS - [ ] **Step 5: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün - [ ] **Step 6: Commit** ```bash git add lib/main.dart test/player/home_shell_expansion_test.dart git commit -m "HomeShell: Blur-Layer und Mini-Player-Fade an progress gekoppelt" ``` --- ### Task 5: `NowPlayingScreen` zu Overlay-Inhalt umbauen **Files:** - Modify: `lib/player/now_playing_screen.dart` (kompletter Umbau von `NowPlayingScreen`, `_CoverGrund` bleibt inhaltlich, `_Cover`/`Hero` entfällt) - Modify: `lib/main.dart` (Overlay-Inhalt in den `Stack` aus Task 4 einhängen) - Test: `test/player/now_playing_screen_test.dart` (bestehende Datei prüfen/anpassen, falls vorhanden — sonst neu) **Interfaces:** - Consumes: `PlayerExpansionController.progress` (Task 1) - Produces: `NowPlayingScreen` ist kein `StatelessWidget` mehr, das eine ganze `Scaffold`-Seite baut, sondern liefert seinen Inhalt für ein vorgegebenes Rechteck; `HomeShell` übernimmt Positionierung/Größe. Dies ist der größte Einzelschritt. Aufteilung in Unterschritte: - [ ] **Step 1: `NowPlayingScreen` von `Scaffold` zu einfachem Inhalt umbauen** `NowPlayingScreen.build` verliert `Scaffold`/`AppBar`/`extendBodyBehindAppBar` — die AppBar-Icons (Songtext, Sleep-Timer, Warteschlange) wandern in eine eigene `Row` oben im Inhalt, gerahmt von `SafeArea`. Die `_CoverGrund`-Farbverlauf-Logik bleibt unverändert bestehen, wird aber jetzt direkt vom neuen Wurzel-Widget verwendet statt vom `Scaffold`. ```dart /// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress /// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route. class NowPlayingScreen extends StatelessWidget { const NowPlayingScreen({super.key}); void _showLyrics(BuildContext context, String songId) { if (songId.isEmpty) return; showModalBottomSheet( context: context, builder: (ctx) => _LyricsSheet(songId: songId), ); } @override Widget build(BuildContext context) { final handler = context.read(); // Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor: // transparent) auf dem durchgehend dunklen Theme lief (siehe // MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die // Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die // Icons je nach Systemzustand zufällig dunkel auf dunklem Grund. return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: _CoverGrund( handler: handler, child: Material( color: Colors.transparent, child: SafeArea( child: Column( children: [ _VollbildLeiste(handler: handler, onLyrics: _showLyrics), Expanded( child: GestureDetector( onVerticalDragStart: (_) {}, // s. Task 8 child: StreamBuilder( stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) { return const Center(child: Text('Nichts in Wiedergabe')); } return LayoutBuilder( builder: (context, raum) { final nebeneinander = raum.maxWidth > raum.maxHeight; final inhalt = _Angaben(item: item, handler: handler); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: nebeneinander ? Row(children: [ Expanded(child: Center(child: _CoverPlatzhalter())), const SizedBox(width: MeloSpace.lg), Expanded( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: inhalt, ), ), ), ]) : Column(children: [ Expanded(child: Center(child: _CoverPlatzhalter())), const SizedBox(height: MeloSpace.lg), Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: inhalt, ), ), ]), ); }, ); }, ), ), ), ], ), ), ), ), ); } } /// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche /// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt /// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio) /// von außen nachzurechnen. final coverPlatzhalterKey = GlobalKey(); /// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover` /// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom /// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann. class _CoverPlatzhalter extends StatelessWidget { const _CoverPlatzhalter(); @override Widget build(BuildContext context) => AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox()); } /// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als /// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier /// nicht mehr. class _VollbildLeiste extends StatelessWidget { const _VollbildLeiste({required this.handler, required this.onLyrics}); final MeloAudioHandler handler; final void Function(BuildContext, String) onLyrics; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ StreamBuilder( stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) return const SizedBox.shrink(); final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; return IconButton( tooltip: songId.isEmpty ? 'Kein Songtext für diesen Titel verfügbar' : 'Songtext', icon: const Icon(Icons.lyrics), onPressed: songId.isEmpty ? null : () => onLyrics(context, songId), ); }, ), _SleepTimerButton(handler: handler), IconButton( tooltip: 'Warteschlange', icon: const Icon(Icons.queue_music), onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const QueueScreen()), ), ), ], ); } } ``` `_Angaben`, `_Herz`, `_ProgressBar`, `_Controls`, `_SleepTimerButton`, `_LyricsSheet`, `_Mitlaufend`, `_CoverGrund`/`_CoverGrundState` bleiben **unverändert** (nur `_Cover`/`coverHeldenName`/`Hero` entfallen — Cover-Rendering übernimmt Task 6 vollständig). - [ ] **Step 2: `HomeShell`s `Stack` um den Overlay-Inhalt ergänzen** In `lib/main.dart`, im `Stack` aus Task 4, nach dem Mini-Player-`Positioned`. **Wichtig — nicht `if (p == 0) return SizedBox.shrink()` verwenden:** das würde `NowPlayingScreen` bei jedem vollständigen Schließen aus dem Baum entfernen und `_CoverGrundState` disposen — exakt der State-Verlust, den die Spec als behobenen Nachteil des alten Route-Ansatzes benennt (siehe Spec „Verhaltensänderung“). Stattdessen `Visibility` mit `maintainState: true`, das den Subtree gemountet lässt, aber bei `p == 0` weder malt noch Touch-/Screenreader-Ereignisse durchlässt: ```dart AnimatedBuilder( animation: _expansion, builder: (context, child) { final p = _expansion.progress; return Positioned.fromRect( rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, child: Visibility( visible: p > 0, maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec maintainAnimation: true, child: Opacity( // Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec. opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0), child: child, ), ), ); }, child: const NowPlayingScreen(), ), ``` `NowPlayingScreen` hängt hier bewusst wieder als statisches `AnimatedBuilder.child` (nicht im `builder`) — das ist diesmal korrekt, weil `NowPlayingScreen` selbst nichts direkt von `_expansion.progress` liest (nur seine Position/Sichtbarkeit/Deckkraft von außen gesteuert wird); seine eigene Reaktivität (Songtitel, Fortschritt, …) läuft unverändert über eigene `StreamBuilder`. Mit zwei kleinen Hilfsmethoden in `_HomeShellState`: ```dart Rect _miniRect(BuildContext context) { final size = MediaQuery.sizeOf(context); final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight; return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe); } Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context); ``` - [ ] **Step 3: Bestehenden `now_playing_screen_test.dart` (falls vorhanden) prüfen/anpassen** Run: `find test -iname "*now_playing*"` — falls eine Datei existiert, die `NowPlayingScreen` bisher in ein `MaterialApp(home: NowPlayingScreen())` pumpt (als eigene Route), auf `pumpWidget(MaterialApp(home: Scaffold(body: NowPlayingScreen())))` umstellen (kein Scaffold/AppBar mehr in `NowPlayingScreen` selbst, Test muss also selbst einen Rahmen stellen) und Assertions auf entfallene `AppBar`-Icons in die neue `_VollbildLeiste`-Struktur überführen. - [ ] **Step 4: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün. Bekannter Stolperstein: verwaiste Imports (`Hero`/`coverHeldenName` nicht mehr referenziert) — `flutter analyze` zeigt das als `unused_element`/`unused_import`. - [ ] **Step 5: Commit** ```bash git add lib/player/now_playing_screen.dart lib/main.dart test/ git commit -m "NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut" ``` --- ### Task 6: `_WanderndesCover` — Cover-Interpolation statt `Hero` **Files:** - Modify: `lib/player/now_playing_screen.dart` (neue Komponente `_WanderndesCover`, ersetzt `_CoverPlatzhalter`-Leerstelle inhaltlich über eine eigene `Stack`-Ebene in `HomeShell`) - Modify: `lib/main.dart` (Cover-Ebene im `Stack` ergänzen) - Modify: `lib/player/mini_player.dart` (eigenes `Hero`/`CoverImage` entfernen) - Test: `test/player/wanderndes_cover_test.dart` (neu) **Interfaces:** - Consumes: `PlayerExpansionController.progress` - Produces: `class WanderndesCover extends StatelessWidget` (public — wird sowohl aus `now_playing_screen.dart`-Kontext als auch direkt aus `main.dart` referenziert, daher kein führender Unterstrich) - `const WanderndesCover({required double progress, required Uri? artUri})` - [ ] **Step 1: Fehlschlagenden Test schreiben** ```dart // test/player/wanderndes_cover_test.dart import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:melo/player/now_playing_screen.dart'; void main() { testWidgets('Rect bei progress=0 entspricht dem Mini-Player-Rechteck', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: WanderndesCover( progress: 0, artUri: null, miniRect: const Rect.fromLTWH(0, 700, 400, 52), vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), ), ), )); final box = tester.getRect(find.byType(WanderndesCover)); expect(box, const Rect.fromLTWH(0, 700, 400, 52)); }); testWidgets('Rect bei progress=1 entspricht dem Vollbild-Rechteck', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: WanderndesCover( progress: 1, artUri: null, miniRect: const Rect.fromLTWH(0, 700, 400, 52), vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), ), ), )); final box = tester.getRect(find.byType(WanderndesCover)); expect(box, const Rect.fromLTWH(0, 0, 400, 800)); }); testWidgets('Rect bei progress=0.5 liegt in der Mitte', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: WanderndesCover( progress: 0.5, artUri: null, miniRect: const Rect.fromLTWH(0, 700, 400, 52), vollbildRect: const Rect.fromLTWH(0, 0, 400, 800), ), ), )); final box = tester.getRect(find.byType(WanderndesCover)); expect(box, Rect.lerp( const Rect.fromLTWH(0, 700, 400, 52), const Rect.fromLTWH(0, 0, 400, 800), 0.5, )); }); } ``` - [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** Run: `flutter test test/player/wanderndes_cover_test.dart` Expected: FAIL — `WanderndesCover` existiert nicht - [ ] **Step 3: Implementierung schreiben** In `lib/player/now_playing_screen.dart` (öffentliche Komponente, ersetzt die alte `_Cover`-Klasse und `coverHeldenName`): ```dart /// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert. /// /// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten /// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau /// keine Route mehr. Positionierung/Größe kommen direkt aus [progress], /// live an die Zugstrecke der Wischgeste gekoppelt. class WanderndesCover extends StatelessWidget { const WanderndesCover({ super.key, required this.progress, required this.artUri, required this.miniRect, required this.vollbildRect, }); final double progress; final Uri? artUri; final Rect miniRect; final Rect vollbildRect; static const _miniRadius = 6.0; static const _vollbildRadius = 16.0; @override Widget build(BuildContext context) { final rect = Rect.lerp(miniRect, vollbildRect, progress)!; final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!; return Positioned.fromRect( rect: rect, child: CoverImage(artUri: artUri, radius: radius), ); } } ``` Import ergänzen: `import 'dart:ui' show lerpDouble;` (falls `dart:ui` noch nicht importiert ist — in `now_playing_screen.dart` bislang nicht der Fall, separat von Task 4s `dart:ui`-Import in `main.dart`). `_Cover`-Klasse und `coverHeldenName`-Konstante aus `now_playing_screen.dart` entfernen (durch `_CoverPlatzhalter`/`WanderndesCover` ersetzt). - [ ] **Step 4: `HomeShell` verdrahten** In `lib/main.dart`, `Stack` (Task 5s Overlay-Positioned ergänzen um eine eigene Cover-Ebene, die on top of allem liegt und permanent — auch bei `progress == 0` — das Cover an der Mini-Player-Position zeigt): ```dart StreamBuilder( stream: context.read().mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) return const SizedBox.shrink(); return AnimatedBuilder( animation: _expansion, builder: (context, child) => WanderndesCover( progress: _expansion.progress, artUri: item.artUri, miniRect: _miniCoverRect(context), vollbildRect: _vollbildCoverRect(context), ), ); }, ), ``` `_miniCoverRect` ist eine reine Formel wie `_miniRect` (feste 52×52-Größe, linksbündig mit `MeloSpace.md`-Abstand, vertikal mittig in `MiniPlayer.hoehe` — Werte aus dem bisherigen `mini_player.dart`-Layout übernehmen). `_vollbildCoverRect` ist **nicht** per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter` sitzt in `NowPlayingScreen`s eigenem responsivem `LayoutBuilder` (Row-vs-Column-Weiche, `AspectRatio(1)` innerhalb `Expanded`/`Padding(horizontal: 24)`) — eine externe Nachrechnung dieser Logik in `_HomeShellState` würde bei jeder künftigen Layout-Änderung an `NowPlayingScreen` stillschweigend auseinanderlaufen. Stattdessen wird die tatsächliche Position **gemessen**: `_CoverPlatzhalter` bekommt einen `GlobalKey` (`coverPlatzhalterKey`, exportiert aus `now_playing_screen.dart`), `_vollbildCoverRect` liest ihn aus: ```dart Rect _vollbildCoverRect(BuildContext context) { final box = coverPlatzhalterKey.currentContext?.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) { // Vor dem ersten Layout (z.B. ganz am Anfang, progress noch 0) — // Notlösung: Platzhalter mittig im Vollbild-Rechteck annehmen, bis // der erste Frame mit progress > 0 eine echte Messung liefert. final voll = _vollbildRect(context); final seite = voll.width - 48; return Rect.fromLTWH(voll.left + 24, voll.top + 80, seite, seite); } return box.localToGlobal(Offset.zero) & box.size; } ``` Da `NowPlayingScreen` dank `Visibility(maintainState: true)` (Task 5) auch bei `progress == 0` durchgehend im Baum bleibt (nur unsichtbar), ist `_CoverPlatzhalter` fast immer schon gemessen — der Notlösungs-Zweig greift nur im allerersten Frame vor dem ersten Layout-Pass. - [ ] **Step 5: `mini_player.dart` — eigenes Cover entfernen** In `_LeisteState.build`, den `Hero(tag: coverHeldenName, child: CoverImage(...))`-Block ersatzlos streichen (das per `WanderndesCover` in `HomeShell` gerenderte Cover deckt exakt dieselbe Position bei `progress == 0` ab) — die umgebende `Row` bekommt an der Stelle stattdessen einen leeren `SizedBox(width: 52)` als Platzhalter, damit Titel/Bedienung nicht springen. - [ ] **Step 6: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test test/player/wanderndes_cover_test.dart` Expected: PASS - [ ] **Step 7: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün - [ ] **Step 8: Commit** ```bash git add lib/player/now_playing_screen.dart lib/main.dart lib/player/mini_player.dart test/player/wanderndes_cover_test.dart git commit -m "WanderndesCover ersetzt Hero-Übergang: Cover wandert live mit progress" ``` --- ### Task 7: Mini-Player-Geste an den Controller anschließen **Files:** - Modify: `lib/player/mini_player.dart:55-181` (`_LeisteState`) - Test: bestehende Mini-Player-Tests prüfen/anpassen (`find test -iname "*mini_player*"`) **Interfaces:** - Consumes: `PlayerExpansionController.dragBy`/`dragEnd`/`open` (Task 1) - [ ] **Step 1: Bestehenden Test lesen und Erwartung anpassen (RED)** Bestehende Tests, die `_versatz`/das Zurückschnappen des Mini-Players direkt prüfen, auf Prüfung von `PlayerExpansionController.progress` umstellen (Provider im Testaufbau bereitstellen). Wo ein Test bisher `Navigator.push`/`find.byType(NowPlayingScreen)` nach Tap/Wisch erwartete, jetzt `controller.progress == 1` nach `pumpAndSettle()` erwarten. - [ ] **Step 2: `_LeisteState` umbauen** ```dart class _LeisteState extends State<_Leiste> { /// Für die horizontale Titel-Wechsel-Geste (unverändert). double _horizontalVersatz = 0; bool _ziehtHorizontalGerade = false; /// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie /// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt /// im PlayerExpansionController). Wird für [PlayerExpansionController. /// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet; /// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt /// zurückrechnen. double _vertikaleStrecke = 0; void _oeffne(BuildContext context) => context.read().open(context); @override Widget build(BuildContext context) { final texte = Theme.of(context).textTheme; final handler = widget.handler; final item = widget.item; return DecoratedBox( decoration: const BoxDecoration( color: MeloTheme.surface, border: Border(top: BorderSide(color: MeloTheme.border)), ), child: Material( color: Colors.transparent, child: Column( mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onVerticalDragStart: (_) => _vertikaleStrecke = 0, onVerticalDragUpdate: (details) { _vertikaleStrecke += details.delta.dy; context.read().dragBy(details.delta.dy); }, onVerticalDragEnd: (details) => context .read() .dragEnd(context, _vertikaleStrecke, details.velocity.pixelsPerSecond.dy), onHorizontalDragUpdate: (details) => setState(() { _ziehtHorizontalGerade = true; _horizontalVersatz += details.delta.dx; }), onHorizontalDragEnd: (details) { final wechsel = titelWechselMitStrecke( _horizontalVersatz, details.velocity.pixelsPerSecond.dx); setState(() { _ziehtHorizontalGerade = false; _horizontalVersatz = 0; }); switch (wechsel) { case Wechsel.naechster: handler.skipToNext(); case Wechsel.vorheriger: handler.skipToPrevious(); case null: break; } }, child: InkWell( onTap: () => _oeffne(context), child: AnimatedContainer( duration: _ziehtHorizontalGerade ? Duration.zero : MeloMotion.ruhig(context, MeloMotion.normal), curve: MeloMotion.curve, transform: Matrix4.translationValues(_horizontalVersatz, 0, 0), height: MiniPlayer.hoehe, child: Row( children: [ const SizedBox(width: MeloSpace.md), const SizedBox(width: 52), // Platz für WanderndesCover, s. Task 6 const SizedBox(width: MeloSpace.md), // ... Titel/Künstler-Column, _Bedienung unverändert ... ], ), ), ), ), _Fortschritt(handler: handler), ], ), ), ); } } ``` - [ ] **Step 3: Tests laufen lassen, bis grün** Run: `flutter test test/player/ --no-pub` Expected: PASS — bei Fehlschlägen die `strecke`-Herleitung gemäß obigem Hinweis korrigieren, nicht den Test aufweichen. - [ ] **Step 4: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün - [ ] **Step 5: Commit** ```bash git add lib/player/mini_player.dart test/ git commit -m "Mini-Player-Wischgeste steuert PlayerExpansionController statt lokalen Versatz" ``` --- ### Task 8: Schließgeste im Vollbild-Player — symmetrisch live **Files:** - Modify: `lib/player/now_playing_screen.dart` (der `GestureDetector` aus Task 5 Step 1, bislang nur `onVerticalDragStart: (_) {}` als Platzhalter) - Test: bestehende Now-Playing-Tests erweitern **Interfaces:** - Consumes: `PlayerExpansionController.dragBy`/`dragEnd` (Task 1) - [ ] **Step 1: Fehlschlagenden Test schreiben/erweitern** In `test/player/home_shell_expansion_test.dart` (oder `now_playing_screen_test.dart`, je nachdem wo der volle `HomeShell`-Kontext verfügbar ist): ```dart testWidgets('Ziehen nach unten im Vollbild-Player verringert progress live', (tester) async { await tester.pumpWidget(const MaterialApp(home: _TestShell())); final ctx = tester.element(find.byType(_TestShell)); final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; controller.open(ctx); await tester.pumpAndSettle(); expect(controller.progress, 1.0); await tester.drag(find.byType(NowPlayingScreen), const Offset(0, 150)); await tester.pump(); expect(controller.progress, lessThan(1.0)); }); ``` - [ ] **Step 2: Test laufen lassen, sicherstellen dass er fehlschlägt** Run: `flutter test --no-pub -t "Ziehen nach unten"` Expected: FAIL — der `GestureDetector` in `NowPlayingScreen` reagiert noch nicht auf `onVerticalDragUpdate` - [ ] **Step 3: Gestenlogik in `NowPlayingScreen` ergänzen** Der `GestureDetector` aus Task 5 Step 1 (`onVerticalDragStart: (_) {}`) wird zu einem `StatefulWidget`-Wrapper (`_SchliessGeste`), da eine lokale Rohstrecke über die Gestendauer mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in `mini_player.dart`): ```dart class _SchliessGeste extends StatefulWidget { const _SchliessGeste({required this.child}); final Widget child; @override State<_SchliessGeste> createState() => _SchliessGesteState(); } class _SchliessGesteState extends State<_SchliessGeste> { double _strecke = 0; @override Widget build(BuildContext context) { final controller = context.read(); return GestureDetector( onVerticalDragStart: (_) => _strecke = 0, onVerticalDragUpdate: (details) { _strecke += details.delta.dy; controller.dragBy(details.delta.dy); }, onVerticalDragEnd: (details) => controller.dragEnd( context, _strecke, details.velocity.pixelsPerSecond.dy), child: widget.child, ); } } ``` In `NowPlayingScreen.build` (Task 5 Step 1) ersetzt `_SchliessGeste` den bisherigen Platzhalter-`GestureDetector` um den `StreamBuilder`-Bereich. - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test --no-pub -t "Ziehen nach unten"` Expected: PASS - [ ] **Step 5: Analyze + volle Suite** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Grün - [ ] **Step 6: Commit** ```bash git add lib/player/now_playing_screen.dart test/ git commit -m "Vollbild-Player: Schließgeste live an progress gekoppelt, symmetrisch zum Öffnen" ``` --- ### Task 9: Integrationstest über den ganzen Zyklus + Spec-Abdeckung prüfen **Files:** - Modify: `test/player/home_shell_expansion_test.dart` (finaler End-to-End-Test gegen `_TestShell`) - Create: `test/player/home_shell_real_expansion_test.dart` (Integrationstest gegen das ECHTE `HomeShell`) **Wichtiger Nachtrag (Review-Panel-Fund):** Alle bisherigen Tests (Tasks 2–8) laufen gegen `_TestShell`, einen separat gepflegten Nachbau von `HomeShell` — praktisch für isolierte Tests ohne Audio-Handler-Boilerplate, aber er hätte den in einer früheren Planfassung enthaltenen `AnimatedBuilder.child`-Bug (siehe Global Constraints, Reaktivitäts-Regel) nicht gefangen, weil der Fehler ausschließlich im echten `lib/main.dart` steckte. Dieser Task ergänzt deshalb zusätzlich einen Test gegen das **echte** `HomeShell`, nach dem Muster von `test/home_shell_test.dart` (Provider-Setup dort abschreiben). - [ ] **Step 1: End-to-End-Test gegen `_TestShell` schreiben** ```dart testWidgets( 'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt', (tester) async { await tester.pumpWidget(const MaterialApp(home: _TestShell())); final ctx = tester.element(find.byType(_TestShell)); final controller = tester.state<_TestShellState>(find.byType(_TestShell))._controller; controller.dragBy(-250); await tester.pump(); expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01)); expect(find.byType(BackdropFilter), findsOneWidget); controller.dragEnd(ctx, -250, 0); await tester.pumpAndSettle(); expect(controller.progress, 1.0); await tester.pageBack(); await tester.pumpAndSettle(); expect(controller.progress, 0.0); expect(find.byType(BackdropFilter), findsNothing); }); ``` - [ ] **Step 2: Test laufen lassen** Run: `flutter test test/player/home_shell_expansion_test.dart` Expected: PASS (falls FAIL: Ursache in den Tasks 1-8 suchen, nicht den Test aufweichen) - [ ] **Step 3: Integrationstest gegen das echte `HomeShell` schreiben (fehlschlagend, falls die Live-Reaktivität irgendwo doch nur pro-forma verdrahtet wäre)** ```dart // test/player/home_shell_real_expansion_test.dart import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:melo/library/category_service.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/library_service.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; import 'package:melo/player/player_expansion_controller.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; import 'package:melo/services/navidrome_service.dart'; import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/sync_service.dart'; import 'package:melo/services/yt_download_service.dart'; import 'package:melo/services/yt_search_service.dart'; import 'package:melo/settings/app_settings.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; // Dieselbe _MemorySpeicher/Provider-Verdrahtung wie test/home_shell_test.dart // — von dort kopieren, nicht neu erfinden. void main() { setUp(() => SharedPreferences.setMockInitialValues({})); testWidgets( 'Echtes HomeShell: Blur-Layer reagiert live auf progress, nicht erst ' 'beim nächsten fremden Rebuild', (tester) async { final db = MeloDb(NativeDatabase.memory()); // ... übriges Setup wie in test/home_shell_test.dart ... await tester.pumpWidget(MultiProvider( providers: [/* wie test/home_shell_test.dart */], child: const MaterialApp(home: HomeShell()), )); await tester.pump(); expect(find.byType(BackdropFilter), findsNothing); final controllerFinder = find.byType(HomeShell); final controller = Provider.of( tester.element(controllerFinder), listen: false); controller.open(tester.element(controllerFinder)); // Absichtlich NUR EINEN kurzen Zwischenschritt pumpen (nicht // pumpAndSettle) — das ist der entscheidende Unterschied zum // `_TestShell`-Test: er beweist, dass der Blur-Layer OHNE einen // zusätzlichen, fremden setState()-Trigger (z.B. Tab-Wechsel) live // erscheint. await tester.pump(const Duration(milliseconds: 50)); expect(find.byType(BackdropFilter), findsOneWidget); await tester.pumpAndSettle(); }); } ``` - [ ] **Step 4: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test test/player/home_shell_real_expansion_test.dart` Expected: PASS. Schlägt er fehl, obwohl Tasks 1-8 wie spezifiziert umgesetzt wurden, liegt der Fehler im echten `lib/main.dart` (z.B. eine der drei `AnimatedBuilder`-Ebenen aus Task 4 wurde doch wieder als `child:` statt in `builder:` verdrahtet) — dort suchen, nicht den Test lockern. - [ ] **Step 5: State-Persistenz-Test schreiben (belegt den in der Spec beworbenen Vorteil)** Im selben File oder `test/player/now_playing_screen_test.dart` (je nachdem, wo `_CoverGrundState` einfacher erreichbar ist): ein Test, der über einen `open() → close() → open()`-Zyklus hinweg beweist, dass `NowPlayingScreen`/`_CoverGrund` NICHT neu erzeugt wird (z.B. per `GlobalKey` auf `_CoverGrund` und `tester.state(...)`-Identitätsvergleich vor/nach dem Zyklus, oder ein `Key` auf einem inneren Marker-Widget, das bei `initState` einen Zähler hochzählt — Zähler bleibt bei genau `1`, obwohl `open()`/`close()` mehrfach aufgerufen wurden). - [ ] **Step 6: Test laufen lassen, sicherstellen dass er besteht** Run: `flutter test --no-pub -t "State-Persistenz"` (oder passender Filter je nach gewähltem Testnamen) Expected: PASS — bestätigt, dass die `Visibility(maintainState: true)`-Lösung aus Task 5 tatsächlich greift. - [ ] **Step 7: Spec-Abdeckung gegenprüfen** Gegen `docs/superpowers/specs/2026-08-29-blur-oeffnen-transition-design.md` durchgehen: `PlayerExpansionController` ✓ (Task 1), Blur-Layer ✓ (Task 4, jetzt zusätzlich gegen das echte `HomeShell` verifiziert), `WanderndesCover` ✓ (Task 6), 4 Öffnen-Stellen ✓ (Task 3+7), Zurück-Taste ✓ (Task 2), symmetrische Schließgeste ✓ (Task 8), State-Persistenz-Vorteil ✓ (dieser Task, Step 5). Keine offenen Punkte. - [ ] **Step 8: Commit** ```bash git add test/player/ git commit -m "Integrationstests: kompletter Zyklus gegen echtes HomeShell + State-Persistenz-Nachweis" ``` --- ### Task 10: CHANGELOG + Abschluss-Verifikation **Files:** - Modify: `CHANGELOG.md` - [ ] **Step 1: CHANGELOG-Eintrag ergänzen** — eigene `### Titel (Datum)`-Zwischenüberschrift direkt unter `## [Unreleased]`, oberhalb des bestehenden `EinblendItem`-Eintrags (Konvention: siehe dortiger Eintrag, nicht nur bare Bullets unter `## [Unreleased]`) ```markdown ## [Unreleased] ### ✨ Vollbild-Player: Live-Blur-Übergang statt fester Animation (2026-08-29) - ✅ **Teil 2 der Animations-Serie**: Übergang Mini-Player ↔ Vollbild-Player hängt jetzt live an der Zugdistanz der Wischgeste (Blur + Cover-Wanderung), nicht erst nach Abschluss einer festen Animation. Symmetrisch beim Öffnen und Schließen. - 🔧 `NowPlayingScreen` ist keine per `Navigator.push` geöffnete Route mehr, sondern ein persistentes Overlay in `HomeShell` (`PlayerExpansionController`, `lib/player/player_expansion_controller.dart`). Der bisherige `Hero`-Cover-Übergang ist durch `WanderndesCover` ersetzt (Rect-Interpolation statt Navigator-Flight). ``` (Direkt unter `## [Unreleased]`, oberhalb des bestehenden `EinblendItem`-Eintrags einsortieren — nicht darüber eine zweite `## [Unreleased]`-Überschrift anlegen.) - [ ] **Step 2: Volle Verifikation** Run: `flutter analyze --no-pub && flutter test --no-pub` Expected: Alles grün, keine Warnungen - [ ] **Step 3: Commit** ```bash git add CHANGELOG.md git commit -m "CHANGELOG: Live-Blur-Übergang für den Vollbild-Player" ``` - [ ] **Step 4: Push** ```bash git push -u origin feature/blur-oeffnen-transition ```