NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut
This commit is contained in:
+29
-1
@@ -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<HomeShell>
|
||||
),
|
||||
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<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);
|
||||
}
|
||||
|
||||
+123
-126
@@ -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<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,
|
||||
child: Scaffold(
|
||||
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
StreamBuilder<MediaItem?>(
|
||||
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<MediaItem?>(
|
||||
child: Column(
|
||||
children: [
|
||||
_VollbildLeiste(handler: handler, onLyrics: _showLyrics),
|
||||
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?>(
|
||||
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;
|
||||
|
||||
@@ -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<void> oeffnePlayer(WidgetTester tester) async {
|
||||
final expansion = PlayerExpansionController(vsync: tester);
|
||||
addTearDown(expansion.dispose);
|
||||
final providers = [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.value(
|
||||
value: expansion),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||
ChangeNotifierProvider<PlayerExpansionController>.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
|
||||
|
||||
Reference in New Issue
Block a user