Skip to content

Commit

Permalink
Merge pull request #1172 from react-native-community/bugfix/ios-cache…
Browse files Browse the repository at this point in the history
…-texttracks

[WIP] Prevent errors on iOS when using text tracks with caching
  • Loading branch information
cobarx authored Aug 28, 2018
2 parents 444581d + b6512e4 commit 03734bb
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 76 deletions.
6 changes: 4 additions & 2 deletions docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion ios/Video/RCTVideo.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

#if __has_include(<react-native-video/RCTVideoCache.h>)
#import <react-native-video/RCTVideoCache.h>
#import "DVURLAsset.h"
#import <DVAssetLoaderDelegate/DVURLAsset.h>
#import <DVAssetLoaderDelegate/DVAssetLoaderDelegate.h>
#endif

@class RCTEventDispatcher;
Expand Down
151 changes: 92 additions & 59 deletions ios/Video/RCTVideo.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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": @{
Expand All @@ -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];
}

Expand All @@ -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];

Expand Down Expand Up @@ -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(<react-native-video/RCTVideoCache.h>)
[_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(<react-native-video/RCTVideoCache.h>)
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(<react-native-video/RCTVideoCache.h>)
}];
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;
Expand All @@ -486,6 +478,61 @@ - (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlaye
[self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler];
}

#if __has_include(<react-native-video/RCTVideoCache.h>)

- (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) {
Expand Down Expand Up @@ -1153,20 +1200,6 @@ - (void)removePlayerLayer
_playerLayer = nil;
}

#if __has_include(<react-native-video/RCTVideoCache.h>)
#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
Expand Down
27 changes: 13 additions & 14 deletions ios/VideoCaching/RCTVideoCache.m
Original file line number Diff line number Diff line change
Expand Up @@ -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, ^{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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),
Expand All @@ -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];
Expand Down Expand Up @@ -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 );
Expand Down

0 comments on commit 03734bb

Please sign in to comment.