CRIT: - Cloud-Token jetzt in flutter_secure_storage (Keystore/Keychain) statt SharedPreferences, mit Migration alter Eintraege - usesCleartextTraffic entfernt (kein HTTP-Klartext mehr) HIGH: - Path-Traversal gefixt: p.basename bei YouTube-Download, Navidrome-Download und Cloud-Auto-Sync MED: - allowBackup=false (kein Backup von Token-Daten) - Navidrome-Salt kryptographisch sicher (Random.secure statt Timestamp)
325 lines
9.5 KiB
Dart
325 lines
9.5 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import '../database/db_helper.dart';
|
||
import '../services/player_service.dart';
|
||
import '../services/musik_scanner.dart';
|
||
import '../services/favoriten_service.dart';
|
||
import '../services/download_service.dart';
|
||
import '../services/navidrome_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/tag.dart';
|
||
|
||
class MeloHomeViewModel extends ChangeNotifier {
|
||
final PlayerService player = PlayerService();
|
||
final DbHelper db = DbHelper();
|
||
final FavoritenService favoriten = FavoritenService();
|
||
final MusikScanner scanner = MusikScanner();
|
||
final DownloadService downloader = DownloadService();
|
||
final NavidromeService navidrome = NavidromeService();
|
||
final PlaylistService playlists = PlaylistService();
|
||
final CloudService cloud = CloudService();
|
||
Timer? _autoSyncTimer;
|
||
|
||
List<Song> songs = [];
|
||
Set<String> aktiveTags = {};
|
||
bool ladt = true;
|
||
Set<int> favoritenIds = {};
|
||
List<Map<String, String>> tags = [];
|
||
List<Song> letzteSongs = [];
|
||
Map<String, int> tagCounts = {};
|
||
bool zeigeBotschaft = false;
|
||
bool serverLadt = false;
|
||
List<SubsonicAlbum> navidromeAlben = [];
|
||
Map<String, Tag> _tagsMap = {}; // Name → Tag für Filter-Lookup
|
||
Map<String, Tag> get tagsMap => _tagsMap;
|
||
|
||
int _playCount = 0;
|
||
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 = [
|
||
{'name': 'Alle', 'icon': ''},
|
||
{'name': '❤️ Für uns', 'icon': '❤️'},
|
||
{'name': 'Nightcore', 'icon': '⚡'},
|
||
{'name': 'Traurig', 'icon': '😢'},
|
||
{'name': 'Party', 'icon': '🎉'},
|
||
{'name': 'Mitsingen', 'icon': '🎤'},
|
||
{'name': '2000er', 'icon': '📀'},
|
||
];
|
||
|
||
List<Song> get gefilterteSongs {
|
||
if (aktiveTags.isEmpty) return songs;
|
||
// Favoriten-Filter
|
||
if (aktiveTags.contains('★ Favoriten')) {
|
||
return songs.where((s) => s.id != null && favoritenIds.contains(s.id)).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 {
|
||
ladt = true;
|
||
notifyListeners();
|
||
|
||
try {
|
||
// Auto-Restore der Navidrome-Session beim Start
|
||
// Dummies bereinigen
|
||
await db.alteDummiesLoeschen();
|
||
|
||
await navidrome.ladeGespeicherteZugangsdaten();
|
||
if (navidrome.istVerbunden) {
|
||
ladeNavidromeAlben();
|
||
}
|
||
|
||
// Echte Songs aus SQLite laden (ohne unspielbare Fake-Dummies)
|
||
songs = await db.alleSongs();
|
||
|
||
favoritenIds = await favoriten.favoritenIds();
|
||
letzteSongs = await db.letzteWiedergaben();
|
||
await ladeTags();
|
||
_starteCloudSyncScheduler();
|
||
} catch (e, stack) {
|
||
debugPrint('ladeSongs Fehler: $e\n$stack');
|
||
}
|
||
|
||
ladt = false;
|
||
notifyListeners();
|
||
}
|
||
|
||
Map<String, int> _berechneTagCounts() {
|
||
final counts = <String, int>{};
|
||
for (final s in songs) {
|
||
if (s.tagIds == null) continue;
|
||
for (final tagId in s.tagIds!) {
|
||
for (final entry in _tagsMap.entries) {
|
||
if (entry.value.id == tagId) {
|
||
counts[entry.key] = (counts[entry.key] ?? 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
Future<void> ladeTags() async {
|
||
var dbTags = await db.alleTags();
|
||
if (dbTags.isEmpty) {
|
||
for (final t in _defaultTags) {
|
||
if (t['name'] != 'Alle') {
|
||
await db.tagErstellen(t['name']!, icon: t['icon']);
|
||
}
|
||
}
|
||
dbTags = await db.alleTags();
|
||
}
|
||
|
||
// Tags-Map füllen
|
||
_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();
|
||
}
|
||
}
|
||
|
||
tags = _defaultTags;
|
||
tagCounts = _berechneTagCounts();
|
||
notifyListeners();
|
||
}
|
||
|
||
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
|
||
if (song.id != null) {
|
||
db.positionAktualisieren(song.id!, song.zuletztPosition ?? 0);
|
||
}
|
||
|
||
// Play-Counter für Hidden Message
|
||
_playCount++;
|
||
if (_playCount >= _botschaftSchwellwert && !zeigeBotschaft) {
|
||
zeigeBotschaft = true;
|
||
notifyListeners();
|
||
}
|
||
|
||
// Letzte Songs aktualisieren
|
||
db.letzteWiedergaben().then((liste) {
|
||
letzteSongs = liste;
|
||
notifyListeners();
|
||
});
|
||
}
|
||
|
||
/// Manuell die Botschaft "Seit 2008" anzeigen
|
||
void botschaftAnzeigen() {
|
||
zeigeBotschaft = true;
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Botschaft ausblenden
|
||
void botschaftAusblenden() {
|
||
zeigeBotschaft = false;
|
||
notifyListeners();
|
||
}
|
||
|
||
void toggleTag(String tag) {
|
||
if (tag == 'Alle') {
|
||
aktiveTags.clear();
|
||
} else if (aktiveTags.contains(tag)) {
|
||
aktiveTags.remove(tag);
|
||
} else {
|
||
aktiveTags.add(tag);
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<void> favoritenUmschalten(Song song) async {
|
||
if (song.id == null) return;
|
||
await favoriten.umschalten(song.id!);
|
||
favoritenIds = await favoriten.favoritenIds();
|
||
notifyListeners();
|
||
}
|
||
|
||
// ─── Navidrome Server-Sync ─────────────────────────
|
||
|
||
/// Navidrome-Zugangsdaten setzen
|
||
void verbindeNavidrome(String url, String user, String password) {
|
||
navidrome.setCredentials(url, user, password);
|
||
}
|
||
|
||
/// Verbindung testen und Alben laden
|
||
Future<bool> ladeNavidromeAlben() async {
|
||
if (!navidrome.istVerbunden) return false;
|
||
serverLadt = true;
|
||
notifyListeners();
|
||
final ok = await navidrome.ping();
|
||
if (ok) {
|
||
navidromeAlben = await navidrome.getAlben();
|
||
}
|
||
serverLadt = false;
|
||
notifyListeners();
|
||
return ok;
|
||
}
|
||
|
||
/// Songs eines Albums laden
|
||
Future<List<SubsonicSong>> ladeAlbumSongs(String albumId) async {
|
||
return navidrome.getSongs(albumId);
|
||
}
|
||
|
||
/// Song von Navidrome runterladen
|
||
Future<bool> downloadNavidromeSong(SubsonicSong s) async {
|
||
final song = await navidrome.downloadSong(s);
|
||
if (song != null) {
|
||
await ladeSongs();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// Song direkt vom Server streamen (ohne Download)
|
||
void starteNavidromeStream(SubsonicSong s) {
|
||
final uri = navidrome.streamUrl(s.id);
|
||
final song = Song(
|
||
titel: s.titel,
|
||
kuenstler: s.kuenstler,
|
||
album: s.album,
|
||
dauerSekunden: s.dauerSekunden,
|
||
dateiPfad: '',
|
||
groesseBytes: s.groesseBytes,
|
||
downloadQuelle: 'server',
|
||
streamUrl: uri.toString(),
|
||
);
|
||
player.spiele(song);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_autoSyncTimer?.cancel();
|
||
_positionsSub?.cancel();
|
||
player.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 = p.basename(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);
|
||
}
|
||
});
|
||
}
|
||
}
|