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
+60
-33
@@ -115,9 +115,35 @@ Future<void> main() async {
|
|||||||
runApp(const MeloApp());
|
runApp(const MeloApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MeloApp extends StatelessWidget {
|
class MeloApp extends StatefulWidget {
|
||||||
const MeloApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
@@ -135,6 +161,8 @@ class MeloApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||||
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: _expansion),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Melo',
|
title: 'Melo',
|
||||||
@@ -153,11 +181,9 @@ class HomeShell extends StatefulWidget {
|
|||||||
State<HomeShell> createState() => _HomeShellState();
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeShellState extends State<HomeShell>
|
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||||
with WidgetsBindingObserver, TickerProviderStateMixin {
|
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
late final AutoScan _autoScan;
|
late final AutoScan _autoScan;
|
||||||
late final PlayerExpansionController _expansion;
|
|
||||||
|
|
||||||
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
||||||
String? _geteilteAdresse;
|
String? _geteilteAdresse;
|
||||||
@@ -167,7 +193,6 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_expansion = PlayerExpansionController(vsync: this);
|
|
||||||
final db = context.read<MeloDb>();
|
final db = context.read<MeloDb>();
|
||||||
final lib = context.read<LibraryService>();
|
final lib = context.read<LibraryService>();
|
||||||
_autoScan = AutoScan(
|
_autoScan = AutoScan(
|
||||||
@@ -196,7 +221,6 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_expansion.dispose();
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -229,18 +253,22 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
const FavoritesScreen(),
|
const FavoritesScreen(),
|
||||||
const YoutubeSearchScreen(),
|
const YoutubeSearchScreen(),
|
||||||
];
|
];
|
||||||
return ChangeNotifierProvider<PlayerExpansionController>.value(
|
// Nur die Referenz holen, NICHT abonnieren (kein context.watch): die
|
||||||
value: _expansion,
|
// 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
|
// Consumer NUR um PopScope — sein `child:` (der Scaffold-Rumpf) wird
|
||||||
// laut Flutter-Semantik bei jedem _expansion-Tick NICHT neu gebaut,
|
// laut Flutter-Semantik bei jedem expansion-Tick NICHT neu gebaut, das
|
||||||
// das ist hier auch nicht nötig: PopScope selbst braucht nur einen
|
// ist hier auch nicht nötig: PopScope selbst braucht nur einen aktuellen
|
||||||
// aktuellen Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste
|
// Wert bei jedem HomeShell-Rebuild (z.B. Zurück-Taste gedrückt), keine
|
||||||
// gedrückt), keine Frame-genaue Aktualisierung während des Ziehens.
|
// Frame-genaue Aktualisierung während des Ziehens. Der Blur-Layer/Mini-
|
||||||
// Der Blur-Layer/Mini-Player-Fade in Task 4 braucht dagegen echte
|
// Player-Fade in Task 4 braucht dagegen echte Live-Reaktivität und
|
||||||
// Live-Reaktivität und bekommt dafür einen eigenen, davon unabhängigen
|
// bekommt dafür einen eigenen, davon unabhängigen AnimatedBuilder direkt
|
||||||
// AnimatedBuilder direkt an seiner Stelle im Stack — siehe
|
// an seiner Stelle im Stack — siehe Reaktivitäts-Regel in Global
|
||||||
// Reaktivitäts-Regel in Global Constraints.
|
// Constraints.
|
||||||
child: Consumer<PlayerExpansionController>(
|
return Consumer<PlayerExpansionController>(
|
||||||
builder: (context, expansion, child) => PopScope(
|
builder: (context, expansion, child) => PopScope(
|
||||||
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
|
// Offener/halb gezogener Player hat Vorrang vor dem Tab-Reset:
|
||||||
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
|
// Zurück soll ihn zuerst schließen, nicht gleichzeitig auch noch
|
||||||
@@ -261,11 +289,11 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) => IgnorePointer(
|
builder: (context, child) => IgnorePointer(
|
||||||
ignoring: _expansion.progress > 0,
|
ignoring: expansion.progress > 0,
|
||||||
child: ExcludeSemantics(
|
child: ExcludeSemantics(
|
||||||
excluding: _expansion.progress > 0,
|
excluding: expansion.progress > 0,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -277,9 +305,9 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final p = _expansion.progress;
|
final p = expansion.progress;
|
||||||
if (p == 0) return const SizedBox.shrink();
|
if (p == 0) return const SizedBox.shrink();
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
@@ -290,15 +318,15 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) => Positioned(
|
builder: (context, child) => Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: _expansion.progress > 0,
|
ignoring: expansion.progress > 0,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -306,9 +334,9 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
child: const MiniPlayer(),
|
child: const MiniPlayer(),
|
||||||
),
|
),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
final p = _expansion.progress;
|
final p = expansion.progress;
|
||||||
return Positioned.fromRect(
|
return Positioned.fromRect(
|
||||||
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
|
rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!,
|
||||||
child: Visibility(
|
child: Visibility(
|
||||||
@@ -331,9 +359,9 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
final item = snapshot.data;
|
final item = snapshot.data;
|
||||||
if (item == null) return const SizedBox.shrink();
|
if (item == null) return const SizedBox.shrink();
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) => WanderndesCover(
|
builder: (context, child) => WanderndesCover(
|
||||||
progress: _expansion.progress,
|
progress: expansion.progress,
|
||||||
artUri: item.artUri,
|
artUri: item.artUri,
|
||||||
miniRect: _miniCoverRect(context),
|
miniRect: _miniCoverRect(context),
|
||||||
vollbildRect: _vollbildCoverRect(context),
|
vollbildRect: _vollbildCoverRect(context),
|
||||||
@@ -346,11 +374,11 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
// Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
|
||||||
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
// optisch im Inhalt auf und wirkt nicht wie ein Hauptmenü.
|
||||||
bottomNavigationBar: AnimatedBuilder(
|
bottomNavigationBar: AnimatedBuilder(
|
||||||
animation: _expansion,
|
animation: expansion,
|
||||||
builder: (context, child) => Opacity(
|
builder: (context, child) => Opacity(
|
||||||
opacity: (1 - _expansion.progress).clamp(0.0, 1.0),
|
opacity: (1 - expansion.progress).clamp(0.0, 1.0),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: _expansion.progress > 0,
|
ignoring: expansion.progress > 0,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -382,7 +410,6 @@ class _HomeShellState extends State<HomeShell>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart';
|
|||||||
import 'package:melo/library/playlist_service.dart';
|
import 'package:melo/library/playlist_service.dart';
|
||||||
import 'package:melo/main.dart';
|
import 'package:melo/main.dart';
|
||||||
import 'package:melo/player/audio_handler.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/baka_auth.dart';
|
||||||
import 'package:melo/services/download_service.dart';
|
import 'package:melo/services/download_service.dart';
|
||||||
import 'package:melo/services/melo_cloud_service.dart';
|
import 'package:melo/services/melo_cloud_service.dart';
|
||||||
@@ -52,6 +53,7 @@ void main() {
|
|||||||
late DownloadService downloads;
|
late DownloadService downloads;
|
||||||
late SyncService sync;
|
late SyncService sync;
|
||||||
MeloAudioHandler? handler;
|
MeloAudioHandler? handler;
|
||||||
|
PlayerExpansionController? expansion;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
@@ -81,10 +83,14 @@ void main() {
|
|||||||
// Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer
|
// Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer
|
||||||
// von drift im Test-Rahmen hängen — und ein anschließendes db.close()
|
// von drift im Test-Rahmen hängen — und ein anschließendes db.close()
|
||||||
// käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt).
|
// käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt).
|
||||||
Widget bauen() {
|
Widget bauen(WidgetTester tester) {
|
||||||
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
|
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
|
||||||
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
|
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
|
||||||
handler ??= MeloAudioHandler(db: db);
|
handler ??= MeloAudioHandler(db: db);
|
||||||
|
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart)
|
||||||
|
// oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von
|
||||||
|
// HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut.
|
||||||
|
expansion ??= PlayerExpansionController(vsync: tester);
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider<MeloDb>.value(value: db),
|
Provider<MeloDb>.value(value: db),
|
||||||
@@ -99,6 +105,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
|
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
|
||||||
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: sync),
|
ChangeNotifierProvider<SyncService>.value(value: sync),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion!),
|
||||||
],
|
],
|
||||||
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
|
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
|
||||||
);
|
);
|
||||||
@@ -113,6 +121,8 @@ void main() {
|
|||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
handler?.dispose();
|
handler?.dispose();
|
||||||
handler = null;
|
handler = null;
|
||||||
|
expansion?.dispose();
|
||||||
|
expansion = null;
|
||||||
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
|
// Die Zeit ein Stück vorstellen: beim Abbauen melden sich die
|
||||||
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
|
// Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein
|
||||||
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
|
// pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann
|
||||||
@@ -126,7 +136,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
|
testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async {
|
||||||
await tester.pumpWidget(bauen());
|
await tester.pumpWidget(bauen(tester));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final leiste =
|
final leiste =
|
||||||
@@ -138,7 +148,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
|
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(bauen());
|
await tester.pumpWidget(bauen(tester));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
|
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||||
@@ -163,7 +173,7 @@ void main() {
|
|||||||
final ziel = i;
|
final ziel = i;
|
||||||
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
|
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(bauen());
|
await tester.pumpWidget(bauen(tester));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(eintrag(hauptmenue[ziel]));
|
await tester.tap(eintrag(hauptmenue[ziel]));
|
||||||
@@ -182,7 +192,7 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
|
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
|
||||||
// Läge es woanders, wäre es kein Hauptmenü mehr.
|
// Läge es woanders, wäre es kein Hauptmenü mehr.
|
||||||
await tester.pumpWidget(bauen());
|
await tester.pumpWidget(bauen(tester));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final menue = tester.getRect(find.byType(BottomNavigationBar));
|
final menue = tester.getRect(find.byType(BottomNavigationBar));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:melo/library/library_service.dart';
|
|||||||
import 'package:melo/library/playlist_service.dart';
|
import 'package:melo/library/playlist_service.dart';
|
||||||
import 'package:melo/main.dart';
|
import 'package:melo/main.dart';
|
||||||
import 'package:melo/player/audio_handler.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/baka_auth.dart';
|
||||||
import 'package:melo/services/download_service.dart';
|
import 'package:melo/services/download_service.dart';
|
||||||
import 'package:melo/services/melo_cloud_service.dart';
|
import 'package:melo/services/melo_cloud_service.dart';
|
||||||
@@ -39,6 +40,7 @@ void main() {
|
|||||||
final playlists = PlaylistService(db);
|
final playlists = PlaylistService(db);
|
||||||
final handler = MeloAudioHandler(db: db);
|
final handler = MeloAudioHandler(db: db);
|
||||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||||
|
final expansion = PlayerExpansionController(vsync: tester);
|
||||||
|
|
||||||
await tester.pumpWidget(MultiProvider(
|
await tester.pumpWidget(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -59,6 +61,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
create: (_) => SyncService(
|
create: (_) => SyncService(
|
||||||
db: db, cloud: MeloCloudService(auth: auth))),
|
db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeShell()),
|
child: const MaterialApp(home: HomeShell()),
|
||||||
));
|
));
|
||||||
@@ -70,6 +74,7 @@ void main() {
|
|||||||
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
|
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
|
||||||
|
|
||||||
handler.dispose();
|
handler.dispose();
|
||||||
|
expansion.dispose();
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +85,7 @@ void main() {
|
|||||||
final playlists = PlaylistService(db);
|
final playlists = PlaylistService(db);
|
||||||
final handler = MeloAudioHandler(db: db);
|
final handler = MeloAudioHandler(db: db);
|
||||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||||
|
final expansion = PlayerExpansionController(vsync: tester);
|
||||||
|
|
||||||
await tester.pumpWidget(MultiProvider(
|
await tester.pumpWidget(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -100,6 +106,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
create: (_) => SyncService(
|
create: (_) => SyncService(
|
||||||
db: db, cloud: MeloCloudService(auth: auth))),
|
db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeShell()),
|
child: const MaterialApp(home: HomeShell()),
|
||||||
));
|
));
|
||||||
@@ -137,6 +145,7 @@ void main() {
|
|||||||
// einem tester.tap.
|
// einem tester.tap.
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
handler.dispose();
|
handler.dispose();
|
||||||
|
expansion.dispose();
|
||||||
await tester.pump(const Duration(milliseconds: 1));
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ void main() {
|
|||||||
final playlists = PlaylistService(db);
|
final playlists = PlaylistService(db);
|
||||||
final handler = MeloAudioHandler(db: db);
|
final handler = MeloAudioHandler(db: db);
|
||||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||||
|
final expansion = PlayerExpansionController(vsync: tester);
|
||||||
|
|
||||||
await tester.pumpWidget(MultiProvider(
|
await tester.pumpWidget(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -64,6 +65,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
create: (_) =>
|
create: (_) =>
|
||||||
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
|
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeShell()),
|
child: const MaterialApp(home: HomeShell()),
|
||||||
));
|
));
|
||||||
@@ -71,13 +74,11 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byType(BackdropFilter), findsNothing);
|
expect(find.byType(BackdropFilter), findsNothing);
|
||||||
|
|
||||||
// PlayerExpansionController wird von HomeShell selbst per
|
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix oberhalb
|
||||||
// ChangeNotifierProvider INNERHALB seines eigenen build() bereitgestellt
|
// von MaterialApp/HomeShell (analog zu MeloApps MultiProvider in
|
||||||
// — der Provider sitzt also unterhalb (nicht oberhalb) des HomeShell-
|
// main.dart), nicht mehr innerhalb von HomeShell.build() — jedes Element
|
||||||
// Elements im Baum. Provider.of von einem HomeShell-Element aus würde
|
// im Baum sieht denselben Controller, hier über ein Element unterhalb von
|
||||||
// deshalb ins Leere laufen (Ancestor-Lookup findet nur Vorfahren, nicht
|
// HomeShell (MiniPlayer, immer im Stack, auch ohne laufenden Titel).
|
||||||
// die eigenen Nachfahren) — stattdessen ein Element unterhalb nehmen,
|
|
||||||
// z.B. MiniPlayer, das immer im Stack steckt, auch ohne laufenden Titel.
|
|
||||||
final descendantContext = tester.element(find.byType(MiniPlayer));
|
final descendantContext = tester.element(find.byType(MiniPlayer));
|
||||||
final controller = Provider.of<PlayerExpansionController>(
|
final controller = Provider.of<PlayerExpansionController>(
|
||||||
descendantContext,
|
descendantContext,
|
||||||
@@ -98,6 +99,7 @@ void main() {
|
|||||||
// test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers).
|
// test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers).
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
handler.dispose();
|
handler.dispose();
|
||||||
|
expansion.dispose();
|
||||||
await tester.pump(const Duration(milliseconds: 1));
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ void main() {
|
|||||||
final playlists = PlaylistService(db);
|
final playlists = PlaylistService(db);
|
||||||
final handler = MeloAudioHandler(db: db);
|
final handler = MeloAudioHandler(db: db);
|
||||||
final auth = BakaAuth(speicher: _MemorySpeicher());
|
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||||
|
final expansion = PlayerExpansionController(vsync: tester);
|
||||||
|
|
||||||
await tester.pumpWidget(MultiProvider(
|
await tester.pumpWidget(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -127,10 +130,13 @@ void main() {
|
|||||||
ChangeNotifierProvider<YtSearchService>(
|
ChangeNotifierProvider<YtSearchService>(
|
||||||
create: (_) => YtSearchService(auth: auth)),
|
create: (_) => YtSearchService(auth: auth)),
|
||||||
ChangeNotifierProvider<DownloadService>(
|
ChangeNotifierProvider<DownloadService>(
|
||||||
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
create: (_) =>
|
||||||
|
DownloadService(db: db, navidrome: NavidromeService())),
|
||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
create: (_) =>
|
create: (_) =>
|
||||||
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
|
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion),
|
||||||
],
|
],
|
||||||
child: const MaterialApp(home: HomeShell()),
|
child: const MaterialApp(home: HomeShell()),
|
||||||
));
|
));
|
||||||
@@ -169,6 +175,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(const SizedBox());
|
await tester.pumpWidget(const SizedBox());
|
||||||
handler.dispose();
|
handler.dispose();
|
||||||
|
expansion.dispose();
|
||||||
await tester.pump(const Duration(milliseconds: 1));
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
|
||||||
|
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/mini_player.dart';
|
||||||
|
import 'package:melo/player/player_expansion_controller.dart';
|
||||||
|
import 'package:melo/playlists/playlist_detail_screen.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';
|
||||||
|
|
||||||
|
class _MemorySpeicher implements TokenSpeicher {
|
||||||
|
final Map<String, String> werte = {};
|
||||||
|
@override
|
||||||
|
Future<String?> lesen(String key) async => werte[key];
|
||||||
|
@override
|
||||||
|
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
|
||||||
|
@override
|
||||||
|
Future<void> loeschen(String key) async => werte.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff —
|
||||||
|
/// dieselbe Klasse wie in playlist_detail_navigation_test.dart.
|
||||||
|
class _FakeAudioHandler extends MeloAudioHandler {
|
||||||
|
_FakeAudioHandler(MeloDb db) : super(db: db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
|
||||||
|
queue.add(items);
|
||||||
|
if (items.isNotEmpty) mediaItem.add(items[startIndex]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Per Navigator.push geöffneter PlaylistDetailScreen (wie aus '
|
||||||
|
'playlists_screen.dart:120) sieht PlayerExpansionController — echtes '
|
||||||
|
'HomeShell, echter Navigator.push, kein direkt daneben geklebter '
|
||||||
|
'Test-Provider (Review-Fund: Provider war bisher nur INNERHALB von '
|
||||||
|
'HomeShell.build() bereitgestellt, eine gepushte Route ist im Baum '
|
||||||
|
'aber ein Geschwister von HomeShell, kein Nachfahre)', (tester) async {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1',
|
||||||
|
path: '/music/roses.mp3',
|
||||||
|
title: 'Roses',
|
||||||
|
artist: const Value('Neoni'),
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
final playlistId = await db.createPlaylist('Road Trip');
|
||||||
|
await db.addSongToPlaylist(playlistId, 'song-1', 0);
|
||||||
|
final playlist = Playlist(
|
||||||
|
id: playlistId,
|
||||||
|
name: 'Road Trip',
|
||||||
|
createdAtMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
deleted: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final lib = LibraryService(db);
|
||||||
|
final playlists = PlaylistService(db);
|
||||||
|
final handler = _FakeAudioHandler(db);
|
||||||
|
final auth = BakaAuth(speicher: _MemorySpeicher());
|
||||||
|
final expansion = PlayerExpansionController(vsync: tester);
|
||||||
|
addTearDown(expansion.dispose);
|
||||||
|
|
||||||
|
// Dieselbe Provider-Verdrahtung wie home_shell_real_expansion_test.dart,
|
||||||
|
// PlayerExpansionController aber jetzt OBERHALB von MaterialApp/HomeShell
|
||||||
|
// — genau wie MeloApps MultiProvider nach dem Fix (main.dart), nicht mehr
|
||||||
|
// nur INNERHALB von HomeShell.build() bereitgestellt.
|
||||||
|
await tester.pumpWidget(MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<MeloDb>.value(value: db),
|
||||||
|
ChangeNotifierProvider<LibraryService>.value(value: lib),
|
||||||
|
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||||
|
Provider<MeloAudioHandler>.value(value: handler),
|
||||||
|
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
|
||||||
|
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||||
|
ChangeNotifierProvider<OfflineMode>(create: (_) => OfflineMode()),
|
||||||
|
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||||
|
ChangeNotifierProvider<YtDownloadService>(
|
||||||
|
create: (_) => YtDownloadService(auth: auth)),
|
||||||
|
ChangeNotifierProvider<YtSearchService>(
|
||||||
|
create: (_) => YtSearchService(auth: auth)),
|
||||||
|
ChangeNotifierProvider<DownloadService>(
|
||||||
|
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
||||||
|
ChangeNotifierProvider<SyncService>(
|
||||||
|
create: (_) =>
|
||||||
|
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
|
||||||
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||||
|
value: expansion),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: HomeShell()),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Navigator.push GENAU wie playlists_screen.dart:120 — von einem Kontext
|
||||||
|
// INNERHALB des echten HomeShell-Baums aus (MiniPlayer steckt immer im
|
||||||
|
// Stack, auch ohne laufenden Titel), nicht von einem manuell daneben
|
||||||
|
// gebauten Test-Baum.
|
||||||
|
final homeShellContext = tester.element(find.byType(MiniPlayer));
|
||||||
|
Navigator.of(homeShellContext).push(MaterialPageRoute(
|
||||||
|
builder: (_) => PlaylistDetailScreen(playlist: playlist),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Roses'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap wie ein echter Nutzer — löst intern playSongs() und danach
|
||||||
|
// context.read<PlayerExpansionController>().open(context) aus.
|
||||||
|
await tester.tap(find.text('Roses'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Bug-Symptom: die ProviderNotFoundException landet im try/catch von
|
||||||
|
// PlaylistDetailScreen und zeigt eine "Wiedergabe fehlgeschlagen"-
|
||||||
|
// Snackbar, statt dass der Player öffnet.
|
||||||
|
expect(find.textContaining('Wiedergabe fehlgeschlagen'), findsNothing,
|
||||||
|
reason: 'context.read<PlayerExpansionController>() darf hier keine '
|
||||||
|
'ProviderNotFoundException werfen.');
|
||||||
|
// Beweis, dass .open() tatsächlich denselben (einzigen) Controller
|
||||||
|
// erreicht hat, nicht nur, dass keine Exception flog.
|
||||||
|
expect(expansion.progress, 1.0);
|
||||||
|
|
||||||
|
// Aufräum-Reihenfolge wie home_shell_real_expansion_test.dart: erst den
|
||||||
|
// Baum abbauen, dann den Handler disposen (offener Positions-Timer).
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
handler.dispose();
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user