✨ lrc fetcher
This commit is contained in:
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:drift/drift.dart' as drift;
|
||||
import 'package:groovybox/data/db.dart' as db;
|
||||
import 'package:groovybox/logic/lyrics_parser.dart';
|
||||
import 'package:groovybox/logic/metadata_service.dart';
|
||||
import 'package:groovybox/providers/audio_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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -296,16 +298,71 @@ class _PlayerLyrics extends HookConsumerWidget {
|
||||
? ref.watch(_trackByPathProvider(trackPath!))
|
||||
: 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(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (track) {
|
||||
if (track == null || track.lyrics == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No Lyrics Available',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
// Show fetch lyrics UI
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'No Lyrics Available',
|
||||
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,22 +370,32 @@ class _PlayerLyrics extends HookConsumerWidget {
|
||||
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||
|
||||
if (lyricsData.type == 'timed') {
|
||||
return _TimedLyricsView(lyrics: lyricsData, player: player);
|
||||
return Stack(
|
||||
children: [
|
||||
_TimedLyricsView(lyrics: lyricsData, player: player),
|
||||
_LyricsRefreshButton(trackPath: trackPath!),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Plain text lyrics
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: lyricsData.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
lyricsData.lines[index].text,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: lyricsData.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
lyricsData.lines[index].text,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_LyricsRefreshButton(trackPath: trackPath!),
|
||||
],
|
||||
);
|
||||
}
|
||||
} 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
|
||||
@@ -504,7 +775,7 @@ class _TimedLyricsView extends HookWidget {
|
||||
).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
child: Text(line.text, textAlign: TextAlign.center),
|
||||
child: Text(line.text),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user