diff --git a/lib/components/Lyrics/SyncedLyrics.dart b/lib/components/Lyrics/SyncedLyrics.dart index 7c7978f4..647dfa8a 100644 --- a/lib/components/Lyrics/SyncedLyrics.dart +++ b/lib/components/Lyrics/SyncedLyrics.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:just_audio/just_audio.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/helpers/timed-lyrics.dart'; -import 'package:spotube/provider/AudioPlayer.dart'; +import 'package:spotube/hooks/useAutoScrollController.dart'; +import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/hooks/useSyncedLyrics.dart'; import 'package:spotube/provider/Playback.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; class SyncedLyrics extends HookConsumerWidget { const SyncedLyrics({Key? key}) : super(key: key); @@ -12,18 +16,13 @@ class SyncedLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - AudioPlayer player = ref.watch(audioPlayerProvider); + final breakpoint = useBreakpoints(); + final controller = useAutoScrollController(); final timedLyrics = useMemoized(() { if (playback.currentTrack == null) return null; return getTimedLyrics(playback.currentTrack!); }, [playback.currentTrack]); final lyricsSnapshot = useFuture(timedLyrics); - final stream = useStream( - player.positionStream.isBroadcast - ? player.positionStream - : player.positionStream.asBroadcastStream(), - ); - final lyricsMap = useMemoized( () => lyricsSnapshot.data?.lyrics @@ -33,22 +32,65 @@ class SyncedLyrics extends HookConsumerWidget { [lyricsSnapshot.data], ); - print(lyricsSnapshot.data?.name); + final currentTime = useSyncedLyrics(ref, lyricsMap); - final currentLyric = useState(""); - - useEffect(() { - if (stream.hasData && lyricsMap.containsKey(stream.data!.inSeconds)) { - currentLyric.value = lyricsMap[stream.data!.inSeconds]!; - } - return null; - }, [stream.data, stream.hasData]); + final textTheme = Theme.of(context).textTheme; return Expanded( child: Column( children: [ - const Text("Lyrics"), - Text(currentLyric.value), + Center( + child: Text( + playback.currentTrack?.name ?? "", + style: breakpoint >= Breakpoints.md + ? textTheme.headline3 + : textTheme.headline4?.copyWith(fontSize: 25), + ), + ), + Center( + child: Text( + artistsToString(playback.currentTrack?.artists ?? []), + style: breakpoint >= Breakpoints.md + ? textTheme.headline5 + : textTheme.headline6, + ), + ), + if (lyricsSnapshot.hasData) + Expanded( + child: ListView.builder( + controller: controller, + itemBuilder: (context, index) { + final lyricSlice = lyricsSnapshot.data!.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: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + lyricSlice.text, + style: TextStyle( + // indicating the active state of that lyric slice + color: isActive ? Colors.green : null, + fontWeight: isActive ? FontWeight.bold : null, + fontSize: 30, + ), + ), + ), + ), + ); + }, + itemCount: lyricsSnapshot.data!.lyrics.length, + ), + ), ], ), ); diff --git a/lib/helpers/getLyrics.dart b/lib/helpers/getLyrics.dart index 15a2fc35..cc347b55 100644 --- a/lib/helpers/getLyrics.dart +++ b/lib/helpers/getLyrics.dart @@ -14,10 +14,29 @@ String clearArtistsOfTitle(String title, List artists) { .trim(); } -String getTitle(String title, [List artists = const []]) { +String getTitle( + String title, { + List artists = const [], + bool onlyCleanArtist = false, +}) { + final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0); + final artistInBracket = + artists.any((artist) => match?.contains(artist) ?? false); + + if (artistInBracket) { + title = title.replaceAll( + RegExp(" *\\([^)]*\\) *"), + '', + ); + } + + title = clearArtistsOfTitle(title, artists); + if (onlyCleanArtist) { + artists = []; + } + return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}" .toLowerCase() - .replaceAll(RegExp(" *\\([^)]*\\) *"), '') .replaceAll(RegExp(" *\\[[^\\]]*]"), '') .replaceAll(RegExp("feat.|ft."), '') .replaceAll(RegExp("\\s+"), ' ') @@ -66,9 +85,8 @@ Future searchSong( apiKey = getRandomElement(lyricsSecrets); } const searchUrl = 'https://api.genius.com/search?q='; - String song = optimizeQuery - ? getTitle(clearArtistsOfTitle(title, artist), artist) - : "$title $artist"; + String song = + optimizeQuery ? getTitle(title, artists: artist) : "$title $artist"; String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart index e3df13d5..b4318847 100644 --- a/lib/helpers/search-youtube.dart +++ b/lib/helpers/search-youtube.dart @@ -16,7 +16,11 @@ Future toYoutubeTrack( final mainArtist = artistsName.first; final featuredArtists = artistsName.length > 1 ? "feat. " + artistsName.sublist(1).join(" ") : ""; - final title = getTitle(clearArtistsOfTitle(track.name!, artistsName)).trim(); + final title = getTitle( + track.name!, + artists: artistsName, + onlyCleanArtist: true, + ).trim(); logger.v("[Track Search Title] $title"); final queryString = format .replaceAll("\$MAIN_ARTIST", mainArtist) diff --git a/lib/helpers/timed-lyrics.dart b/lib/helpers/timed-lyrics.dart index 2563817a..45fb66d5 100644 --- a/lib/helpers/timed-lyrics.dart +++ b/lib/helpers/timed-lyrics.dart @@ -32,8 +32,8 @@ Future getTimedLyrics(Track track) async { final artistNames = track.artists?.map((artist) => artist.name!).toList() ?? []; final query = getTitle( - clearArtistsOfTitle(track.name!, artistNames), - artistNames, + track.name!, + artists: artistNames, ); final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( queryParameters: {"q": query}, diff --git a/lib/hooks/useAutoScrollController.dart b/lib/hooks/useAutoScrollController.dart new file mode 100644 index 00000000..d6267082 --- /dev/null +++ b/lib/hooks/useAutoScrollController.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; + +/// Creates [AutoScrollController] that will be disposed automatically. +/// +/// See also: +/// - [AutoScrollController] +AutoScrollController useAutoScrollController({ + double initialScrollOffset = 0.0, + bool keepScrollOffset = true, + String? debugLabel, + Axis? axis, + AutoScrollController? copyTagsFrom, + double? suggestedRowHeight, + Rect Function() viewportBoundaryGetter = defaultViewportBoundaryGetter, + List? keys, +}) { + return use( + _AutoScrollControllerHook( + initialScrollOffset: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + debugLabel: debugLabel, + axis: axis, + copyTagsFrom: copyTagsFrom, + suggestedRowHeight: suggestedRowHeight, + viewportBoundaryGetter: viewportBoundaryGetter, + keys: keys, + ), + ); +} + +class _AutoScrollControllerHook extends Hook { + const _AutoScrollControllerHook({ + required this.initialScrollOffset, + required this.keepScrollOffset, + required this.viewportBoundaryGetter, + this.axis, + this.copyTagsFrom, + this.suggestedRowHeight, + this.debugLabel, + List? keys, + }) : super(keys: keys); + + final double initialScrollOffset; + final bool keepScrollOffset; + final String? debugLabel; + final Axis? axis; + final AutoScrollController? copyTagsFrom; + final double? suggestedRowHeight; + final Rect Function() viewportBoundaryGetter; + + @override + HookState> createState() => + _AutoScrollControllerHookState(); +} + +class _AutoScrollControllerHookState + extends HookState { + late final controller = AutoScrollController( + initialScrollOffset: hook.initialScrollOffset, + keepScrollOffset: hook.keepScrollOffset, + debugLabel: hook.debugLabel, + axis: hook.axis, + copyTagsFrom: hook.copyTagsFrom, + suggestedRowHeight: hook.suggestedRowHeight, + viewportBoundaryGetter: hook.viewportBoundaryGetter, + ); + + @override + AutoScrollController build(BuildContext context) => controller; + + @override + void dispose() => controller.dispose(); + + @override + String get debugLabel => 'useAutoScrollController'; +} diff --git a/lib/hooks/useSyncedLyrics.dart b/lib/hooks/useSyncedLyrics.dart new file mode 100644 index 00000000..2d6456f3 --- /dev/null +++ b/lib/hooks/useSyncedLyrics.dart @@ -0,0 +1,23 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/provider/AudioPlayer.dart'; + +useSyncedLyrics(WidgetRef ref, Map lyricsMap) { + final player = ref.watch(audioPlayerProvider); + final stream = player.positionStream.isBroadcast + ? player.positionStream + : player.positionStream.asBroadcastStream(); + + final currentTime = useState(0); + + useEffect(() { + final lol = stream.listen((pos) { + if (lyricsMap.containsKey(pos.inSeconds)) { + currentTime.value = pos.inSeconds; + } + }); + return () => lol.cancel(); + }, [lyricsMap]); + + return currentTime.value; +} diff --git a/pubspec.lock b/pubspec.lock index 2161bf14..1c098b39 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -597,6 +597,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.3" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3e40db81..aef69cb5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: logger: ^1.1.0 permission_handler: ^9.2.0 marquee: ^2.2.1 + scroll_to_index: ^2.1.1 dev_dependencies: flutter_test: