diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index f20fda8..bf1df27 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -2,11 +2,16 @@
# Git Pre-Commit Hook: Melo App Qualitäts-Gate
# Läuft automatisch vor jedem git commit
+export FLUTTER="/home/dustin/development/flutter/bin/flutter"
+export DART="/home/dustin/development/flutter/bin/dart"
+export PATH="$HOME/development/flutter/bin:$PATH"
+
echo "🔍 Melo App Qualitäts-Gate gestartet..."
# 1. Dart Analyse
-echo -n " Prüfe: dart analyze ... "
-ANALYZE_OUTPUT=$(flutter analyze 2>&1)
+echo -n " Prüfe: flutter analyze ... "
+cd "$(git rev-parse --show-toplevel)" || exit 1
+ANALYZE_OUTPUT=$($FLUTTER analyze 2>&1)
if echo "$ANALYZE_OUTPUT" | grep -q "No issues found"; then
echo "✅"
else
@@ -15,20 +20,9 @@ else
exit 1
fi
-# 2. Dart Formatierung
-echo -n " Prüfe: dart format ... "
-FORMAT_OUTPUT=$(dart format --set-exit-if-changed lib/ 2>&1)
-if [ $? -eq 0 ]; then
- echo "✅"
-else
- echo "❌ Formatierungsfehler! Führe 'dart format lib/' aus."
- echo "$FORMAT_OUTPUT"
- exit 1
-fi
-
-# 3. Tests
+# 2. Tests
echo -n " Prüfe: flutter test ... "
-TEST_OUTPUT=$(flutter test 2>&1)
+TEST_OUTPUT=$($FLUTTER test 2>&1)
if echo "$TEST_OUTPUT" | grep -q "All tests passed"; then
echo "✅"
elif echo "$TEST_OUTPUT" | grep -q "No tests found"; then
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index e4b1d0c..310c791 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
android {
namespace = "com.melo.melo_app"
- compileSdk = flutter.compileSdkVersion
+ compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 54762b2..21e9731 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,12 +4,14 @@
+
+ android:icon="@mipmap/ic_launcher"
+ android:usesCleartextTraffic="true">
createState() => _DownloadScreenState();
+}
+
+class _DownloadScreenState extends State {
+ final _urlController = TextEditingController();
+ bool _ladt = false;
+ String? _fehler;
+ String? _erfolg;
+ String _speicherOrt = 'App-intern';
+ bool _speichertInDownloads = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _ladeSpeicherPfad();
+ }
+
+ Future _ladeSpeicherPfad() async {
+ final prefs = await SharedPreferences.getInstance();
+ final inDownloads = prefs.getBool('download_in_downloads') ?? false;
+ if (inDownloads) {
+ final dir = await getDownloadsDirectory();
+ if (dir != null) {
+ final pfad = '${dir.path}/Melo';
+ widget.downloader.setzeSpeicherPfad(pfad);
+ setState(() {
+ _speichertInDownloads = true;
+ _speicherOrt = '⬇ Downloads/Melo';
+ });
+ }
+ }
+ }
+
+ Future _ordnerDialog() async {
+ final auswahl = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ backgroundColor: MeloTheme.dunkel1,
+ title: const Text('Speicherort', style: TextStyle(color: Colors.white, fontSize: 16)),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _optionTile(ctx, '📁 App-intern (Music/)', 'intern',
+ icon: Icons.phone_android),
+ const Divider(color: MeloTheme.dunkel2),
+ _optionTile(ctx, '⬇ Downloads/Melo', 'downloads',
+ icon: Icons.download),
+ ],
+ ),
+ ),
+ );
+
+ if (auswahl == null) return;
+
+ final prefs = await SharedPreferences.getInstance();
+ if (auswahl == 'downloads') {
+ final dir = await getDownloadsDirectory();
+ if (dir != null) {
+ final pfad = '${dir.path}/Melo';
+ await prefs.setBool('download_in_downloads', true);
+ widget.downloader.setzeSpeicherPfad(pfad);
+ setState(() {
+ _speichertInDownloads = true;
+ _speicherOrt = '⬇ Downloads/Melo';
+ });
+ }
+ } else {
+ await prefs.setBool('download_in_downloads', false);
+ widget.downloader.setzeSpeicherPfad('');
+ setState(() {
+ _speichertInDownloads = false;
+ _speicherOrt = '📁 App-intern (Music/)';
+ });
+ }
+ }
+
+ Widget _optionTile(BuildContext ctx, String label, String wert,
+ {required IconData icon}) {
+ return ListTile(
+ leading: Icon(icon, color: MeloTheme.rot, size: 20),
+ title: Text(label,
+ style: const TextStyle(color: Colors.white, fontSize: 13)),
+ onTap: () => Navigator.pop(ctx, wert),
+ );
+ }
+
+ void _starteDownload() async {
+ final input = _urlController.text.trim();
+ if (input.isEmpty) {
+ setState(() => _fehler = 'Bitte eine YouTube-URL einfügen');
+ return;
+ }
+
+ setState(() { _ladt = true; _fehler = null; _erfolg = null; });
+ MeloLogger().aktion('download_start', {'url': input.substring(0, 40)});
+
+ final anzahl = await widget.downloader.downloadBatch(input);
+ if (mounted) {
+ setState(() {
+ _ladt = false;
+ if (anzahl > 0) {
+ _erfolg = '✅ $anzahl Song${anzahl > 1 ? 's' : ''} gespeichert';
+ } else {
+ _fehler = widget.downloader.fehler ?? 'Download fehlgeschlagen';
+ }
+ });
+ widget.onSongsChanged();
+ }
+ }
+
+ void _abbrechen() {
+ widget.downloader.abbrechen();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: MeloTheme.schwarz,
+ appBar: AppBar(
+ backgroundColor: MeloTheme.dunkel1,
+ title: const Row(children: [
+ Icon(Icons.download, color: MeloTheme.rot, size: 20),
+ SizedBox(width: 8),
+ Text('Downloads', style: TextStyle(color: Colors.white, fontSize: 18)),
+ ]),
+ actions: [
+ if (_erfolg != null || _fehler != null)
+ IconButton(
+ icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
+ onPressed: () => setState(() { _fehler = null; _erfolg = null; _urlController.clear(); }),
+ ),
+ ],
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ children: [
+ // ─── Zielordner ───
+ GestureDetector(
+ onTap: _ladt ? null : _ordnerDialog,
+ child: Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: MeloTheme.dunkel2),
+ ),
+ child: Row(children: [
+ const Icon(Icons.folder, color: MeloTheme.rot, size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Speicherort', style: TextStyle(color: Colors.grey, fontSize: 11)),
+ Text(_speicherOrt, style: const TextStyle(color: Colors.white, fontSize: 13)),
+ ],
+ ),
+ ),
+ const Icon(Icons.chevron_right, color: Colors.grey, size: 18),
+ ]),
+ ),
+ ),
+ const SizedBox(height: 12),
+
+ // ─── Eingabefeld ───
+ TextField(
+ controller: _urlController,
+ enabled: !_ladt,
+ maxLines: 3,
+ style: const TextStyle(color: Colors.white, fontSize: 14),
+ decoration: InputDecoration(
+ hintText: 'YouTube-URL hier einfügen...\n\nMehrere URLs: eine pro Zeile\nPlaylists werden erkannt 🎯',
+ hintStyle: const TextStyle(color: Colors.grey, fontSize: 13),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
+ filled: true,
+ fillColor: MeloTheme.dunkel1,
+ contentPadding: const EdgeInsets.all(16),
+ ),
+ ),
+ const SizedBox(height: 12),
+
+ // ─── Download- & Abbruch-Button ───
+ Row(
+ children: [
+ Expanded(
+ flex: _ladt ? 3 : 1,
+ child: SizedBox(
+ height: 48,
+ child: ElevatedButton.icon(
+ onPressed: _ladt ? null : _starteDownload,
+ icon: _ladt
+ ? const SizedBox(width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
+ : const Icon(Icons.download, size: 20),
+ label: Text(_ladt ? 'Lade herunter...' : '⬇ Download'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: MeloTheme.rot,
+ foregroundColor: Colors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ),
+ ),
+ if (_ladt) ...[
+ const SizedBox(width: 8),
+ Expanded(
+ flex: 1,
+ child: SizedBox(
+ height: 48,
+ child: ElevatedButton.icon(
+ onPressed: _abbrechen,
+ icon: const Icon(Icons.cancel, size: 20),
+ label: const Text('Stop'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.red.shade800,
+ foregroundColor: Colors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ const SizedBox(height: 16),
+
+ // ─── Live-Status via ListenableBuilder ───
+ if (_ladt)
+ ListenableBuilder(
+ listenable: widget.downloader,
+ builder: (context, _) {
+ final status = widget.downloader.aktuellerTitel;
+ final fortschritt = widget.downloader.fortschritt;
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(children: [
+ if (status != null) ...[
+ Text(status,
+ style: const TextStyle(color: Colors.white70, fontSize: 13),
+ textAlign: TextAlign.center),
+ ],
+ if (fortschritt > 0) ...[
+ const SizedBox(height: 8),
+ LinearProgressIndicator(
+ value: fortschritt,
+ color: MeloTheme.rot,
+ backgroundColor: MeloTheme.dunkel2),
+ const SizedBox(height: 4),
+ Text('${(fortschritt * 100).toStringAsFixed(0)}%',
+ style: const TextStyle(color: Colors.grey, fontSize: 11)),
+ ],
+ ]),
+ );
+ },
+ ),
+
+ // ─── Erfolg ───
+ if (_erfolg != null)
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
+ ),
+ child: Row(children: [
+ const Icon(Icons.check_circle, color: Colors.green, size: 24),
+ const SizedBox(width: 12),
+ Expanded(child: Text(_erfolg!, style: const TextStyle(color: Colors.green, fontSize: 13))),
+ ]),
+ ),
+
+ // ─── Fehler ───
+ if (_fehler != null)
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
+ ),
+ child: Row(children: [
+ const Icon(Icons.error_outline, color: Colors.red, size: 24),
+ const SizedBox(width: 12),
+ Expanded(child: Text(_fehler!, style: const TextStyle(color: Colors.red, fontSize: 13))),
+ ]),
+ ),
+
+ const Spacer(),
+
+ // ─── Tipps ───
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('💡 Tipps', style: TextStyle(color: Colors.grey, fontSize: 12, fontWeight: FontWeight.w600)),
+ const SizedBox(height: 6),
+ _tipp('Einzel-URL: youtube.com/watch?v=...'),
+ _tipp('Playlist: youtube.com/playlist?list=...'),
+ _tipp('Mehrere: eine URL pro Zeile'),
+ _tipp('Cooldown: 5s zwischen Downloads ⏱'),
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _tipp(String text) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 4),
+ child: Row(children: [
+ const Text('• ', style: TextStyle(color: MeloTheme.rot, fontSize: 12)),
+ Expanded(child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 11))),
+ ]),
+ );
+ }
+}
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 3bef5ff..7f76914 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -9,10 +9,9 @@ import '../widgets/melo_header.dart';
import '../widgets/statistik_card.dart';
import '../widgets/tag_leiste.dart';
import '../widgets/song_tile.dart';
-import '../widgets/recent_widget.dart';
-import '../widgets/tag_stats_widget.dart';
import '../widgets/navidrome_browser.dart';
import '../widgets/playlist_sheet.dart';
+import 'download_screen.dart';
class MeloHome extends StatefulWidget {
const MeloHome({super.key});
@@ -237,102 +236,15 @@ class _MeloHomeState extends State {
}
Future _zeigeDownloadDialog() async {
- final controller = TextEditingController();
- final url = await showDialog(
- context: context,
- builder: (ctx) => AlertDialog(
- backgroundColor: MeloTheme.dunkel1,
- title: const Text('⬇ YouTube-Link einfügen', style: TextStyle(color: Colors.white)),
- content: TextField(
- controller: controller,
- autofocus: true,
- style: const TextStyle(color: Colors.white),
- decoration: const InputDecoration(
- hintText: 'https://youtube.com/watch?v=...',
- hintStyle: TextStyle(color: Colors.grey),
- border: OutlineInputBorder(),
- ),
- ),
- actions: [
- TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
- TextButton(
- onPressed: () => Navigator.pop(ctx, controller.text),
- child: const Text('Download', style: TextStyle(color: MeloTheme.rot)),
- ),
- ],
- ),
- );
-
- if (url == null || url.isEmpty) return;
- if (!mounted) return;
-
- showDialog(
- context: context,
- barrierDismissible: false,
- builder: (ctx) {
- Timer? timer;
- return StatefulBuilder(
- builder: (ctx, setDialogState) {
- timer ??= Timer.periodic(const Duration(milliseconds: 200), (_) {
- if (ctx.mounted) setDialogState(() {});
- });
-
- _vm.downloader.downloadVonUrl(url).then((song) {
- timer?.cancel();
- if (song != null && ctx.mounted) {
- setDialogState(() {});
- Future.delayed(const Duration(milliseconds: 800), () {
- if (ctx.mounted) Navigator.pop(ctx);
- _vm.ladeSongs();
- });
- } else if (ctx.mounted) {
- Future.delayed(const Duration(seconds: 3), () {
- if (ctx.mounted) Navigator.pop(ctx);
- });
- }
- });
-
- final fehler = _vm.downloader.fehler;
- final fortschritt = _vm.downloader.fortschritt;
- final statusText = fehler ?? _vm.downloader.aktuellerTitel ?? 'Song wird heruntergeladen...';
-
- return AlertDialog(
- backgroundColor: MeloTheme.dunkel1,
- title: const Text('⬇ Download', style: TextStyle(color: Colors.white)),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (fehler == null)
- LinearProgressIndicator(
- value: fortschritt > 0 ? fortschritt : null,
- color: MeloTheme.rot,
- )
- else
- const Icon(Icons.error, color: Colors.red, size: 40),
- const SizedBox(height: 12),
- Text(statusText,
- style: TextStyle(fontSize: 13, color: fehler != null ? Colors.red : Colors.white70),
- textAlign: TextAlign.center,
- ),
- if (fortschritt > 0) ...[
- const SizedBox(height: 8),
- Text('${(fortschritt * 100).toStringAsFixed(0)}%',
- style: const TextStyle(fontSize: 12, color: Colors.grey)),
- ],
- ],
- ),
- );
- },
- );
- },
- );
+ // Statt Dialog → zum Download-Tab wechseln
+ setState(() => _aktiverTab = 1);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _vm,
- builder: (_, __) {
+ builder: (_, _) {
if (_vm.ladt) {
return const Scaffold(
backgroundColor: MeloTheme.schwarz,
@@ -348,9 +260,14 @@ class _MeloHomeState extends State {
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: SafeArea(
- child: Column(
+ child: _aktiverTab == 1
+ ? DownloadScreen(
+ downloader: _vm.downloader,
+ onSongsChanged: _vm.ladeSongs,
+ )
+ : Column(
children: [
- MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche),
+ MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser),
// ─── EIN/AUS: RecentWidget (Zuletzt gehört) ───
// Entferne die Kommentarzeichen um RecentWidget zu aktivieren:
// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart
index eb242d7..2d94d40 100644
--- a/lib/services/download_service.dart
+++ b/lib/services/download_service.dart
@@ -1,117 +1,303 @@
+import 'dart:async';
+import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
-import 'package:youtube_explode_dart/youtube_explode_dart.dart';
+import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
+import 'melo_logger.dart';
-class DownloadService {
+/// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter.
+/// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder.
+class DownloadService extends ChangeNotifier {
static final DownloadService _instanz = DownloadService._();
factory DownloadService() => _instanz;
DownloadService._();
- final YoutubeExplode _yt = YoutubeExplode();
+ static const String _proxyBasisUrl = 'https://yt.baka-net.de';
+ static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b';
+ static const Map _authHeader = {'X-API-Key': _apiKey};
+
final DbHelper _db = DbHelper();
bool _ladt = false;
double _fortschritt = 0;
String? _aktuellerTitel;
String? _fehler;
+ String _standardPfad = '';
+ Completer? _abbruchCompleter;
bool get ladt => _ladt;
double get fortschritt => _fortschritt;
String? get aktuellerTitel => _aktuellerTitel;
String? get fehler => _fehler;
+ String get standardPfad => _standardPfad;
+
+ /// Synchroner Abbruch-Check – prüft ob der Abbruch-Completer completed wurde.
+ /// KEIN async/await, KEIN Duration.zero-Trick – einfach bool.
+ bool get sollAbbrechen => _abbruchCompleter?.isCompleted ?? false;
+
+ void setzeSpeicherPfad(String pfad) {
+ _standardPfad = pfad;
+ }
+
+ void abbrechen() {
+ if (_abbruchCompleter != null && !_abbruchCompleter!.isCompleted) {
+ _abbruchCompleter!.complete();
+ }
+ _abbruchCompleter = null;
+ }
void _resetStatus() {
_ladt = true;
_fortschritt = 0;
_aktuellerTitel = null;
_fehler = null;
+ _abbruchCompleter = Completer();
+ notifyListeners();
}
+ /// Song von YouTube herunterladen
Future downloadVonUrl(String url) async {
_resetStatus();
+ return _downloadEinzeln(url);
+ }
+
+ /// Mehrere URLs/Playlists nacheinander mit Cooldown
+ Future downloadBatch(String input, {int cooldownSekunden = 5}) async {
+ _resetStatus();
+ MeloLogger().zustand('download_batch_start', {
+ 'input_len': input.length,
+ });
+
+ final urls = _extrahiereUrls(input);
+ MeloLogger().zustand('urls_extrahiert', {
+ 'anzahl': urls.length,
+ });
+
+ if (urls.isEmpty) {
+ _fehler = 'Keine gültigen URLs gefunden';
+ _ladt = false;
+ notifyListeners();
+ MeloLogger().fehler('keine_urls', 'Input: ${input.substring(0, min(input.length, 80))}');
+ return 0;
+ }
+
+ int erfolgreich = 0;
+ _aktuellerTitel = '0/${urls.length} – Starte...';
+ notifyListeners();
+
+ for (int i = 0; i < urls.length; i++) {
+ if (sollAbbrechen) break;
+
+ _aktuellerTitel = '${i + 1}/${urls.length} – ${urls[i]._titelKurz()}';
+ notifyListeners();
+
+ MeloLogger().zustand('download_einzeln_start', {
+ 'index': i,
+ 'gesamt': urls.length,
+ 'url': urls[i].url.substring(0, min(urls[i].url.length, 60)),
+ });
+
+ final song = await _downloadEinzeln(urls[i].url);
+ if (song != null) {
+ erfolgreich++;
+ MeloLogger().zustand('download_einzeln_erfolg', {
+ 'titel': song.titel,
+ 'index': i,
+ 'erfolgreich': erfolgreich,
+ });
+ } else {
+ MeloLogger().zustand('download_einzeln_fehlgeschlagen', {
+ 'index': i,
+ 'fehler': _fehler ?? 'unbekannt',
+ });
+ }
+
+ // Cooldown zwischen Downloads (außer beim letzten)
+ if (i < urls.length - 1 && cooldownSekunden > 0) {
+ _aktuellerTitel = '✅ $erfolgreich/${urls.length} – Warte ${cooldownSekunden}s...';
+ notifyListeners();
+ for (int s = 0; s < cooldownSekunden; s++) {
+ if (sollAbbrechen) break;
+ await Future.delayed(const Duration(seconds: 1));
+ }
+ }
+ }
+
+ _ladt = false;
+ if (sollAbbrechen) {
+ _aktuellerTitel = '❌ Abgebrochen ($erfolgreich fertig)';
+ } else {
+ _aktuellerTitel = '✅ $erfolgreich/${urls.length} Songs geladen';
+ }
+ notifyListeners();
+ MeloLogger().zustand('download_batch_ende', {
+ 'erfolgreich': erfolgreich,
+ 'gesamt': urls.length,
+ 'abgebrochen': sollAbbrechen,
+ });
+ return erfolgreich;
+ }
+
+ /// Einzelnen Song über den yt-proxy herunterladen
+ Future _downloadEinzeln(String url) async {
+ String? dateiPfad;
try {
// URL-Validierung
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
_fehler = 'Keine gültige YouTube-URL';
_ladt = false;
+ notifyListeners();
return null;
}
- String titel = '';
- String kuenstler = '';
- int dauer = 0;
+ // ─── 1/3: Proxy anfragen (Metadaten + Download) ───
+ _aktuellerTitel = 'Proxy wird kontaktiert...';
+ notifyListeners();
+
+ final stopwatch = Stopwatch()..start();
+ http.Response dlAntwort;
- // Video-Info mit Timeout
- _aktuellerTitel = 'YouTube wird kontaktiert...';
try {
- final video = await _yt.videos.get(url).timeout(const Duration(seconds: 15));
- titel = video.title;
- kuenstler = video.author;
- dauer = video.duration?.inSeconds ?? 0;
- _aktuellerTitel = 'Video gefunden: $titel';
+ dlAntwort = await _retryHttpPost(
+ Uri.parse('$_proxyBasisUrl/api/yt-dl'),
+ headers: {
+ ..._authHeader,
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode({'url': url}),
+ maxVersuche: 2,
+ timeout: const Duration(seconds: 150),
+ );
+ stopwatch.stop();
} catch (e) {
- _fehler = 'Timeout/Fehler bei Video-Info: ${e.toString()}';
- debugPrint('Video-Info Fehler (vollständig): $e');
+ stopwatch.stop();
+ MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl',
+ fehler: e.toString(), dauerMs: stopwatch.elapsedMilliseconds);
+ _fehler = 'Proxy nicht erreichbar: ${e.toString().split('\n').first}';
_ladt = false;
+ notifyListeners();
return null;
}
- // Manifest abrufen
- _aktuellerTitel = 'Audio-Stream wird ermittelt... (2/4)';
- AudioStreamInfo? audio;
+ if (sollAbbrechen) {
+ _ladt = false;
+ notifyListeners();
+ return null;
+ }
+
+ if (dlAntwort.statusCode != 200) {
+ String fehlerText;
+ try {
+ final fehlerJson = jsonDecode(dlAntwort.body);
+ fehlerText = fehlerJson['error'] ?? 'Unbekannter Proxy-Fehler';
+ // Cooldown-Info anzeigen wenn vorhanden
+ if (fehlerJson.containsKey('cooldown')) {
+ final cd = fehlerJson['cooldown'] as int;
+ fehlerText += ' (Cooldown: ${cd ~/ 60} min)';
+ }
+ } catch (_) {
+ fehlerText = dlAntwort.body.isNotEmpty
+ ? dlAntwort.body.substring(0, min(dlAntwort.body.length, 200))
+ : 'Unbekannter Proxy-Fehler';
+ }
+ _fehler = 'Proxy-Fehler (${dlAntwort.statusCode}): $fehlerText';
+ _ladt = false;
+ notifyListeners();
+ debugPrint('Proxy-Fehler: ${dlAntwort.body}');
+ MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl',
+ statusCode: dlAntwort.statusCode, fehler: fehlerText);
+ return null;
+ }
+
+ final daten = jsonDecode(dlAntwort.body);
+ final titel = daten['titel'] ?? 'Unbekannt';
+ final dauer = daten['dauer'] ?? 0;
+ final dateiname = daten['dateiname'] ?? 'audio.mp3';
+ final mp3Url = daten['mp3_url'] ?? '/api/dl/$dateiname';
+ final filesize = daten['filesize'] ?? 0;
+
+ MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl',
+ statusCode: 200, dauerMs: stopwatch.elapsedMilliseconds);
+
+ _aktuellerTitel = 'Video gefunden: $titel';
+ notifyListeners();
+
+ // ─── 2/3: MP3 vom Proxy herunterladen ───
+ final mbStr = filesize > 0
+ ? ' (${(filesize / 1024 / 1024).toStringAsFixed(1)} MB)'
+ : '';
+ _aktuellerTitel = 'Lade MP3 herunter$mbStr...';
+ notifyListeners();
+
+ final dir = _standardPfad.isNotEmpty
+ ? Directory(_standardPfad)
+ : Directory('${(await getApplicationDocumentsDirectory()).path}/music');
+ if (!await dir.exists()) await dir.create(recursive: true);
+
+ // Sicheren Dateinamen erstellen
+ final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
+ final lokalerName = '${safeName.isEmpty ? "song" : safeName}.mp3';
+ dateiPfad = '${dir.path}/$lokalerName';
+
+ final stopwatch2 = Stopwatch()..start();
try {
- final manifest = await _yt.videos.streamsClient
- .getManifest(url).timeout(const Duration(seconds: 15));
- final streams = manifest.audioOnly.toList();
- if (streams.isEmpty) {
- _fehler = 'Kein Audio-Stream verfügbar';
+ final mp3Antwort = await _retryHttpGet(
+ Uri.parse('$_proxyBasisUrl$mp3Url'),
+ headers: _authHeader,
+ maxVersuche: 2,
+ timeout: const Duration(seconds: 120),
+ );
+ stopwatch2.stop();
+
+ if (mp3Antwort.statusCode != 200) {
+ _fehler = 'MP3-Download fehlgeschlagen (${mp3Antwort.statusCode})';
_ladt = false;
+ notifyListeners();
+ MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
+ statusCode: mp3Antwort.statusCode, fehler: _fehler);
return null;
}
- audio = streams.reduce((a, b) => a.bitrate.bitsPerSecond > b.bitrate.bitsPerSecond ? a : b);
- } catch (e) {
- _fehler = 'Stream-Fehler: ${e.toString()}';
- debugPrint('Stream Manifest Fehler (vollständig): $e');
- _ladt = false;
- return null;
- }
- // Zielpfad
- _aktuellerTitel = 'Speicher wird vorbereitet... (3/4)';
- final dir = await getApplicationDocumentsDirectory();
- final musikDir = Directory('${dir.path}/music');
- if (!await musikDir.exists()) await musikDir.create(recursive: true);
- final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
- final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp4';
- final dateiPfad = '${musikDir.path}/$dateiName';
-
- // Download
- _aktuellerTitel = 'Lade herunter... (4/4)';
- try {
- final fileStream = _yt.videos.streamsClient.get(audio);
final file = File(dateiPfad);
- final sink = file.openWrite();
- int downloaded = 0;
- final total = audio.size.totalBytes;
- await for (final chunk in fileStream) {
- sink.add(chunk);
- downloaded += chunk.length;
- _fortschritt = total > 0 ? downloaded / total : 0.0;
- }
- await sink.flush();
- await sink.close();
+ await file.writeAsBytes(mp3Antwort.bodyBytes);
+
+ MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
+ statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds);
+
} catch (e) {
- _fehler = 'Download-Fehler: ${e.toString()}';
- debugPrint('Download Stream Fehler (vollständig): $e');
+ stopwatch2.stop();
+ _fehler = 'Download-Fehler: ${e.toString().split('\n').first}';
_ladt = false;
+ notifyListeners();
+ debugPrint('MP3-Download Fehler: $e');
+ MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
+ fehler: e.toString(), dauerMs: stopwatch2.elapsedMilliseconds);
+ _raeumeAuf(dateiPfad);
return null;
}
- // Song speichern
+ if (sollAbbrechen) {
+ _raeumeAuf(dateiPfad);
+ _ladt = false;
+ notifyListeners();
+ return null;
+ }
+
+ // ─── 3/3: Song speichern ───
+ _aktuellerTitel = 'Speichere in Datenbank...';
+ notifyListeners();
+
+ // Künstler aus Titel extrahieren (yt-dlp liefert nur Titel)
+ String kuenstler = 'YouTube';
+ final titelTeile = titel.split(' - ');
+ if (titelTeile.length >= 2) {
+ kuenstler = titelTeile.first.trim();
+ }
+
final song = Song(
titel: titel,
kuenstler: kuenstler,
@@ -125,17 +311,141 @@ class DownloadService {
await _db.songEinfuegen(song);
_ladt = false;
- _aktuellerTitel = '✅ ${song.titel} heruntergeladen';
+ _aktuellerTitel = '✅ ${song.titel}';
+ notifyListeners();
return song;
+
} catch (e) {
- _fehler = 'Fehler: ${e.toString()}';
- debugPrint('Download allgemeiner Fehler (vollständig): $e');
+ _fehler = 'Fehler: ${e.toString().split('\n').first}';
+ debugPrint('Download allgemeiner Fehler: $e');
+ MeloLogger().fehler('download_einzeln_crash', e);
+ _raeumeAuf(dateiPfad);
_ladt = false;
+ notifyListeners();
return null;
}
}
+ /// HTTP POST mit Retry (exponentieller Backoff)
+ Future _retryHttpPost(
+ Uri url, {
+ Map? headers,
+ String? body,
+ int maxVersuche = 2,
+ Duration timeout = const Duration(seconds: 150),
+ }) async {
+ Object? lastError;
+ for (int versuch = 0; versuch < maxVersuche; versuch++) {
+ try {
+ final response = await http
+ .post(url, headers: headers, body: body)
+ .timeout(timeout);
+ return response;
+ } on TimeoutException catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('⏱ POST timeout, retry ${versuch + 1}/$maxVersuche...');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 2));
+ }
+ } on SocketException catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('🔌 POST socket error, retry ${versuch + 1}/$maxVersuche...');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 3));
+ }
+ } catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('⚠️ POST error, retry ${versuch + 1}/$maxVersuche: $e');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 2));
+ }
+ }
+ }
+ throw lastError!;
+ }
+
+ /// HTTP GET mit Retry (exponentieller Backoff)
+ Future _retryHttpGet(
+ Uri url, {
+ Map? headers,
+ int maxVersuche = 2,
+ Duration timeout = const Duration(seconds: 120),
+ }) async {
+ Object? lastError;
+ for (int versuch = 0; versuch < maxVersuche; versuch++) {
+ try {
+ final response = await http
+ .get(url, headers: headers)
+ .timeout(timeout);
+ return response;
+ } on TimeoutException catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('⏱ GET timeout, retry ${versuch + 1}/$maxVersuche...');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 2));
+ }
+ } on SocketException catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('🔌 GET socket error, retry ${versuch + 1}/$maxVersuche...');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 3));
+ }
+ } catch (e) {
+ lastError = e;
+ if (versuch < maxVersuche - 1) {
+ debugPrint('⚠️ GET error, retry ${versuch + 1}/$maxVersuche: $e');
+ await Future.delayed(Duration(seconds: (versuch + 1) * 2));
+ }
+ }
+ }
+ throw lastError!;
+ }
+
+ /// Halb-heruntergeladene Datei löschen
+ void _raeumeAuf(String? pfad) {
+ if (pfad == null) return;
+ try {
+ final file = File(pfad);
+ if (file.existsSync()) {
+ file.deleteSync();
+ debugPrint('🧹 Aufgeräumt: $pfad');
+ }
+ } catch (e) {
+ debugPrint('Aufräumen fehlgeschlagen: $e');
+ }
+ }
+
+ @override
void dispose() {
- _yt.close();
+ abbrechen();
+ super.dispose();
}
}
+
+int min(int a, int b) => a < b ? a : b;
+
+/// Helper: URLs aus Eingabe extrahieren (einzeln, Playlist, mehrzeilig)
+List<_UrlEintrag> _extrahiereUrls(String input) {
+ final result = <_UrlEintrag>[];
+ // FIX: '\n' statt '\\n' – echte Newlines splitten
+ final zeilen = input.split('\n');
+ for (final zeile in zeilen) {
+ final trimmed = zeile.trim();
+ if (trimmed.isEmpty) continue;
+
+ if (trimmed.contains('playlist') || trimmed.contains('list=')) {
+ result.add(_UrlEintrag(trimmed, '📋 Playlist'));
+ } else if (trimmed.contains('youtube.com') || trimmed.contains('youtu.be')) {
+ result.add(_UrlEintrag(trimmed, '🎵 Song'));
+ }
+ }
+ return result;
+}
+
+class _UrlEintrag {
+ final String url;
+ final String typ;
+ _UrlEintrag(this.url, this.typ);
+ String _titelKurz() =>
+ '$typ ${url.length > 40 ? url.substring(0, 40) : url}';
+}
diff --git a/lib/services/melo_logger.dart b/lib/services/melo_logger.dart
new file mode 100644
index 0000000..c6e65b1
--- /dev/null
+++ b/lib/services/melo_logger.dart
@@ -0,0 +1,122 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:http/http.dart' as http;
+
+/// Umfassendes Log-System für die Melo App.
+/// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance.
+class MeloLogger {
+ static final MeloLogger _instanz = MeloLogger._();
+ factory MeloLogger() => _instanz;
+ MeloLogger._();
+
+ bool _initialisiert = false;
+ String _sessionId = '';
+ String _version = '2.7';
+ final List