Fix: Review-Findings korrigiert (Tag-Leiste, Profil, Singleton u.a.)

CRIT:
- Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats
- Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr)
HIGH:
- CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel)
- StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt)
- CloudService als Singleton (konsistenter Token-Zustand in allen Services)
MED/LOW:
- Playlist-Erkennung nur noch via list= Parameter
- Player: fehlende Quelle wird geloggt statt still
- song_tile: null-ID-Guard vor Tag-Dialog
- Scanner-Log mit Exception-Objekt
- FavoritenService: anzahlFavoriten() fuer StatistikCard
This commit is contained in:
Hermes (Server)
2026-07-31 15:42:54 +02:00
parent b3aedc53f9
commit 8b6a04ead8
18 changed files with 767 additions and 503 deletions
+95 -52
View File
@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/song.dart';
import '../database/db_helper.dart';
import 'id3_reader.dart';
import 'melo_logger.dart';
class MusikScanner {
static final MusikScanner _instanz = MusikScanner._();
@@ -17,8 +19,22 @@ class MusikScanner {
int get anzahlNeueSongs => _neueSongs;
Future<bool> frageSpeicherZugriff() async {
final status = await Permission.audio.request();
return status.isGranted;
// App-interner Speicher (Cloud-Downloads) benötigt NIE Berechtigungen
if (Platform.isIOS) return true;
if (Platform.isAndroid) {
// Android 13+: READ_MEDIA_AUDIO
var status = await Permission.audio.status;
if (!status.isGranted) status = await Permission.audio.request();
if (status.isGranted) return true;
// Fallback für Android 10 und älter
var storageStatus = await Permission.storage.status;
if (!storageStatus.isGranted) storageStatus = await Permission.storage.request();
return storageStatus.isGranted;
}
return true;
}
Future<List<Song>> scanneMusikOrdner() async {
@@ -33,25 +49,45 @@ class MusikScanner {
final file = File(pfad);
if (!await file.exists()) continue;
final stat = await file.stat();
final tags = Id3Reader.lesen(pfad);
// Cover-Bytes als Bilddatei speichern
String? coverPfad;
if (tags['cover'] != null && tags['cover'] is List<int>) {
final bytes = tags['cover'] as List<int>;
if (bytes.isNotEmpty) {
try {
final docDir = await getApplicationDocumentsDirectory();
final coversDir = Directory('${docDir.path}/covers');
if (!await coversDir.exists()) await coversDir.create(recursive: true);
final fileHash = pfad.hashCode.abs();
final coverFile = File('${coversDir.path}/cover_$fileHash.jpg');
await coverFile.writeAsBytes(bytes);
coverPfad = coverFile.path;
} catch (e) {
MeloLogger().fehler('cover_speichern', e);
}
}
}
gefunden.add(Song(
titel: _dateiNameOhneEndung(pfad),
kuenstler: 'Unbekannt',
album: '',
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
album: tags['album'] ?? '',
dauerSekunden: await _ermittleDauer(player, pfad),
dateiPfad: pfad,
coverPfad: null,
coverPfad: coverPfad,
groesseBytes: stat.size,
istHeruntergeladen: true,
downloadQuelle: 'local',
));
} catch (_) {
// Datei nicht lesbar → überspringen
} catch (e) {
MeloLogger().fehler('scanner_datei', e);
continue;
}
}
try {
await player.dispose();
} catch (_) {}
try { await player.dispose(); } catch (_) {}
// In DB speichern
final vorhandene = await _db.alleSongs();
@@ -68,56 +104,60 @@ class MusikScanner {
Future<List<String>> _sammleMusikPfade() async {
final pfade = <String>{};
// Typische Musik-Ordner auf Android
final ordner = [
'/storage/emulated/0/Music',
'/storage/emulated/0/Download',
'/storage/emulated/0/Musik',
'/storage/emulated/0/Downloads',
'/sdcard/Music',
'/sdcard/Download',
'/sdcard/Musik',
];
// Externe SD-Karte (falls vorhanden)
// 1. PRIMÄR: App-interner + Downloads-Ordner
try {
final extern = await getExternalStorageDirectory();
if (extern != null) {
ordner.add(extern.path);
}
} catch (_) {}
final appDir = await getApplicationDocumentsDirectory();
final internDir = Directory('${appDir.path}/music');
if (await internDir.exists()) await _durchsucheOrdner(internDir, pfade);
// Android Media Store (bessere Methode)
try {
final pfadeVonMediaStore = await _scanneViaMediaStore();
pfade.addAll(pfadeVonMediaStore);
} catch (_) {}
// Fallback: Dateisystem durchsuchen
for (final ord in ordner) {
try {
final dir = Directory(ord);
if (await dir.exists()) {
await _durchsucheOrdner(dir, pfade);
// Downloads/Melo (für Dateimanager sichtbar)
if (Platform.isAndroid) {
final dlDir = await getDownloadsDirectory();
if (dlDir != null) {
final meloDir = Directory('${dlDir.path}/Melo');
if (await meloDir.exists()) await _durchsucheOrdner(meloDir, pfade);
}
}
} catch (e) {
MeloLogger().fehler('scanner_intern_pfad', e);
}
// 2. Externe System-Ordner (nur Android)
if (Platform.isAndroid) {
final ordner = <String>[
'/storage/emulated/0/Music',
'/storage/emulated/0/Download',
'/storage/emulated/0/Musik',
'/storage/emulated/0/Downloads',
'/sdcard/Music',
'/sdcard/Download',
];
try {
final extern = await getExternalStorageDirectory();
if (extern != null) ordner.add(extern.path);
} catch (_) {}
for (final ord in ordner) {
try {
final dir = Directory(ord);
if (await dir.exists()) {
await _durchsucheOrdner(dir, pfade);
}
} catch (e) {
MeloLogger().fehler('scanner_ordner_zugriff_${ord.split('/').last}', e);
}
}
}
return pfade.toList();
}
Future<List<String>> _scanneViaMediaStore() async {
// Nutzt Android's MediaStore Query
// Wird über Method Channel in native Android implementiert
// Für v1: Fallback auf Dateisystem-Suche
return [];
}
Future<void> _durchsucheOrdner(Directory dir, Set<String> pfade, {int tiefe = 0}) async {
if (tiefe > 4) return;
try {
await for (final entity in dir.list(followLinks: false)) {
final stream = dir.list(followLinks: false);
await for (final entity in stream.handleError((_) {})) {
if (entity is File) {
final ext = entity.path.toLowerCase();
if (ext.endsWith('.mp3') || ext.endsWith('.m4a') ||
@@ -129,15 +169,18 @@ class MusikScanner {
await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1);
}
}
} catch (_) {}
} catch (e) {
MeloLogger().fehler('scanner_durchsuchen_fehler', '$dir: $e');
}
}
Future<int> _ermittleDauer(AudioPlayer player, String pfad) async {
try {
await player.setFilePath(pfad);
await player.setFilePath(pfad).timeout(const Duration(milliseconds: 1500));
final dauer = player.duration;
return dauer?.inSeconds ?? 0;
} catch (_) {
} catch (e) {
try { await player.stop(); } catch (_) {}
return 0;
}
}