feat: add Spotify homepage personalized recommendations (#1402)

* feat: add spotify homepage recommendations

* chore: bring back made for user sectin
This commit is contained in:
Kingkor Roy Tirtho 2024-04-14 12:10:34 +06:00 committed by GitHub
parent 9791e3fb5f
commit 9e25c742d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2455 additions and 15 deletions

View File

@ -88,7 +88,7 @@ class Assets {
AssetGenImage('assets/user-placeholder.png');
/// List of all assets
List<dynamic> get values => [
static List<dynamic> get values => [
albumPlaceholder,
bengaliPatternsBg,
branding,

View File

@ -1,5 +1,6 @@
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart';
abstract class FakeData {
@ -196,4 +197,30 @@ abstract class FakeData {
),
],
);
static final feedSection = SpotifyHomeFeedSection(
typename: "HomeGenericSectionData",
uri: "spotify:section:lol",
title: "Dummy",
items: [
for (int i = 0; i < 10; i++)
SpotifyHomeFeedSectionItem(
typename: "PlaylistResponseWrapper",
playlist: SpotifySectionPlaylist(
name: "Playlist $i",
description: "Really super important description $i",
format: "daily-mix",
images: [
const SpotifySectionItemImage(
height: 1,
width: 1,
url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg",
),
],
owner: "Spotify",
uri: "spotify:playlist:id",
),
)
],
);
}

View File

@ -9,6 +9,7 @@ import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/connect/connect.dart';
import 'package:spotube/pages/connect/control/control.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/feed/feed_section.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart';
@ -76,6 +77,14 @@ final routerProvider = Provider((ref) {
),
),
),
GoRoute(
path: "feeds/:feedId",
pageBuilder: (context, state) => SpotubePage(
child: HomeFeedSectionPage(
sectionUri: state.pathParameters["feedId"] as String,
),
),
)
],
),
GoRoute(

View File

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/provider/spotify/views/home.dart';
import 'package:spotube/utils/service_utils.dart';
class HomePageFeedSection extends HookConsumerWidget {
const HomePageFeedSection({super.key});
@override
Widget build(BuildContext context, ref) {
final homeFeed = ref.watch(homeViewProvider);
final nonShortSections = homeFeed.asData?.value?.sections
.where((s) => s.typename == "HomeGenericSectionData")
.toList() ??
[];
return SliverList.builder(
itemCount: nonShortSections.length,
itemBuilder: (context, index) {
final section = nonShortSections[index];
if (section.items.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView(
items: [
for (final item in section.items)
if (item.album != null)
item.album!.asAlbum
else if (item.artist != null)
item.artist!.asArtist
else if (item.playlist != null)
item.playlist!.asPlaylist
],
title: Text(section.title ?? "No Titel"),
hasNextPage: false,
isLoadingNextPage: false,
onFetchMore: () {},
titleTrailing: Directionality(
textDirection: TextDirection.rtl,
child: TextButton.icon(
label: const Text("Browse More"),
icon: const Icon(SpotubeIcons.angleRight),
onPressed: () =>
ServiceUtils.push(context, "/feeds/${section.uri}"),
),
),
);
},
);
}
}

View File

@ -17,18 +17,21 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
final VoidCallback onFetchMore;
final bool isLoadingNextPage;
final bool hasNextPage;
final Widget? titleTrailing;
const HorizontalPlaybuttonCardView({
HorizontalPlaybuttonCardView({
required this.title,
required this.items,
required this.hasNextPage,
required this.onFetchMore,
required this.isLoadingNextPage,
this.titleTrailing,
super.key,
}) : assert(
items is List<PlaylistSimple> ||
items is List<Album> ||
items is List<Artist>,
items.every(
(item) =>
item is PlaylistSimple || item is Artist || item is AlbumSimple,
),
);
@override
@ -47,11 +50,17 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
DefaultTextStyle(
style: textTheme.titleMedium!,
child: title,
),
if (titleTrailing != null) titleTrailing!,
],
),
SizedBox(
height: height,
child: NotificationListener(
@ -87,7 +96,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
return switch (item) {
PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple),
Album() => AlbumCard(item as Album),
AlbumSimple() => AlbumCard(item as Album),
Artist() => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0),

View File

@ -39,6 +39,7 @@ import 'package:spotube/hooks/configurators/use_init_sys_tray.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
import 'package:timezone/data/latest.dart' as tz;
Future<void> main(List<String> rawArgs) async {
final arguments = await startCLI(rawArgs);
@ -47,6 +48,8 @@ Future<void> main(List<String> rawArgs) async {
await registerWindowsScheme("spotify");
tz.initializeTimeZones();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
MediaKit.ensureInitialized();

View File

@ -0,0 +1,247 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotify/spotify.dart';
part 'home_feed.freezed.dart';
part 'home_feed.g.dart';
@freezed
class SpotifySectionPlaylist with _$SpotifySectionPlaylist {
const SpotifySectionPlaylist._();
const factory SpotifySectionPlaylist({
required String description,
required String format,
required List<SpotifySectionItemImage> images,
required String name,
required String owner,
required String uri,
}) = _SpotifySectionPlaylist;
factory SpotifySectionPlaylist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionPlaylistFromJson(json);
String get id => uri.split(":").last;
Playlist get asPlaylist {
return Playlist()
..id = id
..name = name
..description = description
..collaborative = false
..images = images.map((e) => e.asImage).toList()
..owner = (User()..displayName = "Spotify")
..uri = uri
..type = "playlist";
}
}
@freezed
class SpotifySectionArtist with _$SpotifySectionArtist {
const SpotifySectionArtist._();
const factory SpotifySectionArtist({
required String name,
required String uri,
required List<SpotifySectionItemImage> images,
}) = _SpotifySectionArtist;
factory SpotifySectionArtist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionArtistFromJson(json);
String get id => uri.split(":").last;
Artist get asArtist {
return Artist()
..id = id
..name = name
..images = images.map((e) => e.asImage).toList()
..type = "artist"
..uri = uri;
}
}
@freezed
class SpotifySectionAlbum with _$SpotifySectionAlbum {
const SpotifySectionAlbum._();
const factory SpotifySectionAlbum({
required List<SpotifySectionAlbumArtist> artists,
required List<SpotifySectionItemImage> images,
required String name,
required String uri,
}) = _SpotifySectionAlbum;
factory SpotifySectionAlbum.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionAlbumFromJson(json);
String get id => uri.split(":").last;
Album get asAlbum {
return Album()
..id = id
..name = name
..artists = artists.map((a) => a.asArtist).toList()
..albumType = AlbumType.album
..images = images.map((e) => e.asImage).toList()
..uri = uri;
}
}
@freezed
class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist {
const SpotifySectionAlbumArtist._();
const factory SpotifySectionAlbumArtist({
required String name,
required String uri,
}) = _SpotifySectionAlbumArtist;
factory SpotifySectionAlbumArtist.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionAlbumArtistFromJson(json);
String get id => uri.split(":").last;
Artist get asArtist {
return Artist()
..id = id
..name = name
..type = "artist"
..uri = uri;
}
}
@freezed
class SpotifySectionItemImage with _$SpotifySectionItemImage {
const SpotifySectionItemImage._();
const factory SpotifySectionItemImage({
required num? height,
required String url,
required num? width,
}) = _SpotifySectionItemImage;
factory SpotifySectionItemImage.fromJson(Map<String, dynamic> json) =>
_$SpotifySectionItemImageFromJson(json);
Image get asImage {
return Image()
..height = height?.toInt()
..width = width?.toInt()
..url = url;
}
}
@freezed
class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem {
factory SpotifyHomeFeedSectionItem({
required String typename,
SpotifySectionPlaylist? playlist,
SpotifySectionArtist? artist,
SpotifySectionAlbum? album,
}) = _SpotifyHomeFeedSectionItem;
factory SpotifyHomeFeedSectionItem.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionItemFromJson(json);
}
@freezed
class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection {
factory SpotifyHomeFeedSection({
required String typename,
String? title,
required String uri,
required List<SpotifyHomeFeedSectionItem> items,
}) = _SpotifyHomeFeedSection;
factory SpotifyHomeFeedSection.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionFromJson(json);
}
@freezed
class SpotifyHomeFeed with _$SpotifyHomeFeed {
factory SpotifyHomeFeed({
required String greeting,
required List<SpotifyHomeFeedSection> sections,
}) = _SpotifyHomeFeed;
factory SpotifyHomeFeed.fromJson(Map<String, dynamic> json) =>
_$SpotifyHomeFeedFromJson(json);
}
Map<String, dynamic> transformSectionItemTypeJsonMap(
Map<String, dynamic> json) {
final data = json["content"]["data"];
final objType = json["content"]["data"]["__typename"];
return {
"typename": json["content"]["__typename"],
if (objType == "Playlist")
"playlist": {
"name": data["name"],
"description": data["description"],
"format": data["format"],
"images": (data["images"]["items"] as List)
.expand((j) => j["sources"] as dynamic)
.toList()
.cast<Map<String, dynamic>>(),
"owner": data["ownerV2"]["data"]["name"],
"uri": data["uri"]
},
if (objType == "Artist")
"artist": {
"name": data["profile"]["name"],
"uri": data["uri"],
"images": data["visuals"]["avatarImage"]["sources"],
},
if (objType == "Album")
"album": {
"name": data["name"],
"uri": data["uri"],
"images": data["coverArt"]["sources"],
"artists": data["artists"]["items"]
.map(
(artist) => {
"name": artist["profile"]["name"],
"uri": artist["uri"],
},
)
.toList()
},
};
}
Map<String, dynamic> transformSectionItemJsonMap(Map<String, dynamic> json) {
return {
"typename": json["data"]["__typename"],
"title": json["data"]?["title"]?["text"],
"uri": json["uri"],
"items": (json["sectionItems"]["items"] as List)
.map(
(data) =>
transformSectionItemTypeJsonMap(data as Map<String, dynamic>)
as dynamic,
)
.where(
(w) =>
w["playlist"] != null ||
w["artist"] != null ||
w["album"] != null,
)
.toList()
.cast<Map<String, dynamic>>()
};
}
Map<String, dynamic> transformHomeFeedJsonMap(Map<String, dynamic> json) {
return {
"greeting": json["data"]["home"]["greeting"]["text"],
"sections":
(json["data"]["home"]["sectionContainer"]["sections"]["items"] as List)
.map(
(item) =>
transformSectionItemJsonMap(item as Map<String, dynamic>)
as dynamic,
)
.toList()
.cast<Map<String, dynamic>>()
};
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,169 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_feed.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionPlaylistImpl(
description: json['description'] as String,
format: json['format'] as String,
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
name: json['name'] as String,
owner: json['owner'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionPlaylistImplToJson(
_$SpotifySectionPlaylistImpl instance) =>
<String, dynamic>{
'description': instance.description,
'format': instance.format,
'images': instance.images,
'name': instance.name,
'owner': instance.owner,
'uri': instance.uri,
};
_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionArtistImpl(
name: json['name'] as String,
uri: json['uri'] as String,
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifySectionArtistImplToJson(
_$SpotifySectionArtistImpl instance) =>
<String, dynamic>{
'name': instance.name,
'uri': instance.uri,
'images': instance.images,
};
_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionAlbumImpl(
artists: (json['artists'] as List<dynamic>)
.map((e) =>
SpotifySectionAlbumArtist.fromJson(e as Map<String, dynamic>))
.toList(),
images: (json['images'] as List<dynamic>)
.map((e) =>
SpotifySectionItemImage.fromJson(e as Map<String, dynamic>))
.toList(),
name: json['name'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionAlbumImplToJson(
_$SpotifySectionAlbumImpl instance) =>
<String, dynamic>{
'artists': instance.artists,
'images': instance.images,
'name': instance.name,
'uri': instance.uri,
};
_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionAlbumArtistImpl(
name: json['name'] as String,
uri: json['uri'] as String,
);
Map<String, dynamic> _$$SpotifySectionAlbumArtistImplToJson(
_$SpotifySectionAlbumArtistImpl instance) =>
<String, dynamic>{
'name': instance.name,
'uri': instance.uri,
};
_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson(
Map<String, dynamic> json) =>
_$SpotifySectionItemImageImpl(
height: json['height'] as num?,
url: json['url'] as String,
width: json['width'] as num?,
);
Map<String, dynamic> _$$SpotifySectionItemImageImplToJson(
_$SpotifySectionItemImageImpl instance) =>
<String, dynamic>{
'height': instance.height,
'url': instance.url,
'width': instance.width,
};
_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionItemImpl(
typename: json['typename'] as String,
playlist: json['playlist'] == null
? null
: SpotifySectionPlaylist.fromJson(
json['playlist'] as Map<String, dynamic>),
artist: json['artist'] == null
? null
: SpotifySectionArtist.fromJson(
json['artist'] as Map<String, dynamic>),
album: json['album'] == null
? null
: SpotifySectionAlbum.fromJson(json['album'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SpotifyHomeFeedSectionItemImplToJson(
_$SpotifyHomeFeedSectionItemImpl instance) =>
<String, dynamic>{
'typename': instance.typename,
'playlist': instance.playlist,
'artist': instance.artist,
'album': instance.album,
};
_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedSectionImpl(
typename: json['typename'] as String,
title: json['title'] as String?,
uri: json['uri'] as String,
items: (json['items'] as List<dynamic>)
.map((e) =>
SpotifyHomeFeedSectionItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifyHomeFeedSectionImplToJson(
_$SpotifyHomeFeedSectionImpl instance) =>
<String, dynamic>{
'typename': instance.typename,
'title': instance.title,
'uri': instance.uri,
'items': instance.items,
};
_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(
Map<String, dynamic> json) =>
_$SpotifyHomeFeedImpl(
greeting: json['greeting'] as String,
sections: (json['sections'] as List<dynamic>)
.map(
(e) => SpotifyHomeFeedSection.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SpotifyHomeFeedImplToJson(
_$SpotifyHomeFeedImpl instance) =>
<String, dynamic>{
'greeting': instance.greeting,
'sections': instance.sections,
};

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/views/home_section.dart';
class HomeFeedSectionPage extends HookConsumerWidget {
final String sectionUri;
const HomeFeedSectionPage({super.key, required this.sectionUri});
@override
Widget build(BuildContext context, ref) {
final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri));
final section = homeFeedSection.asData?.value ?? FakeData.feedSection;
return Skeletonizer(
enabled: homeFeedSection.isLoading,
child: Scaffold(
appBar: PageWindowTitleBar(
title: Text(section.title ?? ""),
centerTitle: false,
automaticallyImplyLeading: true,
titleSpacing: 0,
),
body: CustomScrollView(
slivers: [
SliverLayoutBuilder(
builder: (context, constrains) {
return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: constrains.smAndDown ? 225 : 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard(item.album!.asAlbum);
} else if (item.artist != null) {
return ArtistCard(item.artist!.asArtist);
} else if (item.playlist != null) {
return PlaylistCard(item.playlist!.asPlaylist);
}
return const SizedBox();
},
);
},
),
],
),
),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/connect/connect_device.dart';
import 'package:spotube/components/home/sections/featured.dart';
import 'package:spotube/components/home/sections/feed.dart';
import 'package:spotube/components/home/sections/friends.dart';
import 'package:spotube/components/home/sections/genres.dart';
import 'package:spotube/components/home/sections/made_for_user.dart';
@ -66,6 +67,7 @@ class HomePage extends HookConsumerWidget {
const SliverToBoxAdapter(child: HomeFeaturedSection()),
const HomePageFriendsSection(),
const SliverToBoxAdapter(child: HomeNewReleasesSection()),
const HomePageFeedSection(),
const SliverSafeArea(sliver: HomeMadeForUserSection()),
],
),

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -12,7 +11,6 @@ class WebViewLogin extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final mounted = useIsMounted();
final authenticationNotifier = ref.watch(authenticationProvider.notifier);
if (kIsDesktop) {
@ -57,7 +55,7 @@ class WebViewLogin extends HookConsumerWidget {
authenticationNotifier.setCredentials(
await AuthenticationCredentials.fromCookie(cookieHeader),
);
if (mounted()) {
if (context.mounted) {
// ignore: use_build_context_synchronously
GoRouter.of(context).go("/");
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
@ -25,12 +26,16 @@ class AuthenticationCredentials {
static Future<AuthenticationCredentials> fromCookie(String cookie) async {
try {
final spDc = cookie
.split("; ")
.firstWhereOrNull((c) => c.trim().startsWith("sp_dc="))
?.trim();
final res = await get(
Uri.parse(
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player",
),
headers: {
"Cookie": cookie,
"Cookie": spDc ?? "",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"
},
@ -44,7 +49,7 @@ class AuthenticationCredentials {
}
return AuthenticationCredentials(
cookie: cookie,
cookie: "${res.headers["set-cookie"]}; $spDc",
accessToken: body['accessToken'],
expiration: DateTime.fromMillisecondsSinceEpoch(
body['accessTokenExpirationTimestampMs'],
@ -64,6 +69,15 @@ class AuthenticationCredentials {
}
}
/// Returns the cookie value
String? getCookie(String key) => cookie
.split("; ")
.firstWhereOrNull((c) => c.trim().startsWith("$key="))
?.trim()
.split("=")
.last
.replaceAll(";", "");
factory AuthenticationCredentials.fromJson(Map<String, dynamic> json) {
return AuthenticationCredentials(
cookie: json['cookie'] as String,

View File

@ -0,0 +1,22 @@
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/user_preferences_provider.dart';
final homeViewProvider = FutureProvider((ref) async {
final country = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final spTCookie = ref.watch(
authenticationProvider.select((s) => s?.getCookie("sp_t")),
);
if (spTCookie == null) return null;
final spotify = ref.watch(customSpotifyEndpointProvider);
return spotify.getHomeFeed(
country: country,
spTCookie: spTCookie,
);
});

View File

@ -0,0 +1,26 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
final homeSectionViewProvider =
FutureProvider.family<SpotifyHomeFeedSection?, String>(
(ref, sectionUri) async {
final country = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final spTCookie = ref.watch(
authenticationProvider.select((s) => s?.getCookie("sp_t")),
);
if (spTCookie == null) return null;
final spotify = ref.watch(customSpotifyEndpointProvider);
return spotify.getHomeFeedSection(
sectionUri,
country: country,
spTCookie: spTCookie,
);
});

View File

@ -2,7 +2,9 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:spotify/spotify.dart';
import 'package:spotube/models/spotify/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart';
import 'package:timezone/timezone.dart' as tz;
class CustomSpotifyEndpoints {
static const _baseUrl = 'https://api.spotify.com/v1';
@ -175,4 +177,126 @@ class CustomSpotifyEndpoints {
);
return SpotifyFriends.fromJson(jsonDecode(res.body));
}
Future<SpotifyHomeFeed> getHomeFeed({
required String spTCookie,
required Market country,
}) async {
final headers = {
'app-platform': 'WebPlayer',
'authorization': 'Bearer $accessToken',
'content-type': 'application/json;charset=UTF-8',
'dnt': '1',
'origin': 'https://open.spotify.com',
'referer': 'https://open.spotify.com/'
};
final response = await http.get(
Uri(
scheme: "https",
host: "api-partner.spotify.com",
path: "/pathfinder/v1/query",
queryParameters: {
"operationName": "home",
"variables": jsonEncode({
"timeZone": tz.local.name,
"sp_t": spTCookie,
"country": country.name,
"facet": null,
"sectionItemsLimit": 10
}),
"extensions": jsonEncode(
{
"persistedQuery": {
"version": 1,
/// GraphQL persisted Query hash
/// This can change overtime. We've to lookout for it
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
"sha256Hash":
"eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be",
}
},
),
},
),
headers: headers,
);
if (response.statusCode >= 400) {
throw Exception(
"[RequestException] "
"Status: ${response.statusCode}\n"
"Body: ${response.body}",
);
}
final data = SpotifyHomeFeed.fromJson(
transformHomeFeedJsonMap(
jsonDecode(response.body),
),
);
return data;
}
Future<SpotifyHomeFeedSection> getHomeFeedSection(
String sectionUri, {
required String spTCookie,
required Market country,
}) async {
final headers = {
'app-platform': 'WebPlayer',
'authorization': 'Bearer $accessToken',
'content-type': 'application/json;charset=UTF-8',
'dnt': '1',
'origin': 'https://open.spotify.com',
'referer': 'https://open.spotify.com/'
};
final response = await http.get(
Uri(
scheme: "https",
host: "api-partner.spotify.com",
path: "/pathfinder/v1/query",
queryParameters: {
"operationName": "homeSection",
"variables": jsonEncode({
"timeZone": tz.local.name,
"sp_t": spTCookie,
"country": country.name,
"uri": sectionUri
}),
"extensions": jsonEncode(
{
"persistedQuery": {
"version": 1,
/// GraphQL persisted Query hash
/// This can change overtime. We've to lookout for it
/// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/
"sha256Hash":
"eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be",
}
},
),
},
),
headers: headers,
);
if (response.statusCode >= 400) {
throw Exception(
"[RequestException] "
"Status: ${response.statusCode}\n"
"Body: ${response.body}",
);
}
final data = SpotifyHomeFeedSection.fromJson(
transformSectionItemJsonMap(
jsonDecode(response.body)["data"]["homeSections"]["sections"][0],
),
);
return data;
}
}

View File

@ -434,7 +434,7 @@ packages:
source: hosted
version: "0.3.3+5"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab
@ -2238,6 +2238,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
url: "https://pub.dev"
source: hosted
version: "0.9.2"
timing:
dependency: transitive
description:

View File

@ -131,6 +131,8 @@ dependencies:
lrc: ^1.0.2
pub_api_client: ^2.4.0
pubspec_parse: ^1.2.2
timezone: ^0.9.2
crypto: ^3.0.3
dev_dependencies:
build_runner: ^2.4.9