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
+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/main.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/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
@@ -52,6 +53,7 @@ void main() {
late DownloadService downloads;
late SyncService sync;
MeloAudioHandler? handler;
PlayerExpansionController? expansion;
setUp(() {
SharedPreferences.setMockInitialValues({});
@@ -81,10 +83,14 @@ void main() {
// 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()
// 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
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
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(
providers: [
Provider<MeloDb>.value(value: db),
@@ -99,6 +105,8 @@ void main() {
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion!),
],
child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()),
);
@@ -113,6 +121,8 @@ void main() {
await tester.pumpWidget(const SizedBox());
handler?.dispose();
handler = null;
expansion?.dispose();
expansion = null;
// 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
// 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 {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
final leiste =
@@ -138,7 +148,7 @@ void main() {
testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio;
@@ -163,7 +173,7 @@ void main() {
final ziel = i;
testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin',
(tester) async {
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
await tester.tap(eintrag(hauptmenue[ziel]));
@@ -182,7 +192,7 @@ void main() {
testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async {
// Läge es woanders, wäre es kein Hauptmenü mehr.
await tester.pumpWidget(bauen());
await tester.pumpWidget(bauen(tester));
await tester.pump();
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/main.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/download_service.dart';
import 'package:melo/services/melo_cloud_service.dart';
@@ -39,6 +40,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -59,6 +61,8 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -70,6 +74,7 @@ void main() {
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
handler.dispose();
expansion.dispose();
await db.close();
});
@@ -80,6 +85,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -100,6 +106,8 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) => SyncService(
db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -137,6 +145,7 @@ void main() {
// einem tester.tap.
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
}
@@ -44,6 +44,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -64,6 +65,8 @@ void main() {
ChangeNotifierProvider<SyncService>(
create: (_) =>
SyncService(db: db, cloud: MeloCloudService(auth: auth))),
ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion),
],
child: const MaterialApp(home: HomeShell()),
));
@@ -71,13 +74,11 @@ void main() {
expect(find.byType(BackdropFilter), findsNothing);
// PlayerExpansionController wird von HomeShell selbst per
// ChangeNotifierProvider INNERHALB seines eigenen build() bereitgestellt
// — der Provider sitzt also unterhalb (nicht oberhalb) des HomeShell-
// Elements im Baum. Provider.of von einem HomeShell-Element aus würde
// deshalb ins Leere laufen (Ancestor-Lookup findet nur Vorfahren, nicht
// die eigenen Nachfahren) — stattdessen ein Element unterhalb nehmen,
// z.B. MiniPlayer, das immer im Stack steckt, auch ohne laufenden Titel.
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix oberhalb
// von MaterialApp/HomeShell (analog zu MeloApps MultiProvider in
// main.dart), nicht mehr innerhalb von HomeShell.build() — jedes Element
// im Baum sieht denselben Controller, hier über ein Element unterhalb von
// HomeShell (MiniPlayer, immer im Stack, auch ohne laufenden Titel).
final descendantContext = tester.element(find.byType(MiniPlayer));
final controller = Provider.of<PlayerExpansionController>(
descendantContext,
@@ -98,6 +99,7 @@ void main() {
// test/home_shell_test.dart (offener Positions-Timer des Audio-Handlers).
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
await tester.pump(const Duration(milliseconds: 1));
});
@@ -111,6 +113,7 @@ void main() {
final playlists = PlaylistService(db);
final handler = MeloAudioHandler(db: db);
final auth = BakaAuth(speicher: _MemorySpeicher());
final expansion = PlayerExpansionController(vsync: tester);
await tester.pumpWidget(MultiProvider(
providers: [
@@ -127,10 +130,13 @@ void main() {
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
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()),
));
@@ -169,6 +175,7 @@ void main() {
await tester.pumpWidget(const SizedBox());
handler.dispose();
expansion.dispose();
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));
});
}