mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
feat: add Spotify homepage personalized recommendations (#1402)
* feat: add spotify homepage recommendations * chore: bring back made for user sectin
This commit is contained in:
parent
9791e3fb5f
commit
9e25c742d4
@ -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,
|
||||
|
@ -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",
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
52
lib/components/home/sections/feed.dart
Normal file
52
lib/components/home/sections/feed.dart
Normal 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}"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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();
|
||||
|
247
lib/models/spotify/home_feed.dart
Normal file
247
lib/models/spotify/home_feed.dart
Normal 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>>()
|
||||
};
|
||||
}
|
1666
lib/models/spotify/home_feed.freezed.dart
Normal file
1666
lib/models/spotify/home_feed.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
169
lib/models/spotify/home_feed.g.dart
Normal file
169
lib/models/spotify/home_feed.g.dart
Normal 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,
|
||||
};
|
62
lib/pages/home/feed/feed_section.dart
Normal file
62
lib/pages/home/feed/feed_section.dart
Normal 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();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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()),
|
||||
],
|
||||
),
|
||||
|
@ -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("/");
|
||||
}
|
||||
|
@ -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,
|
||||
|
22
lib/provider/spotify/views/home.dart
Normal file
22
lib/provider/spotify/views/home.dart
Normal 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,
|
||||
);
|
||||
});
|
26
lib/provider/spotify/views/home_section.dart
Normal file
26
lib/provider/spotify/views/home_section.dart
Normal 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,
|
||||
);
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
10
pubspec.lock
10
pubspec.lock
@ -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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user