Files
Melo/lib/player/mini_player.dart
Hermes (Server) c2f31dee01 UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke
Die vier zuletzt zurückgestellten Punkte aus der UX-Simulation:

- Abbrechen-Knopf für hängende YouTube-Downloads (Future.any gegen ein
  Abbruch-Signal, Anfrage läuft im Hintergrund aus statt die App zu
  blockieren)
- Punkt am inaktiven Download-Unterreiter, wenn dort etwas im Hintergrund
  läuft (SubTabs bekommt einen optionalen badges-Parameter)
- Versionsnummer unter "Über Melo" (neue Abhängigkeit: package_info_plus)
- Mini-Player führt beim Wischen jetzt sichtbar mit und wertet zusätzlich
  zum Schwung auch die zurückgelegte Strecke aus — ein langsames, aber
  weites Wischen wurde vorher komplett ignoriert

Dabei drei bestehende Widget-Tests korrigiert, die den Download-Tab ohne
Provider<DownloadService> aufbauten (hauptmenue_test.dart,
home_shell_test.dart, online_screen_test.dart) — lief bisher nur, weil
niemand danach fragte; das neue Badge tut das jetzt app-weit.

550 Tests grün (vorher 539), flutter analyze ohne Befund.
2026-08-25 10:25:43 +02:00

257 lines
9.1 KiB
Dart

import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'now_playing_screen.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> {
/// Wie weit der Finger die Leiste gerade zieht — nur zur Anzeige, die
/// eigentliche Entscheidung fällt erst bei [onVerticalDragEnd]/
/// [onHorizontalDragEnd]. `dy` bleibt <= 0: nach unten ist unbelegt, ein
/// Mitführen dorthin würde Wirkung vortäuschen, die es nicht gibt.
Offset _versatz = Offset.zero;
bool _ziehtGerade = false;
void _oeffne(BuildContext context) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
void _zurueckschnappen() {
setState(() {
_ziehtGerade = false;
_versatz = Offset.zero;
});
}
@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(
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
// Vollbild-Wiedergabe, seitwärts wechselt den Titel. Die Leiste
// folgt dabei sichtbar dem Finger — sonst blieb ein
// bedächtiges, aber weites Wischen (unter der Schwung-Schwelle)
// ganz ohne Reaktion.
onVerticalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
final dy = _versatz.dy + details.delta.dy;
_versatz = Offset(_versatz.dx, dy > 0 ? 0 : dy);
}),
onVerticalDragEnd: (details) {
final oeffnet = oeffnetVollbildMitStrecke(
_versatz.dy, details.velocity.pixelsPerSecond.dy);
_zurueckschnappen();
if (oeffnet) _oeffne(context);
},
onHorizontalDragUpdate: (details) => setState(() {
_ziehtGerade = true;
_versatz = Offset(_versatz.dx + details.delta.dx, _versatz.dy);
}),
onHorizontalDragEnd: (details) {
final wechsel = titelWechselMitStrecke(
_versatz.dx, details.velocity.pixelsPerSecond.dx);
_zurueckschnappen();
switch (wechsel) {
case Wechsel.naechster:
handler.skipToNext();
case Wechsel.vorheriger:
handler.skipToPrevious();
case null:
break;
}
},
child: InkWell(
onTap: () => _oeffne(context),
child: AnimatedContainer(
// Während des Ziehens sofort folgen (keine Verzögerung);
// beim Loslassen ohne Wirkung sichtbar zurückschnappen.
duration: _ziehtGerade
? Duration.zero
: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
transform:
Matrix4.translationValues(_versatz.dx, _versatz.dy, 0),
height: MiniPlayer.hoehe,
child: Row(
children: [
const SizedBox(width: MeloSpace.md),
// Dasselbe Bild wie im Vollbild: es wandert beim Öffnen
// dorthin, statt dass ein neuer Bildschirm aufspringt.
Hero(
tag: coverHeldenName,
child: CoverImage(artUri: item.artUri, size: 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),
);
},
);
},
);
}
}