Melo v2.10 - YouTube Proxy, Download-Screen, Logger, iOS
This commit is contained in:
+9
-15
@@ -2,11 +2,16 @@
|
|||||||
# Git Pre-Commit Hook: Melo App Qualitäts-Gate
|
# Git Pre-Commit Hook: Melo App Qualitäts-Gate
|
||||||
# Läuft automatisch vor jedem git commit
|
# 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..."
|
echo "🔍 Melo App Qualitäts-Gate gestartet..."
|
||||||
|
|
||||||
# 1. Dart Analyse
|
# 1. Dart Analyse
|
||||||
echo -n " Prüfe: dart analyze ... "
|
echo -n " Prüfe: flutter analyze ... "
|
||||||
ANALYZE_OUTPUT=$(flutter analyze 2>&1)
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||||
|
ANALYZE_OUTPUT=$($FLUTTER analyze 2>&1)
|
||||||
if echo "$ANALYZE_OUTPUT" | grep -q "No issues found"; then
|
if echo "$ANALYZE_OUTPUT" | grep -q "No issues found"; then
|
||||||
echo "✅"
|
echo "✅"
|
||||||
else
|
else
|
||||||
@@ -15,20 +20,9 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Dart Formatierung
|
# 2. Tests
|
||||||
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
|
|
||||||
echo -n " Prüfe: flutter test ... "
|
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
|
if echo "$TEST_OUTPUT" | grep -q "All tests passed"; then
|
||||||
echo "✅"
|
echo "✅"
|
||||||
elif echo "$TEST_OUTPUT" | grep -q "No tests found"; then
|
elif echo "$TEST_OUTPUT" | grep -q "No tests found"; then
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.melo.melo_app"
|
namespace = "com.melo.melo_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 36
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="Melo"
|
android:label="Melo"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class DbHelper {
|
|||||||
final pfad = await getDatabasesPath();
|
final pfad = await getDatabasesPath();
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
p.join(pfad, 'melo.db'),
|
p.join(pfad, 'melo.db'),
|
||||||
version: 1,
|
version: 2,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE songs (
|
CREATE TABLE songs (
|
||||||
@@ -35,6 +35,7 @@ class DbHelper {
|
|||||||
ist_heruntergeladen INTEGER DEFAULT 0,
|
ist_heruntergeladen INTEGER DEFAULT 0,
|
||||||
hinzugefuegt_am TEXT NOT NULL,
|
hinzugefuegt_am TEXT NOT NULL,
|
||||||
download_quelle TEXT DEFAULT 'local',
|
download_quelle TEXT DEFAULT 'local',
|
||||||
|
stream_url TEXT,
|
||||||
zuletzt_position INTEGER
|
zuletzt_position INTEGER
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
@@ -83,7 +84,14 @@ class DbHelper {
|
|||||||
''');
|
''');
|
||||||
},
|
},
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
// Hier zukünftige DB-Migrationen einpflegen (z.B. if (oldVersion < 2) ...)
|
if (oldVersion < 2) {
|
||||||
|
try {
|
||||||
|
await db.execute('ALTER TABLE songs ADD COLUMN stream_url TEXT');
|
||||||
|
} catch (_) {
|
||||||
|
// Spalte existiert bereits – ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Weitere Migrationen: if (oldVersion < 3) { ... }
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -1,17 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'database/db_helper.dart';
|
import 'database/db_helper.dart';
|
||||||
import 'services/favoriten_service.dart';
|
import 'services/favoriten_service.dart';
|
||||||
|
import 'services/melo_logger.dart';
|
||||||
import 'utils/farb_theme.dart';
|
import 'utils/farb_theme.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Logger startet sofort – zeichnet ALLES auf
|
||||||
|
MeloLogger().init('2.7');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DbHelper().db;
|
await DbHelper().db;
|
||||||
await FavoritenService().init();
|
await FavoritenService().init();
|
||||||
|
MeloLogger().zustand('start_ok', {'db': 'ok'});
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
debugPrint('Start-Fehler: $e\n$stack');
|
MeloLogger().fehler('App-Start', e, stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
runApp(const MeloApp());
|
runApp(const MeloApp());
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ class Song {
|
|||||||
final int groesseBytes;
|
final int groesseBytes;
|
||||||
final bool istHeruntergeladen;
|
final bool istHeruntergeladen;
|
||||||
final String hinzugefuegtAm;
|
final String hinzugefuegtAm;
|
||||||
final String downloadQuelle; // "local", "youtube"
|
final String downloadQuelle; // "local", "youtube", "server"
|
||||||
|
final String? streamUrl; // Für Server-Streaming (Navidrome)
|
||||||
int? zuletztPosition; // Sekunden, für Wiederaufnahme
|
int? zuletztPosition; // Sekunden, für Wiederaufnahme
|
||||||
|
|
||||||
Song({
|
Song({
|
||||||
@@ -24,6 +25,7 @@ class Song {
|
|||||||
this.istHeruntergeladen = false,
|
this.istHeruntergeladen = false,
|
||||||
String? hinzugefuegtAm,
|
String? hinzugefuegtAm,
|
||||||
this.downloadQuelle = 'local',
|
this.downloadQuelle = 'local',
|
||||||
|
this.streamUrl,
|
||||||
this.zuletztPosition,
|
this.zuletztPosition,
|
||||||
}) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String();
|
}) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String();
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ class Song {
|
|||||||
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
|
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
|
||||||
'hinzugefuegt_am': hinzugefuegtAm,
|
'hinzugefuegt_am': hinzugefuegtAm,
|
||||||
'download_quelle': downloadQuelle,
|
'download_quelle': downloadQuelle,
|
||||||
|
'stream_url': streamUrl,
|
||||||
'zuletzt_position': zuletztPosition,
|
'zuletzt_position': zuletztPosition,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ class Song {
|
|||||||
istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1,
|
istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1,
|
||||||
hinzugefuegtAm: m['hinzugefuegt_am'] as String?,
|
hinzugefuegtAm: m['hinzugefuegt_am'] as String?,
|
||||||
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
||||||
|
streamUrl: m['stream_url'] as String?,
|
||||||
zuletztPosition: m['zuletzt_position'] as int?,
|
zuletztPosition: m['zuletzt_position'] as int?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../services/download_service.dart';
|
||||||
|
import '../utils/farb_theme.dart';
|
||||||
|
import '../services/melo_logger.dart';
|
||||||
|
|
||||||
|
class DownloadScreen extends StatefulWidget {
|
||||||
|
final DownloadService downloader;
|
||||||
|
final VoidCallback onSongsChanged;
|
||||||
|
|
||||||
|
const DownloadScreen({
|
||||||
|
super.key,
|
||||||
|
required this.downloader,
|
||||||
|
required this.onSongsChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadScreen> createState() => _DownloadScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadScreenState extends State<DownloadScreen> {
|
||||||
|
final _urlController = TextEditingController();
|
||||||
|
bool _ladt = false;
|
||||||
|
String? _fehler;
|
||||||
|
String? _erfolg;
|
||||||
|
String _speicherOrt = 'App-intern';
|
||||||
|
bool _speichertInDownloads = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ladeSpeicherPfad();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _ordnerDialog() async {
|
||||||
|
final auswahl = await showDialog<String>(
|
||||||
|
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))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,9 @@ import '../widgets/melo_header.dart';
|
|||||||
import '../widgets/statistik_card.dart';
|
import '../widgets/statistik_card.dart';
|
||||||
import '../widgets/tag_leiste.dart';
|
import '../widgets/tag_leiste.dart';
|
||||||
import '../widgets/song_tile.dart';
|
import '../widgets/song_tile.dart';
|
||||||
import '../widgets/recent_widget.dart';
|
|
||||||
import '../widgets/tag_stats_widget.dart';
|
|
||||||
import '../widgets/navidrome_browser.dart';
|
import '../widgets/navidrome_browser.dart';
|
||||||
import '../widgets/playlist_sheet.dart';
|
import '../widgets/playlist_sheet.dart';
|
||||||
|
import 'download_screen.dart';
|
||||||
|
|
||||||
class MeloHome extends StatefulWidget {
|
class MeloHome extends StatefulWidget {
|
||||||
const MeloHome({super.key});
|
const MeloHome({super.key});
|
||||||
@@ -237,102 +236,15 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _zeigeDownloadDialog() async {
|
Future<void> _zeigeDownloadDialog() async {
|
||||||
final controller = TextEditingController();
|
// Statt Dialog → zum Download-Tab wechseln
|
||||||
final url = await showDialog<String>(
|
setState(() => _aktiverTab = 1);
|
||||||
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)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: _vm,
|
listenable: _vm,
|
||||||
builder: (_, __) {
|
builder: (_, _) {
|
||||||
if (_vm.ladt) {
|
if (_vm.ladt) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: MeloTheme.schwarz,
|
backgroundColor: MeloTheme.schwarz,
|
||||||
@@ -348,9 +260,14 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: MeloTheme.schwarz,
|
backgroundColor: MeloTheme.schwarz,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: _aktiverTab == 1
|
||||||
|
? DownloadScreen(
|
||||||
|
downloader: _vm.downloader,
|
||||||
|
onSongsChanged: _vm.ladeSongs,
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche),
|
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser),
|
||||||
// ─── EIN/AUS: RecentWidget (Zuletzt gehört) ───
|
// ─── EIN/AUS: RecentWidget (Zuletzt gehört) ───
|
||||||
// Entferne die Kommentarzeichen um RecentWidget zu aktivieren:
|
// Entferne die Kommentarzeichen um RecentWidget zu aktivieren:
|
||||||
// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
|
// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
|
||||||
|
|||||||
@@ -1,117 +1,303 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
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 'package:path_provider/path_provider.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
import '../database/db_helper.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._();
|
static final DownloadService _instanz = DownloadService._();
|
||||||
factory DownloadService() => _instanz;
|
factory DownloadService() => _instanz;
|
||||||
DownloadService._();
|
DownloadService._();
|
||||||
|
|
||||||
final YoutubeExplode _yt = YoutubeExplode();
|
static const String _proxyBasisUrl = 'https://yt.baka-net.de';
|
||||||
|
static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b';
|
||||||
|
static const Map<String, String> _authHeader = {'X-API-Key': _apiKey};
|
||||||
|
|
||||||
final DbHelper _db = DbHelper();
|
final DbHelper _db = DbHelper();
|
||||||
|
|
||||||
bool _ladt = false;
|
bool _ladt = false;
|
||||||
double _fortschritt = 0;
|
double _fortschritt = 0;
|
||||||
String? _aktuellerTitel;
|
String? _aktuellerTitel;
|
||||||
String? _fehler;
|
String? _fehler;
|
||||||
|
String _standardPfad = '';
|
||||||
|
Completer<void>? _abbruchCompleter;
|
||||||
|
|
||||||
bool get ladt => _ladt;
|
bool get ladt => _ladt;
|
||||||
double get fortschritt => _fortschritt;
|
double get fortschritt => _fortschritt;
|
||||||
String? get aktuellerTitel => _aktuellerTitel;
|
String? get aktuellerTitel => _aktuellerTitel;
|
||||||
String? get fehler => _fehler;
|
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() {
|
void _resetStatus() {
|
||||||
_ladt = true;
|
_ladt = true;
|
||||||
_fortschritt = 0;
|
_fortschritt = 0;
|
||||||
_aktuellerTitel = null;
|
_aktuellerTitel = null;
|
||||||
_fehler = null;
|
_fehler = null;
|
||||||
|
_abbruchCompleter = Completer<void>();
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Song von YouTube herunterladen
|
||||||
Future<Song?> downloadVonUrl(String url) async {
|
Future<Song?> downloadVonUrl(String url) async {
|
||||||
_resetStatus();
|
_resetStatus();
|
||||||
|
return _downloadEinzeln(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mehrere URLs/Playlists nacheinander mit Cooldown
|
||||||
|
Future<int> 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<Song?> _downloadEinzeln(String url) async {
|
||||||
|
String? dateiPfad;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// URL-Validierung
|
// URL-Validierung
|
||||||
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
||||||
_fehler = 'Keine gültige YouTube-URL';
|
_fehler = 'Keine gültige YouTube-URL';
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String titel = '';
|
// ─── 1/3: Proxy anfragen (Metadaten + Download) ───
|
||||||
String kuenstler = '';
|
_aktuellerTitel = 'Proxy wird kontaktiert...';
|
||||||
int dauer = 0;
|
notifyListeners();
|
||||||
|
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
http.Response dlAntwort;
|
||||||
|
|
||||||
// Video-Info mit Timeout
|
|
||||||
_aktuellerTitel = 'YouTube wird kontaktiert...';
|
|
||||||
try {
|
try {
|
||||||
final video = await _yt.videos.get(url).timeout(const Duration(seconds: 15));
|
dlAntwort = await _retryHttpPost(
|
||||||
titel = video.title;
|
Uri.parse('$_proxyBasisUrl/api/yt-dl'),
|
||||||
kuenstler = video.author;
|
headers: {
|
||||||
dauer = video.duration?.inSeconds ?? 0;
|
..._authHeader,
|
||||||
_aktuellerTitel = 'Video gefunden: $titel';
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'url': url}),
|
||||||
|
maxVersuche: 2,
|
||||||
|
timeout: const Duration(seconds: 150),
|
||||||
|
);
|
||||||
|
stopwatch.stop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_fehler = 'Timeout/Fehler bei Video-Info: ${e.toString()}';
|
stopwatch.stop();
|
||||||
debugPrint('Video-Info Fehler (vollständig): $e');
|
MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl',
|
||||||
|
fehler: e.toString(), dauerMs: stopwatch.elapsedMilliseconds);
|
||||||
|
_fehler = 'Proxy nicht erreichbar: ${e.toString().split('\n').first}';
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest abrufen
|
if (sollAbbrechen) {
|
||||||
_aktuellerTitel = 'Audio-Stream wird ermittelt... (2/4)';
|
_ladt = false;
|
||||||
AudioStreamInfo? audio;
|
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 {
|
try {
|
||||||
final manifest = await _yt.videos.streamsClient
|
final mp3Antwort = await _retryHttpGet(
|
||||||
.getManifest(url).timeout(const Duration(seconds: 15));
|
Uri.parse('$_proxyBasisUrl$mp3Url'),
|
||||||
final streams = manifest.audioOnly.toList();
|
headers: _authHeader,
|
||||||
if (streams.isEmpty) {
|
maxVersuche: 2,
|
||||||
_fehler = 'Kein Audio-Stream verfügbar';
|
timeout: const Duration(seconds: 120),
|
||||||
|
);
|
||||||
|
stopwatch2.stop();
|
||||||
|
|
||||||
|
if (mp3Antwort.statusCode != 200) {
|
||||||
|
_fehler = 'MP3-Download fehlgeschlagen (${mp3Antwort.statusCode})';
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
|
notifyListeners();
|
||||||
|
MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
|
||||||
|
statusCode: mp3Antwort.statusCode, fehler: _fehler);
|
||||||
return null;
|
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 file = File(dateiPfad);
|
||||||
final sink = file.openWrite();
|
await file.writeAsBytes(mp3Antwort.bodyBytes);
|
||||||
int downloaded = 0;
|
|
||||||
final total = audio.size.totalBytes;
|
MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
|
||||||
await for (final chunk in fileStream) {
|
statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds);
|
||||||
sink.add(chunk);
|
|
||||||
downloaded += chunk.length;
|
|
||||||
_fortschritt = total > 0 ? downloaded / total : 0.0;
|
|
||||||
}
|
|
||||||
await sink.flush();
|
|
||||||
await sink.close();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_fehler = 'Download-Fehler: ${e.toString()}';
|
stopwatch2.stop();
|
||||||
debugPrint('Download Stream Fehler (vollständig): $e');
|
_fehler = 'Download-Fehler: ${e.toString().split('\n').first}';
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
|
notifyListeners();
|
||||||
|
debugPrint('MP3-Download Fehler: $e');
|
||||||
|
MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
|
||||||
|
fehler: e.toString(), dauerMs: stopwatch2.elapsedMilliseconds);
|
||||||
|
_raeumeAuf(dateiPfad);
|
||||||
return null;
|
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(
|
final song = Song(
|
||||||
titel: titel,
|
titel: titel,
|
||||||
kuenstler: kuenstler,
|
kuenstler: kuenstler,
|
||||||
@@ -125,17 +311,141 @@ class DownloadService {
|
|||||||
await _db.songEinfuegen(song);
|
await _db.songEinfuegen(song);
|
||||||
|
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
_aktuellerTitel = '✅ ${song.titel} heruntergeladen';
|
_aktuellerTitel = '✅ ${song.titel}';
|
||||||
|
notifyListeners();
|
||||||
return song;
|
return song;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_fehler = 'Fehler: ${e.toString()}';
|
_fehler = 'Fehler: ${e.toString().split('\n').first}';
|
||||||
debugPrint('Download allgemeiner Fehler (vollständig): $e');
|
debugPrint('Download allgemeiner Fehler: $e');
|
||||||
|
MeloLogger().fehler('download_einzeln_crash', e);
|
||||||
|
_raeumeAuf(dateiPfad);
|
||||||
_ladt = false;
|
_ladt = false;
|
||||||
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HTTP POST mit Retry (exponentieller Backoff)
|
||||||
|
Future<http.Response> _retryHttpPost(
|
||||||
|
Uri url, {
|
||||||
|
Map<String, String>? 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<http.Response> _retryHttpGet(
|
||||||
|
Uri url, {
|
||||||
|
Map<String, String>? 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() {
|
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}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Map<String, dynamic>> _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<String, dynamic>? details]) {
|
||||||
|
_eintraege.add({
|
||||||
|
'id': _logId++,
|
||||||
|
'zeit': DateTime.now().toIso8601String(),
|
||||||
|
'kategorie': kategorie,
|
||||||
|
'aktion': aktion,
|
||||||
|
'details': details ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void aktion(String name, [Map<String, dynamic>? 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<String, dynamic> daten) {
|
||||||
|
_addEintrag('zustand', name, daten);
|
||||||
|
}
|
||||||
|
|
||||||
|
void manuell(String nachricht) {
|
||||||
|
_addEintrag('user', nachricht);
|
||||||
|
_senden();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,27 +100,38 @@ class NavidromeService {
|
|||||||
try {
|
try {
|
||||||
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
||||||
return r.statusCode == 200;
|
return r.statusCode == 200;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
debugPrint('Navidrome Ping Fehler: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle Alben abrufen
|
/// Alle Alben abrufen
|
||||||
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
||||||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
try {
|
||||||
if (r.statusCode != 200) return [];
|
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
||||||
final data = jsonDecode(r.body);
|
if (r.statusCode != 200) return [];
|
||||||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
final data = jsonDecode(r.body);
|
||||||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
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
|
/// Songs eines Albums abrufen
|
||||||
Future<List<SubsonicSong>> getSongs(String albumId) async {
|
Future<List<SubsonicSong>> getSongs(String albumId) async {
|
||||||
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
try {
|
||||||
if (r.statusCode != 200) return [];
|
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
||||||
final data = jsonDecode(r.body);
|
if (r.statusCode != 200) return [];
|
||||||
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
|
final data = jsonDecode(r.body);
|
||||||
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
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
|
/// Song-Stream-URL
|
||||||
@@ -141,7 +152,8 @@ class NavidromeService {
|
|||||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||||
|
|
||||||
final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
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 dateiPfad = '${musikDir.path}/$dateiName';
|
||||||
|
|
||||||
final file = File(dateiPfad);
|
final file = File(dateiPfad);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
@@ -43,7 +44,16 @@ class PlayerService {
|
|||||||
_aktuellerIndex = _warteschlange.length - 1;
|
_aktuellerIndex = _warteschlange.length - 1;
|
||||||
}
|
}
|
||||||
try {
|
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));
|
if (position > 0) await _p.seek(Duration(seconds: position));
|
||||||
await _p.play();
|
await _p.play();
|
||||||
_songWechsel.add(song);
|
_songWechsel.add(song);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../services/download_service.dart';
|
|||||||
import '../services/navidrome_service.dart';
|
import '../services/navidrome_service.dart';
|
||||||
import '../services/playlist_service.dart';
|
import '../services/playlist_service.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
import '../models/tag.dart';
|
|
||||||
|
|
||||||
class MeloHomeViewModel extends ChangeNotifier {
|
class MeloHomeViewModel extends ChangeNotifier {
|
||||||
final PlayerService player = PlayerService();
|
final PlayerService player = PlayerService();
|
||||||
@@ -54,29 +53,34 @@ class MeloHomeViewModel extends ChangeNotifier {
|
|||||||
ladt = true;
|
ladt = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
var alle = await db.alleSongs();
|
try {
|
||||||
|
var alle = await db.alleSongs();
|
||||||
|
|
||||||
if (alle.isEmpty) {
|
if (alle.isEmpty) {
|
||||||
final beispiele = [
|
final beispiele = [
|
||||||
Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte',
|
Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte',
|
||||||
dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true),
|
dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true),
|
||||||
Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey',
|
Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey',
|
||||||
dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true),
|
dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true),
|
||||||
Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge',
|
Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge',
|
||||||
dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true),
|
dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true),
|
||||||
Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast',
|
Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast',
|
||||||
dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true),
|
dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true),
|
||||||
Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe',
|
Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe',
|
||||||
dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true),
|
dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true),
|
||||||
];
|
];
|
||||||
await db.songsEinfuegen(beispiele);
|
await db.songsEinfuegen(beispiele);
|
||||||
alle = await db.alleSongs();
|
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;
|
ladt = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -200,6 +204,22 @@ class MeloHomeViewModel extends ChangeNotifier {
|
|||||||
return false;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
player.dispose();
|
player.dispose();
|
||||||
|
|||||||
@@ -12,31 +12,56 @@ class MeloHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
ShaderMask(
|
Column(
|
||||||
shaderCallback: (bounds) => const LinearGradient(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
colors: [Colors.white, MeloTheme.rot],
|
children: [
|
||||||
).createShader(bounds),
|
ShaderMask(
|
||||||
child: const Text('Melo', style: TextStyle(
|
shaderCallback: (bounds) => const LinearGradient(
|
||||||
fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)),
|
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: [
|
const SizedBox(height: 8),
|
||||||
if (onServer != null) ...[
|
// Suchleiste – immer sichtbar
|
||||||
_btn(Icons.cloud, onServer!),
|
TextField(
|
||||||
const SizedBox(width: 8),
|
onSubmitted: (wert) {
|
||||||
],
|
if (wert.isNotEmpty) onSearch();
|
||||||
_btn(Icons.download, onDownload),
|
},
|
||||||
const SizedBox(width: 8),
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
_btn(Icons.search, onSearch),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class _NavidromeBrowserState extends State<NavidromeBrowser> {
|
|||||||
else if (vm.navidromeAlben.isEmpty)
|
else if (vm.navidromeAlben.isEmpty)
|
||||||
_platzhalter('"Alben laden" um Musik zu sehen')
|
_platzhalter('"Alben laden" um Musik zu sehen')
|
||||||
else
|
else
|
||||||
_albumListe(context, vm.navidromeAlben as List<SubsonicAlbum>),
|
_albumListe(context, vm.navidromeAlben),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -191,6 +191,11 @@ class _NavidromeBrowserState extends State<NavidromeBrowser> {
|
|||||||
onTap: () => widget.vm.downloadNavidromeSong(s).then((_) => setState(() {})),
|
onTap: () => widget.vm.downloadNavidromeSong(s).then((_) => setState(() {})),
|
||||||
child: const Icon(Icons.download, size: 18, color: MeloTheme.rot),
|
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<NavidromeBrowser> {
|
|||||||
final urlCtrl = TextEditingController();
|
final urlCtrl = TextEditingController();
|
||||||
final userCtrl = TextEditingController();
|
final userCtrl = TextEditingController();
|
||||||
final passCtrl = TextEditingController();
|
final passCtrl = TextEditingController();
|
||||||
|
bool verbindet = false;
|
||||||
|
String? fehler;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
backgroundColor: MeloTheme.dunkel1,
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: const Text('🌐 Navidrome', style: TextStyle(color: Colors.white, fontSize: 16)),
|
backgroundColor: MeloTheme.dunkel1,
|
||||||
content: Column(
|
title: const Text('🌐 Navidrome', style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white),
|
children: [
|
||||||
decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://musik.baka-net.de',
|
TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white),
|
||||||
labelStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())),
|
decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://musik.baka-net.de',
|
||||||
const SizedBox(height: 8),
|
labelStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())),
|
||||||
TextField(controller: userCtrl, style: const TextStyle(color: Colors.white),
|
const SizedBox(height: 8),
|
||||||
decoration: const InputDecoration(labelText: 'Benutzer', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())),
|
TextField(controller: userCtrl, style: const TextStyle(color: Colors.white),
|
||||||
const SizedBox(height: 8),
|
decoration: const InputDecoration(labelText: 'Benutzer', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())),
|
||||||
TextField(controller: passCtrl, style: const TextStyle(color: Colors.white), obscureText: true,
|
const SizedBox(height: 8),
|
||||||
decoration: const InputDecoration(labelText: 'Passwort', labelStyle: TextStyle(color: Colors.grey), border: OutlineInputBorder())),
|
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-90
@@ -1,14 +1,6 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
archive:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: archive
|
|
||||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.0.9"
|
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -98,21 +90,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
csslib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: csslib
|
|
||||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -184,14 +168,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -200,16 +176,8 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: html
|
|
||||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.6"
|
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -248,14 +216,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
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:
|
just_audio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -464,14 +424,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.1"
|
version: "0.2.1"
|
||||||
petitparser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: petitparser
|
|
||||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.2"
|
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -488,14 +440,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
posix:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: posix
|
|
||||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.0"
|
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -576,14 +520,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -693,14 +629,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
unicode:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: unicode
|
|
||||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.9"
|
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -741,14 +669,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xml
|
|
||||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.6.1"
|
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -757,14 +677,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
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:
|
sdks:
|
||||||
dart: ">=3.12.2 <4.0.0"
|
dart: ">=3.12.2 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ dependencies:
|
|||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
|
|
||||||
# YouTube Download
|
# YouTube Download
|
||||||
youtube_explode_dart: ^2.3.10
|
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|
||||||
# Einstellungen
|
# Einstellungen
|
||||||
|
|||||||
Reference in New Issue
Block a user