Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen". Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner: den darf Android bei Speichernot jederzeit selbst leeren, und genau das darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt. - Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch - Rueckfrage ab 30 Titeln samt Groessenschaetzung - Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB; am laengsten nicht Gehoertes weicht zuerst - Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle entfernbar (Schema 10) - Wiedergabe nimmt erst den Download, dann den Zwischenspeicher Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB) schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien loeschen, die die laufende Warteschlange schon als Quelle eingetragen hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim Aufbau der Warteschlange statt beim Abspielen gesetzt wurde. Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings". 433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
852 lines
27 KiB
Dart
852 lines
27 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 '../library/song_media.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 'download_einordnung.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});
|
|
|
|
@override
|
|
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
|
}
|
|
|
|
class _DownloadsScreenState extends State<DownloadsScreen> {
|
|
/// 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('Download',
|
|
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<SubsonicAlbum> _albums = [];
|
|
List<SubsonicArtist> _artists = [];
|
|
bool _loading = 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: 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<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: () => _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: 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: () => _playAlbum(album),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _playAlbum(SubsonicAlbum album) async {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
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 = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
|
final uebersprungen = await handler.loadPlaylist(items);
|
|
if (mounted && uebersprungen > 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
|
);
|
|
}
|
|
} on NichtsOfflineVerfuegbar catch (e) {
|
|
// Kein Fehler, sondern die gewählte Einstellung — deshalb ohne
|
|
// "Fehler:" davor, sonst sucht der Nutzer eine Störung.
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('$e')),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text('Fehler: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _playArtist(SubsonicArtist artist) async {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
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 = [for (final s in songs) subsonicToMediaItem(s, _nav)];
|
|
final uebersprungen = await handler.loadPlaylist(items);
|
|
if (mounted && uebersprungen > 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(offlineHinweis(uebersprungen))),
|
|
);
|
|
}
|
|
} on NichtsOfflineVerfuegbar catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('$e')),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Fehler: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
|
|
@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();
|
|
}
|
|
|
|
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(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|