245 lines
8.7 KiB
Dart
245 lines
8.7 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../shared/theme.dart';
|
|
import 'audio_handler.dart';
|
|
import 'player_expansion_controller.dart';
|
|
import 'wischen.dart';
|
|
|
|
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
|
|
/// Blendet sich aus, wenn nichts läuft.
|
|
class MiniPlayer extends StatelessWidget {
|
|
const MiniPlayer({super.key});
|
|
|
|
/// Höhe der Zeile ohne den Fortschrittsbalken.
|
|
static const double hoehe = 72;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
return StreamBuilder<MediaItem?>(
|
|
stream: handler.mediaItem,
|
|
builder: (context, snapshot) {
|
|
final item = snapshot.data;
|
|
// Ein- und Ausblenden statt Aufpoppen: die Leiste kommt von unten
|
|
// dazu, wenn etwas startet.
|
|
return AnimatedSwitcher(
|
|
duration: MeloMotion.ruhig(context, MeloMotion.normal),
|
|
switchInCurve: MeloMotion.curve,
|
|
transitionBuilder: (kind, animation) => SizeTransition(
|
|
sizeFactor: animation,
|
|
alignment: Alignment.topCenter,
|
|
child: FadeTransition(opacity: animation, child: kind),
|
|
),
|
|
child: item == null
|
|
? const SizedBox.shrink()
|
|
: _Leiste(handler: handler, item: item),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Leiste extends StatefulWidget {
|
|
const _Leiste({required this.handler, required this.item});
|
|
|
|
final MeloAudioHandler handler;
|
|
final MediaItem item;
|
|
|
|
@override
|
|
State<_Leiste> createState() => _LeisteState();
|
|
}
|
|
|
|
class _LeisteState extends State<_Leiste> {
|
|
/// Für die horizontale Titel-Wechsel-Geste (unverändert).
|
|
double _horizontalVersatz = 0;
|
|
bool _ziehtHorizontalGerade = false;
|
|
|
|
/// Rohe, aufsummierte vertikale Zugstrecke seit Gestenbeginn — exakt wie
|
|
/// bisher `_versatz.dy`, nur ohne die Optik-Verantwortung (die liegt jetzt
|
|
/// im PlayerExpansionController). Wird für [PlayerExpansionController.
|
|
/// dragEnd] gebraucht, das dieselbe rohe Strecke wie wischen.dart erwartet;
|
|
/// aus `progress` ließe sich das wegen des Clampings nicht mehr exakt
|
|
/// zurückrechnen.
|
|
double _vertikaleStrecke = 0;
|
|
|
|
void _oeffne(BuildContext context) =>
|
|
context.read<PlayerExpansionController>().open(context);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final texte = Theme.of(context).textTheme;
|
|
final handler = widget.handler;
|
|
final item = widget.item;
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
color: MeloTheme.surface,
|
|
// Trennt die Leiste vom Inhalt darüber, ohne Schatten.
|
|
border: Border(top: BorderSide(color: MeloTheme.border)),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
GestureDetector(
|
|
onVerticalDragStart: (_) => _vertikaleStrecke = 0,
|
|
onVerticalDragUpdate: (details) {
|
|
_vertikaleStrecke += details.delta.dy;
|
|
context.read<PlayerExpansionController>().dragBy(details.delta.dy);
|
|
},
|
|
onVerticalDragEnd: (details) => context
|
|
.read<PlayerExpansionController>()
|
|
.dragEnd(context, _vertikaleStrecke,
|
|
details.velocity.pixelsPerSecond.dy),
|
|
onHorizontalDragUpdate: (details) => setState(() {
|
|
_ziehtHorizontalGerade = true;
|
|
_horizontalVersatz += details.delta.dx;
|
|
}),
|
|
onHorizontalDragEnd: (details) {
|
|
final wechsel = titelWechselMitStrecke(
|
|
_horizontalVersatz, details.velocity.pixelsPerSecond.dx);
|
|
setState(() {
|
|
_ziehtHorizontalGerade = false;
|
|
_horizontalVersatz = 0;
|
|
});
|
|
switch (wechsel) {
|
|
case Wechsel.naechster:
|
|
handler.skipToNext();
|
|
case Wechsel.vorheriger:
|
|
handler.skipToPrevious();
|
|
case null:
|
|
break;
|
|
}
|
|
},
|
|
child: InkWell(
|
|
onTap: () => _oeffne(context),
|
|
child: AnimatedContainer(
|
|
duration: _ziehtHorizontalGerade
|
|
? Duration.zero
|
|
: MeloMotion.ruhig(context, MeloMotion.normal),
|
|
curve: MeloMotion.curve,
|
|
transform: Matrix4.translationValues(_horizontalVersatz, 0, 0),
|
|
height: MiniPlayer.hoehe,
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: MeloSpace.md),
|
|
// Das Cover rendert WanderndesCover in HomeShell in
|
|
// einer eigenen Ebene über allem (deckt exakt diese
|
|
// Position bei progress == 0 ab) — hier nur Platz
|
|
// freihalten, damit Titel/Bedienung nicht springen.
|
|
const SizedBox(width: 52),
|
|
const SizedBox(width: MeloSpace.md),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: texte.bodyLarge
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
Text(
|
|
item.artist ?? 'Unbekannt',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: texte.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: MeloSpace.sm),
|
|
_Bedienung(handler: handler),
|
|
const SizedBox(width: MeloSpace.sm),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_Fortschritt(handler: handler),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Bedienung extends StatelessWidget {
|
|
const _Bedienung({required this.handler});
|
|
|
|
final MeloAudioHandler handler;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return StreamBuilder<PlaybackState>(
|
|
stream: handler.playbackState,
|
|
builder: (context, snap) {
|
|
final laeuft = snap.data?.playing ?? false;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Vorheriger Titel',
|
|
icon: const Icon(Icons.skip_previous),
|
|
onPressed: handler.skipToPrevious,
|
|
),
|
|
// Der wichtigste Knopf der Leiste: eingefärbt und größer, damit
|
|
// er sich ohne Hinsehen treffen lässt.
|
|
IconButton.filled(
|
|
tooltip: laeuft ? 'Pause' : 'Abspielen',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: MeloTheme.red,
|
|
foregroundColor: Colors.white,
|
|
minimumSize:
|
|
const Size(MeloTheme.minTouchTarget, MeloTheme.minTouchTarget),
|
|
),
|
|
icon: Icon(laeuft ? Icons.pause : Icons.play_arrow, size: 26),
|
|
onPressed: laeuft ? handler.pause : handler.play,
|
|
),
|
|
IconButton(
|
|
tooltip: 'Nächster Titel',
|
|
icon: const Icon(Icons.skip_next),
|
|
onPressed: handler.skipToNext,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Fortschritt extends StatelessWidget {
|
|
const _Fortschritt({required this.handler});
|
|
|
|
final MeloAudioHandler handler;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return StreamBuilder<Duration?>(
|
|
stream: handler.durationStream,
|
|
builder: (context, dSnap) {
|
|
final dauer = dSnap.data ?? Duration.zero;
|
|
return StreamBuilder<Duration>(
|
|
stream: handler.positionStream,
|
|
builder: (context, pSnap) {
|
|
final position = pSnap.data ?? Duration.zero;
|
|
return LinearProgressIndicator(
|
|
value: dauer.inMilliseconds > 0
|
|
? (position.inMilliseconds / dauer.inMilliseconds)
|
|
.clamp(0.0, 1.0)
|
|
: 0,
|
|
minHeight: 2,
|
|
backgroundColor: MeloTheme.border,
|
|
valueColor: const AlwaysStoppedAnimation(MeloTheme.red),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|