diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c33d211..dcf7329 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -365,6 +365,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GroovyBox; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -545,6 +546,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GroovyBox; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -568,6 +570,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = GroovyBox; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 417d21b..9e62c30 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,14 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + processing + audio + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +51,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/lib/logic/lrc_providers.dart b/lib/logic/lrc_providers.dart new file mode 100644 index 0000000..420735d --- /dev/null +++ b/lib/logic/lrc_providers.dart @@ -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 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 _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(Uri.parse(url), headers: 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.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 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 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 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?> 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 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 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 +extension MapEntryList on List> { + Map toMap() { + return Map.fromEntries(this); + } +} diff --git a/lib/providers/lrc_fetcher_provider.dart b/lib/providers/lrc_fetcher_provider.dart new file mode 100644 index 0000000..17a36f6 --- /dev/null +++ b/lib/providers/lrc_fetcher_provider.dart @@ -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 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()); diff --git a/lib/providers/lrc_fetcher_provider.g.dart b/lib/providers/lrc_fetcher_provider.g.dart new file mode 100644 index 0000000..b59beb1 --- /dev/null +++ b/lib/providers/lrc_fetcher_provider.g.dart @@ -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 { + 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(value), + ); + } +} + +String _$lyricsFetcherHash() => r'49468a75e00ab1533368acb52328b059831836d3'; + +abstract class _$LyricsFetcher extends $Notifier { + LyricsFetcherState build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LyricsFetcherState, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index a7b07f0..60963f8 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -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.data(null); + final metadataAsync = trackPath != null + ? ref.watch(trackMetadataProvider(trackPath!)) + : const AsyncValue.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 trackAsync, + AsyncValue 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), ), ), ); diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 1ad8ac0..7b4b99c 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -576,7 +576,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -722,7 +722,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; @@ -756,7 +756,7 @@ DEVELOPMENT_TEAM = W7HPZ53V6B; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; - ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; diff --git a/pubspec.lock b/pubspec.lock index 1dc46cc..d014cd8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -425,7 +425,7 @@ packages: source: hosted version: "3.0.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 1fccec5..a32fb85 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: gap: ^3.0.1 styled_widget: ^0.4.1 super_sliver_list: ^0.4.1 + http: ^1.0.0 dev_dependencies: flutter_test: