YouTube-Downloader ueber den Baka-Proxy inkl. Anmeldung und MediaStore
Neuer YouTube-Bereich im Online-Tab: Adresse einfuegen, herunterladen, der Titel landet in Meine Musik. - BakaAuth: Anmeldung gegen baka-net.de/auth, Token verschluesselt auf dem Geraet. Der Server meldet Fehler mit HTTP 200 und Text im Rumpf, deshalb wird der Inhalt geprueft statt nur der Statuscode. - YtDownloadService: Proxy fragen, MP3 abholen, halbe Dateien aufraeumen. Kuenstler nur aus "Kuenstler - Lied", sonst leer statt geraten. - MediaStoreBridge (Kotlin): legt die Datei in Music/Melo ab und meldet sie dem MediaStore. Noetig, weil der Android-Scan sonst beim naechsten Lauf alles wieder als verschwunden markiert. - SubTabs nach shared/ gezogen (jetzt in Meine Musik und Online genutzt). 177 Tests gruen, flutter analyze ohne Befund, Release-APK baut. Der Weg ueber den echten Proxy ist noch nicht auf dem Geraet erprobt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d
This commit is contained in:
co-authored by
Claude Opus 5
parent
a625d70da8
commit
6200c77614
@@ -1,23 +1,38 @@
|
||||
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/yt_download_service.dart';
|
||||
import '../shared/sub_tabs.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 StatelessWidget {
|
||||
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 const SafeArea(
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -25,7 +40,14 @@ class DownloadsScreen extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
Expanded(child: _ServerBrowser()),
|
||||
SubTabs(
|
||||
labels: const ['YouTube', 'Server'],
|
||||
index: _subTab,
|
||||
onChanged: (i) => setState(() => _subTab = i),
|
||||
),
|
||||
Expanded(
|
||||
child: _subTab == 0 ? const _YouTubeBereich() : const _ServerBrowser(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -303,3 +325,201 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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);
|
||||
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<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: 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../playlists/playlists_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/sort_store.dart';
|
||||
import '../shared/sub_tabs.dart';
|
||||
import '../shared/sortable_song_list.dart';
|
||||
import '../shared/theme.dart';
|
||||
import 'album_list.dart';
|
||||
@@ -82,7 +83,8 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
|
||||
),
|
||||
),
|
||||
_QuickAccessRow(onFavoritesTap: widget.onFavoritesTap),
|
||||
_SubTabs(
|
||||
SubTabs(
|
||||
labels: const ['Songs', 'Künstler', 'Alben'],
|
||||
index: _subTab,
|
||||
onChanged: (i) => setState(() => _subTab = i),
|
||||
),
|
||||
@@ -171,52 +173,6 @@ class _Header extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
|
||||
/// Pille, die übrigen sind nur Text.
|
||||
class _SubTabs extends StatelessWidget {
|
||||
const _SubTabs({required this.index, required this.onChanged});
|
||||
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
static const _labels = ['Songs', 'Künstler', 'Alben'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: i == index ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_labels[i],
|
||||
style: TextStyle(
|
||||
color: i == index ? Colors.black : Colors.white70,
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
i == index ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waagerecht scrollende Schnellzugriffe (Favoriten, Wiedergabelisten, …).
|
||||
class _QuickAccessRow extends StatelessWidget {
|
||||
const _QuickAccessRow({this.onFavoritesTap});
|
||||
|
||||
@@ -13,8 +13,10 @@ import 'library/search_screen.dart';
|
||||
import 'player/audio_effects.dart';
|
||||
import 'player/audio_handler.dart';
|
||||
import 'player/mini_player.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/offline_mode.dart';
|
||||
import 'services/yt_download_service.dart';
|
||||
import 'settings/app_settings.dart';
|
||||
import 'shared/theme.dart';
|
||||
|
||||
@@ -26,6 +28,8 @@ late final OfflineMode _offlineMode;
|
||||
late final CategoryService _categories;
|
||||
late final AppSettings _settings;
|
||||
late final AudioEffects _effects;
|
||||
late final BakaAuth _bakaAuth;
|
||||
late final YtDownloadService _ytDownload;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -49,6 +53,9 @@ Future<void> main() async {
|
||||
);
|
||||
_effects = AudioEffects();
|
||||
await _effects.init();
|
||||
_bakaAuth = BakaAuth();
|
||||
await _bakaAuth.laden();
|
||||
_ytDownload = YtDownloadService(auth: _bakaAuth);
|
||||
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
|
||||
// die Klangeffekte müssen dann neu daran gehängt werden.
|
||||
_handler.androidAudioSessionIdStream.listen((id) {
|
||||
@@ -80,6 +87,8 @@ class MeloApp extends StatelessWidget {
|
||||
ChangeNotifierProvider<CategoryService>.value(value: _categories),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: _settings),
|
||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Ablage für den Anmelde-Token. Als Schnittstelle, damit Tests ohne die
|
||||
/// Plattform-Kanäle von FlutterSecureStorage auskommen.
|
||||
abstract class TokenSpeicher {
|
||||
Future<String?> lesen(String key);
|
||||
Future<void> schreiben(String key, String wert);
|
||||
Future<void> loeschen(String key);
|
||||
}
|
||||
|
||||
/// Produktions-Ablage: verschlüsselter Gerätespeicher.
|
||||
class SicherenSpeicher implements TokenSpeicher {
|
||||
const SicherenSpeicher();
|
||||
|
||||
static const _secure = FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<String?> lesen(String key) => _secure.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> schreiben(String key, String wert) =>
|
||||
_secure.write(key: key, value: wert);
|
||||
|
||||
@override
|
||||
Future<void> loeschen(String key) => _secure.delete(key: key);
|
||||
}
|
||||
|
||||
/// Anmeldung am Baka-Konto. Der YouTube-Proxy (yt.baka-net.de) akzeptiert
|
||||
/// nur Anfragen mit dem hier erzeugten Bearer-Token.
|
||||
class BakaAuth extends ChangeNotifier {
|
||||
BakaAuth({http.Client? client, TokenSpeicher? speicher})
|
||||
: _client = client ?? http.Client(),
|
||||
_speicher = speicher ?? const SicherenSpeicher();
|
||||
|
||||
static const basisUrl = 'https://baka-net.de/auth';
|
||||
static const _tokenKey = 'baka_token';
|
||||
static const _userKey = 'baka_user';
|
||||
|
||||
final http.Client _client;
|
||||
final TokenSpeicher _speicher;
|
||||
|
||||
String? _token;
|
||||
String _benutzer = '';
|
||||
|
||||
bool get istAngemeldet => _token != null && _token!.isNotEmpty;
|
||||
String get benutzer => _benutzer;
|
||||
|
||||
/// Header für Anfragen an den Proxy. Ohne Anmeldung ohne `Authorization`.
|
||||
Map<String, String> get authHeader => {
|
||||
if (istAngemeldet) 'Authorization': 'Bearer $_token',
|
||||
};
|
||||
|
||||
/// Stellt eine frühere Anmeldung wieder her (App-Start).
|
||||
Future<void> laden() async {
|
||||
_token = await _speicher.lesen(_tokenKey);
|
||||
_benutzer = await _speicher.lesen(_userKey) ?? '';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Meldet an. Gibt `null` bei Erfolg zurück, sonst den Fehlertext.
|
||||
Future<String?> anmelden(String benutzer, String passwort) async {
|
||||
final http.Response antwort;
|
||||
try {
|
||||
antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$basisUrl/login'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': benutzer, 'password': passwort}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
} catch (e) {
|
||||
debugPrint('Baka-Anmeldung fehlgeschlagen: $e');
|
||||
return 'Keine Verbindung zum Anmelde-Server';
|
||||
}
|
||||
|
||||
Map<String, dynamic> daten;
|
||||
try {
|
||||
daten = jsonDecode(antwort.body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return 'Anmelde-Server antwortet unverständlich (${antwort.statusCode})';
|
||||
}
|
||||
|
||||
final token = daten['token'] as String?;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
_token = token;
|
||||
_benutzer = benutzer;
|
||||
await _speicher.schreiben(_tokenKey, token);
|
||||
await _speicher.schreiben(_userKey, benutzer);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Der Server meldet Fehler mit Status 200 und einem Text im Rumpf.
|
||||
return daten['message'] as String? ??
|
||||
daten['error'] as String? ??
|
||||
'Anmeldung fehlgeschlagen (${antwort.statusCode})';
|
||||
}
|
||||
|
||||
Future<void> abmelden() async {
|
||||
_token = null;
|
||||
_benutzer = '';
|
||||
await _speicher.loeschen(_tokenKey);
|
||||
await _speicher.loeschen(_userKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Trägt fertige Musikdateien in den öffentlichen Musikordner von Android ein.
|
||||
///
|
||||
/// Nötig, weil der Bibliotheks-Scan auf Android über den MediaStore läuft:
|
||||
/// Songs, die dort nicht stehen, werden beim nächsten Scan als verschwunden
|
||||
/// markiert. Nebeneffekt: heruntergeladene Musik ist auch in anderen Apps da.
|
||||
class MediaStore {
|
||||
const MediaStore({this.channel = _standardKanal});
|
||||
|
||||
static const _standardKanal = MethodChannel('de.baka.melo/media_store');
|
||||
|
||||
final MethodChannel channel;
|
||||
|
||||
/// Verschiebt [quellPfad] nach `Music/Melo` und meldet die Datei dem
|
||||
/// MediaStore. Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt.
|
||||
Future<String?> veroeffentliche({
|
||||
required String quellPfad,
|
||||
required String titel,
|
||||
String? kuenstler,
|
||||
}) async {
|
||||
try {
|
||||
return await channel.invokeMethod<String>('publishAudio', {
|
||||
'sourcePath': quellPfad,
|
||||
'title': titel,
|
||||
'artist': kuenstler,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('MediaStore-Eintrag fehlgeschlagen: ${e.message}');
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
// Desktop/Tests: dort gibt es keinen MediaStore.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart';
|
||||
|
||||
/// Entfernt alles, was in einem Dateinamen Ärger macht.
|
||||
String sichererDateiname(String titel) =>
|
||||
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||
|
||||
/// Ergebnis eines geglückten Downloads.
|
||||
class YtErgebnis {
|
||||
const YtErgebnis({
|
||||
required this.dateiPfad,
|
||||
required this.titel,
|
||||
required this.kuenstler,
|
||||
required this.dauerSekunden,
|
||||
});
|
||||
|
||||
final String dateiPfad;
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final int dauerSekunden;
|
||||
}
|
||||
|
||||
/// Lädt Musik über den Baka-YouTube-Proxy herunter. Der Proxy erledigt die
|
||||
/// eigentliche Arbeit und liefert eine fertige MP3; die App speichert sie nur.
|
||||
class YtDownloadService extends ChangeNotifier {
|
||||
YtDownloadService({required this.auth, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
static const proxyUrl = 'https://yt.baka-net.de';
|
||||
|
||||
final BakaAuth auth;
|
||||
final http.Client _client;
|
||||
|
||||
bool _laeuft = false;
|
||||
String? _status;
|
||||
String? _fehler;
|
||||
|
||||
bool get laeuft => _laeuft;
|
||||
|
||||
/// Was gerade passiert — für die Anzeige während des Downloads.
|
||||
String? get status => _status;
|
||||
String? get fehler => _fehler;
|
||||
|
||||
void _melde(String text) {
|
||||
_status = text;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _scheitere(String text) {
|
||||
_fehler = text;
|
||||
_status = null;
|
||||
_laeuft = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
|
||||
/// Gibt bei Erfolg das Ergebnis zurück, sonst `null` (siehe [fehler]).
|
||||
Future<YtErgebnis?> herunterladen(String url,
|
||||
{required String zielOrdner}) async {
|
||||
_fehler = null;
|
||||
_laeuft = true;
|
||||
notifyListeners();
|
||||
|
||||
if (!auth.istAngemeldet) {
|
||||
_scheitere('Bitte zuerst beim Baka-Konto anmelden');
|
||||
return null;
|
||||
}
|
||||
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
||||
_scheitere('Das ist keine YouTube-Adresse');
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ───
|
||||
_melde('Proxy wird gefragt …');
|
||||
final http.Response antwort;
|
||||
try {
|
||||
antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$proxyUrl/api/yt-dl'),
|
||||
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'url': url}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 180));
|
||||
} catch (e) {
|
||||
debugPrint('YT-Proxy nicht erreichbar: $e');
|
||||
_scheitere('Proxy nicht erreichbar');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (antwort.statusCode == 401) {
|
||||
_scheitere('Anmeldung abgelaufen — bitte neu anmelden');
|
||||
return null;
|
||||
}
|
||||
if (antwort.statusCode != 200) {
|
||||
_scheitere(_fehlerText(antwort));
|
||||
return null;
|
||||
}
|
||||
|
||||
final daten = jsonDecode(antwort.body) as Map<String, dynamic>;
|
||||
final titel = daten['titel'] as String? ?? 'Unbekannt';
|
||||
final dauer = (daten['dauer'] as int?) ?? 0;
|
||||
final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3';
|
||||
|
||||
// ─── 2/2: fertige MP3 abholen und speichern ───
|
||||
_melde('Lade „$titel“ …');
|
||||
final ordner = Directory(zielOrdner);
|
||||
if (!await ordner.exists()) {
|
||||
await ordner.create(recursive: true);
|
||||
}
|
||||
final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3');
|
||||
|
||||
try {
|
||||
final mp3 = await _client
|
||||
.get(Uri.parse('$proxyUrl$mp3Url'), headers: auth.authHeader)
|
||||
.timeout(const Duration(seconds: 180));
|
||||
if (mp3.statusCode != 200) {
|
||||
_scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})');
|
||||
return null;
|
||||
}
|
||||
await datei.writeAsBytes(mp3.bodyBytes);
|
||||
} catch (e) {
|
||||
debugPrint('MP3-Abruf fehlgeschlagen: $e');
|
||||
// Halbe Dateien nicht liegen lassen — sonst taucht Müll in der
|
||||
// Bibliothek auf, sobald der Ordner das nächste Mal gescannt wird.
|
||||
if (await datei.exists()) {
|
||||
await datei.delete();
|
||||
}
|
||||
_scheitere('Herunterladen fehlgeschlagen');
|
||||
return null;
|
||||
}
|
||||
|
||||
_laeuft = false;
|
||||
_status = null;
|
||||
notifyListeners();
|
||||
|
||||
// yt-dlp liefert nur einen Titel. "Künstler - Lied" ist die übliche
|
||||
// Form; steht da nichts dergleichen, wird nichts geraten.
|
||||
final teile = titel.split(' - ');
|
||||
return YtErgebnis(
|
||||
dateiPfad: datei.path,
|
||||
titel: titel,
|
||||
kuenstler: teile.length >= 2 ? teile.first.trim() : '',
|
||||
dauerSekunden: dauer,
|
||||
);
|
||||
}
|
||||
|
||||
String _fehlerText(http.Response antwort) {
|
||||
try {
|
||||
final j = jsonDecode(antwort.body) as Map<String, dynamic>;
|
||||
final text = j['error'] as String?;
|
||||
if (text != null && text.isNotEmpty) return text;
|
||||
} catch (_) {
|
||||
// Kein JSON — dann eben der Statuscode.
|
||||
}
|
||||
return 'Proxy-Fehler (${antwort.statusCode})';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Unterreiter im Stil der Referenz-UI: der aktive Reiter ist eine helle
|
||||
/// Pille, die übrigen sind nur Text.
|
||||
class SubTabs extends StatelessWidget {
|
||||
const SubTabs({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(i),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: i == index ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
labels[i],
|
||||
style: TextStyle(
|
||||
color: i == index ? Colors.black : Colors.white70,
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
i == index ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user