✨ lrc fetcher
This commit is contained in:
@@ -365,6 +365,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -545,6 +546,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -568,6 +570,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = GroovyBox;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -24,6 +26,14 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
@@ -41,9 +51,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
240
lib/logic/lrc_providers.dart
Normal file
240
lib/logic/lrc_providers.dart
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
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 http.Client session;
|
||||||
|
|
||||||
|
LRCProvider() {
|
||||||
|
session = http.Client();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get name;
|
||||||
|
|
||||||
|
/// Search and retrieve lyrics by search term (usually title + artist)
|
||||||
|
Future<Lyrics?> 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});
|
||||||
|
|
||||||
|
String get name => 'Musixmatch';
|
||||||
|
|
||||||
|
Future<http.Response> _get(
|
||||||
|
String action,
|
||||||
|
List<MapEntry<String, String>> 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(Uri.parse(url), headers: Map.fromEntries(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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.body)["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<Lyrics?> 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.body)["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.body)["message"]["body"];
|
||||||
|
if (body == null) return null;
|
||||||
|
final lrcStr = body["subtitle"]["subtitle_body"];
|
||||||
|
final lrc = Lyrics(synced: lrcStr);
|
||||||
|
return lrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Lyrics?> getLrcWordByWord(String trackId) async {
|
||||||
|
var lrc = Lyrics();
|
||||||
|
final r = await _get("track.richsync.get", [MapEntry("track_id", trackId)]);
|
||||||
|
if (r.statusCode == 200 &&
|
||||||
|
jsonDecode(r.body)["message"]["header"]["status_code"] == 200) {
|
||||||
|
final lrcRaw = jsonDecode(
|
||||||
|
r.body,
|
||||||
|
)["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<Lyrics?> getLrc(String searchTerm) async {
|
||||||
|
final r = await _get("track.search", [
|
||||||
|
MapEntry("q", searchTerm),
|
||||||
|
MapEntry("page_size", "5"),
|
||||||
|
MapEntry("page", "1"),
|
||||||
|
]);
|
||||||
|
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
|
||||||
|
if (statusCode != 200) return null;
|
||||||
|
final body = jsonDecode(r.body)["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<Map<String, dynamic>?> searchTrack(String searchTerm) async {
|
||||||
|
final params = {"limit": "10", "type": "1", "offset": "0", "s": searchTerm};
|
||||||
|
final response = await session.get(
|
||||||
|
Uri.parse(apiEndpointMetadata).replace(queryParameters: params),
|
||||||
|
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.body)["result"]["songs"];
|
||||||
|
if (results == null || results.isEmpty) return null;
|
||||||
|
// Simple best match - first track
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Lyrics?> getLrcById(String trackId) async {
|
||||||
|
final params = {"id": trackId, "lv": "1"};
|
||||||
|
final response = await session.get(
|
||||||
|
Uri.parse(apiEndpointLyrics).replace(queryParameters: params),
|
||||||
|
headers: {"cookie": cookie},
|
||||||
|
);
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final lrc = Lyrics(plain: data["lrc"]["lyric"]);
|
||||||
|
return lrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Lyrics?> getLrc(String searchTerm) async {
|
||||||
|
final track = await searchTrack(searchTerm);
|
||||||
|
if (track == null) return null;
|
||||||
|
return await getLrcById(track["id"].toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<MapEntry>
|
||||||
|
extension MapEntryList on List<MapEntry<String, String>> {
|
||||||
|
Map<String, String> toMap() {
|
||||||
|
return Map.fromEntries(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/providers/lrc_fetcher_provider.dart
Normal file
96
lib/providers/lrc_fetcher_provider.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:groovybox/data/db.dart' as db;
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:groovybox/logic/lrc_providers.dart';
|
||||||
|
import 'package:groovybox/logic/lyrics_parser.dart';
|
||||||
|
import 'package:groovybox/providers/db_provider.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'lrc_fetcher_provider.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class LyricsFetcher extends _$LyricsFetcher {
|
||||||
|
@override
|
||||||
|
LyricsFetcherState build() {
|
||||||
|
return LyricsFetcherState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchLyricsForTrack({
|
||||||
|
required int trackId,
|
||||||
|
required String searchTerm,
|
||||||
|
required LRCProvider provider,
|
||||||
|
required String trackPath,
|
||||||
|
}) async {
|
||||||
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final lyrics = await provider.getLrc(searchTerm);
|
||||||
|
if (lyrics == null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'No lyrics found from ${provider.name}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the lyrics into LyricsData format
|
||||||
|
String? lyricsJson;
|
||||||
|
if (lyrics.synced != null) {
|
||||||
|
// It's LRC format
|
||||||
|
final lyricsData = LyricsParser.parseLrc(lyrics.synced!);
|
||||||
|
lyricsJson = lyricsData.toJsonString();
|
||||||
|
} else if (lyrics.plain != null) {
|
||||||
|
// Plain text
|
||||||
|
final lyricsData = LyricsParser.parsePlaintext(lyrics.plain!);
|
||||||
|
lyricsJson = lyricsData.toJsonString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lyricsJson != null) {
|
||||||
|
// Update the track in the database
|
||||||
|
final database = ref.read(databaseProvider);
|
||||||
|
await (database.update(database.tracks)
|
||||||
|
..where((t) => t.id.equals(trackId)))
|
||||||
|
.write(db.TracksCompanion(lyrics: drift.Value(lyricsJson)));
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
successMessage: 'Lyrics fetched from ${provider.name}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to parse lyrics',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Error fetching lyrics: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LyricsFetcherState {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final String? successMessage;
|
||||||
|
|
||||||
|
LyricsFetcherState({this.isLoading = false, this.error, this.successMessage});
|
||||||
|
|
||||||
|
LyricsFetcherState copyWith({
|
||||||
|
bool? isLoading,
|
||||||
|
String? error,
|
||||||
|
String? successMessage,
|
||||||
|
}) {
|
||||||
|
return LyricsFetcherState(
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: error ?? this.error,
|
||||||
|
successMessage: successMessage ?? this.successMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers for each LRC provider
|
||||||
|
final musixmatchProvider = Provider((ref) => MusixmatchProvider());
|
||||||
|
final neteaseProvider = Provider((ref) => NetEaseProvider());
|
||||||
63
lib/providers/lrc_fetcher_provider.g.dart
Normal file
63
lib/providers/lrc_fetcher_provider.g.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'lrc_fetcher_provider.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(LyricsFetcher)
|
||||||
|
const lyricsFetcherProvider = LyricsFetcherProvider._();
|
||||||
|
|
||||||
|
final class LyricsFetcherProvider
|
||||||
|
extends $NotifierProvider<LyricsFetcher, LyricsFetcherState> {
|
||||||
|
const LyricsFetcherProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'lyricsFetcherProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$lyricsFetcherHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
LyricsFetcher create() => LyricsFetcher();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(LyricsFetcherState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<LyricsFetcherState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$lyricsFetcherHash() => r'49468a75e00ab1533368acb52328b059831836d3';
|
||||||
|
|
||||||
|
abstract class _$LyricsFetcher extends $Notifier<LyricsFetcherState> {
|
||||||
|
LyricsFetcherState build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<LyricsFetcherState, LyricsFetcherState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<LyricsFetcherState, LyricsFetcherState>,
|
||||||
|
LyricsFetcherState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:groovybox/data/db.dart' as db;
|
import 'package:groovybox/data/db.dart' as db;
|
||||||
import 'package:groovybox/logic/lyrics_parser.dart';
|
import 'package:groovybox/logic/lyrics_parser.dart';
|
||||||
import 'package:groovybox/logic/metadata_service.dart';
|
import 'package:groovybox/logic/metadata_service.dart';
|
||||||
import 'package:groovybox/providers/audio_provider.dart';
|
import 'package:groovybox/providers/audio_provider.dart';
|
||||||
import 'package:groovybox/providers/db_provider.dart';
|
import 'package:groovybox/providers/db_provider.dart';
|
||||||
|
import 'package:groovybox/providers/lrc_fetcher_provider.dart';
|
||||||
import 'package:groovybox/ui/widgets/mini_player.dart';
|
import 'package:groovybox/ui/widgets/mini_player.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@@ -296,16 +298,71 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
? ref.watch(_trackByPathProvider(trackPath!))
|
? ref.watch(_trackByPathProvider(trackPath!))
|
||||||
: const AsyncValue<db.Track?>.data(null);
|
: const AsyncValue<db.Track?>.data(null);
|
||||||
|
|
||||||
|
final metadataAsync = trackPath != null
|
||||||
|
? ref.watch(trackMetadataProvider(trackPath!))
|
||||||
|
: const AsyncValue<TrackMetadata?>.data(null);
|
||||||
|
|
||||||
|
final lyricsFetcher = ref.watch(lyricsFetcherProvider);
|
||||||
|
final musixmatchProviderInstance = ref.watch(musixmatchProvider);
|
||||||
|
final neteaseProviderInstance = ref.watch(neteaseProvider);
|
||||||
|
|
||||||
return trackAsync.when(
|
return trackAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
data: (track) {
|
data: (track) {
|
||||||
if (track == null || track.lyrics == null) {
|
if (track == null || track.lyrics == null) {
|
||||||
return const Center(
|
// Show fetch lyrics UI
|
||||||
child: Text(
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
'No Lyrics Available',
|
'No Lyrics Available',
|
||||||
style: TextStyle(fontStyle: FontStyle.italic),
|
style: TextStyle(fontStyle: FontStyle.italic, fontSize: 18),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Fetch Lyrics'),
|
||||||
|
onPressed: track != null && trackPath != null
|
||||||
|
? () => _showFetchLyricsDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
track,
|
||||||
|
trackPath!,
|
||||||
|
metadataAsync.value,
|
||||||
|
musixmatchProviderInstance,
|
||||||
|
neteaseProviderInstance,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (lyricsFetcher.isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: LinearProgressIndicator(),
|
||||||
|
),
|
||||||
|
if (lyricsFetcher.error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
lyricsFetcher.error!,
|
||||||
|
style: TextStyle(color: Colors.red, fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (lyricsFetcher.successMessage != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Text(
|
||||||
|
lyricsFetcher.successMessage!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,10 +370,17 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||||
|
|
||||||
if (lyricsData.type == 'timed') {
|
if (lyricsData.type == 'timed') {
|
||||||
return _TimedLyricsView(lyrics: lyricsData, player: player);
|
return Stack(
|
||||||
|
children: [
|
||||||
|
_TimedLyricsView(lyrics: lyricsData, player: player),
|
||||||
|
_LyricsRefreshButton(trackPath: trackPath!),
|
||||||
|
],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Plain text lyrics
|
// Plain text lyrics
|
||||||
return ListView.builder(
|
return Stack(
|
||||||
|
children: [
|
||||||
|
ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: lyricsData.lines.length,
|
itemCount: lyricsData.lines.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -329,6 +393,9 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
_LyricsRefreshButton(trackPath: trackPath!),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -337,6 +404,210 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFetchLyricsDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
db.Track track,
|
||||||
|
String trackPath,
|
||||||
|
dynamic metadataObj,
|
||||||
|
musixmatchProvider,
|
||||||
|
neteaseProvider,
|
||||||
|
) {
|
||||||
|
final metadata = metadataObj as TrackMetadata?;
|
||||||
|
final searchTerm =
|
||||||
|
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Fetch Lyrics'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Search term: $searchTerm'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Choose a provider:'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ProviderButton(
|
||||||
|
name: 'Musixmatch',
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await ref
|
||||||
|
.read(lyricsFetcherProvider.notifier)
|
||||||
|
.fetchLyricsForTrack(
|
||||||
|
trackId: track.id,
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
provider: musixmatchProvider,
|
||||||
|
trackPath: trackPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ProviderButton(
|
||||||
|
name: 'NetEase',
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await ref
|
||||||
|
.read(lyricsFetcherProvider.notifier)
|
||||||
|
.fetchLyricsForTrack(
|
||||||
|
trackId: track.id,
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
provider: neteaseProvider,
|
||||||
|
trackPath: trackPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderButton extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const _ProviderButton({required this.name, required this.onPressed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: ElevatedButton(onPressed: onPressed, child: Text(name)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LyricsRefreshButton extends HookConsumerWidget {
|
||||||
|
final String trackPath;
|
||||||
|
|
||||||
|
const _LyricsRefreshButton({required this.trackPath});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final trackAsync = ref.watch(_trackByPathProvider(trackPath));
|
||||||
|
final metadataAsync = ref.watch(trackMetadataProvider(trackPath));
|
||||||
|
final musixmatchProviderInstance = ref.watch(musixmatchProvider);
|
||||||
|
final neteaseProviderInstance = ref.watch(neteaseProvider);
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 16,
|
||||||
|
right: 16,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
iconSize: 24,
|
||||||
|
tooltip: 'Refresh Lyrics',
|
||||||
|
onPressed: () => _showLyricsRefreshDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
trackAsync,
|
||||||
|
metadataAsync,
|
||||||
|
musixmatchProviderInstance,
|
||||||
|
neteaseProviderInstance,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLyricsRefreshDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AsyncValue<db.Track?> trackAsync,
|
||||||
|
AsyncValue<TrackMetadata> metadataAsync,
|
||||||
|
musixmatchProvider,
|
||||||
|
neteaseProvider,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Refresh Lyrics'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Choose an action:'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Re-fetch'),
|
||||||
|
onPressed: trackAsync.maybeWhen(
|
||||||
|
data: (track) => track != null
|
||||||
|
? () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
final metadata = metadataAsync.value;
|
||||||
|
final searchTerm =
|
||||||
|
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
|
||||||
|
.trim();
|
||||||
|
await ref
|
||||||
|
.read(lyricsFetcherProvider.notifier)
|
||||||
|
.fetchLyricsForTrack(
|
||||||
|
trackId: track.id,
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
provider:
|
||||||
|
musixmatchProvider, // Default to Musixmatch
|
||||||
|
trackPath: trackPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
orElse: () => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
label: const Text('Clear'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: trackAsync.maybeWhen(
|
||||||
|
data: (track) => track != null
|
||||||
|
? () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
final database = ref.read(databaseProvider);
|
||||||
|
await (database.update(
|
||||||
|
database.tracks,
|
||||||
|
)..where((t) => t.id.equals(track.id))).write(
|
||||||
|
db.TracksCompanion(
|
||||||
|
lyrics: const drift.Value.absent(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
orElse: () => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider to fetch a single track by path
|
// Provider to fetch a single track by path
|
||||||
@@ -504,7 +775,7 @@ class _TimedLyricsView extends HookWidget {
|
|||||||
).colorScheme.onSurface.withOpacity(0.7),
|
).colorScheme.onSurface.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
child: Text(line.text, textAlign: TextAlign.center),
|
child: Text(line.text),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -576,7 +576,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||||
@@ -756,7 +756,7 @@
|
|||||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
ENABLE_INCOMING_NETWORK_CONNECTIONS = NO;
|
||||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
|
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.0.3"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ dependencies:
|
|||||||
gap: ^3.0.1
|
gap: ^3.0.1
|
||||||
styled_widget: ^0.4.1
|
styled_widget: ^0.4.1
|
||||||
super_sliver_list: ^0.4.1
|
super_sliver_list: ^0.4.1
|
||||||
|
http: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user