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:
Hermes (Server)
2026-08-25 21:03:46 +02:00
co-authored by Claude Sonnet 5
parent 9e65ddcbb5
commit f0313724d4
9 changed files with 796 additions and 7 deletions
@@ -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.
}
+12 -2
View File
@@ -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<void> 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<OfflineMode>.value(value: offline),
ChangeNotifierProvider<BakaAuth>.value(value: auth),
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
ChangeNotifierProvider<DownloadService>.value(value: downloads),
ChangeNotifierProvider<SyncService>.value(value: sync),
],
+5 -2
View File
@@ -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<BakaAuth>(create: (_) => auth),
ChangeNotifierProvider<YtDownloadService>(
create: (_) => YtDownloadService(auth: auth)),
ChangeNotifierProvider<YtSearchService>(
create: (_) => YtSearchService(auth: auth)),
ChangeNotifierProvider<DownloadService>(
create: (_) => DownloadService(db: db, navidrome: NavidromeService())),
ChangeNotifierProvider<SyncService>(
@@ -64,7 +67,7 @@ void main() {
final bar = tester.widget<BottomNavigationBar>(
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();
+204
View File
@@ -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');
});
});
}