diff --git a/lib/main.dart b/lib/main.dart index a8462af..4bee8ae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -325,6 +325,22 @@ class _HomeShellState extends State }, child: const NowPlayingScreen(), ), + 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), + ), + ); + }, + ), ], ), // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund @@ -377,4 +393,31 @@ class _HomeShellState extends State } Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context); + + /// Feste 52×52-Größe, linksbündig mit MeloSpace.md-Abstand, vertikal + /// mittig in MiniPlayer.hoehe — Werte aus mini_player.dart übernommen + /// (dortiges Cover entfällt, siehe Task 6 Step 5). + Rect _miniCoverRect(BuildContext context) { + final mini = _miniRect(context); + const seite = 52.0; + final top = mini.top + (MiniPlayer.hoehe - seite) / 2; + return Rect.fromLTWH(mini.left + MeloSpace.md, top, seite, seite); + } + + /// Nicht per Formel bestimmbar (Review-Panel-Fund): `_CoverPlatzhalter` + /// sitzt in NowPlayingScreens eigenem responsivem LayoutBuilder — die + /// tatsächliche Position wird stattdessen gemessen, siehe + /// coverPlatzhalterKey. + 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; + } } diff --git a/lib/player/mini_player.dart b/lib/player/mini_player.dart index 41dfb77..eb79c7d 100644 --- a/lib/player/mini_player.dart +++ b/lib/player/mini_player.dart @@ -2,7 +2,6 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../shared/cover.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; import 'now_playing_screen.dart'; @@ -136,12 +135,11 @@ class _LeisteState extends State<_Leiste> { child: Row( children: [ const SizedBox(width: MeloSpace.md), - // Dasselbe Bild wie im Vollbild: es wandert beim Öffnen - // dorthin, statt dass ein neuer Bildschirm aufspringt. - Hero( - tag: coverHeldenName, - child: CoverImage(artUri: item.artUri, size: 52), - ), + // Das Cover rendert WanderndesCover in HomeShell in + // einer eigenen Ebene über allem (deckt exakt diese + // Position bei progress == 0 ab) — hier nur Platz + // freihalten, damit Titel/Bedienung nicht springen. + const SizedBox(width: 52), const SizedBox(width: MeloSpace.md), Expanded( child: Column( diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index d8acc21..f4b39f8 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' show lerpDouble; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; @@ -7,6 +8,7 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../services/navidrome_service.dart'; +import '../shared/cover.dart'; import '../shared/favorite_button.dart'; import '../shared/server_favorite_button.dart'; import '../shared/theme.dart'; @@ -101,11 +103,6 @@ class NowPlayingScreen extends StatelessWidget { } } -/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — bis Task 6 -/// (WanderndesCover) den bisherigen Hero-Übergang vollständig ersetzt hat, -/// nutzt `mini_player.dart` diesen Namen noch für sein eigenes Hero-Cover. -const coverHeldenName = 'melo-cover'; - /// 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) @@ -122,6 +119,40 @@ class _CoverPlatzhalter extends StatelessWidget { AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox()); } +/// 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), + ); + } +} + /// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als /// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier /// nicht mehr. diff --git a/test/player/wanderndes_cover_test.dart b/test/player/wanderndes_cover_test.dart new file mode 100644 index 0000000..d115b38 --- /dev/null +++ b/test/player/wanderndes_cover_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/now_playing_screen.dart'; + +void main() { + // WanderndesCover.build() liefert ein Positioned zurück (siehe + // now_playing_screen.dart) — Positioned braucht laut Flutter zwingend + // einen Stack als Vorfahren, sonst wirft es "Incorrect use of + // ParentDataWidget" (per Testlauf verifiziert). Genau so wird es auch in + // main.dart verwendet (direkt als Kind des HomeShell-Stacks), deshalb hier + // ebenfalls in einen Stack gebettet statt direkt als Scaffold.body. + testWidgets('Rect bei progress=0 entspricht dem Mini-Player-Rechteck', + (tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Stack( + children: [ + 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: Stack( + children: [ + 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: Stack( + children: [ + 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, + ), + ); + }); +}