diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb53c2..1b26175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### đŸŽšïžđŸ“ș Equalizer-Fix (Xiaomi) + In-App-YouTube-Suche, Extra-Tab (2026-08-25) + +- đŸŽšïž **Equalizer: "kein System-Klangeffekte" trotz Dolby Atmos behoben.** + `openSystemPanel()` nutzte bisher nur den Android-Standard-Intent + (`ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL`) — dafĂŒr registriert + MIUI/HyperOS auf dem POCO X7 Pro keine Activity, `resolveActivity()` + liefert dort immer `null`. Neu: zuerst der Xiaomi-Weg direkt zur + System-Ton-Seite (`com.android.settings.Settings$SoundSettingsActivity`, + wo Dolby Atmos liegt), danach `Settings.ACTION_SOUND_SETTINGS` als + universeller Fallback, zuletzt weiter der Android-Standard-Intent fĂŒr + GerĂ€te, die den unterstĂŒtzen. Kotlin kompiliert sauber + (`gradlew :app:compileDebugKotlin`); echtes Testen auf einem POCO X7 Pro + war von hier aus nicht möglich. +- đŸ“ș **In-App-YouTube-Suche — neuer Extra-Tab "YT-Suche" (zum Testen).** + Bisher ließ sich nur eine YouTube-Adresse einfĂŒgen; jetzt gibt es eine + echte Suche mit Ergebnisliste (Vorschaubild, Titel, Dauer) und + Download-Knopf pro Treffer. Nutzt den bereits vorhandenen + `/api/search`-Endpunkt des Baka-Proxys (yt-dlp-gestĂŒtzt, dieselbe + Anmeldung wie der bestehende YouTube-Downloader) — kein neuer Server, + kein YouTube-API-Key nötig. Die Vorschaubilder kommen direkt von + YouTubes öffentlichem Thumbnail-CDN, aus der Video-ID der Ergebnis-URL. + Der Download-Knopf lĂ€uft ĂŒber denselben Weg wie im Download-Tab + (Baka-Proxy → App-Zwischenordner → MediaStore → Bibliotheks-Scan). +- 🐛 **Beim Testen gefunden:** `YtDownloadService.herunterladen()` + (Future.any + `.timeout()`) lĂ€sst sich nicht sicher aus einem + `testWidgets()`-Test heraus aufrufen — der Timeout-Timer bleibt unter + Flutters `fake_async`-Testbindung dauerhaft "pending" und lĂ€sst den Lauf + hĂ€ngen bzw. an der Pending-Timer-PrĂŒfung scheitern (reproduziert auch + ganz ohne die neue UI). Genau deshalb hat auch der bestehende + Download-Knopf im Download-Tab keinen eigenen Widget-Test — die + Download-Logik selbst ist ĂŒber `yt_download_test.dart` schon ausfĂŒhrlich + abgedeckt, an `YtDownloadService` wurde nichts geĂ€ndert. +- ✅ **Getestet:** 574 Tests grĂŒn (vorher 557), `flutter analyze` ohne + Befund. Neu: Tests fĂŒr `youtubeVideoId`/Thumbnail-Ableitung, fĂŒr + `YtSearchService.suchen` (Anmeldung, Erfolg, 401, Fehlertext, Timeout, + neue Suche ersetzt alte Treffer) und zwei Widget-Tests fĂŒr den neuen Tab + (Anmelde-Hinweis, Suchergebnisse mit Titel/Dauer). + ### 🔒 Worktree-Lock gegen Doppelarbeit (2026-08-25) Aus der Workflow-Analyse desselben Tages (Dustin↔Hermes↔Claude, siehe diff --git a/android/app/src/main/kotlin/de/baka/melo/AudioEffectsBridge.kt b/android/app/src/main/kotlin/de/baka/melo/AudioEffectsBridge.kt index 9d1a27e..8cfcbab 100644 --- a/android/app/src/main/kotlin/de/baka/melo/AudioEffectsBridge.kt +++ b/android/app/src/main/kotlin/de/baka/melo/AudioEffectsBridge.kt @@ -6,6 +6,7 @@ import android.media.audiofx.AudioEffect import android.media.audiofx.BassBoost import android.media.audiofx.Equalizer import android.media.audiofx.Virtualizer +import android.provider.Settings import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -116,14 +117,36 @@ class AudioEffectsBridge(private val activity: Activity) { /** * Oeffnet die Klangeffekt-Oberflaeche des Systems — auf POCO/Xiaomi ist - * das die Dolby-Atmos-Seite. Gibt false zurueck, wenn kein System die - * Anzeige unterstuetzt. + * das die Dolby-Atmos-Seite unter Einstellungen -> Ton. Gibt false + * zurueck, wenn kein System eine passende Anzeige unterstuetzt. + * + * MIUI/HyperOS registriert KEINE Activity fuer den Android-Standard-Intent + * [AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL] — resolveActivity + * liefert dort immer null, obwohl Dolby Atmos vorhanden ist ("kein + * System-Klangeffekte" trotz vorhandenem Dolby Atmos auf dem POCO X7 Pro). + * Der Xiaomi-Weg geht deshalb direkt ueber die System-Ton-Einstellungen, + * in denen Dolby Atmos als Eintrag liegt. */ private fun openSystemPanel(): Boolean { - val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) + val xiaomiTonEinstellungen = Intent() + .setClassName("com.android.settings", "com.android.settings.Settings\$SoundSettingsActivity") + if (tryStartActivity(xiaomiTonEinstellungen)) return true + + // Universeller Fallback: bringt auf jedem Android-Geraet die + // System-eigene Ton-Seite — auf MIUI/HyperOS ebenfalls die Seite mit + // dem Dolby-Atmos-Eintrag, falls obige Xiaomi-Activity mal umbenannt wird. + if (tryStartActivity(Intent(Settings.ACTION_SOUND_SETTINGS))) return true + + // Letzter Versuch: der Android-Standard, funktioniert auf Geraeten, + // die tatsaechlich eine Effekt-Anzeige dafuer registrieren. + val standardIntent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) .putExtra(AudioEffect.EXTRA_AUDIO_SESSION, sessionId) .putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + return tryStartActivity(standardIntent) + } + + private fun tryStartActivity(intent: Intent): Boolean { if (intent.resolveActivity(activity.packageManager) == null) return false activity.startActivity(intent) return true diff --git a/lib/downloads/youtube_search_screen.dart b/lib/downloads/youtube_search_screen.dart new file mode 100644 index 0000000..a17983d --- /dev/null +++ b/lib/downloads/youtube_search_screen.dart @@ -0,0 +1,240 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../library/library_service.dart'; +import '../services/baka_auth.dart'; +import '../services/media_store.dart'; +import '../services/yt_download_service.dart'; +import '../services/yt_search_service.dart'; +import '../shared/theme.dart'; +import 'download_einordnung.dart'; + +/// Extra-Tab (zum Testen): YouTube direkt in der App durchsuchen, statt nur +/// eine Adresse einzufĂŒgen. Ergebnisse zeigen Vorschaubild, Titel und Dauer; +/// der Download-Knopf nutzt denselben Baka-Proxy-Weg wie der Download-Tab. +class YoutubeSearchScreen extends StatefulWidget { + const YoutubeSearchScreen({super.key}); + + @override + State createState() => _YoutubeSearchScreenState(); +} + +class _YoutubeSearchScreenState extends State { + final _query = TextEditingController(); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + void _suchen() { + final q = _query.text.trim(); + if (q.isEmpty) return; + FocusScope.of(context).unfocus(); + context.read().suchen(q); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final suche = context.watch(); + + return SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _query, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _suchen(), + decoration: const InputDecoration( + hintText: 'YouTube durchsuchen 
', + prefixIcon: Icon(Icons.search), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _suchen, + child: const Text('Suchen'), + ), + ], + ), + ), + if (!auth.istAngemeldet) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + 'Die Suche lĂ€uft ĂŒber den Baka-Server. DafĂŒr brauchst du ' + 'deine Baka-Anmeldung (siehe Download-Tab).', + style: TextStyle(color: MeloTheme.text2, fontSize: 13), + ), + ), + if (suche.laeuft) const LinearProgressIndicator(), + if (suche.fehler != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text(suche.fehler!, + style: const TextStyle(color: Colors.redAccent)), + ), + Expanded( + child: suche.treffer.isEmpty + ? const _Empty() + : ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: suche.treffer.length, + itemBuilder: (context, i) => + _TrefferZeile(treffer: suche.treffer[i]), + ), + ), + ], + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.smart_display_outlined, + size: 64, color: Colors.white24), + const SizedBox(height: 12), + const Text('Nach Musikvideos suchen', + style: TextStyle(color: MeloTheme.text2)), + ], + ), + ); + } +} + +class _TrefferZeile extends StatefulWidget { + const _TrefferZeile({required this.treffer}); + final YtSuchTreffer treffer; + + @override + State<_TrefferZeile> createState() => _TrefferZeileState(); +} + +class _TrefferZeileState extends State<_TrefferZeile> { + bool _laedt = false; + + Future _herunterladen() async { + final dienst = context.read(); + final lib = context.read(); + final db = context.read(); + final messenger = ScaffoldMessenger.of(context); + + setState(() => _laedt = true); + // Erst in einen App-eigenen Zwischenordner — siehe Download-Tab + // (_YouTubeBereich in downloads_screen.dart) fĂŒr denselben Ablauf. + final zwischen = Directory.systemTemp.createTempSync('melo_yt').path; + final ergebnis = + await dienst.herunterladen(widget.treffer.url, zielOrdner: zwischen); + if (!mounted) return; + + if (ergebnis == null) { + setState(() => _laedt = false); + messenger.showSnackBar( + SnackBar(content: Text(dienst.fehler ?? 'Fehlgeschlagen'))); + return; + } + + final pfad = await const MediaStore().veroeffentliche( + quellPfad: ergebnis.dateiPfad, + titel: ergebnis.titel, + kuenstler: ergebnis.kuenstler, + ); + if (!mounted) return; + setState(() => _laedt = false); + if (pfad == null) { + messenger.showSnackBar(const SnackBar( + content: + Text('Heruntergeladen, aber nicht im Musikordner ablegbar'))); + return; + } + + // Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die + // Bibliothek. Ohne gewĂ€hlte Kategorie, wie bei einem schnellen Fund. + await lib.rescan(); + await ordneDownloadEin(db, pfad, ''); + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('✅ ${ergebnis.titel} ist in deiner Musik'))); + } + } + + @override + Widget build(BuildContext context) { + final t = widget.treffer; + return ListTile( + leading: SizedBox( + width: 96, + height: 54, + child: t.thumbnailUrl == null + ? const _ThumbnailPlatzhalter() + : ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + t.thumbnailUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const _ThumbnailPlatzhalter(), + ), + ), + ), + title: Text(t.title, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: Text(_dauerLabel(t.duration)), + trailing: _laedt + ? const SizedBox( + width: MeloTheme.minTouchTarget, + height: MeloTheme.minTouchTarget, + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + : IconButton( + tooltip: 'Herunterladen', + icon: const Icon(Icons.download_for_offline_outlined), + onPressed: _herunterladen, + ), + ); + } +} + +class _ThumbnailPlatzhalter extends StatelessWidget { + const _ThumbnailPlatzhalter(); + + @override + Widget build(BuildContext context) { + return const ColoredBox( + color: MeloTheme.surfaceHigh, + child: Center( + child: Icon(Icons.smart_display_outlined, color: Colors.white24), + ), + ); + } +} + +/// "3:07" — Minuten:Sekunden, wie ĂŒberall sonst in der App. +String _dauerLabel(Duration d) { + final minuten = d.inMinutes; + final sekunden = d.inSeconds % 60; + return '$minuten:${sekunden.toString().padLeft(2, '0')}'; +} diff --git a/lib/main.dart b/lib/main.dart index 059a46d..e9fa0fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'downloads/downloads_screen.dart'; +import 'downloads/youtube_search_screen.dart'; import 'library/android_scan.dart'; import 'library/auto_scan.dart'; import 'library/category_service.dart'; @@ -27,6 +28,7 @@ import 'services/offline_mode.dart'; import 'services/navidrome_service.dart'; import 'services/sync_service.dart'; import 'services/yt_download_service.dart'; +import 'services/yt_search_service.dart'; import 'settings/app_settings.dart'; import 'shared/theme.dart'; @@ -40,6 +42,7 @@ late final AppSettings _settings; late final AudioEffects _effects; late final BakaAuth _bakaAuth; late final YtDownloadService _ytDownload; +late final YtSearchService _ytSearch; late final SyncService _sync; late final DownloadService _downloads; @@ -74,6 +77,7 @@ Future main() async { _bakaAuth = BakaAuth(); await _bakaAuth.laden(); _ytDownload = YtDownloadService(auth: _bakaAuth); + _ytSearch = YtSearchService(auth: _bakaAuth); _sync = SyncService( db: _db, cloud: MeloCloudService(auth: _bakaAuth), @@ -125,6 +129,7 @@ class MeloApp extends StatelessWidget { ChangeNotifierProvider.value(value: _effects), ChangeNotifierProvider.value(value: _bakaAuth), ChangeNotifierProvider.value(value: _ytDownload), + ChangeNotifierProvider.value(value: _ytSearch), ChangeNotifierProvider.value(value: _sync), ChangeNotifierProvider.value(value: _downloads), ], @@ -215,6 +220,7 @@ class _HomeShellState extends State with WidgetsBindingObserver { onAdresseUebernommen: () => setState(() => _geteilteAdresse = null), ), const FavoritesScreen(), + const YoutubeSearchScreen(), ]; return Scaffold( body: Column( @@ -244,6 +250,10 @@ class _HomeShellState extends State with WidgetsBindingObserver { icon: Icon(Icons.favorite_border), activeIcon: Icon(Icons.favorite), label: 'Favoriten'), + BottomNavigationBarItem( + icon: Icon(Icons.smart_display_outlined), + activeIcon: Icon(Icons.smart_display), + label: 'YT-Suche'), ], ), ), diff --git a/lib/services/yt_search_service.dart b/lib/services/yt_search_service.dart new file mode 100644 index 0000000..69ee99a --- /dev/null +++ b/lib/services/yt_search_service.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import 'baka_auth.dart'; +import 'yt_download_service.dart'; + +/// Ein Treffer der YouTube-Suche ĂŒber den Baka-Proxy. +class YtSuchTreffer { + const YtSuchTreffer( + {required this.title, required this.url, required this.duration}); + + final String title; + final String url; + final Duration duration; + + /// Vorschaubild ĂŒber YouTubes öffentliches Thumbnail-CDN — braucht keinen + /// eigenen API-Zugriff, nur die Video-ID aus [url]. + String? get thumbnailUrl { + final id = youtubeVideoId(url); + return id == null ? null : 'https://i.ytimg.com/vi/$id/mqdefault.jpg'; + } +} + +/// Video-ID aus einer YouTube-URL — deckt `watch?v=`, `youtu.be/` und +/// `/shorts/` ab. `null`, wenn sich keine ID erkennen lĂ€sst. +String? youtubeVideoId(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return null; + final ausQuery = uri.queryParameters['v']; + if (ausQuery != null && ausQuery.isNotEmpty) return ausQuery; + final segmente = uri.pathSegments; + if (segmente.isEmpty) return null; + if (uri.host.contains('youtu.be')) return segmente.first; + final shortsIndex = segmente.indexOf('shorts'); + if (shortsIndex != -1 && shortsIndex + 1 < segmente.length) { + return segmente[shortsIndex + 1]; + } + return null; +} + +/// Durchsucht YouTube ĂŒber den Baka-Proxy (`/api/search`, yt-dlp-gestĂŒtzt) — +/// dieselbe Anmeldung wie [YtDownloadService]. +class YtSearchService extends ChangeNotifier { + YtSearchService({required this.auth, http.Client? client}) + : _client = client ?? http.Client(); + + static const proxyUrl = YtDownloadService.proxyUrl; + + final BakaAuth auth; + final http.Client _client; + + bool _laeuft = false; + String? _fehler; + List _treffer = const []; + + bool get laeuft => _laeuft; + String? get fehler => _fehler; + List get treffer => _treffer; + + Future suchen(String query) async { + final q = query.trim(); + if (q.isEmpty) return; + + if (!auth.istAngemeldet) { + _treffer = const []; + _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; + notifyListeners(); + return; + } + + _laeuft = true; + _fehler = null; + notifyListeners(); + + final uri = Uri.parse('$proxyUrl/api/search') + .replace(queryParameters: {'q': q}); + final http.Response antwort; + try { + antwort = await _client + .get(uri, headers: auth.authHeader) + .timeout(const Duration(seconds: 20)); + } on TimeoutException { + _scheitere('Die Suche braucht zu lange — bitte spĂ€ter erneut versuchen'); + return; + } catch (e) { + debugPrint('YT-Suche nicht erreichbar: $e'); + _scheitere('Proxy nicht erreichbar'); + return; + } + + if (antwort.statusCode == 401) { + _scheitere('Anmeldung abgelaufen — bitte neu anmelden'); + return; + } + if (antwort.statusCode != 200) { + _scheitere(_fehlerText(antwort)); + return; + } + + try { + final roh = jsonDecode(antwort.body) as List; + _treffer = [ + for (final eintrag in roh.cast>()) + YtSuchTreffer( + title: eintrag['title'] as String? ?? '', + url: eintrag['url'] as String? ?? '', + duration: + Duration(seconds: (eintrag['duration'] as num?)?.toInt() ?? 0), + ), + ]; + } catch (e) { + _scheitere('Antwort des Servers nicht lesbar'); + return; + } + + _laeuft = false; + notifyListeners(); + } + + void _scheitere(String text) { + _fehler = text; + _treffer = const []; + _laeuft = false; + notifyListeners(); + } + + String _fehlerText(http.Response antwort) { + try { + final j = jsonDecode(antwort.body) as Map; + final text = j['error'] as String?; + if (text != null && text.isNotEmpty) return text; + } catch (_) { + // Kein JSON — dann eben der Statuscode. + } + return 'Proxy-Fehler (${antwort.statusCode})'; + } +} diff --git a/test/downloads/youtube_search_screen_test.dart b/test/downloads/youtube_search_screen_test.dart new file mode 100644 index 0000000..aeb5826 --- /dev/null +++ b/test/downloads/youtube_search_screen_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/downloads/youtube_search_screen.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/library_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/yt_download_service.dart'; +import 'package:melo/services/yt_search_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + client: MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)), + speicher: _MemorySpeicher(), + ); + await auth.anmelden('Baka', 'geheim'); + return auth; +} + +Widget _wrap({ + required MeloDb db, + required BakaAuth auth, + required YtSearchService search, + required YtDownloadService download, +}) { + return MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: LibraryService(db)), + ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: search), + ChangeNotifierProvider.value(value: download), + ], + child: const MaterialApp(home: Scaffold(body: YoutubeSearchScreen())), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('ohne Anmeldung erscheint der Hinweis auf die Baka-Anmeldung', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final auth = BakaAuth( + client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()); + final search = YtSearchService(auth: auth); + final download = YtDownloadService(auth: auth); + + await tester.pumpWidget(_wrap( + db: db, auth: auth, search: search, download: download)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Baka-Anmeldung'), findsOneWidget); + expect(find.text('Nach Musikvideos suchen'), findsOneWidget); + + await db.close(); + }); + + testWidgets('Suche zeigt Titel und Dauer der Treffer', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final auth = await _angemeldeteAuth(); + final search = YtSearchService( + auth: auth, + client: MockClient((req) async { + expect(req.url.queryParameters['q'], 'roses'); + return http.Response( + jsonEncode([ + { + 'title': 'Neoni - Roses', + 'url': 'https://youtu.be/dQw4w9WgXcQ', + 'duration': 187, + }, + ]), + 200); + }), + ); + final download = YtDownloadService(auth: auth); + + await tester.pumpWidget(_wrap( + db: db, auth: auth, search: search, download: download)); + + await tester.enterText(find.byType(TextField), 'roses'); + await tester.tap(find.text('Suchen')); + await tester.pumpAndSettle(); + + expect(find.text('Neoni - Roses'), findsOneWidget); + expect(find.text('3:07'), findsOneWidget); + expect(find.byIcon(Icons.download_for_offline_outlined), findsOneWidget); + + await db.close(); + }); + + // Kein Widget-Test fĂŒr den Download-Knopf selbst: YtDownloadService. + // herunterladen() kombiniert Future.any mit .timeout() — dessen Timer + // bleibt unter Flutters fake_async-Testbindung immer als "pending" stehen + // (auch ganz ohne dieses Widget reproduzierbar) und lĂ€sst den Testlauf + // entweder hĂ€ngen oder an der Pending-Timer-PrĂŒfung scheitern. Aus + // demselben Grund hat auch der bestehende Download-Knopf in + // _YouTubeBereich (downloads_screen.dart) keinen eigenen Widget-Test — + // die Download-Logik selbst ist ĂŒber yt_download_test.dart ausfĂŒhrlich + // abgedeckt. +} diff --git a/test/hauptmenue_test.dart b/test/hauptmenue_test.dart index 03b6e04..2f08869 100644 --- a/test/hauptmenue_test.dart +++ b/test/hauptmenue_test.dart @@ -14,6 +14,7 @@ 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'; +import 'package:melo/services/yt_search_service.dart'; import 'package:melo/settings/app_settings.dart'; import 'package:melo/shared/theme.dart'; import 'package:provider/provider.dart'; @@ -29,8 +30,14 @@ class _MemorySpeicher implements TokenSpeicher { Future loeschen(String key) async => werte.remove(key); } -/// Die vier Bereiche des HauptmenĂŒs, in dieser Reihenfolge. -const hauptmenue = ['Meine Musik', 'Suchen', 'Download', 'Favoriten']; +/// Die fĂŒnf Bereiche des HauptmenĂŒs, in dieser Reihenfolge. +const hauptmenue = [ + 'Meine Musik', + 'Suchen', + 'Download', + 'Favoriten', + 'YT-Suche' +]; void main() { late MeloDb db; @@ -41,6 +48,7 @@ void main() { late AppSettings einstellungen; late OfflineMode offline; late YtDownloadService ytDownload; + late YtSearchService ytSearch; late DownloadService downloads; late SyncService sync; MeloAudioHandler? handler; @@ -55,6 +63,7 @@ void main() { einstellungen = AppSettings(); offline = OfflineMode(); ytDownload = YtDownloadService(auth: auth); + ytSearch = YtSearchService(auth: auth); downloads = DownloadService(db: db, navidrome: NavidromeService()); sync = SyncService(db: db, cloud: MeloCloudService(auth: auth)); }); @@ -87,6 +96,7 @@ void main() { ChangeNotifierProvider.value(value: offline), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: ytDownload), + ChangeNotifierProvider.value(value: ytSearch), ChangeNotifierProvider.value(value: downloads), ChangeNotifierProvider.value(value: sync), ], diff --git a/test/home_shell_test.dart b/test/home_shell_test.dart index 29c6b36..c5b87e7 100644 --- a/test/home_shell_test.dart +++ b/test/home_shell_test.dart @@ -14,6 +14,7 @@ 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'; +import 'package:melo/services/yt_search_service.dart'; import 'package:melo/settings/app_settings.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -32,7 +33,7 @@ void main() { setUp(() => SharedPreferences.setMockInitialValues({})); testWidgets('Untere Navigation folgt der Referenz-UI: ' - 'Meine Musik, Suchen, Download, Favoriten', (tester) async { + 'Meine Musik, Suchen, Download, Favoriten, YT-Suche', (tester) async { final db = MeloDb(NativeDatabase.memory()); final lib = LibraryService(db); final playlists = PlaylistService(db); @@ -51,6 +52,8 @@ void main() { ChangeNotifierProvider(create: (_) => auth), ChangeNotifierProvider( create: (_) => YtDownloadService(auth: auth)), + ChangeNotifierProvider( + create: (_) => YtSearchService(auth: auth)), ChangeNotifierProvider( create: (_) => DownloadService(db: db, navidrome: NavidromeService())), ChangeNotifierProvider( @@ -64,7 +67,7 @@ void main() { final bar = tester.widget( find.byType(BottomNavigationBar)); expect(bar.items.map((i) => i.label).toList(), - ['Meine Musik', 'Suchen', 'Download', 'Favoriten']); + ['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']); handler.dispose(); await db.close(); diff --git a/test/services/yt_search_service_test.dart b/test/services/yt_search_service_test.dart new file mode 100644 index 0000000..181fd24 --- /dev/null +++ b/test/services/yt_search_service_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/yt_search_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + final Map werte = {}; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + client: MockClient( + (_) async => http.Response(jsonEncode({'token': 'jwt-abc'}), 200)), + speicher: _MemorySpeicher(), + ); + await auth.anmelden('Baka', 'geheim'); + return auth; +} + +void main() { + group('youtubeVideoId', () { + test('liest die ID aus watch?v=', () { + expect(youtubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), + 'dQw4w9WgXcQ'); + }); + + test('liest die ID aus einem youtu.be-Kurzlink', () { + expect(youtubeVideoId('https://youtu.be/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); + }); + + test('liest die ID aus einem /shorts/-Link', () { + expect(youtubeVideoId('https://www.youtube.com/shorts/dQw4w9WgXcQ'), + 'dQw4w9WgXcQ'); + }); + + test('liefert null fĂŒr eine URL ohne erkennbare ID', () { + expect(youtubeVideoId('https://example.com/video'), isNull); + }); + + test('liefert null fĂŒr keine gĂŒltige URL', () { + expect(youtubeVideoId(''), isNull); + }); + }); + + group('YtSuchTreffer.thumbnailUrl', () { + test('baut die Thumbnail-Adresse aus der Video-ID', () { + const treffer = YtSuchTreffer( + title: 'Test', + url: 'https://youtu.be/abc123', + duration: Duration(seconds: 10), + ); + expect(treffer.thumbnailUrl, 'https://i.ytimg.com/vi/abc123/mqdefault.jpg'); + }); + + test('ist null ohne erkennbare Video-ID', () { + const treffer = YtSuchTreffer( + title: 'Test', + url: 'https://example.com/x', + duration: Duration(seconds: 10), + ); + expect(treffer.thumbnailUrl, isNull); + }); + }); + + group('YtSearchService.suchen', () { + test('ohne Anmeldung wird gar nicht erst gefragt', () async { + var aufrufe = 0; + final dienst = YtSearchService( + auth: BakaAuth( + client: MockClient((_) async => http.Response('', 500)), + speicher: _MemorySpeicher()), + client: MockClient((_) async { + aufrufe++; + return http.Response('[]', 200); + }), + ); + + await dienst.suchen('roses'); + + expect(dienst.fehler, 'Bitte zuerst beim Baka-Konto anmelden'); + expect(dienst.treffer, isEmpty); + expect(aufrufe, 0); + }); + + test('leere Suchanfrage ruft den Proxy gar nicht erst auf', () async { + var aufrufe = 0; + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + aufrufe++; + return http.Response('[]', 200); + }), + ); + + await dienst.suchen(' '); + + expect(aufrufe, 0); + }); + + test('erfolgreiche Suche fĂŒllt treffer und schickt den Bearer-Token', + () async { + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((req) async { + expect(req.url.path, '/api/search'); + expect(req.url.queryParameters['q'], 'roses'); + expect(req.headers['Authorization'], 'Bearer jwt-abc'); + return http.Response( + jsonEncode([ + { + 'title': 'Neoni - Roses', + 'url': 'https://youtu.be/abc123', + 'duration': 187, + }, + ]), + 200); + }), + ); + + await dienst.suchen('roses'); + + expect(dienst.fehler, isNull); + expect(dienst.laeuft, isFalse); + expect(dienst.treffer, hasLength(1)); + expect(dienst.treffer.first.title, 'Neoni - Roses'); + expect(dienst.treffer.first.url, 'https://youtu.be/abc123'); + expect(dienst.treffer.first.duration, const Duration(seconds: 187)); + }); + + test('401 meldet abgelaufene Anmeldung', () async { + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async => http.Response('Unauthorized', 401)), + ); + + await dienst.suchen('roses'); + + expect(dienst.fehler, 'Anmeldung abgelaufen — bitte neu anmelden'); + expect(dienst.treffer, isEmpty); + }); + + test('Proxy-Fehlermeldung wird im Klartext durchgereicht', () async { + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async => http.Response( + jsonEncode({'error': 'Suche fehlgeschlagen'}), 500)), + ); + + await dienst.suchen('roses'); + + expect(dienst.fehler, 'Suche fehlgeschlagen'); + }); + + test('ZeitĂŒberschreitung meldet sich verstĂ€ndlich', () async { + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async => throw TimeoutException('zu lange')), + ); + + await dienst.suchen('roses'); + + expect(dienst.fehler, + 'Die Suche braucht zu lange — bitte spĂ€ter erneut versuchen'); + }); + + test('eine neue Suche ersetzt die alten Treffer', () async { + var anfrage = 0; + final dienst = YtSearchService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + anfrage++; + if (anfrage == 1) { + return http.Response( + jsonEncode([ + {'title': 'Erstes', 'url': 'https://youtu.be/a', 'duration': 1} + ]), + 200); + } + return http.Response( + jsonEncode([ + {'title': 'Zweites', 'url': 'https://youtu.be/b', 'duration': 2} + ]), + 200); + }), + ); + + await dienst.suchen('erste suche'); + expect(dienst.treffer.single.title, 'Erstes'); + + await dienst.suchen('zweite suche'); + expect(dienst.treffer.single.title, 'Zweites'); + }); + }); +}