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
+240
View File
@@ -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')}';
}