From 9d0cb90a514cd929a46fba83e688bec7dd0c4e45 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Sat, 29 Aug 2026 14:45:41 +0200 Subject: [PATCH] NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut --- lib/main.dart | 30 ++- lib/player/now_playing_screen.dart | 249 ++++++++++++------------ test/player/now_playing_heart_test.dart | 60 +++--- 3 files changed, 182 insertions(+), 157 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ff14021..a8462af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'library/search_screen.dart'; import 'player/audio_effects.dart'; import 'player/audio_handler.dart'; import 'player/mini_player.dart'; +import 'player/now_playing_screen.dart'; import 'player/player_expansion_controller.dart'; import 'services/baka_auth.dart'; import 'services/logger_service.dart'; @@ -304,7 +305,26 @@ class _HomeShellState extends State ), child: const MiniPlayer(), ), - // NowPlayingScreen-Overlay-Inhalt folgt in Task 5 hier. + AnimatedBuilder( + animation: _expansion, + builder: (context, child) { + final p = _expansion.progress; + return Positioned.fromRect( + rect: Rect.lerp(_miniRect(context), _vollbildRect(context), p)!, + child: Visibility( + visible: p > 0, + maintainState: true, // _CoverGrundState bleibt erhalten, siehe Spec + maintainAnimation: true, + child: Opacity( + // Detail-Inhalt blendet erst ab p > 0.3 ein, siehe Spec. + opacity: ((p - 0.3) / 0.7).clamp(0.0, 1.0), + child: child, + ), + ), + ); + }, + child: const NowPlayingScreen(), + ), ], ), // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund @@ -349,4 +369,12 @@ class _HomeShellState extends State ), ); } + + Rect _miniRect(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final unten = size.height - MiniPlayer.hoehe - kBottomNavigationBarHeight; + return Rect.fromLTWH(0, unten, size.width, MiniPlayer.hoehe); + } + + Rect _vollbildRect(BuildContext context) => Offset.zero & MediaQuery.sizeOf(context); } diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 21f9165..d8acc21 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../library/database.dart'; import '../services/navidrome_service.dart'; -import '../shared/cover.dart'; import '../shared/favorite_button.dart'; import '../shared/server_favorite_button.dart'; import '../shared/theme.dart'; @@ -14,9 +14,9 @@ import 'audio_handler.dart'; import 'cover_farbe.dart'; import 'lrc.dart'; import 'queue_screen.dart'; -import 'wischen.dart'; -/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. +/// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress +/// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route. class NowPlayingScreen extends StatelessWidget { const NowPlayingScreen({super.key}); @@ -31,117 +31,135 @@ class NowPlayingScreen extends StatelessWidget { @override Widget build(BuildContext context) { final handler = context.read(); - return _CoverGrund( + // Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor: + // transparent) auf dem durchgehend dunklen Theme lief (siehe + // MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die + // Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die + // Icons je nach Systemzustand zufällig dunkel auf dunklem Grund. + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: _CoverGrund( handler: handler, - child: Scaffold( - // Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig. - backgroundColor: Colors.transparent, - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - actions: [ - StreamBuilder( - stream: handler.mediaItem, - builder: (context, snapshot) { - final item = snapshot.data; - if (item == null) return const SizedBox.shrink(); - // Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse: - // mit der fragte die App den Server nach einem Titel namens - // "https://…" und bekam nie einen Text. - final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; - return IconButton( - tooltip: songId.isEmpty - ? 'Kein Songtext für diesen Titel verfügbar' - : 'Songtext', - icon: const Icon(Icons.lyrics), - onPressed: songId.isEmpty - ? null - : () => _showLyrics(context, songId), - ); - }, - ), - _SleepTimerButton(handler: handler), - IconButton( - tooltip: 'Warteschlange', - icon: const Icon(Icons.queue_music), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const QueueScreen()), - ), - ), - ], - ), - body: GestureDetector( - // Wie bei Apple Music/Spotify: den Vollbild-Player von praktisch - // jeder Stelle nach unten wegziehen schließt ihn wieder — dieselbe - // Schwelle wie beim Mini-Player, damit kein zaghaftes Scrollen die - // Ansicht versehentlich zuklappt. - onVerticalDragEnd: (details) { - final geschwindigkeit = details.primaryVelocity ?? 0; - if (geschwindigkeit > wischSchwelle) Navigator.maybePop(context); - }, + child: Material( + color: Colors.transparent, child: SafeArea( - child: StreamBuilder( + child: Column( + children: [ + _VollbildLeiste(handler: handler, onLyrics: _showLyrics), + Expanded( + child: GestureDetector( + onVerticalDragStart: (_) {}, // s. Task 8 + child: StreamBuilder( + stream: handler.mediaItem, + builder: (context, snapshot) { + final item = snapshot.data; + if (item == null) { + return const Center(child: Text('Nichts in Wiedergabe')); + } + return LayoutBuilder( + builder: (context, raum) { + final nebeneinander = raum.maxWidth > raum.maxHeight; + final inhalt = _Angaben(item: item, handler: handler); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: nebeneinander + ? Row(children: [ + Expanded(child: Center(child: _CoverPlatzhalter())), + const SizedBox(width: MeloSpace.lg), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: inhalt, + ), + ), + ), + ]) + : Column(children: [ + Expanded(child: Center(child: _CoverPlatzhalter())), + const SizedBox(height: MeloSpace.lg), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: inhalt, + ), + ), + ]), + ); + }, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — bis Task 6 +/// (WanderndesCover) den bisherigen Hero-Übergang vollständig ersetzt hat, +/// nutzt `mini_player.dart` diesen Namen noch für sein eigenes Hero-Cover. +const coverHeldenName = 'melo-cover'; + +/// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche +/// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt +/// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio) +/// von außen nachzurechnen. +final coverPlatzhalterKey = GlobalKey(); + +/// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover` +/// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom +/// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann. +class _CoverPlatzhalter extends StatelessWidget { + const _CoverPlatzhalter(); + @override + Widget build(BuildContext context) => + AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox()); +} + +/// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als +/// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier +/// nicht mehr. +class _VollbildLeiste extends StatelessWidget { + const _VollbildLeiste({required this.handler, required this.onLyrics}); + final MeloAudioHandler handler; + final void Function(BuildContext, String) onLyrics; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + StreamBuilder( stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; - if (item == null) { - return const Center(child: Text('Nichts in Wiedergabe')); - } - return LayoutBuilder( - builder: (context, raum) { - // Im Querformat ist die Höhe knapp: Cover und Bedienung - // untereinander ließen vom Cover einen Streifen übrig oder - // schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides - // benutzbar. Dieselbe Aufteilung hilft auf einem Tablet. - final nebeneinander = raum.maxWidth > raum.maxHeight; - final inhalt = _Angaben(item: item, handler: handler); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: nebeneinander - ? Row( - children: [ - Expanded( - child: Center(child: _Cover(item: item)), - ), - const SizedBox(width: MeloSpace.lg), - // Der Inhalt bekommt eine feste Obergrenze: auf - // einem breiten Bildschirm zerrte er sonst über - // die ganze Breite und wäre schlecht lesbar. - Expanded( - child: Center( - child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 420), - child: inhalt, - ), - ), - ), - ], - ) - : Column( - children: [ - Expanded( - child: Center(child: _Cover(item: item)), - ), - const SizedBox(height: MeloSpace.lg), - Center( - child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 520), - child: inhalt, - ), - ), - ], - ), - ); - }, + if (item == null) return const SizedBox.shrink(); + final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; + return IconButton( + tooltip: songId.isEmpty + ? 'Kein Songtext für diesen Titel verfügbar' + : 'Songtext', + icon: const Icon(Icons.lyrics), + onPressed: songId.isEmpty ? null : () => onLyrics(context, songId), ); }, ), + _SleepTimerButton(handler: handler), + IconButton( + tooltip: 'Warteschlange', + icon: const Icon(Icons.queue_music), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const QueueScreen()), + ), ), - ), - ), + ], ); } } @@ -289,27 +307,6 @@ class _Herz extends StatelessWidget { } } -/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran -/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden -/// wandern statt einen neuen Bildschirm aufspringen zu lassen. -const coverHeldenName = 'melo-cover'; - -class _Cover extends StatelessWidget { - const _Cover({required this.item}); - final MediaItem item; - - @override - Widget build(BuildContext context) { - return AspectRatio( - aspectRatio: 1, - child: Hero( - tag: coverHeldenName, - child: CoverImage(artUri: item.artUri, radius: 16), - ), - ); - } -} - class _ProgressBar extends StatelessWidget { const _ProgressBar({required this.handler}); final MeloAudioHandler handler; diff --git a/test/player/now_playing_heart_test.dart b/test/player/now_playing_heart_test.dart index b616d2e..72e4459 100644 --- a/test/player/now_playing_heart_test.dart +++ b/test/player/now_playing_heart_test.dart @@ -89,34 +89,30 @@ void main() { metadataEdited: false, ); - // Öffnet den Vollbild-Player über denselben Weg wie in + // Startet die Wiedergabe über denselben Weg wie in // song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist - // dort nachweislich unproblematisch, im Unterschied zu einem direkten - // Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream. - // - // Seit dem Umbau auf PlayerExpansionController (Plan - // 2026-08-29-blur-oeffnen-transition, Task 3) zeigt der Tap allein noch - // keine sichtbare Oberfläche — das Overlay-Rendering kommt erst mit - // Task 4/5 ("Bekannte Zwischenlücke", siehe Plan). Für die Prüfung von - // NowPlayingScreens eigenem Layout (Herz-Position) wird die Route hier - // deshalb zusätzlich, unabhängig vom Produktions-Pfad, direkt geöffnet. - // Sobald Task 5 NowPlayingScreen zu Overlay-Inhalt umbaut, muss dieser - // Test ohnehin überarbeitet werden (Task 5 durchsucht dafür selbst - // `test -iname "*now_playing*"`, was diese Datei einschließt). + // dort nachweislich unproblematisch und übt die echte SongZeile→MediaItem + // -Umwandlung. Seit dem Umbau auf PlayerExpansionController (Plan + // 2026-08-29-blur-oeffnen-transition, Task 3/5) ist NowPlayingScreen kein + // per Navigator.push geöffneter Screen mehr, sondern reiner Inhalt — hier + // deshalb direkt in einen eigenen Scaffold gepumpt (Task 5 Step 3), statt + // eine Route zu pushen. Der Handler behält den zuvor gestarteten Titel in + // seinem mediaItem-Stream, den NowPlayingScreen darüber sofort sieht. Future oeffnePlayer(WidgetTester tester) async { final expansion = PlayerExpansionController(vsync: tester); addTearDown(expansion.dispose); + final 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), + ]; 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), - ], + providers: providers, child: MaterialApp( home: Scaffold( body: SongZeile( @@ -133,8 +129,13 @@ void main() { await tester.pumpAndSettle(); expect(expansion.progress, 1.0); - Navigator.of(tester.element(find.byType(SongZeile))).push( - MaterialPageRoute(builder: (_) => const NowPlayingScreen()), + await tester.pumpWidget( + MultiProvider( + providers: providers, + child: MaterialApp( + home: Scaffold(body: const NowPlayingScreen()), + ), + ), ); await tester.pumpAndSettle(); } @@ -154,12 +155,11 @@ void main() { expect(find.byType(NowPlayingScreen), findsOneWidget); - // Nicht mehr in der AppBar: - final herzInAppBar = find.descendant( - of: find.byType(AppBar), - matching: find.byType(FavoriteButton), - ); - expect(herzInAppBar, findsNothing); + // NowPlayingScreen ist seit dem Overlay-Umbau (Task 5) kein Scaffold/ + // keine Route mehr und hat gar keine AppBar mehr — die frühere + // "nicht mehr in der AppBar"-Prüfung ist jetzt trivial durch den Umbau + // selbst erfüllt. + expect(find.byType(AppBar), findsNothing); // Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel. final titelRow = find