From 5afe823abdb198340b55d138d8173d886a811632 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Apr 2024 00:48:08 +0600 Subject: [PATCH] feat(lyrics): add LRCLIB lyrics provider as fallback --- lib/models/lyrics.dart | 14 ++ lib/pages/lyrics/lyrics.dart | 32 +++- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 199 ++++++++++++------------ lib/provider/spotify/lyrics/synced.dart | 117 ++++++++++++-- lib/provider/spotify/spotify.dart | 2 + lib/utils/service_utils.dart | 5 +- pubspec.lock | 8 + pubspec.yaml | 1 + 9 files changed, 270 insertions(+), 110 deletions(-) diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index c800b040b..f64572876 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -1,13 +1,18 @@ +import 'package:lrc/lrc.dart'; + class SubtitleSimple { Uri uri; String name; List lyrics; int rating; + String provider; + SubtitleSimple({ required this.uri, required this.name, required this.lyrics, required this.rating, + required this.provider, }); factory SubtitleSimple.fromJson(Map json) { @@ -18,6 +23,7 @@ class SubtitleSimple { .map((e) => LyricSlice.fromJson(e as Map)) .toList(), rating: json["rating"] as int, + provider: json["provider"] as String? ?? "unknown", ); } @@ -27,6 +33,7 @@ class SubtitleSimple { "name": name, "lyrics": lyrics.map((e) => e.toJson()).toList(), "rating": rating, + "provider": provider, }; } } @@ -37,6 +44,13 @@ class LyricSlice { LyricSlice({required this.time, required this.text}); + factory LyricSlice.fromLrcLine(LrcLine line) { + return LyricSlice( + time: line.timestamp, + text: line.lyrics.trim(), + ); + } + factory LyricSlice.fromJson(Map json) { return LyricSlice( time: Duration(milliseconds: json["time"]), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 6d406e33c..a0db7178d 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -19,6 +20,7 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; @@ -43,13 +45,41 @@ class LyricsPage extends HookConsumerWidget { noSetBGColor: true, ); - final tabbar = ThemedButtonsTabBar( + PreferredSizeWidget tabbar = ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.synced} "), Tab(text: " ${context.l10n.plain} "), ], ); + tabbar = PreferredSize( + preferredSize: tabbar.preferredSize, + child: Row( + children: [ + tabbar, + const Spacer(), + Consumer( + builder: (context, ref, child) { + final playback = ref.watch(ProxyPlaylistNotifier.provider); + final lyric = + ref.watch(syncedLyricsProvider(playback.activeTrack)); + final providerName = lyric.asData?.value.provider; + + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text("Powered by $providerName"), + ); + }, + ), + const Gap(5), + ], + ), + ); + final auth = ref.watch(AuthenticationNotifier.provider); if (auth == null) { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index f1c6ec2e6..2c0df0aa1 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; @@ -120,6 +119,7 @@ class PlainLyrics extends HookConsumerWidget { lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", + textAlign: TextAlign.center, ), ); }, diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 5e7a24c84..52824f5e2 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,4 +1,8 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -71,125 +75,128 @@ class SyncedLyrics extends HookConsumerWidget { ); return Stack( children: [ - Column( - children: [ + CustomScrollView( + controller: controller, + slivers: [ if (isModal != true) - Center( - child: Text( + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + centerTitle: true, + title: Text( playlist.activeTrack?.name ?? "Not Playing", style: headlineTextStyle, ), - ), - if (isModal != true) - Center( - child: Text( - playlist.activeTrack?.artists?.asString() ?? "", - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: Text( + playlist.activeTrack?.artists?.asString() ?? "", + style: mediaQuery.mdAndUp + ? textTheme.headlineSmall + : textTheme.titleLarge, + ), ), ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && lyricsState.asData?.value.static != true) - Expanded( - child: ListView.builder( - controller: controller, - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; + SliverList.builder( + itemCount: lyricValue.lyrics.length, + itemBuilder: (context, index) { + final lyricSlice = lyricValue.lyrics[index]; + final isActive = lyricSlice.time.inSeconds == currentTime; - if (isActive) { - controller.scrollToIndex( - index, - preferPosition: AutoScrollPosition.middle, - ); - } - return AutoScrollTag( - key: ValueKey(index), - index: index, - controller: controller, - child: lyricSlice.text.isEmpty - ? Container( + if (isActive) { + controller.scrollToIndex( + index, + preferPosition: AutoScrollPosition.middle, + ); + } + return AutoScrollTag( + key: ValueKey(index), + index: index, + controller: controller, + child: lyricSlice.text.isEmpty + ? Container( + padding: index == lyricValue.lyrics.length - 1 + ? EdgeInsets.only( + bottom: mediaQuery.size.height / 2, + ) + : null, + ) + : Center( + child: Padding( padding: index == lyricValue.lyrics.length - 1 - ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + ? const EdgeInsets.all(8.0).copyWith( + bottom: 100, ) - : null, - ) - : Center( - child: Padding( - padding: index == lyricValue.lyrics.length - 1 - ? const EdgeInsets.all(8.0).copyWith( - bottom: 100, - ) - : const EdgeInsets.all(8.0), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: TextStyle( - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: InkWell( - onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > duration || time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), - ), + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.normal, + fontSize: (isActive ? 28 : 26) * + (textZoomLevel.value / 100), + ), + textAlign: TextAlign.center, + child: InkWell( + onTap: () async { + final duration = + await audioPlayer.duration ?? + Duration.zero; + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > duration || time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: isActive + ? Colors.white + : palette.bodyTextColor, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), ), ), ), - ); - }, - ), + ), + ); + }, ), if (playlist.activeTrack != null && (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const Expanded( - child: ShimmerLyrics(), - ) + const SliverToBoxAdapter(child: ShimmerLyrics()) else if (playlist.activeTrack != null && (timedLyricsQuery.hasError)) ...[ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.no_lyrics_available, + style: bodyTextTheme, + textAlign: TextAlign.center, + ), ), ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), + const SliverGap(26), + const SliverToBoxAdapter( + child: Icon(SpotubeIcons.noLyrics, size: 60), + ), ] else if (lyricsState.asData?.value.static == true) - Expanded( + SliverFillRemaining( child: Center( child: RichText( textAlign: TextAlign.center, diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index d86735db3..6ce74ae79 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -6,26 +6,28 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier load(); } - @override - FutureOr build(track) async { - final spotify = ref.watch(spotifyProvider); - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); + Track get _track => arg!; + + Future getSpotifyLyrics(String? token) async { final res = await http.get( Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", ), headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" + "authorization": "Bearer $token" }); if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "Spotify", + ); } final linesRaw = Map.castFrom( jsonDecode(res.body), @@ -41,12 +43,105 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, - name: track.name!, + name: _track.name!, uri: res.request!.url, rating: 100, + provider: "Spotify", ); } + /// Lyrics credits: [lrclib.net](https://lrclib.net) and their contributors + /// Thanks for their generous public API + Future getLRCLibLyrics() async { + final packageInfo = await PackageInfo.fromPlatform(); + + final res = await http.get( + Uri( + scheme: "https", + host: "lrclib.net", + path: "/api/get", + queryParameters: { + "artist_name": _track.artists?.first.name, + "track_name": _track.name, + "album_name": _track.album?.name, + "duration": _track.duration?.inSeconds.toString(), + }, + ), + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + final json = jsonDecode(res.body) as Map; + + final syncedLyricsRaw = json["syncedLyrics"] as String?; + final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true + ? Lrc.parse(syncedLyricsRaw!) + .lyrics + .map(LyricSlice.fromLrcLine) + .toList() + : null; + + if (syncedLyrics?.isNotEmpty == true) { + return SubtitleSimple( + lyrics: syncedLyrics!, + name: _track.name!, + uri: res.request!.url, + rating: 100, + provider: "LRCLib", + ); + } + + final plainLyrics = (json["plainLyrics"] as String) + .split("\n") + .map((line) => LyricSlice(text: line, time: Duration.zero)) + .toList(); + + return SubtitleSimple( + lyrics: plainLyrics, + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + @override + FutureOr build(track) async { + try { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); + + if (lyrics.lyrics.isEmpty) { + lyrics = await getLRCLibLyrics(); + } + + if (lyrics.lyrics.isEmpty) { + throw Exception("Unable to find lyrics"); + } + + return lyrics; + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + rethrow; + } + } + @override FutureOr fromJson(Map json) => SubtitleSimple.fromJson(json.castKeyDeep()); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index b152db65b..816420f65 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -8,6 +8,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 60c77e59a..88c528966 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -251,6 +251,7 @@ abstract class ServiceUtils { uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, + provider: "Rent An Adviser", ); return subtitle; @@ -307,7 +308,9 @@ abstract class ServiceUtils { case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: - return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0; + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: diff --git a/pubspec.lock b/pubspec.lock index 47c1aba34..588aca13e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,6 +1455,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + lrc: + dependency: "direct main" + description: + name: lrc + sha256: "5100362b5c8e97f4d3f03ff87efeb40e73a6dd780eca2cbde9312e0d44b8e5ba" + url: "https://pub.dev" + source: hosted + version: "1.0.2" mailer: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9f323a6f0..298631d2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,6 +128,7 @@ dependencies: shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.4 + lrc: ^1.0.2 dev_dependencies: build_runner: ^2.3.2