import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../library/library_service.dart'; import '../player/audio_handler.dart'; import '../services/baka_auth.dart'; import '../services/media_store.dart'; import '../services/navidrome_service.dart'; import '../services/server_neuheiten.dart'; import '../services/yt_download_service.dart'; import '../shared/sub_tabs.dart'; import '../shared/theme.dart'; /// Online-Tab: durchsucht den verbundenen Navidrome-Server. Abgespielte /// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert /// und stehen danach offline zur Verfügung. class DownloadsScreen extends StatefulWidget { const DownloadsScreen({super.key}); @override State createState() => _DownloadsScreenState(); } class _DownloadsScreenState extends State { /// 0 = YouTube, 1 = Server. int _subTab = 0; @override Widget build(BuildContext context) { return SafeArea( bottom: false, child: Column( children: [ const Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, 4), child: Align( alignment: Alignment.centerLeft, child: Text('Online', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)), ), ), SubTabs( labels: const ['YouTube', 'Server'], index: _subTab, onChanged: (i) => setState(() => _subTab = i), ), Expanded( child: _subTab == 0 ? const _YouTubeBereich() : 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); List _albums = []; List _artists = []; bool _loading = false; bool _showArtists = false; String? _error; @override void initState() { super.initState(); _nav.ladeGespeicherteZugangsdaten().then((_) { if (_nav.istVerbunden && mounted) { _loadAlbums(); } }); } Future _loadAlbums() async { if (!_nav.istVerbunden) { return; } setState(() { _loading = true; _error = null; }); try { final alben = await _nav.getAlben(anzahl: 50); if (mounted) { setState(() { _albums = alben; _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'); } } 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 (!_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: Colors.white54)), 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: Colors.redAccent), ); } 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 ? Colors.redAccent : Colors.white24), const SizedBox(height: 12), Text( hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white54), ), const SizedBox(height: 16), FilledButton.icon( icon: const Icon(Icons.refresh), label: const Text('Erneut versuchen'), onPressed: _loadAlbums, ), ], ), ), ); } return Column( children: [ 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 ? Colors.redAccent : Colors.grey.shade700, ), child: const Text('📀 Alben'), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: () => setState(() { _showArtists = true; if (_artists.isEmpty) _loadArtists(); }), style: FilledButton.styleFrom( backgroundColor: _showArtists ? Colors.redAccent : Colors.grey.shade700, ), 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), onTap: () => _playArtist(artist), ), ); }, ) : ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12), itemCount: _albums.length, itemBuilder: (context, i) { final album = _albums[i]; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( leading: Icon( Icons.album, size: 48, color: Colors.redAccent.withValues(alpha: 0.5), ), title: Text(album.name), subtitle: Text('${album.songCount} Songs'), onTap: () => _playAlbum(album), ), ); }, ), ), ], ); } Future _playAlbum(SubsonicAlbum album) async { final handler = context.read(); try { final songs = await _nav.getSongs(album.id); if (songs.isEmpty) { if (!mounted) { return; } final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( const SnackBar(content: Text('Album hat keine Songs')), ); return; } final items = songs .map((s) => MediaItem( id: _nav.streamUrl(s.id).toString(), title: s.titel, artist: s.kuenstler, album: s.album, duration: Duration(seconds: s.dauerSekunden), )) .toList(); await handler.loadPlaylist(items); } catch (e) { if (!mounted) { return; } final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( SnackBar(content: Text('Fehler: $e')), ); } } Future _playArtist(SubsonicArtist artist) async { final handler = context.read(); try { final songs = await _nav.getArtistSongs(artist.id); if (songs.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Künstler hat keine Songs')), ); return; } final items = songs .map((s) => MediaItem( id: _nav.streamUrl(s.id).toString(), title: s.titel, artist: s.kuenstler, album: s.album, duration: Duration(seconds: s.dauerSekunden), )) .toList(); await handler.loadPlaylist(items); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e')), ); } } } /// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download. class _YouTubeBereich extends StatefulWidget { const _YouTubeBereich(); @override State<_YouTubeBereich> createState() => _YouTubeBereichState(); } class _YouTubeBereichState extends State<_YouTubeBereich> { final _url = TextEditingController(); String? _meldung; @override void dispose() { _url.dispose(); super.dispose(); } Future _anmelden() async { final auth = context.read(); final daten = await showDialog<(String, String)>( context: context, builder: (_) => const _AnmeldeDialog(), ); if (daten == null || !mounted) return; final fehler = await auth.anmelden(daten.$1, daten.$2); if (!mounted) return; setState(() => _meldung = fehler); } 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); 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, ); if (!mounted) return; if (pfad == null) { setState(() => _meldung = 'Heruntergeladen, aber nicht im Musikordner ablegbar'); return; } _url.clear(); setState(() => _meldung = '✅ ${ergebnis.titel} — wird eingelesen …'); // Der MediaStore kennt die Datei jetzt; ein Scan holt sie in die Bibliothek. await lib.rescan(); if (mounted) { setState(() => _meldung = '✅ ${ergebnis.titel} ist in deiner Musik'); } } @override Widget build(BuildContext context) { final auth = context.watch(); final dienst = context.watch(); return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ if (!auth.istAngemeldet) ...[ const Text( 'Der Downloader läuft über den Baka-Server. Dafür brauchst du ' 'deine Baka-Anmeldung.', style: TextStyle(color: Colors.white54, fontSize: 13), ), const SizedBox(height: 12), FilledButton.icon( icon: const Icon(Icons.login), label: const Text('Beim Baka-Konto anmelden'), onPressed: _anmelden, ), ] else ...[ Row( children: [ const Icon(Icons.verified_user, size: 18, color: Colors.white38), const SizedBox(width: 8), Expanded( child: Text('Angemeldet als ${auth.benutzer}', style: const TextStyle(color: Colors.white54, 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), 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), Text(dienst.status ?? 'Läuft …', style: const TextStyle(color: Colors.white54, fontSize: 12)), ], if (_meldung != null) ...[ const SizedBox(height: 16), Text(_meldung!, style: const TextStyle(color: Colors.white70, fontSize: 13)), ], ], ); } } /// Fragt Benutzername und Passwort ab und gibt beides zurück. class _AnmeldeDialog extends StatefulWidget { const _AnmeldeDialog(); @override State<_AnmeldeDialog> createState() => _AnmeldeDialogState(); } class _AnmeldeDialogState extends State<_AnmeldeDialog> { final _benutzer = TextEditingController(text: 'Baka'); final _passwort = TextEditingController(); bool _sichtbar = false; @override void dispose() { _benutzer.dispose(); _passwort.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Baka-Konto'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _benutzer, decoration: const InputDecoration(labelText: 'Benutzername'), ), TextField( controller: _passwort, obscureText: !_sichtbar, decoration: InputDecoration( labelText: 'Passwort', suffixIcon: IconButton( icon: Icon(_sichtbar ? Icons.visibility_off : Icons.visibility), onPressed: () => setState(() => _sichtbar = !_sichtbar), ), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'), ), FilledButton( onPressed: () => Navigator.pop( context, (_benutzer.text.trim(), _passwort.text)), child: const Text('Anmelden'), ), ], ); } }