Equalizer-Fix für Xiaomi/MIUI (Dolby Atmos) + In-App-YouTube-Suche als Extra-Tab
- openSystemPanel(): Xiaomi-Sound-Settings-Intent zuerst, dann Settings.ACTION_SOUND_SETTINGS, dann Android-Standard-Intent als Fallback. Behebt "kein System-Klangeffekte" trotz Dolby Atmos auf MIUI/HyperOS (POCO X7 Pro), wo der Standard-Intent keine Activity findet. - Neuer Tab "YT-Suche": YouTube-Suche über den bestehenden /api/search-Endpunkt des Baka-Proxys, Ergebnisliste mit Thumbnail (YouTube-CDN), Titel, Dauer und Download-Knopf pro Treffer — derselbe Download-Weg wie im bestehenden Download-Tab. 574 Tests grün, flutter analyze ohne Befund, Kotlin kompiliert sauber (gradlew :app:compileDebugKotlin). Xiaomi-Fix konnte nicht auf echter Hardware getestet werden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VM2JK5mV7AL1g2Rt6H6h9w
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9e65ddcbb5
commit
f0313724d4
@@ -5,6 +5,44 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
### 🔒 Worktree-Lock gegen Doppelarbeit (2026-08-25)
|
||||||
|
|
||||||
Aus der Workflow-Analyse desselben Tages (Dustin↔Hermes↔Claude, siehe
|
Aus der Workflow-Analyse desselben Tages (Dustin↔Hermes↔Claude, siehe
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.media.audiofx.AudioEffect
|
|||||||
import android.media.audiofx.BassBoost
|
import android.media.audiofx.BassBoost
|
||||||
import android.media.audiofx.Equalizer
|
import android.media.audiofx.Equalizer
|
||||||
import android.media.audiofx.Virtualizer
|
import android.media.audiofx.Virtualizer
|
||||||
|
import android.provider.Settings
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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
|
* Oeffnet die Klangeffekt-Oberflaeche des Systems — auf POCO/Xiaomi ist
|
||||||
* das die Dolby-Atmos-Seite. Gibt false zurueck, wenn kein System die
|
* das die Dolby-Atmos-Seite unter Einstellungen -> Ton. Gibt false
|
||||||
* Anzeige unterstuetzt.
|
* 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 {
|
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_AUDIO_SESSION, sessionId)
|
||||||
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
|
.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
|
||||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
.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
|
if (intent.resolveActivity(activity.packageManager) == null) return false
|
||||||
activity.startActivity(intent)
|
activity.startActivity(intent)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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<YoutubeSearchScreen> createState() => _YoutubeSearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _YoutubeSearchScreenState extends State<YoutubeSearchScreen> {
|
||||||
|
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<YtSearchService>().suchen(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = context.watch<BakaAuth>();
|
||||||
|
final suche = context.watch<YtSearchService>();
|
||||||
|
|
||||||
|
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<void> _herunterladen() async {
|
||||||
|
final dienst = context.read<YtDownloadService>();
|
||||||
|
final lib = context.read<LibraryService>();
|
||||||
|
final db = context.read<MeloDb>();
|
||||||
|
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')}';
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'downloads/downloads_screen.dart';
|
import 'downloads/downloads_screen.dart';
|
||||||
|
import 'downloads/youtube_search_screen.dart';
|
||||||
import 'library/android_scan.dart';
|
import 'library/android_scan.dart';
|
||||||
import 'library/auto_scan.dart';
|
import 'library/auto_scan.dart';
|
||||||
import 'library/category_service.dart';
|
import 'library/category_service.dart';
|
||||||
@@ -27,6 +28,7 @@ import 'services/offline_mode.dart';
|
|||||||
import 'services/navidrome_service.dart';
|
import 'services/navidrome_service.dart';
|
||||||
import 'services/sync_service.dart';
|
import 'services/sync_service.dart';
|
||||||
import 'services/yt_download_service.dart';
|
import 'services/yt_download_service.dart';
|
||||||
|
import 'services/yt_search_service.dart';
|
||||||
import 'settings/app_settings.dart';
|
import 'settings/app_settings.dart';
|
||||||
import 'shared/theme.dart';
|
import 'shared/theme.dart';
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ late final AppSettings _settings;
|
|||||||
late final AudioEffects _effects;
|
late final AudioEffects _effects;
|
||||||
late final BakaAuth _bakaAuth;
|
late final BakaAuth _bakaAuth;
|
||||||
late final YtDownloadService _ytDownload;
|
late final YtDownloadService _ytDownload;
|
||||||
|
late final YtSearchService _ytSearch;
|
||||||
late final SyncService _sync;
|
late final SyncService _sync;
|
||||||
late final DownloadService _downloads;
|
late final DownloadService _downloads;
|
||||||
|
|
||||||
@@ -74,6 +77,7 @@ Future<void> main() async {
|
|||||||
_bakaAuth = BakaAuth();
|
_bakaAuth = BakaAuth();
|
||||||
await _bakaAuth.laden();
|
await _bakaAuth.laden();
|
||||||
_ytDownload = YtDownloadService(auth: _bakaAuth);
|
_ytDownload = YtDownloadService(auth: _bakaAuth);
|
||||||
|
_ytSearch = YtSearchService(auth: _bakaAuth);
|
||||||
_sync = SyncService(
|
_sync = SyncService(
|
||||||
db: _db,
|
db: _db,
|
||||||
cloud: MeloCloudService(auth: _bakaAuth),
|
cloud: MeloCloudService(auth: _bakaAuth),
|
||||||
@@ -125,6 +129,7 @@ class MeloApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||||
|
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||||
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||||
],
|
],
|
||||||
@@ -215,6 +220,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||||||
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
|
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
|
||||||
),
|
),
|
||||||
const FavoritesScreen(),
|
const FavoritesScreen(),
|
||||||
|
const YoutubeSearchScreen(),
|
||||||
];
|
];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -244,6 +250,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||||||
icon: Icon(Icons.favorite_border),
|
icon: Icon(Icons.favorite_border),
|
||||||
activeIcon: Icon(Icons.favorite),
|
activeIcon: Icon(Icons.favorite),
|
||||||
label: 'Favoriten'),
|
label: 'Favoriten'),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.smart_display_outlined),
|
||||||
|
activeIcon: Icon(Icons.smart_display),
|
||||||
|
label: 'YT-Suche'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<YtSuchTreffer> _treffer = const [];
|
||||||
|
|
||||||
|
bool get laeuft => _laeuft;
|
||||||
|
String? get fehler => _fehler;
|
||||||
|
List<YtSuchTreffer> get treffer => _treffer;
|
||||||
|
|
||||||
|
Future<void> 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<Map<String, dynamic>>())
|
||||||
|
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<String, dynamic>;
|
||||||
|
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})';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> werte = {};
|
||||||
|
@override
|
||||||
|
Future<String?> lesen(String key) async => werte[key];
|
||||||
|
@override
|
||||||
|
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
|
||||||
|
@override
|
||||||
|
Future<void> loeschen(String key) async => werte.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BakaAuth> _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<MeloDb>.value(value: db),
|
||||||
|
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
|
||||||
|
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
||||||
|
ChangeNotifierProvider<YtSearchService>.value(value: search),
|
||||||
|
ChangeNotifierProvider<YtDownloadService>.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.
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'package:melo/services/navidrome_service.dart';
|
|||||||
import 'package:melo/services/offline_mode.dart';
|
import 'package:melo/services/offline_mode.dart';
|
||||||
import 'package:melo/services/sync_service.dart';
|
import 'package:melo/services/sync_service.dart';
|
||||||
import 'package:melo/services/yt_download_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/settings/app_settings.dart';
|
||||||
import 'package:melo/shared/theme.dart';
|
import 'package:melo/shared/theme.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -29,8 +30,14 @@ class _MemorySpeicher implements TokenSpeicher {
|
|||||||
Future<void> loeschen(String key) async => werte.remove(key);
|
Future<void> loeschen(String key) async => werte.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge.
|
/// Die fünf Bereiche des Hauptmenüs, in dieser Reihenfolge.
|
||||||
const hauptmenue = ['Meine Musik', 'Suchen', 'Download', 'Favoriten'];
|
const hauptmenue = [
|
||||||
|
'Meine Musik',
|
||||||
|
'Suchen',
|
||||||
|
'Download',
|
||||||
|
'Favoriten',
|
||||||
|
'YT-Suche'
|
||||||
|
];
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MeloDb db;
|
late MeloDb db;
|
||||||
@@ -41,6 +48,7 @@ void main() {
|
|||||||
late AppSettings einstellungen;
|
late AppSettings einstellungen;
|
||||||
late OfflineMode offline;
|
late OfflineMode offline;
|
||||||
late YtDownloadService ytDownload;
|
late YtDownloadService ytDownload;
|
||||||
|
late YtSearchService ytSearch;
|
||||||
late DownloadService downloads;
|
late DownloadService downloads;
|
||||||
late SyncService sync;
|
late SyncService sync;
|
||||||
MeloAudioHandler? handler;
|
MeloAudioHandler? handler;
|
||||||
@@ -55,6 +63,7 @@ void main() {
|
|||||||
einstellungen = AppSettings();
|
einstellungen = AppSettings();
|
||||||
offline = OfflineMode();
|
offline = OfflineMode();
|
||||||
ytDownload = YtDownloadService(auth: auth);
|
ytDownload = YtDownloadService(auth: auth);
|
||||||
|
ytSearch = YtSearchService(auth: auth);
|
||||||
downloads = DownloadService(db: db, navidrome: NavidromeService());
|
downloads = DownloadService(db: db, navidrome: NavidromeService());
|
||||||
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
|
sync = SyncService(db: db, cloud: MeloCloudService(auth: auth));
|
||||||
});
|
});
|
||||||
@@ -87,6 +96,7 @@ void main() {
|
|||||||
ChangeNotifierProvider<OfflineMode>.value(value: offline),
|
ChangeNotifierProvider<OfflineMode>.value(value: offline),
|
||||||
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
||||||
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
||||||
|
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
|
||||||
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: sync),
|
ChangeNotifierProvider<SyncService>.value(value: sync),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:melo/services/navidrome_service.dart';
|
|||||||
import 'package:melo/services/offline_mode.dart';
|
import 'package:melo/services/offline_mode.dart';
|
||||||
import 'package:melo/services/sync_service.dart';
|
import 'package:melo/services/sync_service.dart';
|
||||||
import 'package:melo/services/yt_download_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/settings/app_settings.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -32,7 +33,7 @@ void main() {
|
|||||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
testWidgets('Untere Navigation folgt der Referenz-UI: '
|
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 db = MeloDb(NativeDatabase.memory());
|
||||||
final lib = LibraryService(db);
|
final lib = LibraryService(db);
|
||||||
final playlists = PlaylistService(db);
|
final playlists = PlaylistService(db);
|
||||||
@@ -51,6 +52,8 @@ void main() {
|
|||||||
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
ChangeNotifierProvider<BakaAuth>(create: (_) => auth),
|
||||||
ChangeNotifierProvider<YtDownloadService>(
|
ChangeNotifierProvider<YtDownloadService>(
|
||||||
create: (_) => YtDownloadService(auth: auth)),
|
create: (_) => YtDownloadService(auth: auth)),
|
||||||
|
ChangeNotifierProvider<YtSearchService>(
|
||||||
|
create: (_) => YtSearchService(auth: auth)),
|
||||||
ChangeNotifierProvider<DownloadService>(
|
ChangeNotifierProvider<DownloadService>(
|
||||||
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
|
||||||
ChangeNotifierProvider<SyncService>(
|
ChangeNotifierProvider<SyncService>(
|
||||||
@@ -64,7 +67,7 @@ void main() {
|
|||||||
final bar = tester.widget<BottomNavigationBar>(
|
final bar = tester.widget<BottomNavigationBar>(
|
||||||
find.byType(BottomNavigationBar));
|
find.byType(BottomNavigationBar));
|
||||||
expect(bar.items.map((i) => i.label).toList(),
|
expect(bar.items.map((i) => i.label).toList(),
|
||||||
['Meine Musik', 'Suchen', 'Download', 'Favoriten']);
|
['Meine Musik', 'Suchen', 'Download', 'Favoriten', 'YT-Suche']);
|
||||||
|
|
||||||
handler.dispose();
|
handler.dispose();
|
||||||
await db.close();
|
await db.close();
|
||||||
|
|||||||
@@ -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<String, String> werte = {};
|
||||||
|
@override
|
||||||
|
Future<String?> lesen(String key) async => werte[key];
|
||||||
|
@override
|
||||||
|
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
|
||||||
|
@override
|
||||||
|
Future<void> loeschen(String key) async => werte.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BakaAuth> _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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user