import 'dart:async'; import 'dart:convert'; import 'package:flutter_js/extensions/fetch.dart'; import 'package:flutter_js/extensions/xhr.dart'; import 'package:flutter_js/flutter_js.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/metadata/apis/localstorage.dart'; import 'package:spotube/services/metadata/apis/set_interval.dart'; import 'package:spotube/services/metadata/apis/totp.dart'; import 'package:spotube/services/metadata/apis/webview.dart'; const defaultMetadataLimit = "20"; class MetadataSignatureFlags { final bool requiresAuth; const MetadataSignatureFlags({ this.requiresAuth = false, }); factory MetadataSignatureFlags.fromJson(Map json) { return MetadataSignatureFlags( requiresAuth: json["requiresAuth"] ?? false, ); } } /// Signature for metadata and related methods that will return Spotube native /// objects e.g. SpotubeTrack, SpotubePlaylist, etc. class MetadataApiSignature { final JavascriptRuntime runtime; final PluginLocalStorageApi localStorageApi; final PluginWebViewApi webViewApi; final PluginTotpGenerator totpGenerator; final PluginSetIntervalApi setIntervalApi; late MetadataSignatureFlags _signatureFlags; final StreamController _authenticatedStreamController; Stream get authenticatedStream => _authenticatedStreamController.stream; MetadataSignatureFlags get signatureFlags => _signatureFlags; MetadataApiSignature._( this.runtime, this.localStorageApi, this.webViewApi, this.totpGenerator, this.setIntervalApi, ) : _authenticatedStreamController = StreamController.broadcast() { runtime.onMessage("authenticatedStatus", (args) { if (args[0] is Map && (args[0] as Map).containsKey("authenticated")) { final authenticated = args[0]["authenticated"] as bool; _authenticatedStreamController.add(authenticated); } }); } static Future init( String libraryCode, PluginConfiguration config, ) async { final runtime = getJavascriptRuntime(xhr: true).enableXhr(); runtime.enableHandlePromises(); await runtime.enableFetch(); Timer.periodic( const Duration(milliseconds: 100), (timer) { runtime.executePendingJob(); }, ); // Create all the PluginAPIs after library code is evaluated final localStorageApi = PluginLocalStorageApi( runtime: runtime, sharedPreferences: await SharedPreferences.getInstance(), pluginName: config.slug, ); final webViewApi = PluginWebViewApi(runtime: runtime); final totpGenerator = PluginTotpGenerator(runtime); final setIntervalApi = PluginSetIntervalApi(runtime); final metadataApi = MetadataApiSignature._( runtime, localStorageApi, webViewApi, totpGenerator, setIntervalApi, ); final res = runtime.evaluate( """ ;$libraryCode; const metadataApi = new MetadataApi(); """, ); metadataApi._signatureFlags = await metadataApi._getSignatureFlags(); if (res.isError) { AppLogger.reportError( "Error evaluating code: $libraryCode\n${res.rawResult}", ); } return metadataApi; } void dispose() { setIntervalApi.dispose(); webViewApi.dispose(); runtime.dispose(); } Future invoke(String method, [List? args]) async { final completer = Completer(); runtime.onMessage(method, (result) { if (completer.isCompleted) return; try { if (result is Map && result.containsKey("error")) { completer.completeError(result["error"]); } else { completer.complete(result is String ? jsonDecode(result) : result); } } catch (e, stack) { AppLogger.reportError( "[MetadataApiSignature][invoke] Error in $method: $e", stack, ); } }); final code = """ $method(...${args != null ? jsonEncode(args) : "[]"}) .then((res) => { try { sendMessage("$method", res ? JSON.stringify(res) : "[]"); } catch (e) { console.error("Failed to send message in $method.then: ", `\${e.toString()}\n\${e.stack.toString()}`); } }).catch((e) => { try { console.error("Error in $method: ", `\${e.toString()}\n\${e.stack.toString()}`); sendMessage("$method", JSON.stringify({error: `\${e.toString()}\n\${e.stack.toString()}`})); } catch (e) { console.error("Failed to send message in $method.catch: ", `\${e.toString()}\n\${e.stack.toString()}`); } }); """; final res = await runtime.evaluateAsync(code); if (res.isError) { AppLogger.reportError("Error evaluating code: $code\n${res.rawResult}"); completer.completeError("Error evaluating code: $code\n${res.rawResult}"); return completer.future; } return completer.future; } Future _getSignatureFlags() async { final res = await invoke("metadataApi.getSignatureFlags"); return MetadataSignatureFlags.fromJson(res); } // ----- Authentication ------ Future authenticate() async { await invoke("metadataApi.authenticate"); } Future isAuthenticated() async { final res = await invoke("metadataApi.isAuthenticated"); return res as bool; } Future logout() async { await invoke("metadataApi.logout"); } // ----- Track ------ Future getTrack(String id) async { final result = await invoke("metadataApi.getTrack", [id]); return SpotubeTrackObject.fromJson(result); } Future> listTracks({ List? ids, String limit = defaultMetadataLimit, String? cursor, }) async { final result = await invoke( "metadataApi.listTracks", [ ids, limit, cursor, ], ); return SpotubePaginationResponseObject.fromJson( result, SpotubeTrackObject.fromJson, ); } Future> listTracksByAlbum( String albumId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listTracksByAlbum", [albumId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeTrackObject.fromJson, ); } Future> listTopTracksByArtist( String artistId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listTopTracksByArtist", [artistId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeTrackObject.fromJson, ); } Future> listTracksByPlaylist( String playlistId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listTracksByPlaylist", [playlistId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeTrackObject.fromJson, ); } Future> listUserSavedTracks( String userId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listUserSavedTracks", [userId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeTrackObject.fromJson, ); } // ----- Album ------ Future getAlbum(String id) async { final res = await invoke("metadataApi.getAlbum", [id]); return SpotubeAlbumObject.fromJson(res); } Future> listAlbums({ List? ids, String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listAlbums", [ids, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeAlbumObject.fromJson, ); } Future> listAlbumsByArtist( String artistId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listAlbumsByArtist", [artistId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeAlbumObject.fromJson, ); } Future> listUserSavedAlbums( String userId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listUserSavedAlbums", [userId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeAlbumObject.fromJson, ); } // ----- Playlist ------ Future getPlaylist(String id) async { final res = await invoke("metadataApi.getPlaylist", [id]); return SpotubePlaylistObject.fromJson(res); } Future> listFeedPlaylists( String feedId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listFeedPlaylists", [feedId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubePlaylistObject.fromJson, ); } Future> listUserSavedPlaylists( String userId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listUserSavedPlaylists", [userId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubePlaylistObject.fromJson, ); } Future createPlaylist( String userId, String name, { String? description, bool? public, bool? collaborative, String? imageBase64, }) async { final res = await invoke( "metadataApi.createPlaylist", [ userId, name, description, public, collaborative, imageBase64, ], ); return SpotubePlaylistObject.fromJson(res); } Future updatePlaylist( String playlistId, { String? name, String? description, bool? public, bool? collaborative, String? imageBase64, }) async { await invoke( "metadataApi.updatePlaylist", [ playlistId, name, description, public, collaborative, imageBase64, ], ); } Future deletePlaylist(String userId, String playlistId) async { await unsavePlaylist(userId, playlistId); } Future addTracksToPlaylist( String playlistId, List trackIds, { int? position, }) async { await invoke( "metadataApi.addTracksToPlaylist", [ playlistId, trackIds, position, ], ); } Future removeTracksFromPlaylist( String playlistId, List trackIds, ) async { await invoke( "metadataApi.removeTracksFromPlaylist", [ playlistId, trackIds, ], ); } // ----- Artist ------ Future getArtist(String id) async { final res = await invoke("metadataApi.getArtist", [id]); return SpotubeArtistObject.fromJson(res); } Future> listArtists({ List? ids, String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listArtists", [ids, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeArtistObject.fromJson, ); } Future> listUserSavedArtists( String userId, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.listUserSavedArtists", [userId, limit, cursor], ); return SpotubePaginationResponseObject.fromJson( res, SpotubeArtistObject.fromJson, ); } // ----- Search ------ Future search( String query, { String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke( "metadataApi.search", [query, limit, cursor], ); return SpotubeSearchResponseObject.fromJson(res); } // ----- Feed ------ Future getFeed(String id) async { final res = await invoke("metadataApi.getFeed", [id]); return SpotubeFeedObject.fromJson(res); } Future> listFeeds({ String limit = defaultMetadataLimit, String? cursor, }) async { final res = await invoke("metadataApi.listFeeds", [limit, cursor]); return SpotubePaginationResponseObject.fromJson( res, SpotubeFeedObject.fromJson, ); } // ----- User ------ Future getMe() async { final res = await invoke("metadataApi.getMe"); return SpotubeUserObject.fromJson(res); } Future followArtist(String userId, String artistId) async { await invoke("metadataApi.followArtist", [userId, artistId]); } Future unfollowArtist(String userId, String artistId) async { await invoke("metadataApi.unfollowArtist", [userId, artistId]); } Future savePlaylist(String userId, String playlistId) async { await invoke("metadataApi.savePlaylist", [userId, playlistId]); } Future unsavePlaylist(String userId, String playlistId) async { await invoke("metadataApi.unsavePlaylist", [userId, playlistId]); } Future saveAlbum(String userId, String albumId) async { await invoke("metadataApi.saveAlbum", [userId, albumId]); } Future unsaveAlbum(String userId, String albumId) async { await invoke("metadataApi.unsaveAlbum", [userId, albumId]); } Future saveTrack(String userId, String trackId) async { await invoke("metadataApi.saveTrack", [userId, trackId]); } Future unsaveTrack(String userId, String trackId) async { await invoke("metadataApi.unsaveTrack", [userId, trackId]); } }