feat(lyrics): add LRCLIB lyrics provider as fallback

This commit is contained in:
Kingkor Roy Tirtho 2024-04-05 00:48:08 +06:00
parent c8dd8025ec
commit 5afe823abd
9 changed files with 270 additions and 110 deletions

View File

@ -1,13 +1,18 @@
import 'package:lrc/lrc.dart';
class SubtitleSimple {
Uri uri;
String name;
List<LyricSlice> 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<String, dynamic> json) {
@ -18,6 +23,7 @@ class SubtitleSimple {
.map((e) => LyricSlice.fromJson(e as Map<String, dynamic>))
.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<String, dynamic> json) {
return LyricSlice(
time: Duration(milliseconds: json["time"]),

View File

@ -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) {

View File

@ -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,
),
);
},

View File

@ -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,

View File

@ -6,26 +6,28 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?>
load();
}
@override
FutureOr<SubtitleSimple> 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<SubtitleSimple> 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<dynamic, dynamic, String, dynamic>(
jsonDecode(res.body),
@ -41,12 +43,105 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?>
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<SubtitleSimple> 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<String, dynamic>;
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<SubtitleSimple> 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<SubtitleSimple> fromJson(Map<String, dynamic> json) =>
SubtitleSimple.fromJson(json.castKeyDeep<String>());

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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