diff --git a/lib/pages/settings/metadata_plugins.dart b/lib/pages/settings/metadata_plugins.dart index 283cf715..a6a30a7f 100644 --- a/lib/pages/settings/metadata_plugins.dart +++ b/lib/pages/settings/metadata_plugins.dart @@ -11,6 +11,7 @@ import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/auth.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:spotube/provider/metadata_plugin/user.dart'; @RoutePage() class SettingsMetadataProviderPage extends HookConsumerWidget { @@ -25,6 +26,8 @@ class SettingsMetadataProviderPage extends HookConsumerWidget { final metadataPlugin = ref.watch(metadataPluginProvider); final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider); + final user = ref.watch(metadataPluginUserProvider); + return Scaffold( headers: const [ TitleBar( diff --git a/lib/provider/metadata_plugin/library/albums.dart b/lib/provider/metadata_plugin/library/albums.dart new file mode 100644 index 00000000..32abe37b --- /dev/null +++ b/lib/provider/metadata_plugin/library/albums.dart @@ -0,0 +1,8 @@ +import 'package:riverpod/riverpod.dart'; + +class LibraryAlbumsNotifier extends AsyncNotifier { + @override + build() { + return null; + } +} diff --git a/lib/provider/metadata_plugin/library/artists.dart b/lib/provider/metadata_plugin/library/artists.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/provider/metadata_plugin/library/playlists.dart b/lib/provider/metadata_plugin/library/playlists.dart new file mode 100644 index 00000000..990764d8 --- /dev/null +++ b/lib/provider/metadata_plugin/library/playlists.dart @@ -0,0 +1,121 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; +import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; +import 'package:spotube/services/metadata/endpoints/error.dart'; + +class FavoritePlaylistsNotifier + extends PaginatedAsyncNotifier { + FavoritePlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await (await metadataPlugin) + ?.user + .savedPlaylists(limit: limit, offset: offset); + + return playlists!; + } + + @override + build() async { + ref.watch(metadataPluginProvider); + final playlists = await fetch(0, 20); + + return playlists; + } + + void updatePlaylist(SpotubeSimplePlaylistObject playlist) { + if (state.value == null) return; + + if (state.value!.items.none((e) => e.id == playlist.id)) return; + + state = AsyncData( + state.value!.copyWith( + items: state.value!.items + .map((element) => element.id == playlist.id ? playlist : element) + .toList() as List, + ) as SpotubePaginationResponseObject, + ); + } + + // Future addFavorite(PlaylistSimple playlist) async { + // await update((state) async { + // await spotify.invoke( + // (api) => api.playlists.followPlaylist(playlist.id!), + // ); + // return state.copyWith( + // items: [...state.items, playlist], + // ); + // }); + + // ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + // } + + // Future removeFavorite(PlaylistSimple playlist) async { + // await update((state) async { + // await spotify.invoke( + // (api) => api.playlists.unfollowPlaylist(playlist.id!), + // ); + // return state.copyWith( + // items: state.items.where((e) => e.id != playlist.id).toList(), + // ); + // }); + + // ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + // } + + // Future addTracks(String playlistId, List trackIds) async { + // if (state.value == null) return; + + // final spotify = ref.read(spotifyProvider); + + // await spotify.invoke( + // (api) => api.playlists.addTracks( + // trackIds.map((id) => 'spotify:track:$id').toList(), + // playlistId, + // ), + // ); + + // ref.invalidate(playlistTracksProvider(playlistId)); + // } + + // Future removeTracks(String playlistId, List trackIds) async { + // if (state.value == null) return; + + // final spotify = ref.read(spotifyProvider); + + // await spotify.invoke( + // (api) => api.playlists.removeTracks( + // trackIds.map((id) => 'spotify:track:$id').toList(), + // playlistId, + // ), + // ); + + // ref.invalidate(playlistTracksProvider(playlistId)); + // } +} + +final metadataPluginSavedPlaylistsProvider = AsyncNotifierProvider< + FavoritePlaylistsNotifier, + SpotubePaginationResponseObject>( + () => FavoritePlaylistsNotifier(), +); + +final metadataPluginIsSavedPlaylistProvider = + FutureProvider.family( + (ref, id) async { + final plugin = await ref.watch(metadataPluginProvider.future); + + if (plugin == null) { + throw MetadataPluginException.noDefaultPlugin( + "Failed to get metadata plugin", + ); + } + + final follows = await plugin.user.isSavedPlaylist(id); + + return follows; + }, +); diff --git a/lib/provider/metadata_plugin/utils/paginated.dart b/lib/provider/metadata_plugin/utils/paginated.dart new file mode 100644 index 00000000..3dacf751 --- /dev/null +++ b/lib/provider/metadata_plugin/utils/paginated.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +// ignore: implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; +import 'package:spotube/services/metadata/metadata.dart'; + +mixin MetadataPluginMixin +// ignore: invalid_use_of_internal_member + on AsyncNotifierBase> { + Future get metadataPlugin async => + await ref.read(metadataPluginProvider.future); +} + +// ignore: deprecated_member_use +extension on AutoDisposeAsyncNotifierProviderRef { + // When invoked keeps your provider alive for [duration] + // ignore: unused_element + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +// ignore: deprecated_member_use +extension on AutoDisposeRef { + // When invoked keeps your provider alive for [duration] + // ignore: unused_element + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +// ignore: subtype_of_sealed_class +class AsyncLoadingNext extends AsyncData { + const AsyncLoadingNext(super.value); +} + +mixin PaginatedAsyncNotifierMixin + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase> { + Future> fetch(int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final newState = await fetch( + state.value!.nextOffset!, + state.value!.limit, + ); + return newState.copyWith(items: [ + ...state.value!.items as List, + ...newState.items as List, + ]) as SpotubePaginationResponseObject; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items as List; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final newState = await fetch( + state.nextOffset!, + state.limit, + ); + + hasMore = newState.hasMore; + return newState.copyWith(items: [ + ...state.items as List, + ...newState.items as List, + ]) as SpotubePaginationResponseObject; + }); + } + + return state.value!.items as List; + } +} + +abstract class PaginatedAsyncNotifier + extends AsyncNotifier> + with PaginatedAsyncNotifierMixin, MetadataPluginMixin {} + +abstract class AutoDisposePaginatedAsyncNotifier + extends AutoDisposeAsyncNotifier> + with PaginatedAsyncNotifierMixin, MetadataPluginMixin {} diff --git a/lib/services/metadata/endpoints/error.dart b/lib/services/metadata/endpoints/error.dart new file mode 100644 index 00000000..f4c6af94 --- /dev/null +++ b/lib/services/metadata/endpoints/error.dart @@ -0,0 +1,12 @@ +class MetadataPluginException implements Exception { + final String exceptionType; + final String message; + + MetadataPluginException.noDefaultPlugin(this.message) + : exceptionType = "NoDefault"; + + @override + String toString() { + return "${exceptionType}MetadataPluginException: $message"; + } +} diff --git a/lib/services/metadata/endpoints/user.dart b/lib/services/metadata/endpoints/user.dart index ec4cd963..e85f1c88 100644 --- a/lib/services/metadata/endpoints/user.dart +++ b/lib/services/metadata/endpoints/user.dart @@ -1,15 +1,127 @@ import 'package:hetu_script/hetu_script.dart'; +import 'package:hetu_script/values.dart'; import 'package:spotube/models/metadata/metadata.dart'; class MetadataPluginUserEndpoint { final Hetu hetu; MetadataPluginUserEndpoint(this.hetu); + HTInstance get hetuMetadataUser => + (hetu.fetch("metadataPlugin") as HTInstance).memberGet("user") + as HTInstance; + Future me() async { - final raw = await hetu.eval("metadataPlugin.user.me()") as Map; + final raw = await hetuMetadataUser.invoke("me") as Map; return SpotubeUserObject.fromJson( raw.cast(), ); } + + Future> savedTracks({ + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataUser.invoke( + "savedTracks", + namedArgs: { + "offset": offset, + "limit": limit, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => + SpotubeFullTrackObject.fromJson(json.cast()), + ); + } + + Future> + savedPlaylists({ + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataUser.invoke( + "savedPlaylists", + namedArgs: { + "offset": offset, + "limit": limit, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => + SpotubeSimplePlaylistObject.fromJson(json.cast()), + ); + } + + Future> + savedAlbums({ + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataUser.invoke( + "savedAlbums", + namedArgs: { + "offset": offset, + "limit": limit, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => + SpotubeSimpleAlbumObject.fromJson(json.cast()), + ); + } + + Future> + savedArtists({ + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataUser.invoke( + "savedArtists", + namedArgs: { + "offset": offset, + "limit": limit, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => + SpotubeFullArtistObject.fromJson(json.cast()), + ); + } + + Future isSavedPlaylist(String playlistId) async { + return await hetuMetadataUser.invoke( + "isSavedPlaylist", + positionalArgs: [playlistId], + ) as bool; + } + + Future> isSavedTracks(List ids) async { + return await hetuMetadataUser.invoke( + "isSavedTracks", + positionalArgs: [ids], + ) as List; + } + + Future> isSavedAlbums(List ids) async { + return await hetuMetadataUser.invoke( + "isSavedAlbums", + positionalArgs: [ids], + ) as List; + } + + Future> isSavedArtists(List ids) async { + return await hetuMetadataUser.invoke( + "isSavedArtists", + positionalArgs: [ids], + ) as List; + } }