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> _eintraege = []; + int _logId = 0; + Timer? _flushTimer; + + static const String _serverUrl = 'http://159.195.51.99:8991/api/log'; + + void init(String version) { + if (_initialisiert) return; + _initialisiert = true; + _version = version; + _sessionId = DateTime.now().millisecondsSinceEpoch.toString(); + + _flushTimer = Timer.periodic(const Duration(seconds: 30), (_) => _senden()); + _addEintrag('app', 'start', {'version': version, 'session': _sessionId}); + + FlutterError.onError = (details) { + FlutterError.presentError(details); + _addEintrag('crash', 'flutter_error', { + 'fehler': details.exceptionAsString(), + 'stack': details.stack.toString(), + }); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + _addEintrag('crash', 'dart_error', { + 'fehler': error.toString(), + 'stack': stack.toString(), + }); + return true; + }; + + debugPrint('📋 MeloLogger ready (Session: $_sessionId)'); + } + + void _addEintrag(String kategorie, String aktion, [Map? details]) { + _eintraege.add({ + 'id': _logId++, + 'zeit': DateTime.now().toIso8601String(), + 'kategorie': kategorie, + 'aktion': aktion, + 'details': details ?? {}, + }); + } + + void aktion(String name, [Map? details]) { + _addEintrag('aktion', name, details); + } + + void netzwerk(String methode, String url, {int? statusCode, String? fehler, int? dauerMs}) { + _addEintrag('netzwerk', '$methode $url', { + if (statusCode != null) 'status': statusCode, + if (fehler != null) 'fehler': fehler, + if (dauerMs != null) 'dauer_ms': dauerMs, + }); + } + + void fehler(String kontext, Object? error, [StackTrace? stack]) { + _addEintrag('fehler', kontext, { + 'error': error.toString(), + 'stack': stack?.toString() ?? '', + }); + _senden(); + } + + void performance(String name, int dauerMs) { + _addEintrag('performance', name, {'dauer_ms': dauerMs}); + } + + void zustand(String name, Map daten) { + _addEintrag('zustand', name, daten); + } + + void manuell(String nachricht) { + _addEintrag('user', nachricht); + _senden(); + } + + Future _senden() async { + if (_eintraege.isEmpty) return; + + final batch = _eintraege.toList(); + _eintraege.clear(); + + try { + await http.post( + Uri.parse(_serverUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'typ': 'log_batch', + 'session': _sessionId, + 'version': _version, + 'device': '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + 'eintraege': batch, + }), + ).timeout(const Duration(seconds: 5)); + } catch (_) { + _eintraege.insertAll(0, batch); + } + } + + void dispose() { + _flushTimer?.cancel(); + _senden(); + } +} diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index 9a5bdd0..e4a1978 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -100,27 +100,38 @@ class NavidromeService { try { final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10)); return r.statusCode == 200; - } catch (_) { + } catch (e) { + debugPrint('Navidrome Ping Fehler: $e'); return false; } } /// Alle Alben abrufen Future> getAlben({int anzahl = 50}) async { - final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15)); - if (r.statusCode != 200) return []; - final data = jsonDecode(r.body); - final list = data['subsonic-response']?['albumList']?['album'] as List? ?? []; - return list.map((j) => SubsonicAlbum.fromJson(j)).toList(); + try { + final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final list = data['subsonic-response']?['albumList']?['album'] as List? ?? []; + return list.map((j) => SubsonicAlbum.fromJson(j)).toList(); + } catch (e) { + debugPrint('Navidrome getAlben Fehler: $e'); + return []; + } } /// Songs eines Albums abrufen Future> getSongs(String albumId) async { - final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); - if (r.statusCode != 200) return []; - final data = jsonDecode(r.body); - final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; - return songs.map((j) => SubsonicSong.fromJson(j)).toList(); + try { + final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) return []; + final data = jsonDecode(r.body); + final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; + return songs.map((j) => SubsonicSong.fromJson(j)).toList(); + } catch (e) { + debugPrint('Navidrome getSongs Fehler: $e'); + return []; + } } /// Song-Stream-URL @@ -141,7 +152,8 @@ class NavidromeService { if (!await musikDir.exists()) await musikDir.create(recursive: true); final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim(); - final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp3'; + final kurzId = s.id.length > 8 ? s.id.substring(0, 8) : s.id; + final dateiName = '${safeName.isEmpty ? "song" : safeName}_$kurzId.mp3'; final dateiPfad = '${musikDir.path}/$dateiName'; final file = File(dateiPfad); diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 2d667d3..8071a97 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import '../models/song.dart'; @@ -43,7 +44,16 @@ class PlayerService { _aktuellerIndex = _warteschlange.length - 1; } try { - await _p.setFilePath(song.dateiPfad); + if (song.streamUrl != null && song.streamUrl!.isNotEmpty) { + // Streaming vom Server + await _p.setUrl(song.streamUrl!); + } else if (song.dateiPfad.isNotEmpty && await File(song.dateiPfad).exists()) { + // Lokale Datei + await _p.setFilePath(song.dateiPfad); + } else { + debugPrint('Keine gültige Quelle für: ${song.titel}'); + return; + } if (position > 0) await _p.seek(Duration(seconds: position)); await _p.play(); _songWechsel.add(song); diff --git a/lib/viewmodels/melo_home_viewmodel.dart b/lib/viewmodels/melo_home_viewmodel.dart index 33a24f7..fd5e5d9 100644 --- a/lib/viewmodels/melo_home_viewmodel.dart +++ b/lib/viewmodels/melo_home_viewmodel.dart @@ -7,7 +7,6 @@ import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../services/playlist_service.dart'; import '../models/song.dart'; -import '../models/tag.dart'; class MeloHomeViewModel extends ChangeNotifier { final PlayerService player = PlayerService(); @@ -54,29 +53,34 @@ class MeloHomeViewModel extends ChangeNotifier { ladt = true; notifyListeners(); - var alle = await db.alleSongs(); + try { + var alle = await db.alleSongs(); - if (alle.isEmpty) { - final beispiele = [ - Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', - dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true), - Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey', - dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true), - Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge', - dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true), - Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast', - dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true), - Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe', - dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true), - ]; - await db.songsEinfuegen(beispiele); - alle = await db.alleSongs(); + if (alle.isEmpty) { + final beispiele = [ + Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', + dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true), + Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey', + dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true), + Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge', + dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true), + Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast', + dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true), + Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe', + dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true), + ]; + await db.songsEinfuegen(beispiele); + alle = await db.alleSongs(); + } + + songs = alle; + favoritenIds = await favoriten.favoritenIds(); + letzteSongs = await db.letzteWiedergaben(); + await ladeTags(); + } catch (e, stack) { + debugPrint('ladeSongs Fehler: $e\n$stack'); } - songs = alle; - favoritenIds = await favoriten.favoritenIds(); - letzteSongs = await db.letzteWiedergaben(); - await ladeTags(); ladt = false; notifyListeners(); } @@ -200,6 +204,22 @@ class MeloHomeViewModel extends ChangeNotifier { return false; } + /// Song direkt vom Server streamen (ohne Download) + void starteNavidromeStream(SubsonicSong s) { + final uri = navidrome.streamUrl(s.id); + final song = Song( + titel: s.titel, + kuenstler: s.kuenstler, + album: s.album, + dauerSekunden: s.dauerSekunden, + dateiPfad: '', + groesseBytes: s.groesseBytes, + downloadQuelle: 'server', + streamUrl: uri.toString(), + ); + player.spiele(song); + } + @override void dispose() { player.dispose(); diff --git a/lib/widgets/melo_header.dart b/lib/widgets/melo_header.dart index 7c4ef24..194c95f 100644 --- a/lib/widgets/melo_header.dart +++ b/lib/widgets/melo_header.dart @@ -12,31 +12,56 @@ class MeloHeader extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - colors: [Colors.white, MeloTheme.rot], - ).createShader(bounds), - child: const Text('Melo', style: TextStyle( - fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + colors: [Colors.white, MeloTheme.rot], + ).createShader(bounds), + child: const Text('Melo', style: TextStyle( + fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)), + ), + const Text('Deine Musik. Deine Art.', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + ], ), - const Text('Deine Musik. Deine Art.', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)), + Row(children: [ + if (onServer != null) ...[ + _btn(Icons.cloud, onServer!), + const SizedBox(width: 8), + ], + _btn(Icons.download, onDownload), + const SizedBox(width: 8), + _btn(Icons.search, onSearch), + ]), ], ), - Row(children: [ - if (onServer != null) ...[ - _btn(Icons.cloud, onServer!), - const SizedBox(width: 8), - ], - _btn(Icons.download, onDownload), - const SizedBox(width: 8), - _btn(Icons.search, onSearch), - ]), + const SizedBox(height: 8), + // Suchleiste – immer sichtbar + TextField( + onSubmitted: (wert) { + if (wert.isNotEmpty) onSearch(); + }, + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: '🔍 Song, Künstler oder Tag suchen...', + hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 13), + prefixIcon: Icon(Icons.search, size: 18, color: MeloTheme.rot.withValues(alpha: 0.6)), + filled: true, + fillColor: MeloTheme.dunkel1, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + ), + ), ], ), ); diff --git a/lib/widgets/navidrome_browser.dart b/lib/widgets/navidrome_browser.dart index a386f06..d1a26ed 100644 --- a/lib/widgets/navidrome_browser.dart +++ b/lib/widgets/navidrome_browser.dart @@ -52,7 +52,7 @@ class _NavidromeBrowserState extends State { else if (vm.navidromeAlben.isEmpty) _platzhalter('"Alben laden" um Musik zu sehen') else - _albumListe(context, vm.navidromeAlben as List), + _albumListe(context, vm.navidromeAlben), ], ), ); @@ -191,6 +191,11 @@ class _NavidromeBrowserState extends State { onTap: () => widget.vm.downloadNavidromeSong(s).then((_) => setState(() {})), child: const Icon(Icons.download, size: 18, color: MeloTheme.rot), ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => widget.vm.starteNavidromeStream(s), + child: const Icon(Icons.play_arrow, size: 18, color: Colors.greenAccent), + ), ], ), ); @@ -214,36 +219,55 @@ class _NavidromeBrowserState extends State { final urlCtrl = TextEditingController(); final userCtrl = TextEditingController(); final passCtrl = TextEditingController(); + bool verbindet = false; + String? fehler; + showDialog( context: context, - builder: (ctx) => AlertDialog( - backgroundColor: MeloTheme.dunkel1, - title: const Text('🌐 Navidrome', style: TextStyle(color: Colors.white, fontSize: 16)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://musik.baka-net.de', - labelStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), - const SizedBox(height: 8), - TextField(controller: userCtrl, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration(labelText: 'Benutzer', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), - const SizedBox(height: 8), - TextField(controller: passCtrl, style: const TextStyle(color: Colors.white), obscureText: true, - decoration: const InputDecoration(labelText: 'Passwort', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: MeloTheme.dunkel1, + title: const Text('🌐 Navidrome', style: TextStyle(color: Colors.white, fontSize: 16)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://musik.baka-net.de', + labelStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + const SizedBox(height: 8), + TextField(controller: userCtrl, style: const TextStyle(color: Colors.white), + decoration: const InputDecoration(labelText: 'Benutzer', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + const SizedBox(height: 8), + TextField(controller: passCtrl, style: const TextStyle(color: Colors.white), obscureText: true, + decoration: const InputDecoration(labelText: 'Passwort', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())), + if (fehler != null) ...[ + const SizedBox(height: 8), + Text(fehler!, style: const TextStyle(color: Colors.red, fontSize: 12)), + ], + if (verbindet) + const Padding(padding: EdgeInsets.only(top: 12), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: MeloTheme.rot))), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: verbindet ? null : () async { + verbindet = true; + setDialogState(() {}); + widget.vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text); + final ok = await widget.vm.ladeNavidromeAlben(); + verbindet = false; + if (ok && ctx.mounted) { + Navigator.pop(ctx); + if (context.mounted) setState(() {}); + } else if (ctx.mounted) { + setDialogState(() => fehler = '❌ Keine Verbindung\nPrüfe URL + Zugangsdaten'); + } + }, + child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)), + ), ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), - TextButton( - onPressed: () { - widget.vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text); - Navigator.pop(ctx); - widget.vm.ladeNavidromeAlben().then((_) => setState(() {})); - }, - child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)), - ), - ], ), ); } diff --git a/pubspec.lock b/pubspec.lock index 9961663..8684938 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - archive: - dependency: transitive - description: - name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.dev" - source: hosted - version: "4.0.9" args: dependency: transitive description: @@ -98,21 +90,13 @@ packages: source: hosted version: "1.19.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -184,14 +168,6 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" hooks: dependency: transitive description: @@ -200,16 +176,8 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - html: - dependency: transitive - description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" - url: "https://pub.dev" - source: hosted - version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -248,14 +216,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" - url: "https://pub.dev" - source: hosted - version: "4.12.0" just_audio: dependency: "direct main" description: @@ -464,14 +424,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" platform: dependency: transitive description: @@ -488,14 +440,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - posix: - dependency: transitive - description: - name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.dev" - source: hosted - version: "6.5.0" pub_semver: dependency: transitive description: @@ -576,14 +520,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" - simple_sparse_list: - dependency: transitive - description: - name: simple_sparse_list - sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f - url: "https://pub.dev" - source: hosted - version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -693,14 +629,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 - url: "https://pub.dev" - source: hosted - version: "1.1.9" uuid: dependency: transitive description: @@ -741,14 +669,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" yaml: dependency: transitive description: @@ -757,14 +677,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" - youtube_explode_dart: - dependency: "direct main" - description: - name: youtube_explode_dart - sha256: "947ba05e0c4f050743e480e7bca3575ff6427d86cc898c1a69f5e1d188cdc9e0" - url: "https://pub.dev" - source: hosted - version: "2.5.3" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index fc5693d..0d4fcc3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: permission_handler: ^11.3.1 # YouTube Download - youtube_explode_dart: ^2.3.10 path: ^1.9.0 # Einstellungen