Security: Cloud-Auth auf Bearer-JWT umgestellt (IDOR-Luecke geschlossen)

- cloud_service: echter Login gegen baka-auth, Token statt X-API-Key/X-User
- app_config: hartcodierten API-Key-Default entfernt
- download_service + melo_logger: Bearer-Token statt X-API-Key
- navidrome: Passwort in flutter_secure_storage (Keychain/Keystore)
- song: token-haltige stream_url wird nicht mehr in SQLite persistiert
- cloud_screen: Pfad-Traversal beim Download-Dateinamen gefixt (p.basename)
- home_screen: Login-Dialog mit Passwort-Feld, Auto-Sync nutzt restoreLogin
This commit is contained in:
Hermes (Server)
2026-07-31 14:31:12 +02:00
parent 3e9389ab59
commit 9a880b0f21
17 changed files with 1552 additions and 181 deletions
+7
View File
@@ -43,3 +43,10 @@ kotlin {
flutter { flutter {
source = "../.." 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
}
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -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
}
}
}
-2
View File
@@ -1,6 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false android.builtInKotlin=false
+16
View File
@@ -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;
}
+3 -2
View File
@@ -10,8 +10,9 @@ class Song {
final bool istHeruntergeladen; final bool istHeruntergeladen;
final String hinzugefuegtAm; final String hinzugefuegtAm;
final String downloadQuelle; // "local", "youtube", "server" 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 int? zuletztPosition; // Sekunden, für Wiederaufnahme
Set<int>? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert)
Song({ Song({
this.id, this.id,
@@ -41,7 +42,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, 'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
'zuletzt_position': zuletztPosition, 'zuletzt_position': zuletztPosition,
}; };
+335
View File
@@ -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<CloudScreen> createState() => _CloudScreenState();
}
class _CloudScreenState extends State<CloudScreen> {
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<void> _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<void> _ladeSettings() async {
final p = await SharedPreferences.getInstance();
if (mounted) setState(() {
_autoSync = p.getBool('cloud_auto') ?? true;
_syncIntervall = p.getInt('cloud_interval') ?? 6;
});
}
Future<void> _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<File>().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<void> _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<File>()
.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<int>) {
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<int>);
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)),
);
}
}
+161 -31
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:just_audio/just_audio.dart';
import '../services/download_service.dart'; import '../services/download_service.dart';
import '../services/cloud_service.dart';
import '../utils/farb_theme.dart'; import '../utils/farb_theme.dart';
import '../services/melo_logger.dart'; import '../services/melo_logger.dart';
import '../widgets/melo_loader.dart';
class DownloadScreen extends StatefulWidget { class DownloadScreen extends StatefulWidget {
final DownloadService downloader; final DownloadService downloader;
@@ -19,18 +22,76 @@ class DownloadScreen extends StatefulWidget {
State<DownloadScreen> createState() => _DownloadScreenState(); State<DownloadScreen> createState() => _DownloadScreenState();
} }
class _DownloadScreenState extends State<DownloadScreen> { class _DownloadScreenState extends State<DownloadScreen> with WidgetsBindingObserver {
final _urlController = TextEditingController(); final _urlController = TextEditingController();
final _cloud = CloudService();
final _previewPlayer = AudioPlayer();
bool _ladt = false; bool _ladt = false;
List<Map<String, dynamic>> _globalSongs = [];
String? _previewSid;
String? _fehler; String? _fehler;
String? _erfolg; String? _erfolg;
String _speicherOrt = 'App-intern'; String _speicherOrt = 'App-intern (Music/)';
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_previewPlayer.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
_stopPreview();
}
}
Future<void> _ladeGlobalListe() async {
final songs = await _cloud.globalList();
if (mounted) setState(() => _globalSongs = songs.cast<Map<String, dynamic>>());
}
Future<void> _addFromRegistry(String sid) async {
final ok = await _cloud.download(sid, '/tmp/melo_reg_$sid.mp3');
if (ok && mounted) {
setState(() => _erfolg = 'Song hinzugefügt!');
widget.onSongsChanged();
await _ladeGlobalListe();
}
}
Future<void> _startPreview(String sid) async {
if (_previewSid == sid && _previewPlayer.playing) {
await _stopPreview();
return;
}
_previewSid = sid;
try {
final url = 'http://159.195.51.99:8993/api/cloud/stream/$sid';
await _previewPlayer.setUrl(url);
await _previewPlayer.seek(const Duration(seconds: 11));
await _previewPlayer.play();
Future.delayed(const Duration(seconds: 10), () {
if (_previewSid == sid) _stopPreview();
});
} catch (e) {
MeloLogger().fehler('preview', e);
}
}
Future<void> _stopPreview() async {
_previewSid = null;
await _previewPlayer.stop();
}
bool _speichertInDownloads = false; bool _speichertInDownloads = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_ladeSpeicherPfad(); _ladeSpeicherPfad();
_ladeGlobalListe();
} }
Future<void> _ladeSpeicherPfad() async { Future<void> _ladeSpeicherPfad() async {
@@ -63,6 +124,9 @@ class _DownloadScreenState extends State<DownloadScreen> {
const Divider(color: MeloTheme.dunkel2), const Divider(color: MeloTheme.dunkel2),
_optionTile(ctx, '⬇ Downloads/Melo', 'downloads', _optionTile(ctx, '⬇ Downloads/Melo', 'downloads',
icon: Icons.download), icon: Icons.download),
const Divider(color: MeloTheme.dunkel2),
_optionTile(ctx, '💾 SD-Karte / Extern', 'extern',
icon: Icons.sd_storage),
], ],
), ),
), ),
@@ -82,6 +146,19 @@ class _DownloadScreenState extends State<DownloadScreen> {
_speicherOrt = '⬇ Downloads/Melo'; _speicherOrt = '⬇ Downloads/Melo';
}); });
} }
} else if (auswahl == 'extern') {
final dirs = await getExternalStorageDirectories();
if (dirs != null && dirs.isNotEmpty) {
final pfad = '${dirs.first.path}/Melo';
await prefs.setBool('download_in_downloads', false);
widget.downloader.setzeSpeicherPfad(pfad);
setState(() {
_speichertInDownloads = false;
_speicherOrt = '💾 ${dirs.first.path.split('/').last}/Melo';
});
} else {
if (mounted) setState(() => _fehler = 'Kein externer Speicher gefunden');
}
} else { } else {
await prefs.setBool('download_in_downloads', false); await prefs.setBool('download_in_downloads', false);
widget.downloader.setzeSpeicherPfad(''); widget.downloader.setzeSpeicherPfad('');
@@ -139,7 +216,7 @@ class _DownloadScreenState extends State<DownloadScreen> {
title: const Row(children: [ title: const Row(children: [
Icon(Icons.download, color: MeloTheme.rot, size: 20), Icon(Icons.download, color: MeloTheme.rot, size: 20),
SizedBox(width: 8), SizedBox(width: 8),
Text('Downloads', style: TextStyle(color: Colors.white, fontSize: 18)), Text('Lied +', style: TextStyle(color: Colors.white, fontSize: 18)),
]), ]),
actions: [ actions: [
if (_erfolg != null || _fehler != null) if (_erfolg != null || _fehler != null)
@@ -153,6 +230,67 @@ class _DownloadScreenState extends State<DownloadScreen> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: [ children: [
// ─── Globale Registry (Lied +) ───
if (_globalSongs.isNotEmpty) ...[
Row(children: [
const Icon(Icons.public, color: MeloTheme.rot, size: 16),
const SizedBox(width: 6),
Text('Globale Songs (${_globalSongs.length})',
style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600)),
const Spacer(),
GestureDetector(
onTap: _ladeGlobalListe,
child: const Icon(Icons.refresh, color: Colors.grey, size: 16),
),
]),
const SizedBox(height: 8),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _globalSongs.length,
itemBuilder: (_, i) {
final s = _globalSongs[i];
final sid = s['id']?.toString() ?? '';
final title = s['title']?.toString() ?? '?';
final isPreviewing = _previewSid == sid;
return Container(
width: 140,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: isPreviewing ? const Color(0xFF2A0000) : MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isPreviewing ? MeloTheme.rot : MeloTheme.dunkel2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w500),
maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () => _startPreview(sid),
child: Icon(isPreviewing ? Icons.stop : Icons.play_arrow,
color: isPreviewing ? Colors.white : MeloTheme.rot, size: 20),
),
const SizedBox(width: 10),
GestureDetector(
onTap: () => _addFromRegistry(sid),
child: const Icon(Icons.add_circle_outline, color: Colors.grey, size: 18),
),
],
),
],
),
);
},
),
),
const Divider(color: MeloTheme.dunkel2),
],
// ─── Zielordner ─── // ─── Zielordner ───
GestureDetector( GestureDetector(
onTap: _ladt ? null : _ordnerDialog, onTap: _ladt ? null : _ordnerDialog,
@@ -199,12 +337,17 @@ class _DownloadScreenState extends State<DownloadScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// ─── Download- & Abbruch-Button ─── // ─── Animierte Ladeanzeige (während Download) ───
Row( if (_ladt) ...[
children: [ MeloLoader(
Expanded( titel: widget.downloader.aktuellerTitel ?? 'Lade herunter...',
flex: _ladt ? 3 : 1, ),
child: SizedBox( const SizedBox(height: 16),
],
// ─── Download-Button ───
SizedBox(
width: double.infinity,
height: 48, height: 48,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _ladt ? null : _starteDownload, onPressed: _ladt ? null : _starteDownload,
@@ -212,7 +355,7 @@ class _DownloadScreenState extends State<DownloadScreen> {
? const SizedBox(width: 20, height: 20, ? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download, size: 20), : const Icon(Icons.download, size: 20),
label: Text(_ladt ? 'Lade herunter...' : '⬇ Download'), label: Text(_ladt ? 'Lädt...' : '⬇ Download'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.rot, backgroundColor: MeloTheme.rot,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -220,17 +363,15 @@ class _DownloadScreenState extends State<DownloadScreen> {
), ),
), ),
), ),
),
if (_ladt) ...[ if (_ladt) ...[
const SizedBox(width: 8), const SizedBox(height: 8),
Expanded( SizedBox(
flex: 1, width: double.infinity,
child: SizedBox( height: 40,
height: 48,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _abbrechen, onPressed: _abbrechen,
icon: const Icon(Icons.cancel, size: 20), icon: const Icon(Icons.cancel, size: 18),
label: const Text('Stop'), label: const Text('Abbrechen'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade800, backgroundColor: Colors.red.shade800,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -238,20 +379,17 @@ class _DownloadScreenState extends State<DownloadScreen> {
), ),
), ),
), ),
),
], ],
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// ─── Live-Status via ListenableBuilder ─── // ─── Fortschritt ───
if (_ladt) if (_ladt)
ListenableBuilder( ListenableBuilder(
listenable: widget.downloader, listenable: widget.downloader,
builder: (context, _) { builder: (context, _) {
final status = widget.downloader.aktuellerTitel;
final fortschritt = widget.downloader.fortschritt; final fortschritt = widget.downloader.fortschritt;
if (fortschritt <= 0) return const SizedBox.shrink();
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -260,13 +398,6 @@ class _DownloadScreenState extends State<DownloadScreen> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Column(children: [ 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( LinearProgressIndicator(
value: fortschritt, value: fortschritt,
color: MeloTheme.rot, color: MeloTheme.rot,
@@ -274,7 +405,6 @@ class _DownloadScreenState extends State<DownloadScreen> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text('${(fortschritt * 100).toStringAsFixed(0)}%', Text('${(fortschritt * 100).toStringAsFixed(0)}%',
style: const TextStyle(color: Colors.grey, fontSize: 11)), style: const TextStyle(color: Colors.grey, fontSize: 11)),
],
]), ]),
); );
}, },
+373 -62
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../database/db_helper.dart';
import '../viewmodels/melo_home_viewmodel.dart'; import '../viewmodels/melo_home_viewmodel.dart';
import '../models/song.dart'; import '../models/song.dart';
import '../models/playlist.dart'; import '../models/playlist.dart';
@@ -12,6 +14,12 @@ import '../widgets/song_tile.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'; import 'download_screen.dart';
import 'cloud_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:permission_handler/permission_handler.dart';
import '../widgets/cloud_einstellungen.dart';
import '../services/cloud_service.dart';
import '../config/app_config.dart';
class MeloHome extends StatefulWidget { class MeloHome extends StatefulWidget {
const MeloHome({super.key}); const MeloHome({super.key});
@@ -22,14 +30,142 @@ class MeloHome extends StatefulWidget {
class _MeloHomeState extends State<MeloHome> { class _MeloHomeState extends State<MeloHome> {
final MeloHomeViewModel _vm = MeloHomeViewModel(); final MeloHomeViewModel _vm = MeloHomeViewModel();
final CloudService _cloud = CloudService();
int _aktiverTab = 0; int _aktiverTab = 0;
String _nutzer = 'Baka'; // Aktueller Nutzer
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ladeNutzer();
_vm.addListener(() => setState(() {}));
_vm.ladeSongs(); _vm.ladeSongs();
} }
Future<void> _ladeNutzer() async {
final p = await SharedPreferences.getInstance();
final n = p.getString('melo_nutzer') ?? '';
if (n.isNotEmpty && mounted) {
setState(() => _nutzer = n);
_vm.setzeNutzer(n);
}
}
void _zeigeProfil() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: Row(children: [
const Icon(Icons.person, color: MeloTheme.rot, size: 22),
const SizedBox(width: 8),
Text(_nutzer, style: const TextStyle(color: Colors.white, fontSize: 17)),
]),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_profilZeile(Icons.music_note, 'Lieder auf Gerät', '${_vm.songs.length}'),
_profilZeile(Icons.cloud, 'Cloud-Server', '${_cloud.status().then((s) => s?['total'] ?? 0)}'),
const Divider(color: MeloTheme.dunkel2),
ListTile(
leading: const Icon(Icons.swap_horiz, color: Colors.grey, size: 18),
title: const Text('Nutzer wechseln', style: TextStyle(color: Colors.white, fontSize: 13)),
onTap: () {
Navigator.pop(ctx);
_nutzerWechseln();
},
),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red, size: 18),
title: const Text('Abmelden', style: TextStyle(color: Colors.red, fontSize: 13)),
onTap: () async {
final p = await SharedPreferences.getInstance();
await p.remove('melo_nutzer');
if (ctx.mounted) Navigator.pop(ctx);
setState(() => _nutzer = '');
},
),
],
),
),
);
}
Widget _profilZeile(IconData icon, String label, String wert) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(children: [
Icon(icon, color: MeloTheme.rot, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12))),
Text(wert, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
]),
);
}
void _nutzerWechseln() {
final ctrl = TextEditingController(text: _nutzer);
final pwCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('👤 Anmelden', style: TextStyle(color: Colors.white, fontSize: 15)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: ctrl,
autofocus: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Nutzername...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: pwCtrl,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Passwort...',
hintStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
final name = ctrl.text.trim();
final pass = pwCtrl.text;
if (name.isEmpty || pass.isEmpty) return;
final ok = await _vm.cloud.login(name, pass);
if (!ctx.mounted) return;
Navigator.pop(ctx);
if (ok) {
final p = await SharedPreferences.getInstance();
await p.setString('melo_nutzer', name);
if (mounted) setState(() => _nutzer = name);
_vm.setzeNutzer(name);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('❌ Login fehlgeschlagen Name oder Passwort falsch')));
}
}
},
child: const Text('OK', style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
}
@override @override
void dispose() { void dispose() {
_vm.dispose(); _vm.dispose();
@@ -89,22 +225,28 @@ class _MeloHomeState extends State<MeloHome> {
final suchModus = teile.length > 1 ? teile[1] : 'Alle'; final suchModus = teile.length > 1 ? teile[1] : 'Alle';
if (suchtext.isEmpty) return; if (suchtext.isEmpty) return;
// Tag-Namen die zum Suchtext passen
final matchingTags = _vm.tags
.where((t) => t['name'] != 'Alle' && t['name']!.toLowerCase().contains(suchtext))
.map((t) => t['name']!)
.toSet();
final gefiltert = _vm.songs.where((s) { final gefiltert = _vm.songs.where((s) {
if (suchModus == 'Titel') return s.titel.toLowerCase().contains(suchtext); if (suchModus == 'Titel') return s.titel.toLowerCase().contains(suchtext);
if (suchModus == 'Künstler') return s.kuenstler.toLowerCase().contains(suchtext); if (suchModus == 'Künstler') return s.kuenstler.toLowerCase().contains(suchtext);
if (suchModus == 'Tag') { if (suchModus == 'Tag') {
return _vm.tags.any((t) => return s.tagIds != null && matchingTags.any((name) {
t['name'] != 'Alle' && final tag = _vm.tagsMap[name];
t['name']!.toLowerCase().contains(suchtext) && return tag != null && s.tagIds!.contains(tag.id);
'${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); });
} }
// Alle // Alle
if (s.titel.toLowerCase().contains(suchtext)) return true; if (s.titel.toLowerCase().contains(suchtext)) return true;
if (s.kuenstler.toLowerCase().contains(suchtext)) return true; if (s.kuenstler.toLowerCase().contains(suchtext)) return true;
return _vm.tags.any((t) => return s.tagIds != null && matchingTags.any((name) {
t['name'] != 'Alle' && final tag = _vm.tagsMap[name];
t['name']!.toLowerCase().contains(suchtext) && return tag != null && s.tagIds!.contains(tag.id);
'${s.titel} ${s.kuenstler}'.toLowerCase().contains(t['name']!.toLowerCase())); });
}).toList(); }).toList();
if (!mounted) return; if (!mounted) return;
showDialog( showDialog(
@@ -159,30 +301,79 @@ class _MeloHomeState extends State<MeloHome> {
} }
void _zeigeServerBrowser() { void _zeigeServerBrowser() {
if (_vm.navidrome.istVerbunden) {
// Abmelden
_vm.navidrome.setCredentials('', '', '');
_vm.navidromeAlben.clear();
setState(() {});
return;
}
// Verbinden: einfacher Login-Dialog
final urlCtrl = TextEditingController(text: AppConfig.navidromeUrl);
final userCtrl = TextEditingController();
final passCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('🌐 Navidrome verbinden', style: TextStyle(color: Colors.white, fontSize: 15)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: urlCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Server', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: userCtrl, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Benutzer', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
const SizedBox(height: 8),
TextField(controller: passCtrl, obscureText: true, style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(labelText: 'Passwort', border: OutlineInputBorder(),
labelStyle: TextStyle(color: Colors.grey))),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')),
TextButton(
onPressed: () async {
_vm.verbindeNavidrome(urlCtrl.text, userCtrl.text, passCtrl.text);
await _vm.ladeNavidromeAlben();
await _vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text);
if (ctx.mounted) Navigator.pop(ctx);
setState(() {});
},
child: const Text('Verbinden', style: TextStyle(color: MeloTheme.rot)),
),
],
),
);
}
void _zeigePlaylistSheet() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: MeloTheme.schwarz, backgroundColor: MeloTheme.schwarz,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => SizedBox( builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.7, height: MediaQuery.of(context).size.height * 0.7,
child: Column( child: PlaylistSheet(vm: _vm),
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40, height: 4,
decoration: BoxDecoration(
color: MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(child: NavidromeBrowser(vm: _vm)),
],
),
), ),
); );
} }
void _zeigePlaylistSheet() { void _songLoeschen(Song song) async {
if (song.id != null) {
final db = DbHelper();
await db.loeschSong(song.id!);
if (song.dateiPfad.isNotEmpty) {
try { await File(song.dateiPfad).delete(); } catch (_) {}
}
_vm.ladeSongs();
}
}
void _zeigePlaylists() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: MeloTheme.schwarz, backgroundColor: MeloTheme.schwarz,
@@ -252,8 +443,8 @@ class _MeloHomeState extends State<MeloHome> {
); );
} }
final gesamtMB = _vm.songs.isEmpty ? '0' final gesamtMB = _vm.songs.isEmpty ? '0.0'
: (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(0); : (_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1);
final gesamtMin = _vm.songs.isEmpty ? 0 final gesamtMin = _vm.songs.isEmpty ? 0
: (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round(); : (_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round();
@@ -265,29 +456,13 @@ class _MeloHomeState extends State<MeloHome> {
downloader: _vm.downloader, downloader: _vm.downloader,
onSongsChanged: _vm.ladeSongs, onSongsChanged: _vm.ladeSongs,
) )
: _aktiverTab == 3
? CloudScreen(cloud: _cloud, onSongsChanged: _vm.ladeSongs)
: Column( : Column(
children: [ children: [
MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeServerBrowser), MeloHeader(onDownload: _zeigeDownloadDialog, onSearch: _zeigeSuche, onServer: _zeigeProfil, onSettings: _zeigeEinstellungen),
// ─── EIN/AUS: RecentWidget (Zuletzt gehört) ───
// Entferne die Kommentarzeichen um RecentWidget zu aktivieren:
// RecentWidget(songs: _vm.letzteSongs, onPlay: _vm.spieleSong),
StatistikCard(
anzahlSongs: _vm.songs.length,
gesamtMB: gesamtMB,
gesamtMin: gesamtMin,
anzahlFavoriten: _vm.favoritenIds.length,
),
// ─── EIN/AUS: Hidden Message "Seit 2008" ───
// Entferne die Kommentarzeichen um die Botschaft zu aktivieren:
if (_vm.zeigeBotschaft) _botschaftBanner(), if (_vm.zeigeBotschaft) _botschaftBanner(),
TagLeiste( _tagBereich(),
tags: _vm.tags,
aktiveTags: _vm.aktiveTags,
onTagToggled: _vm.toggleTag,
),
// ─── EIN/AUS: TagStatsWidget (Tag-Counts) ───
// Entferne die Kommentarzeichen um TagStatsWidget zu aktivieren:
// TagStatsWidget(tagCounts: _vm.tagCounts),
Expanded(child: _songListe()), Expanded(child: _songListe()),
const MiniPlayer(), const MiniPlayer(),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -336,7 +511,9 @@ class _MeloHomeState extends State<MeloHome> {
), ),
), ),
Expanded( Expanded(
child: ListView.builder( child: songs.isEmpty
? _emptyStateWidget()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4), padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
itemCount: songs.length, itemCount: songs.length,
itemBuilder: (_, i) => SongTile( itemBuilder: (_, i) => SongTile(
@@ -346,6 +523,7 @@ class _MeloHomeState extends State<MeloHome> {
onPlay: _vm.spieleSong, onPlay: _vm.spieleSong,
onMetadataChanged: _vm.ladeSongs, onMetadataChanged: _vm.ladeSongs,
onAddToPlaylist: _zeigeAddToPlaylist, onAddToPlaylist: _zeigeAddToPlaylist,
onDelete: _songLoeschen,
), ),
), ),
), ),
@@ -353,6 +531,87 @@ class _MeloHomeState extends State<MeloHome> {
); );
} }
void _zeigeEinstellungen() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: MeloTheme.dunkel1,
title: const Text('⚙ Einstellungen', style: TextStyle(color: Colors.white, fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Stats
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: MeloTheme.dunkel2, borderRadius: BorderRadius.circular(10)),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
_statWert('${_vm.songs.length}', 'Songs'),
_statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.groesseBytes) / 1048576).toStringAsFixed(1)} MB', 'Größe'),
_statWert('${(_vm.songs.fold(0, (int s, Song song) => s + song.dauerSekunden) / 60).round()} Min', 'Dauer'),
_statWert('${_vm.favoritenIds.length}', '❤️'),
]),
),
ListTile(
leading: const Icon(Icons.favorite, color: MeloTheme.rot),
title: const Text('Favoriten', style: TextStyle(color: Colors.white)),
subtitle: const Text('Deine Lieblingssongs', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () {
Navigator.pop(ctx);
_vm.aktiveTags = {'★ Favoriten'};
},
),
ListTile(
leading: const Icon(Icons.cloud, color: MeloTheme.rot),
title: const Text('Cloud Sync', style: TextStyle(color: Colors.white)),
subtitle: const Text('Auto-Sync & Einstellungen', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () {
Navigator.pop(ctx);
showDialog(
context: context,
builder: (_) => const CloudEinstellungen(),
);
},
),
ListTile(
leading: const Icon(Icons.dns, color: MeloTheme.rot),
title: const Text('Server verbinden', style: TextStyle(color: Colors.white)),
subtitle: const Text('Navidrome / Musik-Server', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () { Navigator.pop(ctx); _zeigeServerBrowser(); },
),
ListTile(
leading: const Icon(Icons.person, color: MeloTheme.rot),
title: const Text('Profil', style: TextStyle(color: Colors.white)),
subtitle: Text(_nutzer.isEmpty ? 'Nicht angemeldet' : _nutzer, style: const TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () { Navigator.pop(ctx); _zeigeProfil(); },
),
SwitchListTile(
title: const Text('Diagnosedaten senden', style: TextStyle(color: Colors.white, fontSize: 13)),
subtitle: const Text('Absturz-Logs & Nutzungsdaten', style: TextStyle(color: Colors.grey, fontSize: 11)),
value: AppConfig.sendeDiagnosedaten,
activeColor: MeloTheme.rot,
onChanged: (v) {
AppConfig.sendeDiagnosedaten = v;
},
),
if (Platform.isAndroid)
ListTile(
leading: const Icon(Icons.folder_open, color: MeloTheme.rot),
title: const Text('Voller Speicherzugriff', style: TextStyle(color: Colors.white)),
subtitle: const Text('Zum Speichern in Downloads/Music', style: TextStyle(color: Colors.grey, fontSize: 12)),
onTap: () async {
final status = await Permission.manageExternalStorage.request();
if (status.isGranted) {
await SharedPreferences.getInstance().then((p) => p.setBool('manage_storage', true));
}
},
),
],
),
),
);
}
Widget _bottomNav() { Widget _bottomNav() {
return Container( return Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -363,18 +622,21 @@ class _MeloHomeState extends State<MeloHome> {
backgroundColor: MeloTheme.schwarz, backgroundColor: MeloTheme.schwarz,
selectedItemColor: MeloTheme.rot, selectedItemColor: MeloTheme.rot,
unselectedItemColor: MeloTheme.textSekundaer, unselectedItemColor: MeloTheme.textSekundaer,
currentIndex: _aktiverTab, currentIndex: _aktiverTab.clamp(0, 3),
onTap: (i) { onTap: (i) {
setState(() => _aktiverTab = i); setState(() => _aktiverTab = i);
if (i == 2) _zeigePlaylistSheet(); // Tags-Tab → Playlists if (i == 0 && _vm.aktiveTags.contains('★ Favoriten')) {
if (i == 3) _zeigePlaylistSheet(); // Favoriten-Tab → Playlists _vm.aktiveTags.clear();
} else if (i == 2) {
// Playlisten öffnen
_zeigePlaylists();
}
}, },
items: const [ items: const [
BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'), BottomNavigationBarItem(icon: Icon(Icons.music_note, size: 22), label: 'Musik'),
BottomNavigationBarItem(icon: Icon(Icons.download, size: 22), label: 'Downloads'), BottomNavigationBarItem(icon: Icon(Icons.add_circle, size: 22), label: 'Lied +'),
BottomNavigationBarItem(icon: Icon(Icons.label, size: 22), label: 'Tags'), BottomNavigationBarItem(icon: Icon(Icons.queue_music, size: 22), label: 'Playlisten'),
BottomNavigationBarItem(icon: Icon(Icons.favorite, size: 22), label: 'Favoriten'), BottomNavigationBarItem(icon: Icon(Icons.cloud, size: 22), label: 'Cloud'),
BottomNavigationBarItem(icon: Icon(Icons.settings, size: 22), label: 'Einstellungen'),
], ],
), ),
); );
@@ -411,15 +673,8 @@ class _MeloHomeState extends State<MeloHome> {
const Text('💌', style: TextStyle(fontSize: 20)), const Text('💌', style: TextStyle(fontSize: 20)),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Text(_vm.nutzerBotschaft ?? '🎵 Danke fürs Zuhören!',
crossAxisAlignment: CrossAxisAlignment.start, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
children: const [
Text('Seit 2008',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
Text('Danke, dass du immer da bist ♥',
style: TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
],
),
), ),
GestureDetector( GestureDetector(
onTap: _vm.botschaftAusblenden, onTap: _vm.botschaftAusblenden,
@@ -430,4 +685,60 @@ class _MeloHomeState extends State<MeloHome> {
), ),
); );
} }
/// Leerer Zustand wenn keine Songs in der Bibliothek sind
Widget _emptyStateWidget() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.library_music_outlined, size: 56, color: MeloTheme.rot.withValues(alpha: 0.5)),
const SizedBox(height: 16),
const Text('Noch keine Songs in Melo',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Füge Songs über "Lied +" hinzu, verbinde deinen Server oder starte einen lokalen Scan.',
textAlign: TextAlign.center,
style: TextStyle(color: MeloTheme.textSekundaer, fontSize: 12, height: 1.4)),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _scanMusik,
icon: const Icon(Icons.search, size: 16),
label: const Text('Jetzt Musik scannen'),
style: ElevatedButton.styleFrom(
backgroundColor: MeloTheme.dunkel1,
foregroundColor: Colors.white,
side: const BorderSide(color: MeloTheme.dunkel2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
);
}
Widget _tagBereich() {
return AnimatedSize(
duration: const Duration(milliseconds: 200),
child: _tagsOffen
? TagLeiste(
tags: _vm.tags,
aktiveTags: _vm.aktiveTags,
onTagToggled: _vm.toggleTag,
)
: const SizedBox.shrink(),
);
}
Widget _statWert(String wert, String label) {
return Column(children: [
Text(wert, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700)),
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 10)),
]);
}
bool _tagsOffen = false;
} }
+208
View File
@@ -0,0 +1,208 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../services/melo_logger.dart';
/// Cloud-Sync Service für Melo Registry.
/// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort).
/// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke).
class CloudService {
static String get _base => AppConfig.cloudUrl;
static String get _authBase => AppConfig.authUrl;
String _user = '';
String _token = '';
String get user => _user;
String get token => _token;
bool get istAngemeldet => _token.isNotEmpty;
/// Echter Login gegen den Baka-Auth-Server.
/// Der Token wird gespeichert und bei allen Cloud-Calls als
/// Authorization: Bearer `token` mitgeschickt.
Future<bool> login(String user, String pass) async {
try {
final r = await http
.post(Uri.parse('$_authBase/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': user, 'password': pass}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
if (d['status'] == 'ok' && d['token'] != null) {
_user = d['username'] as String? ?? user;
_token = d['token'] as String;
await _speichereToken();
MeloLogger.cloudToken = _token;
return true;
}
}
} catch (_) {}
return false;
}
/// Stellt gespeicherten Token wieder her (Auto-Login nach App-Start).
Future<bool> restoreLogin() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString('melo_cloud_token') ?? '';
if (t.isEmpty) return false;
_token = t;
_user = prefs.getString('melo_cloud_user') ?? '';
MeloLogger.cloudToken = t;
return true;
}
Future<void> logout() async {
_token = '';
_user = '';
MeloLogger.cloudToken = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('melo_cloud_token');
await prefs.remove('melo_cloud_user');
}
Future<void> _speichereToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('melo_cloud_token', _token);
await prefs.setString('melo_cloud_user', _user);
}
Map<String, String> get _authHeader => {
if (_token.isNotEmpty) 'Authorization': 'Bearer $_token',
};
Future<Map?> status() => _get('/api/cloud/status');
Future<List<Map>> listSongs() async {
final r = await _get('/api/cloud/list');
return List<Map>.from(r?['songs'] ?? []);
}
Future<String?> upload(String filepath, String filename) async {
try {
final req = http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
req.headers.addAll(_authHeader);
req.files.add(await http.MultipartFile.fromPath('file', filepath,
filename: filename));
final resp = await req.send().timeout(const Duration(seconds: 120));
final body = jsonDecode(await resp.stream.bytesToString());
return body['song_id'] as String?;
} catch (e) {
MeloLogger().fehler('cloud_upload', e);
return null;
}
}
Future<bool> download(String songId, String destPath) async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/download/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 120));
if (r.statusCode == 200) {
final file = File(destPath);
if (!await file.parent.exists()) {
await file.parent.create(recursive: true);
}
await file.writeAsBytes(r.bodyBytes);
return true;
}
return false;
} catch (e) {
MeloLogger().fehler('cloud_download', e);
return false;
}
}
Future<bool> delete(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/delete'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
Future<Map?> _get(String path) async {
try {
final r = await http
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<String?> share(List<String> songIds) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/share'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/import'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<List<Map>> syncChanges(String since) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/sync'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return List<Map>.from(d['changes'] ?? []);
}
} catch (_) {}
return [];
}
Future<List<Map>> globalList() async {
try {
final r = await http
.get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
}
} catch (_) {}
return [];
}
Future<bool> toggleGlobal(String songId) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/toggle-global'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
}
+13 -2
View File
@@ -7,6 +7,7 @@ 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'; import 'melo_logger.dart';
import 'cloud_service.dart';
/// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter. /// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter.
/// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder. /// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder.
@@ -16,8 +17,10 @@ class DownloadService extends ChangeNotifier {
DownloadService._(); DownloadService._();
static const String _proxyBasisUrl = 'https://yt.baka-net.de'; static const String _proxyBasisUrl = 'https://yt.baka-net.de';
static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b'; static Map<String, String> get _authHeader {
static const Map<String, String> _authHeader = {'X-API-Key': _apiKey}; final t = CloudService().token;
return {if (t.isNotEmpty) 'Authorization': 'Bearer $t'};
}
final DbHelper _db = DbHelper(); final DbHelper _db = DbHelper();
@@ -146,6 +149,14 @@ class DownloadService extends ChangeNotifier {
String? dateiPfad; String? dateiPfad;
try { try {
// Login-Check: yt-proxy verlangt jetzt einen gültigen Cloud-Token
if (!CloudService().istAngemeldet) {
_fehler = 'Bitte zuerst in der Cloud anmelden (Cloud-Tab)';
_ladt = false;
notifyListeners();
return null;
}
// 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';
+10 -2
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config/app_config.dart';
/// Umfassendes Log-System für die Melo App. /// Umfassendes Log-System für die Melo App.
/// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance. /// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance.
@@ -12,6 +13,9 @@ class MeloLogger {
factory MeloLogger() => _instanz; factory MeloLogger() => _instanz;
MeloLogger._(); MeloLogger._();
/// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen.
static String? cloudToken;
bool _initialisiert = false; bool _initialisiert = false;
String _sessionId = ''; String _sessionId = '';
String _version = '2.7'; String _version = '2.7';
@@ -19,7 +23,7 @@ class MeloLogger {
int _logId = 0; int _logId = 0;
Timer? _flushTimer; Timer? _flushTimer;
static const String _serverUrl = 'http://159.195.51.99:8991/api/log'; String get _serverUrl => '${AppConfig.logUrl}/api/log';
void init(String version) { void init(String version) {
if (_initialisiert) return; if (_initialisiert) return;
@@ -94,6 +98,7 @@ class MeloLogger {
Future<void> _senden() async { Future<void> _senden() async {
if (_eintraege.isEmpty) return; if (_eintraege.isEmpty) return;
if (!AppConfig.sendeDiagnosedaten) return;
final batch = _eintraege.toList(); final batch = _eintraege.toList();
_eintraege.clear(); _eintraege.clear();
@@ -101,7 +106,10 @@ class MeloLogger {
try { try {
await http.post( await http.post(
Uri.parse(_serverUrl), Uri.parse(_serverUrl),
headers: {'Content-Type': 'application/json'}, headers: {
'Content-Type': 'application/json',
if (cloudToken != null) 'Authorization': 'Bearer $cloudToken',
},
body: jsonEncode({ body: jsonEncode({
'typ': 'log_batch', 'typ': 'log_batch',
'session': _sessionId, 'session': _sessionId,
+29
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../models/song.dart'; import '../models/song.dart';
import '../database/db_helper.dart'; import '../database/db_helper.dart';
@@ -63,6 +64,10 @@ class SubsonicAlbum {
class NavidromeService { class NavidromeService {
final DbHelper _db = DbHelper(); final DbHelper _db = DbHelper();
/// Passwort & Zugangsdaten liegen verschlüsselt im Keychain/Keystore
/// (flutter_secure_storage) statt im Klartext in SharedPreferences.
static const _secure = FlutterSecureStorage();
String _serverUrl = ''; String _serverUrl = '';
String _user = ''; String _user = '';
String _password = ''; String _password = '';
@@ -80,6 +85,30 @@ class NavidromeService {
_token = md5.convert(utf8.encode(_password + _salt)).toString(); _token = md5.convert(utf8.encode(_password + _salt)).toString();
} }
Future<void> ladeGespeicherteZugangsdaten() async {
try {
final url = await _secure.read(key: 'navidrome_url');
final user = await _secure.read(key: 'navidrome_user');
final pass = await _secure.read(key: 'navidrome_pass');
if (url != null && user != null && pass != null && url.isNotEmpty) {
setCredentials(url, user, pass);
}
} catch (e) {
debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e');
}
}
Future<void> speichereZugangsdaten(String url, String user, String password) async {
setCredentials(url, user, password);
try {
await _secure.write(key: 'navidrome_url', value: url);
await _secure.write(key: 'navidrome_user', value: user);
await _secure.write(key: 'navidrome_pass', value: password);
} catch (e) {
debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e');
}
}
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty; bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
Uri _uri(String endpoint, [Map<String, String>? extra]) { Uri _uri(String endpoint, [Map<String, String>? extra]) {
+129 -34
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../database/db_helper.dart'; import '../database/db_helper.dart';
import '../services/player_service.dart'; import '../services/player_service.dart';
@@ -6,7 +7,13 @@ import '../services/favoriten_service.dart';
import '../services/download_service.dart'; 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 '../services/cloud_service.dart';
import '../services/melo_logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
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();
@@ -16,6 +23,8 @@ class MeloHomeViewModel extends ChangeNotifier {
final DownloadService downloader = DownloadService(); final DownloadService downloader = DownloadService();
final NavidromeService navidrome = NavidromeService(); final NavidromeService navidrome = NavidromeService();
final PlaylistService playlists = PlaylistService(); final PlaylistService playlists = PlaylistService();
final CloudService cloud = CloudService();
Timer? _autoSyncTimer;
List<Song> songs = []; List<Song> songs = [];
Set<String> aktiveTags = {}; Set<String> aktiveTags = {};
@@ -27,9 +36,39 @@ class MeloHomeViewModel extends ChangeNotifier {
bool zeigeBotschaft = false; bool zeigeBotschaft = false;
bool serverLadt = false; bool serverLadt = false;
List<SubsonicAlbum> navidromeAlben = []; List<SubsonicAlbum> navidromeAlben = [];
Map<String, Tag> _tagsMap = {}; // Name → Tag für Filter-Lookup
Map<String, Tag> get tagsMap => _tagsMap;
int _playCount = 0; int _playCount = 0;
static const int _botschaftSchwellwert = 10; static const int _botschaftSchwellwert = 10;
String? _nutzerBotschaft;
/// Per-User Easteregg-Botschaften
static const _botschaften = {
'Baka': '💌 Seit 2008 Danke, dass du immer da bist ♥',
'Tinker': '🎀 Für meine beste Freundin Melo & Melo 💕',
};
String? get nutzerBotschaft => _nutzerBotschaft;
void setzeNutzer(String name) {
_nutzerBotschaft = _botschaften[name];
}
StreamSubscription<Duration>? _positionsSub;
int _letzteGespeicherteSekunde = -1;
void _startePositionTracking() {
_positionsSub?.cancel();
_letzteGespeicherteSekunde = -1;
_positionsSub = player.positionStream.listen((pos) {
final s = player.aktuellerSong;
final sek = pos.inSeconds;
if (s?.id != null && sek > 0 && sek % 10 == 0 && sek != _letzteGespeicherteSekunde) {
_letzteGespeicherteSekunde = sek;
db.positionAktualisieren(s!.id!, sek);
}
});
}
static const _defaultTags = [ static const _defaultTags = [
{'name': 'Alle', 'icon': ''}, {'name': 'Alle', 'icon': ''},
@@ -43,10 +82,18 @@ class MeloHomeViewModel extends ChangeNotifier {
List<Song> get gefilterteSongs { List<Song> get gefilterteSongs {
if (aktiveTags.isEmpty) return songs; if (aktiveTags.isEmpty) return songs;
return songs.where((s) => // Favoriten-Filter
aktiveTags.any((tag) => if (aktiveTags.contains('★ Favoriten')) {
s.titel.contains(tag) || s.kuenstler.contains(tag)) return songs.where((s) => s.id != null && favoritenIds.contains(s.id)).toList();
).toList(); }
// Filter: Songs mit ALLEN aktiven Tags (AND-Logik)
return songs.where((s) {
if (s.tagIds == null) return false;
return aktiveTags.every((tagName) {
final tag = _tagsMap[tagName];
return tag != null && s.tagIds!.contains(tag.id);
});
}).toList();
} }
Future<void> ladeSongs() async { Future<void> ladeSongs() async {
@@ -54,29 +101,22 @@ class MeloHomeViewModel extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
var alle = await db.alleSongs(); // Auto-Restore der Navidrome-Session beim Start
// Dummies bereinigen
await db.alteDummiesLoeschen();
if (alle.isEmpty) { await navidrome.ladeGespeicherteZugangsdaten();
final beispiele = [ if (navidrome.istVerbunden) {
Song(titel: 'Leichtes Gepäck', kuenstler: 'Silbermond', album: 'Schritte', ladeNavidromeAlben();
dauerSekunden: 232, dateiPfad: '', groesseBytes: 5242880, istHeruntergeladen: true),
Song(titel: 'Auf uns', kuenstler: 'Andreas Bourani', album: 'Hey',
dauerSekunden: 241, dateiPfad: '', groesseBytes: 4819200, istHeruntergeladen: true),
Song(titel: '80 Millionen', kuenstler: 'Max Giesinger', album: 'Der Junge',
dauerSekunden: 225, dateiPfad: '', groesseBytes: 5107200, istHeruntergeladen: true),
Song(titel: 'Tage wie diese', kuenstler: 'Die Toten Hosen', album: 'Ballast',
dauerSekunden: 252, dateiPfad: '', groesseBytes: 4300800, istHeruntergeladen: true),
Song(titel: 'Atlantis', kuenstler: 'Frida Gold', album: 'Liebe',
dauerSekunden: 228, dateiPfad: '', groesseBytes: 3987200, istHeruntergeladen: true),
];
await db.songsEinfuegen(beispiele);
alle = await db.alleSongs();
} }
songs = alle; // Echte Songs aus SQLite laden (ohne unspielbare Fake-Dummies)
songs = await db.alleSongs();
favoritenIds = await favoriten.favoritenIds(); favoritenIds = await favoriten.favoritenIds();
letzteSongs = await db.letzteWiedergaben(); letzteSongs = await db.letzteWiedergaben();
await ladeTags(); await ladeTags();
_starteCloudSyncScheduler();
} catch (e, stack) { } catch (e, stack) {
debugPrint('ladeSongs Fehler: $e\n$stack'); debugPrint('ladeSongs Fehler: $e\n$stack');
} }
@@ -88,11 +128,12 @@ class MeloHomeViewModel extends ChangeNotifier {
Map<String, int> _berechneTagCounts() { Map<String, int> _berechneTagCounts() {
final counts = <String, int>{}; final counts = <String, int>{};
for (final s in songs) { for (final s in songs) {
for (final t in tags) { if (s.tagIds == null) continue;
final name = t['name']!; for (final tagId in s.tagIds!) {
if (name == 'Alle') continue; for (final entry in _tagsMap.entries) {
if (s.titel.contains(name) || s.kuenstler.contains(name)) { if (entry.value.id == tagId) {
counts[name] = (counts[name] ?? 0) + 1; counts[entry.key] = (counts[entry.key] ?? 0) + 1;
}
} }
} }
} }
@@ -109,19 +150,37 @@ class MeloHomeViewModel extends ChangeNotifier {
} }
dbTags = await db.alleTags(); dbTags = await db.alleTags();
} }
tags = [{'name': 'Alle', 'icon': ''}, ...dbTags.map((t) => t.toDisplay())];
// Tag-Counts nachladen wenn Tags geladen // Tags-Map füllen
tagCounts = _berechneTagCounts(); _tagsMap = {for (final t in dbTags) t.name: t};
// Tag-IDs für jeden Song laden
for (final song in songs) {
if (song.id != null) {
final songTags = await db.tagsFuerSong(song.id!);
song.tagIds = songTags.map((t) => t.id!).toSet();
}
} }
void spieleSong(Song song) { tags = _defaultTags;
player.setWarteschlange(songs, tagCounts = _berechneTagCounts();
startIndex: songs.indexWhere((s) => s.id == song.id)); notifyListeners();
player.spiele(song); }
void spieleSong(Song song, {List<Song>? warteschlange}) {
player.setWarteschlange(warteschlange ?? songs,
startIndex: (warteschlange ?? songs).indexWhere((s) => s.id == song.id));
// Wiederaufnahme
final pos = (song.zuletztPosition != null && song.zuletztPosition! > 5)
? song.zuletztPosition! : 0;
player.spiele(song, position: pos);
// Position-Tracking starten (alle 10s speichern)
_startePositionTracking();
// In Verlauf speichern // In Verlauf speichern
if (song.id != null) { if (song.id != null) {
db.positionAktualisieren(song.id!, 0); db.positionAktualisieren(song.id!, song.zuletztPosition ?? 0);
} }
// Play-Counter für Hidden Message // Play-Counter für Hidden Message
@@ -222,7 +281,43 @@ class MeloHomeViewModel extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_autoSyncTimer?.cancel();
_positionsSub?.cancel();
player.dispose(); player.dispose();
super.dispose(); super.dispose();
} }
Future<void> _starteCloudSyncScheduler() async {
_autoSyncTimer?.cancel();
final prefs = await SharedPreferences.getInstance();
final autoSync = prefs.getBool('cloud_auto') ?? true;
final intervalStunden = prefs.getInt('cloud_interval') ?? 6;
if (!autoSync || intervalStunden <= 0) return;
_autoSyncTimer = Timer.periodic(Duration(hours: intervalStunden), (_) async {
try {
await cloud.restoreLogin();
if (!cloud.istAngemeldet) return;
final serverSongs = await cloud.listSongs();
if (serverSongs.isEmpty) return;
// Nur neue Songs herunterladen
final dir = Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
final localFiles = dir.listSync().whereType<File>()
.map((f) => f.path.split('/').last).toSet();
for (final s in serverSongs) {
final title = s['title'].toString();
if (localFiles.contains(title)) continue; // schon lokal
await cloud.download(s['id'].toString(), '${dir.path}/$title');
}
await ladeSongs(); // UI aktualisieren
} catch (e) {
MeloLogger().fehler('background_auto_sync', e);
}
});
}
} }
+160
View File
@@ -0,0 +1,160 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/farb_theme.dart';
import '../services/melo_logger.dart';
/// Animierte Ladeanzeige mit Easter Eggs für Melo
class MeloLoader extends StatefulWidget {
final String titel;
final String? subtitle;
const MeloLoader({super.key, required this.titel, this.subtitle});
@override
State<MeloLoader> createState() => _MeloLoaderState();
}
class _MeloLoaderState extends State<MeloLoader>
with TickerProviderStateMixin {
late AnimationController _pulsCtrl;
late AnimationController _equalCtrl;
late Animation<double> _pulsAnim;
int _downloadCount = 0;
String? _easterEgg;
static const _easterEggs = [
'🎵 Baka hat dich programmiert!',
'🔥 Melo > Spotify. Change my mind.',
'💀 Dieser Download kostet dich 5 Jahre Lebenszeit.',
'🎸 Fun Fact: Dieser Song wurde von einem Server-Proxy gerettet!',
'🐧 Linux > Windows. (Server-Fakt)',
'🍎 SideStore > iLoader. Immer.',
'🛡️ Geschützt von Caddy + Basic Auth.',
'🤫 Melo ist besser als YouTube Music.',
'⚡ Server: Aingrad, Netcup RS 1000.',
'💾 yt-dlp 2026.03.17 Updates sind wichtig.',
'🎯 Der Proxy läuft auf Systemd stabil.',
'🧠 Dustin hat das selbst gecoded!',
'📱 iOS + Android + Linux = schon 3/5!',
'🐛 Crash-Logs auf crash.baka-net.de',
'🎵 Navidrome: musik.baka-net.de',
'🗿 Du hast den Stein der Weisen gefunden.',
'🧙 Die Antwort ist 42.',
'⚔️ SDS Origin Guide im Vault.',
];
@override
void initState() {
super.initState();
_pulsCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_pulsAnim = Tween(begin: 0.6, end: 1.0).animate(
CurvedAnimation(parent: _pulsCtrl, curve: Curves.easeInOut),
);
_equalCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..repeat();
_ladeCount();
}
Future<void> _ladeCount() async {
try {
final prefs = await SharedPreferences.getInstance();
_downloadCount = prefs.getInt('dl_count') ?? 0;
_downloadCount++;
await prefs.setInt('dl_count', _downloadCount);
MeloLogger().aktion('dl_count', {'c': _downloadCount});
if (_downloadCount % 10 == 0 && mounted) {
final rng = Random(_downloadCount * 7);
final msg = _easterEggs[rng.nextInt(_easterEggs.length)];
setState(() => _easterEgg = msg);
}
} catch (_) {}
}
@override
void dispose() {
_pulsCtrl.dispose();
_equalCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 40,
child: AnimatedBuilder(
animation: _equalCtrl,
builder: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) {
final phase = (i * 0.4 + _equalCtrl.value * 2 * pi);
final h = (sin(phase).abs() * 24 + 8);
return Container(
width: 4,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: MeloTheme.rot.withValues(alpha: _pulsAnim.value),
borderRadius: BorderRadius.circular(2),
),
);
}),
),
),
),
const SizedBox(height: 12),
FadeTransition(
opacity: _pulsAnim,
child: Text(
widget.titel,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 15),
),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 4),
Text(widget.subtitle!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
if (_easterEgg != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: MeloTheme.dunkel1,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: MeloTheme.rot.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🥚', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Flexible(
child: Text(
_easterEgg!,
style: const TextStyle(color: MeloTheme.rot, fontSize: 12),
),
),
],
),
),
],
],
);
}
}
+68 -12
View File
@@ -97,14 +97,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +113,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -154,10 +154,58 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "5.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -268,10 +316,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "5.1.1"
logging: logging:
dependency: transitive dependency: transitive
description: description:
@@ -661,6 +709,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+2 -4
View File
@@ -28,14 +28,12 @@ dependencies:
# HTTP (Navidrome Server-Sync) # HTTP (Navidrome Server-Sync)
http: ^1.2.0 http: ^1.2.0
crypto: ^3.0.6 crypto: ^3.0.6
flutter_secure_storage: ^10.3.1
# UI
cupertino_icons: ^1.0.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^5.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true