mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat(lyrics): add LRCLIB lyrics provider as fallback
This commit is contained in:
parent
c8dd8025ec
commit
5afe823abd
@ -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"]),
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -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,17 +75,20 @@ 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(
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(40),
|
||||
child: Text(
|
||||
playlist.activeTrack?.artists?.asString() ?? "",
|
||||
style: mediaQuery.mdAndUp
|
||||
@ -89,12 +96,11 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
: textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (lyricValue != null &&
|
||||
lyricValue.lyrics.isNotEmpty &&
|
||||
lyricsState.asData?.value.static != true)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
SliverList.builder(
|
||||
itemCount: lyricValue.lyrics.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lyricSlice = lyricValue.lyrics[index];
|
||||
@ -169,15 +175,13 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (playlist.activeTrack != null &&
|
||||
(timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing))
|
||||
const Expanded(
|
||||
child: ShimmerLyrics(),
|
||||
)
|
||||
const SliverToBoxAdapter(child: ShimmerLyrics())
|
||||
else if (playlist.activeTrack != null &&
|
||||
(timedLyricsQuery.hasError)) ...[
|
||||
Container(
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
@ -186,10 +190,13 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
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,
|
||||
|
@ -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>());
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user