From 9080441b875ceb91260bbad79291365a98d5be95 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 6 Feb 2023 23:06:26 +0600 Subject: [PATCH] feat(home): personalized section --- lib/collections/routes.dart | 5 +- lib/collections/spotube_icons.dart | 2 + lib/components/album/album_card.dart | 23 +++- lib/components/genre/category_card.dart | 2 - lib/pages/{genre => home}/genres.dart | 48 +++----- lib/pages/home/home.dart | 69 +++++++++++ lib/pages/home/personalized.dart | 150 ++++++++++++++++++++++++ lib/provider/downloader_provider.dart | 2 +- lib/services/queries/album.dart | 25 ++++ lib/services/queries/category.dart | 5 +- lib/services/queries/playlist.dart | 20 ++++ 11 files changed, 306 insertions(+), 45 deletions(-) rename lib/pages/{genre => home}/genres.dart (75%) create mode 100644 lib/pages/home/home.dart create mode 100644 lib/pages/home/personalized.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index aa04d5dc4..5fec09f8d 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -2,13 +2,13 @@ import 'package:catcher/catcher.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/shared/spotube_page_route.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/pages/genre/genres.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart'; @@ -31,8 +31,7 @@ final router = GoRouter( routes: [ GoRoute( path: "/", - pageBuilder: (context, state) => - SpotubePage(child: const GenrePage()), + pageBuilder: (context, state) => SpotubePage(child: const HomePage()), ), GoRoute( path: "/search", diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index fa31427b8..485abacdf 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -63,4 +63,6 @@ abstract class SpotubeIcons { static const userRemove = FeatherIcons.userX; static const close = FeatherIcons.x; static const minimize = FeatherIcons.chevronDown; + static const personalized = FeatherIcons.star; + static const genres = FeatherIcons.music; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 2cf0b9e66..7a1a40a18 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -12,6 +12,27 @@ import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:uuid/uuid.dart'; +enum AlbumType { + album, + single, + compilation; + + factory AlbumType.from(String? type) { + switch (type) { + case "album": + return AlbumType.album; + case "single": + return AlbumType.single; + case "compilation": + return AlbumType.compilation; + default: + return AlbumType.album; + } + } + + String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); +} + class AlbumCard extends HookConsumerWidget { final Album album; final PlaybuttonCardViewType viewType; @@ -48,7 +69,7 @@ class AlbumCard extends HookConsumerWidget { isLoading: isPlaylistPlaying && playlist?.isLoading == true, title: album.name!, description: - "Album • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", onTap: () { ServiceUtils.navigate(context, "/album/${album.id}", extra: album); }, diff --git a/lib/components/genre/category_card.dart b/lib/components/genre/category_card.dart index a0fe3268c..5e3a3139d 100644 --- a/lib/components/genre/category_card.dart +++ b/lib/components/genre/category_card.dart @@ -14,11 +14,9 @@ import 'package:spotube/services/queries/queries.dart'; class CategoryCard extends HookConsumerWidget { final Category category; - final Iterable? playlists; CategoryCard( this.category, { Key? key, - this.playlists, }) : super(key: key); final logger = getLogger(CategoryCard); diff --git a/lib/pages/genre/genres.dart b/lib/pages/home/genres.dart similarity index 75% rename from lib/pages/genre/genres.dart rename to lib/pages/home/genres.dart index 09035ba84..45d649e70 100644 --- a/lib/pages/genre/genres.dart +++ b/lib/pages/home/genres.dart @@ -4,19 +4,16 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:platform_ui/platform_ui.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/genre/category_card.dart'; import 'package:spotube/components/shared/compact_search.dart'; import 'package:spotube/components/shared/shimmers/shimmer_categories.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/platform.dart'; import 'package:tuple/tuple.dart'; class GenrePage extends HookConsumerWidget { @@ -59,16 +56,11 @@ class GenrePage extends HookConsumerWidget { final searchText = useState(""); final categories = useMemoized( () { - final categories = [ - Category() - ..id = "user-featured-playlists" - ..name = "Featured", - ...categoriesQuery.pages - .expand( - (page) => page?.items ?? const Iterable.empty(), - ) - .toList() - ]; + final categories = categoriesQuery.pages + .expand( + (page) => page?.items ?? const Iterable.empty(), + ) + .toList(); if (searchText.value.isEmpty) { return categories; } @@ -117,27 +109,15 @@ class GenrePage extends HookConsumerWidget { ), ), ); - return PlatformScaffold( - appBar: kIsDesktop - ? PageWindowTitleBar( - actions: [searchbar, const SizedBox(width: 10)], - ) - : null, - backgroundColor: PlatformProperty.all( - PlatformTheme.of(context).scaffoldBackgroundColor!, - ), - body: (platform == TargetPlatform.windows && kIsDesktop) || kIsMobile - ? Stack( - children: [ - Positioned.fill(child: list), - Positioned( - top: kIsMobile ? 30 : 10, - right: kIsMobile ? 5 : 20, - child: searchbar, - ), - ], - ) - : list, + return Stack( + children: [ + Positioned.fill(child: list), + Positioned( + top: 0, + right: 10, + child: searchbar, + ), + ], ); }); } diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart new file mode 100644 index 000000000..0b7681e3e --- /dev/null +++ b/lib/pages/home/home.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:platform_ui/platform_ui.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/pages/home/genres.dart'; +import 'package:spotube/pages/home/personalized.dart'; + +class HomePage extends HookConsumerWidget { + const HomePage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final index = useState(0); + final tabbar = PlatformTabBar( + androidIsScrollable: true, + selectedIndex: index.value, + onSelectedIndexChanged: (value) => index.value = value, + isNavigational: + PlatformProperty.byPlatformGroup(mobile: false, desktop: true), + tabs: [ + PlatformTab( + label: 'Genres', + icon: PlatformProperty.only( + android: const SizedBox.shrink(), + other: const Icon(SpotubeIcons.genres), + ).resolve(platform!), + ), + PlatformTab( + label: 'Personalized', + icon: PlatformProperty.only( + android: const SizedBox.shrink(), + other: const Icon(SpotubeIcons.personalized), + ).resolve(platform!), + ), + ], + ); + + return PlatformScaffold( + appBar: platform == TargetPlatform.windows + ? PreferredSize( + preferredSize: const Size.fromHeight(40), + child: tabbar, + ) + : PageWindowTitleBar( + titleWidth: 347, + centerTitle: true, + center: tabbar, + ), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => SlideTransition( + position: animation.drive( + Tween( + begin: const Offset(1, 0), + end: const Offset(0, 0), + ), + ), + child: child, + ), + child: [ + const GenrePage(), + const PersonalizedPage(), + ][index.value], + ), + ); + } +} diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart new file mode 100644 index 000000000..d54274eaa --- /dev/null +++ b/lib/pages/home/personalized.dart @@ -0,0 +1,150 @@ +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide Page; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:platform_ui/platform_ui.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; +import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class PersonalizedItemCard extends HookWidget { + final Iterable>? playlists; + final Iterable>? albums; + final String title; + final bool hasNextPage; + final void Function() onFetchMore; + + PersonalizedItemCard({ + this.playlists, + this.albums, + required this.title, + required this.hasNextPage, + required this.onFetchMore, + Key? key, + }) : assert(playlists == null || albums == null), + super(key: key); + + final logger = getLogger(PersonalizedItemCard); + + @override + Widget build(BuildContext context) { + final scrollController = useScrollController(); + + final playlistItems = playlists + ?.expand( + (page) => page.items ?? const Iterable.empty(), + ) + .toList(); + + final albumItems = albums + ?.expand( + (page) => page.items ?? const Iterable.empty(), + ) + .toList(); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + PlatformText.headline(title), + ], + ), + ), + SizedBox( + height: playlists != null ? 245 : 285, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ), + child: Scrollbar( + controller: scrollController, + interactive: false, + child: Waypoint( + controller: scrollController, + onTouchEdge: hasNextPage ? onFetchMore : null, + child: ListView( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + ...?playlistItems + ?.map((playlist) => PlaylistCard(playlist)), + ...?albumItems?.map( + (album) => AlbumCard( + TypeConversionUtils.simpleAlbum_X_Album(album), + ), + ), + if (hasNextPage) const ShimmerPlaybuttonCard(count: 1), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +class PersonalizedPage extends HookConsumerWidget { + const PersonalizedPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final spotify = ref.watch(spotifyProvider); + + final featuredPlaylistsQuery = useInfiniteQuery( + job: Queries.playlist.featured, + externalData: spotify, + ); + + final newReleases = useInfiniteQuery( + job: Queries.album.newReleases, + externalData: spotify, + ); + + useEffect(() { + if (featuredPlaylistsQuery.hasError && + featuredPlaylistsQuery.pages.first == null) { + featuredPlaylistsQuery.setExternalData(spotify); + featuredPlaylistsQuery.refetch(); + } + if (newReleases.hasError && newReleases.pages.first == null) { + newReleases.setExternalData(spotify); + newReleases.refetch(); + } + return null; + }, [spotify]); + + return ListView( + children: [ + PersonalizedItemCard( + playlists: + featuredPlaylistsQuery.pages.whereType>(), + title: 'Featured', + hasNextPage: featuredPlaylistsQuery.hasNextPage, + onFetchMore: featuredPlaylistsQuery.fetchNextPage, + ), + PersonalizedItemCard( + albums: newReleases.pages.whereType>(), + title: 'New Releases', + hasNextPage: newReleases.hasNextPage, + onFetchMore: newReleases.fetchNextPage, + ), + ], + ); + } +} diff --git a/lib/provider/downloader_provider.dart b/lib/provider/downloader_provider.dart index 84f910263..eeceacb95 100644 --- a/lib/provider/downloader_provider.dart +++ b/lib/provider/downloader_provider.dart @@ -7,7 +7,7 @@ import 'package:http/http.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:queue/queue.dart'; import 'package:path/path.dart' as path; -import 'package:spotify/spotify.dart' hide Image; +import 'package:spotify/spotify.dart' hide Image, Queue; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/models/logger.dart'; diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart index b6dae35fc..48ff64ebd 100644 --- a/lib/services/queries/album.dart +++ b/lib/services/queries/album.dart @@ -1,3 +1,4 @@ +import 'package:catcher/catcher.dart'; import 'package:fl_query/fl_query.dart'; import 'package:spotify/spotify.dart'; @@ -22,4 +23,28 @@ class AlbumQueries { return spotify.me .isSavedAlbums([getVariable(queryKey)]).then((value) => value.first); }); + + final newReleases = InfiniteQueryJob, SpotifyApi, int>( + queryKey: "new-releases", + initialParam: 0, + getNextPageParam: (lastPage, lastParam) => + lastPage.items?.length == 5 ? lastPage.nextOffset : null, + getPreviousPageParam: (firstPage, firstParam) => firstPage.nextOffset - 6, + refetchOnExternalDataChange: true, + task: (_, pageParam, spotify) async { + try { + final albums = await Pages( + spotify, + 'v1/browse/new-releases', + (json) => AlbumSimple.fromJson(json), + 'albums', + (json) => AlbumSimple.fromJson(json), + ).getPage(5, pageParam); + return albums; + } catch (e, stack) { + Catcher.reportCheckedError(e, stack); + rethrow; + } + }, + ); } diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart index 772b0f932..866f8b65d 100644 --- a/lib/services/queries/category.dart +++ b/lib/services/queries/category.dart @@ -27,10 +27,7 @@ class CategoryQueries { getPreviousPageParam: (lastPage, lastParam) => lastPage.nextOffset - 6, task: (queryKey, pageKey, spotify) { final id = getVariable(queryKey); - return (id != "user-featured-playlists" - ? spotify.playlists.getByCategoryId(id) - : spotify.playlists.featured) - .getPage(5, pageKey); + return spotify.playlists.getByCategoryId(id).getPage(5, pageKey); }, ); } diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index e263e7050..ea3ce119b 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,3 +1,4 @@ +import 'package:catcher/catcher.dart'; import 'package:fl_query/fl_query.dart'; import 'package:spotify/spotify.dart'; @@ -33,4 +34,23 @@ class PlaylistQueries { ); }, ); + + final featured = InfiniteQueryJob, SpotifyApi, int>( + queryKey: "featured-playlists", + initialParam: 0, + getNextPageParam: (lastPage, lastParam) => + lastPage.items?.length == 5 ? lastPage.nextOffset : null, + getPreviousPageParam: (firstPage, firstParam) => firstPage.nextOffset - 6, + refetchOnExternalDataChange: true, + task: (_, pageParam, spotify) async { + try { + final playlists = + await spotify.playlists.featured.getPage(5, pageParam); + return playlists; + } catch (e, stack) { + Catcher.reportCheckedError(e, stack); + rethrow; + } + }, + ); }