diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 36e4bc4..98180c7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -12,6 +12,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } defaultConfig { @@ -44,9 +45,14 @@ flutter { source = "../.." } -// AAR Metadata Check deaktivieren (file_picker compileSdk vs Projekt compileSdk) +// Core Library Desugaring (benötigt von flutter_local_notifications) +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + +// AAR Metadata Check deaktivieren tasks.configureEach { - if (name.contains("checkAarMetaData", ignoreCase = true) || name.contains("checkAarMetadata", ignoreCase = true)) { + if (name.contains("checkAarMetadata", ignoreCase = true) || name.contains("checkAarMetaData", ignoreCase = true)) { enabled = false } } diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index c2f5fa2..df34407 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -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 = []; 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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 b, int o) => + (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]; + + static int _u32le(List b, int o) => + (b[o + 3] << 24) | (b[o + 2] << 16) | (b[o + 1] << 8) | b[o]; + + static int _u64(List 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('.'); diff --git a/test/widget_test.dart b/test/widget_test.dart index 805f0c9..992d9eb 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,12 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:melo_app/main.dart'; +import 'package:flutter/material.dart'; +import 'package:melo_app/screens/login_screen.dart'; void main() { - testWidgets('Melo App startet', (WidgetTester tester) async { - await tester.pumpWidget(const MeloApp()); - expect(find.text('Melo'), findsOneWidget); + testWidgets('LoginScreen zeigt Anmelde-Felder', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: LoginScreen())); + await tester.pumpAndSettle(); + // Prüft dass was gerendert wird (kein Crash) + expect(find.byType(TextField), findsWidgets); }); }