feat: add user endpoint calls in metadata and paginated async notifiers

This commit is contained in:
Kingkor Roy Tirtho 2025-06-14 20:25:38 +06:00
parent 3306f21860
commit f8211cbcc7
7 changed files with 355 additions and 1 deletions

View File

@ -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/auth.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:spotube/provider/metadata_plugin/user.dart';
@RoutePage() @RoutePage()
class SettingsMetadataProviderPage extends HookConsumerWidget { class SettingsMetadataProviderPage extends HookConsumerWidget {
@ -25,6 +26,8 @@ class SettingsMetadataProviderPage extends HookConsumerWidget {
final metadataPlugin = ref.watch(metadataPluginProvider); final metadataPlugin = ref.watch(metadataPluginProvider);
final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider); final isAuthenticated = ref.watch(metadataPluginAuthenticatedProvider);
final user = ref.watch(metadataPluginUserProvider);
return Scaffold( return Scaffold(
headers: const [ headers: const [
TitleBar( TitleBar(

View File

@ -0,0 +1,8 @@
import 'package:riverpod/riverpod.dart';
class LibraryAlbumsNotifier extends AsyncNotifier<void> {
@override
build() {
return null;
}
}

View File

@ -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<SpotubeSimplePlaylistObject> {
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<SpotubeSimplePlaylistObject>,
) as SpotubePaginationResponseObject<SpotubeSimplePlaylistObject>,
);
}
// Future<void> 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<void> 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<void> addTracks(String playlistId, List<String> 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<void> removeTracks(String playlistId, List<String> 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<SpotubeSimplePlaylistObject>>(
() => FavoritePlaylistsNotifier(),
);
final metadataPluginIsSavedPlaylistProvider =
FutureProvider.family<bool, String>(
(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;
},
);

View File

@ -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<K>
// ignore: invalid_use_of_internal_member
on AsyncNotifierBase<SpotubePaginationResponseObject<K>> {
Future<MetadataPlugin?> 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<T> extends AsyncData<T> {
const AsyncLoadingNext(super.value);
}
mixin PaginatedAsyncNotifierMixin<K>
// ignore: invalid_use_of_internal_member
on AsyncNotifierBase<SpotubePaginationResponseObject<K>> {
Future<SpotubePaginationResponseObject<K>> fetch(int offset, int limit);
Future<void> 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<K>,
...newState.items as List<K>,
]) as SpotubePaginationResponseObject<K>;
},
);
}
Future<List<K>> fetchAll() async {
if (state.value == null) return [];
if (!state.value!.hasMore) return state.value!.items as List<K>;
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<K>,
...newState.items as List<K>,
]) as SpotubePaginationResponseObject<K>;
});
}
return state.value!.items as List<K>;
}
}
abstract class PaginatedAsyncNotifier<K>
extends AsyncNotifier<SpotubePaginationResponseObject<K>>
with PaginatedAsyncNotifierMixin<K>, MetadataPluginMixin<K> {}
abstract class AutoDisposePaginatedAsyncNotifier<K>
extends AutoDisposeAsyncNotifier<SpotubePaginationResponseObject<K>>
with PaginatedAsyncNotifierMixin<K>, MetadataPluginMixin<K> {}

View File

@ -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";
}
}

View File

@ -1,15 +1,127 @@
import 'package:hetu_script/hetu_script.dart'; import 'package:hetu_script/hetu_script.dart';
import 'package:hetu_script/values.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
class MetadataPluginUserEndpoint { class MetadataPluginUserEndpoint {
final Hetu hetu; final Hetu hetu;
MetadataPluginUserEndpoint(this.hetu); MetadataPluginUserEndpoint(this.hetu);
HTInstance get hetuMetadataUser =>
(hetu.fetch("metadataPlugin") as HTInstance).memberGet("user")
as HTInstance;
Future<SpotubeUserObject> me() async { Future<SpotubeUserObject> me() async {
final raw = await hetu.eval("metadataPlugin.user.me()") as Map; final raw = await hetuMetadataUser.invoke("me") as Map;
return SpotubeUserObject.fromJson( return SpotubeUserObject.fromJson(
raw.cast<String, dynamic>(), raw.cast<String, dynamic>(),
); );
} }
Future<SpotubePaginationResponseObject<SpotubeFullTrackObject>> 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<String, dynamic>(),
(Map json) =>
SpotubeFullTrackObject.fromJson(json.cast<String, dynamic>()),
);
}
Future<SpotubePaginationResponseObject<SpotubeSimplePlaylistObject>>
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<String, dynamic>(),
(Map json) =>
SpotubeSimplePlaylistObject.fromJson(json.cast<String, dynamic>()),
);
}
Future<SpotubePaginationResponseObject<SpotubeSimpleAlbumObject>>
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<String, dynamic>(),
(Map json) =>
SpotubeSimpleAlbumObject.fromJson(json.cast<String, dynamic>()),
);
}
Future<SpotubePaginationResponseObject<SpotubeFullArtistObject>>
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<String, dynamic>(),
(Map json) =>
SpotubeFullArtistObject.fromJson(json.cast<String, dynamic>()),
);
}
Future<bool> isSavedPlaylist(String playlistId) async {
return await hetuMetadataUser.invoke(
"isSavedPlaylist",
positionalArgs: [playlistId],
) as bool;
}
Future<List<bool>> isSavedTracks(List<String> ids) async {
return await hetuMetadataUser.invoke(
"isSavedTracks",
positionalArgs: [ids],
) as List<bool>;
}
Future<List<bool>> isSavedAlbums(List<String> ids) async {
return await hetuMetadataUser.invoke(
"isSavedAlbums",
positionalArgs: [ids],
) as List<bool>;
}
Future<List<bool>> isSavedArtists(List<String> ids) async {
return await hetuMetadataUser.invoke(
"isSavedArtists",
positionalArgs: [ids],
) as List<bool>;
}
} }