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
+60 -33
View File
@@ -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>
), ),
), ),
), ),
),
); );
} }
+15 -5
View File
@@ -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));
+9
View File
@@ -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));
});
}