- 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
336 lines
12 KiB
Dart
336 lines
12 KiB
Dart
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)),
|
|
);
|
|
}
|
|
}
|