diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a5681..a9897ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog All changes to this project will be documented in this file. +## [1.2.0] - 2024-04-03 +- Add support of progressive uploads + ## [1.1.0] - 2024-02-16 - Add support for RN new architecture: Turbo Native Modules - Add an API to set time out diff --git a/README.md b/README.md index c360dd5..75a49c7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ - [Getting started](#getting-started) - [Installation](#installation) - [Code sample](#code-sample) + - [Regular upload](#regular-upload) + - [Progressive upload](#progressive-upload) - [Android](#android) - [Permissions](#permissions) - [Notifications](#notifications) @@ -54,6 +56,8 @@ yarn add @api.video/react-native-video-uploader ### Code sample +#### Regular upload + ```js import ApiVideoUploader from '@api.video/react-native-video-uploader'; @@ -66,6 +70,27 @@ ApiVideoUploader.uploadWithUploadToken('YOUR_UPLOAD_TOKEN', 'path/to/my-video.mp }); ``` +#### Progressive upload + +For more details about progressive uploads, see the [progressive upload documentation](https://docs.api.video/vod/progressive-upload). + +```js +import ApiVideoUploader from '@api.video/react-native-video-uploader'; + +(async () => { + const uploadSession = ApiVideoUploader.createProgressiveUploadSession({token: 'YOUR_UPLOAD_TOKEN'}); + try { + await session.uploadPart("path/to/video.mp4.part1"); + await session.uploadPart("path/to/video.mp4.part2"); + // ... + const video = await session.uploadLastPart("path/to/video.mp4.partn"); + // ... + } catch(e: any) { + // Manages error here + } +})(); +``` + ### Android #### Permissions diff --git a/android/src/main/java/video/api/reactnative/uploader/UploaderModule.kt b/android/src/main/java/video/api/reactnative/uploader/UploaderModule.kt index 5c9db6a..3aecebf 100644 --- a/android/src/main/java/video/api/reactnative/uploader/UploaderModule.kt +++ b/android/src/main/java/video/api/reactnative/uploader/UploaderModule.kt @@ -88,10 +88,60 @@ class UploaderModule(reactContext: ReactApplicationContext) : } } + // Progressive upload + @ReactMethod + override fun createProgressiveUploadSession(sessionId: String, videoId: String) { + uploaderModuleImpl.createUploadProgressiveSession(sessionId, videoId) + } + + @ReactMethod + override fun createProgressiveUploadWithUploadTokenSession( + sessionId: String, + token: String, + videoId: String? + ) { + uploaderModuleImpl.createUploadWithUploadTokenProgressiveSession(sessionId, token, videoId) + } + + @ReactMethod + override fun uploadPart(sessionId: String, filePath: String, promise: Promise) { + uploadPart(sessionId, filePath, false, promise) + } + + @ReactMethod + override fun uploadLastPart(sessionId: String, filePath: String, promise: Promise) { + uploadPart(sessionId, filePath, true, promise) + } + + @ReactMethod + override fun disposeProgressiveUploadSession(sessionId: String) { + uploaderModuleImpl.disposeProgressiveUploadSession(sessionId) + } + + private fun uploadPart( + sessionId: String, + filePath: String, + isLastPart: Boolean, + promise: Promise + ) { + try { + uploaderModuleImpl.uploadPart(sessionId, filePath, isLastPart, { _ -> + }, { video -> + promise.resolve(video) + }, { + promise.reject(CancellationException("Upload was cancelled")) + }, { e -> + promise.reject(e) + }) + } catch (e: Exception) { + promise.reject(e) + } + } + companion object { const val NAME = "ApiVideoUploader" const val SDK_NAME = "reactnative-uploader" - const val SDK_VERSION = "1.1.0" + const val SDK_VERSION = "1.2.0" } } diff --git a/android/src/main/java/video/api/reactnative/uploader/UploaderPackage.java b/android/src/main/java/video/api/reactnative/uploader/UploaderPackage.java index 19e1763..28c7d74 100644 --- a/android/src/main/java/video/api/reactnative/uploader/UploaderPackage.java +++ b/android/src/main/java/video/api/reactnative/uploader/UploaderPackage.java @@ -1,5 +1,6 @@ package video.api.reactnative.uploader; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.react.TurboReactPackage; @@ -14,7 +15,7 @@ public class UploaderPackage extends TurboReactPackage { @Nullable @Override - public NativeModule getModule(String name, ReactApplicationContext reactContext) { + public NativeModule getModule(String name, @NonNull ReactApplicationContext reactContext) { if (name.equals(UploaderModule.NAME)) { return new UploaderModule(reactContext); } else { diff --git a/android/src/oldarch/video/api/reactnative/uploader/UploaderModule.kt b/android/src/oldarch/video/api/reactnative/uploader/UploaderModule.kt index f24d0ea..2e45f28 100644 --- a/android/src/oldarch/video/api/reactnative/uploader/UploaderModule.kt +++ b/android/src/oldarch/video/api/reactnative/uploader/UploaderModule.kt @@ -20,4 +20,14 @@ abstract class UploaderModuleSpec(reactContext: ReactApplicationContext) : abstract fun uploadWithUploadToken(token: String, filePath: String, videoId: String?, promise: Promise) abstract fun upload(videoId: String, filePath: String, promise: Promise) + + abstract fun createProgressiveUploadSession(sessionId: String, videoId: String) + + abstract fun createProgressiveUploadWithUploadTokenSession(sessionId: String, token: String, videoId: String?) + + abstract fun uploadPart(sessionId: String, filePath: String, promise: Promise) + + abstract fun uploadLastPart(sessionId: String, filePath: String, promise: Promise) + + abstract fun disposeProgressiveUploadSession(sessionId: String) } diff --git a/example/src/App.tsx b/example/src/App.tsx index bb09a1d..f583d30 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -7,6 +7,7 @@ import { SafeAreaView, ScrollView, StyleSheet, + Switch, Text, TextInput, View, @@ -22,6 +23,7 @@ import type { Video } from 'src/types'; export default function App() { const [videoFile, setVideoFile] = React.useState(null); const [uploading, setUploading] = React.useState(false); + const [isProgressive, setIsProgressive] = React.useState(false); const [uploadToken, setUploadToken] = React.useState(''); const [chunkSize, setChunkSize] = React.useState('20'); const [uploadResult, setUploadResult] = React.useState(null); @@ -58,7 +60,12 @@ export default function App() { }, []); const onUploadButtonPress = React.useCallback( - (token: string, uri: string | null, chunkSize: string) => { + ( + token: string, + uri: string | null, + chunkSize: string, + isProgressive: boolean + ) => { const chunkSizeInt = parseInt(chunkSize); if (!uri) { @@ -80,19 +87,46 @@ export default function App() { const resolveUri = (u: string): Promise => { return Platform.OS === 'android' ? ReactNativeBlobUtil.fs.stat(u).then((stat) => stat.path) - : new Promise((resolve, _) => resolve(u)); + : new Promise((resolve, _) => resolve(u.replace('file://', ''))); }; - resolveUri(uri).then((u) => { - ApiVideoUploader.uploadWithUploadToken(token, u) - .then((value: Video) => { - setUploadResult(value); - setUploading(false); - }) - .catch((e: any) => { - Alert.alert('Upload failed', e?.message || JSON.stringify(e)); - setUploading(false); + resolveUri(uri).then(async (u) => { + if (isProgressive) { + const size = (await ReactNativeBlobUtil.fs.stat(u)).size; + + const session = ApiVideoUploader.createProgressiveUploadSession({ + token, }); + const chunkSizeBytes = 1024 * 1024 * chunkSizeInt; + let start = 0; + for ( + let i = 0; + start <= size - chunkSizeBytes; + start += chunkSizeBytes, i++ + ) { + await ReactNativeBlobUtil.fs.slice( + u, + `${u}.part${i}`, + start, + start + chunkSizeBytes + ); + await session.uploadPart(`${u}.part${i}`); + } + await ReactNativeBlobUtil.fs.slice(u, `${u}.lastpart`, start, size); + const value = await session.uploadLastPart(u + '.lastpart'); + setUploadResult(value); + setUploading(false); + } else { + ApiVideoUploader.uploadWithUploadToken(token, u) + .then((value: Video) => { + setUploadResult(value); + setUploading(false); + }) + .catch((e: any) => { + Alert.alert('Upload failed', e?.message || JSON.stringify(e)); + setUploading(false); + }); + } }); }, [] @@ -165,11 +199,35 @@ export default function App() { keyboardType="numeric" /> + + Progressive upload + setIsProgressive(!isProgressive)} + value={isProgressive} + /> + And finally... upload! - onUploadButtonPress(uploadToken, videoFile, chunkSize) + onUploadButtonPress( + uploadToken, + videoFile, + chunkSize, + isProgressive + ) } > {uploading ? 'UPLOADING...' : 'UPLOAD'} diff --git a/ios/RNUploader.mm b/ios/RNUploader.mm index 65acd54..17d429c 100644 --- a/ios/RNUploader.mm +++ b/ios/RNUploader.mm @@ -32,16 +32,30 @@ @interface RCT_EXTERN_REMAP_MODULE(ApiVideoUploader, RNUploader, NSObject)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { diff --git a/ios/RNUploader.swift b/ios/RNUploader.swift index 20b8d30..c672658 100644 --- a/ios/RNUploader.swift +++ b/ios/RNUploader.swift @@ -6,7 +6,7 @@ class RNUploader: NSObject { override init() { do { - try uploadModule.setSdkName(name: "reactnative-uploader", version: "1.1.0") + try uploadModule.setSdkName(name: "reactnative-uploader", version: "1.2.0") } catch { fatalError("Failed to set SDK name: \(error)") } @@ -75,4 +75,45 @@ class RNUploader: NSObject { reject("upload_failed", error.localizedDescription, error) } } + + @objc(createUploadProgressiveSession::) + func createUploadProgressiveSession(sessionId: String, videoId: String) { + do { + try uploadModule.createUploadProgressiveSession(sessionId: sessionId, videoId: videoId) + } catch { + fatalError("Failed to create progressive upload session: \(error)") + } + } + + @objc(createProgressiveUploadWithUploadTokenSession:::) + func createProgressiveUploadWithUploadTokenSession(sessionId: String, token: String, videoId: String?) { + do { + try uploadModule.createProgressiveUploadWithUploadTokenSession(sessionId: sessionId, token: token, videoId: videoId) + } catch { + fatalError("Failed to create progressive upload with upload token session: \(error)") + } + } + + @objc(uploadPart::withResolver:withRejecter:) + func uploadPart(sessionId: String, filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + uploadModule.uploadPart(sessionId: sessionId, filePath: filePath, onProgress: { _ in }, onSuccess: { video in + resolve(video) + }, onError: { error in + reject("upload_part_failed", error.localizedDescription, error) + }) + } + + @objc(uploadLastPart::withResolver:withRejecter:) + func uploadLastPart(sessionId: String, filePath: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + uploadModule.uploadLastPart(sessionId: sessionId, filePath: filePath, onProgress: { _ in }, onSuccess: { video in + resolve(video) + }, onError: { error in + reject("upload_last_part_failed", error.localizedDescription, error) + }) + } + + @objc(disposeProgressiveUploadSession:) + func disposeProgressiveUploadSession(sessionId: String) { + uploadModule.disposeProgressiveUploadSession(sessionId) + } } diff --git a/ios/StringExtensions.swift b/ios/StringExtensions.swift index 4365123..a17725d 100644 --- a/ios/StringExtensions.swift +++ b/ios/StringExtensions.swift @@ -1,10 +1,10 @@ extension String { func deletePrefix(_ prefix: String) -> String { - guard self.hasPrefix(prefix) else { return self } - return String(self.dropFirst(prefix.count)) + guard hasPrefix(prefix) else { return self } + return String(dropFirst(prefix.count)) } - + var url: URL { - return URL(fileURLWithPath: self.deletePrefix("file://")) + return URL(fileURLWithPath: deletePrefix("file://")) } } diff --git a/ios/UploaderModule.swift b/ios/UploaderModule.swift index 3ca2567..1fa1487 100644 --- a/ios/UploaderModule.swift +++ b/ios/UploaderModule.swift @@ -135,4 +135,3 @@ public class UploaderModule: NSObject { public enum UploaderError: Error { case invalidParameter(message: String) } - diff --git a/package.json b/package.json index 7c15f74..638434a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@api.video/react-native-video-uploader", - "version": "1.1.0", + "version": "1.2.0", "description": "The official React Native video uploader for api.video", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/NativeApiVideoUploader.ts b/src/NativeApiVideoUploader.ts index b701ac2..9a8c7b7 100644 --- a/src/NativeApiVideoUploader.ts +++ b/src/NativeApiVideoUploader.ts @@ -16,6 +16,27 @@ export interface Spec extends TurboModule { videoId?: string ) => Promise; upload: (videoId: string, filepath: string) => Promise; + + // Progressive upload + /** + * Create a new session for progressive upload. + * @param sessionId - The session id. Must be unique. + */ + createProgressiveUploadSession: (sessionId: string, videoId: string) => void; + /** + * Create a new session for progressive upload with upload token. + * @param sessionId - The session id. Must be unique. + */ + createProgressiveUploadWithUploadTokenSession: ( + sessionId: string, + token: string, + videoId?: string + ) => void; + + uploadPart: (sessionId: string, filepath: string) => Promise; + uploadLastPart: (sessionId: string, filepath: string) => Promise; + + disposeProgressiveUploadSession: (sessionId: string) => void; } export default TurboModuleRegistry.getEnforcing('ApiVideoUploader'); diff --git a/src/index.tsx b/src/index.tsx index e3b9bd5..dfe58f4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,28 @@ const ApiVideoUploader = ApiVideoUploaderModule } ); +type ProgressiveUploadParams = { videoId: string }; +type ProgressiveUploadWithUploadTokenParams = { + token: string; + videoId?: string; +}; + +function isProgressiveUploadWithUploadTokenParams( + params: ProgressiveUploadWithUploadTokenParams | ProgressiveUploadParams +): params is ProgressiveUploadWithUploadTokenParams { + return ( + (params as ProgressiveUploadWithUploadTokenParams).token !== 'undefined' + ); +} + +function sanitizeVideo(videoJson: string): Video { + const video = JSON.parse(videoJson); + return { + ...video, + _public: video.public, + }; +} + export default { setApplicationName: (name: string, version: string): void => { ApiVideoUploader.setApplicationName(name, version); @@ -54,23 +76,55 @@ export default { token, filepath, videoId - ).then((value: string) => { - const json = JSON.parse(value); - - return { - ...json, - _public: json.public, - } as Video; - }); + ).then((value: string) => sanitizeVideo(value)); }, upload: (videoId: string, filepath: string): Promise