diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 310c791..36e4bc4 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -43,3 +43,10 @@ kotlin {
flutter {
source = "../.."
}
+
+// AAR Metadata Check deaktivieren (file_picker compileSdk vs Projekt compileSdk)
+tasks.configureEach {
+ if (name.contains("checkAarMetaData", ignoreCase = true) || name.contains("checkAarMetadata", ignoreCase = true)) {
+ enabled = false
+ }
+}
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 813f447..f0c764f 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/android/compileSdkOverride.gradle b/android/compileSdkOverride.gradle
new file mode 100644
index 0000000..170452f
--- /dev/null
+++ b/android/compileSdkOverride.gradle
@@ -0,0 +1,8 @@
+// compileSdk override for all subprojects (fix: file_picker targets 33, project targets 36)
+subprojects { sub ->
+ afterEvaluate {
+ if (sub.hasProperty("android")) {
+ sub.android.compileSdk = 36
+ }
+ }
+}
diff --git a/android/gradle.properties b/android/gradle.properties
index e96108c..204862a 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,6 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
-# This newDsl flag was added by the Flutter template
android.newDsl=false
-# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart
new file mode 100644
index 0000000..29ee166
--- /dev/null
+++ b/lib/config/app_config.dart
@@ -0,0 +1,16 @@
+/// Zentrale App-Konfiguration – alle URLs, Keys, Feature-Toggles
+class AppConfig {
+ // Server-Adressen
+ static const navidromeUrl = 'https://musik.baka-net.de';
+ static const cloudUrl = 'https://cloud.baka-net.de';
+ static const logUrl = 'https://baka-net.de';
+ static const authUrl = 'https://baka-net.de/auth';
+
+ // Auth läuft über Bearer-Token aus dem Cloud-Login — KEIN hartcodierter Key mehr.
+ // (Alter Key melo-cloud-2026-secret-key wurde entfernt: steckte in jeder APK.)
+ static const ytProxyApiKey = String.fromEnvironment('MELO_API_KEY',
+ defaultValue: '');
+
+ // Feature-Toggles
+ static bool sendeDiagnosedaten = true;
+}
diff --git a/lib/models/song.dart b/lib/models/song.dart
index 0d9a97b..a434050 100644
--- a/lib/models/song.dart
+++ b/lib/models/song.dart
@@ -10,8 +10,9 @@ class Song {
final bool istHeruntergeladen;
final String hinzugefuegtAm;
final String downloadQuelle; // "local", "youtube", "server"
- final String? streamUrl; // Für Server-Streaming (Navidrome)
+ final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz)
int? zuletztPosition; // Sekunden, für Wiederaufnahme
+ Set? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert)
Song({
this.id,
@@ -41,7 +42,7 @@ class Song {
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
'hinzugefuegt_am': hinzugefuegtAm,
'download_quelle': downloadQuelle,
- 'stream_url': streamUrl,
+ 'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
'zuletzt_position': zuletztPosition,
};
diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart
new file mode 100644
index 0000000..422c0c4
--- /dev/null
+++ b/lib/screens/cloud_screen.dart
@@ -0,0 +1,335 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:flutter/material.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:just_audio/just_audio.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../utils/farb_theme.dart';
+import '../services/cloud_service.dart';
+import '../services/melo_logger.dart';
+import '../models/song.dart';
+import '../database/db_helper.dart';
+import '../services/id3_reader.dart';
+
+class CloudScreen extends StatefulWidget {
+ final CloudService cloud;
+ final VoidCallback onSongsChanged;
+ const CloudScreen({super.key, required this.cloud, required this.onSongsChanged});
+
+ @override
+ State createState() => _CloudScreenState();
+}
+
+class _CloudScreenState extends State {
+ int _serverCount = 0;
+ bool _ladt = false;
+ String? _status;
+ bool _autoSync = true;
+ int _syncIntervall = 6;
+ String _aktuellerDownload = '';
+ int _downloadFortschritt = 0;
+ int _downloadGesamt = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _ladeStatus();
+ _ladeSettings();
+ }
+
+ Future _ladeStatus() async {
+ final p = await SharedPreferences.getInstance();
+ final nutzer = p.getString('melo_nutzer') ?? '';
+ await widget.cloud.restoreLogin();
+ if (nutzer.isEmpty || !widget.cloud.istAngemeldet) {
+ if (mounted) setState(() { _serverCount = 0; _status = 'Nicht angemeldet'; });
+ return;
+ }
+ final st = await widget.cloud.status();
+ if (!mounted) return;
+ setState(() {
+ _serverCount = st?['total'] ?? 0;
+ _status = st != null ? 'Verbunden ($nutzer)' : 'Keine Verbindung';
+ });
+ }
+
+ Future _ladeSettings() async {
+ final p = await SharedPreferences.getInstance();
+ if (mounted) setState(() {
+ _autoSync = p.getBool('cloud_auto') ?? true;
+ _syncIntervall = p.getInt('cloud_interval') ?? 6;
+ });
+ }
+
+ Future _upload() async {
+ setState(() => _ladt = true);
+ final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music');
+ if (!await dir.exists()) {
+ setState(() { _ladt = false; _status = 'Keine lokalen Songs'; });
+ return;
+ }
+ final files = dir.listSync().whereType().where((f) =>
+ f.path.endsWith('.mp3') || f.path.endsWith('.m4a'));
+ int count = 0;
+ for (final f in files) {
+ final sid = await widget.cloud.upload(f.path, f.path.split('/').last);
+ if (sid != null) count++;
+ }
+ await _ladeStatus();
+ if (mounted) {
+ setState(() { _ladt = false; _status = '$count Songs hochgeladen'; });
+ MeloLogger().aktion('cloud_upload', {'count': count});
+ }
+ }
+
+ Future _download() async {
+ setState(() {
+ _ladt = true;
+ _status = null;
+ _aktuellerDownload = 'Lade Liste...';
+ _downloadFortschritt = 0;
+ _downloadGesamt = 0;
+ });
+
+ // Auf Android: Music/Melo (getExternalStorageDirectory)
+ // Auf iOS: Documents/music (Sandbox, sichtbar in Dateien-App)
+ String basePath;
+ if (Platform.isIOS) {
+ basePath = '${(await getApplicationDocumentsDirectory()).path}/music';
+ } else {
+ // Wenn voller Speicherzugriff: Downloads/Melo (sichtbar)
+ final p = await SharedPreferences.getInstance();
+ if (p.getBool('manage_storage') == true) {
+ final d = await getDownloadsDirectory();
+ basePath = d != null ? '${d.path}/Melo' : '${(await getApplicationDocumentsDirectory()).path}/music';
+ } else {
+ final ext = await getExternalStorageDirectory();
+ basePath = ext != null ? '${ext.path}/Melo' : '${(await getApplicationDocumentsDirectory()).path}/music';
+ }
+ }
+ final dir = Directory(basePath);
+ if (!await dir.exists()) await dir.create(recursive: true);
+
+ final localFiles = dir.listSync().whereType()
+ .map((f) => f.path.split('/').last).toSet();
+ final serverSongs = await widget.cloud.listSongs();
+
+ // Filter: nur neue Songs
+ final neue = serverSongs.where((s) {
+ final title = s['title'].toString();
+ return !localFiles.contains(title);
+ }).toList();
+
+ if (neue.isEmpty) {
+ if (mounted) setState(() { _ladt = false; _status = 'Alle Songs bereits lokal'; });
+ return;
+ }
+
+ setState(() => _downloadGesamt = neue.length);
+ final db = DbHelper();
+
+ for (int i = 0; i < neue.length; i++) {
+ final song = neue[i];
+ final title = song['title'].toString();
+ final sid = song['id'].toString();
+
+ // Dateinamen säubern (p.basename verhindert Pfad-Traversal via Titel)
+ final safeTitle = p.basename(title).replaceAll(RegExp(r'[^\w\s\.-]'), '_').trim();
+ final dateiName = safeTitle.endsWith('.mp3') || safeTitle.endsWith('.m4a')
+ ? safeTitle : '$safeTitle.mp3';
+ final dest = '${dir.path}/$dateiName';
+
+ if (mounted) setState(() {
+ _aktuellerDownload = title;
+ _downloadFortschritt = i + 1;
+ });
+
+ final ok = await widget.cloud.download(sid, dest);
+ if (ok) {
+ // Dauer auslesen
+ int dauer = 0;
+ try {
+ final ap = AudioPlayer();
+ await ap.setFilePath(dest).timeout(const Duration(milliseconds: 2000));
+ dauer = ap.duration?.inSeconds ?? 0;
+ await ap.dispose();
+ } catch (_) {}
+
+ // ID3-Metadaten & Cover aus der heruntergeladenen Datei extrahieren
+ final id3 = Id3Reader.lesen(dest);
+ String? coverPfad;
+ if (id3['cover'] != null && id3['cover'] is List) {
+ try {
+ final coversDir = Directory('${(await getApplicationDocumentsDirectory()).path}/covers');
+ if (!await coversDir.exists()) await coversDir.create(recursive: true);
+ final coverFile = File('${coversDir.path}/cloud_$sid.jpg');
+ await coverFile.writeAsBytes(id3['cover'] as List);
+ coverPfad = coverFile.path;
+ } catch (_) {}
+ }
+
+ try {
+ await db.songEinfuegen(Song(
+ titel: (id3['titel'] as String).isNotEmpty
+ ? id3['titel']
+ : title.replaceAll('.mp3', '').replaceAll('.m4a', ''),
+ kuenstler: (id3['kuenstler'] as String).isNotEmpty
+ ? id3['kuenstler']
+ : 'Melo Cloud',
+ album: id3['album'] ?? '',
+ dauerSekunden: dauer,
+ dateiPfad: dest,
+ coverPfad: coverPfad,
+ downloadQuelle: 'cloud',
+ istHeruntergeladen: true,
+ ));
+ } catch (_) {}
+ }
+ }
+
+ await _ladeStatus();
+ widget.onSongsChanged(); // Musik-Tab aktualisieren
+
+ if (mounted) {
+ setState(() {
+ _ladt = false;
+ _aktuellerDownload = '';
+ _status = '${_downloadGesamt} Songs heruntergeladen';
+ });
+ MeloLogger().aktion('cloud_download', {'count': _downloadGesamt});
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: MeloTheme.schwarz,
+ appBar: AppBar(
+ backgroundColor: MeloTheme.dunkel1,
+ title: const Row(children: [
+ Text('☁️', style: TextStyle(fontSize: 20)),
+ SizedBox(width: 8),
+ Text('Cloud Sync', style: TextStyle(color: Colors.white, fontSize: 18)),
+ ]),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
+ onPressed: _ladeStatus,
+ ),
+ ],
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ children: [
+ // Status-Karte
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: MeloTheme.dunkel1,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: MeloTheme.dunkel2),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.storage, color: MeloTheme.rot, size: 28),
+ const SizedBox(width: 12),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('$_serverCount Songs auf Server',
+ style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
+ Text(_status ?? 'Lädt...',
+ style: const TextStyle(color: Colors.grey, fontSize: 12)),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ // Upload / Download
+ Row(
+ children: [
+ Expanded(child: _btn(Icons.upload, 'Upload', _upload)),
+ const SizedBox(width: 12),
+ Expanded(child: _btn(Icons.download, 'Download', _download)),
+ ],
+ ),
+ // Download-Fortschritt
+ if (_ladt && _aktuellerDownload.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ LinearProgressIndicator(
+ value: _downloadGesamt > 0 ? _downloadFortschritt / _downloadGesamt : null,
+ backgroundColor: MeloTheme.dunkel2,
+ valueColor: const AlwaysStoppedAnimation(MeloTheme.rot),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ '$_downloadFortschritt/$_downloadGesamt: $_aktuellerDownload',
+ style: const TextStyle(color: Colors.grey, fontSize: 12),
+ maxLines: 2, overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ if (_status != null && !_ladt)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Text(_status!, style: const TextStyle(color: Colors.grey, fontSize: 13)),
+ ),
+ const SizedBox(height: 12),
+ // Sync-Einstellungen
+ const Divider(color: MeloTheme.dunkel2),
+ const Align(
+ alignment: Alignment.centerLeft,
+ child: Text('⚙ Sync-Einstellungen', style: TextStyle(color: Colors.grey, fontSize: 11)),
+ ),
+ SwitchListTile(
+ dense: true,
+ title: const Text('Auto-Sync', style: TextStyle(color: Colors.white, fontSize: 13)),
+ value: _autoSync,
+ activeColor: MeloTheme.rot,
+ onChanged: (v) async {
+ setState(() => _autoSync = v);
+ (await SharedPreferences.getInstance()).setBool('cloud_auto', v);
+ },
+ ),
+ Row(
+ children: ['Manuell', 'Alle 3h', 'Alle 6h', 'Alle 12h'].asMap().entries.map((e) {
+ final vals = [0, 3, 6, 12];
+ return Expanded(
+ child: ChoiceChip(
+ label: Text(e.value, style: TextStyle(fontSize: 10, color: _syncIntervall == vals[e.key] ? Colors.white : Colors.grey)),
+ selected: _syncIntervall == vals[e.key],
+ selectedColor: MeloTheme.rot,
+ backgroundColor: MeloTheme.dunkel2,
+ onSelected: (v) async {
+ setState(() => _syncIntervall = vals[e.key]);
+ (await SharedPreferences.getInstance()).setInt('cloud_interval', vals[e.key]);
+ },
+ ),
+ );
+ }).toList(),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _btn(IconData icon, String label, VoidCallback onTap) {
+ return ElevatedButton.icon(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: MeloTheme.dunkel1,
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ side: const BorderSide(color: MeloTheme.dunkel2),
+ ),
+ ),
+ onPressed: _ladt ? null : onTap,
+ icon: Icon(icon, size: 18, color: MeloTheme.rot),
+ label: Text(label, style: const TextStyle(color: Colors.white, fontSize: 14)),
+ );
+ }
+}
diff --git a/lib/screens/download_screen.dart b/lib/screens/download_screen.dart
index 4e8c41c..dec2811 100644
--- a/lib/screens/download_screen.dart
+++ b/lib/screens/download_screen.dart
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
+import 'package:just_audio/just_audio.dart';
import '../services/download_service.dart';
+import '../services/cloud_service.dart';
import '../utils/farb_theme.dart';
import '../services/melo_logger.dart';
+import '../widgets/melo_loader.dart';
class DownloadScreen extends StatefulWidget {
final DownloadService downloader;
@@ -19,18 +22,76 @@ class DownloadScreen extends StatefulWidget {
State createState() => _DownloadScreenState();
}
-class _DownloadScreenState extends State {
+class _DownloadScreenState extends State with WidgetsBindingObserver {
final _urlController = TextEditingController();
+ final _cloud = CloudService();
+ final _previewPlayer = AudioPlayer();
bool _ladt = false;
+ List