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
+91
View File
@@ -0,0 +1,91 @@
import 'dart:io';
/// Liest ID3-Tags (v1 + v2) und eingebettetes Cover aus MP3-Dateien
class Id3Reader {
/// Gibt Metadaten zurück: titel, kuenstler, album, coverBytes
static Map<String, dynamic> lesen(String filepath) {
final result = <String, dynamic>{
'titel': '',
'kuenstler': '',
'album': '',
'cover': null,
};
try {
final file = File(filepath);
if (!file.existsSync()) return result;
final bytes = file.readAsBytesSync();
// ID3v1 (letzte 128 Bytes)
if (bytes.length > 128) {
final tag = bytes.sublist(bytes.length - 128);
if (String.fromCharCodes(tag.sublist(0, 3)) == 'TAG') {
result['titel'] = _trimNull(tag.sublist(3, 33)).trim();
result['kuenstler'] = _trimNull(tag.sublist(33, 63)).trim();
result['album'] = _trimNull(tag.sublist(63, 93)).trim();
}
}
// ID3v2 Header (Anfang der Datei) für Cover
if (bytes.length > 10 && String.fromCharCodes(bytes.sublist(0, 3)) == 'ID3') {
final size = _synchSafeInt(bytes, 6);
var pos = 10;
// Frame-Header lesen
while (pos < size && pos + 10 < bytes.length) {
final frameId = String.fromCharCodes(bytes.sublist(pos, pos + 4));
final frameSize = _frameSize(bytes, pos + 4);
pos += 10;
if (frameId == 'APIC' && pos + frameSize <= bytes.length) {
// APIC = Attached Picture
var p = pos;
// Encoding (1 Byte) + MIME-Type
final enc = bytes[p]; p += 1;
var mimeEnd = p;
while (mimeEnd < bytes.length && bytes[mimeEnd] != 0) mimeEnd++;
final mime = String.fromCharCodes(bytes.sublist(p, mimeEnd));
p = mimeEnd + 1;
// Picture Type (1 Byte)
p += 1;
// Description (null-terminated)
var descEnd = p;
while (descEnd < bytes.length) {
if (enc == 1 || enc == 2) {
if (descEnd + 1 < bytes.length && bytes[descEnd] == 0 && bytes[descEnd + 1] == 0) break;
descEnd += 2;
} else {
if (bytes[descEnd] == 0) break;
descEnd += 1;
}
}
p = descEnd + (enc == 1 || enc == 2 ? 2 : 1);
final remaining = pos + frameSize - p;
if (remaining > 0 && p + remaining <= bytes.length) {
result['cover'] = bytes.sublist(p, p + remaining);
}
break;
}
pos += frameSize;
}
}
} catch (_) {}
// Fallback: Dateiname als Titel
if (result['titel'].isEmpty) {
result['titel'] = filepath.split('/').last.replaceAll('.mp3', '').replaceAll('.m4a', '');
}
return result;
}
static String _trimNull(List<int> bytes) {
final end = bytes.indexWhere((b) => b == 0);
return String.fromCharCodes(end < 0 ? bytes : bytes.sublist(0, end));
}
static int _synchSafeInt(List<int> bytes, int offset) {
return (bytes[offset] << 21) | (bytes[offset + 1] << 14) | (bytes[offset + 2] << 7) | bytes[offset + 3];
}
static int _frameSize(List<int> bytes, int offset) {
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
}
}