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.
This commit is contained in:
@@ -59,6 +59,11 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Fortschrittsanzeigen stecken jeweils nur im aktiven Unterreiter — ohne
|
||||
// diesen Punkt sah man nicht, dass im anderen Bereich gerade ein
|
||||
// Download läuft, während man selbst woanders war.
|
||||
final ytLaeuft = context.watch<YtDownloadService>().laeuft;
|
||||
final serverLaeuft = context.watch<DownloadService>().laeuft;
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
@@ -67,6 +72,10 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
SubTabs(
|
||||
labels: const ['YouTube', 'Server'],
|
||||
index: _subTab,
|
||||
badges: [
|
||||
ytLaeuft && _subTab != 0,
|
||||
serverLaeuft && _subTab != 1,
|
||||
],
|
||||
onChanged: (i) => setState(() => _subTab = i),
|
||||
),
|
||||
Expanded(
|
||||
@@ -789,8 +798,21 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Text(dienst.status ?? 'Läuft …',
|
||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(dienst.status ?? 'Läuft …',
|
||||
style: const TextStyle(
|
||||
color: MeloTheme.text2, fontSize: 12)),
|
||||
),
|
||||
// Ohne diesen Knopf war ein hängender Download (bis zu zwei
|
||||
// mal 180s) nur durch Schließen der App zu stoppen.
|
||||
TextButton(
|
||||
onPressed: dienst.abbrechen,
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (_meldung != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -42,19 +42,40 @@ class MiniPlayer extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Leiste extends StatelessWidget {
|
||||
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,
|
||||
@@ -68,14 +89,30 @@ class _Leiste extends StatelessWidget {
|
||||
children: [
|
||||
GestureDetector(
|
||||
// Wischen wie in jeder anderen Musik-App: nach oben öffnet die
|
||||
// Vollbild-Wiedergabe, seitwärts wechselt den Titel.
|
||||
// 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) {
|
||||
if (oeffnetVollbild(details.velocity.pixelsPerSecond.dy)) {
|
||||
_oeffne(context);
|
||||
}
|
||||
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) {
|
||||
switch (titelWechsel(details.velocity.pixelsPerSecond.dx)) {
|
||||
final wechsel = titelWechselMitStrecke(
|
||||
_versatz.dx, details.velocity.pixelsPerSecond.dx);
|
||||
_zurueckschnappen();
|
||||
switch (wechsel) {
|
||||
case Wechsel.naechster:
|
||||
handler.skipToNext();
|
||||
case Wechsel.vorheriger:
|
||||
@@ -86,7 +123,15 @@ class _Leiste extends StatelessWidget {
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: () => _oeffne(context),
|
||||
child: SizedBox(
|
||||
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: [
|
||||
|
||||
@@ -23,3 +23,28 @@ Wechsel? titelWechsel(double geschwindigkeit) {
|
||||
/// Wiedergabe aus Versehen wegschiebt.
|
||||
bool oeffnetVollbild(double geschwindigkeit) =>
|
||||
geschwindigkeit < -wischSchwelle;
|
||||
|
||||
/// Ab welcher zurückgelegten Strecke (in Pixeln) eine langsame, aber
|
||||
/// eindeutige Wischbewegung auch ohne Schwung zählt.
|
||||
///
|
||||
/// Ohne diese Schwelle bekam ein bedächtiges, aber weites Wischen (unter
|
||||
/// [wischSchwelle] Pixel pro Sekunde) überhaupt keine Reaktion — für den
|
||||
/// Nutzer sah das aus wie ein nicht reagierender Bildschirm statt "zu
|
||||
/// langsam gewischt".
|
||||
const wischStreckeSchwelle = 80.0;
|
||||
|
||||
/// Wie [titelWechsel], aber eine ausreichend weite Bewegung zählt zusätzlich
|
||||
/// auch bei geringem Schwung.
|
||||
Wechsel? titelWechselMitStrecke(double strecke, double geschwindigkeit) {
|
||||
final ausSchwung = titelWechsel(geschwindigkeit);
|
||||
if (ausSchwung != null) return ausSchwung;
|
||||
if (strecke.abs() <= wischStreckeSchwelle) return null;
|
||||
return strecke < 0 ? Wechsel.naechster : Wechsel.vorheriger;
|
||||
}
|
||||
|
||||
/// Wie [oeffnetVollbild], aber eine ausreichend weite Bewegung nach oben
|
||||
/// zählt zusätzlich auch bei geringem Schwung.
|
||||
bool oeffnetVollbildMitStrecke(double strecke, double geschwindigkeit) {
|
||||
if (oeffnetVollbild(geschwindigkeit)) return true;
|
||||
return strecke < -wischStreckeSchwelle;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ import 'baka_auth.dart';
|
||||
String sichererDateiname(String titel) =>
|
||||
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||
|
||||
/// Signalisiert, dass [YtDownloadService.abbrechen] während einer laufenden
|
||||
/// Anfrage aufgerufen wurde.
|
||||
class _Abgebrochen implements Exception {
|
||||
const _Abgebrochen();
|
||||
}
|
||||
|
||||
/// Ergebnis eines geglückten Downloads.
|
||||
class YtErgebnis {
|
||||
const YtErgebnis({
|
||||
@@ -41,6 +47,11 @@ class YtDownloadService extends ChangeNotifier {
|
||||
String? _status;
|
||||
String? _fehler;
|
||||
|
||||
/// Ist gesetzt, während [herunterladen] läuft. [abbrechen] löst es aus —
|
||||
/// die Netzwerk-Anfrage selbst läuft im Hintergrund weiter, bis der Socket
|
||||
/// sie beendet, aber die App wartet nicht länger darauf.
|
||||
Completer<void>? _abbruch;
|
||||
|
||||
bool get laeuft => _laeuft;
|
||||
|
||||
/// Was gerade passiert — für die Anzeige während des Downloads.
|
||||
@@ -59,6 +70,20 @@ class YtDownloadService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Bricht den laufenden Download ab, falls einer läuft.
|
||||
void abbrechen() {
|
||||
_abbruch?.complete();
|
||||
}
|
||||
|
||||
/// Wartet auf [aufgabe], bricht aber sofort mit [_Abgebrochen] ab, sobald
|
||||
/// [abbrechen] aufgerufen wird — ohne auf das Ende von [aufgabe] zu warten.
|
||||
Future<T> _abbrechbar<T>(Future<T> aufgabe, Completer<void> abbruch) {
|
||||
return Future.any<T>([
|
||||
aufgabe,
|
||||
abbruch.future.then((_) => throw const _Abgebrochen()),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
|
||||
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
|
||||
/// altersbeschränkte Videos.
|
||||
@@ -67,6 +92,7 @@ class YtDownloadService extends ChangeNotifier {
|
||||
{required String zielOrdner, bool cookies = true}) async {
|
||||
_fehler = null;
|
||||
_laeuft = true;
|
||||
final abbruch = _abbruch = Completer<void>();
|
||||
notifyListeners();
|
||||
|
||||
if (!auth.istAngemeldet) {
|
||||
@@ -82,13 +108,22 @@ class YtDownloadService extends ChangeNotifier {
|
||||
_melde('Proxy wird gefragt …');
|
||||
final http.Response antwort;
|
||||
try {
|
||||
antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$proxyUrl/api/yt-dl'),
|
||||
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'url': url, 'cookies': cookies}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 180));
|
||||
antwort = await _abbrechbar(
|
||||
_client
|
||||
.post(
|
||||
Uri.parse('$proxyUrl/api/yt-dl'),
|
||||
headers: {
|
||||
...auth.authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: jsonEncode({'url': url, 'cookies': cookies}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 180)),
|
||||
abbruch,
|
||||
);
|
||||
} on _Abgebrochen {
|
||||
_scheitere('Abgebrochen');
|
||||
return null;
|
||||
} on TimeoutException {
|
||||
// Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich
|
||||
// erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die
|
||||
@@ -124,14 +159,23 @@ class YtDownloadService extends ChangeNotifier {
|
||||
final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3');
|
||||
|
||||
try {
|
||||
final mp3 = await _client
|
||||
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
|
||||
.timeout(const Duration(seconds: 180));
|
||||
final mp3 = await _abbrechbar(
|
||||
_client
|
||||
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
|
||||
.timeout(const Duration(seconds: 180)),
|
||||
abbruch,
|
||||
);
|
||||
if (mp3.statusCode != 200) {
|
||||
_scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})');
|
||||
return null;
|
||||
}
|
||||
await datei.writeAsBytes(mp3.bodyBytes);
|
||||
} on _Abgebrochen {
|
||||
if (await datei.exists()) {
|
||||
await datei.delete();
|
||||
}
|
||||
_scheitere('Abgebrochen');
|
||||
return null;
|
||||
} on TimeoutException {
|
||||
if (await datei.exists()) {
|
||||
await datei.delete();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../downloads/downloads_liste_screen.dart';
|
||||
@@ -32,11 +33,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late final CacheManager _cache = CacheManager();
|
||||
int _cacheSize = 0;
|
||||
|
||||
/// "1.2.3 (45)" — leer, solange noch nicht geladen. Praktisch beim Melden
|
||||
/// von Bugs oder um zu prüfen, ob ein Update angekommen ist.
|
||||
String _version = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_navidrome.ladeGespeicherteZugangsdaten();
|
||||
_cache.init().then((_) => _updateCacheSize());
|
||||
PackageInfo.fromPlatform().then((info) {
|
||||
if (mounted) {
|
||||
setState(() => _version = '${info.version} (${info.buildNumber})');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateCacheSize() async {
|
||||
@@ -293,10 +303,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const _SectionLabel('Über Melo'),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Melo'),
|
||||
subtitle: Text('Deine Musik. Offline. Kein Abo.'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Melo'),
|
||||
subtitle: Text(_version.isEmpty
|
||||
? 'Deine Musik. Offline. Kein Abo.'
|
||||
: 'Deine Musik. Offline. Kein Abo. · Version $_version'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+35
-10
@@ -13,6 +13,7 @@ class SubTabs extends StatelessWidget {
|
||||
required this.labels,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
this.badges,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
@@ -20,6 +21,11 @@ class SubTabs extends StatelessWidget {
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
/// Punkt neben dem Reiter-Text, an derselben Position je Eintrag wie
|
||||
/// [labels] — z. B. "hier läuft im Hintergrund etwas". `null` oder `false`
|
||||
/// zeigt nichts.
|
||||
final List<bool>? badges;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
@@ -33,6 +39,7 @@ class SubTabs extends StatelessWidget {
|
||||
child: _Reiter(
|
||||
label: labels[i],
|
||||
aktiv: i == index,
|
||||
badge: badges != null && i < badges!.length && badges![i],
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
@@ -47,10 +54,12 @@ class _Reiter extends StatelessWidget {
|
||||
required this.label,
|
||||
required this.aktiv,
|
||||
required this.onTap,
|
||||
this.badge = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool aktiv;
|
||||
final bool badge;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -82,16 +91,32 @@ class _Reiter extends StatelessWidget {
|
||||
// vorzudrängen — sonst schweben die Wörter im Nichts.
|
||||
border: aktiv ? null : MeloTheme.hairline,
|
||||
),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: MeloMotion.ruhig(context, MeloMotion.fast),
|
||||
curve: MeloMotion.curve,
|
||||
style: TextStyle(
|
||||
color: aktiv ? MeloTheme.black : MeloTheme.text2,
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
child: Text(label),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: MeloMotion.ruhig(context, MeloMotion.fast),
|
||||
curve: MeloMotion.curve,
|
||||
style: TextStyle(
|
||||
color: aktiv ? MeloTheme.black : MeloTheme.text2,
|
||||
fontSize: 15,
|
||||
height: 1.2,
|
||||
fontWeight: aktiv ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
child: Text(label),
|
||||
),
|
||||
if (badge) ...[
|
||||
const SizedBox(width: MeloSpace.xs),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: MeloTheme.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user