v2.45.1 — Auto-Scan ohne AudioPlayer: Byte-Parser für Dauer (MP3/M4A/FLAC/WAV), MPEG-Header-Offset fix, WAV Little-Endian fix

This commit is contained in:
Dustin
2026-08-03 02:47:05 +02:00
parent 0b8d8ce2f8
commit 4c987036bc
3 changed files with 286 additions and 18 deletions
+271 -12
View File
@@ -1,6 +1,6 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:http/http.dart' as http;
@@ -46,8 +46,6 @@ class MusikScanner {
final gefunden = <Song>[];
final pfade = await _sammleMusikPfade();
final player = AudioPlayer();
for (final pfad in pfade) {
try {
final file = File(pfad);
@@ -87,7 +85,7 @@ class MusikScanner {
jahr: _nichtLeer(tags['jahr']),
genre: _nichtLeer(tags['genre']),
track: _nichtLeer(tags['track']),
dauerSekunden: await _ermittleDauer(player, pfad),
dauerSekunden: await _ermittleDauer(pfad),
dateiPfad: pfad,
coverPfad: coverPfad,
groesseBytes: stat.size,
@@ -101,8 +99,6 @@ class MusikScanner {
}
}
try { await player.dispose(); } catch (_) {}
// In DB speichern
final vorhandene = await _db.alleSongs();
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
@@ -201,17 +197,280 @@ class MusikScanner {
}
}
Future<int> _ermittleDauer(AudioPlayer player, String pfad) async {
/// Leichtgewichtige Dauer-Ermittlung OHNE AudioPlayer (Issue #15).
/// Parst Container-Header direkt aus der Datei nur stdlib, kein neues Package:
/// - MP3: MPEG-Frame-Header (Bitrate) → CBR-Schätzung
/// - M4A: moov/mvhd-Box (Timescale + Duration)
/// - FLAC: STREAMINFO (TotalSamples / SampleRate)
/// - WAV: data-Chunk / ByteRate
/// - OGG/AAC: 0 (überspringen, Dauer unbekannt)
Future<int> _ermittleDauer(String pfad) async {
try {
await player.setFilePath(pfad).timeout(const Duration(milliseconds: 1500));
final dauer = player.duration;
return dauer?.inSeconds ?? 0;
} catch (e) {
try { await player.stop(); } catch (_) {}
final file = File(pfad);
if (!await file.exists()) return 0;
final size = await file.length();
if (size < 16) return 0;
final raf = await file.open();
try {
await raf.setPosition(0);
final kopf = await raf.read(12);
if (kopf.length < 4) return 0;
// MP3: ID3v2-Tag oder direkter MPEG-Frame-Sync
if (kopf[0] == 0x49 && kopf[1] == 0x44 && kopf[2] == 0x33) {
return await _dauerMp3(raf, size, id3v2: true);
}
if (kopf[0] == 0xFF && (kopf[1] & 0xE0) == 0xE0) {
return await _dauerMp3(raf, size, id3v2: false);
}
// FLAC: "fLaC"
if (kopf[0] == 0x66 && kopf[1] == 0x4C &&
kopf[2] == 0x61 && kopf[3] == 0x43) {
return await _dauerFlac(raf);
}
// WAV: "RIFF"
if (kopf[0] == 0x52 && kopf[1] == 0x49 &&
kopf[2] == 0x46 && kopf[3] == 0x46) {
return await _dauerWav(raf, size);
}
// M4A/AAC: ftyp-Box an Position 4
if (kopf.length >= 8 &&
kopf[4] == 0x66 && kopf[5] == 0x74 &&
kopf[6] == 0x79 && kopf[7] == 0x70) {
return await _dauerM4a(raf, size);
}
return 0; // OGG/AAC: überspringen
} finally {
await raf.close();
}
} catch (_) {
return 0;
}
}
// ── MP3: MPEG-Frame-Header (Bitraten-Tabellen, kbps) ──
static const List<int> _mpeg1Bitraten = [
// Index 0 = free-format (nicht unterstützt)
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
];
static const List<int> _mpeg2Bitraten = [
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160,
];
/// Parst einen MPEG-Audio-Frame-Header ab Position [i].
/// Liefert (bitrateKbps, frameLen) oder null bei ungültigem Header.
(int, int)? _parseMpegHeader(Uint8List d, int i) {
if (i + 3 >= d.length) return null;
if (d[i] != 0xFF) return null;
if ((d[i + 1] & 0xE0) != 0xE0) return null;
final versionBits = (d[i + 1] >> 3) & 0x03;
final layerBits = (d[i + 1] >> 1) & 0x03;
if (versionBits == 1) return null; // reserviert
if (layerBits == 0) return null; // reserviert
final bitrateIdx = d[i + 2] >> 4;
final sampleIdx = (d[i + 2] >> 2) & 0x03;
final padding = (d[i + 2] >> 1) & 0x01;
if (bitrateIdx == 15 || bitrateIdx == 0 || sampleIdx == 3) return null;
final isMpeg1 = versionBits == 3;
final layer1 = layerBits == 3; // Layer I
final bitrate = (isMpeg1 ? _mpeg1Bitraten : _mpeg2Bitraten)[bitrateIdx];
final sampleRate = isMpeg1
? (sampleIdx == 0 ? 44100 : sampleIdx == 1 ? 48000 : 32000)
: versionBits == 2
? (sampleIdx == 0 ? 22050 : sampleIdx == 1 ? 24000 : 16000)
: (sampleIdx == 0 ? 11025 : sampleIdx == 1 ? 12000 : 8000);
final frameLen = layer1
? (12 * bitrate * 1000 ~/ sampleRate + padding) * 4
: 144 * bitrate * 1000 ~/ sampleRate + padding;
if (frameLen < 24) return null;
return (bitrate, frameLen);
}
Future<int> _dauerMp3(RandomAccessFile raf, int dateiGroesse,
{required bool id3v2}) async {
// ID3v2-Tag überspringen (Syncsafe-Größe in Bytes 6-9)
var start = 0;
if (id3v2) {
await raf.setPosition(0);
final id3 = await raf.read(10);
if (id3.length >= 10) {
var tagSize = 0;
for (var i = 6; i < 10; i++) {
tagSize = (tagSize << 7) | (id3[i] & 0x7F);
}
start = 10 + tagSize;
}
}
// Ersten gültigen Frame-Header suchen (max. 2 MB Scan für große Cover-Tags)
const maxScan = 2 * 1024 * 1024;
final scanBis = (start + maxScan < dateiGroesse) ? start + maxScan : dateiGroesse;
var scanPos = start;
var rest = Uint8List(0); // bis zu 3 Bytes Überlappung zwischen Chunks
while (scanPos < scanBis) {
await raf.setPosition(scanPos);
final chunk = await raf.read(4096);
if (chunk.isEmpty) break;
final data = Uint8List.fromList([...rest, ...chunk]);
for (var i = 0; i < data.length - 4; i++) {
final header = _parseMpegHeader(data, i);
if (header == null) continue;
final abs = scanPos - rest.length + i;
final frameLen = header.$2;
// 2 Folge-Frames müssen ebenfalls Sync haben (False-Positive-Schutz)
if (abs + 2 * frameLen > dateiGroesse) continue;
if (!await _hatFrameSync(raf, abs + frameLen)) continue;
if (!await _hatFrameSync(raf, abs + 2 * frameLen)) continue;
final audioBytes = dateiGroesse - abs;
if (audioBytes <= 0) return 0;
// CBR-Schätzung: audioBytes * 8 Bit / Bitrate
return audioBytes * 8 ~/ (header.$1 * 1000);
}
// Nächster Chunk mit 3-Byte-Überlappung
final uLen = data.length > 3 ? 3 : data.length;
rest = Uint8List.fromList(data.sublist(data.length - uLen));
scanPos += chunk.length;
}
return 0;
}
Future<bool> _hatFrameSync(RandomAccessFile raf, int pos) async {
if (pos < 0) return false;
await raf.setPosition(pos);
final b = await raf.read(2);
return b.length == 2 && b[0] == 0xFF && (b[1] & 0xE0) == 0xE0;
}
// ── M4A: moov → mvhd (Timescale + Duration) ──
Future<int> _dauerM4a(RandomAccessFile raf, int dateiGroesse) async {
var pos = 0;
while (pos + 8 <= dateiGroesse) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
var boxGroesse = _u32(kopf, 0);
final typ = String.fromCharCodes(kopf.sublist(4, 8));
if (boxGroesse == 1) {
// 64-Bit-Größe (nur bei >4GB-Dateien relevant)
final ext = await raf.read(8);
if (ext.length < 8) break;
final high = _u32(ext, 0);
boxGroesse = high > 0 ? dateiGroesse - pos : _u32(ext, 4);
} else if (boxGroesse == 0) {
boxGroesse = dateiGroesse - pos; // Box reicht bis EOF
}
if (boxGroesse < 8) break;
if (typ == 'moov') {
return await _dauerMvhd(raf, pos + 8, boxGroesse - 8);
}
pos += boxGroesse;
}
return 0;
}
Future<int> _dauerMvhd(RandomAccessFile raf, int start, int laenge) async {
final ende = start + laenge;
var pos = start;
while (pos + 8 <= ende) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
var boxGroesse = _u32(kopf, 0);
final typ = String.fromCharCodes(kopf.sublist(4, 8));
if (boxGroesse == 0) boxGroesse = ende - pos;
if (boxGroesse < 8) break;
if (typ == 'mvhd') {
await raf.setPosition(pos + 8);
final payload = await raf.read(32);
if (payload.length < 20) return 0;
final version = payload[0];
if (version == 1) {
if (payload.length < 32) return 0;
final timescale = _u32(payload, 20);
final duration = _u64(payload, 24);
if (timescale == 0) return 0;
return duration ~/ timescale;
}
final timescale = _u32(payload, 12);
final duration = _u32(payload, 16);
if (timescale == 0) return 0;
return duration ~/ timescale;
}
pos += boxGroesse;
}
return 0;
}
// ── FLAC: STREAMINFO (TotalSamples / SampleRate) ──
Future<int> _dauerFlac(RandomAccessFile raf) async {
await raf.setPosition(4);
final blockKopf = await raf.read(4);
if (blockKopf.length < 4) return 0;
final blockTyp = blockKopf[0] & 0x7F;
final blockLen = (blockKopf[1] << 16) | (blockKopf[2] << 8) | blockKopf[3];
if (blockTyp != 0 || blockLen < 18) return 0; // STREAMINFO erwartet
await raf.setPosition(8);
final d = await raf.read(blockLen);
if (d.length < 18) return 0;
final sampleRate = (d[10] << 12) | (d[11] << 4) | (d[12] >> 4);
final totalSamples = ((d[13] & 0x0F) << 32) |
(d[14] << 24) | (d[15] << 16) | (d[16] << 8) | d[17];
if (sampleRate == 0) return 0;
return totalSamples ~/ sampleRate;
}
// ── WAV: data-Chunk / ByteRate ──
Future<int> _dauerWav(RandomAccessFile raf, int dateiGroesse) async {
// fmt-Chunk: ByteRate liegt bei Offset 28 (nach RIFF+WAVE+fmt -Header)
await raf.setPosition(24);
final b = await raf.read(8);
if (b.length < 8) return 0;
final byteRate = _u32le(b, 4);
// data-Chunk finden (Chunks word-aligned)
var pos = 12;
while (pos + 8 <= dateiGroesse) {
await raf.setPosition(pos);
final kopf = await raf.read(8);
if (kopf.length < 8) break;
final size = _u32le(kopf, 4);
final typ = String.fromCharCodes(kopf.sublist(0, 4));
if (typ == 'data') {
if (byteRate == 0) return 0;
return size ~/ byteRate;
}
if (size < 8) break;
pos += 8 + size + (size % 2);
}
return 0;
}
// ── Byte-Helfer ──
static int _u32(List<int> b, int o) =>
(b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
static int _u32le(List<int> b, int o) =>
(b[o + 3] << 24) | (b[o + 2] << 16) | (b[o + 1] << 8) | b[o];
static int _u64(List<int> b, int o) =>
(b[o] << 56) | (b[o + 1] << 48) | (b[o + 2] << 40) | (b[o + 3] << 32) |
(b[o + 4] << 24) | (b[o + 5] << 16) | (b[o + 6] << 8) | b[o + 7];
String _dateiNameOhneEndung(String pfad) {
final name = pfad.split('/').last;
final dot = name.lastIndexOf('.');