NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut

This commit is contained in:
Hermes (Server)
2026-08-29 14:45:41 +02:00
parent 96113a3e41
commit 9d0cb90a51
3 changed files with 182 additions and 157 deletions
+29 -1
View File
@@ -20,6 +20,7 @@ import 'library/search_screen.dart';
import 'player/audio_effects.dart'; import 'player/audio_effects.dart';
import 'player/audio_handler.dart'; import 'player/audio_handler.dart';
import 'player/mini_player.dart'; import 'player/mini_player.dart';
import 'player/now_playing_screen.dart';
import 'player/player_expansion_controller.dart'; import 'player/player_expansion_controller.dart';
import 'services/baka_auth.dart'; import 'services/baka_auth.dart';
import 'services/logger_service.dart'; import 'services/logger_service.dart';
@@ -304,7 +305,26 @@ class _HomeShellState extends State<HomeShell>
), ),
child: const MiniPlayer(), 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 // Haarlinie darüber: ohne sie geht die Leiste auf schwarzem Grund
@@ -349,4 +369,12 @@ class _HomeShellState extends State<HomeShell>
), ),
); );
} }
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);
} }
+107 -110
View File
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/favorite_button.dart'; import '../shared/favorite_button.dart';
import '../shared/server_favorite_button.dart'; import '../shared/server_favorite_button.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
@@ -14,9 +14,9 @@ import 'audio_handler.dart';
import 'cover_farbe.dart'; import 'cover_farbe.dart';
import 'lrc.dart'; import 'lrc.dart';
import 'queue_screen.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 { class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key}); const NowPlayingScreen({super.key});
@@ -31,32 +31,122 @@ class NowPlayingScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
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<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: _CoverGrund(
handler: handler, handler: handler,
child: Scaffold( child: Material(
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig. color: Colors.transparent,
backgroundColor: Colors.transparent, child: SafeArea(
extendBodyBehindAppBar: true, child: Column(
appBar: AppBar( children: [
backgroundColor: Colors.transparent, _VollbildLeiste(handler: handler, onLyrics: _showLyrics),
actions: [ Expanded(
child: GestureDetector(
onVerticalDragStart: (_) {}, // s. Task 8
child: StreamBuilder<MediaItem?>(
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<MediaItem?>( StreamBuilder<MediaItem?>(
stream: handler.mediaItem, stream: handler.mediaItem,
builder: (context, snapshot) { builder: (context, snapshot) {
final item = snapshot.data; final item = snapshot.data;
if (item == null) return const SizedBox.shrink(); 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) ?? ''; final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
return IconButton( return IconButton(
tooltip: songId.isEmpty tooltip: songId.isEmpty
? 'Kein Songtext für diesen Titel verfügbar' ? 'Kein Songtext für diesen Titel verfügbar'
: 'Songtext', : 'Songtext',
icon: const Icon(Icons.lyrics), icon: const Icon(Icons.lyrics),
onPressed: songId.isEmpty onPressed: songId.isEmpty ? null : () => onLyrics(context, songId),
? null
: () => _showLyrics(context, songId),
); );
}, },
), ),
@@ -70,78 +160,6 @@ class NowPlayingScreen extends StatelessWidget {
), ),
), ),
], ],
),
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: SafeArea(
child: StreamBuilder<MediaItem?>(
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,
),
),
],
),
);
},
);
},
),
),
),
),
); );
} }
} }
@@ -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 { class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.handler}); const _ProgressBar({required this.handler});
final MeloAudioHandler handler; final MeloAudioHandler handler;
+25 -25
View File
@@ -89,26 +89,19 @@ void main() {
metadataEdited: false, 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 // song_list_navigation_test.dart (Tap auf eine SongZeile) — dieser Weg ist
// dort nachweislich unproblematisch, im Unterschied zu einem direkten // dort nachweislich unproblematisch und übt die echte SongZeile→MediaItem
// Aufbau von NowPlayingScreen mit vorab befülltem MediaItem-Stream. // -Umwandlung. Seit dem Umbau auf PlayerExpansionController (Plan
// // 2026-08-29-blur-oeffnen-transition, Task 3/5) ist NowPlayingScreen kein
// Seit dem Umbau auf PlayerExpansionController (Plan // per Navigator.push geöffneter Screen mehr, sondern reiner Inhalt — hier
// 2026-08-29-blur-oeffnen-transition, Task 3) zeigt der Tap allein noch // deshalb direkt in einen eigenen Scaffold gepumpt (Task 5 Step 3), statt
// keine sichtbare Oberfläche — das Overlay-Rendering kommt erst mit // eine Route zu pushen. Der Handler behält den zuvor gestarteten Titel in
// Task 4/5 ("Bekannte Zwischenlücke", siehe Plan). Für die Prüfung von // seinem mediaItem-Stream, den NowPlayingScreen darüber sofort sieht.
// 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).
Future<void> oeffnePlayer(WidgetTester tester) async { Future<void> oeffnePlayer(WidgetTester tester) async {
final expansion = PlayerExpansionController(vsync: tester); final expansion = PlayerExpansionController(vsync: tester);
addTearDown(expansion.dispose); addTearDown(expansion.dispose);
await tester.pumpWidget( final providers = [
MultiProvider(
providers: [
Provider<MeloDb>.value(value: db), Provider<MeloDb>.value(value: db),
Provider<MeloAudioHandler>.value(value: handler), Provider<MeloAudioHandler>.value(value: handler),
ChangeNotifierProvider<CategoryService>.value(value: kategorien), ChangeNotifierProvider<CategoryService>.value(value: kategorien),
@@ -116,7 +109,10 @@ void main() {
ChangeNotifierProvider<AppSettings>.value(value: einstellungen), ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
ChangeNotifierProvider<PlayerExpansionController>.value( ChangeNotifierProvider<PlayerExpansionController>.value(
value: expansion), value: expansion),
], ];
await tester.pumpWidget(
MultiProvider(
providers: providers,
child: MaterialApp( child: MaterialApp(
home: Scaffold( home: Scaffold(
body: SongZeile( body: SongZeile(
@@ -133,8 +129,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(expansion.progress, 1.0); expect(expansion.progress, 1.0);
Navigator.of(tester.element(find.byType(SongZeile))).push( await tester.pumpWidget(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()), MultiProvider(
providers: providers,
child: MaterialApp(
home: Scaffold(body: const NowPlayingScreen()),
),
),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@@ -154,12 +155,11 @@ void main() {
expect(find.byType(NowPlayingScreen), findsOneWidget); expect(find.byType(NowPlayingScreen), findsOneWidget);
// Nicht mehr in der AppBar: // NowPlayingScreen ist seit dem Overlay-Umbau (Task 5) kein Scaffold/
final herzInAppBar = find.descendant( // keine Route mehr und hat gar keine AppBar mehr — die frühere
of: find.byType(AppBar), // "nicht mehr in der AppBar"-Prüfung ist jetzt trivial durch den Umbau
matching: find.byType(FavoriteButton), // selbst erfüllt.
); expect(find.byType(AppBar), findsNothing);
expect(herzInAppBar, findsNothing);
// Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel. // Aber sichtbar auf dem Bildschirm, in derselben Row wie der Titel.
final titelRow = find final titelRow = find