diff --git a/Video.js b/Video.js index ab88629c04..646f23fa1f 100644 --- a/Video.js +++ b/Video.js @@ -34,6 +34,12 @@ export default class Video extends Component { }; } + static export = async (url) => { + return await NativeModules.VideoManager.export( + url, + ); + }; + setNativeProps(nativeProps) { this._root.setNativeProps(nativeProps); } diff --git a/android-exoplayer/build.gradle b/android-exoplayer/build.gradle index 502fa59947..181c327336 100644 --- a/android-exoplayer/build.gradle +++ b/android-exoplayer/build.gradle @@ -23,7 +23,7 @@ android { dependencies { implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" - implementation('com.google.android.exoplayer:exoplayer:2.10.5') { + api('com.google.android.exoplayer:exoplayer:2.10.5') { exclude group: 'com.android.support' } diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java index 956e648540..7fe154ca37 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoPlayerCache.java @@ -1,39 +1,49 @@ package com.brentvatne.exoplayer; -import android.content.Context; import android.net.Uri; import android.util.Log; +import android.util.Pair; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; -import com.google.android.exoplayer2.upstream.cache.Cache; -import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import com.google.android.exoplayer2.upstream.cache.CacheUtil; -import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; -import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DataSourceInputStream; - -import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import com.google.android.exoplayer2.upstream.cache.CacheUtil; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import java.io.IOException; +import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; -import java.io.OutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URL; public class ExoPlayerCache extends ReactContextBaseJavaModule { private static SimpleCache instance = null; private static final String CACHE_KEY_PREFIX = "exoPlayerCacheKeyPrefix"; + private String TMP_EXPORT_PATH; public ExoPlayerCache(ReactApplicationContext reactContext) { super(reactContext); + + TMP_EXPORT_PATH = getReactApplicationContext().getCacheDir().toString() + "/video-tmp"; + File exportPath = new File(TMP_EXPORT_PATH); + + // Clear the temporary export files on launch to make sure this doesn't grow infinitely. + if (exportPath.exists()) { + for (File child: exportPath.listFiles()) { + child.delete(); + } + } } @Override @@ -50,42 +60,43 @@ public void exportVideo(final String url, final Promise promise) { public void run() { Log.d(getName(), "Exporting..."); Log.d(getName(), url); + final Uri uri = Uri.parse(url); + + final SimpleCache downloadCache = VideoCache.getInstance().getSimpleCache(); + final DataSource dataSource = createDataSource(downloadCache); final DataSpec dataSpec = new DataSpec(uri, 0, C.LENGTH_UNSET, null); - final SimpleCache downloadCache = ExoPlayerCache.getInstance(getReactApplicationContext()); - CacheKeyFactory cacheKeyFactory = ds -> CACHE_KEY_PREFIX + "." + CacheUtil.generateKey(ds.uri);; - + File targetFile = new File(TMP_EXPORT_PATH, uri.getLastPathSegment()); + + // Create export dir if not exists. + targetFile.getParentFile().mkdirs(); + + + // https://github.com/google/ExoPlayer/issues/5569 try { - CacheUtil.getCached( - dataSpec, + CacheUtil.getCached( + dataSpec, downloadCache, - cacheKeyFactory + new ExoplayerCacheKeyFactory() ); DataSourceInputStream inputStream = new DataSourceInputStream(createDataSource(downloadCache), dataSpec); + BufferedOutputStream outStream = new BufferedOutputStream(new FileOutputStream(targetFile), 64 * 1024); - File targetFile = new File(ExoPlayerCache.getCacheDir(getReactApplicationContext()) + "/" + uri.getLastPathSegment()); - OutputStream outStream = new FileOutputStream(targetFile); - - byte[] buffer = new byte[8 * 1024]; - int bytesRead; try { - while ((bytesRead = inputStream.read(buffer)) != -1) { - outStream.write(buffer, 0, bytesRead); - // TODO Add onProgress() callback here + byte[] data = new byte[64 * 1024]; + int bytesRead; + while ((bytesRead = inputStream.read(data)) != C.RESULT_END_OF_INPUT) { + outStream.write(data, 0, bytesRead); } } catch (IOException e) { - // TODO this exception should not be thrown - Log.d(getName(), "Read error"); + Log.d(getName(), "Write error"); e.printStackTrace(); + } finally { + dataSource.close(); + outStream.close(); } - CacheUtil.getCached( - dataSpec, - downloadCache, - cacheKeyFactory - ); - Log.d(getName(), "Export succeeded"); Log.d(getName(), targetFile.getPath()); @@ -100,23 +111,11 @@ public void run() { exportThread.start(); } - public static SimpleCache getInstance(Context context) { - if(instance == null) { - instance = new SimpleCache(new File(ExoPlayerCache.getCacheDir(context)), new NoOpCacheEvictor()); - } - return instance; - } - - private static String getCacheDir(Context context) { - return context.getCacheDir().toString() + "/video"; - } - - private DataSource createDataSource(Cache cache) { + private CacheDataSource createDataSource(Cache cache) { return new CacheDataSourceFactory(cache, DataSourceUtil.getDefaultDataSourceFactory( getReactApplicationContext(), null, null )).createDataSource(); } - } diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoplayerCacheKeyFactory.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoplayerCacheKeyFactory.java new file mode 100644 index 0000000000..2d1603d79f --- /dev/null +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ExoplayerCacheKeyFactory.java @@ -0,0 +1,19 @@ +package com.brentvatne_exp.exoplayer; + +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; + +public class ExoplayerCacheKeyFactory implements CacheKeyFactory { + @Override + public String buildCacheKey(DataSpec dataSpec) { + String uri = dataSpec.uri.toString(); + + // Strip query parameters for cache key since this breaks lookup. + int queryIndex = uri.indexOf("?"); + if (queryIndex != -1) { + uri = uri.substring(0, queryIndex); + } + + return uri; + } +} diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 502b7bd9bd..9486edfdd7 100644 --- a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -43,7 +43,6 @@ import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; -import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -62,7 +61,9 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultAllocator; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.upstream.cache.Cache; +import com.google.android.exoplayer2.upstream.FileDataSourceFactory; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory; import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.util.Util; @@ -103,6 +104,8 @@ class ReactExoplayerView extends FrameLayout implements private ExoPlayerView exoPlayerView; private SimpleCache downloadCache; + private ExoplayerCacheKeyFactory cacheKeyFactory; + private CacheDataSourceFactory cacheDataSourceFactory; private DataSource.Factory mediaDataSourceFactory; private SimpleExoPlayer player; private DefaultTrackSelector trackSelector; @@ -197,7 +200,13 @@ public void setId(int id) { private void createViews() { clearResumePosition(); mediaDataSourceFactory = buildDataSourceFactory(true); - downloadCache = ExoPlayerCache.getInstance(getContext()); + downloadCache = VideoCache.getInstance().getSimpleCache(); + cacheKeyFactory = new ExoplayerCacheKeyFactory(); + cacheDataSourceFactory = new CacheDataSourceFactory( + downloadCache, mediaDataSourceFactory, new FileDataSourceFactory(), + new CacheDataSinkFactory(downloadCache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), + 0, null, cacheKeyFactory); + if (CookieHandler.getDefault() != DEFAULT_COOKIE_MANAGER) { CookieHandler.setDefault(DEFAULT_COOKIE_MANAGER); @@ -434,7 +443,7 @@ private MediaSource buildMediaSource(Uri uri, String overrideExtension) { config.buildLoadErrorHandlingPolicy(minLoadRetryCount) ).createMediaSource(uri); case C.TYPE_OTHER: - return new ExtractorMediaSource.Factory(new CacheDataSourceFactory(downloadCache, mediaDataSourceFactory)) + return new ExtractorMediaSource.Factory(cacheDataSourceFactory) .createMediaSource(uri); default: { throw new IllegalStateException("Unsupported type: " + type); diff --git a/android-exoplayer/src/main/java/com/brentvatne/exoplayer/VideoCache.java b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/VideoCache.java new file mode 100644 index 0000000000..e7bce667ae --- /dev/null +++ b/android-exoplayer/src/main/java/com/brentvatne/exoplayer/VideoCache.java @@ -0,0 +1,52 @@ +package com.brentvatne.exoplayer; + +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +public class VideoCache { + + private static volatile VideoCache instance; + private SimpleCache cache; + + private VideoCache() { + if (instance != null) { + throw new RuntimeException("Use getInstance()"); + } + } + + public void setSimpleCache(SimpleCache cache) { + this.cache = cache; + } + + public SimpleCache getSimpleCache() { + if (this.cache == null) { + throw new RuntimeException("Tried to access video cache but no cache is set"); + } + + return this.cache; + } + + public boolean hasSimpleCache() { + return this.cache != null; + } + + public void resetSimpleCache() { + if (hasSimpleCache()) { + this.cache.release(); + this.cache = null; + } else { + throw new RuntimeException("Resetting video cache but no cache is set!"); + } + + return; + } + + public static VideoCache getInstance() { + if (instance == null) { + synchronized (VideoCache.class) { + if (instance == null) instance = new VideoCache(); + } + } + + return instance; + } +} diff --git a/ios/RCTVideo.h b/ios/RCTVideo.h index 6cc8bc9069..edebee275c 100644 --- a/ios/RCTVideo.h +++ b/ios/RCTVideo.h @@ -46,5 +46,6 @@ - (AVPlayerViewController*)createPlayerViewController:(AVPlayer*)player withPlayerItem:(AVPlayerItem*)playerItem; - (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; ++ (void)export:(NSString *)url resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @end diff --git a/ios/RCTVideo.m b/ios/RCTVideo.m index a4e20281b2..e97906ca93 100644 --- a/ios/RCTVideo.m +++ b/ios/RCTVideo.m @@ -591,6 +591,7 @@ - (void)dvAssetLoaderDelegate:(DVAssetLoaderDelegate *)loaderDelegate didLoadData:(NSData *)data forURL:(NSURL *)url { DebugLog(@"dvAssetLoaderDelegate: url '%@'", [url absoluteString]); + [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { DebugLog(@"Cache data stored successfully 🎉"); }]; diff --git a/ios/RCTVideo.xcodeproj/project.pbxproj b/ios/RCTVideo.xcodeproj/project.pbxproj index 3d1f80e5ac..a9de74f030 100644 --- a/ios/RCTVideo.xcodeproj/project.pbxproj +++ b/ios/RCTVideo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C7522D2C2452D73B00027383 /* RCTVideoExport.m in Sources */ = {isa = PBXBuildFile; fileRef = C7522D2A2452D73B00027383 /* RCTVideoExport.m */; }; C7EF13182180C23D00E42B96 /* RCTVideoManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C7EF130D2180C23D00E42B96 /* RCTVideoManager.m */; }; C7EF13192180C23D00E42B96 /* RCTVideoPlayerViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C7EF130E2180C23D00E42B96 /* RCTVideoPlayerViewController.m */; }; C7EF131A2180C23D00E42B96 /* RCTVideoCache.m in Sources */ = {isa = PBXBuildFile; fileRef = C7EF13122180C23D00E42B96 /* RCTVideoCache.m */; }; @@ -38,6 +39,8 @@ /* Begin PBXFileReference section */ 134814201AA4EA6300B7C361 /* libRCTVideo.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTVideo.a; sourceTree = BUILT_PRODUCTS_DIR; }; 641E28441F0EEC8500443AF6 /* libRCTVideo.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTVideo.a; sourceTree = BUILT_PRODUCTS_DIR; }; + C7522D2A2452D73B00027383 /* RCTVideoExport.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTVideoExport.m; sourceTree = ""; }; + C7522D2B2452D73B00027383 /* RCTVideoExport.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTVideoExport.h; sourceTree = ""; }; C7EF130D2180C23D00E42B96 /* RCTVideoManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTVideoManager.m; sourceTree = ""; }; C7EF130E2180C23D00E42B96 /* RCTVideoPlayerViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTVideoPlayerViewController.m; sourceTree = ""; }; C7EF130F2180C23D00E42B96 /* RCTVideo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTVideo.h; sourceTree = ""; }; @@ -87,6 +90,8 @@ 58B511D21A9E6C8500147676 = { isa = PBXGroup; children = ( + C7522D2B2452D73B00027383 /* RCTVideoExport.h */, + C7522D2A2452D73B00027383 /* RCTVideoExport.m */, C7EF130F2180C23D00E42B96 /* RCTVideo.h */, C7EF13152180C23D00E42B96 /* RCTVideo.m */, C7EF13102180C23D00E42B96 /* RCTVideoCache.h */, @@ -164,6 +169,7 @@ developmentRegion = English; hasScannedForEncodings = 0; knownRegions = ( + English, en, ); mainGroup = 58B511D21A9E6C8500147676; @@ -187,6 +193,7 @@ C7EF13192180C23D00E42B96 /* RCTVideoPlayerViewController.m in Sources */, C7EF131B2180C23D00E42B96 /* UIView+FindUIViewController.m in Sources */, C7EF131A2180C23D00E42B96 /* RCTVideoCache.m in Sources */, + C7522D2C2452D73B00027383 /* RCTVideoExport.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RCTVideoCache.h b/ios/RCTVideoCache.h index bad999ff08..604f0fc061 100644 --- a/ios/RCTVideoCache.h +++ b/ios/RCTVideoCache.h @@ -23,8 +23,6 @@ typedef NS_ENUM(NSUInteger, RCTVideoCacheStatus) { } @property(nonatomic, strong) SPTPersistentCache * _Nullable videoCache; -@property(nonatomic, strong) NSString * cachePath; -@property(nonatomic, strong) NSString * cacheIdentifier; @property(nonatomic, strong) NSString * temporaryCachePath; + (RCTVideoCache *)sharedInstance; @@ -34,5 +32,6 @@ typedef NS_ENUM(NSUInteger, RCTVideoCacheStatus) { - (AVURLAsset *)getItemFromTemporaryStorage:(NSString *)key; - (BOOL)saveDataToTemporaryStorage:(NSData *)data key:(NSString *)key; - (void) createTemporaryPath; +- (void) setVideoCache:(SPTPersistentCache *)videoCache; @end diff --git a/ios/RCTVideoCache.m b/ios/RCTVideoCache.m index 1a2b83a53b..c71f28a406 100644 --- a/ios/RCTVideoCache.m +++ b/ios/RCTVideoCache.m @@ -3,8 +3,6 @@ @implementation RCTVideoCache @synthesize videoCache; -@synthesize cachePath; -@synthesize cacheIdentifier; @synthesize temporaryCachePath; + (RCTVideoCache *)sharedInstance { @@ -18,28 +16,34 @@ + (RCTVideoCache *)sharedInstance { - (id)init { if (self = [super init]) { - self.cacheIdentifier = @"rct.video.cache"; - self.temporaryCachePath = [NSTemporaryDirectory() stringByAppendingPathComponent:self.cacheIdentifier]; - self.cachePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject stringByAppendingPathComponent:self.cacheIdentifier]; - SPTPersistentCacheOptions *options = [SPTPersistentCacheOptions new]; - options.cachePath = self.cachePath; - options.cacheIdentifier = self.cacheIdentifier; - options.defaultExpirationPeriod = 60 * 60 * 24 * 30; - options.garbageCollectionInterval = (NSUInteger)(1.5 * SPTPersistentCacheDefaultGCIntervalSec); - options.sizeConstraintBytes = 1024 * 1024 * 100; - options.useDirectorySeparation = NO; -#ifdef DEBUG - options.debugOutput = ^(NSString *string) { - NSLog(@"Video Cache: %@", string); - }; -#endif + self.temporaryCachePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"video-tmp"]; + + // The caching lbirary used by rn-video unfortunately forces creation of a temporary file. + // Since we don't know when we're done with it we don't know when to delete it. + // First possibility where we know it: app launch -> no video played or exported. + // So we clear the video-tmp folder here if it exists. + [self clearTemporaryIfExists]; [self createTemporaryPath]; - self.videoCache = [[SPTPersistentCache alloc] initWithOptions:options]; - [self.videoCache scheduleGarbageCollector]; } return self; } +- (void) clearTemporaryIfExists { + BOOL isDir; + BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:self.temporaryCachePath isDirectory:&isDir]; + + if (!exists) { + return; + } + + NSError *error = nil; + [[NSFileManager defaultManager] removeItemAtPath:self.temporaryCachePath error:&error]; + + if (error) { + NSLog(@"Error clearing tmp-video cache! %@", error); + } +} + - (void) createTemporaryPath { NSError *error = nil; BOOL success = [[NSFileManager defaultManager] createDirectoryAtPath:self.temporaryCachePath @@ -60,6 +64,8 @@ - (void)storeItem:(NSData *)data forUri:(NSString *)uri withCallback:(void(^)(BO handler(NO); return; } + + [self saveDataToTemporaryStorage:data key:key]; [self.videoCache storeData:data forKey:key locked:NO withCallback:^(SPTPersistentCacheResponse * _Nonnull response) { if (response.error) { @@ -83,6 +89,7 @@ - (AVURLAsset *)getItemFromTemporaryStorage:(NSString *)key { } NSURL *assetUrl = [[NSURL alloc] initFileURLWithPath:temporaryFilePath]; AVURLAsset *asset = [AVURLAsset URLAssetWithURL:assetUrl options:nil]; + return asset; } @@ -93,42 +100,40 @@ - (BOOL)saveDataToTemporaryStorage:(NSData *)data key:(NSString *)key { } - (NSString *)generateCacheKeyForUri:(NSString *)uri { - NSString *uriWithoutQueryParams = uri; - - // parse file extension - if ([uri rangeOfString:@"?"].location != NSNotFound) { - NSArray * components = [uri componentsSeparatedByString:@"?"]; - uriWithoutQueryParams = [components objectAtIndex:0]; - } - - NSString * pathExtension = [uriWithoutQueryParams pathExtension]; - NSArray * supportedExtensions = @[@"m4v", @"mp4", @"mov"]; - if ([pathExtension isEqualToString:@""]) { - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: NSLocalizedString(@"Missing file extension.", nil), - NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Missing file extension.", nil), - NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Missing file extension.", nil) - }; - NSError *error = [NSError errorWithDomain:@"RCTVideoCache" - code:RCTVideoCacheStatusMissingFileExtension userInfo:userInfo]; - @throw error; - } else if (![supportedExtensions containsObject:pathExtension]) { - // Notably, we don't currently support m3u8 (HLS playlists) - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: NSLocalizedString(@"Unsupported file extension.", nil), - NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Unsupported file extension.", nil), - NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Unsupported file extension.", nil) - }; - NSError *error = [NSError errorWithDomain:@"RCTVideoCache" - code:RCTVideoCacheStatusUnsupportedFileExtension userInfo:userInfo]; - @throw error; - } - return [[self generateHashForUrl:uri] stringByAppendingPathExtension:pathExtension]; + // Hash is different if we add query params -> Causes redownload for sharing, so we strip query from the url. + NSURLComponents *components = [[NSURLComponents alloc] initWithString:uri]; + [components setQuery:nil]; + + NSString *uriWithoutQueryParams = [components string]; + NSString * pathExtension = [uriWithoutQueryParams pathExtension]; + NSArray * supportedExtensions = @[@"m4v", @"mp4", @"mov"]; + if ([pathExtension isEqualToString:@""]) { + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: NSLocalizedString(@"Missing file extension.", nil), + NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Missing file extension.", nil), + NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Missing file extension.", nil) + }; + NSError *error = [NSError errorWithDomain:@"RCTVideoCache" + code:RCTVideoCacheStatusMissingFileExtension userInfo:userInfo]; + @throw error; + } else if (![supportedExtensions containsObject:pathExtension]) { + // Notably, we don't currently support m3u8 (HLS playlists) + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: NSLocalizedString(@"Unsupported file extension.", nil), + NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Unsupported file extension.", nil), + NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Unsupported file extension.", nil) + }; + NSError *error = [NSError errorWithDomain:@"RCTVideoCache" + code:RCTVideoCacheStatusUnsupportedFileExtension userInfo:userInfo]; + @throw error; + } + return [[self generateHashForUrl:uriWithoutQueryParams] stringByAppendingPathExtension:pathExtension]; } - (void)getItemForUri:(NSString *)uri withCallback:(void(^)(RCTVideoCacheStatus, AVAsset * _Nullable)) handler { @try { NSString *key = [self generateCacheKeyForUri:uri]; + AVURLAsset * temporaryAsset = [self getItemFromTemporaryStorage:key]; if (temporaryAsset != nil) { handler(RCTVideoCacheStatusAvailable, temporaryAsset); @@ -158,17 +163,17 @@ - (void)getItemForUri:(NSString *)uri withCallback:(void(^)(RCTVideoCacheStatus, } - (NSString *)generateHashForUrl:(NSString *)string { - const char *cStr = [string UTF8String]; - unsigned char result[CC_MD5_DIGEST_LENGTH]; - CC_MD5( cStr, (CC_LONG)strlen(cStr), result ); - - return [NSString stringWithFormat: - @"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X", - result[0], result[1], result[2], result[3], - result[4], result[5], result[6], result[7], - result[8], result[9], result[10], result[11], - result[12], result[13], result[14], result[15] - ]; + const char *cStr = [string UTF8String]; + unsigned char result[CC_MD5_DIGEST_LENGTH]; + CC_MD5( cStr, (CC_LONG)strlen(cStr), result ); + + return [NSString stringWithFormat: + @"%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X%02X", + result[0], result[1], result[2], result[3], + result[4], result[5], result[6], result[7], + result[8], result[9], result[10], result[11], + result[12], result[13], result[14], result[15] + ]; } @end diff --git a/ios/RCTVideoExport.h b/ios/RCTVideoExport.h new file mode 100644 index 0000000000..e79b56715f --- /dev/null +++ b/ios/RCTVideoExport.h @@ -0,0 +1,18 @@ +// +// RCTVideoExport.h +// react-native-video +// +#import +#import +#import +#import "RCTVideoCache.h" +#import "DVURLAsset.h" + +@interface RCTVideoExport : NSObject + ++ (RCTVideoExport *)sharedInstance; +- (void)export:(NSString *)uri resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; + +@end + + diff --git a/ios/RCTVideoExport.m b/ios/RCTVideoExport.m new file mode 100644 index 0000000000..e74550e7a0 --- /dev/null +++ b/ios/RCTVideoExport.m @@ -0,0 +1,110 @@ +// +// RCTVideoExport.m +// react-native-video +// +#import "RCTVideoExport.h" +#import +#include + +@implementation RCTVideoExport + ++ (RCTVideoCache *)sharedInstance { + static RCTVideoCache *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + + +- (void)export:(NSString *)uri resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + // Thread to prevent stuttering. + RCTVideoCache *videoCache = [RCTVideoCache sharedInstance]; + + [videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { + NSURL *url = [NSURL URLWithString:uri]; + + // If the asset is not in cache it is incomplete so we need to download it. + if (cachedAsset == nil) { + // Create a download session. + NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; + NSURLSessionDownloadTask *task = [session downloadTaskWithURL:url completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *) response; + + if (error != nil || httpResponse == nil) { + return reject(@"ERROR_COULD_NOT_DOWNLOAD_VIDEO", @"Could not download video.", error); + } + + if (httpResponse.statusCode != 200) { + return reject(@"ERROR_COULD_NOT_DOWNLOAD_VIDEO", @"Received status != 200.", error); + } + + // AVAsset only works if file has .mp4, downloaded file has .tmp so we move it to temporary directory. + NSString *tempFile = [[[NSUUID UUID] UUIDString] stringByAppendingString:@".mp4"]; + NSString *temporaryPath = [videoCache temporaryCachePath]; + NSURL *temporaryVideoPath = [NSURL fileURLWithPath:[temporaryPath stringByAppendingString:tempFile]]; + + NSError *err = nil; + NSFileManager *fileManager = [NSFileManager defaultManager]; + [fileManager moveItemAtURL:location toURL:temporaryVideoPath error:&err]; + + if (err != nil) { + return reject(@"ERROR_COULD_NOT_DOWNLOAD_VIDEO", @"Temporary file error.", err); + } + + // Now move the downloaded file to cache. + [videoCache storeItem:[NSData dataWithContentsOfURL:temporaryVideoPath] forUri:uri withCallback:^(BOOL success) { + NSLog(@"Downloaded video and stored to video cache!"); + + NSError *err = nil; + // Remove downloaded file, we don't care about the error here. + [fileManager removeItemAtURL:temporaryVideoPath error:&err]; + }]; + + // Now use the cached file for exporting. + [videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { + if (cachedAsset) { + return [self assetExport:[AVAsset assetWithURL:temporaryVideoPath] resolve:resolve reject:reject]; + } + + return reject(@"ERROR_COULD_NOT_DOWNLOAD_VIDEO", @"Cache error", nil); + }]; + }]; + + [task resume]; + return; + }; + + [self assetExport:cachedAsset resolve:resolve reject:reject]; + }]; +} + +- (void)assetExport:(AVAsset *)asset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + AVAssetExportSession *exportSession = [AVAssetExportSession exportSessionWithAsset:asset presetName:AVAssetExportPresetPassthrough]; + NSString *cachePath = [[RCTVideoCache sharedInstance] temporaryCachePath]; + + if (exportSession == nil) { + return reject(@"ERROR_COULD_NOT_CREATE_EXPORT_SESSION", @"Could not create export session", nil); + } + + NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingString:@".mp4"]; + NSString *outputPath = [cachePath stringByAppendingPathComponent:fileName]; + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + + exportSession.outputFileType = AVFileTypeMPEG4; + exportSession.outputURL = outputURL; + exportSession.shouldOptimizeForNetworkUse = true; + + [exportSession exportAsynchronouslyWithCompletionHandler:^{ + if ([exportSession status] == AVAssetExportSessionStatusFailed) { + return reject(@"ERROR_COULD_NOT_EXPORT_VIDEO", @"Could not export video", exportSession.error); + } else if ([exportSession status] == AVAssetExportSessionStatusCancelled) { + return reject(@"ERROR_EXPORT_SESSION_CANCELLED", @"Export session was cancelled", exportSession.error); + } else { + return resolve(@{@"uri": outputURL.absoluteString}); + } + }]; +} + +@end diff --git a/ios/RCTVideoManager.m b/ios/RCTVideoManager.m index d5b699fd94..4a952e4366 100644 --- a/ios/RCTVideoManager.m +++ b/ios/RCTVideoManager.m @@ -1,5 +1,6 @@ #import "RCTVideoManager.h" #import "RCTVideo.h" +#import "RCTVideoExport.h" #import #import #import @@ -81,9 +82,21 @@ - (dispatch_queue_t)methodQueue } }]; } +RCT_REMAP_METHOD(export, + exportURL:(NSString *)url + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){ + [[RCTVideoExport sharedInstance] export:url resolve:resolve reject:reject]; + }); +} RCT_EXPORT_VIEW_PROPERTY(onPictureInPictureStatusChanged, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onRestoreUserInterfaceForPictureInPictureStop, RCTDirectEventBlock); + + + - (NSDictionary *)constantsToExport { return @{