import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart' as path_provider; import 'dart:io'; /// Represents lyrics data class Lyrics { final String? synced; // LRC format synced lyrics final String? plain; // Plain text lyrics Lyrics({this.synced, this.plain}); Lyrics withSynced(String? s) => Lyrics(synced: s, plain: plain); } /// Abstract base class for LRC providers abstract class LrcProvider { late final Dio session; LrcProvider() { session = Dio(); } String get name; /// Search and retrieve lyrics by search term (usually title + artist) Future getLrc(String searchTerm); } /// Musixmatch LRC provider class MusixmatchProvider extends LrcProvider { static const String rootUrl = "https://apic-desktop.musixmatch.com/ws/1.1/"; final String? lang; final bool enhanced; String? token; MusixmatchProvider({this.lang, this.enhanced = false}); @override String get name => 'Musixmatch'; Future _get( String action, List> query, ) async { if (action != "token.get" && token == null) { await _getToken(); } query.add(MapEntry("app_id", "web-desktop-app-v1.0")); if (token != null) { query.add(MapEntry("usertoken", token!)); } final t = DateTime.now().millisecondsSinceEpoch.toString(); query.add(MapEntry("t", t)); final url = rootUrl + action; return await session.get(url, queryParameters: Map.fromEntries(query)); } Future _getToken() async { final dir = await path_provider.getApplicationSupportDirectory(); final tokenPath = path.join( dir.path, "syncedlyrics", "musixmatch_token.json", ); final file = File(tokenPath); if (file.existsSync()) { final data = jsonDecode(file.readAsStringSync()); final cachedToken = data['token']; final expirationTime = data['expiration_time']; final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; if (cachedToken != null && expirationTime != null && currentTime < expirationTime) { token = cachedToken; return; } } // Token not cached or expired, fetch new token final d = await _get("token.get", [MapEntry("user_language", "en")]); if (d.statusCode == 401) { await Future.delayed(Duration(seconds: 10)); return await _getToken(); } final newToken = jsonDecode(d.data)["message"]["body"]["user_token"]; final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; final expirationTime = currentTime + 600; // 10 minutes token = newToken; final tokenData = {"token": newToken, "expiration_time": expirationTime}; file.createSync(recursive: true); file.writeAsStringSync(jsonEncode(tokenData)); } Future getLrcById(String trackId) async { var r = await _get("track.subtitle.get", [ MapEntry("track_id", trackId), MapEntry("subtitle_format", "lrc"), ]); if (lang != null) { final rTr = await _get("crowd.track.translations.get", [ MapEntry("track_id", trackId), MapEntry("subtitle_format", "lrc"), MapEntry("translation_fields_set", "minimal"), MapEntry("selected_language", lang!), ]); final bodyTr = jsonDecode(rTr.data)["message"]["body"]; if (bodyTr["translations_list"] == null || (bodyTr["translations_list"] as List).isEmpty) { throw Exception("Couldn't find translations"); } // Translation handling would need full implementation } if (r.statusCode != 200) return null; final body = jsonDecode(r.data)["message"]["body"]; if (body == null) return null; final lrcStr = body["subtitle"]["subtitle_body"]; final lrc = Lyrics(synced: lrcStr); return lrc; } Future getLrcWordByWord(String trackId) async { var lrc = Lyrics(); final r = await _get("track.richsync.get", [MapEntry("track_id", trackId)]); if (r.statusCode == 200 && jsonDecode(r.data)["message"]["header"]["status_code"] == 200) { final lrcRaw = jsonDecode( r.data, )["message"]["body"]["richsync"]["richsync_body"]; final data = jsonDecode(lrcRaw); String lrcStr = ""; if (data is List) { for (final i in data) { lrcStr += "[${formatTime(i['ts'])}] "; if (i['l'] is List) { for (final l in i['l']) { final t = formatTime( double.parse(i['ts'].toString()) + double.parse(l['o'].toString()), ); lrcStr += "<$t> ${l['c']} "; } } lrcStr += "\n"; } } lrc = lrc.withSynced(lrcStr); } return lrc; } @override Future getLrc(String searchTerm) async { final r = await _get("track.search", [ MapEntry("q", searchTerm), MapEntry("page_size", "5"), MapEntry("page", "1"), ]); final statusCode = jsonDecode(r.data)["message"]["header"]["status_code"]; if (statusCode != 200) return null; final body = jsonDecode(r.data)["message"]["body"]; if (body == null || body is! Map) return null; final tracks = body["track_list"]; if (tracks == null || tracks is! List || tracks.isEmpty) return null; // Simple "best match" - first track final track = tracks.firstWhere((t) => true, orElse: () => null); if (track == null) return null; final trackId = track["track"]["track_id"]; if (enhanced) { final lrc = await getLrcWordByWord(trackId); if (lrc != null && lrc.synced != null) { return lrc; } } return await getLrcById(trackId); } } /// NetEase provider class NetEaseProvider extends LrcProvider { static const String apiEndpointMetadata = "https://music.163.com/api/search/pc"; static const String apiEndpointLyrics = "https://music.163.com/api/song/lyric"; static const String cookie = "NMTID=00OAVK3xqDG726ITU6jopU6jF2yMk0AAAGCO8l1BA; JSESSIONID-WYYY=8KQo11YK2GZP45RMlz8Kn80vHZ9%2FGvwzRKQXXy0iQoFKycWdBlQjbfT0MJrFa6hwRfmpfBYKeHliUPH287JC3hNW99WQjrh9b9RmKT%2Fg1Exc2VwHZcsqi7ITxQgfEiee50po28x5xTTZXKoP%2FRMctN2jpDeg57kdZrXz%2FD%2FWghb%5C4DuZ%3A1659124633932; _iuqxldmzr_=32; _ntes_nnid=0db6667097883aa9596ecfe7f188c3ec,1659122833973; _ntes_nuid=0db6667097883aa9596ecfe7f188c3ec; WNMCID=xygast.1659122837568.01.0; WEVNSM=1.0.0; WM_NI=CwbjWAFbcIzPX3dsLP%2F52VB%2Bxr572gmqAYwvN9KU5X5f1nRzBYl0SNf%2BV9FTmmYZy%2FoJLADaZS0Q8TrKfNSBNOt0HLB8rRJh9DsvMOT7%2BCGCQLbvlWAcJBJeXb1P8yZ3RHA%3D; WM_NIKE=9ca17ae2e6ffcda170e2e6ee90c65b85ae87b9aa5483ef8ab3d14a939e9a83c459959caeadce47e991fbaee82af0fea7c3b92a81a9ae8aabb64b86beadaaf95c9c437e2a3; WM_TID=AAkRFnl03RdABEBEQFOBWHCPOeMra4IL; playerid=94262567"; @override String get name => 'NetEase'; Future?> searchTrack(String searchTerm) async { final params = {"limit": "10", "type": "1", "offset": "0", "s": searchTerm}; final response = await session.get( apiEndpointMetadata, queryParameters: params, options: Options(headers: {"cookie": cookie}), ); // Update the session cookies from the new sent cookies for the next request. // In http package, we can set it, but for simplicity, pass to next call final results = jsonDecode(response.data)["result"]["songs"]; if (results == null || results.isEmpty) return null; // Simple best match - first track return results[0]; } Future getLrcById(String trackId) async { final params = {"id": trackId, "lv": "1"}; final response = await session.get( apiEndpointLyrics, queryParameters: params, options: Options(headers: {"cookie": cookie}), ); final data = jsonDecode(response.data); final lrc = Lyrics(synced: data["lrc"]["lyric"]); return lrc; } @override Future getLrc(String searchTerm) async { final track = await searchTrack(searchTerm); if (track == null) return null; return await getLrcById(track["id"].toString()); } } /// Lrclib provider class LrclibProvider extends LrcProvider { static const String rootUrl = "https://lrclib.net"; static const String apiEndpoint = "$rootUrl/api"; static const String searchEndpoint = "$apiEndpoint/search"; static const String lrcEndpoint = "$apiEndpoint/get/"; @override String get name => 'Lrclib'; Future getLrcById(String trackId) async { final url = lrcEndpoint + trackId; final response = await session.get(url); if (response.statusCode != 200) return null; final track = response.data; final synced = track['syncedLyrics']; final plain = track['plainLyrics']; return Lyrics(synced: synced, plain: plain); } @override Future getLrc(String searchTerm) async { final response = await session.get( searchEndpoint, queryParameters: {'q': searchTerm}, ); if (response.statusCode != 200) return null; final tracks = response.data as List; if (tracks.isEmpty) return null; // Find first track with syncedLyrics not empty for (final track in tracks) { final synced = track['syncedLyrics']; if (synced != null && synced.trim().isNotEmpty) { final id = track['id'].toString(); return await getLrcById(id); } } return null; } } // Utility function String formatTime(dynamic time) { final seconds = time.toInt(); final minutes = seconds ~/ 60; final remainingSeconds = seconds % 60; final centiseconds = ((time - seconds) * 100).toInt(); return '$minutes:$remainingSeconds.${centiseconds.toString().padLeft(2, '0')}'; } // Extension for List extension MapEntryList on List> { Map toMap() { return Map.fromEntries(this); } }