From a9ba2582fb7527eca405c8e4d796ff63cfc03b79 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 14 Jun 2025 20:53:18 +0600 Subject: [PATCH] feat: add playlist endpoint and providers --- .../metadata_plugin/library/playlists.dart | 91 ++++++------ .../metadata_plugin/tracks/playlist.dart | 36 +++++ .../metadata_plugin/utils/common.dart | 44 ++++++ .../utils/family_paginated.dart | 103 +++++++++++++ .../metadata_plugin/utils/paginated.dart | 37 +---- lib/services/metadata/endpoints/playlist.dart | 135 ++++++++++++++++++ lib/services/metadata/metadata.dart | 3 + 7 files changed, 369 insertions(+), 80 deletions(-) create mode 100644 lib/provider/metadata_plugin/tracks/playlist.dart create mode 100644 lib/provider/metadata_plugin/utils/common.dart create mode 100644 lib/provider/metadata_plugin/utils/family_paginated.dart diff --git a/lib/provider/metadata_plugin/library/playlists.dart b/lib/provider/metadata_plugin/library/playlists.dart index 990764d8..a017f65f 100644 --- a/lib/provider/metadata_plugin/library/playlists.dart +++ b/lib/provider/metadata_plugin/library/playlists.dart @@ -2,6 +2,7 @@ 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/tracks/playlist.dart'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; import 'package:spotube/services/metadata/endpoints/error.dart'; @@ -40,61 +41,63 @@ class FavoritePlaylistsNotifier ); } - // 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], - // ); - // }); + Future addFavorite(SpotubeSimplePlaylistObject playlist) async { + await update((state) async { + (await metadataPlugin)!.playlist.save(playlist.id); + return state.copyWith( + items: [...state.items, playlist], + ) as SpotubePaginationResponseObject; + }); - // ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); - // } + ref.invalidate(metadataPluginIsSavedPlaylistProvider(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(), - // ); - // }); + Future removeFavorite(SpotubeSimplePlaylistObject playlist) async { + await update((state) async { + (await metadataPlugin)!.playlist.unsave(playlist.id); + return state.copyWith( + items: state.items + .where((e) => (e as SpotubeSimplePlaylistObject).id != playlist.id) + .toList() as List, + ) as SpotubePaginationResponseObject; + }); - // ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); - // } + ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id)); + } - // Future addTracks(String playlistId, List trackIds) async { - // if (state.value == null) return; + Future delete(SpotubeSimplePlaylistObject playlist) async { + await update((state) async { + (await metadataPlugin)!.playlist.deletePlaylist(playlist.id); + return state.copyWith( + items: state.items + .where((e) => (e as SpotubeSimplePlaylistObject).id != playlist.id) + .toList() as List, + ) as SpotubePaginationResponseObject; + }); - // final spotify = ref.read(spotifyProvider); + ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id)); + ref.invalidate(metadataPluginPlaylistTracksProvider(playlist.id)); + } - // await spotify.invoke( - // (api) => api.playlists.addTracks( - // trackIds.map((id) => 'spotify:track:$id').toList(), - // playlistId, - // ), - // ); + Future addTracks(String playlistId, List trackIds) async { + if (state.value == null) return; - // ref.invalidate(playlistTracksProvider(playlistId)); - // } + await (await metadataPlugin)! + .playlist + .addTracks(playlistId, trackIds: trackIds); - // Future removeTracks(String playlistId, List trackIds) async { - // if (state.value == null) return; + ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId)); + } - // final spotify = ref.read(spotifyProvider); + Future removeTracks(String playlistId, List trackIds) async { + if (state.value == null) return; - // await spotify.invoke( - // (api) => api.playlists.removeTracks( - // trackIds.map((id) => 'spotify:track:$id').toList(), - // playlistId, - // ), - // ); + await (await metadataPlugin)! + .playlist + .removeTracks(playlistId, trackIds: trackIds); - // ref.invalidate(playlistTracksProvider(playlistId)); - // } + ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId)); + } } final metadataPluginSavedPlaylistsProvider = AsyncNotifierProvider< diff --git a/lib/provider/metadata_plugin/tracks/playlist.dart b/lib/provider/metadata_plugin/tracks/playlist.dart new file mode 100644 index 00000000..c0302bb4 --- /dev/null +++ b/lib/provider/metadata_plugin/tracks/playlist.dart @@ -0,0 +1,36 @@ +import 'package:hooks_riverpod/hooks_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/family_paginated.dart'; +import 'package:spotube/provider/metadata_plugin/utils/common.dart'; + +class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + SpotubeFullTrackObject, String> { + PlaylistTracksNotifier() : super(); + + @override + fetch(offset, limit) async { + final tracks = await (await metadataPlugin)!.playlist.tracks( + arg, + offset: offset, + limit: limit, + ); + + return tracks; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(metadataPluginProvider); + return await fetch(0, 20); + } +} + +final metadataPluginPlaylistTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + PlaylistTracksNotifier, + SpotubePaginationResponseObject, + String>( + () => PlaylistTracksNotifier(), +); diff --git a/lib/provider/metadata_plugin/utils/common.dart b/lib/provider/metadata_plugin/utils/common.dart new file mode 100644 index 00000000..6a64bd91 --- /dev/null +++ b/lib/provider/metadata_plugin/utils/common.dart @@ -0,0 +1,44 @@ +// ignore: implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/metadata/metadata.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); +} + +extension AutoDisposeAsyncNotifierCacheFor +// ignore: deprecated_member_use + 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 AutoDisposeCacheFor 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); +} diff --git a/lib/provider/metadata_plugin/utils/family_paginated.dart b/lib/provider/metadata_plugin/utils/family_paginated.dart new file mode 100644 index 00000000..eb656431 --- /dev/null +++ b/lib/provider/metadata_plugin/utils/family_paginated.dart @@ -0,0 +1,103 @@ +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/utils/common.dart'; + +abstract class FamilyPaginatedAsyncNotifier + extends FamilyAsyncNotifier, A> + with MetadataPluginMixin { + 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 AutoDisposeFamilyPaginatedAsyncNotifier + extends AutoDisposeFamilyAsyncNotifier, + A> with MetadataPluginMixin { + 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; + } +} diff --git a/lib/provider/metadata_plugin/utils/paginated.dart b/lib/provider/metadata_plugin/utils/paginated.dart index 3dacf751..c82e2f51 100644 --- a/lib/provider/metadata_plugin/utils/paginated.dart +++ b/lib/provider/metadata_plugin/utils/paginated.dart @@ -4,42 +4,7 @@ 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); -} +import 'package:spotube/provider/metadata_plugin/utils/common.dart'; mixin PaginatedAsyncNotifierMixin // ignore: invalid_use_of_internal_member diff --git a/lib/services/metadata/endpoints/playlist.dart b/lib/services/metadata/endpoints/playlist.dart index e69de29b..09c36446 100644 --- a/lib/services/metadata/endpoints/playlist.dart +++ b/lib/services/metadata/endpoints/playlist.dart @@ -0,0 +1,135 @@ +import 'package:hetu_script/hetu_script.dart'; +import 'package:hetu_script/values.dart'; +import 'package:spotube/models/metadata/metadata.dart'; + +class MetadataPluginPlaylistEndpoint { + final Hetu hetu; + MetadataPluginPlaylistEndpoint(this.hetu); + + HTInstance get hetuMetadataPlaylist => + (hetu.fetch("metadataPlugin") as HTInstance).memberGet("playlist") + as HTInstance; + + Future getPlaylist(String id) async { + final raw = await hetuMetadataPlaylist + .invoke("getPlaylist", positionalArgs: [id]) as Map; + + return SpotubeFullPlaylistObject.fromJson( + raw.cast(), + ); + } + + Future> tracks( + String id, { + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataPlaylist.invoke( + "tracks", + positionalArgs: [id], + namedArgs: { + "offset": offset, + "limit": limit, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => + SpotubeFullTrackObject.fromJson(json.cast()), + ); + } + + Future create( + String userId, { + required String name, + String? description, + bool? public, + bool? collaborative, + }) async { + final raw = await hetuMetadataPlaylist.invoke( + "create", + positionalArgs: [userId], + namedArgs: { + "name": name, + "description": description, + "public": public, + "collaborative": collaborative, + }..removeWhere((key, value) => value == null), + ) as Map?; + + if (raw == null) return null; + + return SpotubeFullPlaylistObject.fromJson( + raw.cast(), + ); + } + + Future update( + String playlistId, { + String? name, + String? description, + bool? public, + bool? collaborative, + }) async { + await hetuMetadataPlaylist.invoke( + "update", + positionalArgs: [playlistId], + namedArgs: { + "name": name, + "description": description, + "public": public, + "collaborative": collaborative, + }..removeWhere((key, value) => value == null), + ); + } + + Future addTracks( + String playlistId, { + required List trackIds, + int? position, + }) async { + await hetuMetadataPlaylist.invoke( + "addTracks", + positionalArgs: [playlistId], + namedArgs: { + "trackIds": trackIds, + "position": position, + }..removeWhere((key, value) => value == null), + ); + } + + Future removeTracks( + String playlistId, { + required List trackIds, + }) async { + await hetuMetadataPlaylist.invoke( + "removeTracks", + positionalArgs: [playlistId], + namedArgs: { + "trackIds": trackIds, + }..removeWhere((key, value) => value == null), + ); + } + + Future save(String playlistId) async { + await hetuMetadataPlaylist.invoke( + "save", + positionalArgs: [playlistId], + ); + } + + Future unsave(String playlistId) async { + await hetuMetadataPlaylist.invoke( + "unsave", + positionalArgs: [playlistId], + ); + } + + Future deletePlaylist(String playlistId) async { + await hetuMetadataPlaylist.invoke( + "deletePlaylist", + positionalArgs: [playlistId], + ); + } +} diff --git a/lib/services/metadata/metadata.dart b/lib/services/metadata/metadata.dart index 8f219207..01e158fe 100644 --- a/lib/services/metadata/metadata.dart +++ b/lib/services/metadata/metadata.dart @@ -12,6 +12,7 @@ import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/services/metadata/apis/localstorage.dart'; import 'package:spotube/services/metadata/endpoints/auth.dart'; +import 'package:spotube/services/metadata/endpoints/playlist.dart'; import 'package:spotube/services/metadata/endpoints/user.dart'; const defaultMetadataLimit = "20"; @@ -73,9 +74,11 @@ class MetadataPlugin { late final MetadataAuthEndpoint auth; late final MetadataPluginUserEndpoint user; + late final MetadataPluginPlaylistEndpoint playlist; MetadataPlugin._(this.hetu) { auth = MetadataAuthEndpoint(hetu); + playlist = MetadataPluginPlaylistEndpoint(hetu); user = MetadataPluginUserEndpoint(hetu); } }