feat: add playlist endpoint and providers

This commit is contained in:
Kingkor Roy Tirtho 2025-06-14 20:53:18 +06:00
parent f8211cbcc7
commit a9ba2582fb
7 changed files with 369 additions and 80 deletions

View File

@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.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/provider/metadata_plugin/utils/paginated.dart';
import 'package:spotube/services/metadata/endpoints/error.dart'; import 'package:spotube/services/metadata/endpoints/error.dart';
@ -40,61 +41,63 @@ class FavoritePlaylistsNotifier
); );
} }
// Future<void> addFavorite(PlaylistSimple playlist) async { Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async {
// await update((state) async { await update((state) async {
// await spotify.invoke( (await metadataPlugin)!.playlist.save(playlist.id);
// (api) => api.playlists.followPlaylist(playlist.id!), return state.copyWith(
// ); items: [...state.items, playlist],
// return state.copyWith( ) as SpotubePaginationResponseObject<SpotubeSimplePlaylistObject>;
// items: [...state.items, playlist], });
// );
// });
// ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
// } }
// Future<void> removeFavorite(PlaylistSimple playlist) async { Future<void> removeFavorite(SpotubeSimplePlaylistObject playlist) async {
// await update((state) async { await update((state) async {
// await spotify.invoke( (await metadataPlugin)!.playlist.unsave(playlist.id);
// (api) => api.playlists.unfollowPlaylist(playlist.id!), return state.copyWith(
// ); items: state.items
// return state.copyWith( .where((e) => (e as SpotubeSimplePlaylistObject).id != playlist.id)
// items: state.items.where((e) => e.id != playlist.id).toList(), .toList() as List<SpotubeSimplePlaylistObject>,
// ); ) as SpotubePaginationResponseObject<SpotubeSimplePlaylistObject>;
// }); });
// ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
// } }
// Future<void> addTracks(String playlistId, List<String> trackIds) async { Future<void> delete(SpotubeSimplePlaylistObject playlist) async {
// if (state.value == null) return; 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<SpotubeSimplePlaylistObject>,
) as SpotubePaginationResponseObject<SpotubeSimplePlaylistObject>;
});
// final spotify = ref.read(spotifyProvider); ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
ref.invalidate(metadataPluginPlaylistTracksProvider(playlist.id));
}
// await spotify.invoke( Future<void> addTracks(String playlistId, List<String> trackIds) async {
// (api) => api.playlists.addTracks( if (state.value == null) return;
// trackIds.map((id) => 'spotify:track:$id').toList(),
// playlistId,
// ),
// );
// ref.invalidate(playlistTracksProvider(playlistId)); await (await metadataPlugin)!
// } .playlist
.addTracks(playlistId, trackIds: trackIds);
// Future<void> removeTracks(String playlistId, List<String> trackIds) async { ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId));
// if (state.value == null) return; }
// final spotify = ref.read(spotifyProvider); Future<void> removeTracks(String playlistId, List<String> trackIds) async {
if (state.value == null) return;
// await spotify.invoke( await (await metadataPlugin)!
// (api) => api.playlists.removeTracks( .playlist
// trackIds.map((id) => 'spotify:track:$id').toList(), .removeTracks(playlistId, trackIds: trackIds);
// playlistId,
// ),
// );
// ref.invalidate(playlistTracksProvider(playlistId)); ref.invalidate(metadataPluginPlaylistTracksProvider(playlistId));
// } }
} }
final metadataPluginSavedPlaylistsProvider = AsyncNotifierProvider< final metadataPluginSavedPlaylistsProvider = AsyncNotifierProvider<

View File

@ -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<SpotubeFullTrackObject>,
String>(
() => PlaylistTracksNotifier(),
);

View File

@ -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<K>
// ignore: invalid_use_of_internal_member
on AsyncNotifierBase<SpotubePaginationResponseObject<K>> {
Future<MetadataPlugin?> 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<T> extends AsyncData<T> {
const AsyncLoadingNext(super.value);
}

View File

@ -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<K, A>
extends FamilyAsyncNotifier<SpotubePaginationResponseObject<K>, A>
with MetadataPluginMixin<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 AutoDisposeFamilyPaginatedAsyncNotifier<K, A>
extends AutoDisposeFamilyAsyncNotifier<SpotubePaginationResponseObject<K>,
A> with MetadataPluginMixin<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>;
}
}

View File

@ -4,42 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:riverpod/src/async_notifier.dart'; import 'package:riverpod/src/async_notifier.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.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> mixin PaginatedAsyncNotifierMixin<K>
// ignore: invalid_use_of_internal_member // ignore: invalid_use_of_internal_member

View File

@ -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<SpotubeFullPlaylistObject> getPlaylist(String id) async {
final raw = await hetuMetadataPlaylist
.invoke("getPlaylist", positionalArgs: [id]) as Map;
return SpotubeFullPlaylistObject.fromJson(
raw.cast<String, dynamic>(),
);
}
Future<SpotubePaginationResponseObject<SpotubeFullTrackObject>> 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<String, dynamic>(),
(Map json) =>
SpotubeFullTrackObject.fromJson(json.cast<String, dynamic>()),
);
}
Future<SpotubeFullPlaylistObject?> 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<String, dynamic>(),
);
}
Future<void> 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<void> addTracks(
String playlistId, {
required List<String> trackIds,
int? position,
}) async {
await hetuMetadataPlaylist.invoke(
"addTracks",
positionalArgs: [playlistId],
namedArgs: {
"trackIds": trackIds,
"position": position,
}..removeWhere((key, value) => value == null),
);
}
Future<void> removeTracks(
String playlistId, {
required List<String> trackIds,
}) async {
await hetuMetadataPlaylist.invoke(
"removeTracks",
positionalArgs: [playlistId],
namedArgs: {
"trackIds": trackIds,
}..removeWhere((key, value) => value == null),
);
}
Future<void> save(String playlistId) async {
await hetuMetadataPlaylist.invoke(
"save",
positionalArgs: [playlistId],
);
}
Future<void> unsave(String playlistId) async {
await hetuMetadataPlaylist.invoke(
"unsave",
positionalArgs: [playlistId],
);
}
Future<void> deletePlaylist(String playlistId) async {
await hetuMetadataPlaylist.invoke(
"deletePlaylist",
positionalArgs: [playlistId],
);
}
}

View File

@ -12,6 +12,7 @@ import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/metadata/apis/localstorage.dart'; import 'package:spotube/services/metadata/apis/localstorage.dart';
import 'package:spotube/services/metadata/endpoints/auth.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'; import 'package:spotube/services/metadata/endpoints/user.dart';
const defaultMetadataLimit = "20"; const defaultMetadataLimit = "20";
@ -73,9 +74,11 @@ class MetadataPlugin {
late final MetadataAuthEndpoint auth; late final MetadataAuthEndpoint auth;
late final MetadataPluginUserEndpoint user; late final MetadataPluginUserEndpoint user;
late final MetadataPluginPlaylistEndpoint playlist;
MetadataPlugin._(this.hetu) { MetadataPlugin._(this.hetu) {
auth = MetadataAuthEndpoint(hetu); auth = MetadataAuthEndpoint(hetu);
playlist = MetadataPluginPlaylistEndpoint(hetu);
user = MetadataPluginUserEndpoint(hetu); user = MetadataPluginUserEndpoint(hetu);
} }
} }