WanderndesCover ersetzt Hero-Übergang: Cover wandert live mit progress
This commit is contained in:
@@ -325,6 +325,22 @@ class _HomeShellState extends State<HomeShell>
|
||||
},
|
||||
child: const NowPlayingScreen(),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||
@@ -377,4 +393,31 @@ class _HomeShellState extends State<HomeShell>
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user