Files
Melo/docs/superpowers/plans/2026-08-29-blur-oeffnen-transition.md
T
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

1551 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<PlayerExpansionController>`, 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<PlayerExpansionController>()`/`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<PlayerExpansionController>.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<HomeShell>
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<PlayerExpansionController>.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<PlayerExpansionController>(
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<PlayerExpansionController>().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<PlayerExpansionController>().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<PlayerExpansionController>().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<PlayerExpansionController>().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<MeloAudioHandler>();
// 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<SystemUiOverlayStyle>(
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<MediaItem?>(
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<MediaItem?>(
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<MediaItem?>(
stream: context.read<MeloAudioHandler>().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<PlayerExpansionController>().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<PlayerExpansionController>().dragBy(details.delta.dy);
},
onVerticalDragEnd: (details) => context
.read<PlayerExpansionController>()
.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<PlayerExpansionController>();
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<MediaItem?>`-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 28) 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<PlayerExpansionController>(
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
```