react-native-video/ios/Video/RCTVideo.m
Nahuel Marisi 7a9081d5af Removing the call to observeValueForKeyPath:ofObject:change:context: on super from RCTVideo.
If the super class is not actually observing the key, the app will crash. Checking to see if
the super class responds to this selector doesn't solve this issue.

react-native-video github issue: https://github.com/react-native-community/react-native-video/issues/1515

Discussion about this particular problem: https://stackoverflow.com/questions/6574714/whats-wrong-with-this-observevalueforkeypathofobjectchangecontext-implement
2019-08-20 09:37:49 +02:00

1621 lines
59 KiB
Objective-C

#import <React/RCTConvert.h>
#import "RCTVideo.h"
#import <React/RCTBridgeModule.h>
#import <React/RCTEventDispatcher.h>
#import <React/UIView+React.h>
#include <MediaAccessibility/MediaAccessibility.h>
#include <AVFoundation/AVFoundation.h>
static NSString *const statusKeyPath = @"status";
static NSString *const playbackLikelyToKeepUpKeyPath = @"playbackLikelyToKeepUp";
static NSString *const playbackBufferEmptyKeyPath = @"playbackBufferEmpty";
static NSString *const readyForDisplayKeyPath = @"readyForDisplay";
static NSString *const playbackRate = @"rate";
static NSString *const timedMetadata = @"timedMetadata";
static NSString *const externalPlaybackActive = @"externalPlaybackActive";
static int const RCTVideoUnset = -1;
#ifdef DEBUG
#define DebugLog(...) NSLog(__VA_ARGS__)
#else
#define DebugLog(...) (void)0
#endif
@implementation RCTVideo
{
AVPlayer *_player;
AVPlayerItem *_playerItem;
NSDictionary *_source;
BOOL _playerItemObserversSet;
BOOL _playerBufferEmpty;
AVPlayerLayer *_playerLayer;
BOOL _playerLayerObserverSet;
RCTVideoPlayerViewController *_playerViewController;
NSURL *_videoURL;
/* Required to publish events */
RCTEventDispatcher *_eventDispatcher;
BOOL _playbackRateObserverRegistered;
BOOL _isExternalPlaybackActiveObserverRegistered;
BOOL _videoLoadStarted;
bool _pendingSeek;
float _pendingSeekTime;
float _lastSeekTime;
/* For sending videoProgress events */
Float64 _progressUpdateInterval;
BOOL _controls;
id _timeObserver;
/* Keep track of any modifiers, need to be applied after each play */
float _volume;
float _rate;
float _maxBitRate;
BOOL _muted;
BOOL _paused;
BOOL _repeat;
BOOL _allowsExternalPlayback;
NSArray * _textTracks;
NSDictionary * _selectedTextTrack;
NSDictionary * _selectedAudioTrack;
BOOL _playbackStalled;
BOOL _playInBackground;
BOOL _playWhenInactive;
BOOL _pictureInPicture;
NSString * _ignoreSilentSwitch;
NSString * _resizeMode;
BOOL _fullscreen;
BOOL _fullscreenAutorotate;
NSString * _fullscreenOrientation;
BOOL _fullscreenPlayerPresented;
NSString *_filterName;
BOOL _filterEnabled;
UIViewController * _presentingViewController;
#if __has_include(<react-native-video/RCTVideoCache.h>)
RCTVideoCache * _videoCache;
#endif
#if TARGET_OS_IOS
void (^__strong _Nonnull _restoreUserInterfaceForPIPStopCompletionHandler)(BOOL);
AVPictureInPictureController *_pipController;
#endif
}
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
{
if ((self = [super init])) {
_eventDispatcher = eventDispatcher;
_playbackRateObserverRegistered = NO;
_isExternalPlaybackActiveObserverRegistered = NO;
_playbackStalled = NO;
_rate = 1.0;
_volume = 1.0;
_resizeMode = @"AVLayerVideoGravityResizeAspectFill";
_fullscreenAutorotate = YES;
_fullscreenOrientation = @"all";
_pendingSeek = false;
_pendingSeekTime = 0.0f;
_lastSeekTime = 0.0f;
_progressUpdateInterval = 250;
_controls = NO;
_playerBufferEmpty = YES;
_playInBackground = false;
_allowsExternalPlayback = YES;
_playWhenInactive = false;
_pictureInPicture = false;
_ignoreSilentSwitch = @"inherit"; // inherit, ignore, obey
#if TARGET_OS_IOS
_restoreUserInterfaceForPIPStopCompletionHandler = NULL;
#endif
#if __has_include(<react-native-video/RCTVideoCache.h>)
_videoCache = [RCTVideoCache sharedInstance];
#endif
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillResignActive:)
name:UIApplicationWillResignActiveNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationDidEnterBackground:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground:)
name:UIApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(audioRouteChanged:)
name:AVAudioSessionRouteChangeNotification
object:nil];
}
return self;
}
- (RCTVideoPlayerViewController*)createPlayerViewController:(AVPlayer*)player
withPlayerItem:(AVPlayerItem*)playerItem {
RCTVideoPlayerViewController* viewController = [[RCTVideoPlayerViewController alloc] init];
viewController.showsPlaybackControls = YES;
viewController.rctDelegate = self;
viewController.preferredOrientation = _fullscreenOrientation;
viewController.view.frame = self.bounds;
viewController.player = player;
return viewController;
}
/* ---------------------------------------------------------
** Get the duration for a AVPlayerItem.
** ------------------------------------------------------- */
- (CMTime)playerItemDuration
{
AVPlayerItem *playerItem = [_player currentItem];
if (playerItem.status == AVPlayerItemStatusReadyToPlay)
{
return([playerItem duration]);
}
return(kCMTimeInvalid);
}
- (CMTimeRange)playerItemSeekableTimeRange
{
AVPlayerItem *playerItem = [_player currentItem];
if (playerItem.status == AVPlayerItemStatusReadyToPlay)
{
return [playerItem seekableTimeRanges].firstObject.CMTimeRangeValue;
}
return (kCMTimeRangeZero);
}
-(void)addPlayerTimeObserver
{
const Float64 progressUpdateIntervalMS = _progressUpdateInterval / 1000;
// @see endScrubbing in AVPlayerDemoPlaybackViewController.m
// of https://developer.apple.com/library/ios/samplecode/AVPlayerDemo/Introduction/Intro.html
__weak RCTVideo *weakSelf = self;
_timeObserver = [_player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(progressUpdateIntervalMS, NSEC_PER_SEC)
queue:NULL
usingBlock:^(CMTime time) { [weakSelf sendProgressUpdate]; }
];
}
/* Cancels the previously registered time observer. */
-(void)removePlayerTimeObserver
{
if (_timeObserver)
{
[_player removeTimeObserver:_timeObserver];
_timeObserver = nil;
}
}
#pragma mark - Progress
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
[self removePlayerLayer];
[self removePlayerItemObservers];
[_player removeObserver:self forKeyPath:playbackRate context:nil];
[_player removeObserver:self forKeyPath:externalPlaybackActive context: nil];
}
#pragma mark - App lifecycle handlers
- (void)applicationWillResignActive:(NSNotification *)notification
{
if (_playInBackground || _playWhenInactive || _paused) return;
[_player pause];
[_player setRate:0.0];
}
- (void)applicationDidEnterBackground:(NSNotification *)notification
{
if (_playInBackground) {
// Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html
[_playerLayer setPlayer:nil];
[_playerViewController setPlayer:nil];
}
}
- (void)applicationWillEnterForeground:(NSNotification *)notification
{
[self applyModifiers];
if (_playInBackground) {
[_playerLayer setPlayer:_player];
[_playerViewController setPlayer:_player];
}
}
#pragma mark - Audio events
- (void)audioRouteChanged:(NSNotification *)notification
{
NSNumber *reason = [[notification userInfo] objectForKey:AVAudioSessionRouteChangeReasonKey];
NSNumber *previousRoute = [[notification userInfo] objectForKey:AVAudioSessionRouteChangePreviousRouteKey];
if (reason.unsignedIntValue == AVAudioSessionRouteChangeReasonOldDeviceUnavailable) {
self.onVideoAudioBecomingNoisy(@{@"target": self.reactTag});
}
}
#pragma mark - Progress
- (void)sendProgressUpdate
{
AVPlayerItem *video = [_player currentItem];
if (video == nil || video.status != AVPlayerItemStatusReadyToPlay) {
return;
}
CMTime playerDuration = [self playerItemDuration];
if (CMTIME_IS_INVALID(playerDuration)) {
return;
}
CMTime currentTime = _player.currentTime;
const Float64 duration = CMTimeGetSeconds(playerDuration);
const Float64 currentTimeSecs = CMTimeGetSeconds(currentTime);
[[NSNotificationCenter defaultCenter] postNotificationName:@"RCTVideo_progress" object:nil userInfo:@{@"progress": [NSNumber numberWithDouble: currentTimeSecs / duration]}];
if( currentTimeSecs >= 0 && self.onVideoProgress) {
self.onVideoProgress(@{
@"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(currentTime)],
@"playableDuration": [self calculatePlayableDuration],
@"atValue": [NSNumber numberWithLongLong:currentTime.value],
@"atTimescale": [NSNumber numberWithInt:currentTime.timescale],
@"target": self.reactTag,
@"seekableDuration": [self calculateSeekableDuration],
});
}
}
/*!
* Calculates and returns the playable duration of the current player item using its loaded time ranges.
*
* \returns The playable duration of the current player item in seconds.
*/
- (NSNumber *)calculatePlayableDuration
{
AVPlayerItem *video = _player.currentItem;
if (video.status == AVPlayerItemStatusReadyToPlay) {
__block CMTimeRange effectiveTimeRange;
[video.loadedTimeRanges enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
CMTimeRange timeRange = [obj CMTimeRangeValue];
if (CMTimeRangeContainsTime(timeRange, video.currentTime)) {
effectiveTimeRange = timeRange;
*stop = YES;
}
}];
Float64 playableDuration = CMTimeGetSeconds(CMTimeRangeGetEnd(effectiveTimeRange));
if (playableDuration > 0) {
return [NSNumber numberWithFloat:playableDuration];
}
}
return [NSNumber numberWithInteger:0];
}
- (NSNumber *)calculateSeekableDuration
{
CMTimeRange timeRange = [self playerItemSeekableTimeRange];
if (CMTIME_IS_NUMERIC(timeRange.duration))
{
return [NSNumber numberWithFloat:CMTimeGetSeconds(timeRange.duration)];
}
return [NSNumber numberWithInteger:0];
}
- (void)addPlayerItemObservers
{
[_playerItem addObserver:self forKeyPath:statusKeyPath options:0 context:nil];
[_playerItem addObserver:self forKeyPath:playbackBufferEmptyKeyPath options:0 context:nil];
[_playerItem addObserver:self forKeyPath:playbackLikelyToKeepUpKeyPath options:0 context:nil];
[_playerItem addObserver:self forKeyPath:timedMetadata options:NSKeyValueObservingOptionNew context:nil];
_playerItemObserversSet = YES;
}
/* Fixes https://github.com/brentvatne/react-native-video/issues/43
* Crashes caused when trying to remove the observer when there is no
* observer set */
- (void)removePlayerItemObservers
{
if (_playerItemObserversSet) {
[_playerItem removeObserver:self forKeyPath:statusKeyPath];
[_playerItem removeObserver:self forKeyPath:playbackBufferEmptyKeyPath];
[_playerItem removeObserver:self forKeyPath:playbackLikelyToKeepUpKeyPath];
[_playerItem removeObserver:self forKeyPath:timedMetadata];
_playerItemObserversSet = NO;
}
}
#pragma mark - Player and source
- (void)setSrc:(NSDictionary *)source
{
_source = source;
[self removePlayerLayer];
[self removePlayerTimeObserver];
[self removePlayerItemObservers];
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) {
_playerItem = playerItem;
[self addPlayerItemObservers];
[self setFilter:_filterName];
[self setMaxBitRate:_maxBitRate];
[_player pause];
if (_playbackRateObserverRegistered) {
[_player removeObserver:self forKeyPath:playbackRate context:nil];
_playbackRateObserverRegistered = NO;
}
if (_isExternalPlaybackActiveObserverRegistered) {
[_player removeObserver:self forKeyPath:externalPlaybackActive context:nil];
_isExternalPlaybackActiveObserverRegistered = NO;
}
_player = [AVPlayer playerWithPlayerItem:_playerItem];
_player.actionAtItemEnd = AVPlayerActionAtItemEndNone;
[_player addObserver:self forKeyPath:playbackRate options:0 context:nil];
_playbackRateObserverRegistered = YES;
[_player addObserver:self forKeyPath:externalPlaybackActive options:0 context:nil];
_isExternalPlaybackActiveObserverRegistered = YES;
[self addPlayerTimeObserver];
//Perform on next run loop, otherwise onVideoLoadStart is nil
if (self.onVideoLoadStart) {
id uri = [source objectForKey:@"uri"];
id type = [source objectForKey:@"type"];
self.onVideoLoadStart(@{@"src": @{
@"uri": uri ? uri : [NSNull null],
@"type": type ? type : [NSNull null],
@"isNetwork": [NSNumber numberWithBool:(bool)[source objectForKey:@"isNetwork"]]},
@"target": self.reactTag
});
}
}];
});
_videoLoadStarted = YES;
}
- (NSURL*) urlFilePath:(NSString*) filepath {
if ([filepath containsString:@"file://"]) {
return [NSURL URLWithString:filepath];
}
// if no file found, check if the file exists in the Document directory
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString* relativeFilePath = [filepath lastPathComponent];
// the file may be multiple levels below the documents directory
NSArray* fileComponents = [filepath componentsSeparatedByString:@"Documents/"];
if (fileComponents.count > 1) {
relativeFilePath = [fileComponents objectAtIndex:1];
}
NSString *path = [paths.firstObject stringByAppendingPathComponent:relativeFilePath];
if ([[NSFileManager defaultManager] fileExistsAtPath:path]) {
return [NSURL fileURLWithPath:path];
}
return nil;
}
- (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nullable)assetOptions withCallback:(void(^)(AVPlayerItem *))handler
{
if (!_textTracks || _textTracks.count==0) {
handler([AVPlayerItem playerItemWithAsset:asset]);
return;
}
// AVPlayer can't airplay AVMutableCompositions
_allowsExternalPlayback = NO;
// sideload text tracks
AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init];
AVAssetTrack *videoAsset = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject;
AVMutableCompositionTrack *videoCompTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
[videoCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
ofTrack:videoAsset
atTime:kCMTimeZero
error:nil];
AVAssetTrack *audioAsset = [asset tracksWithMediaType:AVMediaTypeAudio].firstObject;
AVMutableCompositionTrack *audioCompTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
[audioCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
ofTrack:audioAsset
atTime:kCMTimeZero
error:nil];
NSMutableArray* validTextTracks = [NSMutableArray array];
for (int i = 0; i < _textTracks.count; ++i) {
AVURLAsset *textURLAsset;
NSString *textUri = [_textTracks objectAtIndex:i][@"uri"];
if ([[textUri lowercaseString] hasPrefix:@"http"]) {
textURLAsset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:textUri] options:assetOptions];
} else {
textURLAsset = [AVURLAsset URLAssetWithURL:[self urlFilePath:textUri] options:nil];
}
AVAssetTrack *textTrackAsset = [textURLAsset tracksWithMediaType:AVMediaTypeText].firstObject;
if (!textTrackAsset) continue; // fix when there's no textTrackAsset
[validTextTracks addObject:[_textTracks objectAtIndex:i]];
AVMutableCompositionTrack *textCompTrack = [mixComposition
addMutableTrackWithMediaType:AVMediaTypeText
preferredTrackID:kCMPersistentTrackID_Invalid];
[textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
ofTrack:textTrackAsset
atTime:kCMTimeZero
error:nil];
}
if (validTextTracks.count != _textTracks.count) {
[self setTextTracks:validTextTracks];
}
handler([AVPlayerItem playerItemWithAsset:mixComposition]);
}
- (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlayerItem *))handler
{
bool isNetwork = [RCTConvert BOOL:[source objectForKey:@"isNetwork"]];
bool isAsset = [RCTConvert BOOL:[source objectForKey:@"isAsset"]];
bool shouldCache = [RCTConvert BOOL:[source objectForKey:@"shouldCache"]];
NSString *uri = [source objectForKey:@"uri"];
NSString *type = [source objectForKey:@"type"];
if (!uri || [uri isEqualToString:@""]) {
DebugLog(@"Could not find video URL in source '%@'", source);
return;
}
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>)
if (shouldCache && (!_textTracks || !_textTracks.count)) {
/* 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) {
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
[self playerItemPrepareText:asset assetOptions:assetOptions withCallback:handler];
return;
}
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[[NSURL alloc] initFileURLWithPath:[[NSBundle mainBundle] pathForResource:uri ofType:type]] options:nil];
[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:cachedAsset]);
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([keyPath isEqualToString:readyForDisplayKeyPath] && [change objectForKey:NSKeyValueChangeNewKey] && self.onReadyForDisplay) {
self.onReadyForDisplay(@{@"target": self.reactTag});
return;
}
if (object == _playerItem) {
// When timeMetadata is read the event onTimedMetadata is triggered
if ([keyPath isEqualToString:timedMetadata]) {
NSArray<AVMetadataItem *> *items = [change objectForKey:@"new"];
if (items && ![items isEqual:[NSNull null]] && items.count > 0) {
NSMutableArray *array = [NSMutableArray new];
for (AVMetadataItem *item in items) {
NSString *value = (NSString *)item.value;
NSString *identifier = item.identifier;
if (![value isEqual: [NSNull null]]) {
NSDictionary *dictionary = [[NSDictionary alloc] initWithObjects:@[value, identifier] forKeys:@[@"value", @"identifier"]];
[array addObject:dictionary];
}
}
self.onTimedMetadata(@{
@"target": self.reactTag,
@"metadata": array
});
}
}
if ([keyPath isEqualToString:statusKeyPath]) {
// Handle player item status change.
if (_playerItem.status == AVPlayerItemStatusReadyToPlay) {
float duration = CMTimeGetSeconds(_playerItem.asset.duration);
if (isnan(duration)) {
duration = 0.0;
}
NSObject *width = @"undefined";
NSObject *height = @"undefined";
NSString *orientation = @"undefined";
if ([_playerItem.asset tracksWithMediaType:AVMediaTypeVideo].count > 0) {
AVAssetTrack *videoTrack = [[_playerItem.asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0];
width = [NSNumber numberWithFloat:videoTrack.naturalSize.width];
height = [NSNumber numberWithFloat:videoTrack.naturalSize.height];
CGAffineTransform preferredTransform = [videoTrack preferredTransform];
if ((videoTrack.naturalSize.width == preferredTransform.tx
&& videoTrack.naturalSize.height == preferredTransform.ty)
|| (preferredTransform.tx == 0 && preferredTransform.ty == 0))
{
orientation = @"landscape";
} else {
orientation = @"portrait";
}
}
if (self.onVideoLoad && _videoLoadStarted) {
self.onVideoLoad(@{@"duration": [NSNumber numberWithFloat:duration],
@"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(_playerItem.currentTime)],
@"canPlayReverse": [NSNumber numberWithBool:_playerItem.canPlayReverse],
@"canPlayFastForward": [NSNumber numberWithBool:_playerItem.canPlayFastForward],
@"canPlaySlowForward": [NSNumber numberWithBool:_playerItem.canPlaySlowForward],
@"canPlaySlowReverse": [NSNumber numberWithBool:_playerItem.canPlaySlowReverse],
@"canStepBackward": [NSNumber numberWithBool:_playerItem.canStepBackward],
@"canStepForward": [NSNumber numberWithBool:_playerItem.canStepForward],
@"naturalSize": @{
@"width": width,
@"height": height,
@"orientation": orientation
},
@"audioTracks": [self getAudioTrackInfo],
@"textTracks": [self getTextTrackInfo],
@"target": self.reactTag});
}
_videoLoadStarted = NO;
[self attachListeners];
[self applyModifiers];
} else if (_playerItem.status == AVPlayerItemStatusFailed && self.onVideoError) {
self.onVideoError(@{@"error": @{@"code": [NSNumber numberWithInteger: _playerItem.error.code],
@"domain": _playerItem.error.domain},
@"target": self.reactTag});
}
} else if ([keyPath isEqualToString:playbackBufferEmptyKeyPath]) {
_playerBufferEmpty = YES;
self.onVideoBuffer(@{@"isBuffering": @(YES), @"target": self.reactTag});
} else if ([keyPath isEqualToString:playbackLikelyToKeepUpKeyPath]) {
// Continue playing (or not if paused) after being paused due to hitting an unbuffered zone.
if ((!(_controls || _fullscreenPlayerPresented) || _playerBufferEmpty) && _playerItem.playbackLikelyToKeepUp) {
[self setPaused:_paused];
}
_playerBufferEmpty = NO;
self.onVideoBuffer(@{@"isBuffering": @(NO), @"target": self.reactTag});
}
} else if (object == _player) {
if([keyPath isEqualToString:playbackRate]) {
if(self.onPlaybackRateChange) {
self.onPlaybackRateChange(@{@"playbackRate": [NSNumber numberWithFloat:_player.rate],
@"target": self.reactTag});
}
if(_playbackStalled && _player.rate > 0) {
if(self.onPlaybackResume) {
self.onPlaybackResume(@{@"playbackRate": [NSNumber numberWithFloat:_player.rate],
@"target": self.reactTag});
}
_playbackStalled = NO;
}
}
else if([keyPath isEqualToString:externalPlaybackActive]) {
if(self.onVideoExternalPlaybackChange) {
self.onVideoExternalPlaybackChange(@{@"isExternalPlaybackActive": [NSNumber numberWithBool:_player.isExternalPlaybackActive],
@"target": self.reactTag});
}
}
} else if (object == _playerViewController.contentOverlayView) {
// when controls==true, this is a hack to reset the rootview when rotation happens in fullscreen
if ([keyPath isEqualToString:@"frame"]) {
CGRect oldRect = [change[NSKeyValueChangeOldKey] CGRectValue];
CGRect newRect = [change[NSKeyValueChangeNewKey] CGRectValue];
if (!CGRectEqualToRect(oldRect, newRect)) {
if (CGRectEqualToRect(newRect, [UIScreen mainScreen].bounds)) {
NSLog(@"in fullscreen");
} else NSLog(@"not fullscreen");
[self.reactViewController.view setFrame:[UIScreen mainScreen].bounds];
[self.reactViewController.view setNeedsLayout];
}
return;
}
}
}
- (void)attachListeners
{
// listen for end of file
[[NSNotificationCenter defaultCenter] removeObserver:self
name:AVPlayerItemDidPlayToEndTimeNotification
object:[_player currentItem]];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(playerItemDidReachEnd:)
name:AVPlayerItemDidPlayToEndTimeNotification
object:[_player currentItem]];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:AVPlayerItemPlaybackStalledNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(playbackStalled:)
name:AVPlayerItemPlaybackStalledNotification
object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:AVPlayerItemNewAccessLogEntryNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleAVPlayerAccess:)
name:AVPlayerItemNewAccessLogEntryNotification
object:nil];
}
- (void)handleAVPlayerAccess:(NSNotification *)notification {
AVPlayerItemAccessLog *accessLog = [((AVPlayerItem *)notification.object) accessLog];
AVPlayerItemAccessLogEvent *lastEvent = accessLog.events.lastObject;
/* TODO: get this working
if (self.onBandwidthUpdate) {
self.onBandwidthUpdate(@{@"bitrate": [NSNumber numberWithFloat:lastEvent.observedBitrate]});
}
*/
}
- (void)playbackStalled:(NSNotification *)notification
{
if(self.onPlaybackStalled) {
self.onPlaybackStalled(@{@"target": self.reactTag});
}
_playbackStalled = YES;
}
- (void)playerItemDidReachEnd:(NSNotification *)notification
{
if(self.onVideoEnd) {
self.onVideoEnd(@{@"target": self.reactTag});
}
if (_repeat) {
AVPlayerItem *item = [notification object];
[item seekToTime:kCMTimeZero];
[self applyModifiers];
} else {
[self removePlayerTimeObserver];
}
}
#pragma mark - Prop setters
- (void)setResizeMode:(NSString*)mode
{
if( _controls )
{
_playerViewController.videoGravity = mode;
}
else
{
_playerLayer.videoGravity = mode;
}
_resizeMode = mode;
}
- (void)setPlayInBackground:(BOOL)playInBackground
{
_playInBackground = playInBackground;
}
- (void)setAllowsExternalPlayback:(BOOL)allowsExternalPlayback
{
_allowsExternalPlayback = allowsExternalPlayback;
_player.allowsExternalPlayback = _allowsExternalPlayback;
}
- (void)setPlayWhenInactive:(BOOL)playWhenInactive
{
_playWhenInactive = playWhenInactive;
}
- (void)setPictureInPicture:(BOOL)pictureInPicture
{
#if TARGET_OS_IOS
if (_pictureInPicture == pictureInPicture) {
return;
}
_pictureInPicture = pictureInPicture;
if (_pipController && _pictureInPicture && ![_pipController isPictureInPictureActive]) {
dispatch_async(dispatch_get_main_queue(), ^{
[_pipController startPictureInPicture];
});
} else if (_pipController && !_pictureInPicture && [_pipController isPictureInPictureActive]) {
dispatch_async(dispatch_get_main_queue(), ^{
[_pipController stopPictureInPicture];
});
}
#endif
}
#if TARGET_OS_IOS
- (void)setRestoreUserInterfaceForPIPStopCompletionHandler:(BOOL)restore
{
if (_restoreUserInterfaceForPIPStopCompletionHandler != NULL) {
_restoreUserInterfaceForPIPStopCompletionHandler(restore);
_restoreUserInterfaceForPIPStopCompletionHandler = NULL;
}
}
- (void)setupPipController {
if (!_pipController && _playerLayer && [AVPictureInPictureController isPictureInPictureSupported]) {
// Create new controller passing reference to the AVPlayerLayer
_pipController = [[AVPictureInPictureController alloc] initWithPlayerLayer:_playerLayer];
_pipController.delegate = self;
}
}
#endif
- (void)setIgnoreSilentSwitch:(NSString *)ignoreSilentSwitch
{
_ignoreSilentSwitch = ignoreSilentSwitch;
[self applyModifiers];
}
- (void)setPaused:(BOOL)paused
{
if (paused) {
[_player pause];
[_player setRate:0.0];
} else {
if([_ignoreSilentSwitch isEqualToString:@"ignore"]) {
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil];
} else if([_ignoreSilentSwitch isEqualToString:@"obey"]) {
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil];
}
[_player play];
[_player setRate:_rate];
}
_paused = paused;
}
- (float)getCurrentTime
{
return _playerItem != NULL ? CMTimeGetSeconds(_playerItem.currentTime) : 0;
}
- (void)setCurrentTime:(float)currentTime
{
NSDictionary *info = @{
@"time": [NSNumber numberWithFloat:currentTime],
@"tolerance": [NSNumber numberWithInt:100]
};
[self setSeek:info];
}
- (void)setSeek:(NSDictionary *)info
{
NSNumber *seekTime = info[@"time"];
NSNumber *seekTolerance = info[@"tolerance"];
int timeScale = 1000;
AVPlayerItem *item = _player.currentItem;
if (item && item.status == AVPlayerItemStatusReadyToPlay) {
// TODO check loadedTimeRanges
CMTime cmSeekTime = CMTimeMakeWithSeconds([seekTime floatValue], timeScale);
CMTime current = item.currentTime;
// TODO figure out a good tolerance level
CMTime tolerance = CMTimeMake([seekTolerance floatValue], timeScale);
BOOL wasPaused = _paused;
if (CMTimeCompare(current, cmSeekTime) != 0) {
if (!wasPaused) [_player pause];
[_player seekToTime:cmSeekTime toleranceBefore:tolerance toleranceAfter:tolerance completionHandler:^(BOOL finished) {
if (!_timeObserver) {
[self addPlayerTimeObserver];
}
if (!wasPaused) {
[self setPaused:false];
}
if(self.onVideoSeek) {
self.onVideoSeek(@{@"currentTime": [NSNumber numberWithFloat:CMTimeGetSeconds(item.currentTime)],
@"seekTime": seekTime,
@"target": self.reactTag});
}
}];
_pendingSeek = false;
}
} else {
// TODO: See if this makes sense and if so, actually implement it
_pendingSeek = true;
_pendingSeekTime = [seekTime floatValue];
}
}
- (void)setRate:(float)rate
{
_rate = rate;
[self applyModifiers];
}
- (void)setMuted:(BOOL)muted
{
_muted = muted;
[self applyModifiers];
}
- (void)setVolume:(float)volume
{
_volume = volume;
[self applyModifiers];
}
- (void)setMaxBitRate:(float) maxBitRate {
_maxBitRate = maxBitRate;
_playerItem.preferredPeakBitRate = maxBitRate;
}
- (void)applyModifiers
{
if (_muted) {
if (!_controls) {
[_player setVolume:0];
}
[_player setMuted:YES];
} else {
[_player setVolume:_volume];
[_player setMuted:NO];
}
[self setMaxBitRate:_maxBitRate];
[self setSelectedAudioTrack:_selectedAudioTrack];
[self setSelectedTextTrack:_selectedTextTrack];
[self setResizeMode:_resizeMode];
[self setRepeat:_repeat];
[self setPaused:_paused];
[self setControls:_controls];
[self setAllowsExternalPlayback:_allowsExternalPlayback];
}
- (void)setRepeat:(BOOL)repeat {
_repeat = repeat;
}
- (void)setMediaSelectionTrackForCharacteristic:(AVMediaCharacteristic)characteristic
withCriteria:(NSDictionary *)criteria
{
NSString *type = criteria[@"type"];
AVMediaSelectionGroup *group = [_player.currentItem.asset
mediaSelectionGroupForMediaCharacteristic:characteristic];
AVMediaSelectionOption *mediaOption;
if ([type isEqualToString:@"disabled"]) {
// Do nothing. We want to ensure option is nil
} else if ([type isEqualToString:@"language"] || [type isEqualToString:@"title"]) {
NSString *value = criteria[@"value"];
for (int i = 0; i < group.options.count; ++i) {
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
NSString *optionValue;
if ([type isEqualToString:@"language"]) {
optionValue = [currentOption extendedLanguageTag];
} else {
optionValue = [[[currentOption commonMetadata]
valueForKey:@"value"]
objectAtIndex:0];
}
if ([value isEqualToString:optionValue]) {
mediaOption = currentOption;
break;
}
}
//} else if ([type isEqualToString:@"default"]) {
// option = group.defaultOption; */
} else if ([type isEqualToString:@"index"]) {
if ([criteria[@"value"] isKindOfClass:[NSNumber class]]) {
int index = [criteria[@"value"] intValue];
if (group.options.count > index) {
mediaOption = [group.options objectAtIndex:index];
}
}
} else { // default. invalid type or "system"
[_player.currentItem selectMediaOptionAutomaticallyInMediaSelectionGroup:group];
return;
}
// If a match isn't found, option will be nil and text tracks will be disabled
[_player.currentItem selectMediaOption:mediaOption inMediaSelectionGroup:group];
}
- (void)setSelectedAudioTrack:(NSDictionary *)selectedAudioTrack {
_selectedAudioTrack = selectedAudioTrack;
[self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicAudible
withCriteria:_selectedAudioTrack];
}
- (void)setSelectedTextTrack:(NSDictionary *)selectedTextTrack {
_selectedTextTrack = selectedTextTrack;
if (_textTracks) { // sideloaded text tracks
[self setSideloadedText];
} else { // text tracks included in the HLS playlist
[self setMediaSelectionTrackForCharacteristic:AVMediaCharacteristicLegible
withCriteria:_selectedTextTrack];
}
}
- (void) setSideloadedText {
NSString *type = _selectedTextTrack[@"type"];
NSArray *textTracks = [self getTextTrackInfo];
// The first few tracks will be audio & video track
int firstTextIndex = 0;
for (firstTextIndex = 0; firstTextIndex < _player.currentItem.tracks.count; ++firstTextIndex) {
if ([_player.currentItem.tracks[firstTextIndex].assetTrack hasMediaCharacteristic:AVMediaCharacteristicLegible]) {
break;
}
}
int selectedTrackIndex = RCTVideoUnset;
if ([type isEqualToString:@"disabled"]) {
// Do nothing. We want to ensure option is nil
} else if ([type isEqualToString:@"language"]) {
NSString *selectedValue = _selectedTextTrack[@"value"];
for (int i = 0; i < textTracks.count; ++i) {
NSDictionary *currentTextTrack = [textTracks objectAtIndex:i];
if ([selectedValue isEqualToString:currentTextTrack[@"language"]]) {
selectedTrackIndex = i;
break;
}
}
} else if ([type isEqualToString:@"title"]) {
NSString *selectedValue = _selectedTextTrack[@"value"];
for (int i = 0; i < textTracks.count; ++i) {
NSDictionary *currentTextTrack = [textTracks objectAtIndex:i];
if ([selectedValue isEqualToString:currentTextTrack[@"title"]]) {
selectedTrackIndex = i;
break;
}
}
} else if ([type isEqualToString:@"index"]) {
if ([_selectedTextTrack[@"value"] isKindOfClass:[NSNumber class]]) {
int index = [_selectedTextTrack[@"value"] intValue];
if (textTracks.count > index) {
selectedTrackIndex = index;
}
}
}
// in the situation that a selected text track is not available (eg. specifies a textTrack not available)
if (![type isEqualToString:@"disabled"] && selectedTrackIndex == RCTVideoUnset) {
CFArrayRef captioningMediaCharacteristics = MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(kMACaptionAppearanceDomainUser);
NSArray *captionSettings = (__bridge NSArray*)captioningMediaCharacteristics;
if ([captionSettings containsObject:AVMediaCharacteristicTranscribesSpokenDialogForAccessibility]) {
selectedTrackIndex = 0; // If we can't find a match, use the first available track
NSString *systemLanguage = [[NSLocale preferredLanguages] firstObject];
for (int i = 0; i < textTracks.count; ++i) {
NSDictionary *currentTextTrack = [textTracks objectAtIndex:i];
if ([systemLanguage isEqualToString:currentTextTrack[@"language"]]) {
selectedTrackIndex = i;
break;
}
}
}
}
for (int i = firstTextIndex; i < _player.currentItem.tracks.count; ++i) {
BOOL isEnabled = NO;
if (selectedTrackIndex != RCTVideoUnset) {
isEnabled = i == selectedTrackIndex + firstTextIndex;
}
[_player.currentItem.tracks[i] setEnabled:isEnabled];
}
}
-(void) setStreamingText {
NSString *type = _selectedTextTrack[@"type"];
AVMediaSelectionGroup *group = [_player.currentItem.asset
mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicLegible];
AVMediaSelectionOption *mediaOption;
if ([type isEqualToString:@"disabled"]) {
// Do nothing. We want to ensure option is nil
} else if ([type isEqualToString:@"language"] || [type isEqualToString:@"title"]) {
NSString *value = _selectedTextTrack[@"value"];
for (int i = 0; i < group.options.count; ++i) {
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
NSString *optionValue;
if ([type isEqualToString:@"language"]) {
optionValue = [currentOption extendedLanguageTag];
} else {
optionValue = [[[currentOption commonMetadata]
valueForKey:@"value"]
objectAtIndex:0];
}
if ([value isEqualToString:optionValue]) {
mediaOption = currentOption;
break;
}
}
//} else if ([type isEqualToString:@"default"]) {
// option = group.defaultOption; */
} else if ([type isEqualToString:@"index"]) {
if ([_selectedTextTrack[@"value"] isKindOfClass:[NSNumber class]]) {
int index = [_selectedTextTrack[@"value"] intValue];
if (group.options.count > index) {
mediaOption = [group.options objectAtIndex:index];
}
}
} else { // default. invalid type or "system"
[_player.currentItem selectMediaOptionAutomaticallyInMediaSelectionGroup:group];
return;
}
// If a match isn't found, option will be nil and text tracks will be disabled
[_player.currentItem selectMediaOption:mediaOption inMediaSelectionGroup:group];
}
- (void)setTextTracks:(NSArray*) textTracks;
{
_textTracks = textTracks;
// in case textTracks was set after selectedTextTrack
if (_selectedTextTrack) [self setSelectedTextTrack:_selectedTextTrack];
}
- (NSArray *)getAudioTrackInfo
{
NSMutableArray *audioTracks = [[NSMutableArray alloc] init];
AVMediaSelectionGroup *group = [_player.currentItem.asset
mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible];
for (int i = 0; i < group.options.count; ++i) {
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
NSString *title = @"";
NSArray *values = [[currentOption commonMetadata] valueForKey:@"value"];
if (values.count > 0) {
title = [values objectAtIndex:0];
}
NSString *language = [currentOption extendedLanguageTag] ? [currentOption extendedLanguageTag] : @"";
NSDictionary *audioTrack = @{
@"index": [NSNumber numberWithInt:i],
@"title": title,
@"language": language
};
[audioTracks addObject:audioTrack];
}
return audioTracks;
}
- (NSArray *)getTextTrackInfo
{
// if sideloaded, textTracks will already be set
if (_textTracks) return _textTracks;
// if streaming video, we extract the text tracks
NSMutableArray *textTracks = [[NSMutableArray alloc] init];
AVMediaSelectionGroup *group = [_player.currentItem.asset
mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicLegible];
for (int i = 0; i < group.options.count; ++i) {
AVMediaSelectionOption *currentOption = [group.options objectAtIndex:i];
NSString *title = @"";
NSArray *values = [[currentOption commonMetadata] valueForKey:@"value"];
if (values.count > 0) {
title = [values objectAtIndex:0];
}
NSString *language = [currentOption extendedLanguageTag] ? [currentOption extendedLanguageTag] : @"";
NSDictionary *textTrack = @{
@"index": [NSNumber numberWithInt:i],
@"title": title,
@"language": language
};
[textTracks addObject:textTrack];
}
return textTracks;
}
- (BOOL)getFullscreen
{
return _fullscreenPlayerPresented;
}
- (void)setFullscreen:(BOOL) fullscreen {
if( fullscreen && !_fullscreenPlayerPresented && _player )
{
// Ensure player view controller is not null
if( !_playerViewController )
{
[self usePlayerViewController];
}
// Set presentation style to fullscreen
[_playerViewController setModalPresentationStyle:UIModalPresentationFullScreen];
// Find the nearest view controller
UIViewController *viewController = [self firstAvailableUIViewController];
if( !viewController )
{
UIWindow *keyWindow = [[UIApplication sharedApplication] keyWindow];
viewController = keyWindow.rootViewController;
if( viewController.childViewControllers.count > 0 )
{
viewController = viewController.childViewControllers.lastObject;
}
}
if( viewController )
{
_presentingViewController = viewController;
if(self.onVideoFullscreenPlayerWillPresent) {
self.onVideoFullscreenPlayerWillPresent(@{@"target": self.reactTag});
}
[viewController presentViewController:_playerViewController animated:true completion:^{
_playerViewController.showsPlaybackControls = YES;
_fullscreenPlayerPresented = fullscreen;
_playerViewController.autorotate = _fullscreenAutorotate;
if(self.onVideoFullscreenPlayerDidPresent) {
self.onVideoFullscreenPlayerDidPresent(@{@"target": self.reactTag});
}
}];
}
}
else if ( !fullscreen && _fullscreenPlayerPresented )
{
[self videoPlayerViewControllerWillDismiss:_playerViewController];
[_presentingViewController dismissViewControllerAnimated:true completion:^{
[self videoPlayerViewControllerDidDismiss:_playerViewController];
}];
}
}
- (void)setFullscreenAutorotate:(BOOL)autorotate {
_fullscreenAutorotate = autorotate;
if (_fullscreenPlayerPresented) {
_playerViewController.autorotate = autorotate;
}
}
- (void)setFullscreenOrientation:(NSString *)orientation {
_fullscreenOrientation = orientation;
if (_fullscreenPlayerPresented) {
_playerViewController.preferredOrientation = orientation;
}
}
- (void)usePlayerViewController
{
if( _player )
{
if (!_playerViewController) {
_playerViewController = [self createPlayerViewController:_player withPlayerItem:_playerItem];
}
// to prevent video from being animated when resizeMode is 'cover'
// resize mode must be set before subview is added
[self setResizeMode:_resizeMode];
if (_controls) {
UIViewController *viewController = [self reactViewController];
[viewController addChildViewController:_playerViewController];
[self addSubview:_playerViewController.view];
}
[_playerViewController addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil];
[_playerViewController.contentOverlayView addObserver:self forKeyPath:@"frame" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
}
}
- (void)usePlayerLayer
{
if( _player )
{
_playerLayer = [AVPlayerLayer playerLayerWithPlayer:_player];
_playerLayer.frame = self.bounds;
_playerLayer.needsDisplayOnBoundsChange = YES;
// to prevent video from being animated when resizeMode is 'cover'
// resize mode must be set before layer is added
[self setResizeMode:_resizeMode];
[_playerLayer addObserver:self forKeyPath:readyForDisplayKeyPath options:NSKeyValueObservingOptionNew context:nil];
_playerLayerObserverSet = YES;
[self.layer addSublayer:_playerLayer];
self.layer.needsDisplayOnBoundsChange = YES;
#if TARGET_OS_IOS
[self setupPipController];
#endif
}
}
- (void)setControls:(BOOL)controls
{
if( _controls != controls || (!_playerLayer && !_playerViewController) )
{
_controls = controls;
if( _controls )
{
[self removePlayerLayer];
[self usePlayerViewController];
}
else
{
[_playerViewController.view removeFromSuperview];
_playerViewController = nil;
[self usePlayerLayer];
}
}
}
- (void)setProgressUpdateInterval:(float)progressUpdateInterval
{
_progressUpdateInterval = progressUpdateInterval;
if (_timeObserver) {
[self removePlayerTimeObserver];
[self addPlayerTimeObserver];
}
}
- (void)removePlayerLayer
{
[_playerLayer removeFromSuperlayer];
if (_playerLayerObserverSet) {
[_playerLayer removeObserver:self forKeyPath:readyForDisplayKeyPath];
_playerLayerObserverSet = NO;
}
_playerLayer = nil;
}
#pragma mark - RCTVideoPlayerViewControllerDelegate
- (void)videoPlayerViewControllerWillDismiss:(AVPlayerViewController *)playerViewController
{
if (_playerViewController == playerViewController && _fullscreenPlayerPresented && self.onVideoFullscreenPlayerWillDismiss)
{
self.onVideoFullscreenPlayerWillDismiss(@{@"target": self.reactTag});
}
}
- (void)videoPlayerViewControllerDidDismiss:(AVPlayerViewController *)playerViewController
{
if (_playerViewController == playerViewController && _fullscreenPlayerPresented)
{
_fullscreenPlayerPresented = false;
_presentingViewController = nil;
_playerViewController = nil;
[self applyModifiers];
if(self.onVideoFullscreenPlayerDidDismiss) {
self.onVideoFullscreenPlayerDidDismiss(@{@"target": self.reactTag});
}
}
}
- (void)setFilter:(NSString *)filterName {
_filterName = filterName;
if (!_filterEnabled) {
return;
} else if ([[_source objectForKey:@"uri"] rangeOfString:@"m3u8"].location != NSNotFound) {
return; // filters don't work for HLS... return
} else if (!_playerItem.asset) {
return;
}
CIFilter *filter = [CIFilter filterWithName:filterName];
_playerItem.videoComposition = [AVVideoComposition
videoCompositionWithAsset:_playerItem.asset
applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest *_Nonnull request) {
if (filter == nil) {
[request finishWithImage:request.sourceImage context:nil];
} else {
CIImage *image = request.sourceImage.imageByClampingToExtent;
[filter setValue:image forKey:kCIInputImageKey];
CIImage *output = [filter.outputImage imageByCroppingToRect:request.sourceImage.extent];
[request finishWithImage:output context:nil];
}
}];
}
- (void)setFilterEnabled:(BOOL)filterEnabled {
_filterEnabled = filterEnabled;
}
#pragma mark - React View Management
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
// We are early in the game and somebody wants to set a subview.
// That can only be in the context of playerViewController.
if( !_controls && !_playerLayer && !_playerViewController )
{
[self setControls:true];
}
if( _controls )
{
view.frame = self.bounds;
[_playerViewController.contentOverlayView insertSubview:view atIndex:atIndex];
}
else
{
RCTLogError(@"video cannot have any subviews");
}
return;
}
- (void)removeReactSubview:(UIView *)subview
{
if( _controls )
{
[subview removeFromSuperview];
}
else
{
RCTLogError(@"video cannot have any subviews");
}
return;
}
- (void)layoutSubviews
{
[super layoutSubviews];
if( _controls )
{
_playerViewController.view.frame = self.bounds;
// also adjust all subviews of contentOverlayView
for (UIView* subview in _playerViewController.contentOverlayView.subviews) {
subview.frame = self.bounds;
}
}
else
{
[CATransaction begin];
[CATransaction setAnimationDuration:0];
_playerLayer.frame = self.bounds;
[CATransaction commit];
}
}
#pragma mark - Lifecycle
- (void)removeFromSuperview
{
[_player pause];
if (_playbackRateObserverRegistered) {
[_player removeObserver:self forKeyPath:playbackRate context:nil];
_playbackRateObserverRegistered = NO;
}
if (_isExternalPlaybackActiveObserverRegistered) {
[_player removeObserver:self forKeyPath:externalPlaybackActive context:nil];
_isExternalPlaybackActiveObserverRegistered = NO;
}
_player = nil;
[self removePlayerLayer];
[_playerViewController.contentOverlayView removeObserver:self forKeyPath:@"frame"];
[_playerViewController removeObserver:self forKeyPath:readyForDisplayKeyPath];
[_playerViewController.view removeFromSuperview];
_playerViewController.rctDelegate = nil;
_playerViewController.player = nil;
_playerViewController = nil;
[self removePlayerTimeObserver];
[self removePlayerItemObservers];
_eventDispatcher = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
[super removeFromSuperview];
}
#pragma mark - Export
- (void)save:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
AVAsset *asset = _playerItem.asset;
if (asset != nil) {
AVAssetExportSession *exportSession = [AVAssetExportSession
exportSessionWithAsset:asset presetName:AVAssetExportPresetHighestQuality];
if (exportSession != nil) {
NSString *path = nil;
NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
path = [self generatePathInDirectory:[[self cacheDirectoryPath] stringByAppendingPathComponent:@"Videos"]
withExtension:@".mp4"];
NSURL *url = [NSURL fileURLWithPath:path];
exportSession.outputFileType = AVFileTypeMPEG4;
exportSession.outputURL = url;
exportSession.videoComposition = _playerItem.videoComposition;
exportSession.shouldOptimizeForNetworkUse = true;
[exportSession exportAsynchronouslyWithCompletionHandler:^{
switch ([exportSession status]) {
case AVAssetExportSessionStatusFailed:
reject(@"ERROR_COULD_NOT_EXPORT_VIDEO", @"Could not export video", exportSession.error);
break;
case AVAssetExportSessionStatusCancelled:
reject(@"ERROR_EXPORT_SESSION_CANCELLED", @"Export session was cancelled", exportSession.error);
break;
default:
resolve(@{@"uri": url.absoluteString});
break;
}
}];
} else {
reject(@"ERROR_COULD_NOT_CREATE_EXPORT_SESSION", @"Could not create export session", nil);
}
} else {
reject(@"ERROR_ASSET_NIL", @"Asset is nil", nil);
}
}
- (BOOL)ensureDirExistsWithPath:(NSString *)path {
BOOL isDir = NO;
NSError *error;
BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:path isDirectory:&isDir];
if (!(exists && isDir)) {
[[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error];
if (error) {
return NO;
}
}
return YES;
}
- (NSString *)generatePathInDirectory:(NSString *)directory withExtension:(NSString *)extension {
NSString *fileName = [[[NSUUID UUID] UUIDString] stringByAppendingString:extension];
[self ensureDirExistsWithPath:directory];
return [directory stringByAppendingPathComponent:fileName];
}
- (NSString *)cacheDirectoryPath {
NSArray *array = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
return array[0];
}
#pragma mark - Picture in Picture
#if TARGET_OS_IOS
- (void)pictureInPictureControllerDidStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
if (self.onPictureInPictureStatusChanged) {
self.onPictureInPictureStatusChanged(@{
@"isActive": [NSNumber numberWithBool:false]
});
}
}
- (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
if (self.onPictureInPictureStatusChanged) {
self.onPictureInPictureStatusChanged(@{
@"isActive": [NSNumber numberWithBool:true]
});
}
}
- (void)pictureInPictureControllerWillStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
}
- (void)pictureInPictureControllerWillStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController {
}
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController failedToStartPictureInPictureWithError:(NSError *)error {
}
- (void)pictureInPictureController:(AVPictureInPictureController *)pictureInPictureController restoreUserInterfaceForPictureInPictureStopWithCompletionHandler:(void (^)(BOOL))completionHandler {
NSAssert(_restoreUserInterfaceForPIPStopCompletionHandler == NULL, @"restoreUserInterfaceForPIPStopCompletionHandler was not called after picture in picture was exited.");
if (self.onRestoreUserInterfaceForPictureInPictureStop) {
self.onRestoreUserInterfaceForPictureInPictureStop(@{});
}
_restoreUserInterfaceForPIPStopCompletionHandler = completionHandler;
}
#endif
@end