diff --git a/lib/services/lyrics_service.dart b/lib/services/lyrics_service.dart new file mode 100644 index 0000000..574a39d --- /dev/null +++ b/lib/services/lyrics_service.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import '../models/song.dart'; + +/// Eine synchronisierte Lyrik-Zeile mit Zeitstempel. +class LyricsZeile { + final Duration zeit; + final String text; + + const LyricsZeile(this.zeit, this.text); +} + +/// Lädt Songtexte für einen Song. +/// +/// Quellen (in dieser Reihenfolge): +/// 1. Lokale `.lrc`-Datei neben dem Song (`song.dateiPfad` mit `.lrc`-Endung) +/// 2. Netease-API (public, kein Key) — Suche nach Titel+Künstler, dann Lyrics +/// 3. Kein Treffer → `null` (UI zeigt freundlichen Hinweis) +class LyricsService { + static final LyricsService _instanz = LyricsService._(); + factory LyricsService() => _instanz; + LyricsService._(); + + static const _neteaseSearchUrl = 'https://music.163.com/api/search/get/web'; + static const _neteaseLyricUrl = 'https://music.163.com/api/song/lyric'; + static const _timeout = Duration(seconds: 8); + + /// Netease akzeptiert Requests nur mit Browser-ähnlichen Headern. + static const _header = { + 'User-Agent': + 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Mobile Safari/537.36 Melo/2.49', + 'Referer': 'https://music.163.com/', + 'Accept': 'application/json, text/plain, */*', + }; + + /// Lädt Lyrics für [song]: erst lokale `.lrc`-Datei, dann Netease. + /// Gibt `null` zurück, wenn keine Quelle liefert. + Future?> ladeLyrics(Song song) async { + final lokal = await _ladeLokaleLrc(song); + if (lokal != null && lokal.isNotEmpty) return lokal; + try { + final netease = await _ladeVonNetease(song); + if (netease != null && netease.isNotEmpty) return netease; + } catch (_) { + // Netzwerk-/Parse-Fehler → still kein Lyrics (Fallback-Hinweis in der UI) + } + return null; + } + + /// a) Lokale `.lrc`-Datei neben der Audiodatei (z.B. `song.mp3` → `song.lrc`). + Future?> _ladeLokaleLrc(Song song) async { + if (song.dateiPfad.isEmpty) return null; + try { + final lrcPfad = song.dateiPfad.replaceFirst( + RegExp(r'\.[^./\\]+$'), + '.lrc', + ); + if (lrcPfad == song.dateiPfad) return null; + final file = File(lrcPfad); + if (!await file.exists()) return null; + final inhalt = await file.readAsString(); + final zeilen = parseLrc(inhalt); + return zeilen.isEmpty ? null : zeilen; + } catch (_) { + return null; + } + } + + /// b) Netease-API: Sucht den Song und lädt dessen LRC-Text. + Future?> _ladeVonNetease(Song song) async { + final query = '${song.titel} ${song.kuenstler}'.trim(); + if (query.isEmpty) return null; + + final client = http.Client(); + try { + // 1) Song-ID über die Websuche finden + final searchUri = Uri.parse(_neteaseSearchUrl).replace( + queryParameters: { + 'csrf_token': '', + 's': query, + 'type': '1', + 'offset': '0', + 'total': 'true', + 'limit': '5', + }, + ); + final searchRes = + await client.get(searchUri, headers: _header).timeout(_timeout); + if (searchRes.statusCode != 200) return null; + final searchData = + jsonDecode(utf8.decode(searchRes.bodyBytes)) as Map; + final songs = (searchData['result']?['songs']) as List? ?? const []; + if (songs.isEmpty) return null; + final id = (songs.first as Map)['id']; + if (id == null) return null; + + // 2) LRC-Text zur Song-ID laden + final lyricUri = Uri.parse(_neteaseLyricUrl).replace( + queryParameters: { + 'id': '$id', + 'lv': '-1', + 'kv': '-1', + 'tv': '-1', + }, + ); + final lyricRes = + await client.get(lyricUri, headers: _header).timeout(_timeout); + if (lyricRes.statusCode != 200) return null; + final lyricData = + jsonDecode(utf8.decode(lyricRes.bodyBytes)) as Map; + final lrcText = + (lyricData['lrc'] as Map?)?['lyric'] as String?; + if (lrcText == null || lrcText.trim().isEmpty) return null; + final zeilen = parseLrc(lrcText); + return zeilen.isEmpty ? null : zeilen; + } catch (_) { + return null; + } finally { + client.close(); + } + } + + // ─── LRC-Parser ─── + + /// Parst LRC-Text in synchronisierte Zeilen. + /// + /// Unterstützt `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, mehrere + /// Zeitstempel pro Zeile sowie den `[offset:±ms]`-Meta-Tag. + /// Meta-Tags (`[ti:]`, `[ar:]`, `[al:]`, `[by:]`, …) werden ignoriert. + static List parseLrc(String inhalt) { + final zeilen = []; + var offsetMs = 0; + final zeitRegex = RegExp(r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]'); + + for (final rohZeile in inhalt.split(RegExp(r'\r?\n'))) { + final zeile = rohZeile.trim(); + if (zeile.isEmpty) continue; + + // Meta-Tag-Zeilen überspringen, aber [offset:…] auswerten + if (zeile.startsWith('[') && !zeitRegex.hasMatch(zeile)) { + final offsetMatch = + RegExp(r'^\[offset:([+-]?\d+)\]').firstMatch(zeile); + if (offsetMatch != null) { + offsetMs = int.tryParse(offsetMatch.group(1)!) ?? 0; + } + continue; + } + + final matches = zeitRegex.allMatches(zeile).toList(); + if (matches.isEmpty) continue; + + // Text = Rest nach dem letzten Zeitstempel; Karaoke-Tags `<…>` entfernen + var text = zeile.substring(matches.last.end).trim(); + text = text.replaceAll(RegExp(r'<[^>]*>'), '').trim(); + + for (final m in matches) { + final min = int.parse(m.group(1)!); + final sek = int.parse(m.group(2)!); + final msStr = m.group(3); + var ms = 0; + if (msStr != null && msStr.isNotEmpty) { + ms = int.parse(msStr.padRight(3, '0').substring(0, 3)); + } + var zeit = Duration(minutes: min, seconds: sek, milliseconds: ms); + if (offsetMs != 0) { + zeit += Duration(milliseconds: offsetMs); + if (zeit.isNegative) zeit = Duration.zero; + } + zeilen.add(LyricsZeile(zeit, text)); + } + } + + zeilen.sort((a, b) => a.zeit.compareTo(b.zeit)); + return zeilen; + } + + /// Index der aktiven Zeile für [position]: + /// die letzte Zeile, deren Zeitstempel ≤ [position] ist. + /// Gibt 0 zurück, wenn [position] vor der ersten Zeile liegt. + static int indexFuerPosition(Duration position, List zeilen) { + if (zeilen.isEmpty) return -1; + for (var i = zeilen.length - 1; i >= 0; i--) { + if (zeilen[i].zeit <= position) return i; + } + return 0; + } +} diff --git a/test/lyrics_service_test.dart b/test/lyrics_service_test.dart new file mode 100644 index 0000000..044fc2b --- /dev/null +++ b/test/lyrics_service_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo_app/models/song.dart'; +import 'package:melo_app/services/lyrics_service.dart'; + +void main() { + // Blockiert echte HTTP-Requests im Test (Netease-Fallback → 400 → null) + TestWidgetsFlutterBinding.ensureInitialized(); + + group('parseLrc', () { + test('parst einfache [mm:ss.xx]-Zeilen', () { + const lrc = '[00:12.34]Erste Zeile\n[00:45.67]Zweite Zeile\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.length, 2); + expect(zeilen[0].zeit, const Duration(milliseconds: 12340)); + expect(zeilen[0].text, 'Erste Zeile'); + expect(zeilen[1].zeit, const Duration(milliseconds: 45670)); + expect(zeilen[1].text, 'Zweite Zeile'); + }); + + test('unterstützt [mm:ss] ohne Millisekunden', () { + const lrc = '[01:05]Nur Sekunden\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.length, 1); + expect(zeilen[0].zeit, const Duration(minutes: 1, seconds: 5)); + }); + + test('unterstützt mehrere Zeitstempel pro Zeile', () { + const lrc = '[00:10.00][00:20.00][00:30.00]Refrain\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.length, 3); + expect(zeilen.map((z) => z.text).toSet(), {'Refrain'}); + expect(zeilen[0].zeit, const Duration(seconds: 10)); + expect(zeilen[1].zeit, const Duration(seconds: 20)); + expect(zeilen[2].zeit, const Duration(seconds: 30)); + }); + + test('ignoriert Meta-Tags und wertet [offset:] aus', () { + const lrc = '[ti:Testtitel]\n[ar:Testkünstler]\n[offset:500]\n' + '[00:10.00]Zeile mit Offset\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.length, 1); + expect(zeilen[0].zeit, const Duration(milliseconds: 10500)); + }); + + test('sortiert Zeilen nach Zeitstempel', () { + const lrc = '[00:30.00]Spät\n[00:10.00]Früh\n[00:20.00]Mitte\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.map((z) => z.text).toList(), ['Früh', 'Mitte', 'Spät']); + }); + + test('entfernt Karaoke-Tags <...>', () { + const lrc = '[00:10.00]<00:10.00>Hal<00:11.00>lo\n'; + final zeilen = LyricsService.parseLrc(lrc); + expect(zeilen.length, 1); + expect(zeilen[0].text, 'Hallo'); + }); + + test('leere Eingabe liefert leere Liste', () { + expect(LyricsService.parseLrc(''), isEmpty); + expect(LyricsService.parseLrc('nur Text ohne Zeitstempel'), isEmpty); + }); + }); + + group('indexFuerPosition', () { + final zeilen = LyricsService.parseLrc( + '[00:10.00]Eins\n[00:20.00]Zwei\n[00:30.00]Drei\n', + ); + + test('vor der ersten Zeile → 0', () { + expect( + LyricsService.indexFuerPosition(const Duration(seconds: 5), zeilen), + 0, + ); + }); + + test('genau auf einer Zeile → deren Index', () { + expect( + LyricsService.indexFuerPosition(const Duration(seconds: 20), zeilen), + 1, + ); + }); + + test('zwischen zwei Zeilen → vorherige', () { + expect( + LyricsService.indexFuerPosition(const Duration(seconds: 25), zeilen), + 1, + ); + }); + + test('nach der letzten Zeile → letzter Index', () { + expect( + LyricsService.indexFuerPosition(const Duration(seconds: 99), zeilen), + 2, + ); + }); + + test('leere Liste → -1', () { + expect(LyricsService.indexFuerPosition(Duration.zero, []), -1); + }); + }); + + group('ladeLyrics (lokale .lrc)', () { + test('liest .lrc-Datei neben dem Song', () async { + final dir = await Directory.systemTemp.createTemp('melo_lrc_test'); + addTearDown(() => dir.delete(recursive: true)); + final songDatei = '${dir.path}/test.mp3'; + final lrcDatei = '${dir.path}/test.lrc'; + File(songDatei).writeAsStringSync('dummy'); + File(lrcDatei).writeAsStringSync('[00:05.00]Lokal\n[00:10.00]Zeile\n'); + + final song = Song( + titel: 'Test', + kuenstler: 'K', + dauerSekunden: 60, + dateiPfad: songDatei, + ); + final zeilen = await LyricsService().ladeLyrics(song); + expect(zeilen, isNotNull); + expect(zeilen!.length, 2); + expect(zeilen.first.text, 'Lokal'); + }); + + test('ohne .lrc-Datei → null', () async { + final dir = await Directory.systemTemp.createTemp('melo_lrc_test2'); + addTearDown(() => dir.delete(recursive: true)); + final song = Song( + titel: 'Test', + kuenstler: 'K', + dauerSekunden: 60, + dateiPfad: '${dir.path}/ohne.mp3', + ); + // Keine .lrc-Datei anlegen → Netease wird nicht getroffen (kein Netz im Test) + final zeilen = await LyricsService().ladeLyrics(song); + expect(zeilen, isNull); + }); + }); +}