diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 485abacd..2866cb8f 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -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; } diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/components/lyrics/zoom_controls.dart new file mode 100644 index 00000000..25479c15 --- /dev/null +++ b/lib/components/lyrics/zoom_controls.dart @@ -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 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); + }, + ), + ], + ), + ); + } +} diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index 118bd8c3..c800b040 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -9,6 +9,26 @@ class SubtitleSimple { required this.lyrics, required this.rating, }); + + factory SubtitleSimple.fromJson(Map json) { + return SubtitleSimple( + uri: Uri.parse(json["uri"] as String), + name: json["name"] as String, + lyrics: (json["lyrics"] as List) + .map((e) => LyricSlice.fromJson(e as Map)) + .toList(), + rating: json["rating"] as int, + ); + } + + Map 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 json) { + return LyricSlice( + time: Duration(milliseconds: json["time"]), + text: json["text"] as String, + ); + } + + Map toJson() { + return { + "time": time.inMilliseconds, + "text": text, + }; + } + @override String toString() { return "LyricsSlice({time: $time, text: $text})"; diff --git a/lib/pages/lyrics/genius_lyrics.dart b/lib/pages/lyrics/genius_lyrics.dart deleted file mode 100644 index b158a13b..00000000 --- a/lib/pages/lyrics/genius_lyrics.dart +++ /dev/null @@ -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( - 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"), - ), - ) - ], - ); - } -} diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index f36986b1..ee61deef 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -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), diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart new file mode 100644 index 00000000..17a4785c --- /dev/null +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -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(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( + 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, + ), + ), + ], + ); + } +} diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index d7081399..021f4fd7 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -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(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, + ), + ), ], ); }); diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart index 17bca789..989a2e97 100644 --- a/lib/services/queries/lyrics.dart +++ b/lib/services/queries/lyrics.dart @@ -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 spotifySynced(WidgetRef ref, Track? track) { + return useSpotifyQuery( + "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( + 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()), + toJson: (data) => data.toJson(), + ), + ref: ref, + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 4c4d707f..ca29daf6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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"