From c2f31dee01fb563b9e9cbf1974a8c4b4ffce875f Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Tue, 25 Aug 2026 10:25:43 +0200 Subject: [PATCH] UX-Simulation, Teil 3: Abbrechen, Badge, Versionsnummer, Wisch-Strecke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- CHANGELOG.md | 35 ++++++++++ lib/downloads/downloads_screen.dart | 26 +++++++- lib/player/mini_player.dart | 59 +++++++++++++++-- lib/player/wischen.dart | 25 ++++++++ lib/services/yt_download_service.dart | 64 ++++++++++++++++--- lib/settings/settings_screen.dart | 20 ++++-- lib/shared/sub_tabs.dart | 45 ++++++++++--- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 16 +++++ pubspec.yaml | 1 + test/downloads/online_screen_test.dart | 7 ++ test/hauptmenue_test.dart | 5 ++ test/home_shell_test.dart | 4 ++ test/player/wischen_test.dart | 43 +++++++++++++ test/services/yt_download_test.dart | 22 +++++++ test/shared/sub_tabs_test.dart | 51 +++++++++++++++ 16 files changed, 392 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2ba6b..937c3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🧑‍🤝‍🧑 UX-Simulation, Teil 3: die zurückgestellten Punkte (2026-08-25) + +Die vier Punkte aus Teil 2, die als "eigenes kleines Vorhaben" zurückgestellt +waren, sind jetzt auch erledigt: + +- ⏹️ **Abbrechen-Knopf für YouTube-Downloads.** Ein hängender Download (bis + zu zwei mal 180s) ließ sich vorher nur durch Schließen der App stoppen. + `YtDownloadService` wartet jetzt mit `Future.any` gegen ein Abbruch-Signal + — die Netzwerk-Anfrage läuft im Hintergrund aus, die App wartet aber + nicht mehr darauf und meldet sich sofort zurück. +- 🔴 **Punkt am inaktiven Download-Unterreiter,** wenn dort im Hintergrund + ein Download läuft — man sah vorher nicht, dass beim Wechsel zu + YouTube/Server nebenan noch etwas passiert. `SubTabs` kann das jetzt für + jeden Reiter einzeln (neuer, optionaler `badges`-Parameter). +- 🔢 **Versionsnummer unter "Über Melo"** — dafür kam `package_info_plus` + als neue Abhängigkeit dazu (federated plugin, kein zusätzliches + Manifest nötig). +- 👆 **Mini-Player folgt jetzt dem Finger.** Vorher zählte nur ein kräftiger + Schwung (≥ 300 px/s); wer langsam, aber weit genug wischte, bekam gar + keine Reaktion — wie ein hängender Bildschirm statt "zu langsam + gewischt". Der Player führt die Bewegung jetzt sichtbar mit + (`AnimatedContainer.transform`) und entscheidet beim Loslassen über + Schwung **oder** zurückgelegte Strecke; ohne Wirkung schnappt er sichtbar + zurück. Nach unten bleibt weiterhin unbelegt (Absicht, unverändert). +- 🔧 **Nebenbei gefunden:** Drei bestehende Widget-Tests + (`hauptmenue_test.dart`, `home_shell_test.dart`, + `online_screen_test.dart`) bauten den Download-Tab ohne + `Provider` auf — funktionierte bisher nur, weil niemand + ihn dort abfragte. Das Badge tut das jetzt app-weit; die drei + Test-Aufbauten sind entsprechend ergänzt. +- ✅ **Getestet:** 550 Tests grün (vorher 539), `flutter analyze` ohne + Befund. Neu: Abbruch-Test für den YouTube-Download (hängende Anfrage per + `Completer`), zwei Tests für das SubTabs-Badge, vier für die + streckenbasierten Wisch-Funktionen. + ### 🧑‍🤝‍🧑 UX-Simulation, Teil 2: Player-Feinschliff, Downloads, Einstellungen (2026-08-25) Zweiter Block derselben 39 Funde: diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 9327d4b..538a602 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -59,6 +59,11 @@ class _DownloadsScreenState extends State { @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().laeuft; + final serverLaeuft = context.watch().laeuft; return SafeArea( bottom: false, child: Column( @@ -67,6 +72,10 @@ class _DownloadsScreenState extends State { 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), diff --git a/lib/player/mini_player.dart b/lib/player/mini_player.dart index 01eb061..41dfb77 100644 --- a/lib/player/mini_player.dart +++ b/lib/player/mini_player.dart @@ -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: [ diff --git a/lib/player/wischen.dart b/lib/player/wischen.dart index 3696b7f..dfd140e 100644 --- a/lib/player/wischen.dart +++ b/lib/player/wischen.dart @@ -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; +} diff --git a/lib/services/yt_download_service.dart b/lib/services/yt_download_service.dart index cd951d8..006bdf8 100644 --- a/lib/services/yt_download_service.dart +++ b/lib/services/yt_download_service.dart @@ -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? _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 _abbrechbar(Future aufgabe, Completer abbruch) { + return Future.any([ + 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(); 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(); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 5ae0922..bee8a8d 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -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 { 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 _updateCacheSize() async { @@ -293,10 +303,12 @@ class _SettingsScreenState extends State { ), 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'), ), ], ), diff --git a/lib/shared/sub_tabs.dart b/lib/shared/sub_tabs.dart index 1e047fd..ea5f9a8 100644 --- a/lib/shared/sub_tabs.dart +++ b/lib/shared/sub_tabs.dart @@ -13,6 +13,7 @@ class SubTabs extends StatelessWidget { required this.labels, required this.index, required this.onChanged, + this.badges, }); final List labels; @@ -20,6 +21,11 @@ class SubTabs extends StatelessWidget { final int index; final ValueChanged 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? 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, + ), + ), + ], + ], ), ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d62e0fb..fc7d1fe 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import device_info_plus import file_picker_darwin import flutter_secure_storage_darwin import just_audio +import package_info_plus import record_macos import shared_preferences_foundation import sqflite_darwin @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2581857..fb052ba 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,6 +720,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 315f531..eebfab3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: shared_preferences: ^2.2.2 flutter_secure_storage: ^11.0.0 record: ^7.1.1 + package_info_plus: ^10.2.1 dev_dependencies: flutter_test: diff --git a/test/downloads/online_screen_test.dart b/test/downloads/online_screen_test.dart index b975fb7..501acc4 100644 --- a/test/downloads/online_screen_test.dart +++ b/test/downloads/online_screen_test.dart @@ -10,6 +10,8 @@ import 'package:melo/downloads/downloads_screen.dart'; import 'package:melo/library/category_service.dart'; import 'package:melo/library/database.dart'; import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/navidrome_service.dart'; import 'package:melo/services/yt_download_service.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,6 +42,11 @@ Widget _wrap(BakaAuth auth, client: MockClient((_) async => http.Response('', 500)), ), ), + // Nur fürs Badge im Unterreiter-Kopf gebraucht (dienst.laeuft) — der + // Download-Tab beobachtet ihn app-weit, unabhängig vom Unterreiter. + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService()), + ), ], child: const MaterialApp(home: Scaffold(body: DownloadsScreen())), ); diff --git a/test/hauptmenue_test.dart b/test/hauptmenue_test.dart index 15ad774..01180ad 100644 --- a/test/hauptmenue_test.dart +++ b/test/hauptmenue_test.dart @@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/navidrome_service.dart'; import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/sync_service.dart'; import 'package:melo/services/yt_download_service.dart'; @@ -39,6 +41,7 @@ void main() { late AppSettings einstellungen; late OfflineMode offline; late YtDownloadService ytDownload; + late DownloadService downloads; late SyncService sync; MeloAudioHandler? handler; @@ -52,6 +55,7 @@ void main() { einstellungen = AppSettings(); offline = OfflineMode(); ytDownload = YtDownloadService(auth: auth); + downloads = DownloadService(db: db, navidrome: NavidromeService()); sync = SyncService(db: db, cloud: MeloCloudService(auth: auth)); }); @@ -83,6 +87,7 @@ void main() { ChangeNotifierProvider.value(value: offline), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: ytDownload), + ChangeNotifierProvider.value(value: downloads), ChangeNotifierProvider.value(value: sync), ], child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()), diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart index b25372a..6efdc94 100644 --- a/test/home_shell_test.dart +++ b/test/home_shell_test.dart @@ -8,7 +8,9 @@ import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/navidrome_service.dart'; import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/sync_service.dart'; import 'package:melo/services/yt_download_service.dart'; @@ -49,6 +51,8 @@ void main() { ChangeNotifierProvider(create: (_) => auth), ChangeNotifierProvider( create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => DownloadService(db: db, navidrome: NavidromeService())), ChangeNotifierProvider( create: (_) => SyncService( db: db, cloud: MeloCloudService(auth: auth))), diff --git a/test/player/wischen_test.dart b/test/player/wischen_test.dart index 4878588..9528d36 100644 --- a/test/player/wischen_test.dart +++ b/test/player/wischen_test.dart @@ -49,4 +49,47 @@ void main() { expect(oeffnetVollbild(0), isFalse); }); }); + + group('titelWechselMitStrecke', () { + test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke', + () { + expect(titelWechselMitStrecke(-5, -1200), Wechsel.naechster); + expect(titelWechselMitStrecke(5, 1200), Wechsel.vorheriger); + }); + + test('eine weite, aber langsame Bewegung wechselt trotzdem', () { + expect(titelWechselMitStrecke(-wischStreckeSchwelle - 1, 0), + Wechsel.naechster); + expect(titelWechselMitStrecke(wischStreckeSchwelle + 1, 0), + Wechsel.vorheriger); + }); + + test('kurz und langsam wechselt nichts', () { + expect(titelWechselMitStrecke(20, 80), isNull); + }); + + test('genau auf der Streckenschwelle wechselt es noch nicht', () { + expect(titelWechselMitStrecke(wischStreckeSchwelle, 0), isNull); + }); + }); + + group('oeffnetVollbildMitStrecke', () { + test('kräftiger Schwung reicht wie bisher, egal wie kurz die Strecke', + () { + expect(oeffnetVollbildMitStrecke(-5, -1200), isTrue); + }); + + test('eine weite, aber langsame Bewegung nach oben öffnet trotzdem', () { + expect(oeffnetVollbildMitStrecke(-wischStreckeSchwelle - 1, 0), isTrue); + }); + + test('dieselbe Strecke nach unten öffnet nicht — nur oben ist belegt', + () { + expect(oeffnetVollbildMitStrecke(wischStreckeSchwelle + 1, 0), isFalse); + }); + + test('kurz und langsam öffnet nichts', () { + expect(oeffnetVollbildMitStrecke(-20, -80), isFalse); + }); + }); } diff --git a/test/services/yt_download_test.dart b/test/services/yt_download_test.dart index 98a85c1..b915abe 100644 --- a/test/services/yt_download_test.dart +++ b/test/services/yt_download_test.dart @@ -74,6 +74,28 @@ void main() { 'Der Server braucht zu lange — bitte später erneut versuchen'); }); + test('abbrechen() beendet einen hängenden Download sofort', () async { + // Antwortet nie — genau der Fall, in dem der Nutzer heute nicht mehr + // herankam. + final haengt = Completer(); + final dienst = YtDownloadService( + auth: await _angemeldeteAuth(), + client: MockClient((_) => haengt.future), + ); + + final ergebnisFuture = dienst.herunterladen('https://youtu.be/dQw4w9WgXcQ', + zielOrdner: ziel.path); + // Der Service braucht einen Mikrotask, um _abbruch zu setzen, bevor + // abbrechen() etwas zu vervollständigen hat. + await Future.delayed(Duration.zero); + dienst.abbrechen(); + final ergebnis = await ergebnisFuture; + + expect(ergebnis, isNull); + expect(dienst.fehler, 'Abgebrochen'); + expect(dienst.laeuft, isFalse); + }); + test('eine tote Verbindung zum Proxy meldet sich anders als ein Timeout', () async { final dienst = YtDownloadService( diff --git a/test/shared/sub_tabs_test.dart b/test/shared/sub_tabs_test.dart index 4478048..ee9d383 100644 --- a/test/shared/sub_tabs_test.dart +++ b/test/shared/sub_tabs_test.dart @@ -81,6 +81,57 @@ void main() { expect(find.byType(InkWell), findsNWidgets(2)); }); + bool istPunkt(Widget w) => + w is Container && (w.decoration as BoxDecoration?)?.shape == BoxShape.circle; + + testWidgets( + 'ein Punkt zeigt an, dass im anderen Reiter etwas im Hintergrund läuft', + (tester) async { + await tester.pumpWidget(_wrap(SubTabs( + labels: const ['YouTube', 'Server'], + index: 0, + badges: const [false, true], + onChanged: (_) {}, + ))); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.ancestor( + of: find.text('Server'), matching: find.byType(AnimatedContainer)), + matching: find.byWidgetPredicate(istPunkt), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.ancestor( + of: find.text('YouTube'), + matching: find.byType(AnimatedContainer)), + matching: find.byWidgetPredicate(istPunkt), + ), + findsNothing, + ); + }); + + testWidgets('ohne badges-Angabe bleibt ein Reiter ohne Punkt', (tester) async { + await tester.pumpWidget(_wrap(SubTabs( + labels: const ['Lieder', 'Kategorie'], + index: 0, + onChanged: (_) {}, + ))); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.ancestor( + of: find.text('Lieder'), matching: find.byType(AnimatedContainer)), + matching: find.byWidgetPredicate(istPunkt), + ), + findsNothing, + ); + }); + testWidgets('der Wechsel wird animiert, nicht gesprungen', (tester) async { await tester.pumpWidget(_wrap(SubTabs( labels: const ['Lieder', 'Kategorie'],