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
+123 -126
View File
@@ -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;