fix(auth): refresh access token timer not working

refactor: use fl_query for spotify API instead of riverpod
This commit is contained in:
Kingkor Roy Tirtho 2022-10-23 16:06:03 +06:00
parent b5144dfa26
commit b3ac5ca3bb
17 changed files with 616 additions and 375 deletions

View File

@ -1,4 +1,5 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -57,9 +58,14 @@ class AlbumView extends HookConsumerWidget {
final SpotifyApi spotify = ref.watch(spotifyProvider);
final Auth auth = ref.watch(authProvider);
final tracksSnapshot = ref.watch(albumTracksQuery(album.id!));
final albumSavedSnapshot =
ref.watch(albumIsSavedForCurrentUserQuery(album.id!));
final tracksSnapshot = useQuery(
job: albumTracksQueryJob(album.id!),
externalData: spotify,
);
final albumSavedSnapshot = useQuery(
job: albumIsSavedForCurrentUserQueryJob(album.id!),
externalData: spotify,
);
final albumArt = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
@ -82,11 +88,11 @@ class AlbumView extends HookConsumerWidget {
routePath: "/album/${album.id}",
bottomSpace: breakpoint.isLessThanOrEqualTo(Breakpoints.md),
onPlay: ([track]) {
if (tracksSnapshot.asData?.value != null) {
if (tracksSnapshot.hasData) {
if (!isAlbumPlaying) {
playPlaylist(
playback,
tracksSnapshot.asData!.value
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
@ -95,7 +101,7 @@ class AlbumView extends HookConsumerWidget {
} else if (isAlbumPlaying && track != null) {
playPlaylist(
playback,
tracksSnapshot.asData!.value
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
@ -112,35 +118,7 @@ class AlbumView extends HookConsumerWidget {
ClipboardData(text: "https://open.spotify.com/album/${album.id}"),
);
},
heartBtn: auth.isLoggedIn
? albumSavedSnapshot.when(
data: (isSaved) {
return HeartButton(
isLiked: isSaved,
onPressed: () {
(isSaved
? spotify.me.removeAlbums(
[album.id!],
)
: spotify.me.saveAlbums(
[album.id!],
))
.whenComplete(() {
ref.refresh(
albumIsSavedForCurrentUserQuery(
album.id!,
),
);
QueryBowl.of(context).refetchQueries(
[currentUserAlbumsQueryJob.queryKey],
);
});
},
);
},
error: (error, _) => Text("Error $error"),
loading: () => const CircularProgressIndicator())
: null,
heartBtn: AlbumHeartButton(album: album),
);
}
}

View File

@ -1,3 +1,5 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -49,20 +51,28 @@ class ArtistProfile extends HookConsumerWidget {
final Playback playback = ref.watch(playbackProvider);
final artistsSnapshot = ref.watch(artistProfileQuery(artistId));
final isFollowingSnapshot =
ref.watch(currentUserFollowsArtistQuery(artistId));
final topTracksSnapshot = ref.watch(artistTopTracksQuery(artistId));
final relatedArtists = ref.watch(artistRelatedArtistsQuery(artistId));
return SafeArea(
child: Scaffold(
appBar: const PageWindowTitleBar(
leading: BackButton(),
),
body: artistsSnapshot.when<Widget>(
data: (data) {
body: HookBuilder(
builder: (context) {
final artistsQuery = useQuery(
job: artistProfileQueryJob(artistId),
externalData: spotify,
);
if (artistsQuery.isLoading || !artistsQuery.hasData) {
return const ShimmerArtistProfile();
} else if (artistsQuery.hasError) {
return Center(
child: Text(artistsQuery.error.toString()),
);
}
final data = artistsQuery.data!;
return SingleChildScrollView(
controller: parentScrollController,
padding: const EdgeInsets.all(20),
@ -115,42 +125,57 @@ class ArtistProfile extends HookConsumerWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
isFollowingSnapshot.when(
data: (isFollowing) {
return OutlinedButton(
onPressed: () async {
try {
isFollowing
? await spotify.me.unfollow(
FollowingType.artist,
[artistId],
)
: await spotify.me.follow(
FollowingType.artist,
[artistId],
);
} catch (e, stack) {
logger.e(
"FollowButton.onPressed",
e,
stack,
);
} finally {
ref.refresh(
currentUserFollowsArtistQuery(
artistId),
);
}
},
child: Text(
isFollowing ? "Following" : "Follow",
),
HookBuilder(
builder: (context) {
final isFollowingQuery = useQuery(
job: currentUserFollowsArtistQueryJob(
artistId),
externalData: spotify,
);
if (isFollowingQuery.isLoading ||
!isFollowingQuery.hasData) {
return const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(),
);
},
error: (error, stackTrace) => Container(),
loading: () =>
const CircularProgressIndicator
.adaptive()),
}
return OutlinedButton(
onPressed: () async {
try {
isFollowingQuery.data!
? await spotify.me.unfollow(
FollowingType.artist,
[artistId],
)
: await spotify.me.follow(
FollowingType.artist,
[artistId],
);
} catch (e, stack) {
logger.e(
"FollowButton.onPressed",
e,
stack,
);
} finally {
QueryBowl.of(context).refetchQueries([
currentUserFollowsArtistQueryJob(
artistId)
.queryKey,
]);
}
},
child: Text(
isFollowingQuery.data!
? "Following"
: "Follow",
),
);
},
),
IconButton(
icon: const Icon(Icons.share_rounded),
onPressed: () {
@ -180,8 +205,23 @@ class ArtistProfile extends HookConsumerWidget {
],
),
const SizedBox(height: 50),
topTracksSnapshot.when(
data: (topTracks) {
HookBuilder(
builder: (context) {
final topTracksQuery = useQuery(
job: artistTopTracksQueryJob(artistId),
externalData: spotify,
);
if (topTracksQuery.isLoading || !topTracksQuery.hasData) {
return const CircularProgressIndicator.adaptive();
} else if (topTracksQuery.hasError) {
return Center(
child: Text(topTracksQuery.error.toString()),
);
}
final topTracks = topTracksQuery.data!;
final isPlaylistPlaying =
playback.playlist?.id == data.id;
playPlaylist(List<Track> tracks,
@ -248,10 +288,6 @@ class ArtistProfile extends HookConsumerWidget {
}),
]);
},
error: (error, stack) =>
Text("Failed to find top tracks $error"),
loading: () => const Center(
child: CircularProgressIndicator.adaptive()),
),
const SizedBox(height: 50),
Text(
@ -266,28 +302,36 @@ class ArtistProfile extends HookConsumerWidget {
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 10),
relatedArtists.when(
data: (artists) {
HookBuilder(
builder: (context) {
final relatedArtists = useQuery(
job: artistRelatedArtistsQueryJob(artistId),
externalData: spotify,
);
if (relatedArtists.isLoading || !relatedArtists.hasData) {
return const CircularProgressIndicator.adaptive();
} else if (relatedArtists.hasError) {
return Center(
child: Text(relatedArtists.error.toString()),
);
}
return Center(
child: Wrap(
spacing: 20,
runSpacing: 20,
children: artists
children: relatedArtists.data!
.map((artist) => ArtistCard(artist))
.toList(),
),
);
},
error: (error, stackTrack) =>
Text("Failed to get Artist albums $error"),
loading: () => const CircularProgressIndicator.adaptive(),
),
],
),
);
},
error: (_, __) => const Text("Life's miserable"),
loading: () => const ShimmerArtistProfile(),
),
),
);

View File

@ -1,5 +1,6 @@
import 'package:badges/badges.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -9,6 +10,7 @@ import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/utils/platform.dart';
@ -42,7 +44,7 @@ class Sidebar extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final breakpoints = useBreakpoints();
final extended = useState(false);
final meSnapshot = ref.watch(currentUserQuery);
final auth = ref.watch(authProvider);
final downloadCount = ref.watch(
downloaderProvider.select((s) => s.currentlyRunning),
@ -161,15 +163,28 @@ class Sidebar extends HookConsumerWidget {
),
SizedBox(
width: extended.value ? 256 : 80,
child: Builder(
child: HookBuilder(
builder: (context) {
final data = meSnapshot.asData?.value;
final me = useQuery(
job: currentUserQueryJob,
externalData: ref.watch(spotifyProvider),
);
final data = me.data;
final avatarImg = TypeConversionUtils.image_X_UrlString(
data?.images,
index: (data?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.artist,
);
useEffect(() {
if (auth.isLoggedIn && !me.hasData) {
me.setExternalData(ref.read(spotifyProvider));
me.refetch();
}
return;
}, [auth.isLoggedIn, me.hasData]);
if (extended.value) {
return Padding(
padding: const EdgeInsets.all(16).copyWith(left: 0),

View File

@ -1,3 +1,4 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
@ -5,7 +6,9 @@ import 'package:spotube/components/LoaderShimmers/ShimmerLyrics.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
class Lyrics extends HookConsumerWidget {
final Color? titleBarForegroundColor;
@ -17,7 +20,13 @@ class Lyrics extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final geniusLyricsSnapshot = ref.watch(geniusLyricsQuery);
final geniusLyricsQuery = useQuery(
job: geniusLyricsQueryJob,
externalData: Tuple2(
playback.track,
ref.watch(userPreferencesProvider).geniusAccessToken,
),
);
final breakpoint = useBreakpoints();
final textTheme = Theme.of(context).textTheme;
@ -46,8 +55,18 @@ class Lyrics extends HookConsumerWidget {
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: geniusLyricsSnapshot.when(
data: (lyrics) {
child: Builder(
builder: (context) {
if (geniusLyricsQuery.isLoading) {
return const ShimmerLyrics();
} else if (geniusLyricsQuery.hasError) {
return Text(
"Sorry, no Lyrics were found for `${playback.track?.name}` :'(\n${geniusLyricsQuery.error.toString()}",
);
}
final lyrics = geniusLyricsQuery.data;
return Text(
lyrics == null && playback.track == null
? "No Track being played currently"
@ -56,9 +75,6 @@ class Lyrics extends HookConsumerWidget {
?.copyWith(color: textTheme.headline1?.color),
);
},
error: (error, __) => Text(
"Sorry, no Lyrics were found for `${playback.track?.name}` :'("),
loading: () => const ShimmerLyrics(),
),
),
),

View File

@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -30,14 +31,17 @@ class SyncedLyrics extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final timedLyricsSnapshot = ref.watch(rentanadviserLyricsQuery);
Playback playback = ref.watch(playbackProvider);
final timedLyricsQuery = useQuery(
job: rentanadviserLyricsQueryJob,
externalData: playback.track,
);
final lyricDelay = ref.watch(lyricDelayState);
Playback playback = ref.watch(playbackProvider);
final breakpoint = useBreakpoints();
final controller = useAutoScrollController();
final failed = useState(false);
final lyricValue = timedLyricsSnapshot.asData?.value;
final lyricValue = timedLyricsQuery.data;
final lyricsMap = useMemoized(
() =>
lyricValue?.lyrics

View File

@ -6,13 +6,9 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/components/Library/UserLocalTracks.dart';
import 'package:spotube/components/Player/PlayerQueue.dart';
import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/hooks/useForceUpdate.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerActions extends HookConsumerWidget {
@ -27,11 +23,8 @@ class PlayerActions extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final SpotifyApi spotifyApi = ref.watch(spotifyProvider);
final Playback playback = ref.watch(playbackProvider);
final Auth auth = ref.watch(authProvider);
final downloader = ref.watch(downloaderProvider);
final update = useForceUpdate();
final isInQueue =
downloader.inQueue.any((element) => element.id == playback.track?.id);
final localTracks = ref.watch(localTracksProvider).value;
@ -96,35 +89,7 @@ class PlayerActions extends HookConsumerWidget {
? () => downloader.addToQueue(playback.track!)
: null,
),
if (auth.isLoggedIn)
FutureBuilder<bool>(
future: playback.track?.id != null
? spotifyApi.tracks.me.containsOne(playback.track!.id!)
: Future.value(false),
initialData: false,
builder: (context, snapshot) {
bool isLiked = snapshot.data ?? false;
return HeartButton(
isLiked: isLiked,
onPressed: () async {
try {
if (playback.track?.id == null) return;
isLiked
? await spotifyApi.tracks.me
.removeOne(playback.track!.id!)
: await spotifyApi.tracks.me
.saveOne(playback.track!.id!);
} catch (e, stack) {
logger.e("FavoriteButton.onPressed", e, stack);
} finally {
update();
ref.refresh(currentUserSavedTracksQuery);
ref.refresh(
playlistTracksQuery("user-liked-tracks"),
);
}
});
}),
if (playback.track != null) TrackHeartButton(track: playback.track!),
],
);
}

View File

@ -1,6 +1,4 @@
import 'dart:convert';
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -8,10 +6,8 @@ import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/TrackCollectionView.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
@ -59,15 +55,18 @@ class PlaylistView extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final Auth auth = ref.watch(authProvider);
SpotifyApi spotify = ref.watch(spotifyProvider);
final isPlaylistPlaying =
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
final breakpoint = useBreakpoints();
final meSnapshot = ref.watch(currentUserQuery);
final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!));
final meSnapshot =
useQuery(job: currentUserQueryJob, externalData: spotify);
final tracksSnapshot = useQuery(
job: playlistTracksQueryJob(playlist.id!),
externalData: spotify,
);
final titleImage = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
@ -76,11 +75,6 @@ class PlaylistView extends HookConsumerWidget {
),
[playlist.images]);
final color = usePaletteGenerator(
context,
titleImage,
).dominantColor;
return TrackCollectionView(
id: playlist.id!,
isPlaying: isPlaylistPlaying,
@ -89,15 +83,15 @@ class PlaylistView extends HookConsumerWidget {
tracksSnapshot: tracksSnapshot,
description: playlist.description,
isOwned: playlist.owner?.id != null &&
playlist.owner!.id == meSnapshot.asData?.value.id,
playlist.owner!.id == meSnapshot.data?.id,
onPlay: ([track]) {
if (tracksSnapshot.asData?.value != null) {
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
playPlaylist(playback, tracksSnapshot.asData!.value, ref);
playPlaylist(playback, tracksSnapshot.data!, ref);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
playback,
tracksSnapshot.asData!.value,
tracksSnapshot.data!,
ref,
currentTrack: track,
);
@ -126,48 +120,7 @@ class PlaylistView extends HookConsumerWidget {
);
});
},
heartBtn: (auth.isLoggedIn && playlist.id != "user-liked-tracks"
? meSnapshot.when(
data: (me) {
final query = playlistIsFollowedQuery(
jsonEncode({"playlistId": playlist.id, "userId": me.id!}));
final followingSnapshot = ref.watch(query);
return followingSnapshot.when(
data: (isFollowing) {
return HeartButton(
isLiked: isFollowing,
color: color?.titleTextColor,
icon: playlist.owner?.id != null &&
me.id == playlist.owner?.id
? Icons.delete_outline_rounded
: null,
onPressed: () async {
try {
isFollowing
? await spotify.playlists
.unfollowPlaylist(playlist.id!)
: await spotify.playlists
.followPlaylist(playlist.id!);
} catch (e, stack) {
logger.e("FollowButton.onPressed", e, stack);
} finally {
ref.refresh(query);
QueryBowl.of(context).refetchQueries([
currentUserPlaylistsQueryJob.queryKey,
]);
}
},
);
},
error: (error, _) => Text("Error $error"),
loading: () => const CircularProgressIndicator(),
);
},
error: (error, _) => Text("Error $error"),
loading: () => const CircularProgressIndicator(),
)
: null),
heartBtn: PlaylistHeartButton(playlist: playlist),
);
}
}

View File

@ -1,3 +1,4 @@
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';
@ -12,9 +13,11 @@ import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
final searchTermStateProvider = StateProvider<String>((ref) => "");
@ -24,18 +27,26 @@ class Search extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final Auth auth = ref.watch(authProvider);
final searchTerm = ref.watch(searchTermStateProvider);
final controller =
useTextEditingController(text: ref.read(searchTermStateProvider));
final albumController = useScrollController();
final playlistController = useScrollController();
final artistController = useScrollController();
final breakpoint = useBreakpoints();
final searchMutation = useMutation(
job: searchMutationJob,
);
if (auth.isAnonymous) {
return const AnonymousFallback();
}
final searchSnapshot = ref.watch(searchQuery(searchTerm));
final getVariables = useCallback(
() => Tuple2(
ref.read(searchTermStateProvider),
ref.read(spotifyProvider),
),
[],
);
return SafeArea(
child: Material(
@ -49,14 +60,15 @@ class Search extends HookConsumerWidget {
),
color: Theme.of(context).backgroundColor,
child: TextField(
controller: controller,
onChanged: (value) {
ref.read(searchTermStateProvider.notifier).state = value;
},
decoration: InputDecoration(
isDense: true,
suffix: ElevatedButton(
child: const Icon(Icons.search_rounded),
onPressed: () {
ref.read(searchTermStateProvider.notifier).state =
controller.value.text;
searchMutation.mutate(getVariables());
},
),
contentPadding: const EdgeInsets.symmetric(
@ -67,19 +79,26 @@ class Search extends HookConsumerWidget {
hintText: "Search...",
),
onSubmitted: (value) {
ref.read(searchTermStateProvider.notifier).state =
controller.value.text;
searchMutation.mutate(getVariables());
},
),
),
searchSnapshot.when(
data: (data) {
HookBuilder(
builder: (context) {
if (searchMutation.hasError && searchMutation.isError) {
return Text("Alas! Error=${searchMutation.error}");
}
if (searchMutation.isLoading) {
return const CircularProgressIndicator();
}
Playback playback = ref.watch(playbackProvider);
List<AlbumSimple> albums = [];
List<Artist> artists = [];
List<Track> tracks = [];
List<PlaylistSimple> playlists = [];
for (MapEntry<int, Page> page in data.asMap().entries) {
for (MapEntry<int, Page> page
in (searchMutation.data ?? []).asMap().entries) {
for (var item in page.value.items ?? []) {
if (item is AlbumSimple) {
albums.add(item);
@ -241,8 +260,6 @@ class Search extends HookConsumerWidget {
),
);
},
error: (error, __) => Text("Error $error"),
loading: () => const CircularProgressIndicator(),
)
],
),

View File

@ -32,7 +32,7 @@ class Action extends StatelessWidget {
}
return TextButton.icon(
style: TextButton.styleFrom(
primary: Theme.of(context).textTheme.bodyMedium?.color,
foregroundColor: Theme.of(context).textTheme.bodyMedium?.color,
padding: const EdgeInsets.all(20),
),
icon: icon,

View File

@ -1,8 +1,19 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/usePaletteColor.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
class HeartButton extends StatelessWidget {
class HeartButton extends ConsumerWidget {
final bool isLiked;
final void Function() onPressed;
final void Function()? onPressed;
final IconData? icon;
final Color? color;
const HeartButton({
@ -14,16 +25,219 @@ class HeartButton extends StatelessWidget {
}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, ref) {
final auth = ref.watch(authProvider);
if (!auth.isLoggedIn) return Container();
return IconButton(
icon: Icon(
icon ??
(!isLiked
? Icons.favorite_outline_rounded
: Icons.favorite_rounded),
color: isLiked ? Theme.of(context).primaryColor : color,
color: isLiked ? Colors.pink : color,
),
onPressed: onPressed,
);
}
}
Tuple3<bool, Mutation<bool, Tuple2<SpotifyApi, bool>>, Query<User, SpotifyApi>>
useTrackToggleLike(Track track, WidgetRef ref) {
final me = useQuery(
job: currentUserQueryJob, externalData: ref.watch(spotifyProvider));
final savedTracks = useQuery(
job: playlistTracksQueryJob("user-liked-tracks"),
externalData: ref.watch(spotifyProvider),
);
final isLiked =
savedTracks.data?.map((track) => track.id).contains(track.id) ?? false;
final mounted = useIsMounted();
final toggleTrackLike = useMutation<bool, Tuple2<SpotifyApi, bool>>(
job: toggleFavoriteTrackMutationJob(track.id!),
onMutate: (variable) {
savedTracks.setQueryData(
(oldData) {
if (!variable.item2) {
return [...(oldData ?? []), track];
}
return oldData
?.where(
(element) => element.id != track.id,
)
.toList() ??
[];
},
);
return track;
},
onData: (payload, variables, _) {
if (!mounted()) return;
savedTracks.refetch();
},
onError: (payload, variables, queryContext) {
if (!mounted()) return;
savedTracks.setQueryData(
(oldData) {
if (variables.item2) {
return [...(oldData ?? []), track];
}
return oldData
?.where(
(element) => element.id != track.id,
)
.toList() ??
[];
},
);
},
);
return Tuple3(isLiked, toggleTrackLike, me);
}
class TrackHeartButton extends HookConsumerWidget {
final Track track;
const TrackHeartButton({
Key? key,
required this.track,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final savedTracks = useQuery(
job: playlistTracksQueryJob("user-liked-tracks"),
externalData: ref.watch(spotifyProvider),
);
final toggler = useTrackToggleLike(track, ref);
if (toggler.item3.isLoading || !toggler.item3.hasData) {
return const CircularProgressIndicator();
}
return HeartButton(
isLiked: toggler.item1,
onPressed: savedTracks.hasData
? () {
toggler.item2.mutate(
Tuple2(ref.read(spotifyProvider), toggler.item1),
);
}
: null,
);
}
}
class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist;
const PlaylistHeartButton({
required this.playlist,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final me = useQuery(
job: currentUserQueryJob,
externalData: ref.watch(spotifyProvider),
);
final job = playlistIsFollowedQueryJob("${playlist.id}:${me.data?.id}");
final isLikedQuery = useQuery(
job: job,
externalData: ref.watch(spotifyProvider),
);
final togglePlaylistLike = useMutation<bool, Tuple2<SpotifyApi, bool>>(
job: toggleFavoritePlaylistMutationJob(playlist.id!),
onData: (payload, variables, queryContext) {
isLikedQuery.refetch();
QueryBowl.of(context)
.getQuery(currentUserPlaylistsQueryJob.queryKey)
?.refetch();
},
);
final titleImage = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
[playlist.images]);
final color = usePaletteGenerator(
context,
titleImage,
).dominantColor;
if (me.isLoading || !me.hasData) return const CircularProgressIndicator();
return HeartButton(
isLiked: isLikedQuery.data ?? false,
color: color?.titleTextColor,
onPressed: isLikedQuery.hasData
? () {
togglePlaylistLike.mutate(
Tuple2(
ref.read(spotifyProvider),
isLikedQuery.data!,
),
);
}
: null,
);
}
}
class AlbumHeartButton extends HookConsumerWidget {
final AlbumSimple album;
const AlbumHeartButton({
required this.album,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final spotify = ref.watch(spotifyProvider);
final me = useQuery(
job: currentUserQueryJob,
externalData: spotify,
);
final albumIsSaved = useQuery(
job: albumIsSavedForCurrentUserQueryJob(album.id!),
externalData: spotify,
);
final isLiked = albumIsSaved.data ?? false;
final toggleAlbumLike = useMutation<bool, Tuple2<SpotifyApi, bool>>(
job: toggleFavoriteAlbumMutationJob(album.id!),
onData: (payload, variables, queryContext) {
albumIsSaved.refetch();
QueryBowl.of(context)
.getQuery(currentUserAlbumsQueryJob.queryKey)
?.refetch();
},
);
if (me.isLoading || !me.hasData) return const CircularProgressIndicator();
return HeartButton(
isLiked: isLiked,
onPressed: albumIsSaved.hasData
? () {
toggleAlbumLike
.mutate(Tuple2(ref.read(spotifyProvider), isLiked));
}
: null,
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -13,12 +14,12 @@ import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/utils/platform.dart';
class TrackCollectionView extends HookConsumerWidget {
class TrackCollectionView<T> extends HookConsumerWidget {
final logger = getLogger(TrackCollectionView);
final String id;
final String title;
final String? description;
final AsyncValue<List<TrackSimple>> tracksSnapshot;
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final bool isPlaying;
final void Function([Track? currentTrack]) onPlay;
@ -78,7 +79,7 @@ class TrackCollectionView extends HookConsumerWidget {
const CircleBorder(),
),
),
onPressed: tracksSnapshot.asData?.value != null ? onPlay : null,
onPressed: tracksSnapshot.data != null ? onPlay : null,
child: Icon(
isPlaying ? Icons.stop_rounded : Icons.play_arrow_rounded,
color: Theme.of(context).backgroundColor,
@ -175,7 +176,14 @@ class TrackCollectionView extends HookConsumerWidget {
const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(path: titleImage),
child: UniversalImage(
path: titleImage,
placeholder: (context, url) {
return const UniversalImage(
path: "assets/album-placeholder.png",
);
},
),
),
),
Column(
@ -220,14 +228,25 @@ class TrackCollectionView extends HookConsumerWidget {
);
}),
),
tracksSnapshot.when(
data: (tracks) {
HookBuilder(
builder: (context) {
if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) {
return const ShimmerTrackTile();
} else if (tracksSnapshot.hasError &&
tracksSnapshot.isError) {
return SliverToBoxAdapter(
child: Text("Error ${tracksSnapshot.error}"));
}
final tracks = tracksSnapshot.data!;
return TracksTableView(
tracks is! List<Track>
? tracks
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(
track, album!))
.map(
(track) =>
TypeConversionUtils.simpleTrack_X_Track(
track, album!),
)
.toList()
: tracks,
onTrackPlayButtonPressed: onPlay,
@ -235,10 +254,7 @@ class TrackCollectionView extends HookConsumerWidget {
userPlaylist: isOwned,
);
},
error: (error, _) =>
SliverToBoxAdapter(child: Text("Error $error")),
loading: () => const ShimmerTrackTile(),
),
)
],
)),
);

View File

@ -4,16 +4,16 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Image;
import 'package:spotube/components/Shared/AdaptivePopupMenuButton.dart';
import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/LinkText.dart';
import 'package:spotube/components/Shared/UniversalImage.dart';
import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/useForceUpdate.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
class TrackTile extends HookConsumerWidget {
final Playback playback;
@ -60,28 +60,6 @@ class TrackTile extends HookConsumerWidget {
final breakpoint = useBreakpoints();
final auth = ref.watch(authProvider);
final spotify = ref.watch(spotifyProvider);
final update = useForceUpdate();
final savedTracksSnapshot = ref.watch(currentUserSavedTracksQuery);
final isSaved = savedTracksSnapshot.asData?.value.any(
(e) => track.value.id == e.id,
) ??
false;
final actionFavorite = useCallback((bool isLiked) async {
try {
isLiked
? await spotify.tracks.me.removeOne(track.value.id!)
: await spotify.tracks.me.saveOne(track.value.id!);
} catch (e, stack) {
logger.e("FavoriteButton.onPressed", e, stack);
} finally {
update();
ref.refresh(currentUserSavedTracksQuery);
ref.refresh(playlistTracksQuery("user-liked-tracks"));
}
}, [track.value.id, spotify]);
final actionRemoveFromPlaylist = useCallback(() async {
if (playlistId == null) return;
@ -188,6 +166,8 @@ class TrackTile extends HookConsumerWidget {
index: track.value.album?.images?.length == 1 ? 0 : 2,
);
final toggler = useTrackToggleLike(track.value, ref);
return AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
@ -291,14 +271,17 @@ class TrackTile extends HookConsumerWidget {
if (!isReallyLocal)
AdaptiveActions(
actions: [
if (auth.isLoggedIn)
if (toggler.item3.hasData)
Action(
icon: Icon(isSaved
? Icons.favorite_rounded
: Icons.favorite_border_rounded),
icon: toggler.item1
? const Icon(
Icons.favorite_rounded,
color: Colors.pink,
)
: const Icon(Icons.favorite_border_rounded),
text: const Text("Save as favorite"),
onPressed: () {
actionFavorite(isSaved);
toggler.item2.mutate(Tuple2(spotify, toggler.item1));
},
),
if (auth.isLoggedIn)

View File

@ -30,7 +30,7 @@ class Auth extends PersistedChangeNotifier {
Duration get expiresIn =>
_expiration?.difference(DateTime.now()) ?? Duration.zero;
refresh() async {
Future<void> refresh() async {
final data = await ServiceUtils.getAccessToken(authCookie!);
_accessToken = data.accessToken;
_expiration = data.expiration;
@ -39,15 +39,17 @@ class Auth extends PersistedChangeNotifier {
}
Timer? _createRefresher() {
if (expiration == null || !isExpired || authCookie == null) {
if (expiration == null || authCookie == null) {
return null;
}
if (isExpired) {
refresh();
}
_refresher?.cancel();
return Timer(expiresIn, refresh);
return Timer(expiresIn - const Duration(minutes: 5), refresh);
}
void _restartRefresher() {
_refresher?.cancel();
_refresher = _createRefresher();
}

View File

@ -1,16 +1,13 @@
import 'dart:convert';
import 'package:fl_query/fl_query.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/Logger.dart';
import 'package:spotube/models/LyricsModels.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/UserPreferences.dart';
import 'package:collection/collection.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
final categoriesQueryJob =
InfiniteQueryJob<Page<Category>, Map<String, dynamic>, int>(
@ -47,7 +44,7 @@ final categoryPlaylistsQueryJob =
final currentUserPlaylistsQueryJob =
QueryJob<Iterable<PlaylistSimple>, SpotifyApi>(
queryKey: "current-user-query",
queryKey: "current-user-playlists",
task: (_, spotify) {
return spotify.playlists.me.all();
},
@ -60,14 +57,6 @@ final currentUserAlbumsQueryJob = QueryJob<Iterable<AlbumSimple>, SpotifyApi>(
},
);
final currentUserFollowingArtistsQuery =
FutureProvider.family<CursorPage<Artist>, String>(
(ref, pageKey) {
final spotify = ref.watch(spotifyProvider);
return spotify.me.following(FollowingType.artist).getPage(15, pageKey);
},
);
final currentUserFollowingArtistsQueryJob =
InfiniteQueryJob<CursorPage<Artist>, SpotifyApi, String>(
queryKey: "user-following-artists",
@ -80,34 +69,17 @@ final currentUserFollowingArtistsQueryJob =
},
);
final artistProfileQuery = FutureProvider.family<Artist, String>(
(ref, id) {
final spotify = ref.watch(spotifyProvider);
return spotify.artists.get(id);
},
final artistProfileQueryJob = QueryJob.withVariableKey<Artist, SpotifyApi>(
preQueryKey: "artist-profile",
task: (queryKey, externalData) =>
externalData.artists.get(getVariable(queryKey)),
);
final currentUserFollowsArtistQuery = FutureProvider.family<bool, String>(
(ref, artistId) async {
final spotify = ref.watch(spotifyProvider);
final result = await spotify.me.isFollowing(
FollowingType.artist,
[artistId],
);
return result.first;
},
);
final artistTopTracksQuery =
FutureProvider.family<Iterable<Track>, String>((ref, id) {
final spotify = ref.watch(spotifyProvider);
return spotify.artists.getTopTracks(id, "US");
});
final artistAlbumsQuery = FutureProvider.family<Page<Album>, String>(
(ref, id) {
final spotify = ref.watch(spotifyProvider);
return spotify.artists.albums(id).getPage(5, 0);
final artistTopTracksQueryJob =
QueryJob.withVariableKey<Iterable<Track>, SpotifyApi>(
preQueryKey: "artist-top-track-query",
task: (queryKey, spotify) {
return spotify.artists.getTopTracks(getVariable(queryKey), "US");
},
);
@ -123,53 +95,54 @@ final artistAlbumsQueryJob =
},
);
final artistRelatedArtistsQuery =
FutureProvider.family<Iterable<Artist>, String>(
(ref, id) {
final spotify = ref.watch(spotifyProvider);
return spotify.artists.getRelatedArtists(id);
final artistRelatedArtistsQueryJob =
QueryJob.withVariableKey<Iterable<Artist>, SpotifyApi>(
preQueryKey: "artist-related-artist-query",
task: (queryKey, spotify) {
return spotify.artists.getRelatedArtists(getVariable(queryKey));
},
);
final currentUserSavedTracksQuery = FutureProvider<List<Track>>((ref) {
final spotify = ref.watch(spotifyProvider);
return spotify.tracks.me.saved.all().then(
(tracks) => tracks.map((e) => e.track!).toList(),
);
});
final playlistTracksQuery = FutureProvider.family<List<Track>, String>(
(ref, id) {
try {
final spotify = ref.watch(spotifyProvider);
return id != "user-liked-tracks"
? spotify.playlists.getTracksByPlaylistId(id).all().then(
(value) => value.toList(),
)
: spotify.tracks.me.saved.all().then(
(tracks) => tracks.map((e) => e.track!).toList(),
);
} catch (e, stack) {
getLogger("playlistTracksQuery").e(
"Fetching playlist tracks",
e,
stack,
);
return [];
}
final currentUserFollowsArtistQueryJob =
QueryJob.withVariableKey<bool, SpotifyApi>(
preQueryKey: "user-follows-artists-query",
task: (artistId, spotify) async {
final result = await spotify.me.isFollowing(
FollowingType.artist,
[getVariable(artistId)],
);
return result.first;
},
);
final albumTracksQuery = FutureProvider.family<List<TrackSimple>, String>(
(ref, id) {
final spotify = ref.watch(spotifyProvider);
final playlistTracksQueryJob =
QueryJob.withVariableKey<List<Track>, SpotifyApi>(
preQueryKey: "playlist-tracks",
task: (queryKey, spotify) {
final id = getVariable(queryKey);
return id != "user-liked-tracks"
? spotify.playlists.getTracksByPlaylistId(id).all().then(
(value) => value.toList(),
)
: spotify.tracks.me.saved.all().then(
(tracks) => tracks.map((e) => e.track!).toList(),
);
},
);
final albumTracksQueryJob =
QueryJob.withVariableKey<List<TrackSimple>, SpotifyApi>(
preQueryKey: "album-tracks",
task: (queryKey, spotify) {
final id = getVariable(queryKey);
return spotify.albums.getTracks(id).all().then((value) => value.toList());
},
);
final currentUserQuery = FutureProvider<User>(
(ref) async {
final spotify = ref.watch(spotifyProvider);
final currentUserQueryJob = QueryJob<User, SpotifyApi>(
queryKey: "current-user",
refetchOnExternalDataChange: true,
task: (_, spotify) async {
final me = await spotify.me.get();
if (me.images == null || me.images?.isEmpty == true) {
me.images = [
@ -186,50 +159,112 @@ final currentUserQuery = FutureProvider<User>(
},
);
final playlistIsFollowedQuery = FutureProvider.family<bool, String>(
(ref, raw) {
final data = jsonDecode(raw);
final playlistId = data["playlistId"] as String;
final userId = data["userId"] as String;
final spotify = ref.watch(spotifyProvider);
return spotify.playlists
.followedBy(playlistId, [userId]).then((value) => value.first);
final playlistIsFollowedQueryJob = QueryJob.withVariableKey<bool, SpotifyApi>(
preQueryKey: "playlist-is-followed",
task: (queryKey, spotify) {
final idMap = getVariable(queryKey).split(":");
return spotify.playlists.followedBy(idMap.first, [idMap.last]).then(
(value) => value.first,
);
},
);
final albumIsSavedForCurrentUserQuery =
FutureProvider.family<bool, String>((ref, albumId) {
final spotify = ref.watch(spotifyProvider);
return spotify.me.isSavedAlbums([albumId]).then((value) => value.first);
final albumIsSavedForCurrentUserQueryJob =
QueryJob.withVariableKey<bool, SpotifyApi>(task: (queryKey, spotify) {
return spotify.me
.isSavedAlbums([getVariable(queryKey)]).then((value) => value.first);
});
final searchQuery = FutureProvider.family<List<Page>, String>((ref, term) {
final spotify = ref.watch(spotifyProvider);
if (term.isEmpty) return [];
return spotify.search.get(term).first(10);
});
final searchMutationJob = MutationJob<List<Page>, Tuple2<String, SpotifyApi>>(
mutationKey: "search-query",
task: (ref, variables) {
final queryString = variables.item1;
final spotify = variables.item2;
if (queryString.isEmpty) return [];
return spotify.search.get(queryString).first(10);
},
);
final geniusLyricsQuery = FutureProvider<String?>(
(ref) {
final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
final geniusAccessToken =
ref.watch(userPreferencesProvider.select((s) => s.geniusAccessToken));
final geniusLyricsQueryJob = QueryJob<String, Tuple2<Track?, String>>(
queryKey: "genius-lyrics-query",
task: (_, externalData) async {
final currentTrack = externalData.item1;
final geniusAccessToken = externalData.item2;
if (currentTrack == null) {
return "“Give this player a track to play”\n- S'Challa";
}
return ServiceUtils.getLyrics(
final lyrics = await ServiceUtils.getLyrics(
currentTrack.name!,
currentTrack.artists?.map((s) => s.name).whereNotNull().toList() ?? [],
apiKey: geniusAccessToken,
optimizeQuery: true,
);
if (lyrics == null) throw Exception("Unable find lyrics");
return lyrics;
},
);
final rentanadviserLyricsQuery = FutureProvider<SubtitleSimple?>(
(ref) {
final currentTrack = ref.watch(playbackProvider.select((s) => s.track));
if (currentTrack == null) return null;
return ServiceUtils.getTimedLyrics(currentTrack);
final rentanadviserLyricsQueryJob = QueryJob<SubtitleSimple, SpotubeTrack?>(
queryKey: "synced-lyrics",
task: (_, currentTrack) async {
if (currentTrack == null) throw "No track currently";
final timedLyrics = await ServiceUtils.getTimedLyrics(currentTrack);
if (timedLyrics == null) throw Exception("Unable to find lyrics");
return timedLyrics;
},
);
final toggleFavoriteTrackMutationJob =
MutationJob.withVariableKey<bool, Tuple2<SpotifyApi, bool>>(
preMutationKey: "toggle-track-like",
task: (queryKey, externalData) async {
final trackId = getVariable(queryKey);
final spotify = externalData.item1;
final isLiked = externalData.item2;
if (isLiked) {
await spotify.tracks.me.removeOne(trackId);
} else {
await spotify.tracks.me.saveOne(trackId);
}
return !isLiked;
},
);
final toggleFavoritePlaylistMutationJob =
MutationJob.withVariableKey<bool, Tuple2<SpotifyApi, bool>>(
preMutationKey: "toggle-playlist-like",
task: (queryKey, externalData) async {
final playlistId = getVariable(queryKey);
final spotify = externalData.item1;
final isLiked = externalData.item2;
if (isLiked) {
await spotify.playlists.unfollowPlaylist(playlistId);
} else {
await spotify.playlists.followPlaylist(playlistId);
}
return !isLiked;
},
);
final toggleFavoriteAlbumMutationJob =
MutationJob.withVariableKey<bool, Tuple2<SpotifyApi, bool>>(
preMutationKey: "toggle-album-like",
task: (queryKey, externalData) async {
final albumId = getVariable(queryKey);
final spotify = externalData.item1;
final isLiked = externalData.item2;
if (isLiked) {
await spotify.me.removeAlbums([albumId]);
} else {
await spotify.me.saveAlbums([albumId]);
}
return !isLiked;
},
);

View File

@ -6,7 +6,6 @@ import 'package:flutter/widgets.dart' hide Image;
import 'package:metadata_god/metadata_god.dart' hide Image;
import 'package:path/path.dart';
import 'package:spotube/components/Shared/AnchorButton.dart';
import 'package:spotube/components/Shared/LinkText.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/utils/primitive_utils.dart';

View File

@ -485,14 +485,14 @@ packages:
name: fl_query
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
version: "0.3.1"
fl_query_hooks:
dependency: "direct main"
description:
name: fl_query_hooks
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
version: "0.3.1"
flutter:
dependency: "direct main"
description: flutter

View File

@ -57,8 +57,8 @@ dependencies:
url: https://github.com/KRTirtho/metadata_god.git
ref: 7d195fdde324b382fc12067c56391285807e6233
visibility_detector: ^0.3.3
fl_query: ^0.3.0
fl_query_hooks: ^0.3.0
fl_query: ^0.3.1
fl_query_hooks: ^0.3.1
flutter_inappwebview: ^5.4.3+7
tuple: ^2.0.1