P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
891 lines
28 KiB
Dart
891 lines
28 KiB
Dart
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<DownloadsScreen> createState() => _DownloadsScreenState();
|
|
}
|
|
|
|
class _DownloadsScreenState extends State<DownloadsScreen> {
|
|
/// 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) {
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
const Ueberschrift('Download'),
|
|
SubTabs(
|
|
labels: const ['YouTube', 'Server'],
|
|
index: _subTab,
|
|
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<SubsonicAlbum> _albums = [];
|
|
List<SubsonicArtist> _artists = [];
|
|
bool _loading = false;
|
|
bool _laedtNach = false;
|
|
/// 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 (_nav.istVerbunden && mounted) {
|
|
_loadAlbums();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> _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: 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<List<SubsonicSong>> Function() holeTitel;
|
|
|
|
@override
|
|
State<_LadeKnopf> createState() => _LadeKnopfState();
|
|
}
|
|
|
|
class _LadeKnopfState extends State<_LadeKnopf> {
|
|
bool _holt = false;
|
|
|
|
Future<void> _antippen() async {
|
|
final dienst = context.read<DownloadService>();
|
|
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<SubsonicSong> 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 = <String>[];
|
|
if (neu > 0) teile.add('$neu Titel heruntergeladen');
|
|
if (dienst.fehlgeschlagen > 0) {
|
|
teile.add('${dienst.fehlgeschlagen} fehlgeschlagen');
|
|
}
|
|
if (teile.isEmpty) return 'War schon heruntergeladen';
|
|
return teile.join(' · ');
|
|
}
|
|
|
|
Future<bool> _bestaetigt(List<SubsonicSong> titel) async {
|
|
final mb = geschaetzteBytes([for (final t in titel) t.dauerSekunden]) ~/
|
|
(1024 * 1024);
|
|
final ja = await showDialog<bool>(
|
|
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<DownloadService>();
|
|
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<SpeicherOrt> _orte = const [];
|
|
|
|
/// Gewählter Datenträger; `null` = Android entscheidet (nur ein Ort da).
|
|
String? _volume;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ladeEinstellungen();
|
|
_uebernimmAdresse();
|
|
}
|
|
|
|
@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<void> _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<void> _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<void> _anmelden() async {
|
|
final auth = context.read<BakaAuth>();
|
|
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<void> _herunterladen() async {
|
|
final dienst = context.read<YtDownloadService>();
|
|
final lib = context.read<LibraryService>();
|
|
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<MeloDb>();
|
|
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<BakaAuth>();
|
|
final dienst = context.watch<YtDownloadService>();
|
|
|
|
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: MeloTheme.text2, 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: 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<String>(
|
|
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),
|
|
Text(dienst.status ?? 'Läuft …',
|
|
style: const TextStyle(color: MeloTheme.text2, fontSize: 12)),
|
|
],
|
|
if (_meldung != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(_meldung!,
|
|
style: const TextStyle(color: MeloTheme.text2, 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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<CategoryService>().alleNamen;
|
|
return Autocomplete<String>(
|
|
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(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|