From 617b046789d7c5ece4335f33bc85e485c9862e5c Mon Sep 17 00:00:00 2001 From: Abdulrahman Alzenki Date: Fri, 26 Oct 2018 13:33:03 -0700 Subject: [PATCH] Implement picture in picture for iOS Test Plan: - Run on ipad - test out onIsPictureInPictureSupported, onIsPictureInPictureActive, restoreUserInterfaceForPictureInPictureStop, startPictureInPicture, stopPictureInPicture --- README.md | 73 +++++++++++++++++++++++++++++++++++++ Video.js | 28 ++++++++++++++ ios/Video/RCTVideo.h | 4 +- ios/Video/RCTVideo.m | 73 +++++++++++++++++++++++++++++++++++++ ios/Video/RCTVideoManager.m | 4 ++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ee34399..3e28061f 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,8 @@ var styles = StyleSheet.create({ * [onFullscreenPlayerDidPresent](#onfullscreenplayerdidpresent) * [onFullscreenPlayerWillDismiss](#onfullscreenplayerwilldismiss) * [onFullscreenPlayerDidDismiss](#onfullscreenplayerdiddismiss) +* [onIsPictureInPictureActive](#onispictureinpictureactive) +* [onIsPictureInPictureSupported](#onispictureinpicturesupported) * [onLoad](#onload) * [onLoadStart](#onloadstart) * [onProgress](#onprogress) @@ -308,7 +310,10 @@ var styles = StyleSheet.create({ * [dismissFullscreenPlayer](#dismissfullscreenplayer) * [presentFullscreenPlayer](#presentfullscreenplayer) * [save](#save) +* [restoreUserInterfaceForPictureInPictureStop](#restoreuserinterfaceforpictureinpicturestop) +* [startPictureInPicture](#startpictureinpicture) * [seek](#seek) +* [stopPictureInPicture](#stoppictureinpicture) ### Configurable props @@ -861,6 +866,38 @@ Payload: none Platforms: Android ExoPlayer, Android MediaPlayer, iOS +#### onIsPictureInPictureActive +Callback function that is called when picture in picture becames active on inactive. + +Property | Type | Description +--- | --- | --- +active | boolean | Boolean indicating whether picture in picture is active + +Example: +``` +{ + active: true +} +``` + +Platforms: iOS + +#### onIsPictureInPictureSupported +Callback function that is called initially to determine whether or not picture in picture is supported. + +Property | Type | Description +--- | --- | --- +supported | boolean | Boolean indicating whether picture in picture is supported + +Example: +``` +{ + supported: true +} +``` + +Platforms: iOS + #### onLoad Callback function that is called when the media is loaded and ready to play. @@ -1057,6 +1094,30 @@ Future: Platforms: iOS +#### restoreUserInterfaceForPictureInPictureStop +`restoreUserInterfaceForPictureInPictureStop(restore)` + +This function corresponds to Apple's [restoreUserInterfaceForPictureInPictureStop](https://developer.apple.com/documentation/avkit/avpictureinpicturecontrollerdelegate/1614703-pictureinpicturecontroller?language=objc). IMPORTANT: After picture in picture stops, this function must be called. + +Example: +``` +this.player.restoreUserInterfaceForPictureInPictureStop(true); +``` + +Platforms: iOS + +#### startPictureInPicture +`startPictureInPicture()` + +Calling this function will start picture in picture if it is supported. + +Example: +``` +this.player.startPictureInPicture(); +``` + +Platforms: iOS + #### seek() `seek(seconds)` @@ -1086,6 +1147,18 @@ this.player.seek(120, 50); // Seek to 2 minutes with +/- 50 milliseconds accurac Platforms: iOS +#### stopPictureInPicture +`stopPictureInPicture()` + +Calling this function will stop picture in picture if it is currently active. + +Example: +``` +this.player.stopPictureInPicture(); +``` + +Platforms: iOS + diff --git a/Video.js b/Video.js index 92544467..42e9731c 100644 --- a/Video.js +++ b/Video.js @@ -78,6 +78,18 @@ export default class Video extends Component { return await NativeModules.VideoManager.save(options, findNodeHandle(this._root)); } + startPictureInPicture = () => { + this.setNativeProps({ pictureInPicture: true }); + }; + + stopPictureInPicture = () => { + this.setNativeProps({ pictureInPicture: false }); + }; + + restoreUserInterfaceForPictureInPictureStop = (restore) => { + this.setNativeProps({ restoreUserInterfaceForPIPStopCompletionHandler: restore }); + }; + _assignRoot = (component) => { this._root = component; }; @@ -198,6 +210,18 @@ export default class Video extends Component { } }; + _onIsPictureInPictureSupported = (event) => { + if (this.props.onIsPictureInPictureSupported) { + this.props.onIsPictureInPictureSupported(event.nativeEvent); + } + }; + + _onIsPictureInPictureActive = (event) => { + if (this.props.onIsPictureInPictureActive) { + this.props.onIsPictureInPictureActive(event.nativeEvent); + } + }; + _onAudioFocusChanged = (event) => { if (this.props.onAudioFocusChanged) { this.props.onAudioFocusChanged(event.nativeEvent); @@ -267,6 +291,8 @@ export default class Video extends Component { onPlaybackRateChange: this._onPlaybackRateChange, onAudioFocusChanged: this._onAudioFocusChanged, onAudioBecomingNoisy: this._onAudioBecomingNoisy, + onIsPictureInPictureSupported: this._onIsPictureInPictureSupported, + onIsPictureInPictureActive: this._onIsPictureInPictureActive, }); const posterStyle = { @@ -420,6 +446,8 @@ Video.propTypes = { onPlaybackRateChange: PropTypes.func, onAudioFocusChanged: PropTypes.func, onAudioBecomingNoisy: PropTypes.func, + onIsPictureInPictureSupported: PropTypes.func, + onIsPictureInPictureActive: PropTypes.func, onExternalPlaybackChange: PropTypes.func, /* Required by react-native */ diff --git a/ios/Video/RCTVideo.h b/ios/Video/RCTVideo.h index 05527a57..9c84bc15 100644 --- a/ios/Video/RCTVideo.h +++ b/ios/Video/RCTVideo.h @@ -16,7 +16,7 @@ #if __has_include() @interface RCTVideo : UIView #else -@interface RCTVideo : UIView +@interface RCTVideo : UIView #endif @property (nonatomic, copy) RCTBubblingEventBlock onVideoLoadStart; @@ -38,6 +38,8 @@ @property (nonatomic, copy) RCTBubblingEventBlock onPlaybackResume; @property (nonatomic, copy) RCTBubblingEventBlock onPlaybackRateChange; @property (nonatomic, copy) RCTBubblingEventBlock onVideoExternalPlaybackChange; +@property (nonatomic, copy) RCTBubblingEventBlock onIsPictureInPictureSupported; +@property (nonatomic, copy) RCTBubblingEventBlock onIsPictureInPictureActive; - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER; diff --git a/ios/Video/RCTVideo.m b/ios/Video/RCTVideo.m index 52b0342a..c95e5909 100644 --- a/ios/Video/RCTVideo.m +++ b/ios/Video/RCTVideo.m @@ -27,6 +27,8 @@ static int const RCTVideoUnset = -1; AVPlayer *_player; AVPlayerItem *_playerItem; NSDictionary *_source; + AVPictureInPictureController *_pipController; + void (^__strong _Nonnull _restoreUserInterfaceForPIPStopCompletionHandler)(BOOL); BOOL _playerItemObserversSet; BOOL _playerBufferEmpty; AVPlayerLayer *_playerLayer; @@ -101,6 +103,7 @@ static int const RCTVideoUnset = -1; _allowsExternalPlayback = YES; _playWhenInactive = false; _ignoreSilentSwitch = @"inherit"; // inherit, ignore, obey + _restoreUserInterfaceForPIPStopCompletionHandler = NULL; #if __has_include() _videoCache = [RCTVideoCache sharedInstance]; #endif @@ -379,6 +382,12 @@ static int const RCTVideoUnset = -1; @"target": self.reactTag }); } + + if (@available(iOS 9, *)) { + if (self.onIsPictureInPictureSupported) { + self.onIsPictureInPictureSupported(@{@"supported": [NSNumber numberWithBool:(bool)[AVPictureInPictureController isPictureInPictureSupported]]}); + } + } }]; }); _videoLoadStarted = YES; @@ -780,6 +789,37 @@ static int const RCTVideoUnset = -1; _playWhenInactive = playWhenInactive; } +- (void)setPictureInPicture:(BOOL)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]; + }); + } +} + +- (void)setRestoreUserInterfaceForPIPStopCompletionHandler:(BOOL)restore +{ + if (_restoreUserInterfaceForPIPStopCompletionHandler != NULL) { + _restoreUserInterfaceForPIPStopCompletionHandler(restore); + _restoreUserInterfaceForPIPStopCompletionHandler = NULL; + } +} + +- (void)setupPipController { + if (@available(iOS 9, *)) { + if (!_pipController && _playerLayer && [AVPictureInPictureController isPictureInPictureSupported]) { + // Create new controller passing reference to the AVPlayerLayer + _pipController = [[AVPictureInPictureController alloc] initWithPlayerLayer:_playerLayer]; + _pipController.delegate = self; + } + } +} + - (void)setIgnoreSilentSwitch:(NSString *)ignoreSilentSwitch { _ignoreSilentSwitch = ignoreSilentSwitch; @@ -1234,6 +1274,8 @@ static int const RCTVideoUnset = -1; [self.layer addSublayer:_playerLayer]; self.layer.needsDisplayOnBoundsChange = YES; + + [self setupPipController]; } } @@ -1490,4 +1532,35 @@ static int const RCTVideoUnset = -1; return array[0]; } +#pragma mark - Picture in Picture + +- (void)pictureInPictureControllerDidStopPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { + if (self.onIsPictureInPictureActive && _pipController) { + self.onIsPictureInPictureActive(@{@"active": [NSNumber numberWithBool:false]}); + } +} + +- (void)pictureInPictureControllerDidStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { + if (self.onIsPictureInPictureActive && _pipController) { + self.onIsPictureInPictureActive(@{@"active": [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."); + _restoreUserInterfaceForPIPStopCompletionHandler = completionHandler; +} + @end diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 1ca1b5b4..ebb6126b 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -42,6 +42,8 @@ RCT_EXPORT_VIEW_PROPERTY(fullscreenOrientation, NSString); RCT_EXPORT_VIEW_PROPERTY(filter, NSString); RCT_EXPORT_VIEW_PROPERTY(filterEnabled, BOOL); RCT_EXPORT_VIEW_PROPERTY(progressUpdateInterval, float); +RCT_EXPORT_VIEW_PROPERTY(pictureInPicture, BOOL); +RCT_EXPORT_VIEW_PROPERTY(restoreUserInterfaceForPIPStopCompletionHandler, BOOL); /* Should support: onLoadStart, onLoad, and onError to stay consistent with Image */ RCT_EXPORT_VIEW_PROPERTY(onVideoLoadStart, RCTBubblingEventBlock); RCT_EXPORT_VIEW_PROPERTY(onVideoLoad, RCTBubblingEventBlock); @@ -77,6 +79,8 @@ RCT_REMAP_METHOD(save, } }]; } +RCT_EXPORT_VIEW_PROPERTY(onIsPictureInPictureSupported, RCTBubblingEventBlock); +RCT_EXPORT_VIEW_PROPERTY(onIsPictureInPictureActive, RCTBubblingEventBlock); - (NSDictionary *)constantsToExport {