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
@@ -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 '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<void> 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<AudioEffects>.value(value: _effects),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||
ChangeNotifierProvider<YtSearchService>.value(value: _ytSearch),
|
||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||
],
|
||||
@@ -215,6 +220,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
|
||||
),
|
||||
const FavoritesScreen(),
|
||||
const YoutubeSearchScreen(),
|
||||
];
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
@@ -244,6 +250,10 @@ class _HomeShellState extends State<HomeShell> 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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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})';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user