Skip to content

Commit

Permalink
feat: personal playlist recommendations
Browse files Browse the repository at this point in the history
  • Loading branch information
KRTirtho committed May 28, 2023
1 parent ec11af5 commit ae820a2
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 10 deletions.
9 changes: 4 additions & 5 deletions lib/pages/home/genres.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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],
Expand Down
7 changes: 5 additions & 2 deletions lib/pages/home/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
],
),
),
Expand Down
17 changes: 17 additions & 0 deletions lib/pages/home/personalized.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<PlaylistSimple>() ??
<PlaylistSimple>[];
if (playlists.isEmpty) return const SizedBox.shrink();
return PersonalizedItemCard(
playlists: playlists,
title: item["name"] ?? "",
hasNextPage: false,
onFetchMore: () {},
);
})
],
);
}
Expand Down
8 changes: 8 additions & 0 deletions lib/provider/custom_spotify_endpoint_provider.dart
Original file line number Diff line number Diff line change
@@ -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<CustomSpotifyEndpoints>((ref) {
final auth = ref.watch(AuthenticationNotifier.provider);
return CustomSpotifyEndpoints(auth?.accessToken ?? "");
});
83 changes: 83 additions & 0 deletions lib/services/custom_spotify_endpoints/spotify_endpoints.dart
Original file line number Diff line number Diff line change
@@ -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<Map<String, dynamic>> getView(
String view, {
int limit = 20,
int contentLimit = 10,
List<String> 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}',
);
}
}
}
2 changes: 2 additions & 0 deletions lib/services/queries/queries.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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._();
Expand All @@ -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._();
25 changes: 25 additions & 0 deletions lib/services/queries/views.dart
Original file line number Diff line number Diff line change
@@ -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<Map<String, dynamic>?, 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<Map<String, dynamic>?, dynamic>("views/$view", () {
if (auth == null) return null;
return customSpotify.getView(view, market: market, country: market);
});
}
}
8 changes: 5 additions & 3 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:/KRTirtho/piped_client"
source: git
version: "0.1.0"
platform:
dependency: transitive
Expand Down

0 comments on commit ae820a2

Please sign in to comment.