import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../library/category_service.dart'; import '../library/database.dart'; import '../library/library_service.dart'; import '../player/audio_handler.dart'; import '../services/baka_auth.dart'; import '../services/media_store.dart'; import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../services/server_neuheiten.dart'; import '../services/yt_download_service.dart'; import '../shared/sub_tabs.dart'; import '../shared/ueberschrift.dart'; import 'download_einordnung.dart'; import 'server_titel_screen.dart'; import '../shared/theme.dart'; /// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server /// (Unterreiter). Abgespielte /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert /// und stehen danach offline zur Verfügung. class DownloadsScreen extends StatefulWidget { const DownloadsScreen({ super.key, this.adresse, this.onAdresseUebernommen, }); /// Adresse aus einem „Teilen"-Aufruf. Wird ins Eingabefeld übernommen und /// der YouTube-Unterreiter aufgeschlagen. final String? adresse; /// Meldet zurück, dass die Adresse angekommen ist — sie soll beim nächsten /// Wechsel in diesen Bereich nicht erneut auftauchen. final VoidCallback? onAdresseUebernommen; @override State createState() => _DownloadsScreenState(); } class _DownloadsScreenState extends State { /// 0 = YouTube, 1 = Server. int _subTab = 0; @override void didUpdateWidget(DownloadsScreen alt) { super.didUpdateWidget(alt); // Eine geteilte Adresse gehört zum YouTube-Bereich; wer gerade beim // Server war, soll trotzdem dort landen. if (widget.adresse != null && widget.adresse != alt.adresse) { setState(() => _subTab = 0); } } @override Widget build(BuildContext context) { // Fortschrittsanzeigen stecken jeweils nur im aktiven Unterreiter — ohne // diesen Punkt sah man nicht, dass im anderen Bereich gerade ein // Download läuft, während man selbst woanders war. final ytLaeuft = context.watch().laeuft; final serverLaeuft = context.watch().laeuft; return SafeArea( bottom: false, child: Column( children: [ const Ueberschrift('Download'), SubTabs( labels: const ['YouTube', 'Server'], index: _subTab, badges: [ ytLaeuft && _subTab != 0, serverLaeuft && _subTab != 1, ], onChanged: (i) => setState(() => _subTab = i), ), Expanded( child: _subTab == 0 ? _YouTubeBereich( adresse: widget.adresse, onAdresseUebernommen: widget.onAdresseUebernommen, ) : const _ServerBrowser(), ), ], ), ); } } class _ServerBrowser extends StatefulWidget { const _ServerBrowser(); @override State<_ServerBrowser> createState() => _ServerBrowserState(); } class _ServerBrowserState extends State<_ServerBrowser> { late final NavidromeService _nav = NavidromeService(); // Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf. late final ServerNeuheiten _neuheiten = ServerNeuheiten(holeAlben: () async => _albums); static const _seite = 50; List _albums = []; List _artists = []; bool _loading = false; bool _laedtNach = false; // Solange die gespeicherten Zugangsdaten noch geladen werden, ist // _nav.istVerbunden fälschlich noch false — ohne diesen Zustand blitzte // beim Öffnen kurz "nicht verbunden" auf, obwohl der Server verbunden ist. bool _pruefeZugangsdaten = true; /// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss. bool _alleAlbenDa = false; bool _showArtists = false; String? _error; @override void initState() { super.initState(); _nav.ladeGespeicherteZugangsdaten().then((_) { if (!mounted) return; setState(() => _pruefeZugangsdaten = false); if (_nav.istVerbunden) _loadAlbums(); }); } Future _loadAlbums() async { if (!_nav.istVerbunden) { return; } setState(() { _loading = true; _error = null; }); try { final alben = await _nav.getAlben(anzahl: _seite); if (mounted) { setState(() { _albums = alben; _alleAlbenDa = alben.length < _seite; _loading = false; }); // Erst jetzt prüfen — _albums ist gefüllt. await _neuheiten.pruefe(); if (mounted) setState(() {}); } } catch (e) { if (mounted) { setState(() { _loading = false; _error = e is NavidromeException ? e.message : 'Verbindungsfehler'; }); } debugPrint('Fehler beim Laden der Alben: $e'); } } /// Holt die nächste Seite Alben. Vorher blieb die Liste bei 50 stehen, /// ohne dass irgendetwas darauf hinwies. Future _mehrAlben() async { if (_laedtNach || _alleAlbenDa) return; setState(() => _laedtNach = true); try { final weitere = await _nav.getAlben(anzahl: _seite, ab: _albums.length); if (!mounted) return; setState(() { _albums = [..._albums, ...weitere]; _alleAlbenDa = weitere.length < _seite; _laedtNach = false; }); } catch (e) { if (!mounted) return; setState(() => _laedtNach = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Nachladen fehlgeschlagen: $e')), ); } } Future _loadArtists() async { if (!_nav.istVerbunden) return; setState(() { _loading = true; _error = null; }); try { final artists = await _nav.getArtists(); if (mounted) { setState(() { _artists = artists; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _loading = false; _error = e is NavidromeException ? e.message : 'Verbindungsfehler'; }); } debugPrint('Fehler beim Laden der Künstler: $e'); } } @override Widget build(BuildContext context) { if (_pruefeZugangsdaten) { return const Center( child: CircularProgressIndicator(color: MeloTheme.red), ); } if (!_nav.istVerbunden) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, size: 64, color: Colors.white24), const SizedBox(height: 12), const Text('Musikserver nicht verbunden', style: TextStyle(color: MeloTheme.text2)), const SizedBox(height: 12), const Text('Gehe zu Einstellungen → Navidrome zum Verbinden', style: TextStyle(color: Colors.white30, fontSize: 12)), ], ), ); } if (_loading) { return const Center( child: CircularProgressIndicator(color: MeloTheme.red), ); } if (_albums.isEmpty) { final hatFehler = _error != null; return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(hatFehler ? Icons.error_outline : Icons.album, size: 64, color: hatFehler ? MeloTheme.red : Colors.white24), const SizedBox(height: 12), Text( hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen', textAlign: TextAlign.center, style: const TextStyle(color: MeloTheme.text2), ), const SizedBox(height: 16), FilledButton.icon( icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), onPressed: _loadAlbums, ), ], ), ), ); } return Column( children: [ const _LadeFortschritt(), if (_neuheiten.neue.isNotEmpty) MaterialBanner( backgroundColor: MeloTheme.surfaceHigh, content: Text( _neuheiten.neue.length == 1 ? 'Neu auf dem Server: ${_neuheiten.neue.first.name}' : '${_neuheiten.neue.length} neue Alben auf dem Server', ), actions: [ TextButton( onPressed: () async { await _neuheiten.alsGesehenMarkieren(); if (mounted) setState(() {}); }, child: const Text('Verstanden'), ), ], ), Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Expanded( child: FilledButton( onPressed: () => setState(() { _showArtists = false; if (_albums.isEmpty) _loadAlbums(); }), style: FilledButton.styleFrom( backgroundColor: !_showArtists ? MeloTheme.red : MeloTheme.surfaceHigh, ), child: const Text('Alben'), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: () => setState(() { _showArtists = true; if (_artists.isEmpty) _loadArtists(); }), style: FilledButton.styleFrom( backgroundColor: _showArtists ? MeloTheme.red : MeloTheme.surfaceHigh, ), child: const Text('Künstler'), ), ), ], ), ), Expanded( child: _showArtists ? ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: _artists.length, itemBuilder: (context, i) { final artist = _artists[i]; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: const Icon(Icons.person, size: 48), title: Text(artist.name), trailing: _LadeKnopf( tooltip: 'Alles von ${artist.name} herunterladen', holeTitel: () => _nav.getArtistSongs(artist.id), ), onTap: () => _oeffneKuenstler(artist), ), ); }, ) : ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: _albums.length + (_alleAlbenDa ? 0 : 1), itemBuilder: (context, i) { if (i == _albums.length) { return Padding( padding: const EdgeInsets.symmetric( vertical: MeloSpace.md), child: Center( child: _laedtNach ? const CircularProgressIndicator( color: MeloTheme.red) : OutlinedButton( onPressed: _mehrAlben, child: const Text('Mehr Alben laden'), ), ), ); } final album = _albums[i]; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: Icon( Icons.album, size: 48, color: MeloTheme.red.withValues(alpha: 0.5), ), title: Text(album.name), subtitle: Text('${album.songCount} Titel'), trailing: _LadeKnopf( tooltip: 'Album herunterladen', holeTitel: () => _nav.getSongs(album.id), ), onTap: () => _oeffneAlbum(album), ), ); }, ), ), ], ); } void _oeffneAlbum(SubsonicAlbum album) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => ServerTitelScreen( titel: album.name, untertitel: album.kuenstler, coverId: album.coverId, navidrome: _nav, holeTitel: () => _nav.getSongs(album.id), ), )); } void _oeffneKuenstler(SubsonicArtist artist) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => ServerTitelScreen( titel: artist.name, navidrome: _nav, holeTitel: () => _nav.getArtistSongs(artist.id), ), )); } } /// Knopf, der die Titel hinter einem Album oder Künstler herunterlädt. /// /// [holeTitel] wird erst beim Antippen aufgerufen — die Titelliste eines /// Albums kostet einen Serveraufruf, und den für jede sichtbare Zeile im /// Voraus zu machen wäre Unfug. class _LadeKnopf extends StatefulWidget { const _LadeKnopf({required this.tooltip, required this.holeTitel}); final String tooltip; final Future> Function() holeTitel; @override State<_LadeKnopf> createState() => _LadeKnopfState(); } class _LadeKnopfState extends State<_LadeKnopf> { bool _holt = false; Future _antippen() async { final dienst = context.read(); final messenger = ScaffoldMessenger.of(context); if (dienst.laeuft) { messenger.showSnackBar( const SnackBar(content: Text('Es läuft schon ein Download')), ); return; } setState(() => _holt = true); final List titel; try { titel = await widget.holeTitel(); } catch (e) { if (mounted) setState(() => _holt = false); messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); return; } if (!mounted) return; setState(() => _holt = false); if (titel.isEmpty) { messenger.showSnackBar(const SnackBar(content: Text('Nichts zu laden'))); return; } // Ein Album geht ohne Nachfrage durch; ein ganzer Künstler kann // mehrere hundert Titel und einige Gigabyte sein. if (brauchtRueckfrage(titel.length) && !await _bestaetigt(titel)) return; // Zweite Prüfung: zwischen der ersten und jetzt lagen ein Serveraufruf // und womöglich ein Dialog. In der Zeit kann ein anderer Knopf einen // Download gestartet haben — `lade` liefert dann sofort 0, und ohne diese // Prüfung meldete die App „War schon heruntergeladen". if (dienst.laeuft) { messenger.showSnackBar( const SnackBar(content: Text('Es läuft schon ein Download')), ); return; } final neu = await dienst.lade(titel); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(_meldung(dienst, neu)))); } /// Sagt beides: was ankam und was nicht. Ein einzelner Fehlschlag darf /// nicht den Erfolg der übrigen neunzehn Titel verdecken. static String _meldung(DownloadService dienst, int neu) { final teile = []; if (neu > 0) teile.add('$neu Titel heruntergeladen'); if (dienst.fehlgeschlagen > 0) { teile.add('${dienst.fehlgeschlagen} fehlgeschlagen'); } if (teile.isEmpty) { // Kein Titel, kein Fehlschlag: entweder war wirklich schon alles da, // oder der Download ist gar nicht erst gestartet (z.B. keine // Verbindung zum Server) — dann steckt der Grund in dienst.fehler. return dienst.fehler ?? 'War schon heruntergeladen'; } return teile.join(' · '); } Future _bestaetigt(List titel) async { final mb = geschaetzteBytes([for (final t in titel) t.dauerSekunden]) ~/ (1024 * 1024); final ja = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Wirklich herunterladen?'), content: Text('${titel.length} Titel, geschätzt etwa $mb MB.\n' 'Die Schätzung ist grob — der Server nennt die genaue Größe ' 'erst beim Abruf.'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Herunterladen'), ), ], ), ); return ja ?? false; } @override Widget build(BuildContext context) { if (_holt) { return const SizedBox( width: MeloTheme.minTouchTarget, height: MeloTheme.minTouchTarget, child: Center( child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } return IconButton( tooltip: widget.tooltip, icon: const Icon(Icons.download_for_offline_outlined), onPressed: _antippen, ); } } /// Zeigt den laufenden Download über der Liste, mit Abbruch. class _LadeFortschritt extends StatelessWidget { const _LadeFortschritt(); @override Widget build(BuildContext context) { final dienst = context.watch(); if (!dienst.laeuft) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.fromLTRB( MeloSpace.md, MeloSpace.sm, MeloSpace.sm, 0), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Lädt ${(dienst.fertig + 1).clamp(1, dienst.gesamt)} ' 'von ${dienst.gesamt}' '${dienst.aktuell == null ? '' : ' — ${dienst.aktuell}'}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: MeloTheme.text2, fontSize: 12), ), const SizedBox(height: MeloSpace.xs), LinearProgressIndicator(value: dienst.anteil), ], ), ), TextButton( onPressed: dienst.abbrechen, child: const Text('Abbrechen'), ), ], ), ); } } /// Merkt sich, ob der Proxy seine YouTube-Cookies benutzen soll. const _cookiesSchluessel = 'yt_cookies'; /// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download. class _YouTubeBereich extends StatefulWidget { const _YouTubeBereich({this.adresse, this.onAdresseUebernommen}); final String? adresse; final VoidCallback? onAdresseUebernommen; @override State<_YouTubeBereich> createState() => _YouTubeBereichState(); } class _YouTubeBereichState extends State<_YouTubeBereich> { final _url = TextEditingController(); String? _meldung; /// Cookies des Servers mitschicken — nötig für altersbeschränkte Videos. bool _cookies = true; List _orte = const []; /// Gewählter Datenträger; `null` = Android entscheidet (nur ein Ort da). String? _volume; /// Wer am Melo-Server angemeldet ist, braucht keine eigene /// Baka-Anmeldung mehr — nur Gäste (kein Server-Account) sehen sie. bool _serverUser = false; /// Die automatische Anmeldung ist fehlgeschlagen (z. B. abweichendes /// Baka-Passwort) — dann bleibt der manuelle Anmelde-Weg als Rückfalloption /// sichtbar, sonst käme so ein User nie mehr an den Dialog heran. bool _autoLoginFehlgeschlagen = false; @override void initState() { super.initState(); _ladeEinstellungen(); _uebernimmAdresse(); _pruefeServerUser(); } Future _pruefeServerUser() async { final nav = context.read().navidrome; final auth = context.read(); final ergebnis = await pruefeServerUser(nav, auth); if (!mounted) return; setState(() { _serverUser = ergebnis.serverUser; _autoLoginFehlgeschlagen = ergebnis.autoLoginFehlgeschlagen; }); } @override void didUpdateWidget(_YouTubeBereich alt) { super.didUpdateWidget(alt); if (widget.adresse != null && widget.adresse != alt.adresse) { _uebernimmAdresse(); } } /// Trägt eine geteilte Adresse ins Feld — ohne den Download zu starten. /// Kategorie und Speicherort will man vorher noch wählen können. void _uebernimmAdresse() { final adresse = widget.adresse; if (adresse == null) return; _url.text = adresse; widget.onAdresseUebernommen?.call(); } Future _ladeEinstellungen() async { final prefs = await SharedPreferences.getInstance(); final orte = await const MediaStore().speicherOrte(); if (!mounted) return; setState(() { _cookies = prefs.getBool(_cookiesSchluessel) ?? true; _orte = orte; _volume = orte.length > 1 ? orte.first.name : null; }); } /// Kategorie, in die der heruntergeladene Titel wandert. Leer = keine. final _kategorie = TextEditingController(); Future _setzeCookies(bool an) async { setState(() => _cookies = an); final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_cookiesSchluessel, an); } @override void dispose() { _url.dispose(); _kategorie.dispose(); super.dispose(); } Future _anmelden() async { final nav = context.read().navidrome; await showDialog( context: context, builder: (_) => _AnmeldeDialog(benutzer: nav.benutzer), ); } Future _herunterladen() async { final dienst = context.read(); final lib = context.read(); final url = _url.text.trim(); if (url.isEmpty) return; setState(() => _meldung = null); // Erst in einen App-eigenen Zwischenordner — erst wenn die Datei // vollständig da ist, wandert sie in den öffentlichen Musikordner. final zwischen = Directory.systemTemp.createTempSync('melo_yt').path; final ergebnis = await dienst.herunterladen(url, zielOrdner: zwischen, cookies: _cookies); if (!mounted) return; if (ergebnis == null) { setState(() => _meldung = dienst.fehler); return; } final pfad = await const MediaStore().veroeffentliche( quellPfad: ergebnis.dateiPfad, titel: ergebnis.titel, kuenstler: ergebnis.kuenstler, volume: _volume, ); if (!mounted) return; if (pfad == null) { setState(() => _meldung = 'Heruntergeladen, aber nicht im Musikordner ablegbar'); return; } final db = context.read(); final kategorie = _kategorie.text; _url.clear(); setState(() => _meldung = '✅ ${ergebnis.titel} — wird eingelesen …'); // Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die Bibliothek. await lib.rescan(); // Erst nach dem Scan: vorher gibt es die Songzeile noch nicht. Hier wird // auch das Album-Tag von YouTube verworfen (siehe ordneDownloadEin). await ordneDownloadEin(db, pfad, kategorie); if (mounted) { setState(() => _meldung = kategorie.trim().isEmpty ? '✅ ${ergebnis.titel} ist in deiner Musik' : '✅ ${ergebnis.titel} ist in „${kategorie.trim()}“'); } } @override Widget build(BuildContext context) { final auth = context.watch(); final dienst = context.watch(); // Server-User gelten sofort als berechtigt — die Anmeldung passiert für // sie im Hintergrund (_pruefeServerUser). Nur wenn die automatische // Anmeldung nachweislich fehlgeschlagen ist, bleibt der manuelle Weg // als Rückfalloption sichtbar — sonst gäbe es keinen Weg mehr zurück. final zugriffOk = auth.istAngemeldet || (_serverUser && !_autoLoginFehlgeschlagen); return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ if (!zugriffOk) ...[ Text( _autoLoginFehlgeschlagen ? 'Die automatische Anmeldung hat nicht geklappt — vermutlich ' 'weicht dein Server-Passwort vom Baka-Konto ab. Einmal ' 'manuell mit dem aktuellen Baka-Passwort anmelden, dann ' 'gleicht sich das Passwort wieder ab.' : 'Der Downloader läuft über den Baka-Server. Dafür brauchst ' 'du deine Baka-Anmeldung.', style: const TextStyle(color: MeloTheme.text2, fontSize: 13), ), const SizedBox(height: 12), SizedBox( width: double.infinity, height: MeloTheme.minTouchTarget + 4, child: FilledButton.icon( icon: const Icon(Icons.login), label: const Text('Beim Baka-Konto anmelden', style: TextStyle(fontSize: 16)), onPressed: _anmelden, ), ), ] else ...[ if (!_serverUser) ...[ Row( children: [ const Icon(Icons.verified_user, size: 18, color: MeloTheme.text3), const SizedBox(width: 8), Expanded( child: Text('Angemeldet als ${auth.benutzer}', style: const TextStyle(color: MeloTheme.text2, fontSize: 13)), ), TextButton( onPressed: auth.abmelden, child: const Text('Abmelden'), ), ], ), const SizedBox(height: 8), ], TextField( controller: _url, enabled: !dienst.laeuft, decoration: const InputDecoration( labelText: 'YouTube-Adresse', hintText: 'https://youtu.be/…', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), _KategorieFeld(controller: _kategorie, aktiv: !dienst.laeuft), SwitchListTile( dense: true, contentPadding: EdgeInsets.zero, title: const Text('YouTube-Cookies des Servers verwenden'), subtitle: const Text('Nötig für altersbeschränkte Videos'), value: _cookies, onChanged: dienst.laeuft ? null : _setzeCookies, ), if (_orte.length > 1) ...[ const SizedBox(height: 4), DropdownButtonFormField( initialValue: _volume, decoration: const InputDecoration( labelText: 'Speicherort', border: OutlineInputBorder(), ), items: [ for (final ort in _orte) DropdownMenuItem( value: ort.name, child: Text(ort.beschreibung), ), ], onChanged: dienst.laeuft ? null : (wert) => setState(() => _volume = wert), ), ], const SizedBox(height: 12), FilledButton.icon( icon: const Icon(Icons.download), label: const Text('Herunterladen'), onPressed: dienst.laeuft ? null : _herunterladen, ), ], if (dienst.laeuft) ...[ const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 8), Row( children: [ Expanded( child: Text(dienst.status ?? 'Läuft …', style: const TextStyle( color: MeloTheme.text2, fontSize: 12)), ), // Ohne diesen Knopf war ein hängender Download (bis zu zwei // mal 180s) nur durch Schließen der App zu stoppen. TextButton( onPressed: dienst.abbrechen, child: const Text('Abbrechen'), ), ], ), ], if (_meldung != null) ...[ const SizedBox(height: 16), Text(_meldung!, style: TextStyle( color: _meldung!.startsWith('✅') ? MeloTheme.text2 : MeloTheme.red, fontSize: 13)), ], ], ); } } /// Fragt Benutzername und Passwort ab und gibt beides zurück. class _AnmeldeDialog extends StatefulWidget { const _AnmeldeDialog({this.benutzer = ''}); /// Bereits bekannter Navidrome-Benutzername (leer, falls keiner bekannt) — /// vorausgefüllt statt immer "Baka" zu zeigen, egal wer die App nutzt. final String benutzer; @override State<_AnmeldeDialog> createState() => _AnmeldeDialogState(); } class _AnmeldeDialogState extends State<_AnmeldeDialog> { late final _benutzer = TextEditingController(text: widget.benutzer); final _passwort = TextEditingController(); bool _sichtbar = false; bool _verbindet = false; String? _fehler; @override void dispose() { _benutzer.dispose(); _passwort.dispose(); super.dispose(); } Future _anmelden() async { setState(() { _verbindet = true; _fehler = null; }); final auth = context.read(); final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text); if (!mounted) return; if (fehler == null) { // Navidrome- und Baka-Konto teilen sich die Zugangsdaten (siehe // navidrome_service.dart) — ohne diese Übernahme würde der nächste // Auto-Login nach Ablauf des Baka-Tokens wieder mit dem alten // Passwort scheitern. final nav = context.read().navidrome; await nav.speichereZugangsdaten( nav.serverUrl, _benutzer.text.trim(), _passwort.text); if (!mounted) return; Navigator.pop(context); return; } setState(() { _verbindet = false; _fehler = fehler; }); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Baka-Konto'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _benutzer, enabled: !_verbindet, decoration: const InputDecoration(labelText: 'Benutzername'), ), TextField( controller: _passwort, enabled: !_verbindet, obscureText: !_sichtbar, decoration: InputDecoration( labelText: 'Passwort', suffixIcon: IconButton( icon: Icon(_sichtbar ? Icons.visibility_off : Icons.visibility), onPressed: () => setState(() => _sichtbar = !_sichtbar), ), ), ), if (_fehler != null) ...[ const SizedBox(height: 8), Text(_fehler!, style: const TextStyle(color: MeloTheme.red, fontSize: 12)), ], if (_verbindet) const Padding( padding: EdgeInsets.only(top: 12), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: MeloTheme.red), ), ), ], ), actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), actions: [ TextButton( style: TextButton.styleFrom( minimumSize: const Size(88, MeloTheme.minTouchTarget)), onPressed: _verbindet ? null : () => Navigator.pop(context), child: const Text('Abbrechen', style: TextStyle(fontSize: 16)), ), FilledButton( style: FilledButton.styleFrom( minimumSize: const Size(120, MeloTheme.minTouchTarget)), onPressed: _verbindet ? null : _anmelden, child: const Text('Anmelden', style: TextStyle(fontSize: 16)), ), ], ); } } /// Eingabefeld für die Kategorie eines Downloads: schlägt die Kategorien der /// Bibliothek vor, nimmt aber auch eine neue entgegen. /// /// Nötig, weil das Album-Tag von YouTube verworfen wird — ohne diese Angabe /// landet der Titel ohne Kategorie in der Bibliothek. class _KategorieFeld extends StatelessWidget { const _KategorieFeld({required this.controller, required this.aktiv}); final TextEditingController controller; final bool aktiv; @override Widget build(BuildContext context) { final vorhanden = context.watch().alleNamen; return Autocomplete( optionsBuilder: (wert) => kategorieVorschlaege(wert.text, vorhanden), onSelected: (wert) => controller.text = wert, fieldViewBuilder: (context, feld, focus, onSubmit) { // Der Autocomplete führt einen eigenen Controller; seinen Text an // den unseren durchreichen, damit der Download ihn sieht. feld.addListener(() => controller.text = feld.text); return TextField( controller: feld, focusNode: focus, enabled: aktiv, decoration: const InputDecoration( labelText: 'Kategorie (optional)', hintText: 'z. B. Nightcore', helperText: 'Leer lassen = ohne Kategorie', border: OutlineInputBorder(), ), ); }, ); } }