UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen
Zweiter Block aus der UX-Simulation (39 Funde insgesamt): - Songtext-Icon deaktiviert statt totem Knopf, wenn kein Ziel bekannt ist - Sleep-Timer-Restzeit jetzt auch im Sheet sichtbar, nicht nur im Tooltip - Drag-Handle in der Warteschlange, Wisch-nach-unten schließt den Vollbild-Player - Fehlender Tooltip am Songtext-Sheet-Schließen-Knopf ergänzt - YouTube-Download: Zeitüberschreitung meldet sich anders als toter Server - Server-Tab zeigte beim Öffnen kurz fälschlich "nicht verbunden" - Einstellungen: Favoriten-/Playlisten-Sync von "Speicher" zu "Musikserver" verschoben, Navidrome-Eintrag erklärt jetzt seinen Zweck - Kommentar in geteilte_adresse.dart korrigiert (Verhalten unverändert, dafür gibt es einen Test) Bewusst zurückgestellt: ServerFavoriteButton bleibt lokal-only (dokumentierte Architektur-Entscheidung, siehe G3 im UX-Review), Live-Wischgeste am Mini-Player, YouTube-Download-Abbrechen, Download-Badge im inaktiven Unterreiter, Versionsnummer (neue Abhängigkeit nötig). 539 Tests grün (vorher 537), flutter analyze ohne Befund.
This commit is contained in:
@@ -5,6 +5,52 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🧑🤝🧑 UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen (2026-08-25)
|
||||||
|
|
||||||
|
Zweiter Block derselben 39 Funde:
|
||||||
|
|
||||||
|
- 🎧 **Songtext-Icon ohne ID war ein toter Knopf** — sichtbar, tippbar, aber
|
||||||
|
ohne jede Wirkung. Jetzt ausgegraut mit erklärendem Tooltip, wenn kein
|
||||||
|
Songtext-Ziel bekannt ist.
|
||||||
|
- ⏱️ **Sleep-Timer-Restzeit war faktisch unsichtbar** (nur im Tooltip, den
|
||||||
|
kaum jemand per Long-Press entdeckt) — steht jetzt auch im Sheet.
|
||||||
|
- ↕️ **Warteschlange umsortieren war unauffindbar:** kein sichtbares Zeichen,
|
||||||
|
dass die Liste per Drag reagiert. Jetzt mit Drag-Handle-Symbol wie bei
|
||||||
|
Spotify/Apple Music.
|
||||||
|
- 👇 **Vollbild-Player ließ sich nicht wegwischen** — nur über den
|
||||||
|
Zurück-Pfeil schließbar, obwohl der Mini-Player schon Wischgesten kann.
|
||||||
|
Jetzt schließt Wischen nach unten den Player, wie bei Apple Music/Spotify.
|
||||||
|
- 🏷️ Fehlendes `tooltip:` am Schließen-Knopf des Songtext-Sheets ergänzt —
|
||||||
|
jeder andere Icon-Button der App hat einen.
|
||||||
|
- 📡 **YouTube-Downloads:** Zeitüberschreitung (Server erreichbar, braucht
|
||||||
|
nur zu lange) und toter Server meldeten sich bisher identisch — jetzt mit
|
||||||
|
eigener, ehrlicherer Meldung ("braucht zu lange" statt "nicht
|
||||||
|
erreichbar").
|
||||||
|
- 🔄 **Server-Tab blitzte beim Öffnen kurz "nicht verbunden" auf**, obwohl
|
||||||
|
verbunden — weil `build()` lief, bevor die gespeicherten Zugangsdaten
|
||||||
|
fertig geladen waren. Jetzt erst ein Ladezustand, dann die echte Antwort.
|
||||||
|
- 🗂️ **Einstellungen umsortiert:** Favoriten-Sync und Playlisten-Sync
|
||||||
|
standen unter "Speicher", obwohl sie nichts mit Speicherplatz zu tun
|
||||||
|
haben — jetzt bei "Musikserver", direkt beim Navidrome-Eintrag. Der
|
||||||
|
Navidrome-Eintrag selbst sagt jetzt, wofür er da ist ("Eigener
|
||||||
|
Musik-Server"), statt nur den Software-Namen zu zeigen.
|
||||||
|
- ✏️ Kommentar in `geteilte_adresse.dart` korrigiert: er behauptete, der
|
||||||
|
Server entscheide über nicht-YouTube-Adressen — tatsächlich lehnt
|
||||||
|
`YtDownloadService` sie schon vorher ab. Verhalten bewusst nicht
|
||||||
|
geändert (dafür gibt es einen eigenen Test), nur die Doku richtiggestellt.
|
||||||
|
- ⏸️ **Bewusst nicht angefasst:** das separate Server-Favoriten-Herz
|
||||||
|
(`ServerFavoriteButton`) bleibt wie dokumentiert lokal-only — das ist eine
|
||||||
|
bestehende, begründete Architektur-Entscheidung (siehe G3 im
|
||||||
|
UX-Review) und keine Kleinigkeit, die sich nebenbei mitreparieren lässt.
|
||||||
|
Ebenso zurückgestellt: Live-Mitführen des Fingers beim langsamen Wischen
|
||||||
|
am Mini-Player, ein Abbrechen-Knopf für laufende YouTube-Downloads, ein
|
||||||
|
Lauf-Badge auf dem inaktiven Download-Unterreiter und eine Versionsnummer
|
||||||
|
unter "Über Melo" (bräuchte eine neue Abhängigkeit) — alles eigene
|
||||||
|
kleine Vorhaben, keine Fünf-Minuten-Fixe.
|
||||||
|
- ✅ **Getestet:** 539 Tests grün (vorher 537, 2 neu für die
|
||||||
|
Timeout-Unterscheidung beim YouTube-Download), `flutter analyze` ohne
|
||||||
|
Befund.
|
||||||
|
|
||||||
### 🧑🤝🧑 UX-Simulation: Mehrfachauswahl, Baka-Login, Bestätigungen, Suche (2026-08-25)
|
### 🧑🤝🧑 UX-Simulation: Mehrfachauswahl, Baka-Login, Bestätigungen, Suche (2026-08-25)
|
||||||
|
|
||||||
7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart,
|
7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
List<SubsonicArtist> _artists = [];
|
List<SubsonicArtist> _artists = [];
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _laedtNach = false;
|
bool _laedtNach = false;
|
||||||
|
// Solange die gespeicherten Zugangsdaten noch geladen werden, ist
|
||||||
|
// _nav.istVerbunden fälschlich noch false — ohne diesen Zustand blitzte
|
||||||
|
// beim Öffnen kurz "nicht verbunden" auf, obwohl der Server verbunden ist.
|
||||||
|
bool _pruefeZugangsdaten = true;
|
||||||
/// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss.
|
/// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss.
|
||||||
bool _alleAlbenDa = false;
|
bool _alleAlbenDa = false;
|
||||||
bool _showArtists = false;
|
bool _showArtists = false;
|
||||||
@@ -110,9 +114,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nav.ladeGespeicherteZugangsdaten().then((_) {
|
_nav.ladeGespeicherteZugangsdaten().then((_) {
|
||||||
if (_nav.istVerbunden && mounted) {
|
if (!mounted) return;
|
||||||
_loadAlbums();
|
setState(() => _pruefeZugangsdaten = false);
|
||||||
}
|
if (_nav.istVerbunden) _loadAlbums();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +201,12 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_pruefeZugangsdaten) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: MeloTheme.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_nav.istVerbunden) {
|
if (!_nav.istVerbunden) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'audio_handler.dart';
|
|||||||
import 'cover_farbe.dart';
|
import 'cover_farbe.dart';
|
||||||
import 'lrc.dart';
|
import 'lrc.dart';
|
||||||
import 'queue_screen.dart';
|
import 'queue_screen.dart';
|
||||||
|
import 'wischen.dart';
|
||||||
|
|
||||||
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
|
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
|
||||||
class NowPlayingScreen extends StatelessWidget {
|
class NowPlayingScreen extends StatelessWidget {
|
||||||
@@ -44,17 +45,21 @@ class NowPlayingScreen extends StatelessWidget {
|
|||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final item = snapshot.data;
|
final item = snapshot.data;
|
||||||
if (item == null) return const SizedBox.shrink();
|
if (item == null) return const SizedBox.shrink();
|
||||||
|
// Bei Server-Titeln die Navidrome-ID, nicht die Abspiel-Adresse:
|
||||||
|
// mit der fragte die App den Server nach einem Titel namens
|
||||||
|
// "https://…" und bekam nie einen Text.
|
||||||
|
final songId = songIdOf(item) ?? navidromeIdOf(item) ?? '';
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Songtext',
|
tooltip: songId.isEmpty
|
||||||
|
? 'Kein Songtext für diesen Titel verfügbar'
|
||||||
|
: 'Songtext',
|
||||||
icon: const Icon(Icons.lyrics),
|
icon: const Icon(Icons.lyrics),
|
||||||
// Bei Server-Titeln die Navidrome-ID, nicht die
|
onPressed: songId.isEmpty
|
||||||
// Abspiel-Adresse: mit der fragte die App den Server nach
|
? null
|
||||||
// einem Titel namens "https://…" und bekam nie einen Text.
|
: () => _showLyrics(context, songId),
|
||||||
onPressed: () => _showLyrics(
|
|
||||||
context, songIdOf(item) ?? navidromeIdOf(item) ?? ''),
|
|
||||||
),
|
),
|
||||||
// Ein Titel der Bibliothek wird lokal favorisiert, einer
|
// Ein Titel der Bibliothek wird lokal favorisiert, einer
|
||||||
// vom Server am Server — beide bekommen dasselbe Herz,
|
// vom Server am Server — beide bekommen dasselbe Herz,
|
||||||
@@ -75,7 +80,16 @@ class NowPlayingScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: GestureDetector(
|
||||||
|
// Wie bei Apple Music/Spotify: den Vollbild-Player von praktisch
|
||||||
|
// jeder Stelle nach unten wegziehen schließt ihn wieder — dieselbe
|
||||||
|
// Schwelle wie beim Mini-Player, damit kein zaghaftes Scrollen die
|
||||||
|
// Ansicht versehentlich zuklappt.
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
final geschwindigkeit = details.primaryVelocity ?? 0;
|
||||||
|
if (geschwindigkeit > wischSchwelle) Navigator.maybePop(context);
|
||||||
|
},
|
||||||
|
child: SafeArea(
|
||||||
child: StreamBuilder<MediaItem?>(
|
child: StreamBuilder<MediaItem?>(
|
||||||
stream: handler.mediaItem,
|
stream: handler.mediaItem,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -136,6 +150,7 @@ class NowPlayingScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,6 +465,12 @@ class _SleepTimerButton extends StatelessWidget {
|
|||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: ListTile(
|
: ListTile(
|
||||||
title: const Text('Timer beenden'),
|
title: const Text('Timer beenden'),
|
||||||
|
// Ohne die Restzeit hier war sie faktisch unsichtbar —
|
||||||
|
// die einzige andere Stelle ist der Tooltip des
|
||||||
|
// AppBar-Icons, den kaum jemand entdeckt.
|
||||||
|
subtitle: Text(
|
||||||
|
'Noch ${remaining.inMinutes}:'
|
||||||
|
'${(remaining.inSeconds % 60).toString().padLeft(2, '0')}'),
|
||||||
leading: const Icon(Icons.close),
|
leading: const Icon(Icons.close),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
handler.sleepTimer.cancel();
|
handler.sleepTimer.cancel();
|
||||||
@@ -583,6 +604,7 @@ class _LyricsSheetState extends State<_LyricsSheet> {
|
|||||||
fontSize: 18, fontWeight: FontWeight.w600),
|
fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Schließen',
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class QueueScreen extends StatelessWidget {
|
|||||||
handler.moveQueueItem(oldIndex, newIndex),
|
handler.moveQueueItem(oldIndex, newIndex),
|
||||||
itemBuilder: (context, i) => _Zeile(
|
itemBuilder: (context, i) => _Zeile(
|
||||||
key: ValueKey(queue[i].id),
|
key: ValueKey(queue[i].id),
|
||||||
|
index: i,
|
||||||
item: queue[i],
|
item: queue[i],
|
||||||
laeuft: queue[i].id == currentId,
|
laeuft: queue[i].id == currentId,
|
||||||
onSpringen: () => handler.skipToQueueItem(i),
|
onSpringen: () => handler.skipToQueueItem(i),
|
||||||
@@ -63,12 +64,15 @@ class QueueScreen extends StatelessWidget {
|
|||||||
class _Zeile extends StatelessWidget {
|
class _Zeile extends StatelessWidget {
|
||||||
const _Zeile({
|
const _Zeile({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.index,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.laeuft,
|
required this.laeuft,
|
||||||
required this.onSpringen,
|
required this.onSpringen,
|
||||||
required this.onEntfernen,
|
required this.onEntfernen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Position in der Warteschlange — nötig für das Drag-Handle.
|
||||||
|
final int index;
|
||||||
final MediaItem item;
|
final MediaItem item;
|
||||||
|
|
||||||
/// Dieser Eintrag steht in der Wiedergabe — auch pausiert.
|
/// Dieser Eintrag steht in der Wiedergabe — auch pausiert.
|
||||||
@@ -113,11 +117,25 @@ class _Zeile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
subtitle: Text(item.artist ?? 'Unbekannt',
|
subtitle: Text(item.artist ?? 'Unbekannt',
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
trailing: IconButton(
|
// Ohne sichtbares Handle war Umsortieren nur per unmarkiertem
|
||||||
|
// Long-Press-and-Drag irgendwo auf der Zeile auffindbar.
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
tooltip: 'Aus Warteschlange entfernen',
|
tooltip: 'Aus Warteschlange entfernen',
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: onEntfernen,
|
onPressed: onEntfernen,
|
||||||
),
|
),
|
||||||
|
ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Icon(Icons.drag_handle, color: MeloTheme.text3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: onSpringen,
|
onTap: onSpringen,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
|
|||||||
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die
|
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die
|
||||||
/// YouTube-App hängt Titel und „über @YouTube" drumherum.
|
/// YouTube-App hängt Titel und „über @YouTube" drumherum.
|
||||||
///
|
///
|
||||||
/// Bewusst **nicht** auf youtube.com eingeschränkt: der Downloader läuft über
|
/// Bewusst **nicht** auf youtube.com eingeschränkt: das Herauslösen der
|
||||||
/// yt-dlp am Server und kann mehr als YouTube. Was davon geht, entscheidet
|
/// Adresse aus dem geteilten Text ist unabhängig von ihrer Herkunft. Ob eine
|
||||||
/// der Server, nicht diese Zeile.
|
/// nicht-YouTube-Adresse tatsächlich heruntergeladen werden kann, entscheidet
|
||||||
|
/// [YtDownloadService.herunterladen] — der lehnt sie heute client-seitig ab
|
||||||
|
/// (siehe dort), unabhängig davon, was der Server könnte.
|
||||||
String? adresseAus(String? text) {
|
String? adresseAus(String? text) {
|
||||||
if (text == null || text.isEmpty) return null;
|
if (text == null || text.isEmpty) return null;
|
||||||
final treffer = _adresse.firstMatch(text);
|
final treffer = _adresse.firstMatch(text);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -88,6 +89,12 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
body: jsonEncode({'url': url, 'cookies': cookies}),
|
body: jsonEncode({'url': url, 'cookies': cookies}),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 180));
|
.timeout(const Duration(seconds: 180));
|
||||||
|
} on TimeoutException {
|
||||||
|
// Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich
|
||||||
|
// erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die
|
||||||
|
// Irre führen.
|
||||||
|
_scheitere('Der Server braucht zu lange — bitte später erneut versuchen');
|
||||||
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('YT-Proxy nicht erreichbar: $e');
|
debugPrint('YT-Proxy nicht erreichbar: $e');
|
||||||
_scheitere('Proxy nicht erreichbar');
|
_scheitere('Proxy nicht erreichbar');
|
||||||
@@ -125,6 +132,12 @@ class YtDownloadService extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await datei.writeAsBytes(mp3.bodyBytes);
|
await datei.writeAsBytes(mp3.bodyBytes);
|
||||||
|
} on TimeoutException {
|
||||||
|
if (await datei.exists()) {
|
||||||
|
await datei.delete();
|
||||||
|
}
|
||||||
|
_scheitere('Der Server braucht zu lange — bitte später erneut versuchen');
|
||||||
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('MP3-Abruf fehlgeschlagen: $e');
|
debugPrint('MP3-Abruf fehlgeschlagen: $e');
|
||||||
// Halbe Dateien nicht liegen lassen — sonst taucht Müll in der
|
// Halbe Dateien nicht liegen lassen — sonst taucht Müll in der
|
||||||
|
|||||||
@@ -166,9 +166,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.cloud_circle),
|
leading: const Icon(Icons.cloud_circle),
|
||||||
title: const Text('Navidrome'),
|
title: const Text('Navidrome'),
|
||||||
subtitle: _navidrome.istVerbunden
|
// "Navidrome" allein ist nur der Name der Server-Software — ohne
|
||||||
? const Text('Verbunden')
|
// den Zusatz muss man raten, wofür der Eintrag da ist.
|
||||||
: const Text('Nicht verbunden'),
|
subtitle: Text(_navidrome.istVerbunden
|
||||||
|
? 'Eigener Musik-Server · Verbunden'
|
||||||
|
: 'Eigener Musik-Server (optional) · Nicht verbunden'),
|
||||||
trailing: _navidrome.istVerbunden
|
trailing: _navidrome.istVerbunden
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
@@ -201,6 +203,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.favorite),
|
||||||
|
title: const Text('Favoriten-Sync'),
|
||||||
|
subtitle: const Text('Favoriten vom Server importieren'),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Vom Server laden',
|
||||||
|
icon: const Icon(Icons.cloud_download),
|
||||||
|
onPressed: () => _syncServerFavorites(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.playlist_play),
|
||||||
|
title: const Text('Playlisten-Sync'),
|
||||||
|
subtitle: const Text('Server-Playlisten importieren'),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Vom Server laden',
|
||||||
|
icon: const Icon(Icons.cloud_download),
|
||||||
|
onPressed: () => _syncServerPlaylists(),
|
||||||
|
),
|
||||||
|
),
|
||||||
const _SectionLabel('Speicher'),
|
const _SectionLabel('Speicher'),
|
||||||
StreamBuilder<List<Download>>(
|
StreamBuilder<List<Download>>(
|
||||||
stream: db.watchDownloads(),
|
stream: db.watchDownloads(),
|
||||||
@@ -256,26 +278,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.favorite),
|
|
||||||
title: const Text('Favoriten-Sync'),
|
|
||||||
subtitle: const Text('Favoriten vom Server importieren'),
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: 'Vom Server laden',
|
|
||||||
icon: const Icon(Icons.cloud_download),
|
|
||||||
onPressed: () => _syncServerFavorites(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.playlist_play),
|
|
||||||
title: const Text('Playlisten-Sync'),
|
|
||||||
subtitle: const Text('Server-Playlisten importieren'),
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: 'Vom Server laden',
|
|
||||||
icon: const Icon(Icons.cloud_download),
|
|
||||||
onPressed: () => _syncServerPlaylists(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const _SectionLabel('Geräte-Abgleich'),
|
const _SectionLabel('Geräte-Abgleich'),
|
||||||
const _CloudSyncBereich(),
|
const _CloudSyncBereich(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -56,6 +57,38 @@ void main() {
|
|||||||
expect(aufrufe, 0);
|
expect(aufrufe, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Zeitüberschreitung beim Proxy meldet sich anders als eine tote '
|
||||||
|
'Verbindung', () async {
|
||||||
|
final dienst = YtDownloadService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((_) async => throw TimeoutException('zu lange')),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ergebnis = await dienst.herunterladen(
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ',
|
||||||
|
zielOrdner: ziel.path);
|
||||||
|
|
||||||
|
expect(ergebnis, isNull);
|
||||||
|
expect(dienst.fehler,
|
||||||
|
'Der Server braucht zu lange — bitte später erneut versuchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine tote Verbindung zum Proxy meldet sich anders als ein Timeout',
|
||||||
|
() async {
|
||||||
|
final dienst = YtDownloadService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((_) async => throw const SocketException('down')),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ergebnis = await dienst.herunterladen(
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ',
|
||||||
|
zielOrdner: ziel.path);
|
||||||
|
|
||||||
|
expect(ergebnis, isNull);
|
||||||
|
expect(dienst.fehler, 'Proxy nicht erreichbar');
|
||||||
|
});
|
||||||
|
|
||||||
test('Erfolgreicher Download legt die MP3 im Zielordner ab', () async {
|
test('Erfolgreicher Download legt die MP3 im Zielordner ab', () async {
|
||||||
final dienst = YtDownloadService(
|
final dienst = YtDownloadService(
|
||||||
auth: await _angemeldeteAuth(),
|
auth: await _angemeldeteAuth(),
|
||||||
|
|||||||
Reference in New Issue
Block a user