diff --git a/NYT360Video.podspec b/NYT360Video.podspec index b7da563..6af3534 100644 --- a/NYT360Video.podspec +++ b/NYT360Video.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'NYT360Video' - s.version = '0.5.3' + s.version = '0.6.0' s.summary = 'NYT360Video plays 360ยบ video streamed from an AVPlayer.' s.description = <<-DESC diff --git a/NYT360VideoExample/Info.plist b/NYT360VideoExample/Info.plist index 19b385a..7f3057e 100644 --- a/NYT360VideoExample/Info.plist +++ b/NYT360VideoExample/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.5.3 + 0.6.0 CFBundleSignature ???? CFBundleVersion diff --git a/NYT360VideoExample/ViewController.m b/NYT360VideoExample/ViewController.m index 247c128..3fbcbff 100644 --- a/NYT360VideoExample/ViewController.m +++ b/NYT360VideoExample/ViewController.m @@ -35,6 +35,13 @@ - (void)viewDidLoad { [self.nyt360VC didMoveToParentViewController:self]; [self.player play]; + + UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(reorientVerticalCameraAngle:)]; + [self.view addGestureRecognizer:tapRecognizer]; +} + +- (void)reorientVerticalCameraAngle:(id)sender { + [self.nyt360VC reorientVerticalCameraAngleToHorizon:YES]; } @end diff --git a/NYT360VideoTests/Info.plist b/NYT360VideoTests/Info.plist index 24f2e09..3b73345 100644 --- a/NYT360VideoTests/Info.plist +++ b/NYT360VideoTests/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 0.5.3 + 0.6.0 CFBundleSignature ???? CFBundleVersion diff --git a/NYT360VideoTests/NYT360EulerAngleCalculationsTests.m b/NYT360VideoTests/NYT360EulerAngleCalculationsTests.m index 09a78f0..ff439ae 100644 --- a/NYT360VideoTests/NYT360EulerAngleCalculationsTests.m +++ b/NYT360VideoTests/NYT360EulerAngleCalculationsTests.m @@ -7,6 +7,7 @@ // @import XCTest; +@import SceneKit; #import "NYT360DataTypes.h" #import "NYT360EulerAngleCalculations.h" @@ -132,4 +133,74 @@ - (void)testItCalculatesTheOptimalYFovForAVarietyOfInputs { XCTAssertEqualWithAccuracy(NYT360OptimalYFovForViewSize(CGSizeMake(0, 1)), 120.0, 2.0); } +- (void)testItCalculatesTheCorrectCompassAngleForAVarietyOfInputs { + + SCNVector3 eulerAngles; + float pi = M_PI; + float compassAngle; + float referenceAngle; + + referenceAngle = NYT360EulerAngleCalculationDefaultReferenceCompassAngle; + + eulerAngles.y = 0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi, 0.001); + + eulerAngles.y = pi; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, 0, 0.001); + + eulerAngles.y = -pi; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, 0, 0.001); + + eulerAngles.y = pi * 0.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * 1.5, 0.001); + + eulerAngles.y = pi * -0.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * 0.5, 0.001); + + eulerAngles.y = pi * 1.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * 0.5, 0.001); + + eulerAngles.y = pi * -1.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * -0.5, 0.001); + + eulerAngles.y = pi * 2.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi, 0.001); + + eulerAngles.y = pi * -2.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, -pi, 0.001); + + eulerAngles.y = pi * 2.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * 1.5, 0.001); + + eulerAngles.y = pi * -2.5; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi * -1.5, 0.001); + + eulerAngles.y = pi * 3.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, 0, 0.001); + + eulerAngles.y = pi * -3.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, 0, 0.001); + + eulerAngles.y = pi * 4.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, pi, 0.001); + + eulerAngles.y = pi * -4.0; + compassAngle = NYT360CompassAngleForEulerAngles(eulerAngles, referenceAngle); + XCTAssertEqualWithAccuracy(compassAngle, -pi, 0.001); +} + @end diff --git a/README.md b/README.md index 920843d..2d1b8ba 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ NYT360Video is available through [CocoaPods](http://cocoapods.org). To install i pod 'NYT360Video' ``` +## Known Issues + +- **iOS 10 CoreAudio Crash** - On devices running iOS 10 (at least as of Beta 7), host applications will crash if the device is locked while an NYT360ViewController is visible (whether paused or not). The crash is caused by a CoreAudio exception. [An extended discussion of the issue can be found here](https://github.com/nytm/ios-360-videos/issues/37). A workaround that appears to work for some, though not all, apps is to enable the background audio capability in the host application's plist. + ## Authors - Maxwell Dayvson Da Silva: diff --git a/Sources/Info.plist b/Sources/Info.plist index c89d5f2..63c3e67 100644 --- a/Sources/Info.plist +++ b/Sources/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 0.5.3 + 0.6.0 CFBundleSignature ???? CFBundleVersion diff --git a/Sources/NYT360CameraController.h b/Sources/NYT360CameraController.h index f8e16c5..5cf1e64 100644 --- a/Sources/NYT360CameraController.h +++ b/Sources/NYT360CameraController.h @@ -14,17 +14,51 @@ #import "NYT360MotionManagement.h" @class NYT360CameraPanGestureRecognizer; +@class NYT360CameraController; + +/** + * The block type used for compass angle updates. + * + * @param compassAngle The compass angle in radians. + */ +typedef void(^NYT360CompassAngleUpdateBlock)(float compassAngle); NS_ASSUME_NONNULL_BEGIN +@protocol NYT360CameraControllerDelegate + +/** + * Called the first time the user moves the camera. + * + * @note This method is called synchronously when the camera angle is updated; an implementation should return quickly to avoid performance implications. + * + * @param controller The camera controller with which the user interacted. + * @param method The method by which the user moved the camera. + */ +- (void)cameraController:(NYT360CameraController *)controller userInitallyMovedCameraViaMethod:(NYT360UserInteractionMethod)method; + +@end + @interface NYT360CameraController : NSObject -#pragma mark - Camera Angle Direction +/** + * The delegate of the controller. + */ +@property (nullable, nonatomic, weak) id delegate; + +#pragma mark - Compass Angle + +/** + * Returns the current compass angle in radians + */ +@property (nonatomic, readonly) float compassAngle; /** - Returns the latest camera angle direction. + * A block invoked whenever the compass angle has been updated. + * + * @note This method is called synchronously from SCNSceneRendererDelegate. Its implementation should return quickly to avoid performance implications. */ -@property (nonatomic, readonly) double cameraAngleDirection; +@property (nonatomic, copy, nullable) NYT360CompassAngleUpdateBlock compassAngleUpdateBlock; #pragma mark - Initializers @@ -56,15 +90,22 @@ NS_ASSUME_NONNULL_BEGIN /** * Updates the camera angle based on the current device motion. It's assumed that this method will be called many times a second during SceneKit rendering updates. */ -- (void)updateCameraAngle; - -#pragma mark - Panning Options +- (void)updateCameraAngleForCurrentDeviceMotion; /** * Updates the yFov of the camera to provide the optimal viewing angle for a given view size. Portrait videos will use a wider angle than landscape videos. */ - (void)updateCameraFOV:(CGSize)viewSize; +/** + * Reorients the camera's vertical angle component so it's pointing directly at the horizon. + * + * @param animated Passing `YES` will animate the change with a standard duration. + */ +- (void)reorientVerticalCameraAngleToHorizon:(BOOL)animated; + +#pragma mark - Panning Options + /** * An otherwise vanilla subclass of UIPanGestureRecognizer used by NYT360Video to enable manual camera panning. This class is exposed so that host applications can more easily configure interaction with other gesture recognizers without having to have references to specific instances of an NYT360Video pan recognizer. */ @@ -72,13 +113,22 @@ NS_ASSUME_NONNULL_BEGIN /** - * Changing this property will allow you to suppress undesired range of motion along either the x or y axis. For example, y axis input should be suppressed when a 360 video is playing inline in a scroll view. + * Changing this property will allow you to suppress undesired range of motion along either the x or y axis for device motion input. For example, y axis input might be suppressed when a 360 video is playing inline in a scroll view. + + * When this property is set, any disallowed axis will cause the current camera angles to be clamped to zero for that axis. Existing angles for the any allowed axes will not be affected. + + * Defaults to NYT360PanningAxisHorizontal | NYT360PanningAxisVertical. + */ +@property (nonatomic, assign) NYT360PanningAxis allowedDeviceMotionPanningAxes; + +/** + * Changing this property will allow you to suppress undesired range of motion along either the x or y axis for pan gesture recognizer input. For example, y axis input should probably be suppressed when a 360 video is playing inline in a scroll view. * When this property is set, any disallowed axis will cause the current camera angles to be clamped to zero for that axis. Existing angles for the any allowed axes will not be affected. * Defaults to NYT360PanningAxisHorizontal | NYT360PanningAxisVertical. */ -@property (nonatomic, assign) NYT360PanningAxis allowedPanningAxes; +@property (nonatomic, assign) NYT360PanningAxis allowedPanGesturePanningAxes; @end diff --git a/Sources/NYT360CameraController.m b/Sources/NYT360CameraController.m index 62c6adc..5c4e321 100644 --- a/Sources/NYT360CameraController.m +++ b/Sources/NYT360CameraController.m @@ -10,7 +10,9 @@ #import "NYT360EulerAngleCalculations.h" #import "NYT360CameraPanGestureRecognizer.h" -static const NSTimeInterval NYT360CameraControllerPreferredMotionUpdateInterval = (1.0 / 60.0); +static inline CGFloat distance(CGPoint a, CGPoint b) { + return sqrt(pow(a.x - b.x, 2) + pow(a.y - b.y, 2)); +} static inline CGPoint subtractPoints(CGPoint a, CGPoint b) { return CGPointMake(b.x - a.x, b.y - a.y); @@ -28,6 +30,9 @@ @interface NYT360CameraController () @property (nonatomic, assign) CGPoint rotateDelta; @property (nonatomic, assign) CGPoint currentPosition; +@property (nonatomic, assign) BOOL isAnimatingReorientation; +@property (nonatomic, assign) BOOL hasReportedInitialCameraMovement; + @end @implementation NYT360CameraController @@ -44,13 +49,16 @@ - (instancetype)initWithView:(SCNView *)view motionManager:(id minimalRotationDistanceToReport) { + [self reportInitialCameraMovementIfNeededViaMethod:NYT360UserInteractionMethodGyroscope]; + } } - (void)updateCameraFOV:(CGSize)viewSize { self.pointOfView.camera.yFov = NYT360OptimalYFovForViewSize(viewSize); } +- (void)reorientVerticalCameraAngleToHorizon:(BOOL)animated { + + if (animated) { + self.isAnimatingReorientation = YES; + [SCNTransaction begin]; + [SCNTransaction setAnimationDuration:[CATransaction animationDuration]]; + } + + CGPoint position = self.currentPosition; + position.y = 0; + self.currentPosition = position; + + SCNVector3 eulerAngles = self.pointOfView.eulerAngles; + eulerAngles.x = 0; // Vertical camera angle = rotation around the x axis. + self.pointOfView.eulerAngles = eulerAngles; + + if (animated) { + [SCNTransaction setCompletionBlock:^{ + // Reset the transaction duration to 0 since otherwise further + // updates from device motion and pan gesture recognition would be + // subject to a non-zero implicit duration. + [SCNTransaction setAnimationDuration:0]; + self.isAnimatingReorientation = NO; + }]; + [SCNTransaction commit]; + } + +} + #pragma mark - Panning Options -- (void)setAllowedPanningAxes:(NYT360PanningAxis)allowedPanningAxes { +- (void)setAllowedDeviceMotionPanningAxes:(NYT360PanningAxis)allowedDeviceMotionPanningAxes { // TODO: [jaredsinclair] Consider adding an animated version of this method. - if (_allowedPanningAxes != allowedPanningAxes) { - _allowedPanningAxes = allowedPanningAxes; - NYT360EulerAngleCalculationResult result = NYT360UpdatedPositionAndAnglesForAllowedAxes(self.currentPosition, allowedPanningAxes); + if (_allowedDeviceMotionPanningAxes != allowedDeviceMotionPanningAxes) { + _allowedDeviceMotionPanningAxes = allowedDeviceMotionPanningAxes; + NYT360EulerAngleCalculationResult result = NYT360UpdatedPositionAndAnglesForAllowedAxes(self.currentPosition, allowedDeviceMotionPanningAxes); self.currentPosition = result.position; self.pointOfView.eulerAngles = result.eulerAngles; + } +} +- (void)setAllowedPanGesturePanningAxes:(NYT360PanningAxis)allowedPanGesturePanningAxes { + // TODO: [jaredsinclair] Consider adding an animated version of this method. + if (_allowedPanGesturePanningAxes != allowedPanGesturePanningAxes) { + _allowedPanGesturePanningAxes = allowedPanGesturePanningAxes; + NYT360EulerAngleCalculationResult result = NYT360UpdatedPositionAndAnglesForAllowedAxes(self.currentPosition, allowedPanGesturePanningAxes); + self.currentPosition = result.position; + self.pointOfView.eulerAngles = result.eulerAngles; } } #pragma mark - Private - (void)handlePan:(UIPanGestureRecognizer *)recognizer { + + // Ignore input during reorientation animations since SceneKit doesn't + // provide a way to do so smoothly. The "jump" to the updated values would + // be jarring otherwise. + if (self.isAnimatingReorientation) { return; } + CGPoint point = [recognizer locationInView:self.view]; switch (recognizer.state) { case UIGestureRecognizerStateBegan: @@ -127,13 +192,25 @@ - (void)handlePan:(UIPanGestureRecognizer *)recognizer { self.rotateCurrent = point; self.rotateDelta = subtractPoints(self.rotateStart, self.rotateCurrent); self.rotateStart = self.rotateCurrent; - NYT360EulerAngleCalculationResult result = NYT360PanGestureChangeCalculation(self.currentPosition, self.rotateDelta, self.view.bounds.size, self.allowedPanningAxes); + NYT360EulerAngleCalculationResult result = NYT360PanGestureChangeCalculation(self.currentPosition, self.rotateDelta, self.view.bounds.size, self.allowedPanGesturePanningAxes); self.currentPosition = result.position; self.pointOfView.eulerAngles = result.eulerAngles; + if (self.compassAngleUpdateBlock) { + self.compassAngleUpdateBlock(self.compassAngle); + } + [self reportInitialCameraMovementIfNeededViaMethod:NYT360UserInteractionMethodTouch]; break; default: break; } } +- (void)reportInitialCameraMovementIfNeededViaMethod:(NYT360UserInteractionMethod)method { + // only fire once per video: + if (!self.hasReportedInitialCameraMovement) { + self.hasReportedInitialCameraMovement = YES; + [self.delegate cameraController:self userInitallyMovedCameraViaMethod:method]; + } +} + @end diff --git a/Sources/NYT360DataTypes.h b/Sources/NYT360DataTypes.h index 3c1ef96..f03d670 100644 --- a/Sources/NYT360DataTypes.h +++ b/Sources/NYT360DataTypes.h @@ -10,5 +10,10 @@ typedef NS_OPTIONS(NSInteger, NYT360PanningAxis) { NYT360PanningAxisHorizontal = 1 << 0, - NYT360PanningAxisVertical = 1 << 1 + NYT360PanningAxisVertical = 1 << 1, +}; + +typedef NS_ENUM(NSInteger, NYT360UserInteractionMethod) { + NYT360UserInteractionMethodGyroscope = 0, + NYT360UserInteractionMethodTouch, }; diff --git a/Sources/NYT360EulerAngleCalculations.h b/Sources/NYT360EulerAngleCalculations.h index 08761eb..c8912d7 100644 --- a/Sources/NYT360EulerAngleCalculations.h +++ b/Sources/NYT360EulerAngleCalculations.h @@ -11,6 +11,7 @@ @import CoreMotion; extern CGFloat const NYT360EulerAngleCalculationNoiseThresholdDefault; +extern float const NYT360EulerAngleCalculationDefaultReferenceCompassAngle; #import "NYT360DataTypes.h" @@ -31,3 +32,5 @@ NYT360EulerAngleCalculationResult NYT360DeviceMotionCalculation(CGPoint position NYT360EulerAngleCalculationResult NYT360PanGestureChangeCalculation(CGPoint position, CGPoint rotateDelta, CGSize viewSize, NYT360PanningAxis allowedPanningAxes); CGFloat NYT360OptimalYFovForViewSize(CGSize viewSize); + +float NYT360CompassAngleForEulerAngles(SCNVector3 eulerAngles, float referenceAngle); diff --git a/Sources/NYT360EulerAngleCalculations.m b/Sources/NYT360EulerAngleCalculations.m index 9a068c6..7037b94 100644 --- a/Sources/NYT360EulerAngleCalculations.m +++ b/Sources/NYT360EulerAngleCalculations.m @@ -11,6 +11,7 @@ #pragma mark - Constants CGFloat const NYT360EulerAngleCalculationNoiseThresholdDefault = 0.12; +float const NYT360EulerAngleCalculationDefaultReferenceCompassAngle = M_PI; #pragma mark - Inline Functions @@ -37,6 +38,23 @@ static inline CGPoint NYT360AdjustPositionForAllowedAxes(CGPoint position, NYT36 return position; } +static inline float NYT360UnitRotationForCameraRotation(float cameraRotation) { + + // Use a modulus so that we don't pass the host application a compass angle + // value that is greater than one rotation, which wouldn't make sense in the + // context of a compass animation. + float oneRotation = 2.0 * M_PI; + float rawResult = fmodf(cameraRotation, oneRotation); + + // `rawResult` will be less than `oneRotation`, but if it's very very close + // to `oneRotation` than wrap the result back around to zero radians. + float accuracy = 0.0001; + float difference = oneRotation - fabsf(rawResult); + float wrappedAround = (difference < accuracy) ? 0 : rawResult; + + return wrappedAround; +} + #pragma mark - Calculations NYT360EulerAngleCalculationResult NYT360UpdatedPositionAndAnglesForAllowedAxes(CGPoint position, NYT360PanningAxis allowedPanningAxes) { @@ -136,3 +154,7 @@ CGFloat NYT360OptimalYFovForViewSize(CGSize viewSize) { } return yFov; } + +float NYT360CompassAngleForEulerAngles(SCNVector3 eulerAngles, float referenceAngle) { + return NYT360UnitRotationForCameraRotation(eulerAngles.y + referenceAngle); +} diff --git a/Sources/NYT360PlayerScene.m b/Sources/NYT360PlayerScene.m index dc00f10..4429529 100644 --- a/Sources/NYT360PlayerScene.m +++ b/Sources/NYT360PlayerScene.m @@ -7,6 +7,7 @@ // @import SpriteKit; +@import AVFoundation; #import "NYT360PlayerScene.h" @@ -28,6 +29,8 @@ - (BOOL)videoNodeShouldAllowPlaybackToBegin:(NYTSKVideoNode *)videoNode; /** * There is a bug in SceneKit wherein a paused video node will begin playing again when the application becomes active. This is caused by cascading calls to `[fooNode setPaused:NO]` across all nodes in a scene. To prevent the video node from unpausing along with the rest of the nodes, we must subclass SKVideoNode and override `setPaused:`, only unpausing the node if `nytDelegate` allows it. + * + * This SceneKit bug is present on iOS 9 as well as iOS 10 (at least up to beta 7, the latest at the time of this writing). */ @interface NYTSKVideoNode: SKVideoNode @@ -60,6 +63,7 @@ @interface NYT360PlayerScene () @property (nonatomic, assign) BOOL videoPlaybackIsPaused; @property (nonatomic, readonly) SCNNode *cameraNode; @property (nonatomic, readonly) NYTSKVideoNode *videoNode; +@property (nonatomic, readonly) AVPlayer *player; @end @@ -70,6 +74,8 @@ - (instancetype)initWithAVPlayer:(AVPlayer *)player boundToView:(SCNView *)view _videoPlaybackIsPaused = YES; + _player = player; + _camera = [SCNCamera new]; _cameraNode = ({ @@ -123,10 +129,34 @@ - (void)play { // See note in NYTSKVideoNode above. self.videoPlaybackIsPaused = NO; - // Internally, SceneKit prefers to use `setPaused:` to toggle playback on a - // video node. Mimic this usage here to ensure consistency and avoid putting - // the player into an out-of-sync state. - self.videoNode.paused = NO; + if ([self isIOS10OrLater]) { + // On iOS 10, AVPlayer playback on a video node seems to work most + // reliably by directly invoking `play` and `pause` on the player, + // rather than the alternatives: calling the `play` and `pause` methods + // or the `setPaused:` setter on the video node. Those alternatives + // either do not work at all in certain cases, or they lead to spurious + // rate changes on the AVPlayer, which fire KVO notifications that + // NYTVideoViewController is not designed to handle. In an ideal world, + // we could refactor NYTVideoViewController to handle such edge cases. + // In practice, it is preferable to use the following technique so that + // 360 AVPlayer behavior on iOS 10 is the same as on iOS 9, and the same + // as standard AVPlayer behavior on both operating systems. + [self.player play]; + // On iOS 10, you must also update the `paused` property of the video + // node to match the playback state of its AVPlayer if you have invoked + // play/pause directly on the AVPlayer, otherwise the video node's + // internal state and the AVPlayer's timeControlStatus can get out of + // sync. One symptom of this problem is where the video can look like + // it's paused even though the audio is still playing in the background. + // The steps to reproduce this particular bug are finicky but reliable. + self.videoNode.paused = NO; + } + else { + // Prior to iOS 10, SceneKit prefers to use `setPaused:` alone to toggle + // playback on a video node. Mimic this usage here to ensure consistency + // and avoid putting the player into an out-of-sync state. + self.videoNode.paused = NO; + } } @@ -135,16 +165,52 @@ - (void)pause { // See note in NYTSKVideoNode above. self.videoPlaybackIsPaused = YES; - // Internally, SceneKit prefers to use `setPaused:` to toggle playback on a - // video node. Mimic this usage here to ensure consistency and avoid putting - // the player into an out-of-sync state. - self.videoNode.paused = YES; + if ([self isIOS10OrLater]) { + // On iOS 10, AVPlayer playback on a video node seems to work most + // reliably by directly invoking `play` and `pause` on the player, + // rather than the alternatives: calling the `play` and `pause` methods + // or the `setPaused:` setter on the video node. Those alternatives + // either do not work at all in certain cases, or they lead to spurious + // rate changes on the AVPlayer, which fire KVO notifications that + // NYTVideoViewController is not designed to handle. In an ideal world, + // we could refactor NYTVideoViewController to handle such edge cases. + // In practice, it is preferable to use the following technique so that + // 360 AVPlayer behavior on iOS 10 is the same as on iOS 9, and the same + // as standard AVPlayer behavior on both operating systems. + [self.player pause]; + // On iOS 10, you must also update the `paused` property of the video + // node to match the playback state of its AVPlayer if you have invoked + // play/pause directly on the AVPlayer, otherwise the video node's + // internal state and the AVPlayer's timeControlStatus can get out of + // sync. One symptom of this problem is where the video can look like + // it's paused even though the audio is still playing in the background. + // The steps to reproduce this particular bug are finicky but reliable. + self.videoNode.paused = YES; + } + else { + // Prior to iOS 10, SceneKit prefers to use `setPaused:` alone to toggle + // playback on a video node. Mimic this usage here to ensure consistency + // and avoid putting the player into an out-of-sync state. + self.videoNode.paused = YES; + } } +#pragma mark - NYTSKVideoNodeDelegate + - (BOOL)videoNodeShouldAllowPlaybackToBegin:(NYTSKVideoNode *)videoNode { // See note in NYTSKVideoNode above. return !self.videoPlaybackIsPaused; } +#pragma mark - Convenience + +- (BOOL)isIOS10OrLater { + NSOperatingSystemVersion ios10; + ios10.majorVersion = 10; + ios10.minorVersion = 0; + ios10.patchVersion = 0; + return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:ios10]; +} + @end diff --git a/Sources/NYT360ViewController.h b/Sources/NYT360ViewController.h index ea7797b..46d1fd6 100644 --- a/Sources/NYT360ViewController.h +++ b/Sources/NYT360ViewController.h @@ -24,11 +24,22 @@ NS_ASSUME_NONNULL_BEGIN @protocol NYT360ViewControllerDelegate /** - * Called when the camera angle was updated. + * Called when the compass angle is updated. * * @param viewController The view controller that updated the angle. + * @param compassAngle The current compass angle. + * + * @note This method is called synchronously from SCNSceneRendererDelegate; its implementation should return quickly to avoid performance implications. + */ +- (void)nyt360ViewController:(NYT360ViewController *)viewController didUpdateCompassAngle:(float)compassAngle; + +/** + * Called when the user first moves the camera. + * + * @param viewController The view controller with which the user interacted. + * @param method The method by which the user moved the camera. */ -- (void)cameraAngleWasUpdated:(NYT360ViewController *)viewController; +- (void)videoViewController:(NYT360ViewController *)viewController userInitallyMovedCameraViaMethod:(NYT360UserInteractionMethod)method; @end @@ -51,9 +62,9 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Camera Movement /** - Returns the latest camera angle direction. + Returns the current compass angle. */ -@property (nonatomic, readonly) double cameraAngleDirection; +@property (nonatomic, readonly) float compassAngle; /** * An otherwise vanilla subclass of UIPanGestureRecognizer used by NYT360Video to enable manual camera panning. This class is exposed so that host applications can more easily configure interaction with other gesture recognizers without having to have references to specific instances of an NYT360Video pan recognizer. @@ -61,13 +72,29 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) NYT360CameraPanGestureRecognizer *panRecognizer; /** - * Changing this property will allow you to suppress undesired range of motion along either the x or y axis. For example, y axis input should be suppressed when a 360 video is playing inline in a scroll view. + * Changing this property will allow you to suppress undesired range of motion along either the x or y axis for device motion input. For example, y axis input might be suppressed when a 360 video is playing inline in a scroll view. + + * When this property is set, any disallowed axis will cause the current camera angles to be clamped to zero for that axis. Existing angles for the any allowed axes will not be affected. + + * Defaults to NYT360PanningAxisHorizontal | NYT360PanningAxisVertical. + */ +@property (nonatomic, assign) NYT360PanningAxis allowedDeviceMotionPanningAxes; + +/** + * Changing this property will allow you to suppress undesired range of motion along either the x or y axis for pan gesture recognizer input. For example, y axis input should probably be suppressed when a 360 video is playing inline in a scroll view. * When this property is set, any disallowed axis will cause the current camera angles to be clamped to zero for that axis. Existing angles for the any allowed axes will not be affected. * Defaults to NYT360PanningAxisHorizontal | NYT360PanningAxisVertical. */ -@property (nonatomic, assign) NYT360PanningAxis allowedPanningAxes; +@property (nonatomic, assign) NYT360PanningAxis allowedPanGesturePanningAxes; + +/** + * Reorients the camera's vertical angle component so it's pointing directly at the horizon. + * + * @param animated Passing `YES` will animate the change with a standard duration. + */ +- (void)reorientVerticalCameraAngleToHorizon:(BOOL)animated; @end diff --git a/Sources/NYT360ViewController.m b/Sources/NYT360ViewController.m index fd83a40..0b90cde 100644 --- a/Sources/NYT360ViewController.m +++ b/Sources/NYT360ViewController.m @@ -40,7 +40,7 @@ CGRect NYT360ViewControllerSceneBoundsForScreenBounds(CGRect screenBounds) { return CGRectMake(0, 0, max, min); } -@interface NYT360ViewController () +@interface NYT360ViewController () @property (nonatomic, readonly) CGSize underlyingSceneSize; @property (nonatomic, readonly) SCNView *sceneView; @@ -62,6 +62,14 @@ - (instancetype)initWithAVPlayer:(AVPlayer *)player motionManager:(id)renderer updateAtTime:(NSTimeInterval)time { - [self.cameraController updateCameraAngle]; - - [self.delegate cameraAngleWasUpdated:self]; + [self.cameraController updateCameraAngleForCurrentDeviceMotion]; +} + +#pragma mark - NYT360CameraControllerDelegate + +- (void)cameraController:(NYT360CameraController *)controller userInitallyMovedCameraViaMethod:(NYT360UserInteractionMethod)method { + [self.delegate videoViewController:self userInitallyMovedCameraViaMethod:method]; } @end