diff --git a/lib/pages/home/genres.dart b/lib/pages/home/genres.dart index 7ea67e8b6..8bc4fd36e 100644 --- a/lib/pages/home/genres.dart +++ b/lib/pages/home/genres.dart @@ -12,7 +12,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/queries/queries.dart'; -import 'package:tuple/tuple.dart'; class GenrePage extends HookConsumerWidget { const GenrePage({Key? key}) : super(key: key); @@ -39,13 +38,13 @@ class GenrePage extends HookConsumerWidget { return categories; } return categories - .map((e) => Tuple2( + .map((e) => ( weightedRatio(e.name!, searchText.value), e, )) - .sorted((a, b) => b.item1.compareTo(a.item1)) - .where((e) => e.item1 > 50) - .map((e) => e.item2) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) .toList(); }, [categoriesQuery.pages, searchText.value], diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 7b03e090a..6d663cf1d 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -18,13 +18,16 @@ class HomePage extends HookConsumerWidget { centerTitle: true, leadingWidth: double.infinity, leading: ThemedButtonsTabBar( - tabs: [context.l10n.genre, context.l10n.personalized], + tabs: [ + context.l10n.personalized, + context.l10n.genre, + ], ), ), body: const TabBarView( children: [ - GenrePage(), PersonalizedPage(), + GenrePage(), ], ), ), diff --git a/lib/pages/home/personalized.dart b/lib/pages/home/personalized.dart index ff8549d99..6c4ea851d 100644 --- a/lib/pages/home/personalized.dart +++ b/lib/pages/home/personalized.dart @@ -102,6 +102,8 @@ class PersonalizedPage extends HookConsumerWidget { [featuredPlaylistsQuery.pages], ); + final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + final newReleases = useQueries.album.newReleases(ref); final userArtists = useQueries.artist .followedByMeAll(ref) @@ -136,6 +138,21 @@ class PersonalizedPage extends HookConsumerWidget { hasNextPage: newReleases.hasNextPage, onFetchMore: newReleases.fetchNext, ), + ...?madeForUser.data?["content"]?["items"]?.map((item) { + final playlists = item["content"]?["items"] + ?.where((itemL2) => itemL2["type"] == "playlist") + .map((itemL2) => PlaylistSimple.fromJson(itemL2)) + .toList() + .cast() ?? + []; + if (playlists.isEmpty) return const SizedBox.shrink(); + return PersonalizedItemCard( + playlists: playlists, + title: item["name"] ?? "", + hasNextPage: false, + onFetchMore: () {}, + ); + }) ], ); } diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart new file mode 100644 index 000000000..4857a3588 --- /dev/null +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; + +final customSpotifyEndpointProvider = Provider((ref) { + final auth = ref.watch(AuthenticationNotifier.provider); + return CustomSpotifyEndpoints(auth?.accessToken ?? ""); +}); diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart new file mode 100644 index 000000000..9e4ce60ef --- /dev/null +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class CustomSpotifyEndpoints { + static const _baseUrl = 'https://api.spotify.com/v1'; + final String accessToken; + final http.Client _client; + + CustomSpotifyEndpoints(this.accessToken) : _client = http.Client(); + + // views API + + /// Get a single view of given genre + /// + /// Currently known genres are: + /// - new-releases-page + /// - made-for-x-hub (it requires authentication) + /// - my-mix-genres (it requires authentication) + /// - artist-seed-mixes (it requires authentication) + /// - my-mix-decades (it requires authentication) + /// - my-mix-moods (it requires authentication) + /// - podcasts-and-more (it requires authentication) + /// - uniquely-yours-in-hub (it requires authentication) + /// - made-for-x-dailymix (it requires authentication) + /// - made-for-x-discovery (it requires authentication) + Future> getView( + String view, { + int limit = 20, + int contentLimit = 10, + List types = const [ + "album", + "playlist", + "artist", + "show", + "station", + "episode", + "merch", + "artist_concerts", + "uri_link" + ], + String imageStyle = "gradient_overlay", + String includeExternal = "audio", + String? locale, + String? market, + String? country, + }) async { + if (accessToken.isEmpty) { + throw Exception('[CustomSpotifyEndpoints.getView]: accessToken is empty'); + } + + final queryParams = { + 'limit': limit.toString(), + 'content_limit': contentLimit.toString(), + 'types': types.join(','), + 'image_style': imageStyle, + 'include_external': includeExternal, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + if (locale != null) 'locale': locale, + if (market != null) 'market': market, + if (country != null) 'country': country, + }.entries.map((e) => '${e.key}=${e.value}').join('&'); + + final res = await _client.get( + Uri.parse('$_baseUrl/views/$view?$queryParams'), + headers: { + "content-type": "application/json", + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + + if (res.statusCode == 200) { + return jsonDecode(res.body); + } else { + throw Exception( + '[CustomSpotifyEndpoints.getView]: Failed to get view' + '\nStatus code: ${res.statusCode}' + '\nBody: ${res.body}', + ); + } + } +} diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart index d6f59f4f9..cc3ce132f 100644 --- a/lib/services/queries/queries.dart +++ b/lib/services/queries/queries.dart @@ -5,6 +5,7 @@ import 'package:spotube/services/queries/lyrics.dart'; import 'package:spotube/services/queries/playlist.dart'; import 'package:spotube/services/queries/search.dart'; import 'package:spotube/services/queries/user.dart'; +import 'package:spotube/services/queries/views.dart'; class Queries { const Queries._(); @@ -15,6 +16,7 @@ class Queries { final playlist = const PlaylistQueries(); final search = const SearchQueries(); final user = const UserQueries(); + final views = const ViewsQueries(); } const useQueries = Queries._(); diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart new file mode 100644 index 000000000..ad9c850ea --- /dev/null +++ b/lib/services/queries/views.dart @@ -0,0 +1,25 @@ +import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; + +class ViewsQueries { + const ViewsQueries(); + + Query?, dynamic> get( + WidgetRef ref, + String view, + ) { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final auth = ref.watch(AuthenticationNotifier.provider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + + return useQuery?, dynamic>("views/$view", () { + if (auth == null) return null; + return customSpotify.getView(view, market: market, country: market); + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index 05e039dbb..b711306b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1306,9 +1306,11 @@ packages: piped_client: dependency: "direct main" description: - path: "../piped_client" - relative: true - source: path + path: "." + ref: eaade37d0938d31dbfa35bb5152caca4e284bda6 + resolved-ref: eaade37d0938d31dbfa35bb5152caca4e284bda6 + url: "https://github.com/KRTirtho/piped_client" + source: git version: "0.1.0" platform: dependency: transitive