From 918e3f7dfe77745f0ec4810ccd46666c50ab58ef Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 5 Jan 2018 09:10:13 -0600 Subject: [PATCH] Videos play in full-screen media view controller, use modern movie player. // FREEBIE --- .../Cells/ConversationViewCell.h | 6 +- .../ConversationView/Cells/OWSMessageCell.m | 6 +- .../ConversationViewController.m | 78 +- .../ViewControllers/FullImageViewController.m | 802 ++++++++++++------ 4 files changed, 573 insertions(+), 319 deletions(-) diff --git a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h index 22b18ae08..db315d5f7 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h +++ b/Signal/src/ViewControllers/ConversationView/Cells/ConversationViewCell.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // NS_ASSUME_NONNULL_BEGIN @@ -18,7 +18,9 @@ NS_ASSUME_NONNULL_BEGIN - (void)didTapImageViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream imageView:(UIView *)imageView; -- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream; +- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem + attachmentStream:(TSAttachmentStream *)attachmentStream + imageView:(UIView *)imageView; - (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream; - (void)didTapTruncatedTextMessage:(ConversationViewItem *)conversationItem; - (void)didTapFailedIncomingAttachment:(ConversationViewItem *)viewItem diff --git a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m index da153cbc4..2aedc8d45 100644 --- a/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m +++ b/Signal/src/ViewControllers/ConversationView/Cells/OWSMessageCell.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "OWSMessageCell.h" @@ -1172,7 +1172,9 @@ NS_ASSUME_NONNULL_BEGIN [self.delegate didTapAudioViewItem:self.viewItem attachmentStream:self.attachmentStream]; return; case OWSMessageCellType_Video: - [self.delegate didTapVideoViewItem:self.viewItem attachmentStream:self.attachmentStream]; + [self.delegate didTapVideoViewItem:self.viewItem + attachmentStream:self.attachmentStream + imageView:self.stillImageView]; return; case OWSMessageCellType_GenericAttachment: [AttachmentSharing showShareUIForAttachment:self.attachmentStream]; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 0ada12975..07ff935cb 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -169,7 +169,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { @property (nonatomic) NSArray *viewItems; @property (nonatomic) NSMutableDictionary *viewItemCache; -@property (nonatomic, nullable) MPMoviePlayerController *videoPlayer; @property (nonatomic, nullable) AVAudioRecorder *audioRecorder; @property (nonatomic, nullable) OWSAudioAttachmentPlayer *audioAttachmentPlayer; @property (nonatomic, nullable) NSUUID *voiceMessageUUID; @@ -1999,6 +1998,8 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { OWSAssert(attachmentStream); OWSAssert(imageView); + [self dismissKeyBoard]; + UIWindow *window = [UIApplication sharedApplication].keyWindow; CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window]; FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachmentStream:attachmentStream @@ -2007,43 +2008,22 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [vc presentFromViewController:self]; } -- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream +- (void)didTapVideoViewItem:(ConversationViewItem *)viewItem + attachmentStream:(TSAttachmentStream *)attachmentStream + imageView:(UIImageView *)imageView { OWSAssert([NSThread isMainThread]); OWSAssert(viewItem); OWSAssert(attachmentStream); - NSFileManager *fileManager = [NSFileManager defaultManager]; - if (![fileManager fileExistsAtPath:[attachmentStream.mediaURL path]]) { - OWSFail(@"%@ Missing video file: %@", self.logTag, attachmentStream.mediaURL); - } - [self dismissKeyBoard]; - self.videoPlayer = [[MPMoviePlayerController alloc] initWithContentURL:attachmentStream.mediaURL]; - [_videoPlayer prepareToPlay]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(moviePlayerWillExitFullscreen:) - name:MPMoviePlayerWillExitFullscreenNotification - object:_videoPlayer]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(moviePlayerDidExitFullscreen:) - name:MPMoviePlayerDidExitFullscreenNotification - object:_videoPlayer]; - - _videoPlayer.controlStyle = MPMovieControlStyleDefault; - _videoPlayer.shouldAutoplay = YES; - [self.view addSubview:_videoPlayer.view]; - // We can't animate from the cell media frame; - // MPMoviePlayerController will animate a crop of its - // contents rather than scaling them. - _videoPlayer.view.frame = self.view.bounds; + UIWindow *window = [UIApplication sharedApplication].keyWindow; + CGRect convertedRect = [imageView convertRect:imageView.bounds toView:window]; - // FIXME inputAccessoryView - we lose and regain first responder here, causing keyboard to appear above video - // Approaches: - // - put the video player in a separate VC (like the full image view controller) - // - some kind of "showing video" flag to supress first responder. - [_videoPlayer setFullscreen:YES animated:NO]; + FullImageViewController *vc = [[FullImageViewController alloc] initWithAttachmentStream:attachmentStream + fromRect:convertedRect + viewItem:viewItem]; + [vc presentFromViewController:self]; } - (void)didTapAudioViewItem:(ConversationViewItem *)viewItem attachmentStream:(TSAttachmentStream *)attachmentStream @@ -2124,42 +2104,6 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { [self.navigationController pushViewController:view animated:YES]; } -#pragma mark - Video Playback - -// There's more than one way to exit the fullscreen video playback. -// There's a done button, a "toggle fullscreen" button and I think -// there's some gestures too. These fire slightly different notifications. -// We want to hide & clean up the video player immediately in all of -// these cases. -- (void)moviePlayerWillExitFullscreen:(id)sender -{ - DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); - - [self clearVideoPlayer]; -} - -// See comment on moviePlayerWillExitFullscreen: -- (void)moviePlayerDidExitFullscreen:(id)sender -{ - DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); - - [self clearVideoPlayer]; -} - -- (void)clearVideoPlayer -{ - [_videoPlayer stop]; - [_videoPlayer.view removeFromSuperview]; - self.videoPlayer = nil; -} - -- (void)setVideoPlayer:(MPMoviePlayerController *_Nullable)videoPlayer -{ - _videoPlayer = videoPlayer; - - [ViewControllerUtils setAudioIgnoresHardwareMuteSwitch:videoPlayer != nil]; -} - #pragma mark - System Messages - (void)didTapSystemMessageWithInteraction:(TSInteraction *)interaction diff --git a/Signal/src/ViewControllers/FullImageViewController.m b/Signal/src/ViewControllers/FullImageViewController.m index 49a4c6bcb..50dc56e86 100644 --- a/Signal/src/ViewControllers/FullImageViewController.m +++ b/Signal/src/ViewControllers/FullImageViewController.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "FullImageViewController.h" @@ -11,6 +11,8 @@ #import "UIColor+OWS.h" #import "UIUtil.h" #import "UIView+OWS.h" +#import +#import #import #import @@ -19,8 +21,6 @@ NS_ASSUME_NONNULL_BEGIN #define kMinZoomScale 1.0f #define kMaxZoomScale 8.0f -#define kBackgroundAlpha 0.6f - // In order to use UIMenuController, the view from which it is // presented must have certain custom behaviors. @interface AttachmentMenuView : UIView @@ -47,14 +47,12 @@ NS_ASSUME_NONNULL_BEGIN @interface FullImageViewController () -@property (nonatomic) UIView *backgroundView; @property (nonatomic) UIScrollView *scrollView; @property (nonatomic) UIImageView *imageView; + @property (nonatomic) UIButton *shareButton; -@property (nonatomic) UIView *contentView; @property (nonatomic) CGRect originRect; -@property (nonatomic) BOOL isPresenting; @property (nonatomic) NSData *fileData; @property (nonatomic, nullable) TSAttachmentStream *attachmentStream; @@ -62,6 +60,15 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, nullable) ConversationViewItem *viewItem; @property (nonatomic) UIToolbar *footerBar; +@property (nonatomic) BOOL areToolbarsHidden; +@property (nonatomic, nullable) MPMoviePlayerController *mpVideoPlayer; +@property (nonatomic, nullable) AVPlayer *videoPlayer; + +@property (nonatomic, nullable) NSArray *imageViewConstraints; +@property (nonatomic, nullable) NSLayoutConstraint *imageViewBottomConstraint; +@property (nonatomic, nullable) NSLayoutConstraint *imageViewLeadingConstraint; +@property (nonatomic, nullable) NSLayoutConstraint *imageViewTopConstraint; +@property (nonatomic, nullable) NSLayoutConstraint *imageViewTrailingConstraint; @end @@ -71,7 +78,6 @@ NS_ASSUME_NONNULL_BEGIN fromRect:(CGRect)rect viewItem:(ConversationViewItem *_Nullable)viewItem { - self = [super initWithNibName:nil bundle:nil]; if (self) { @@ -85,7 +91,6 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithAttachment:(SignalAttachment *)attachment fromRect:(CGRect)rect { - self = [super initWithNibName:nil bundle:nil]; if (self) { @@ -139,22 +144,42 @@ NS_ASSUME_NONNULL_BEGIN } } -- (void)loadView { +- (BOOL)isVideo +{ + if (self.attachmentStream) { + return self.attachmentStream.isVideo; + } else if (self.attachment) { + return self.attachment.isVideo; + } else { + return NO; + } +} + +- (void)loadView +{ self.view = [AttachmentMenuView new]; - self.view.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; - self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.view.backgroundColor = [UIColor clearColor]; } -- (void)viewDidLoad { +- (void)viewDidLoad +{ [super viewDidLoad]; - - [self initializeBackground]; - [self initializeContentViewAndFooterBar]; - [self initializeScrollView]; - [self initializeImageView]; + + [self createContents]; [self initializeGestureRecognizers]; - [self populateImageView:self.image]; + // Even though bars are opaque, we want content to be layed out behind them. + // The bars might obscure part of the content, but they can easily be hidden by tapping + // The alternative would be that content would shift when the navbars hide. + self.extendedLayoutIncludesOpaqueBars = YES; + + // TODO better title. + self.title = @"Attachment"; + + self.navigationItem.leftBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemStop + target:self + action:@selector(didTapDismissButton:)]; } - (void)viewWillDisappear:(BOOL)animated { @@ -166,101 +191,281 @@ NS_ASSUME_NONNULL_BEGIN } } -#pragma mark - Initializers - -- (void)initializeBackground { - self.imageView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; - - self.backgroundView = [UIView new]; - self.backgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:kBackgroundAlpha]; - [self.view addSubview:self.backgroundView]; - [self.backgroundView autoPinEdgesToSuperviewEdges]; +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + [self updateMinZoomScale]; + [self centerImageViewConstraints]; } -- (void)initializeContentViewAndFooterBar { - self.contentView = [UIView new]; - [self.backgroundView addSubview:self.contentView]; - [self.contentView autoPinWidthToSuperview]; - [self.contentView autoPinToTopLayoutGuideOfViewController:self withInset:0]; - - self.footerBar = [UIToolbar new]; - _footerBar.barTintColor = [UIColor ows_signalBrandBlueColor]; - [self.footerBar setItems:@[ - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil], - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction - target:self - action:@selector(shareWasPressed:)], - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil], - ] - animated:NO]; - [self.backgroundView addSubview:self.footerBar]; - [self.footerBar autoPinWidthToSuperview]; - [self.footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0]; - [self.footerBar autoPinEdge:ALEdgeTop toEdge:ALEdgeBottom ofView:self.contentView]; -} - -- (void)shareWasPressed:(id)sender { - DDLogInfo(@"%@: sharing image.", self.logTag); +- (void)updateMinZoomScale +{ + CGSize viewSize = self.scrollView.bounds.size; + UIImage *image = self.imageView.image; + OWSAssert(image); - [AttachmentSharing showShareUIForURL:self.attachmentUrl]; -} + if (image.size.width == 0 || image.size.height == 0) { + OWSFail(@"%@ Invalid image dimensions. %@", self.logTag, NSStringFromCGSize(image.size)); + return; + } -- (void)initializeScrollView { - self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds]; - self.scrollView.delegate = self; - self.scrollView.zoomScale = 1.0f; - self.scrollView.maximumZoomScale = kMaxZoomScale; - self.scrollView.scrollEnabled = NO; - [self.contentView addSubview:self.scrollView]; + CGFloat scaleWidth = viewSize.width / image.size.width; + CGFloat scaleHeight = viewSize.height / image.size.height; + CGFloat minScale = MIN(scaleWidth, scaleHeight); + self.scrollView.minimumZoomScale = minScale; + self.scrollView.zoomScale = minScale; } -- (void)initializeImageView { +#pragma mark - Initializers + +- (void)createContents +{ + CGFloat kFooterHeight = 44; + + UIScrollView *scrollView = [UIScrollView new]; + [self.view addSubview:scrollView]; + self.scrollView = scrollView; + scrollView.delegate = self; + + // TODO set max based on MIN. + scrollView.maximumZoomScale = kMaxZoomScale; + scrollView.showsVerticalScrollIndicator = NO; + scrollView.showsHorizontalScrollIndicator = NO; + scrollView.decelerationRate = UIScrollViewDecelerationRateFast; + self.automaticallyAdjustsScrollViewInsets = NO; + + [scrollView autoPinToSuperviewEdges]; + if (self.isAnimated) { if ([self.fileData ows_isValidImage]) { YYImage *animatedGif = [YYImage imageWithData:self.fileData]; - YYAnimatedImageView *imageView = [[YYAnimatedImageView alloc] init]; - imageView.image = animatedGif; - imageView.frame = self.originRect; - imageView.contentMode = UIViewContentModeScaleAspectFill; - imageView.clipsToBounds = YES; - self.imageView = imageView; + YYAnimatedImageView *animatedView = [[YYAnimatedImageView alloc] init]; + animatedView.image = animatedGif; + self.imageView = animatedView; } else { - self.imageView = [[UIImageView alloc] initWithFrame:self.originRect]; + self.imageView = [UIImageView new]; } + } else if (self.isVideo) { + [self setupVideoPlayer]; + + // Present the static video preview + UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image]; + self.imageView = imageView; + } else { // Present the static image using standard UIImageView - self.imageView = [[UIImageView alloc] initWithFrame:self.originRect]; - self.imageView.contentMode = UIViewContentModeScaleAspectFill; - self.imageView.userInteractionEnabled = YES; - self.imageView.clipsToBounds = YES; - self.imageView.layer.allowsEdgeAntialiasing = YES; - // Use trilinear filters for better scaling quality at - // some performance cost. - self.imageView.layer.minificationFilter = kCAFilterTrilinear; - self.imageView.layer.magnificationFilter = kCAFilterTrilinear; + UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image]; + + self.imageView = imageView; + } + + OWSAssert(self.imageView); + + [scrollView addSubview:self.imageView]; + self.imageView.contentMode = UIViewContentModeScaleAspectFit; + self.imageView.userInteractionEnabled = YES; + self.imageView.clipsToBounds = YES; + self.imageView.layer.allowsEdgeAntialiasing = YES; + self.imageView.translatesAutoresizingMaskIntoConstraints = NO; + + // Use trilinear filters for better scaling quality at + // some performance cost. + self.imageView.layer.minificationFilter = kCAFilterTrilinear; + self.imageView.layer.magnificationFilter = kCAFilterTrilinear; + + [self applyInitialImageViewConstraints]; + + if (self.isVideo) { + UIButton *playButton = [UIButton new]; + + [playButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside]; + + UIImage *playImage = [UIImage imageNamed:@"play_button"]; + [playButton setBackgroundImage:playImage forState:UIControlStateNormal]; + playButton.contentMode = UIViewContentModeScaleAspectFill; + + [self.view addSubview:playButton]; + + CGFloat playButtonWidth = ScaleFromIPhone5(70); + [playButton autoSetDimensionsToSize:CGSizeMake(playButtonWidth, playButtonWidth)]; + [playButton autoCenterInSuperview]; } - [self.scrollView addSubview:self.imageView]; + UIToolbar *footerBar = [UIToolbar new]; + _footerBar = footerBar; + footerBar.barTintColor = [UIColor ows_signalBrandBlueColor]; + [footerBar setItems:@[ + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction + target:self + action:@selector(didPressShare:)], + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil], + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemTrash + target:self + action:@selector(didPressDelete:)], + ] + animated:NO]; + [self.view addSubview:footerBar]; + + [footerBar autoPinWidthToSuperview]; + [footerBar autoPinToBottomLayoutGuideOfViewController:self withInset:0]; + [footerBar autoSetDimension:ALDimensionHeight toSize:kFooterHeight]; +} + +- (void)applyInitialImageViewConstraints +{ + if (self.imageViewConstraints.count > 0) { + [NSLayoutConstraint deactivateConstraints:self.imageViewConstraints]; + } + + CGRect convertedRect = + [self.imageView.superview convertRect:self.originRect fromView:[UIApplication sharedApplication].keyWindow]; + + NSMutableArray *imageViewConstraints = [NSMutableArray new]; + self.imageViewConstraints = imageViewConstraints; + + [imageViewConstraints addObjectsFromArray:[self.imageView autoSetDimensionsToSize:convertedRect.size]]; + [imageViewConstraints addObjectsFromArray:@[ + [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:convertedRect.origin.y], + [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:convertedRect.origin.x] + ]]; } -- (void)populateImageView:(UIImage *)image { - if (image && !self.isAnimated) { - self.imageView.image = image; +- (void)applyFinalImageViewConstraints +{ + if (self.imageViewConstraints.count > 0) { + [NSLayoutConstraint deactivateConstraints:self.imageViewConstraints]; } + + NSMutableArray *imageViewConstraints = [NSMutableArray new]; + self.imageViewConstraints = imageViewConstraints; + + self.imageViewLeadingConstraint = [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeLeading]; + self.imageViewTopConstraint = [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeTop]; + self.imageViewTrailingConstraint = [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeTrailing]; + self.imageViewBottomConstraint = [self.imageView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; + + [imageViewConstraints addObjectsFromArray:@[ + self.imageViewTopConstraint, + self.imageViewTrailingConstraint, + self.imageViewBottomConstraint, + self.imageViewLeadingConstraint + ]]; } -- (void)initializeGestureRecognizers { - UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(imageDismissGesture:)]; - singleTap.delegate = self; - [self.view addGestureRecognizer:singleTap]; - - UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(imageDismissGesture:)]; +- (void)setupVideoPlayer +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + if (![fileManager fileExistsAtPath:[self.attachmentUrl path]]) { + OWSFail(@"%@ Missing video file: %@", self.logTag, self.attachmentStream.mediaURL); + } + + if (@available(iOS 9.0, *)) { + AVPlayer *player = [[AVPlayer alloc] initWithURL:self.attachmentUrl]; + self.videoPlayer = player; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(playerItemDidPlayToCompletion:) + name:AVPlayerItemDidPlayToEndTimeNotification + object:player.currentItem]; + } else { + MPMoviePlayerController *videoPlayer = + [[MPMoviePlayerController alloc] initWithContentURL:self.attachmentStream.mediaURL]; + self.mpVideoPlayer = videoPlayer; + + videoPlayer.controlStyle = MPMovieControlStyleNone; + [videoPlayer prepareToPlay]; + + // + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerWillExitFullscreen:) + // name:MPMoviePlayerWillExitFullscreenNotification + // object:videoPlayer]; + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerDidExitFullscreen:) + // name:MPMoviePlayerDidExitFullscreenNotification + // object:videoPlayer]; + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerWillEnterFullscreen:) + // name:MPMoviePlayerWillEnterFullscreenNotification + // object:videoPlayer]; + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerPlaybackStateDidChange:) + // name:MPMoviePlayerPlaybackStateDidChangeNotification + // object:videoPlayer]; + // + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerDidEnterFullscreen:) + // name:MPMoviePlayerDidEnterFullscreenNotification + // object:videoPlayer]; + // + // + // [[NSNotificationCenter defaultCenter] addObserver:self + // selector:@selector(moviePlayerDidFinishPlayback:) + // name:MPMoviePlayerPlaybackDidFinishNotification + // object:videoPlayer]; + // + // // Don't show any controls intially. We switch control style after the view is fullscreen to make them + // appear upon tapping. + //// videoPlayer.controlStyle = MPMovieControlStyleFullscreen; + // videoPlayer.shouldAutoplay = YES; + // + // // We can't animate from the cell media frame; + // // MPMoviePlayerController will animate a crop of its + // // contents rather than scaling them. + // videoPlayer.view.frame = self.view.bounds; + // + // self.imageView = videoPlayer.view; + } +} + +- (void)setAreToolbarsHidden:(BOOL)areToolbarsHidden +{ + if (_areToolbarsHidden == areToolbarsHidden) { + return; + } + + _areToolbarsHidden = areToolbarsHidden; + + if (!areToolbarsHidden) { + // Hiding the status bar affects the positioing of the navbar. We don't want to show that in the animation + // so when *showing* the toolbars, we show the status bar first. When hiding, we hide it last. + [[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden withAnimation:UIStatusBarAnimationFade]; + } + [UIView animateWithDuration:0.1 + animations:^(void) { + self.view.backgroundColor = areToolbarsHidden ? UIColor.blackColor : UIColor.whiteColor; + self.navigationController.navigationBar.alpha = areToolbarsHidden ? 0 : 1; + self.footerBar.alpha = areToolbarsHidden ? 0 : 1; + } + completion:^(BOOL finished) { + // although navbar has 0 alpha at this point, if we don't also "hide" it, adjusting the status bar + // resets the alpha. + if (areToolbarsHidden) { + // [self.navigationController setNavigationBarHidden:areToolbarsHidden + // animated:NO]; + // Hiding the status bar affects the positioing of the navbar. We don't want to show that in the + // animation so when *showing* the toolbars, we show the status bar first. When hiding, we hide it last. + [[UIApplication sharedApplication] setStatusBarHidden:areToolbarsHidden + withAnimation:UIStatusBarAnimationNone]; + // position the navbar, but have it be transparent + self.navigationController.navigationBar.alpha = 0; + } + }]; +} + +- (void)initializeGestureRecognizers +{ + UITapGestureRecognizer *doubleTap = + [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)]; doubleTap.numberOfTapsRequired = 2; - doubleTap.delegate = self; [self.view addGestureRecognizer:doubleTap]; - + + UITapGestureRecognizer *singleTap = + [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapImage:)]; + [singleTap requireGestureRecognizerToFail:doubleTap]; + + [self.view addGestureRecognizer:singleTap]; + // UISwipeGestureRecognizer supposedly supports multiple directions, // but in practice it works better if you use a separate GR for each // direction. @@ -270,8 +475,8 @@ NS_ASSUME_NONNULL_BEGIN @(UISwipeGestureRecognizerDirectionUp), @(UISwipeGestureRecognizerDirectionDown), ]) { - UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self - action:@selector(imageDismissGesture:)]; + UISwipeGestureRecognizer *swipe = + [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(didSwipeImage:)]; swipe.direction = (UISwipeGestureRecognizerDirection) direction.integerValue; swipe.delegate = self; [self.view addGestureRecognizer:swipe]; @@ -285,10 +490,39 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - Gesture Recognizers -- (void)imageDismissGesture:(UIGestureRecognizer *)sender { - if (sender.state == UIGestureRecognizerStateRecognized) { + +- (void)didTapDismissButton:(id)sender +{ + [self dismiss]; +} + +- (void)didTapImage:(id)sender +{ + DDLogVerbose(@"%@ did tap image.", self.logTag); + self.areToolbarsHidden = !self.areToolbarsHidden; +} + +- (void)didDoubleTapImage:(id)sender +{ + DDLogVerbose(@"%@ did tap image.", self.logTag); + if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) { + [self.scrollView setZoomScale:self.scrollView.minimumZoomScale * 2 animated:YES]; + } else { + [self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:YES]; + } +} + +- (void)didSwipeImage:(UIGestureRecognizer *)sender +{ + // Ignore if image is zoomed in at all. + // e.g. otherwise, for example, if the image is horizontally larger than the scroll + // view, but fits vertically, swiping left/right will scroll the image, but swiping up/down + // would dismiss the image. That would not be intuitive. + if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) { + return; + } + [self dismiss]; - } } - (void)longPressGesture:(UIGestureRecognizer *)sender { @@ -318,6 +552,31 @@ NS_ASSUME_NONNULL_BEGIN } } +- (void)didPressShare:(id)sender +{ + DDLogInfo(@"%@: sharing image.", self.logTag); + + [self.viewItem shareAction]; +} + +- (void)didPressDelete:(id)sender +{ + DDLogInfo(@"%@: sharing image.", self.logTag); + + UIAlertController *actionSheet = + [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + [actionSheet addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"TXT_DELETE_TITLE", nil) + style:UIAlertActionStyleDestructive + handler:^(UIAlertAction *action) { + [self.viewItem deleteAction]; + [self dismiss]; + }]]; + + [actionSheet addAction:[OWSAlerts cancelAction]]; + + [self presentViewController:actionSheet animated:YES completion:nil]; +} + - (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender { if (action == self.viewItem.metadataActionSelector) { @@ -343,200 +602,247 @@ NS_ASSUME_NONNULL_BEGIN - (void)deleteAction:(nullable id)sender { - [self.viewItem deleteAction]; - - [self dismiss]; + [self didPressDelete:sender]; } -- (BOOL)canBecomeFirstResponder +#pragma mark - Presentation + +- (void)presentFromViewController:(UIViewController *)viewController { - return YES; -} + UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:self]; -#pragma mark - Presentation + // UIModalPresentationCustom retains the current view context behind our VC, allowing us to manually + // animate in our view, over the existing context, similar to a cross disolve, but allowing us to have + // more fine grained control + navController.modalPresentationStyle = UIModalPresentationCustom; + navController.navigationBar.barTintColor = UIColor.ows_materialBlueColor; + navController.navigationBar.translucent = NO; + navController.navigationBar.opaque = YES; -- (void)presentFromViewController:(UIViewController *)viewController { - _isPresenting = YES; self.view.userInteractionEnabled = NO; - [self.view addSubview:self.imageView]; - self.modalPresentationStyle = UIModalPresentationOverCurrentContext; - self.view.alpha = 0; - - [viewController - presentViewController:self - animated:NO - completion:^{ - UIWindow *window = [UIApplication sharedApplication].keyWindow; - // During the presentation animation, we want to seamlessly animate the image - // from its location in the conversation view. To do so, we need a - // consistent coordinate system, so we pass the `originRect` in the - // coordinate system of the window. - self.imageView.frame = [self.view convertRect:self.originRect - fromView:window]; - - [UIView animateWithDuration:0.25f - delay:0 - options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut - animations:^() { - self.view.alpha = 1.0f; - // During the presentation animation, we want to seamlessly animate the image - // to its resting location in this view. We use `resizedFrameForImageView` - // to determine its size "at rest" in the content view, and then convert - // from the content view's coordinate system to the root view coordinate - // system because the image view is temporarily hosted by the root view during - // the presentation animation. - self.imageView.frame = [self resizedFrameForImageView:self.image.size]; - self.imageView.center = [self.contentView convertPoint:self.contentView.center - fromView:self.contentView]; - } - completion:^(BOOL completed) { - self.scrollView.frame = self.contentView.bounds; - [self.scrollView addSubview:self.imageView]; - [self updateLayouts]; - self.view.userInteractionEnabled = YES; - _isPresenting = NO; - }]; - [UIUtil modalCompletionBlock](); - }]; -} - -- (void)dismiss { + + self.view.alpha = 0.0; + [viewController presentViewController:navController + animated:NO + completion:^{ + + // 1. Fade in the entire view. + [UIView animateWithDuration:0.1 + animations:^{ + self.view.alpha = 1.0; + }]; + + // Make sure imageView is layed out before we update it's frame in the next + // animation. + [self.imageView.superview layoutIfNeeded]; + + // 2. Animate imageView from it's initial position, which should match where it was + // in the presenting view to it's final position, front and center in this view. This + // animation intentionally overlaps the previous + [UIView animateWithDuration:0.2 + delay:0.08 + options:UIViewAnimationOptionCurveEaseOut + animations:^(void) { + [self applyFinalImageViewConstraints]; + [self.imageView.superview layoutIfNeeded]; + // We must lay out *before* we centerImageViewConstraints + // because it uses the imageView.frame to build the contstraints + // that will center the imageView, and then once again + // to ensure that the centered constraints are applied. + [self centerImageViewConstraints]; + [self.imageView.superview layoutIfNeeded]; + self.view.backgroundColor = UIColor.whiteColor; + } + completion:^(BOOL finished) { + self.view.userInteractionEnabled = YES; + + if (self.isVideo) { + [self playVideo]; + } + }]; + }]; +} + +- (void)dismiss +{ self.view.userInteractionEnabled = NO; - [UIView animateWithDuration:0.25f - delay:0 - options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveLinear - animations:^() { - self.backgroundView.backgroundColor = [UIColor clearColor]; - self.scrollView.alpha = 0; - self.view.alpha = 0; + [UIApplication sharedApplication].statusBarHidden = NO; + + OWSAssert(self.imageView.superview); + + [self.imageView.superview layoutIfNeeded]; + + // Move the image view pack to it's initial position, i.e. where + // it sits on the screen in the conversation view. + [self applyInitialImageViewConstraints]; + [UIView animateWithDuration:0.2 + delay:0.0 + options:UIViewAnimationOptionCurveEaseInOut + animations:^(void) { + [self.imageView.superview layoutIfNeeded]; + + // In case user has hidden bars, which changes background to black. + self.view.backgroundColor = UIColor.whiteColor; + + // fade out content and toolbars + self.navigationController.view.alpha = 0.0; } - completion:^(BOOL completed) { - [self.presentingViewController dismissViewControllerAnimated:NO completion:nil]; + completion:^(BOOL finished) { + [self.presentingViewController dismissViewControllerAnimated:NO completion:nil]; }]; } -#pragma mark - Update Layout +#pragma mark - UIScrollViewDelegate -- (void)viewDidLayoutSubviews { - [self updateLayouts]; +- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +{ + return self.imageView; } -- (void)updateLayouts { - if (_isPresenting) { - return; - } - - self.scrollView.frame = self.contentView.bounds; - self.imageView.frame = [self resizedFrameForImageView:self.image.size]; - self.scrollView.contentSize = self.imageView.frame.size; - self.scrollView.contentInset = [self contentInsetForScrollView:self.scrollView.zoomScale]; -} +- (void)centerImageViewConstraints +{ + OWSAssert(self.scrollView); -#pragma mark - Resizing + CGSize scrollViewSize = self.scrollView.bounds.size; + CGSize imageViewSize = self.imageView.frame.size; -- (CGRect)resizedFrameForImageView:(CGSize)imageSize { - CGRect frame = self.contentView.bounds; - CGSize screenSize = - CGSizeMake(frame.size.width * self.scrollView.zoomScale, frame.size.height * self.scrollView.zoomScale); - CGSize targetSize = screenSize; + CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2); + self.imageViewTopConstraint.constant = yOffset; + self.imageViewBottomConstraint.constant = yOffset; - if ([self isImagePortrait]) { - if ([self getAspectRatioForCGSize:screenSize] < [self getAspectRatioForCGSize:imageSize]) { - targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize]; - } else { - targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize]; - } - } else { - if ([self getAspectRatioForCGSize:screenSize] > [self getAspectRatioForCGSize:imageSize]) { - targetSize.height = screenSize.width * [self getAspectRatioForCGSize:imageSize]; - } else { - targetSize.width = screenSize.height / [self getAspectRatioForCGSize:imageSize]; - } - } + CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2); + self.imageViewLeadingConstraint.constant = xOffset; + self.imageViewTrailingConstraint.constant = xOffset; +} - frame.size = targetSize; - frame.origin = CGPointMake(0, 0); - return frame; +- (void)scrollViewDidZoom:(UIScrollView *)scrollView +{ + [self centerImageViewConstraints]; + [self.view layoutIfNeeded]; } -- (UIEdgeInsets)contentInsetForScrollView:(CGFloat)targetZoomScale { - UIEdgeInsets inset = UIEdgeInsetsZero; +#pragma mark - Video Playback - CGSize boundsSize = self.scrollView.bounds.size; - CGSize contentSize = self.image.size; - CGSize minSize; +- (void)playVideo +{ + OWSAssert(self.isVideo); + OWSAssert(self.videoPlayer); - if ([self isImagePortrait]) { - if ([self getAspectRatioForCGSize:boundsSize] < [self getAspectRatioForCGSize:contentSize]) { - minSize.height = boundsSize.height; - minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize]; - } else { - minSize.width = boundsSize.width; - minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize]; - } - } else { - if ([self getAspectRatioForCGSize:boundsSize] > [self getAspectRatioForCGSize:contentSize]) { - minSize.width = boundsSize.width; - minSize.height = minSize.width * [self getAspectRatioForCGSize:contentSize]; - } else { - minSize.height = boundsSize.height; - minSize.width = minSize.height / [self getAspectRatioForCGSize:contentSize]; - } - } + AVPlayerViewController *vc = [AVPlayerViewController new]; + AVPlayer *player = self.videoPlayer; + vc.player = player; - CGSize finalSize = self.view.bounds.size; + vc.modalPresentationStyle = UIModalPresentationCustom; + vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; - minSize.width *= targetZoomScale; - minSize.height *= targetZoomScale; + // Rewind for repeated plays + [player seekToTime:kCMTimeZero]; + [self presentViewController:vc + animated:NO + completion:^(void) { + [player play]; + }]; +} - if (minSize.height > finalSize.height && minSize.width > finalSize.width) { - inset = UIEdgeInsetsZero; - } else { - CGFloat dy = boundsSize.height - minSize.height; - CGFloat dx = boundsSize.width - minSize.width; +- (void)playerItemDidPlayToCompletion:(NSNotification *)notification +{ + OWSAssert(self.isVideo); + OWSAssert(self.videoPlayer); + DDLogVerbose(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); - dy = (dy > 0) ? dy : 0; - dx = (dx > 0) ? dx : 0; + [self dismissViewControllerAnimated:NO completion:nil]; +} - inset.top = dy / 2.0f; - inset.bottom = dy / 2.0f; - inset.left = dx / 2.0f; - inset.right = dx / 2.0f; - } - return inset; +- (void)moviePlayerPlaybackStateDidChange:(NSNotification *)notification +{ + DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + OWSAssert(self.mpVideoPlayer); } -#pragma mark - UIScrollViewDelegate +- (void)moviePlayerWillEnterFullscreen:(NSNotification *)notification +{ + DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + OWSAssert(self.videoPlayer); + self.mpVideoPlayer.controlStyle = MPMovieControlStyleNone; +} -- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +- (void)moviePlayerDidEnterFullscreen:(NSNotification *)notification { - return self.imageView; + DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + OWSAssert(self.videoPlayer); + self.mpVideoPlayer.controlStyle = MPMovieControlStyleFullscreen; } -- (void)scrollViewDidZoom:(UIScrollView *)scrollView { - scrollView.contentInset = [self contentInsetForScrollView:scrollView.zoomScale]; +// There's more than one way to exit the fullscreen video playback. +// There's a done button, a "toggle fullscreen" button and I think +// there's some gestures too. These fire slightly different notifications. +// We want to hide & clean up the video player immediately in all of +// these cases. +- (void)moviePlayerWillExitFullscreen:(NSNotification *)notification +{ + DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); - if (self.scrollView.scrollEnabled == NO) { - self.scrollView.scrollEnabled = YES; - } + // If we didn't just complete playback, user chose to exit fullscreen. + // In that case, we dismiss the view controller since the user is probably done. + // if (!self.didJustCompleteVideoPlayback) { + // [self dismiss]; + // } + + // self.didJustCompleteVideoPlayback = NO; + self.mpVideoPlayer.controlStyle = MPMovieControlStyleNone; + + // [self clearVideoPlayer]; } -- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(nullable UIView *)view atScale:(CGFloat)scale +// See comment on moviePlayerWillExitFullscreen: +- (void)moviePlayerDidExitFullscreen:(NSNotification *)notification { - self.scrollView.scrollEnabled = (scale > 1); - self.scrollView.contentInset = [self contentInsetForScrollView:scale]; + DDLogDebug(@"%@ %s", self.logTag, __PRETTY_FUNCTION__); + self.mpVideoPlayer.controlStyle = MPMovieControlStyleEmbedded; + // [self clearVideoPlayer]; } -#pragma mark - Utility - -- (BOOL)isImagePortrait { - return ([self getAspectRatioForCGSize:self.image.size] > 1.0f); +- (void)moviePlayerDidFinishPlayback:(NSNotification *)notification +{ + OWSAssert(self.videoPlayer); + + NSNumber *reason = notification.userInfo[MPMoviePlayerPlaybackDidFinishReasonUserInfoKey]; + DDLogDebug(@"%@ movie player finished with reason %@", self.logTag, reason); + OWSAssert(reason); + + switch (reason.integerValue) { + case MPMovieFinishReasonPlaybackEnded: { + DDLogDebug(@"%@ video played to completion.", self.logTag); + self.mpVideoPlayer.controlStyle = MPMovieControlStyleNone; + [self.mpVideoPlayer setFullscreen:NO animated:YES]; + break; + } + case MPMovieFinishReasonPlaybackError: { + DDLogDebug(@"%@ error playing video.", self.logTag); + break; + } + case MPMovieFinishReasonUserExited: { + // FIXME: unable to fire this (only tried on iOS11.2 so far) + DDLogDebug(@"%@ user exited video playback", self.logTag); + [self dismiss]; + break; + } + } } -- (CGFloat)getAspectRatioForCGSize:(CGSize)size { - return size.height / size.width; -} +//- (void)clearVideoPlayer +//{ +// [self.videoPlayer stop]; +// [self.videoPlayer.view removeFromSuperview]; +// self.videoPlayer = nil; +//} +//- (void)setVideoPlayer:(MPMoviePlayerController *_Nullable)videoPlayer +//{ +// _mpVideoPlayer = mpVideoPlayer; +// +// [ViewControllerUtils setAudioIgnoresHardwareMuteSwitch:videoPlayer != nil]; +//} #pragma mark - Saving images to Camera Roll