Files
Melo/lib/player/mini_player.dart
Hermes (Server)andClaude Sonnet 5 6687f2f67c Fix: PlayerExpansionController.dragEnd rät Öffnen/Schließen nicht mehr aus Vorzeichen
Bug (Code-Review, P0-nah): dragEnd() wählte die Entscheidungslogik allein
über das Vorzeichen von strecke. Bei bereits vollständig offenem Player
klappte ein schwaches, versehentliches Fingerzittern nach oben (kleine
negative Strecke, weit unter der Wisch-Schwelle) den Player ungewollt zu,
weil das Vorzeichen in den ÖFFNEN-Zweig führte statt in den (korrekten)
Schließen-Zweig, der bei so einer schwachen Geste keine Wirkung hätte.

dragEnd() bekommt jetzt den Pflichtparameter `ausVollbild`, der den
Aufrufer-Kontext explizit benennt statt ihn zu erraten. mini_player.dart
(Öffnen-Geste) ruft mit ausVollbild: false, now_playing_screen.dart
(Schließen-Geste) mit ausVollbild: true.

Test zuerst (RED mit der alten 3-Parameter-Signatur reproduziert, dann
GREEN nach dem Fix): test/player/player_expansion_controller_test.dart.
Bestehende Tests (player_expansion_controller_test.dart,
home_shell_expansion_test.dart) an die neue Signatur angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:36:46 +02:00

246 lines
8.8 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,
ausVollbild: false),
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),
);
},
);
},
);
}
}