Fix: PlayerExpansionController oberhalb des Navigators bereitstellen
Bug (Code-Review, P0 — blockierte den Push): PlayerExpansionController wurde bisher nur INNERHALB von HomeShell.build() bereitgestellt. Per Navigator.push geöffnete Screens (PlaylistDetailScreen, ServerTitelScreen) sind im Widget-Baum aber Geschwister von HomeShell im selben Navigator/Overlay, keine Nachfahren — sie sahen den Provider nicht. context.read<PlayerExpansionController>().open(context) warf dort zur Laufzeit ProviderNotFoundException, vom bestehenden try/catch abgefangen zu einer "Wiedergabe fehlgeschlagen"-Snackbar — der Player öffnete sich nie. MeloApp ist jetzt ein StatefulWidget (TickerProviderStateMixin), erzeugt PlayerExpansionController in initState() und stellt ihn in seiner MultiProvider-Liste bereit — oberhalb von MaterialApp/Navigator, wie alle anderen App-weiten Provider. HomeShell erzeugt keinen eigenen mehr, sondern liest die Referenz per context.read<PlayerExpansionController>() einmalig (keine Subscription) und reicht sie an seine AnimatedBuilder weiter, die weiterhin direkt am Controller lauschen — die bewusste "Consumer nur um PopScope"-Optimierung (Scaffold wird nicht bei jedem progress-Tick neu gebaut) bleibt dabei erhalten. Test zuerst (RED mit der heutigen, echten ProviderNotFoundException reproduziert — via echtem HomeShell + echtem Navigator.push, nicht dem bisherigen Kurzschluss-Testaufbau, der den Provider manuell direkt neben das Test-Widget klebt): test/player/provider_scope_navigator_push_test.dart. Bestehende Tests, die HomeShell direkt aufbauen (hauptmenue_test.dart, home_shell_test.dart, home_shell_real_expansion_test.dart), an die neue Verdrahtung angepasst — der Controller kommt jetzt aus ihrer eigenen MultiProvider-Liste statt aus HomeShell selbst. Volle Suite (625 Tests) grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6687f2f67c
commit
0fe624d1e8
+78
-51
@@ -115,9 +115,35 @@ Future<void> main() async {
|
||||
runApp(const MeloApp());
|
||||
}
|
||||
|
||||
class MeloApp extends StatelessWidget {
|
||||
class MeloApp extends StatefulWidget {
|
||||
const MeloApp({super.key});
|
||||
|
||||
@override
|
||||
State<MeloApp> createState() => _MeloAppState();
|
||||
}
|
||||
|
||||
class _MeloAppState extends State<MeloApp> with TickerProviderStateMixin {
|
||||
// Muss oberhalb des Navigators bereitgestellt werden (Review-Fund): auch
|
||||
// per Navigator.push geöffnete Screens (z.B. PlaylistDetailScreen,
|
||||
// ServerTitelScreen) rufen .open(context) auf. Als HomeShell-internen
|
||||
// Provider steckte der Controller nur innerhalb von HomeShell.build() — für
|
||||
// eine gepushte Route (ein Geschwister von HomeShell im selben
|
||||
// Navigator/Overlay, kein Nachfahre) war er dort unsichtbar und
|
||||
// context.read<PlayerExpansionController>() warf ProviderNotFoundException.
|
||||
late final PlayerExpansionController _expansion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expansion = PlayerExpansionController(vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expansion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
@@ -135,6 +161,8 @@ class MeloApp extends StatelessWidget {
|
||||
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: _expansion),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
@@ -153,11 +181,9 @@ class HomeShell extends StatefulWidget {
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell>
|
||||
with WidgetsBindingObserver, TickerProviderStateMixin {
|
||||
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
int _index = 0;
|
||||
late final AutoScan _autoScan;
|
||||
late final PlayerExpansionController _expansion;
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
||||
String? _geteilteAdresse;
|
||||
@@ -167,7 +193,6 @@ class _HomeShellState extends State<HomeShell>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expansion = PlayerExpansionController(vsync: this);
|
||||
final db = context.read<MeloDb>();
|
||||
final lib = context.read<LibraryService>();
|
||||
_autoScan = AutoScan(
|
||||
@@ -196,7 +221,6 @@ class _HomeShellState extends State<HomeShell>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expansion.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -229,43 +253,47 @@ class _HomeShellState extends State<HomeShell>
|
||||
const FavoritesScreen(),
|
||||
const YoutubeSearchScreen(),
|
||||
];
|
||||
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(
|
||||
// Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
|
||||
// Scaffold-weiten AnimatedBuilder unten hören direkt am Controller
|
||||
// (animation: expansion) und aktualisieren sich selbst live. Ein
|
||||
// context.watch hier würde stattdessen bei JEDEM _expansion-Tick den
|
||||
// kompletten HomeShell.build() (inkl. Scaffold-Rumpf) neu aufrufen.
|
||||
final expansion = context.read<PlayerExpansionController>();
|
||||
// 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.
|
||||
return 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(
|
||||
extendBody: true, // NowPlayingScreen (Task 5) braucht die volle Höhe bis unter die BottomNavigationBar
|
||||
body: Stack(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) => IgnorePointer(
|
||||
ignoring: _expansion.progress > 0,
|
||||
ignoring: expansion.progress > 0,
|
||||
child: ExcludeSemantics(
|
||||
excluding: _expansion.progress > 0,
|
||||
excluding: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
@@ -277,9 +305,9 @@ class _HomeShellState extends State<HomeShell>
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) {
|
||||
final p = _expansion.progress;
|
||||
final p = expansion.progress;
|
||||
if (p == 0) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
@@ -290,15 +318,15 @@ class _HomeShellState extends State<HomeShell>
|
||||
},
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
||||
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: _expansion.progress > 0,
|
||||
ignoring: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
@@ -306,9 +334,9 @@ class _HomeShellState extends State<HomeShell>
|
||||
child: const MiniPlayer(),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) {
|
||||
final p = _expansion.progress;
|
||||
final p = expansion.progress;
|
||||
return Positioned.fromRect(
|
||||
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
|
||||
child: Visibility(
|
||||
@@ -331,9 +359,9 @@ class _HomeShellState extends State<HomeShell>
|
||||
final item = snapshot.data;
|
||||
if (item == null) return const SizedBox.shrink();
|
||||
return AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) => WanderndesCover(
|
||||
progress: _expansion.progress,
|
||||
progress: expansion.progress,
|
||||
artUri: item.artUri,
|
||||
miniRect: _miniCoverRect(context),
|
||||
vollbildRect: _vollbildCoverRect(context),
|
||||
@@ -346,11 +374,11 @@ class _HomeShellState extends State<HomeShell>
|
||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
||||
bottomNavigationBar: AnimatedBuilder(
|
||||
animation: _expansion,
|
||||
animation: expansion,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
||||
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: _expansion.progress > 0,
|
||||
ignoring: expansion.progress > 0,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
@@ -382,8 +410,7 @@ class _HomeShellState extends State<HomeShell>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Rect _miniRect(BuildContext context) {
|
||||
|
||||
Reference in New Issue
Block a user