diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f7444..fd2ba6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [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) 7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart, diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 96551e0..9327d4b 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -101,6 +101,10 @@ class _ServerBrowserState extends State<_ServerBrowser> { List _artists = []; bool _loading = 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. bool _alleAlbenDa = false; bool _showArtists = false; @@ -110,9 +114,9 @@ class _ServerBrowserState extends State<_ServerBrowser> { void initState() { super.initState(); _nav.ladeGespeicherteZugangsdaten().then((_) { - if (_nav.istVerbunden && mounted) { - _loadAlbums(); - } + if (!mounted) return; + setState(() => _pruefeZugangsdaten = false); + if (_nav.istVerbunden) _loadAlbums(); }); } @@ -197,6 +201,12 @@ class _ServerBrowserState extends State<_ServerBrowser> { @override Widget build(BuildContext context) { + if (_pruefeZugangsdaten) { + return const Center( + child: CircularProgressIndicator(color: MeloTheme.red), + ); + } + if (!_nav.istVerbunden) { return Center( child: Column( diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index a80dea4..3f961d0 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -14,6 +14,7 @@ import 'audio_handler.dart'; import 'cover_farbe.dart'; import 'lrc.dart'; import 'queue_screen.dart'; +import 'wischen.dart'; /// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. class NowPlayingScreen extends StatelessWidget { @@ -44,17 +45,21 @@ class NowPlayingScreen extends StatelessWidget { builder: (context, snapshot) { final item = snapshot.data; 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( mainAxisSize: MainAxisSize.min, children: [ IconButton( - tooltip: 'Songtext', + tooltip: songId.isEmpty + ? 'Kein Songtext für diesen Titel verfügbar' + : 'Songtext', icon: const Icon(Icons.lyrics), - // 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. - onPressed: () => _showLyrics( - context, songIdOf(item) ?? navidromeIdOf(item) ?? ''), + onPressed: songId.isEmpty + ? null + : () => _showLyrics(context, songId), ), // Ein Titel der Bibliothek wird lokal favorisiert, einer // 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( stream: handler.mediaItem, builder: (context, snapshot) { @@ -134,6 +148,7 @@ class NowPlayingScreen extends StatelessWidget { ); }, ), + ), ), ), ); @@ -450,6 +465,12 @@ class _SleepTimerButton extends StatelessWidget { ? const SizedBox.shrink() : ListTile( 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), onTap: () { handler.sleepTimer.cancel(); @@ -583,6 +604,7 @@ class _LyricsSheetState extends State<_LyricsSheet> { fontSize: 18, fontWeight: FontWeight.w600), ), IconButton( + tooltip: 'Schließen', icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), diff --git a/lib/player/queue_screen.dart b/lib/player/queue_screen.dart index 0751f5d..76a0bad 100644 --- a/lib/player/queue_screen.dart +++ b/lib/player/queue_screen.dart @@ -41,6 +41,7 @@ class QueueScreen extends StatelessWidget { handler.moveQueueItem(oldIndex, newIndex), itemBuilder: (context, i) => _Zeile( key: ValueKey(queue[i].id), + index: i, item: queue[i], laeuft: queue[i].id == currentId, onSpringen: () => handler.skipToQueueItem(i), @@ -63,12 +64,15 @@ class QueueScreen extends StatelessWidget { class _Zeile extends StatelessWidget { const _Zeile({ super.key, + required this.index, required this.item, required this.laeuft, required this.onSpringen, required this.onEntfernen, }); + /// Position in der Warteschlange — nötig für das Drag-Handle. + final int index; final MediaItem item; /// Dieser Eintrag steht in der Wiedergabe — auch pausiert. @@ -113,10 +117,24 @@ class _Zeile extends StatelessWidget { ), subtitle: Text(item.artist ?? 'Unbekannt', maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: IconButton( - tooltip: 'Aus Warteschlange entfernen', - icon: const Icon(Icons.close), - onPressed: onEntfernen, + // 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', + icon: const Icon(Icons.close), + onPressed: onEntfernen, + ), + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.drag_handle, color: MeloTheme.text3), + ), + ), + ], ), onTap: onSpringen, ); diff --git a/lib/services/geteilte_adresse.dart b/lib/services/geteilte_adresse.dart index a71f9db..0153a3f 100644 --- a/lib/services/geteilte_adresse.dart +++ b/lib/services/geteilte_adresse.dart @@ -5,9 +5,11 @@ import 'package:flutter/services.dart'; /// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die /// YouTube-App hängt Titel und „über @YouTube" drumherum. /// -/// Bewusst **nicht** auf youtube.com eingeschränkt: der Downloader läuft über -/// yt-dlp am Server und kann mehr als YouTube. Was davon geht, entscheidet -/// der Server, nicht diese Zeile. +/// Bewusst **nicht** auf youtube.com eingeschränkt: das Herauslösen der +/// Adresse aus dem geteilten Text ist unabhängig von ihrer Herkunft. Ob eine +/// 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) { if (text == null || text.isEmpty) return null; final treffer = _adresse.firstMatch(text); diff --git a/lib/services/yt_download_service.dart b/lib/services/yt_download_service.dart index 91f4294..cd951d8 100644 --- a/lib/services/yt_download_service.dart +++ b/lib/services/yt_download_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -88,6 +89,12 @@ class YtDownloadService extends ChangeNotifier { body: jsonEncode({'url': url, 'cookies': cookies}), ) .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) { debugPrint('YT-Proxy nicht erreichbar: $e'); _scheitere('Proxy nicht erreichbar'); @@ -125,6 +132,12 @@ class YtDownloadService extends ChangeNotifier { return null; } 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) { debugPrint('MP3-Abruf fehlgeschlagen: $e'); // Halbe Dateien nicht liegen lassen — sonst taucht Müll in der diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 3733d49..5ae0922 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -166,9 +166,11 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.cloud_circle), title: const Text('Navidrome'), - subtitle: _navidrome.istVerbunden - ? const Text('Verbunden') - : const Text('Nicht verbunden'), + // "Navidrome" allein ist nur der Name der Server-Software — ohne + // den Zusatz muss man raten, wofür der Eintrag da ist. + subtitle: Text(_navidrome.istVerbunden + ? 'Eigener Musik-Server · Verbunden' + : 'Eigener Musik-Server (optional) · Nicht verbunden'), trailing: _navidrome.istVerbunden ? IconButton( icon: const Icon(Icons.logout), @@ -201,6 +203,26 @@ class _SettingsScreenState extends State { }, ), ), + 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'), StreamBuilder>( stream: db.watchDownloads(), @@ -256,26 +278,6 @@ class _SettingsScreenState extends State { }, ), ), - 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 _SectionLabel('Geräte-Abgleich'), const _CloudSyncBereich(), diff --git a/test/services/yt_download_test.dart b/test/services/yt_download_test.dart index 47e130c..98a85c1 100644 --- a/test/services/yt_download_test.dart +++ b/test/services/yt_download_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -56,6 +57,38 @@ void main() { 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 { final dienst = YtDownloadService( auth: await _angemeldeteAuth(),