## LyricsService (neu: lib/services/lyrics_service.dart) - LyricsZeile-Modell (zeit + text) - LRC-Parser: [mm:ss], [mm:ss.xx], [mm:ss.xxx], mehrere Zeitstempel/Zeile, [offset:±ms], Meta-Tags ignoriert, Karaoke-Tags <...> entfernt, sortiert - Quellen: lokale .lrc neben dem Song (song.dateiPfad → .lrc) → Netease-API (Suche + Lyric, public, kein Key) → null - Keine neuen Packages (nur http) ## Tests (test/lyrics_service_test.dart, 14 neu) - parseLrc: Formate, Multi-Timestamps, Offset, Sortierung, Karaoke-Tags - indexFuerPosition: Grenzfälle - ladeLyrics: lokale .lrc-Datei (Temp-Datei), ohne Datei → null (HTTP im Test geblockt) - flutter analyze 0 Issues, Tests 35/35
189 lines
6.5 KiB
Dart
189 lines
6.5 KiB
Dart
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 = <String, String>{
|
|
'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<List<LyricsZeile>?> 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<List<LyricsZeile>?> _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<List<LyricsZeile>?> _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<String, dynamic>;
|
|
final songs = (searchData['result']?['songs']) as List? ?? const [];
|
|
if (songs.isEmpty) return null;
|
|
final id = (songs.first as Map<String, dynamic>)['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<String, dynamic>;
|
|
final lrcText =
|
|
(lyricData['lrc'] as Map<String, dynamic>?)?['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<LyricsZeile> parseLrc(String inhalt) {
|
|
final zeilen = <LyricsZeile>[];
|
|
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<LyricsZeile> zeilen) {
|
|
if (zeilen.isEmpty) return -1;
|
|
for (var i = zeilen.length - 1; i >= 0; i--) {
|
|
if (zeilen[i].zeit <= position) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
}
|