diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 20b67cc..ed69032 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -3,6 +3,7 @@ import 'dart:ui' show lerpDouble; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show OverflowBoxFit; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -61,7 +62,43 @@ class NowPlayingScreen extends StatelessWidget { return LayoutBuilder( builder: (context, raum) { final nebeneinander = raum.maxWidth > raum.maxHeight; - final inhalt = _Angaben(item: item, handler: handler); + // Während der Öffnen-Geste ist das interpolierte + // Rechteck (main.dart, Rect.lerp Mini-/Vollbild- + // Player) oft noch kleiner, als _Angaben an + // Mindesthöhe braucht — ohne diese Kapselung meldet + // Flutter dann "RenderFlex overflowed" (im Row-Zweig + // unten bekäme _Angaben selbst eine zu knappe Höhe + // zugeteilt, im Column-Zweig überschreitet Lücke + + // _Angaben-Eigenhöhe zusammen die verfügbare Höhe). + // ConstrainedBox AUSSEN um OverflowBox deckelt, was + // OverflowBox nach außen als eigene Größe meldet, + // hart auf das von raum bekannte Maß; OverflowBox + // selbst gibt ihrem Kind (_Angaben) über + // maxHeight: infinity davon UNABHÄNGIG weiterhin + // unbegrenzte Höhe — _Angaben behält so seine + // natürliche Größe, der überschüssige Teil wird + // vom ClipRect einfach nicht gemalt statt eine + // Overflow-Warnung auszulösen. Läge die Deckelung + // stattdessen INNEN (zwischen OverflowBox und + // _Angaben), bekäme _Angaben selbst zu wenig Höhe + // und liefe intern über. Bei ausreichend Platz + // (Endzustand progress==1) ist raum.maxHeight groß + // genug, dass nichts geklemmt wird — die Optik + // bleibt dort unverändert. + final inhalt = ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: (raum.maxHeight - MeloSpace.lg) + .clamp(0.0, double.infinity), + ), + child: OverflowBox( + alignment: Alignment.center, + maxHeight: double.infinity, + fit: OverflowBoxFit.deferToChild, + child: _Angaben(item: item, handler: handler), + ), + ), + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: nebeneinander diff --git a/test/player/now_playing_screen_overflow_test.dart b/test/player/now_playing_screen_overflow_test.dart new file mode 100644 index 0000000..2e552de --- /dev/null +++ b/test/player/now_playing_screen_overflow_test.dart @@ -0,0 +1,128 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:drift/drift.dart' show driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/playlist_service.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/player/mini_player.dart'; +import 'package:melo/player/now_playing_screen.dart'; +import 'package:melo/player/player_expansion_controller.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Ersetzt in [MeloAudioHandler.loadPlaylist] den echten just_audio-Zugriff — +/// dieselbe Klasse wie in now_playing_heart_test.dart. +class _FakeAudioHandler extends MeloAudioHandler { + _FakeAudioHandler(MeloDb db) : super(db: db); + + @override + Future loadPlaylist(List items, {int startIndex = 0}) async { + queue.add(items); + if (items.isNotEmpty) mediaItem.add(items[startIndex]); + return 0; + } +} + +void main() { + void stelleKanaeleRuhig() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => '/tmp/melo-test', + ); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); + } + + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets( + 'NowPlayingScreen in einem während der Öffnen-Geste noch kleinen, ' + 'interpolierten Rechteck (progress ~0.4) wirft keinen ' + 'RenderFlex-Overflow', (tester) async { + TestWidgetsFlutterBinding.ensureInitialized(); + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + stelleKanaeleRuhig(); + final db = MeloDb(NativeDatabase.memory()); + final kategorien = CategoryService(db); + final playlists = PlaylistService(db); + final einstellungen = AppSettings(); + await einstellungen.init(); + final handler = _FakeAudioHandler(db); + addTearDown(kategorien.dispose); + addTearDown(playlists.dispose); + + // Ein echtes MediaItem, wie es beim Abspielen im mediaItem-Stream steht + // — NowPlayingScreen zeigt sonst nur "Nichts in Wiedergabe" und hat dann + // gar keinen Inhalt, der überlaufen könnte. + handler.mediaItem.add(const MediaItem( + id: 'song-1', + title: 'Roses', + artist: 'Neoni', + )); + + // Dieselbe Rect-Interpolation wie HomeShell._miniRect/_vollbildRect bei + // progress=0.4 auf der Standard-Testbildschirmgröße (800x600, siehe + // flutter_test-Default) — main.dart positioniert NowPlayingScreen + // während der Öffnen-Geste über genau so ein Positioned.fromRect mit + // Rect.lerp zwischen Mini- und Vollbild-Rechteck. Bei p>0.3 blendet der + // Inhalt laut Spec schon sichtbar ein (main.dart: Opacity((p-0.3)/0.7)), + // das Rechteck ist zu dem Zeitpunkt aber noch weit von der + // Vollbildgröße entfernt. + const p = 0.4; + const screenSize = Size(800, 600); + const miniHoehe = MiniPlayer.hoehe; + final unten = screenSize.height - miniHoehe - kBottomNavigationBarHeight; + final miniRect = Rect.fromLTWH(0, unten, screenSize.width, miniHoehe); + final vollbildRect = + Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); + final rect = Rect.lerp(miniRect, vollbildRect, p)!; + + final expansion = PlayerExpansionController(vsync: tester); + addTearDown(expansion.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ChangeNotifierProvider.value(value: kategorien), + ChangeNotifierProvider.value(value: playlists), + ChangeNotifierProvider.value(value: einstellungen), + ChangeNotifierProvider.value( + value: expansion), + ], + child: MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Positioned.fromRect( + rect: rect, + child: const NowPlayingScreen(), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + // Aufräum-Reihenfolge wie in den übrigen Tests dieser Datei-Familie: + // erst den Baum abbauen, dann den Handler disposen (offener Positions- + // Timer, siehe now_playing_heart_test.dart). + await tester.pumpWidget(const SizedBox()); + handler.dispose(); + await tester.pump(const Duration(milliseconds: 1)); + }); +}