NowPlayingScreen: von Scaffold/Route zu Overlay-Inhalt in HomeShell umgebaut
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user