feat(lyrics): use official spotify API for fetching lyrics and add zoom controls

This commit is contained in:
Kingkor Roy Tirtho 2023-03-04 22:15:05 +06:00
parent bd48ca44ee
commit 10d0660972
9 changed files with 306 additions and 113 deletions

View File

@ -65,4 +65,6 @@ abstract class SpotubeIcons {
static const minimize = FeatherIcons.chevronDown;
static const personalized = FeatherIcons.star;
static const genres = FeatherIcons.music;
static const zoomIn = FeatherIcons.zoomIn;
static const zoomOut = FeatherIcons.zoomOut;
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/collections/spotube_icons.dart';
class ZoomControls extends HookWidget {
final int value;
final ValueChanged<int> onChanged;
final int min;
final int max;
const ZoomControls({
Key? key,
required this.value,
required this.onChanged,
this.min = 50,
this.max = 200,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: PlatformTheme.of(context)
.secondaryBackgroundColor
?.withOpacity(0.7),
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(maxHeight: 50),
margin: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
PlatformIconButton(
icon: const Icon(SpotubeIcons.zoomOut),
onPressed: () {
if (value == min) return;
onChanged(value - 10);
},
),
PlatformText("$value%"),
PlatformIconButton(
icon: const Icon(SpotubeIcons.zoomIn),
onPressed: () {
if (value == max) return;
onChanged(value + 10);
},
),
],
),
);
}
}

View File

@ -9,6 +9,26 @@ class SubtitleSimple {
required this.lyrics,
required this.rating,
});
factory SubtitleSimple.fromJson(Map<String, dynamic> json) {
return SubtitleSimple(
uri: Uri.parse(json["uri"] as String),
name: json["name"] as String,
lyrics: (json["lyrics"] as List<dynamic>)
.map((e) => LyricSlice.fromJson(e as Map<String, dynamic>))
.toList(),
rating: json["rating"] as int,
);
}
Map<String, dynamic> toJson() {
return {
"uri": uri.toString(),
"name": name,
"lyrics": lyrics.map((e) => e.toJson()).toList(),
"rating": rating,
};
}
}
class LyricSlice {
@ -17,6 +37,20 @@ class LyricSlice {
LyricSlice({required this.time, required this.text});
factory LyricSlice.fromJson(Map<String, dynamic> json) {
return LyricSlice(
time: Duration(milliseconds: json["time"]),
text: json["text"] as String,
);
}
Map<String, dynamic> toJson() {
return {
"time": time.inMilliseconds,
"text": text,
};
}
@override
String toString() {
return "LyricsSlice({time: $time, text: $text})";

View File

@ -1,102 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class GeniusLyrics extends HookConsumerWidget {
final PaletteColor palette;
final bool? isModal;
const GeniusLyrics({
required this.palette,
this.isModal,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final geniusLyricsQuery = useQueries.lyrics.static(
playlist?.activeTrack,
ref.watch(userPreferencesProvider).geniusAccessToken,
);
final breakpoint = useBreakpoints();
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isModal != true) ...[
Center(
child: Text(
playlist?.activeTrack.name ?? "",
style: breakpoint >= Breakpoints.md
? textTheme.displaySmall
: textTheme.headlineMedium?.copyWith(
fontSize: 25,
color: palette.titleTextColor,
),
),
),
Center(
child: Text(
TypeConversionUtils.artists_X_String<Artist>(
playlist?.activeTrack.artists ?? []),
style: (breakpoint >= Breakpoints.md
? textTheme.headlineSmall
: textTheme.titleLarge)
?.copyWith(color: palette.bodyTextColor),
),
)
],
Expanded(
child: SingleChildScrollView(
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Builder(
builder: (context) {
if (geniusLyricsQuery.isLoading ||
geniusLyricsQuery.isRefreshing) {
return const ShimmerLyrics();
} else if (geniusLyricsQuery.hasError) {
return Text(
"Sorry, no Lyrics were found for `${playlist?.activeTrack.name}` :'(\n${geniusLyricsQuery.error.toString()}",
style: textTheme.bodyLarge?.copyWith(
color: palette.bodyTextColor,
),
);
}
final lyrics = geniusLyricsQuery.data;
return Text(
lyrics == null && playlist?.activeTrack == null
? "No Track being played currently"
: lyrics ?? "",
style:
TextStyle(color: palette.bodyTextColor, fontSize: 18),
);
},
),
),
),
),
),
const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text("Powered by genius.com"),
),
)
],
);
}
}

View File

@ -9,7 +9,7 @@ import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/pages/lyrics/genius_lyrics.dart';
import 'package:spotube/pages/lyrics/plain_lyrics.dart';
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/platform.dart';
@ -41,7 +41,7 @@ class LyricsPage extends HookConsumerWidget {
Widget body = [
SyncedLyrics(palette: palette, isModal: isModal),
GeniusLyrics(palette: palette, isModal: isModal),
PlainLyrics(palette: palette, isModal: isModal),
][index.value];
final tabbar = PreferredSize(
@ -58,7 +58,7 @@ class LyricsPage extends HookConsumerWidget {
color: PlatformTextTheme.of(context).caption?.color,
),
PlatformTab(
label: "Genius",
label: "Plain",
icon: const SizedBox.shrink(),
color: PlatformTextTheme.of(context).caption?.color,
),
@ -71,7 +71,7 @@ class LyricsPage extends HookConsumerWidget {
child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Theme.of(context).backgroundColor.withOpacity(.4),
color: Theme.of(context).colorScheme.background.withOpacity(.4),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),

View File

@ -0,0 +1,132 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/lyrics/zoom_controls.dart';
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlainLyrics extends HookConsumerWidget {
final PaletteColor palette;
final bool? isModal;
const PlainLyrics({
required this.palette,
this.isModal,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final lyricsQuery = useQueries.lyrics.spotifySynced(
ref,
playlist?.activeTrack,
);
final breakpoint = useBreakpoints();
final textTheme = Theme.of(context).textTheme;
final textZoomLevel = useState<int>(100);
return Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isModal != true) ...[
Center(
child: Text(
playlist?.activeTrack.name ?? "",
style: breakpoint >= Breakpoints.md
? textTheme.displaySmall
: textTheme.headlineMedium?.copyWith(
fontSize: 25,
color: palette.titleTextColor,
),
),
),
Center(
child: Text(
TypeConversionUtils.artists_X_String<Artist>(
playlist?.activeTrack.artists ?? []),
style: (breakpoint >= Breakpoints.md
? textTheme.headlineSmall
: textTheme.titleLarge)
?.copyWith(color: palette.bodyTextColor),
),
)
],
Expanded(
child: SingleChildScrollView(
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Builder(
builder: (context) {
if (lyricsQuery.isLoading || lyricsQuery.isRefreshing) {
return const ShimmerLyrics();
} else if (lyricsQuery.hasError) {
return Text(
"Sorry, no Lyrics were found for `${playlist?.activeTrack.name}` :'(\n${lyricsQuery.error.toString()}",
style: textTheme.bodyLarge?.copyWith(
color: palette.bodyTextColor,
),
);
}
final lyrics =
lyricsQuery.data?.lyrics.mapIndexed((i, e) {
final next =
lyricsQuery.data?.lyrics.elementAtOrNull(i + 1);
if (next != null &&
e.time - next.time >
const Duration(milliseconds: 700)) {
return "${e.text}\n";
}
return e.text;
}).join("\n");
return AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
color: palette.bodyTextColor,
fontSize: 24 * textZoomLevel.value / 100,
height: textZoomLevel.value < 70
? 1.5
: textZoomLevel.value > 150
? 1.7
: 2,
),
child: Text(
lyrics == null && playlist?.activeTrack == null
? "No Track being played currently"
: lyrics ?? "",
),
);
},
),
),
),
),
),
],
),
Align(
alignment: Alignment.bottomRight,
child: ZoomControls(
value: textZoomLevel.value,
onChanged: (value) => textZoomLevel.value = value,
min: 50,
max: 200,
),
),
],
);
}
}

View File

@ -1,4 +1,3 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -6,14 +5,13 @@ import 'package:palette_generator/palette_generator.dart';
import 'package:platform_ui/platform_ui.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';
import 'package:spotube/components/shared/spotube_marquee_text.dart';
import 'package:spotube/components/lyrics/lyric_delay_adjust_dialog.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/hooks/use_synced_lyrics.dart';
import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
@ -44,7 +42,8 @@ class SyncedLyrics extends HookConsumerWidget {
final breakpoint = useBreakpoints();
final controller = useAutoScrollController();
final timedLyricsQuery = useQueries.lyrics.synced(playlist?.activeTrack);
final timedLyricsQuery =
useQueries.lyrics.spotifySynced(ref, playlist?.activeTrack);
final lyricValue = timedLyricsQuery.data;
final lyricsMap = useMemoized(
() =>
@ -56,6 +55,7 @@ class SyncedLyrics extends HookConsumerWidget {
[lyricValue],
);
final currentTime = useSyncedLyrics(ref, lyricsMap, lyricDelay);
final textZoomLevel = useState<int>(100);
final textTheme = Theme.of(context).textTheme;
@ -128,7 +128,8 @@ class SyncedLyrics extends HookConsumerWidget {
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
fontSize: isActive ? 30 : 26,
fontSize: (isActive ? 30 : 26) *
(textZoomLevel.value / 100),
),
child: Text(
lyricSlice.text,
@ -169,6 +170,15 @@ class SyncedLyrics extends HookConsumerWidget {
),
),
),
Align(
alignment: Alignment.bottomRight,
child: ZoomControls(
value: textZoomLevel.value,
onChanged: (value) => textZoomLevel.value = value,
min: 50,
max: 200,
),
),
],
);
});

View File

@ -1,10 +1,16 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/map.dart';
import 'package:spotube/hooks/use_spotify_query.dart';
import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:http/http.dart' as http;
class LyricsQueries {
const LyricsQueries();
@ -48,4 +54,61 @@ class LyricsQueries {
},
);
}
/// The Concept behind this method was shamelessly stolen from
/// https://github.com/akashrchandran/spotify-lyrics-api
///
/// Thanks to [akashrchandran](https://github.com/akashrchandran) for the idea
///
/// Special thanks to [raptag](https://github.com/raptag) for discovering this
/// jem
Query<SubtitleSimple, dynamic> spotifySynced(WidgetRef ref, Track? track) {
return useSpotifyQuery<SubtitleSimple, dynamic>(
"spotify-synced-lyrics/${track?.id}}",
(spotify) async {
if (track == null) {
throw "No track currently";
}
final token = await spotify.getCredentials();
final res = await http.get(
Uri.parse(
"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}"
});
if (res.statusCode != 200) {
throw Exception("Unable to find lyrics");
}
final linesRaw = Map.castFrom<dynamic, dynamic, String, dynamic>(
jsonDecode(res.body),
)["lyrics"]?["lines"] as List?;
final lines = linesRaw?.map((line) {
return LyricSlice(
time: Duration(milliseconds: int.parse(line["startTimeMs"])),
text: line["words"] as String,
);
}).toList() ??
[];
return SubtitleSimple(
lyrics: lines,
name: track.name!,
uri: res.request!.url,
rating: 100,
);
},
jsonConfig: JsonConfig(
fromJson: (json) => SubtitleSimple.fromJson(json.castKeyDeep<String>()),
toJson: (data) => data.toJson(),
),
ref: ref,
);
}
}

View File

@ -535,7 +535,7 @@ packages:
description:
path: "packages/fl_query"
ref: new-architecture
resolved-ref: "0c819d4e11572d592b5334280b8b4f2657f21459"
resolved-ref: "5332bd16d389e703b0eaf17ab79bd59382500d08"
url: "https://github.com/KRTirtho/fl-query.git"
source: git
version: "0.3.1"
@ -544,7 +544,7 @@ packages:
description:
path: "packages/fl_query_hooks"
ref: new-architecture
resolved-ref: "0c819d4e11572d592b5334280b8b4f2657f21459"
resolved-ref: "5332bd16d389e703b0eaf17ab79bd59382500d08"
url: "https://github.com/KRTirtho/fl-query.git"
source: git
version: "0.3.1"