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:
Hermes (Server)
2026-08-29 15:47:12 +02:00
co-authored by Claude Sonnet 5
parent 6687f2f67c
commit 0fe624d1e8
5 changed files with 267 additions and 64 deletions
+78 -51
View File
@@ -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) {