From e6761a6f8eadf4ab260723253a8e00121b6365b5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 25 Oct 2022 12:13:19 +0600 Subject: [PATCH] feat(search): infinite scroll for tracks, artists, playlists and albums --- lib/components/Search/Search.dart | 348 +++++++++++++++++++----------- lib/provider/SpotifyRequests.dart | 18 +- 2 files changed, 241 insertions(+), 125 deletions(-) diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart index fca9c28af..3195b195d 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -6,9 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; +import 'package:spotube/components/Shared/Waypoint.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Auth.dart'; @@ -18,6 +20,7 @@ import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:tuple/tuple.dart'; +import 'package:collection/collection.dart'; final searchTermStateProvider = StateProvider((ref) => ""); @@ -32,14 +35,6 @@ class Search extends HookConsumerWidget { final artistController = useScrollController(); final breakpoint = useBreakpoints(); - final searchMutation = useMutation( - job: searchMutationJob, - ); - - if (auth.isAnonymous) { - return const AnonymousFallback(); - } - final getVariables = useCallback( () => Tuple2( ref.read(searchTermStateProvider), @@ -48,6 +43,37 @@ class Search extends HookConsumerWidget { [], ); + final searchTrack = useInfiniteQuery( + job: searchQueryJob(SearchType.track.key), + externalData: getVariables()); + final searchAlbum = useInfiniteQuery( + job: searchQueryJob(SearchType.album.key), + externalData: getVariables()); + final searchPlaylist = useInfiniteQuery( + job: searchQueryJob(SearchType.playlist.key), + externalData: getVariables()); + final searchArtist = useInfiniteQuery( + job: searchQueryJob(SearchType.artist.key), + externalData: getVariables()); + + if (auth.isAnonymous) { + return const AnonymousFallback(); + } + + void onSearch() { + for (final query in [ + searchTrack, + searchAlbum, + searchPlaylist, + searchArtist, + ]) { + query.enabled = false; + query.fetched = true; + query.setExternalData(getVariables()); + query.refetch(); + } + } + return SafeArea( child: Material( color: Theme.of(context).backgroundColor, @@ -66,10 +92,8 @@ class Search extends HookConsumerWidget { decoration: InputDecoration( isDense: true, suffix: ElevatedButton( + onPressed: onSearch, child: const Icon(Icons.search_rounded), - onPressed: () { - searchMutation.mutate(getVariables()); - }, ), contentPadding: const EdgeInsets.symmetric( horizontal: 10, @@ -79,26 +103,24 @@ class Search extends HookConsumerWidget { hintText: "Search...", ), onSubmitted: (value) { - searchMutation.mutate(getVariables()); + onSearch(); }, ), ), HookBuilder( builder: (context) { - if (searchMutation.hasError && searchMutation.isError) { - return Text("Alas! Error=${searchMutation.error}"); - } - if (searchMutation.isLoading) { - return const CircularProgressIndicator(); - } - Playback playback = ref.watch(playbackProvider); List albums = []; List artists = []; List tracks = []; List playlists = []; - for (MapEntry page - in (searchMutation.data ?? []).asMap().entries) { + final pages = [ + ...searchTrack.pages, + ...searchAlbum.pages, + ...searchPlaylist.pages, + ...searchArtist.pages, + ].expand((page) => page ?? []).toList(); + for (MapEntry page in pages.asMap().entries) { for (var item in page.value.items ?? []) { if (item is AlbumSimple) { albums.add(item); @@ -126,69 +148,112 @@ class Search extends HookConsumerWidget { "Songs", style: Theme.of(context).textTheme.headline5, ), - ...tracks.asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return TrackTile( - playback, - track: track, - duration: duration, - isActive: playback.track?.id == track.value.id, - onTrackPlayButtonPressed: (currentTrack) async { - var isPlaylistPlaying = playback.playlist?.id != - null && - playback.playlist?.id == currentTrack.id; - if (!isPlaylistPlaying) { - playback.playPlaylist( - CurrentPlaylist( - tracks: [currentTrack], - id: currentTrack.id!, - name: currentTrack.name!, - thumbnail: - TypeConversionUtils.image_X_UrlString( - currentTrack.album?.images, - placeholder: ImagePlaceholder.albumArt, + if (searchTrack.isLoading && + !searchTrack.isFetchingNextPage) + const CircularProgressIndicator() + else if (searchTrack.hasError) + Text( + searchTrack.error?[searchTrack.pageParams.last]) + else + ...tracks.asMap().entries.map((track) { + String duration = + "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + return TrackTile( + playback, + track: track, + duration: duration, + isActive: playback.track?.id == track.value.id, + onTrackPlayButtonPressed: (currentTrack) async { + var isPlaylistPlaying = + playback.playlist?.id != null && + playback.playlist?.id == + currentTrack.id; + if (!isPlaylistPlaying) { + playback.playPlaylist( + CurrentPlaylist( + tracks: [currentTrack], + id: currentTrack.id!, + name: currentTrack.name!, + thumbnail: TypeConversionUtils + .image_X_UrlString( + currentTrack.album?.images, + placeholder: + ImagePlaceholder.albumArt, + ), ), - ), - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playback.track?.id) { - playback.play(currentTrack); - } - }, - ); - }), - if (albums.isNotEmpty) + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playback.track?.id) { + playback.play(currentTrack); + } + }, + ); + }), + if (searchTrack.hasNextPage && tracks.isNotEmpty) + Center( + child: TextButton( + onPressed: searchTrack.isFetchingNextPage + ? null + : () => searchTrack.fetchNextPage(), + child: searchTrack.isFetchingNextPage + ? const CircularProgressIndicator() + : const Text("Load more"), + ), + ), + if (playlists.isNotEmpty) Text( - "Albums", + "Playlists", style: Theme.of(context).textTheme.headline5, ), const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: albumController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: albumController, - child: Row( - children: albums.map((album) { - return AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album( - album, + if (searchPlaylist.isLoading && + !searchPlaylist.isFetchingNextPage) + const CircularProgressIndicator() + else if (searchPlaylist.hasError) + Text(searchPlaylist + .error?[searchPlaylist.pageParams.last]) + else + ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + scrollbarOrientation: + breakpoint > Breakpoints.md + ? ScrollbarOrientation.bottom + : ScrollbarOrientation.top, + controller: playlistController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: playlistController, + child: Row( + children: [ + ...playlists.mapIndexed( + (i, playlist) { + if (i == playlists.length - 1 && + searchPlaylist.hasNextPage) { + return Waypoint( + onEnter: () { + searchPlaylist.fetchNextPage(); + }, + child: + const ShimmerPlaybuttonCard( + count: 1), + ); + } + return PlaylistCard(playlist); + }, ), - ); - }).toList(), + ], + ), ), ), ), - ), const SizedBox(height: 20), if (artists.isNotEmpty) Text( @@ -196,64 +261,105 @@ class Search extends HookConsumerWidget { style: Theme.of(context).textTheme.headline5, ), const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - controller: artistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + if (searchArtist.isLoading && + !searchArtist.isFetchingNextPage) + const CircularProgressIndicator() + else if (searchArtist.hasError) + Text(searchArtist + .error?[searchArtist.pageParams.last]) + else + ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( controller: artistController, - child: Row( - children: artists - .map( - (artist) => Container( - margin: const EdgeInsets.symmetric( - horizontal: 15), - child: ArtistCard(artist), - ), - ) - .toList(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: artistController, + child: Row( + children: [ + ...artists.mapIndexed( + (i, artist) { + if (i == artists.length - 1 && + searchArtist.hasNextPage) { + return Waypoint( + onEnter: () { + searchArtist.fetchNextPage(); + }, + child: + const ShimmerPlaybuttonCard( + count: 1), + ); + } + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 15), + child: ArtistCard(artist), + ); + }, + ), + ], + ), ), ), ), - ), const SizedBox(height: 20), - if (playlists.isNotEmpty) + if (albums.isNotEmpty) Text( - "Playlists", + "Albums", style: Theme.of(context).textTheme.headline5, ), const SizedBox(height: 10), - ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - PointerDeviceKind.touch, - PointerDeviceKind.mouse, - }, - ), - child: Scrollbar( - scrollbarOrientation: breakpoint > Breakpoints.md - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: playlistController, - child: Row( - children: playlists - .map( - (playlist) => PlaylistCard(playlist), - ) - .toList(), + if (searchAlbum.isLoading && + !searchAlbum.isFetchingNextPage) + const CircularProgressIndicator() + else if (searchAlbum.hasError) + Text( + searchAlbum.error?[searchAlbum.pageParams.last]) + else + ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: albumController, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: albumController, + child: Row( + children: [ + ...albums.mapIndexed((i, album) { + if (i == albums.length - 1 && + searchAlbum.hasNextPage) { + return Waypoint( + onEnter: () { + searchAlbum.fetchNextPage(); + }, + child: const ShimmerPlaybuttonCard( + count: 1), + ); + } + return AlbumCard( + TypeConversionUtils + .simpleAlbum_X_Album( + album, + ), + ); + }), + ], + ), ), ), ), - ), ], ), ), diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index f85a82b94..af60326bd 100644 --- a/lib/provider/SpotifyRequests.dart +++ b/lib/provider/SpotifyRequests.dart @@ -174,13 +174,23 @@ final albumIsSavedForCurrentUserQueryJob = .isSavedAlbums([getVariable(queryKey)]).then((value) => value.first); }); -final searchMutationJob = MutationJob, Tuple2>( - mutationKey: "search-query", - task: (ref, variables) { +final searchQueryJob = InfiniteQueryJob.withVariableKey, + Tuple2, int>( + preQueryKey: "search-query", + initialParam: 0, + enabled: false, + getNextPageParam: (lastPage, lastParam) => + (lastPage.first.items?.length ?? 0) < 10 ? null : lastParam + 10, + getPreviousPageParam: (lastPage, lastParam) => lastParam - 10, + task: (queryKey, pageParam, variables) { final queryString = variables.item1; final spotify = variables.item2; if (queryString.isEmpty) return []; - return spotify.search.get(queryString).first(10); + final searchType = getVariable(queryKey); + return spotify.search.get( + queryString, + types: [SearchType(searchType)], + ).getPage(10, pageParam); }, );