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.
257 lines
9.1 KiB
Dart
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),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|