Behebt die Findings aus dem abschließenden Gesamt-Branch-Review für feature/youtube-gast-zugang, alle in einem Rutsch: - KRITISCH: Gast-Modus war eine Einbahnstraße — einmal "Als Gast fortfahren" getippt, war _AnmeldeDialog (einziger Baka-Login-Einstieg, auch für Melo-Cloud-Sync) dauerhaft unerreichbar. Neu: GastZugang.verwerfen() plus "Anmelden"-Knopf neben der Gast-Statuszeile in beiden Screens. - WICHTIG: zugriffOk prüfte den rohen gast.hatToken statt istGastModus — ein alter Gast-Token konnte den Auto-Login-Rückfallweg für Server-User verdecken. istGastModus wird jetzt vor zugriffOk berechnet und dort verwendet. - WICHTIG: CHANGELOG ergänzt — der Server-Endpunkt POST /api/guest-token fehlt noch (separates Vorhaben). - Doppel-Tap-Schutz für den Gast-Button in beiden Screens. - Widersprüchlicher Hinweistext im YT-Suche-Tab (verlangte Baka-Login direkt über dem Gast-Button) korrigiert. - Zwei Gast-Tests in youtube_search_screen_test.dart bekommen jetzt echtes gast:-Wiring in YtSearchService/YtDownloadService. - 2 neue Regressionstests fürs Anmelden-Escape-Hatch. Getestet: volle Suite grün (602 Tests, 1 skipped, 0 failed), flutter analyze ohne Befund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
342 lines
11 KiB
Dart
342 lines
11 KiB
Dart
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/download_service.dart';
|
|
import '../services/gast_zugang.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();
|
|
|
|
/// Server-User (siehe _YouTubeBereich in downloads_screen.dart) brauchen
|
|
/// hier keinen Anmelde-Hinweis — die Anmeldung passiert im Hintergrund.
|
|
bool _serverUser = false;
|
|
|
|
/// Die automatische Anmeldung ist fehlgeschlagen — dann bleibt der
|
|
/// Hinweis auf die manuelle Anmeldung (siehe Download-Tab) sichtbar.
|
|
bool _autoLoginFehlgeschlagen = false;
|
|
|
|
/// Fehlertext, falls das Holen des Gast-Tokens scheitert.
|
|
String? _gastFehler;
|
|
|
|
/// Verhindert Mehrfach-Anfragen bei schnellem Doppel-Antippen.
|
|
bool _gastLaeuft = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pruefeServerUser();
|
|
}
|
|
|
|
Future<void> _pruefeServerUser() async {
|
|
final nav = context.read<DownloadService>().navidrome;
|
|
final auth = context.read<BakaAuth>();
|
|
final ergebnis = await pruefeServerUser(nav, auth);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_serverUser = ergebnis.serverUser;
|
|
_autoLoginFehlgeschlagen = ergebnis.autoLoginFehlgeschlagen;
|
|
});
|
|
}
|
|
|
|
Future<void> _alsGastFortfahren() async {
|
|
if (_gastLaeuft) return;
|
|
final gast = context.read<GastZugang>();
|
|
setState(() {
|
|
_gastFehler = null;
|
|
_gastLaeuft = true;
|
|
});
|
|
final fehler = await gast.holeToken();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_gastLaeuft = false;
|
|
if (fehler != null) _gastFehler = fehler;
|
|
});
|
|
}
|
|
|
|
@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 gast = context.watch<GastZugang>();
|
|
final suche = context.watch<YtSearchService>();
|
|
final istGastModus = !_serverUser && !auth.istAngemeldet && gast.hatToken;
|
|
final zugriffOk = auth.istAngemeldet ||
|
|
(_serverUser && !_autoLoginFehlgeschlagen) ||
|
|
istGastModus;
|
|
|
|
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 (!zugriffOk) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Text(
|
|
_serverUser
|
|
? 'Die Suche läuft über den Baka-Server. Dafür brauchst '
|
|
'du deine Baka-Anmeldung (siehe Download-Tab).'
|
|
: 'Die Suche läuft über den Baka-Server. Melde dich mit '
|
|
'deinem Baka-Konto an (siehe Download-Tab) oder '
|
|
'nutze sie eingeschränkt als Gast.',
|
|
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
|
),
|
|
),
|
|
if (!_serverUser) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: MeloTheme.minTouchTarget + 4,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.person_outline),
|
|
label: const Text('Als Gast fortfahren (5 Downloads/Tag)',
|
|
style: TextStyle(fontSize: 16)),
|
|
onPressed: _gastLaeuft ? null : _alsGastFortfahren,
|
|
),
|
|
),
|
|
),
|
|
if (_gastFehler != null)
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Text(_gastFehler!,
|
|
style:
|
|
const TextStyle(color: MeloTheme.red, fontSize: 13)),
|
|
),
|
|
],
|
|
],
|
|
if (istGastModus)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
gast.verbleibend == null
|
|
? 'Als Gast unterwegs (5 Downloads/Tag)'
|
|
: 'Als Gast unterwegs — noch ${gast.verbleibend} von 5 heute',
|
|
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: gast.verwerfen,
|
|
child: const Text('Anmelden'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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')}';
|
|
}
|