diff --git a/docs/caching.md b/docs/caching.md index 143218e1ed..c2b8df97ab 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -9,11 +9,13 @@ The cache is backed by [SPTPersistentCache](https://github.com/spotify/SPTPersis # How Does It Work The caching is based on the url of the asset. -SPTPersistentCache is a LRU ([last recently used](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU))) cache. +SPTPersistentCache is a LRU ([Least Recently Used](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU))) cache. # Restrictions -Currenly the uri of the resource that should be cached needs to have the appropriate file extension (one of `mp4`, `m4v` or `mov`). In order to be cached. In future versions (once dependencies allow access to the `content-type` header) this will no longer be necessary. You will also receive warnings in the xcode logs by using the `debug` mode. So if you are not 100% sure if your video is cached, check your xcode logs! +Currently, caching is only supported for URLs that end in a `.mp4`, `.m4v`, or `.mov` extension. In future versions, URLs that end in a query string (e.g. test.mp4?resolution=480p) will be support once dependencies allow access to the `Content-Type` header. At this time, HLS playlists (.m3u8) and videos that sideload text tracks are not supported and will bypass the cache. + +You will also receive warnings in the Xcode logs by using the `debug` mode. So if you are not 100% sure if your video is cached, check your Xcode logs! By default files expire after 30 days and the maxmimum cache size is 100mb. diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index 0028c3f70b..b2296c5d84 100644 --- a/ios/Video/RCTVideo.h +++ b/ios/Video/RCTVideo.h @@ -7,7 +7,8 @@ #if __has_include() #import -#import "DVURLAsset.h" +#import +#import #endif @class RCTEventDispatcher; diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m index 7c98707b6b..9fe30b6357 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -15,6 +15,12 @@ static int const RCTVideoUnset = -1; +#ifdef DEBUG + #define DebugLog(...) NSLog(__VA_ARGS__) +#else + #define DebugLog(...) (void)0 +#endif + @implementation RCTVideo { AVPlayer *_player; @@ -312,7 +318,7 @@ - (void)setSrc:(NSDictionary *)source [self removePlayerTimeObserver]; [self removePlayerItemObservers]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t) 0), dispatch_get_main_queue(), ^{ // perform on next run loop, otherwise other passed react-props may not be set [self playerItemForSource:source withCallback:^(AVPlayerItem * playerItem) { @@ -337,7 +343,7 @@ - (void)setSrc:(NSDictionary *)source [self addPlayerTimeObserver]; //Perform on next run loop, otherwise onVideoLoadStart is nil - if(self.onVideoLoadStart) { + if (self.onVideoLoadStart) { id uri = [source objectForKey:@"uri"]; id type = [source objectForKey:@"type"]; self.onVideoLoadStart(@{@"src": @{ @@ -362,7 +368,7 @@ - (NSURL*) urlFilePath:(NSString*) filepath { NSString* relativeFilePath = [filepath lastPathComponent]; // the file may be multiple levels below the documents directory NSArray* fileComponents = [filepath componentsSeparatedByString:@"Documents/"]; - if (fileComponents.count>1) { + if (fileComponents.count > 1) { relativeFilePath = [fileComponents objectAtIndex:1]; } @@ -373,12 +379,13 @@ - (NSURL*) urlFilePath:(NSString*) filepath { return nil; } -- (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSMutableDictionary * __nullable)assetOptions withCallback:(void(^)(AVPlayerItem *))handler +- (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nullable)assetOptions withCallback:(void(^)(AVPlayerItem *))handler { if (!_textTracks) { handler([AVPlayerItem playerItemWithAsset:asset]); return; } + // sideload text tracks AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init]; @@ -430,53 +437,38 @@ - (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlaye NSString *uri = [source objectForKey:@"uri"]; NSString *type = [source objectForKey:@"type"]; - NSURL *url = (isNetwork || isAsset) ? - [NSURL URLWithString:uri] : - [[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]]; + NSURL *url = isNetwork || isAsset + ? [NSURL URLWithString:uri] + : [[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]]; NSMutableDictionary *assetOptions = [[NSMutableDictionary alloc] init]; if (isNetwork) { + /* Per #1091, this is not a public API. + * We need to either get approval from Apple to use this or use a different approach. + NSDictionary *headers = [source objectForKey:@"requestHeaders"]; + if ([headers count] > 0) { + [assetOptions setObject:headers forKey:@"AVURLAssetHTTPHeaderFieldsKey"]; + } + */ + NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]; + [assetOptions setObject:cookies forKey:AVURLAssetHTTPCookiesKey]; + #if __has_include() - [_videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { - switch (videoCacheStatus) { - case RCTVideoCacheStatusMissingFileExtension: { -#ifdef DEBUG - NSLog(@"Could not generate cache key for uri '%@'. It is currently not supported to cache urls that do not include a file extension. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md.", uri); -#endif - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; - [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; - return; - } - case RCTVideoCacheStatusUnsupportedFileExtension: { -#ifdef DEBUG - NSLog(@"Could not generate cache key for uri '%@'. The file extension of that uri is currently not supported. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md.", uri); -#endif - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; - [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; - return; - } - default: - if (cachedAsset) { - [self playerItemPrepareText:cachedAsset assetOptions:assetOptions withCallback:handler]; - return; - } - } -#endif - NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]; - [assetOptions setObject:cookies forKey:AVURLAssetHTTPCookiesKey]; -#if __has_include() - DVURLAsset *asset = [[DVURLAsset alloc] initWithURL:url options:assetOptions networkTimeout: 10000]; - asset.loaderDelegate = self; -#else - AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; -#endif - [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; -#if __has_include() - }]; + if (!_textTracks) { + /* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying + * to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded. + * Until this is fixed, we need to bypass caching when text tracks are specified. + */ + DebugLog(@"Caching is not supported for uri '%@' because text tracks are not compatible with the cache. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md", uri); + [self playerItemForSourceUsingCache:uri assetOptions:assetOptions withCallback:handler]; + return; + } #endif + + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:assetOptions]; + [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; return; - } - else if (isAsset) { + } else if (isAsset) { AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; return; @@ -486,6 +478,61 @@ - (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlaye [self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler]; } +#if __has_include() + +- (void)playerItemForSourceUsingCache:(NSString *)uri assetOptions:(NSDictionary *)options withCallback:(void(^)(AVPlayerItem *))handler { + NSURL *url = [NSURL URLWithString:uri]; + [_videoCache getItemForUri:uri withCallback:^(RCTVideoCacheStatus videoCacheStatus, AVAsset * _Nullable cachedAsset) { + switch (videoCacheStatus) { + case RCTVideoCacheStatusMissingFileExtension: { + DebugLog(@"Could not generate cache key for uri '%@'. It is currently not supported to cache urls that do not include a file extension. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md", uri); + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; + [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; + return; + } + case RCTVideoCacheStatusUnsupportedFileExtension: { + DebugLog(@"Could not generate cache key for uri '%@'. The file extension of that uri is currently not supported. The video file will not be cached. Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md", uri); + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:options]; + [self playerItemPrepareText:asset assetOptions:options withCallback:handler]; + return; + } + default: + if (cachedAsset) { + DebugLog(@"Playing back uri '%@' from cache", uri); + // See note in playerItemForSource about not being able to support text tracks & caching + handler([AVPlayerItem playerItemWithAsset:asset]); + return; + } + } + + DVURLAsset *asset = [[DVURLAsset alloc] initWithURL:url options:options networkTimeout:10000]; + asset.loaderDelegate = self; + + /* More granular code to have control over the DVURLAsset + DVAssetLoaderDelegate *resourceLoaderDelegate = [[DVAssetLoaderDelegate alloc] initWithURL:url]; + resourceLoaderDelegate.delegate = self; + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO]; + components.scheme = [DVAssetLoaderDelegate scheme]; + AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:[components URL] options:options]; + [asset.resourceLoader setDelegate:resourceLoaderDelegate queue:dispatch_get_main_queue()]; + */ + + handler([AVPlayerItem playerItemWithAsset:asset]); + }]; +} + +#pragma mark - DVAssetLoaderDelegate + +- (void)dvAssetLoaderDelegate:(DVAssetLoaderDelegate *)loaderDelegate + didLoadData:(NSData *)data + forURL:(NSURL *)url { + [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { + DebugLog(@"Cache data stored successfully 🎉"); + }]; +} + +#endif + - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (object == _playerItem) { @@ -1153,20 +1200,6 @@ - (void)removePlayerLayer _playerLayer = nil; } -#if __has_include() -#pragma mark - DVAssetLoaderDelegate -- (void)dvAssetLoaderDelegate:(DVAssetLoaderDelegate *)loaderDelegate - didLoadData:(NSData *)data - forURL:(NSURL *)url { - [_videoCache storeItem:data forUri:[url absoluteString] withCallback:^(BOOL success) { -#ifdef DEBUG - NSLog(@"data stored succesfully 🎉"); -#endif - }]; -} - -#endif - #pragma mark - RCTVideoPlayerViewControllerDelegate - (void)videoPlayerViewControllerWillDismiss:(AVPlayerViewController *)playerViewController diff --git a/ios/VideoCaching/RCTVideoCache.m b/ios/VideoCaching/RCTVideoCache.m index 4f23a1132a..1a2b83a53b 100644 --- a/ios/VideoCaching/RCTVideoCache.m +++ b/ios/VideoCaching/RCTVideoCache.m @@ -7,8 +7,7 @@ @implementation RCTVideoCache @synthesize cacheIdentifier; @synthesize temporaryCachePath; -+ (RCTVideoCache *) sharedInstance -{ ++ (RCTVideoCache *)sharedInstance { static RCTVideoCache *sharedInstance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ @@ -41,9 +40,8 @@ - (id)init { return self; } -- (void) createTemporaryPath -{ - NSError * error = nil; +- (void) createTemporaryPath { + NSError *error = nil; BOOL success = [[NSFileManager defaultManager] createDirectoryAtPath:self.temporaryCachePath withIntermediateDirectories:YES attributes:nil @@ -77,19 +75,19 @@ - (void)storeItem:(NSData *)data forUri:(NSString *)uri withCallback:(void(^)(BO } - (AVURLAsset *)getItemFromTemporaryStorage:(NSString *)key { - NSString * temporaryFilePath =[self.temporaryCachePath stringByAppendingPathComponent:key]; + NSString * temporaryFilePath = [self.temporaryCachePath stringByAppendingPathComponent:key]; BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:temporaryFilePath]; if (!fileExists) { return nil; } - NSURL * assetUrl = [[NSURL alloc] initFileURLWithPath:temporaryFilePath]; + NSURL *assetUrl = [[NSURL alloc] initFileURLWithPath:temporaryFilePath]; AVURLAsset *asset = [AVURLAsset URLAssetWithURL:assetUrl options:nil]; return asset; } - (BOOL)saveDataToTemporaryStorage:(NSData *)data key:(NSString *)key { - NSString * temporaryFilePath = [self.temporaryCachePath stringByAppendingPathComponent:key]; + NSString *temporaryFilePath = [self.temporaryCachePath stringByAppendingPathComponent:key]; [data writeToFile:temporaryFilePath atomically:YES]; return YES; } @@ -105,7 +103,7 @@ - (NSString *)generateCacheKeyForUri:(NSString *)uri { NSString * pathExtension = [uriWithoutQueryParams pathExtension]; NSArray * supportedExtensions = @[@"m4v", @"mp4", @"mov"]; - if ([supportedExtensions containsObject:pathExtension] == NO) { + if ([pathExtension isEqualToString:@""]) { NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: NSLocalizedString(@"Missing file extension.", nil), NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Missing file extension.", nil), @@ -114,11 +112,12 @@ - (NSString *)generateCacheKeyForUri:(NSString *)uri { NSError *error = [NSError errorWithDomain:@"RCTVideoCache" code:RCTVideoCacheStatusMissingFileExtension userInfo:userInfo]; @throw error; - } else if ([pathExtension isEqualToString:@"m3u8"]) { + } else if (![supportedExtensions containsObject:pathExtension]) { + // Notably, we don't currently support m3u8 (HLS playlists) NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: NSLocalizedString(@"Missing file extension.", nil), - NSLocalizedFailureReasonErrorKey: NSLocalizedString(@"Missing file extension.", nil), - NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Missing file extension.", nil) + 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]; @@ -158,7 +157,7 @@ - (void)getItemForUri:(NSString *)uri withCallback:(void(^)(RCTVideoCacheStatus, } } -- (NSString *) generateHashForUrl:(NSString *)string { +- (NSString *)generateHashForUrl:(NSString *)string { const char *cStr = [string UTF8String]; unsigned char result[CC_MD5_DIGEST_LENGTH]; CC_MD5( cStr, (CC_LONG)strlen(cStr), result );