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'; 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; } }