import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore import PresentationDataUtils import Camera import MultilineTextComponent import BlurredBackgroundComponent import Photos import LottieAnimationComponent import TooltipUI import MediaEditor import BundleIconComponent import CameraButtonComponent import VolumeButtons import TelegramNotices import DeviceAccess let videoRedColor = UIColor(rgb: 0xff3b30) enum CameraMode: Equatable { case photo case video } private struct CameraState: Equatable { enum Recording: Equatable { case none case holding case handsFree } let mode: CameraMode let position: Camera.Position let flashMode: Camera.FlashMode let flashModeDidChange: Bool let recording: Recording let duration: Double let isDualCameraEnabled: Bool func updatedMode(_ mode: CameraMode) -> CameraState { return CameraState(mode: mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled) } func updatedPosition(_ position: Camera.Position) -> CameraState { return CameraState(mode: self.mode, position: position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled) } func updatedFlashMode(_ flashMode: Camera.FlashMode) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: flashMode, flashModeDidChange: self.flashMode != flashMode, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled) } func updatedRecording(_ recording: Recording) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled) } func updatedDuration(_ duration: Double) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: duration, isDualCameraEnabled: self.isDualCameraEnabled) } func updatedIsDualCameraEnabled(_ isDualCameraEnabled: Bool) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCameraEnabled: isDualCameraEnabled) } } enum CameraScreenTransition { case animateIn case animateOut case finishedAnimateIn } private let cancelButtonTag = GenericComponentViewTag() private let flashButtonTag = GenericComponentViewTag() private let zoomControlTag = GenericComponentViewTag() private let captureControlsTag = GenericComponentViewTag() private let modeControlTag = GenericComponentViewTag() private let galleryButtonTag = GenericComponentViewTag() private let dualButtonTag = GenericComponentViewTag() private final class CameraScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let cameraState: CameraState let cameraAuthorizationStatus: AccessType let microphoneAuthorizationStatus: AccessType let hasAppeared: Bool let isVisible: Bool let panelWidth: CGFloat let animateFlipAction: ActionSlot let animateShutter: () -> Void let toggleCameraPositionAction: ActionSlot let getController: () -> CameraScreen? let present: (ViewController) -> Void let push: (ViewController) -> Void let completion: ActionSlot> init( context: AccountContext, cameraState: CameraState, cameraAuthorizationStatus: AccessType, microphoneAuthorizationStatus: AccessType, hasAppeared: Bool, isVisible: Bool, panelWidth: CGFloat, animateFlipAction: ActionSlot, animateShutter: @escaping () -> Void, toggleCameraPositionAction: ActionSlot, getController: @escaping () -> CameraScreen?, present: @escaping (ViewController) -> Void, push: @escaping (ViewController) -> Void, completion: ActionSlot> ) { self.context = context self.cameraState = cameraState self.cameraAuthorizationStatus = cameraAuthorizationStatus self.microphoneAuthorizationStatus = microphoneAuthorizationStatus self.hasAppeared = hasAppeared self.isVisible = isVisible self.panelWidth = panelWidth self.animateFlipAction = animateFlipAction self.animateShutter = animateShutter self.toggleCameraPositionAction = toggleCameraPositionAction self.getController = getController self.present = present self.push = push self.completion = completion } static func ==(lhs: CameraScreenComponent, rhs: CameraScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.cameraState != rhs.cameraState { return false } if lhs.cameraAuthorizationStatus != rhs.cameraAuthorizationStatus { return false } if lhs.microphoneAuthorizationStatus != rhs.microphoneAuthorizationStatus { return false } if lhs.hasAppeared != rhs.hasAppeared { return false } if lhs.isVisible != rhs.isVisible { return false } if lhs.panelWidth != rhs.panelWidth { return false } return true } final class State: ComponentState { enum ImageKey: Hashable { case cancel case flip } private var cachedImages: [ImageKey: UIImage] = [:] func image(_ key: ImageKey) -> UIImage { if let image = self.cachedImages[key] { return image } else { var image: UIImage switch key { case .cancel: image = UIImage(bundleImageName: "Camera/CloseIcon")! case .flip: image = UIImage(bundleImageName: "Camera/FlipIcon")! } cachedImages[key] = image return image } } private let context: AccountContext private let present: (ViewController) -> Void private let completion: ActionSlot> private let animateShutter: () -> Void private let animateFlipAction: ActionSlot private let toggleCameraPositionAction: ActionSlot private let getController: () -> CameraScreen? private var resultDisposable = MetaDisposable() private var mediaAssetsContext: MediaAssetsContext? fileprivate var lastGalleryAsset: PHAsset? private var lastGalleryAssetsDisposable: Disposable? private var volumeButtonsListener: VolumeButtonsListener? private let volumeButtonsListenerShouldBeActive = ValuePromise(false, ignoreRepeated: true) var cameraState: CameraState? var swipeHint: CaptureControlsComponent.SwipeHint = .none var isTransitioning = false private let hapticFeedback = HapticFeedback() init( context: AccountContext, present: @escaping (ViewController) -> Void, completion: ActionSlot>, animateShutter: @escaping () -> Void = {}, animateFlipAction: ActionSlot, toggleCameraPositionAction: ActionSlot, getController: @escaping () -> CameraScreen? = { return nil } ) { self.context = context self.present = present self.completion = completion self.animateShutter = animateShutter self.animateFlipAction = animateFlipAction self.toggleCameraPositionAction = toggleCameraPositionAction self.getController = getController super.init() Queue.concurrentDefaultQueue().async { self.setupRecentAssetSubscription() } self.setupVolumeButtonsHandler() self.toggleCameraPositionAction.connect({ [weak self] in if let self { self.togglePosition(self.animateFlipAction) } }) } deinit { self.lastGalleryAssetsDisposable?.dispose() self.resultDisposable.dispose() } func setupRecentAssetSubscription() { let mediaAssetsContext = MediaAssetsContext() self.mediaAssetsContext = mediaAssetsContext self.lastGalleryAssetsDisposable = (mediaAssetsContext.recentAssets() |> map { fetchResult in return fetchResult?.lastObject } |> deliverOnMainQueue).start(next: { [weak self] asset in guard let self else { return } self.lastGalleryAsset = asset self.updated(transition: .easeInOut(duration: 0.2)) }) } func setupVolumeButtonsHandler() { guard self.volumeButtonsListener == nil else { return } self.volumeButtonsListener = VolumeButtonsListener( shouldBeActive: self.volumeButtonsListenerShouldBeActive.get(), upPressed: { [weak self] in if let self { self.handleVolumePressed() } }, upReleased: { [weak self] in if let self { self.handleVolumeReleased() } }, downPressed: { [weak self] in if let self { self.handleVolumePressed() } }, downReleased: { [weak self] in if let self { self.handleVolumeReleased() } } ) } var volumeButtonsListenerActive = false { didSet { self.volumeButtonsListenerShouldBeActive.set(self.volumeButtonsListenerActive) } } private var buttonPressTimestamp: Double? private var buttonPressTimer: SwiftSignalKit.Timer? private var isPressingButton = false private func handleVolumePressed() { guard let controller = self.getController(), let _ = controller.camera else { return } self.isPressingButton = false self.buttonPressTimestamp = CACurrentMediaTime() self.buttonPressTimer = SwiftSignalKit.Timer(timeout: 0.3, repeat: false, completion: { [weak self] in if let self, let _ = self.buttonPressTimestamp { if case .none = controller.cameraState.recording { self.startVideoRecording(pressing: true) self.isPressingButton = true } self.buttonPressTimestamp = nil self.buttonPressTimer?.invalidate() self.buttonPressTimer = nil } }, queue: Queue.mainQueue()) self.buttonPressTimer?.start() } private func handleVolumeReleased() { guard let controller = self.getController(), let _ = controller.camera else { return } if case .none = controller.cameraState.recording { switch controller.cameraState.mode { case .photo: self.animateShutter() self.takePhoto() case .video: self.startVideoRecording(pressing: false) } } else { if self.isPressingButton, case .handsFree = controller.cameraState.recording { } else { self.stopVideoRecording() } } self.buttonPressTimer?.invalidate() self.buttonPressTimer = nil self.buttonPressTimestamp = nil } func updateCameraMode(_ mode: CameraMode) { guard let controller = self.getController(), let _ = controller.camera else { return } controller.updateCameraState({ $0.updatedMode(mode) }, transition: .spring(duration: 0.3)) } func toggleFlashMode() { guard let controller = self.getController(), let camera = controller.camera else { return } switch controller.cameraState.flashMode { case .off: camera.setFlashMode(.on) case .on: camera.setFlashMode(.auto) default: camera.setFlashMode(.off) } self.hapticFeedback.impact(.light) } private var lastFlipTimestamp: Double? func togglePosition(_ action: ActionSlot) { guard let controller = self.getController(), let camera = controller.camera else { return } let currentTimestamp = CACurrentMediaTime() if let lastFlipTimestamp = self.lastFlipTimestamp, currentTimestamp - lastFlipTimestamp < 1.0 { return } if let lastDualCameraTimestamp = self.lastDualCameraTimestamp, currentTimestamp - lastDualCameraTimestamp < 1.5 { return } self.lastFlipTimestamp = currentTimestamp camera.togglePosition() action.invoke(Void()) self.hapticFeedback.impact(.light) } private var lastDualCameraTimestamp: Double? func toggleDualCamera() { guard let controller = self.getController(), let camera = controller.camera else { return } let currentTimestamp = CACurrentMediaTime() if let lastDualCameraTimestamp = self.lastDualCameraTimestamp, currentTimestamp - lastDualCameraTimestamp < 1.5 { return } if let lastFlipTimestamp = self.lastFlipTimestamp, currentTimestamp - lastFlipTimestamp < 1.0 { return } self.lastDualCameraTimestamp = currentTimestamp controller.node.dismissAllTooltips() let _ = ApplicationSpecificNotice.incrementStoriesDualCameraTip(accountManager: self.context.sharedContext.accountManager, count: 2).start() let isEnabled = !controller.cameraState.isDualCameraEnabled camera.setDualCameraEnabled(isEnabled) controller.updateCameraState({ $0.updatedIsDualCameraEnabled(isEnabled) }, transition: .easeInOut(duration: 0.1)) self.hapticFeedback.impact(.light) } func updateSwipeHint(_ hint: CaptureControlsComponent.SwipeHint) { guard hint != self.swipeHint else { return } self.swipeHint = hint self.updated(transition: .easeInOut(duration: 0.2)) } private var isTakingPhoto = false func takePhoto() { guard let controller = self.getController(), let camera = controller.camera else { return } guard !self.isTakingPhoto else { return } self.isTakingPhoto = true controller.node.dismissAllTooltips() let takePhoto = camera.takePhoto() |> mapToSignal { value -> Signal in switch value { case .began: return .single(.pendingImage) case let .finished(image, additionalImage, _): return .single(.image(CameraScreen.Result.Image(image: image, additionalImage: additionalImage, additionalImagePosition: .topRight))) case .failed: return .complete() } } self.completion.invoke(takePhoto) Queue.mainQueue().after(1.0) { self.isTakingPhoto = false } } func startVideoRecording(pressing: Bool) { guard let controller = self.getController(), let camera = controller.camera else { return } guard case .none = controller.cameraState.recording else { return } controller.node.dismissAllTooltips() self.resultDisposable.set((camera.startRecording() |> deliverOnMainQueue).start(next: { [weak self] duration in if let self, let controller = self.getController() { controller.updateCameraState({ $0.updatedDuration(duration) }, transition: .easeInOut(duration: 0.1)) if duration > 59.0 { self.stopVideoRecording() } } })) controller.updateCameraState({ $0.updatedRecording(pressing ? .holding : .handsFree).updatedDuration(0.0) }, transition: .spring(duration: 0.4)) } func stopVideoRecording() { guard let controller = self.getController(), let camera = controller.camera else { return } self.resultDisposable.set((camera.stopRecording() |> deliverOnMainQueue).start(next: { [weak self] result in if let self, case let .finished(mainResult, additionalResult, duration, positionChangeTimestamps, _) = result { self.completion.invoke(.single(.video(CameraScreen.Result.Video(videoPath: mainResult.0, coverImage: mainResult.1, mirror: mainResult.2, additionalVideoPath: additionalResult?.0, additionalCoverImage: additionalResult?.1, dimensions: PixelDimensions(width: 1080, height: 1920), duration: duration, positionChangeTimestamps: positionChangeTimestamps, additionalVideoPosition: .topRight)))) } })) self.isTransitioning = true Queue.mainQueue().after(1.25, { self.isTransitioning = false self.updated(transition: .immediate) }) controller.updateCameraState({ $0.updatedRecording(.none).updatedDuration(0.0) }, transition: .spring(duration: 0.4)) } func lockVideoRecording() { guard let controller = self.getController() else { return } controller.updateCameraState({ $0.updatedRecording(.handsFree) }, transition: .spring(duration: 0.4)) } func updateZoom(fraction: CGFloat) { guard let camera = self.getController()?.camera else { return } camera.setZoomLevel(fraction) } } func makeState() -> State { return State(context: self.context, present: self.present, completion: self.completion, animateShutter: self.animateShutter, animateFlipAction: self.animateFlipAction, toggleCameraPositionAction: self.toggleCameraPositionAction, getController: self.getController) } static var body: Body { let placeholder = Child(PlaceholderComponent.self) let cancelButton = Child(CameraButton.self) let captureControls = Child(CaptureControlsComponent.self) let zoomControl = Child(ZoomComponent.self) let flashButton = Child(CameraButton.self) let flipButton = Child(CameraButton.self) let dualButton = Child(CameraButton.self) let modeControl = Child(ModeComponent.self) let hintLabel = Child(HintLabelComponent.self) let timeBackground = Child(RoundedRectangle.self) let timeLabel = Child(MultilineTextComponent.self) return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let component = context.component let state = context.state let controller = environment.controller let availableSize = context.availableSize state.cameraState = component.cameraState state.volumeButtonsListenerActive = component.hasAppeared && component.isVisible let isTablet: Bool if case .regular = environment.metrics.widthClass { isTablet = true } else { isTablet = false } let smallPanelWidth = min(component.panelWidth, 88.0) let panelWidth = min(component.panelWidth, 185.0) var controlsBottomInset: CGFloat = 0.0 let previewHeight = floorToScreenPixels(availableSize.width * 1.77778) if !isTablet { if availableSize.height < previewHeight + 30.0 { controlsBottomInset = -48.0 } } let hasAllRequiredAccess: Bool switch component.cameraAuthorizationStatus { case .notDetermined: hasAllRequiredAccess = true case .allowed: switch component.microphoneAuthorizationStatus { case .notDetermined: hasAllRequiredAccess = true case .allowed: hasAllRequiredAccess = true default: hasAllRequiredAccess = false } default: hasAllRequiredAccess = false } if !hasAllRequiredAccess { let accountContext = component.context let placeholder = placeholder.update( component: PlaceholderComponent( context: component.context, mode: .denied, action: { accountContext.sharedContext.applicationBindings.openSettings() } ), availableSize: CGSize(width: availableSize.width, height: previewHeight), transition: context.transition ) context.add(placeholder .position(CGPoint(x: availableSize.width / 2.0, y: environment.safeInsets.top + previewHeight / 2.0)) .clipsToBounds(true) .cornerRadius(11.0) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } if case .holding = component.cameraState.recording { } else { let _ = zoomControl // let zoomControl = zoomControl.update( // component: ZoomComponent( // availableValues: state.camera.metrics.zoomLevels, // value: 1.0, // tag: zoomControlTag // ), // availableSize: context.availableSize, // transition: context.transition // ) // context.add(zoomControl // .position(CGPoint(x: context.availableSize.width / 2.0, y: availableSize.height - zoomControl.size.height / 2.0 - 114.0 - environment.safeInsets.bottom)) // .appear(.default(alpha: true)) // .disappear(.default(alpha: true)) // ) } let shutterState: ShutterButtonState if state.isTransitioning { shutterState = .transition } else { switch component.cameraState.recording { case .handsFree: shutterState = .stopRecording case .holding: shutterState = .holdRecording(progress: min(1.0, Float(component.cameraState.duration / 60.0))) case .none: switch component.cameraState.mode { case .photo: shutterState = .generic case .video: shutterState = .video } } } let animateFlipAction = component.animateFlipAction let captureControlsAvailableSize: CGSize if isTablet { captureControlsAvailableSize = CGSize(width: panelWidth, height: availableSize.height) } else { captureControlsAvailableSize = availableSize } let animateShutter = component.animateShutter let captureControls = captureControls.update( component: CaptureControlsComponent( isTablet: isTablet, hasAppeared: component.hasAppeared && hasAllRequiredAccess, hasAccess: hasAllRequiredAccess, shutterState: shutterState, lastGalleryAsset: state.lastGalleryAsset, tag: captureControlsTag, galleryButtonTag: galleryButtonTag, shutterTapped: { [weak state] in guard let state, let cameraState = state.cameraState else { return } if case .none = cameraState.recording { if cameraState.mode == .photo { animateShutter() state.takePhoto() } else if cameraState.mode == .video { state.startVideoRecording(pressing: false) } } else { state.stopVideoRecording() } }, shutterPressed: { [weak state] in guard let state, let cameraState = state.cameraState, case .none = cameraState.recording else { return } state.startVideoRecording(pressing: true) }, shutterReleased: { [weak state] in guard let state, let cameraState = state.cameraState, cameraState.recording != .none else { return } state.stopVideoRecording() }, lockRecording: { [weak state] in guard let state, let cameraState = state.cameraState, cameraState.recording != .none else { return } state.lockVideoRecording() }, flipTapped: { [weak state] in guard let state else { return } state.togglePosition(animateFlipAction) }, galleryTapped: { guard let controller = environment.controller() as? CameraScreen else { return } controller.presentGallery() }, swipeHintUpdated: { [weak state] hint in if let state { state.updateSwipeHint(hint) } }, zoomUpdated: { [weak state] fraction in if let state { state.updateZoom(fraction: fraction) } }, flipAnimationAction: animateFlipAction ), availableSize: captureControlsAvailableSize, transition: context.transition ) let captureControlsPosition: CGPoint if isTablet { captureControlsPosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0) } else { captureControlsPosition = CGPoint(x: availableSize.width / 2.0, y: availableSize.height - captureControls.size.height / 2.0 - environment.safeInsets.bottom - 5.0 + floorToScreenPixels(controlsBottomInset * 0.66)) } context.add(captureControls .position(captureControlsPosition) ) let topControlInset: CGFloat = 20.0 if case .none = component.cameraState.recording, !state.isTransitioning { let cancelButton = cancelButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "cancel", component: AnyComponent( Image( image: state.image(.cancel), size: CGSize(width: 40.0, height: 40.0) ) ) ), action: { guard let controller = controller() as? CameraScreen else { return } controller.requestDismiss(animated: true) } ).tagged(cancelButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(cancelButton .position(CGPoint(x: isTablet ? smallPanelWidth / 2.0 : topControlInset + cancelButton.size.width / 2.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + cancelButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) let flashContentComponent: AnyComponentWithIdentity if component.hasAppeared { let flashIconName: String switch component.cameraState.flashMode { case .off: flashIconName = "flash_off" case .on: flashIconName = "flash_on" case .auto: flashIconName = "flash_auto" @unknown default: flashIconName = "flash_off" } flashContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: flashIconName, mode: !component.cameraState.flashModeDidChange ? .still(position: .end) : .animating(loop: false), range: nil, waitForCompletion: false ), colors: [:], size: CGSize(width: 40.0, height: 40.0) ) ) ) } else { flashContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: "Camera/FlashOffIcon", tintColor: nil ) ) ) } if hasAllRequiredAccess { let flashButton = flashButton.update( component: CameraButton( content: flashContentComponent, action: { [weak state] in if let state { state.toggleFlashMode() } } ).tagged(flashButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(flashButton .position(CGPoint(x: isTablet ? availableSize.width - smallPanelWidth / 2.0 : availableSize.width - topControlInset - flashButton.size.width / 2.0 - 5.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + flashButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) if !isTablet && Camera.isDualCameraSupported { let dualButton = dualButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "dual", component: AnyComponent( DualIconComponent(isSelected: component.cameraState.isDualCameraEnabled) ) ), action: { [weak state] in if let state { state.toggleDualCamera() } } ).tagged(dualButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(dualButton .position(CGPoint(x: availableSize.width - topControlInset - flashButton.size.width / 2.0 - 58.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + dualButton.size.height / 2.0 + 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) } } } if isTablet && hasAllRequiredAccess { let flipButton = flipButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "flip", component: AnyComponent( FlipButtonContentComponent( action: animateFlipAction, maskFrame: .zero ) ) ), minSize: CGSize(width: 44.0, height: 44.0), action: { [weak state] in if let state { state.togglePosition(animateFlipAction) } } ), availableSize: availableSize, transition: context.transition ) context.add(flipButton .position(CGPoint(x: smallPanelWidth / 2.0, y: availableSize.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) } var isVideoRecording = false if case .video = component.cameraState.mode { isVideoRecording = true } else if component.cameraState.recording != .none { isVideoRecording = true } if isVideoRecording && !state.isTransitioning { let duration = Int(component.cameraState.duration) let durationString = String(format: "%02d:%02d", (duration / 60) % 60, duration % 60) let timeLabel = timeLabel.update( component: MultilineTextComponent( text: .plain(NSAttributedString(string: durationString, font: Font.with(size: 21.0, design: .camera), textColor: .white)), horizontalAlignment: .center, textShadowColor: UIColor(rgb: 0x000000, alpha: 0.2) ), availableSize: context.availableSize, transition: context.transition ) let timePosition: CGPoint if isTablet { timePosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0 - 97.0) } else { timePosition = CGPoint(x: availableSize.width / 2.0, y: max(environment.statusBarHeight + 5.0 + 20.0, environment.safeInsets.top + topControlInset + 20.0)) } if component.cameraState.recording != .none { let timeBackground = timeBackground.update( component: RoundedRectangle(color: videoRedColor, cornerRadius: 4.0), availableSize: CGSize(width: timeLabel.size.width + 8.0, height: 28.0), transition: context.transition ) context.add(timeBackground .position(timePosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } context.add(timeLabel .position(timePosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) if case .holding = component.cameraState.recording, !isTablet { let hintText: String? switch state.swipeHint { case .none: hintText = " " case .zoom: hintText = "Swipe up to zoom" case .lock: hintText = "Swipe left to lock" case .releaseLock: hintText = "Release to lock" case .flip: hintText = "Swipe right to flip" } if let hintText { let hintLabel = hintLabel.update( component: HintLabelComponent(text: hintText), availableSize: availableSize, transition: .immediate ) context.add(hintLabel .position(CGPoint(x: availableSize.width / 2.0, y: availableSize.height - environment.safeInsets.bottom - 136.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } } } if case .none = component.cameraState.recording, !state.isTransitioning && hasAllRequiredAccess { let availableModeControlSize: CGSize if isTablet { availableModeControlSize = CGSize(width: panelWidth, height: 120.0) } else { availableModeControlSize = availableSize } let modeControl = modeControl.update( component: ModeComponent( isTablet: isTablet, availableModes: [.photo, .video], currentMode: component.cameraState.mode, updatedMode: { [weak state] mode in if let state { state.updateCameraMode(mode) } }, tag: modeControlTag ), availableSize: availableModeControlSize, transition: context.transition ) let modeControlPosition: CGPoint if isTablet { modeControlPosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0 + modeControl.size.height + 26.0) } else { modeControlPosition = CGPoint(x: availableSize.width / 2.0, y: availableSize.height - environment.safeInsets.bottom + modeControl.size.height / 2.0 + controlsBottomInset) } context.add(modeControl .clipsToBounds(true) .position(modeControlPosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } return availableSize } } } private class BlurView: UIVisualEffectView { private func setup() { for subview in self.subviews { if subview.description.contains("VisualEffectSubview") { subview.isHidden = true } } if let sublayer = self.layer.sublayers?[0], let filters = sublayer.filters { sublayer.backgroundColor = nil sublayer.isOpaque = false let allowedKeys: [String] = [ "gaussianBlur" ] sublayer.filters = filters.filter { filter in guard let filter = filter as? NSObject else { return true } let filterName = String(describing: filter) if !allowedKeys.contains(filterName) { return false } return true } } } override var effect: UIVisualEffect? { get { return super.effect } set { super.effect = newValue self.setup() } } override func didAddSubview(_ subview: UIView) { super.didAddSubview(subview) self.setup() } } public class CameraScreen: ViewController { public enum Mode { case generic case story case instantVideo } public enum PIPPosition: Int32 { case topLeft case topRight case bottomLeft case bottomRight } public enum Result { public struct Image { public let image: UIImage public let additionalImage: UIImage? public let additionalImagePosition: CameraScreen.PIPPosition } public struct Video { public let videoPath: String public let coverImage: UIImage? public let mirror: Bool public let additionalVideoPath: String? public let additionalCoverImage: UIImage? public let dimensions: PixelDimensions public let duration: Double public let positionChangeTimestamps: [(Bool, Double)] public let additionalVideoPosition: CameraScreen.PIPPosition } case pendingImage case image(Image) case video(Video) case asset(PHAsset) case draft(MediaEditorDraft) func withPIPPosition(_ position: CameraScreen.PIPPosition) -> Result { switch self { case let .image(result): return .image(Image(image: result.image, additionalImage: result.additionalImage, additionalImagePosition: position)) case let .video(result): return .video(Video(videoPath: result.videoPath, coverImage: result.coverImage, mirror: result.mirror, additionalVideoPath: result.additionalVideoPath, additionalCoverImage: result.additionalCoverImage, dimensions: result.dimensions, duration: result.duration, positionChangeTimestamps: result.positionChangeTimestamps, additionalVideoPosition: position)) default: return self } } } public final class TransitionIn { public weak var sourceView: UIView? public let sourceRect: CGRect public let sourceCornerRadius: CGFloat public init( sourceView: UIView, sourceRect: CGRect, sourceCornerRadius: CGFloat ) { self.sourceView = sourceView self.sourceRect = sourceRect self.sourceCornerRadius = sourceCornerRadius } } public final class TransitionOut { public weak var destinationView: UIView? public let destinationRect: CGRect public let destinationCornerRadius: CGFloat public init( destinationView: UIView, destinationRect: CGRect, destinationCornerRadius: CGFloat ) { self.destinationView = destinationView self.destinationRect = destinationRect self.destinationCornerRadius = destinationCornerRadius } } fileprivate final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { private weak var controller: CameraScreen? private let context: AccountContext fileprivate var camera: Camera? private let updateState: ActionSlot private let toggleCameraPositionAction: ActionSlot fileprivate let backgroundView: UIView fileprivate let containerView: UIView fileprivate let componentHost: ComponentView private let previewContainerView: UIView private let mainPreviewContainerView: UIView fileprivate var mainPreviewView: CameraSimplePreviewView private let additionalPreviewContainerView: UIView fileprivate var additionalPreviewView: CameraSimplePreviewView fileprivate let previewBlurView: BlurView private var mainPreviewSnapshotView: UIView? private var additionalPreviewSnapshotView: UIView? fileprivate let previewFrameLeftDimView: UIView fileprivate let previewFrameRightDimView: UIView fileprivate let transitionDimView: UIView fileprivate let transitionCornersView: UIImageView private var cameraStateDisposable: Disposable? private var changingPositionDisposable: Disposable? private var appliedDualCamera = false private var pipPosition: PIPPosition = .topRight fileprivate var previewBlurPromise = ValuePromise(false) private let animateFlipAction = ActionSlot() private let idleTimerExtensionDisposable = MetaDisposable() fileprivate var cameraIsActive = true { didSet { if self.cameraIsActive { self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension()) } else { self.idleTimerExtensionDisposable.set(nil) } } } fileprivate var hasGallery = false private var presentationData: PresentationData private var validLayout: ContainerViewLayout? private let completion = ActionSlot>() var cameraState: CameraState { didSet { let previousPosition = oldValue.position let dualCamWasEnabled = oldValue.isDualCameraEnabled let isDualCameraEnabled = self.cameraState.isDualCameraEnabled let currentPosition = self.cameraState.position if case .front = currentPosition, isDualCameraEnabled != dualCamWasEnabled { if isDualCameraEnabled { if let cloneView = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewSnapshotView = cloneView self.mainPreviewContainerView.addSubview(cloneView) } } else { if let cloneView = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { cloneView.frame = self.mainPreviewView.frame self.additionalPreviewSnapshotView = cloneView self.additionalPreviewContainerView.addSubview(cloneView) } if let cloneView = self.additionalPreviewView.snapshotView(afterScreenUpdates: false) { cloneView.frame = self.additionalPreviewView.frame self.mainPreviewSnapshotView = cloneView self.mainPreviewContainerView.addSubview(cloneView) } } } if isDualCameraEnabled && previousPosition != currentPosition { self.animateDualCameraPositionSwitch() } else if dualCamWasEnabled != isDualCameraEnabled { self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .spring(duration: 0.4)) UserDefaults.standard.set(isDualCameraEnabled as NSNumber, forKey: "TelegramStoryCameraIsDualEnabled") } } } private var cameraAuthorizationStatus: AccessType = .notDetermined private var microphoneAuthorizationStatus: AccessType = .notDetermined private var galleryAuthorizationStatus: AccessType = .notDetermined private var authorizationStatusDisposables = DisposableSet() init(controller: CameraScreen) { self.controller = controller self.context = controller.context self.updateState = ActionSlot() self.toggleCameraPositionAction = ActionSlot() self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.backgroundView = UIView() self.backgroundView.backgroundColor = UIColor(rgb: 0x000000) self.containerView = UIView() self.containerView.clipsToBounds = true self.componentHost = ComponentView() self.previewContainerView = UIView() self.previewContainerView.clipsToBounds = true self.previewContainerView.layer.cornerRadius = 12.0 if #available(iOS 13.0, *) { self.previewContainerView.layer.cornerCurve = .continuous } self.previewBlurView = BlurView() self.previewBlurView.isUserInteractionEnabled = false var isDualCameraEnabled = Camera.isDualCameraSupported if isDualCameraEnabled { if let isDualCameraEnabledValue = UserDefaults.standard.object(forKey: "TelegramStoryCameraIsDualEnabled") as? NSNumber { isDualCameraEnabled = isDualCameraEnabledValue.boolValue } } var dualCameraPosition: PIPPosition = .topRight if let dualCameraPositionValue = UserDefaults.standard.object(forKey: "TelegramStoryCameraDualPosition") as? NSNumber { dualCameraPosition = PIPPosition(rawValue: dualCameraPositionValue.int32Value) ?? .topRight } self.pipPosition = dualCameraPosition var cameraFrontPosition = false if let cameraFrontPositionValue = UserDefaults.standard.object(forKey: "TelegramStoryCameraUseFrontPosition") as? NSNumber, cameraFrontPositionValue.boolValue { cameraFrontPosition = true } self.mainPreviewContainerView = UIView() self.mainPreviewContainerView.clipsToBounds = true self.mainPreviewView = CameraSimplePreviewView(frame: .zero, main: true) self.additionalPreviewContainerView = UIView() self.additionalPreviewContainerView.clipsToBounds = true self.additionalPreviewView = CameraSimplePreviewView(frame: .zero, main: false) if isDualCameraEnabled { self.mainPreviewView.resetPlaceholder(front: false) self.additionalPreviewView.resetPlaceholder(front: true) } else { self.mainPreviewView.resetPlaceholder(front: cameraFrontPosition) } self.cameraState = CameraState( mode: .photo, position: cameraFrontPosition ? .front : .back, flashMode: .off, flashModeDidChange: false, recording: .none, duration: 0.0, isDualCameraEnabled: isDualCameraEnabled ) self.previewFrameLeftDimView = UIView() self.previewFrameLeftDimView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) self.previewFrameLeftDimView.isHidden = true self.previewFrameRightDimView = UIView() self.previewFrameRightDimView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) self.previewFrameRightDimView.isHidden = true self.transitionDimView = UIView() self.transitionDimView.backgroundColor = UIColor(rgb: 0x000000) self.transitionDimView.isUserInteractionEnabled = false self.transitionCornersView = UIImageView() super.init() self.backgroundColor = .clear self.view.addSubview(self.backgroundView) self.view.addSubview(self.containerView) self.containerView.addSubview(self.previewContainerView) self.previewContainerView.addSubview(self.mainPreviewContainerView) self.previewContainerView.addSubview(self.additionalPreviewContainerView) self.previewContainerView.addSubview(self.previewBlurView) self.previewContainerView.addSubview(self.previewFrameLeftDimView) self.previewContainerView.addSubview(self.previewFrameRightDimView) self.containerView.addSubview(self.transitionDimView) self.view.addSubview(self.transitionCornersView) self.mainPreviewContainerView.addSubview(self.mainPreviewView) self.additionalPreviewContainerView.addSubview(self.additionalPreviewView) self.completion.connect { [weak self] result in if let self { let pipPosition = self.pipPosition self.animateOutToEditor() self.controller?.completion( result |> map { result in return result.withPIPPosition(pipPosition) } |> beforeNext { [weak self] value in guard let self else { return } if case .pendingImage = value { Queue.mainQueue().async { self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false } } else { Queue.mainQueue().async { if case .image = value { Queue.mainQueue().after(0.3) { self.previewBlurPromise.set(true) } } self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false self.camera?.stopCapture() } } }, nil, {} ) } } if #available(iOS 13.0, *) { if isDualCameraEnabled { let _ = (combineLatest( queue: Queue.mainQueue(), self.mainPreviewView.isPreviewing, self.additionalPreviewView.isPreviewing ) |> filter { $0 && $1 } |> take(1)).start(next: { [weak self] _, _ in self?.mainPreviewView.removePlaceholder(delay: 0.15) self?.additionalPreviewView.removePlaceholder(delay: 0.15) }) } else { let _ = (self.mainPreviewView.isPreviewing |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in self?.mainPreviewView.removePlaceholder(delay: 0.15) }) } } else { Queue.mainQueue().after(0.35) { self.mainPreviewView.removePlaceholder(delay: 0.15) if isDualCameraEnabled { self.additionalPreviewView.removePlaceholder(delay: 0.15) } } } self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension()) self.authorizationStatusDisposables.add((DeviceAccess.authorizationStatus(subject: .camera(.video)) |> deliverOnMainQueue).start(next: { [weak self] status in if let self { self.cameraAuthorizationStatus = status self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .easeInOut(duration: 0.2)) self.maybeSetupCamera() } })) self.authorizationStatusDisposables.add((DeviceAccess.authorizationStatus(subject: .microphone(.video)) |> deliverOnMainQueue).start(next: { [weak self] status in if let self { self.microphoneAuthorizationStatus = status self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .easeInOut(duration: 0.2)) self.maybeSetupCamera() } })) } deinit { self.cameraStateDisposable?.dispose() self.changingPositionDisposable?.dispose() self.idleTimerExtensionDisposable.dispose() self.authorizationStatusDisposables.dispose() } private var pipPanGestureRecognizer: UIPanGestureRecognizer? override func didLoad() { super.didLoad() self.view.disablesInteractiveModalDismiss = true self.view.disablesInteractiveKeyboardGestureRecognizer = true let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinch(_:))) self.previewContainerView.addGestureRecognizer(pinchGestureRecognizer) let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:))) panGestureRecognizer.delegate = self panGestureRecognizer.maximumNumberOfTouches = 1 self.previewContainerView.addGestureRecognizer(panGestureRecognizer) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) self.previewContainerView.addGestureRecognizer(tapGestureRecognizer) let doubleGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleDoubleTap(_:))) doubleGestureRecognizer.numberOfTapsRequired = 2 self.previewContainerView.addGestureRecognizer(doubleGestureRecognizer) let pipPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePipPan(_:))) pipPanGestureRecognizer.delegate = self self.previewContainerView.addGestureRecognizer(pipPanGestureRecognizer) self.pipPanGestureRecognizer = pipPanGestureRecognizer } private func maybeSetupCamera() { if case .allowed = self.cameraAuthorizationStatus, case .allowed = self.microphoneAuthorizationStatus { self.setupCamera() } } private func requestDeviceAccess() { DeviceAccess.authorizeAccess(to: .camera(.video), { granted in if granted { DeviceAccess.authorizeAccess(to: .microphone(.video)) } }) } private func setupCamera() { guard self.camera == nil else { return } let camera = Camera( configuration: Camera.Configuration( preset: .hd1920x1080, position: self.cameraState.position, isDualEnabled: self.cameraState.isDualCameraEnabled, audio: true, photo: true, metadata: false, preferredFps: 60.0 ), previewView: self.mainPreviewView, secondaryPreviewView: self.additionalPreviewView ) self.cameraStateDisposable = combineLatest( queue: Queue.mainQueue(), camera.flashMode, camera.position ).start(next: { [weak self] flashMode, position in guard let self else { return } let previousState = self.cameraState self.cameraState = self.cameraState.updatedPosition(position).updatedFlashMode(flashMode) if !self.animatingDualCameraPositionSwitch { self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .easeInOut(duration: 0.2)) } if previousState.position != self.cameraState.position { UserDefaults.standard.set((self.cameraState.position == .front) as NSNumber, forKey: "TelegramStoryCameraUseFrontPosition") } }) var isFirstTime = true self.changingPositionDisposable = combineLatest( queue: Queue.mainQueue(), camera.modeChange, self.previewBlurPromise.get() ).start(next: { [weak self] modeChange, forceBlur in if let self { if modeChange != .none { if case .dualCamera = modeChange, case .front = self.cameraState.position { } else { if let snapshot = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewView.addSubview(snapshot) self.mainPreviewSnapshotView = snapshot } } if case .position = modeChange { UIView.transition(with: self.previewContainerView, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { self.previewBlurView.effect = UIBlurEffect(style: .dark) }) } else { self.previewContainerView.insertSubview(self.previewBlurView, belowSubview: self.additionalPreviewContainerView) UIView.animate(withDuration: 0.4) { self.previewBlurView.effect = UIBlurEffect(style: .dark) } } } else if forceBlur { UIView.animate(withDuration: 0.4) { self.previewBlurView.effect = UIBlurEffect(style: .dark) } } else { if self.previewBlurView.effect != nil { UIView.animate(withDuration: 0.4, animations: { self.previewBlurView.effect = nil }, completion: { _ in self.previewContainerView.insertSubview(self.previewBlurView, aboveSubview: self.additionalPreviewContainerView) }) } if let previewSnapshotView = self.mainPreviewSnapshotView { self.mainPreviewSnapshotView = nil UIView.animate(withDuration: 0.25, animations: { previewSnapshotView.alpha = 0.0 }, completion: { _ in previewSnapshotView.removeFromSuperview() }) } if let previewSnapshotView = self.additionalPreviewSnapshotView { self.additionalPreviewSnapshotView = nil UIView.animate(withDuration: 0.25, animations: { previewSnapshotView.alpha = 0.0 }, completion: { _ in previewSnapshotView.removeFromSuperview() }) } if isFirstTime { isFirstTime = false } else { if self.cameraState.isDualCameraEnabled { self.mainPreviewView.removePlaceholder() self.additionalPreviewView.removePlaceholder() } } } } }) camera.focus(at: CGPoint(x: 0.5, y: 0.5), autoFocus: true) camera.startCapture() self.camera = camera if self.hasAppeared { self.maybePresentTooltips() } } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { return false } return true } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: gestureRecognizer.view) if gestureRecognizer === self.pipPanGestureRecognizer { if !self.cameraState.isDualCameraEnabled { return false } return self.additionalPreviewContainerView.frame.contains(location) } return self.hasAppeared } @objc private func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { guard let camera = self.camera else { return } switch gestureRecognizer.state { case .changed: let scale = gestureRecognizer.scale camera.setZoomDelta(scale) gestureRecognizer.scale = 1.0 default: break } } private var isDismissing = false @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let controller = self.controller, let layout = self.validLayout else { return } let translation = gestureRecognizer.translation(in: gestureRecognizer.view) switch gestureRecognizer.state { case .began: break case .changed: if case .none = self.cameraState.recording { if case .compact = layout.metrics.widthClass { if translation.x < -10.0 || self.isDismissing { self.isDismissing = true let transitionFraction = 1.0 - max(0.0, translation.x * -1.0) / self.frame.width controller.updateTransitionProgress(transitionFraction, transition: .immediate) } else if translation.y < -10.0 && abs(translation.y) > abs(translation.x) { controller.presentGallery(fromGesture: true) gestureRecognizer.isEnabled = false gestureRecognizer.isEnabled = true } } } case .ended, .cancelled: let velocity = gestureRecognizer.velocity(in: self.view) let transitionFraction = 1.0 - max(0.0, translation.x * -1.0) / self.frame.width controller.completeWithTransitionProgress(transitionFraction, velocity: abs(velocity.x), dismissing: true) self.isDismissing = false default: break } } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let camera = self.camera else { return } let location = gestureRecognizer.location(in: self.mainPreviewView) let point = self.mainPreviewView.cameraPoint(for: location) camera.focus(at: point, autoFocus: false) } @objc private func handleDoubleTap(_ gestureRecognizer: UITapGestureRecognizer) { self.toggleCameraPositionAction.invoke(Void()) } private var pipTranslation: CGPoint? @objc private func handlePipPan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let layout = self.validLayout else { return } let translation = gestureRecognizer.translation(in: self.view) let location = gestureRecognizer.location(in: self.view) let velocity = gestureRecognizer.velocity(in: self.view) switch gestureRecognizer.state { case .began, .changed: self.pipTranslation = translation self.containerLayoutUpdated(layout: layout, transition: .immediate) case .ended, .cancelled: self.pipTranslation = nil self.pipPosition = pipPositionForLocation(layout: layout, position: location, velocity: velocity) self.containerLayoutUpdated(layout: layout, transition: .spring(duration: 0.4)) UserDefaults.standard.set(self.pipPosition.rawValue as NSNumber, forKey: "TelegramStoryCameraDualPosition") default: break } } private var animatingDualCameraPositionSwitch = false func animateDualCameraPositionSwitch() { let duration: Double = 0.5 let timingFunction = kCAMediaTimingFunctionSpring var snapshotView: UIView? if let mainSnapshot = self.mainPreviewContainerView.snapshotView(afterScreenUpdates: false) { mainSnapshot.frame = self.mainPreviewContainerView.frame self.mainPreviewContainerView.superview?.insertSubview(mainSnapshot, belowSubview: self.mainPreviewContainerView) snapshotView = mainSnapshot } CATransaction.begin() CATransaction.setDisableActions(true) self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) CATransaction.commit() self.additionalPreviewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.additionalPreviewContainerView.layer.animateScale(from: 0.01, to: 1.0, duration: duration, timingFunction: timingFunction) self.mainPreviewContainerView.layer.animate( from: self.additionalPreviewContainerView.layer.cornerRadius as NSNumber, to: 12.0 as NSNumber, keyPath: "cornerRadius", timingFunction: timingFunction, duration: duration ) self.mainPreviewContainerView.layer.animatePosition( from: self.additionalPreviewContainerView.center, to: self.mainPreviewContainerView.center, duration: duration, timingFunction: timingFunction ) let scale = self.additionalPreviewContainerView.frame.width / self.mainPreviewContainerView.frame.width self.mainPreviewContainerView.layer.animateScale( from: scale, to: 1.0, duration: duration, timingFunction: timingFunction ) self.animatingDualCameraPositionSwitch = true self.mainPreviewContainerView.layer.animateBounds( from: CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((self.mainPreviewContainerView.bounds.height - self.mainPreviewContainerView.bounds.width) / 2.0)), size: CGSize(width: self.mainPreviewContainerView.bounds.width, height: self.mainPreviewContainerView.bounds.width)), to: self.mainPreviewContainerView.bounds, duration: duration, timingFunction: timingFunction, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() self.animatingDualCameraPositionSwitch = false } ) } func animateIn() { self.transitionDimView.alpha = 0.0 self.backgroundView.alpha = 0.0 UIView.animate(withDuration: 0.4, animations: { self.backgroundView.alpha = 1.0 }) if let layout = self.validLayout, case .regular = layout.metrics.widthClass { self.controller?.statusBar.updateStatusBarStyle(.Hide, animated: true) } if let transitionIn = self.controller?.transitionIn, let sourceView = transitionIn.sourceView { let sourceLocalFrame = sourceView.convert(transitionIn.sourceRect, to: self.view) let sourceScale = sourceLocalFrame.width / self.previewContainerView.frame.width self.previewContainerView.layer.animatePosition(from: sourceLocalFrame.center, to: self.previewContainerView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in self.requestUpdateLayout(hasAppeared: true, transition: .immediate) }) self.previewContainerView.layer.animateScale(from: sourceScale, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) let minSide = min(self.previewContainerView.bounds.width, self.previewContainerView.bounds.height) self.previewContainerView.layer.animateBounds(from: CGRect(origin: CGPoint(x: (self.previewContainerView.bounds.width - minSide) / 2.0, y: (self.previewContainerView.bounds.height - minSide) / 2.0), size: CGSize(width: minSide, height: minSide)), to: self.previewContainerView.bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.previewContainerView.layer.animate( from: minSide / 2.0 as NSNumber, to: self.previewContainerView.layer.cornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3 ) if let view = self.componentHost.view { view.layer.animatePosition(from: sourceLocalFrame.center, to: view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } } } func animateOut(completion: @escaping () -> Void) { self.camera?.stopCapture(invalidate: true) UIView.animate(withDuration: 0.25, animations: { self.backgroundView.alpha = 0.0 }) if let transitionOut = self.controller?.transitionOut(false), let destinationView = transitionOut.destinationView { let destinationLocalFrame = destinationView.convert(transitionOut.destinationRect, to: self.view) let targetScale = destinationLocalFrame.width / self.previewContainerView.frame.width self.previewContainerView.layer.animatePosition(from: self.previewContainerView.center, to: destinationLocalFrame.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in completion() }) self.previewContainerView.layer.animateScale(from: 1.0, to: targetScale, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) let minSide = min(self.previewContainerView.bounds.width, self.previewContainerView.bounds.height) self.previewContainerView.layer.animateBounds(from: self.previewContainerView.bounds, to: CGRect(origin: CGPoint(x: (self.previewContainerView.bounds.width - minSide) / 2.0, y: (self.previewContainerView.bounds.height - minSide) / 2.0), size: CGSize(width: minSide, height: minSide)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) self.previewContainerView.layer.animate( from: self.previewContainerView.layer.cornerRadius as NSNumber, to: minSide / 2.0 as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3, removeOnCompletion: false ) if let view = self.componentHost.view { view.layer.animatePosition(from: view.center, to: destinationLocalFrame.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) view.layer.animateScale(from: 1.0, to: targetScale, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } } else { completion() } self.componentHost.view?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) self.previewContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false) } func animateOutToEditor() { self.cameraIsActive = false self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) if let view = self.componentHost.findTaggedView(tag: cancelButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: dualButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: zoomControlTag) { transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: captureControlsTag) as? CaptureControlsComponent.View { view.animateOutToEditor(transition: transition) } if let view = self.componentHost.findTaggedView(tag: modeControlTag) as? ModeComponent.View { view.animateOutToEditor(transition: transition) } } func pauseCameraCapture() { self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false Queue.mainQueue().after(0.3) { self.previewBlurPromise.set(true) } self.camera?.stopCapture() self.cameraIsActive = false self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) } func resumeCameraCapture() { if !self.mainPreviewView.isEnabled { if let snapshot = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewView.addSubview(snapshot) self.mainPreviewSnapshotView = snapshot } if let snapshot = self.additionalPreviewView.snapshotView(afterScreenUpdates: false) { self.additionalPreviewView.addSubview(snapshot) self.additionalPreviewSnapshotView = snapshot } self.mainPreviewView.isEnabled = true self.additionalPreviewView.isEnabled = true self.camera?.startCapture() if #available(iOS 13.0, *) { let _ = (self.mainPreviewView.isPreviewing |> filter { $0 } |> take(1)).start(next: { [weak self] _ in if let self { self.previewBlurPromise.set(false) } }) } else { Queue.mainQueue().after(1.0) { self.previewBlurPromise.set(false) } } self.cameraIsActive = true self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) } } func animateInFromEditor(toGallery: Bool) { if !toGallery { self.resumeCameraCapture() self.cameraIsActive = true self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) if let view = self.componentHost.findTaggedView(tag: cancelButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: dualButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: zoomControlTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: captureControlsTag) as? CaptureControlsComponent.View { view.animateInFromEditor(transition: transition) } if let view = self.componentHost.findTaggedView(tag: modeControlTag) as? ModeComponent.View { view.animateInFromEditor(transition: transition) } } } func updateModalTransitionFactor(_ value: CGFloat, transition: ContainedViewLayoutTransition) { guard let layout = self.validLayout, case .compact = layout.metrics.widthClass else { return } let progress = 1.0 - value let maxScale = (layout.size.width - 16.0 * 2.0) / layout.size.width let topInset: CGFloat = (layout.statusBarHeight ?? 0.0) + 5.0 let targetTopInset = ceil((layout.statusBarHeight ?? 0.0) - (layout.size.height - layout.size.height * maxScale) / 2.0) let deltaOffset = (targetTopInset - topInset) let scale = 1.0 * progress + (1.0 - progress) * maxScale let offset = (1.0 - progress) * deltaOffset transition.updateSublayerTransformScaleAndOffset(layer: self.containerView.layer, scale: scale, offset: CGPoint(x: 0.0, y: offset), beginWithCurrentState: true) } func presentDraftTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: galleryButtonTag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: self.view) else { return } let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 29.0), size: CGSize()) let controller = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Draft Saved"), location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in return .ignore }) self.controller?.present(controller, in: .current) } func presentDualCameraTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: dualButtonTag) else { return } let parentFrame = self.view.convert(self.bounds, to: nil) let location = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) let accountManager = self.context.sharedContext.accountManager let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Tap here to disable\nthe selfie camera"), textAlignment: .center, location: .point(location, .right), displayDuration: .custom(5.0), inset: 16.0, shouldDismissOnTouch: { point, containerFrame in if containerFrame.contains(point) { let _ = ApplicationSpecificNotice.incrementStoriesDualCameraTip(accountManager: accountManager, count: 2).start() return .dismiss(consume: true) } return .ignore }) self.controller?.present(tooltipController, in: .current) } func presentCameraTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: captureControlsTag) else { return } let parentFrame = self.view.convert(self.bounds, to: nil) let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.minY + 3.0), size: CGSize()) let accountManager = self.context.sharedContext.accountManager let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Take photos or videos to share with all\nyour contacts or close friends at once."), textAlignment: .center, location: .point(location, .bottom), displayDuration: .custom(4.5), inset: 16.0, shouldDismissOnTouch: { [weak self] point, containerFrame in if containerFrame.contains(point) { let _ = ApplicationSpecificNotice.incrementStoriesCameraTip(accountManager: accountManager).start() Queue.mainQueue().justDispatch { self?.maybePresentTooltips() } return .dismiss(consume: true) } return .ignore }) self.controller?.present(tooltipController, in: .current) } func maybePresentTooltips() { guard let layout = self.validLayout, case .compact = layout.metrics.widthClass else { return } let _ = (ApplicationSpecificNotice.incrementStoriesCameraTip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in guard let self else { return } if count > 1 { let _ = (ApplicationSpecificNotice.getStoriesDualCameraTip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in guard let self else { return } if count < 2 { self.presentDualCameraTooltip() } }) return } else { self.presentCameraTooltip() } }) } fileprivate func dismissAllTooltips() { guard let controller = self.controller else { return } controller.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } }) controller.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.componentHost.view { if self.additionalPreviewContainerView.bounds.contains(self.view.convert(point, to: self.additionalPreviewContainerView)) { return self.additionalPreviewContainerView } else { return self.mainPreviewView } } return result } func requestUpdateLayout(hasAppeared: Bool, transition: Transition) { if let layout = self.validLayout { self.containerLayoutUpdated(layout: layout, forceUpdate: true, hasAppeared: hasAppeared, transition: transition) } } fileprivate var hasAppeared = false func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, hasAppeared: Bool = false, transition: Transition) { guard let _ = self.controller else { return } let isFirstTime = self.validLayout == nil self.validLayout = layout let isTablet: Bool if case .regular = layout.metrics.widthClass { isTablet = true } else { isTablet = false } var topInset: CGFloat = (layout.statusBarHeight ?? 0.0) + 5.0 let previewSize: CGSize if isTablet { previewSize = CGSize(width: floorToScreenPixels(layout.size.height / 1.77778), height: layout.size.height) } else { previewSize = CGSize(width: layout.size.width, height: floorToScreenPixels(layout.size.width * 1.77778)) if layout.size.height < previewSize.height + 30.0 { topInset = 0.0 } } let bottomInset = layout.size.height - previewSize.height - topInset let panelWidth: CGFloat let previewFrame: CGRect let viewfinderFrame: CGRect if isTablet { previewFrame = CGRect(origin: .zero, size: layout.size) viewfinderFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - previewSize.width) / 2.0), y: 0.0), size: previewSize) panelWidth = viewfinderFrame.minX } else { previewFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset), size: previewSize) viewfinderFrame = previewFrame panelWidth = 0.0 } let environment = ViewControllerComponentContainer.Environment( statusBarHeight: layout.statusBarHeight ?? 0.0, navigationHeight: 0.0, safeInsets: UIEdgeInsets( top: topInset, left: layout.safeInsets.left, bottom: bottomInset, right: layout.safeInsets.right ), inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, orientation: nil, isVisible: true, theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, controller: { [weak self] in return self?.controller } ) var transition = transition if isFirstTime { transition = transition.withUserData(CameraScreenTransition.animateIn) } else if hasAppeared && !self.hasAppeared { self.hasAppeared = hasAppeared transition = transition.withUserData(CameraScreenTransition.finishedAnimateIn) if self.camera != nil { self.maybePresentTooltips() } else if case .notDetermined = self.cameraAuthorizationStatus { self.requestDeviceAccess() } } let componentSize = self.componentHost.update( transition: transition, component: AnyComponent( CameraScreenComponent( context: self.context, cameraState: self.cameraState, cameraAuthorizationStatus: self.cameraAuthorizationStatus, microphoneAuthorizationStatus: self.microphoneAuthorizationStatus, hasAppeared: self.hasAppeared, isVisible: self.cameraIsActive && !self.hasGallery, panelWidth: panelWidth, animateFlipAction: self.animateFlipAction, animateShutter: { [weak self] in self?.mainPreviewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) }, toggleCameraPositionAction: self.toggleCameraPositionAction, getController: { [weak self] in return self?.controller }, present: { [weak self] c in self?.controller?.present(c, in: .window(.root)) }, push: { [weak self] c in self?.controller?.push(c) }, completion: self.completion ) ), environment: { environment }, forceUpdate: forceUpdate, containerSize: layout.size ) if let componentView = self.componentHost.view { if componentView.superview == nil { self.containerView.insertSubview(componentView, belowSubview: transitionDimView) componentView.clipsToBounds = true } let componentFrame = CGRect(origin: .zero, size: componentSize) transition.setFrame(view: componentView, frame: componentFrame) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag), view.layer.shadowOpacity.isZero { view.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) view.layer.shadowRadius = 3.0 view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOpacity = 0.25 } transition.setPosition(view: self.backgroundView, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)) transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: .zero, size: layout.size)) if !self.hasGallery { transition.setPosition(view: self.containerView, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)) } transition.setBounds(view: self.containerView, bounds: CGRect(origin: .zero, size: layout.size)) transition.setFrame(view: self.transitionDimView, frame: CGRect(origin: .zero, size: layout.size)) let previewContainerFrame: CGRect if isTablet { previewContainerFrame = CGRect(origin: .zero, size: layout.size) } else { previewContainerFrame = previewFrame } transition.setFrame(view: self.previewContainerView, frame: previewContainerFrame) transition.setFrame(view: self.mainPreviewContainerView, frame: CGRect(origin: .zero, size: previewContainerFrame.size)) transition.setFrame(view: self.previewBlurView, frame: CGRect(origin: .zero, size: previewContainerFrame.size)) let isDualCameraEnabled = self.cameraState.isDualCameraEnabled let dualCamUpdated = self.appliedDualCamera != isDualCameraEnabled self.appliedDualCamera = isDualCameraEnabled let dualCameraSize: CGFloat = 160.0 let circleSide = floorToScreenPixels(previewSize.width * dualCameraSize / 393.0) let circleOffset = CGPoint(x: previewSize.width * (dualCameraSize + 107.0) / 1080.0, y: previewSize.width * (dualCameraSize + 278.0) / 1080.0) var origin: CGPoint switch self.pipPosition { case .topLeft: origin = CGPoint(x: circleOffset.x, y: circleOffset.y) if !isDualCameraEnabled { origin = origin.offsetBy(dx: -180.0, dy: 0.0) } case .topRight: origin = CGPoint(x: previewFrame.width - circleOffset.x, y: circleOffset.y) if !isDualCameraEnabled { origin = origin.offsetBy(dx: 180.0, dy: 0.0) } case .bottomLeft: origin = CGPoint(x: circleOffset.x, y: previewFrame.height - circleOffset.y) if !isDualCameraEnabled { origin = origin.offsetBy(dx: -180.0, dy: 0.0) } case .bottomRight: origin = CGPoint(x: previewFrame.width - circleOffset.x, y: previewFrame.height - circleOffset.y) if !isDualCameraEnabled { origin = origin.offsetBy(dx: 180.0, dy: 0.0) } } if let pipTranslation = self.pipTranslation { origin = origin.offsetBy(dx: pipTranslation.x, dy: pipTranslation.y) } let additionalPreviewInnerSize = previewFrame.size.aspectFilled(CGSize(width: circleSide, height: circleSide)) let additionalPreviewFrame = CGRect(origin: CGPoint(x: origin.x - circleSide / 2.0, y: origin.y - circleSide / 2.0), size: CGSize(width: circleSide, height: circleSide)) transition.setPosition(view: self.additionalPreviewContainerView, position: additionalPreviewFrame.center) transition.setBounds(view: self.additionalPreviewContainerView, bounds: CGRect(origin: .zero, size: additionalPreviewFrame.size)) self.additionalPreviewContainerView.layer.cornerRadius = additionalPreviewFrame.width / 2.0 transition.setScale(view: self.additionalPreviewContainerView, scale: isDualCameraEnabled ? 1.0 : 0.1) transition.setAlpha(view: self.additionalPreviewContainerView, alpha: isDualCameraEnabled ? 1.0 : 0.0) if dualCamUpdated && isDualCameraEnabled { if case .back = self.cameraState.position { self.additionalPreviewView.resetPlaceholder(front: true) } else { self.mainPreviewView.resetPlaceholder(front: false) } } var mainPreviewInnerSize = previewFrame.size let mainPreviewView: CameraSimplePreviewView let additionalPreviewView: CameraSimplePreviewView if case .front = self.cameraState.position, isDualCameraEnabled { mainPreviewView = self.additionalPreviewView additionalPreviewView = self.mainPreviewView mainPreviewInnerSize = CGSize(width: floorToScreenPixels(mainPreviewInnerSize.height / 3.0 * 4.0), height: mainPreviewInnerSize.height) } else { mainPreviewView = self.mainPreviewView additionalPreviewView = self.additionalPreviewView } let mainPreviewInnerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((previewFrame.width - mainPreviewInnerSize.width) / 2.0), y: floorToScreenPixels((previewFrame.height - mainPreviewInnerSize.height) / 2.0)), size: mainPreviewInnerSize) let additionalPreviewInnerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((circleSide - additionalPreviewInnerSize.width) / 2.0), y: floorToScreenPixels((circleSide - additionalPreviewInnerSize.height) / 2.0)), size: additionalPreviewInnerSize) if mainPreviewView.superview != self.mainPreviewContainerView { self.mainPreviewContainerView.insertSubview(mainPreviewView, at: 0) } if additionalPreviewView.superview != self.additionalPreviewContainerView { self.additionalPreviewContainerView.insertSubview(additionalPreviewView, at: 0) } mainPreviewView.frame = mainPreviewInnerFrame additionalPreviewView.frame = additionalPreviewInnerFrame self.previewFrameLeftDimView.isHidden = !isTablet transition.setFrame(view: self.previewFrameLeftDimView, frame: CGRect(origin: .zero, size: CGSize(width: viewfinderFrame.minX, height: viewfinderFrame.height))) self.previewFrameRightDimView.isHidden = !isTablet transition.setFrame(view: self.previewFrameRightDimView, frame: CGRect(origin: CGPoint(x: viewfinderFrame.maxX, y: 0.0), size: CGSize(width: viewfinderFrame.minX + 1.0, height: viewfinderFrame.height))) let screenCornerRadius = layout.deviceMetrics.screenCornerRadius if screenCornerRadius > 0.0, self.transitionCornersView.image == nil { self.transitionCornersView.image = generateImage(CGSize(width: screenCornerRadius, height: screenCornerRadius * 3.0), rotatedContext: { size, context in context.setFillColor(UIColor.black.cgColor) context.fill(CGRect(origin: .zero, size: size)) context.setBlendMode(.clear) let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: CGSize(width: size.width * 2.0, height: size.height)), cornerRadius: size.width) context.addPath(path.cgPath) context.fillPath() })?.stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(screenCornerRadius)) } self.transitionCornersView.isHidden = isTablet transition.setPosition(view: self.transitionCornersView, position: CGPoint(x: layout.size.width + screenCornerRadius / 2.0, y: layout.size.height / 2.0)) transition.setBounds(view: self.transitionCornersView, bounds: CGRect(origin: .zero, size: CGSize(width: screenCornerRadius, height: layout.size.height))) if isTablet && isFirstTime { self.animateIn() } } } fileprivate var node: Node { return self.displayNode as! Node } private let context: AccountContext fileprivate let mode: Mode fileprivate let holder: CameraHolder? fileprivate let transitionIn: TransitionIn? fileprivate let transitionOut: (Bool) -> TransitionOut? public final class ResultTransition { public weak var sourceView: UIView? public let sourceRect: CGRect public let sourceImage: UIImage? public let transitionOut: (Bool?) -> (UIView, CGRect)? public init( sourceView: UIView, sourceRect: CGRect, sourceImage: UIImage?, transitionOut: @escaping (Bool?) -> (UIView, CGRect)? ) { self.sourceView = sourceView self.sourceRect = sourceRect self.sourceImage = sourceImage self.transitionOut = transitionOut } } fileprivate let completion: (Signal, ResultTransition?, @escaping () -> Void) -> Void public var transitionedIn: () -> Void = {} private var audioSessionDisposable: Disposable? private let hapticFeedback = HapticFeedback() private var validLayout: ContainerViewLayout? fileprivate var camera: Camera? { return self.node.camera } fileprivate var cameraState: CameraState { return self.node.cameraState } fileprivate func updateCameraState(_ f: (CameraState) -> CameraState, transition: Transition) { self.node.cameraState = f(self.node.cameraState) self.node.requestUpdateLayout(hasAppeared: self.node.hasAppeared, transition: transition) } public init( context: AccountContext, mode: Mode, holder: CameraHolder? = nil, transitionIn: TransitionIn?, transitionOut: @escaping (Bool) -> TransitionOut?, completion: @escaping (Signal, ResultTransition?, @escaping () -> Void) -> Void ) { self.context = context self.mode = mode self.holder = holder self.transitionIn = transitionIn self.transitionOut = transitionOut self.completion = completion super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .Ignore self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.navigationPresentation = .flatModal self.requestAudioSession() } required public init(coder: NSCoder) { preconditionFailure() } deinit { self.audioSessionDisposable?.dispose() if #available(iOS 13.0, *) { try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(false) } } override public func loadDisplayNode() { self.displayNode = Node(controller: self) super.displayNodeDidLoad() } private func requestAudioSession() { self.audioSessionDisposable = self.context.sharedContext.mediaManager.audioSession.push(audioSessionType: .recordWithOthers, activate: { _ in if #available(iOS 13.0, *) { try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true) } }, deactivate: { _ in return .single(Void()) }) } private var galleryController: ViewController? public func returnFromEditor() { self.node.animateInFromEditor(toGallery: self.galleryController?.displayNode.supernode != nil) } private var didStopCameraCapture = false func presentGallery(fromGesture: Bool = false) { if !fromGesture { self.hapticFeedback.impact(.light) } self.node.dismissAllTooltips() self.node.hasGallery = true self.didStopCameraCapture = false let stopCameraCapture = { [weak self] in guard let self, !self.didStopCameraCapture else { return } self.didStopCameraCapture = true self.node.pauseCameraCapture() } let resumeCameraCapture = { [weak self] in guard let self, self.didStopCameraCapture else { return } self.didStopCameraCapture = false self.node.resumeCameraCapture() } let controller: ViewController if let current = self.galleryController { controller = current } else { controller = self.context.sharedContext.makeStoryMediaPickerScreen(context: self.context, getSourceRect: { [weak self] in if let self { if let galleryButton = self.node.componentHost.findTaggedView(tag: galleryButtonTag) { return galleryButton.convert(galleryButton.bounds, to: self.view).offsetBy(dx: 0.0, dy: -15.0) } else { return .zero } } else { return .zero } }, completion: { [weak self] result, transitionView, transitionRect, transitionImage, transitionOut, dismissed in if let self { stopCameraCapture() let resultTransition = ResultTransition( sourceView: transitionView, sourceRect: transitionRect, sourceImage: transitionImage, transitionOut: transitionOut ) if let asset = result as? PHAsset { self.completion(.single(.asset(asset)), resultTransition, dismissed) } else if let draft = result as? MediaEditorDraft { self.completion(.single(.draft(draft)), resultTransition, dismissed) } } }, dismissed: { [weak self] in resumeCameraCapture() if let self { self.node.hasGallery = false self.node.requestUpdateLayout(hasAppeared: self.node.hasAppeared, transition: .immediate) } }) self.galleryController = controller } controller.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak controller] transition in if let self, let controller { let transitionFactor = controller.modalStyleOverlayTransitionFactor if transitionFactor > 0.1 { stopCameraCapture() } self.node.updateModalTransitionFactor(transitionFactor, transition: transition) } } self.push(controller) self.requestLayout(transition: .immediate) } public func presentDraftTooltip() { self.node.presentDraftTooltip() } private var isDismissed = false fileprivate func requestDismiss(animated: Bool, interactive: Bool = false) { guard !self.isDismissed else { return } self.node.dismissAllTooltips() if !interactive { self.hapticFeedback.impact(.light) } self.node.camera?.stopCapture(invalidate: true) self.isDismissed = true if animated { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.node.animateOut(completion: { self.dismiss(animated: false) }) } else { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) if !interactive { if let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(self.node.frame.width, transition: .immediate) } } self.updateTransitionProgress(0.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in self?.dismiss(animated: false) }) } } else { self.dismiss(animated: false) } } public func updateTransitionProgress(_ transitionFraction: CGFloat, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void = {}) { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { return } if self.node.hasAppeared { self.node.dismissAllTooltips() } let transitionFraction = max(0.0, min(1.0, transitionFraction)) let offsetX = floorToScreenPixels((1.0 - transitionFraction) * self.node.frame.width * -1.0) transition.updateTransform(layer: self.node.backgroundView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) let scale: CGFloat = max(0.8, min(1.0, 0.8 + 0.2 * transitionFraction)) if !self.node.hasGallery { transition.updateTransform(layer: self.node.containerView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) transition.updateSublayerTransformScaleAndOffset(layer: self.node.containerView.layer, scale: scale, offset: CGPoint(x: -offsetX * 1.0 / scale * 0.5, y: 0.0), completion: { _ in completion() }) } else { completion() } let dimAlpha = 0.6 * (1.0 - transitionFraction) transition.updateAlpha(layer: self.node.transitionDimView.layer, alpha: dimAlpha) transition.updateTransform(layer: self.node.transitionCornersView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) let sublayerOffsetX = offsetX * 1.0 / scale * 0.5 self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.view.layer.sublayerTransform = CATransform3DMakeTranslation(sublayerOffsetX, 0.0, 0.0) } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.view.layer.sublayerTransform = CATransform3DMakeTranslation(sublayerOffsetX, 0.0, 0.0) } return true }) if let navigationController = self.navigationController as? NavigationController { let offsetX = floorToScreenPixels(transitionFraction * self.node.frame.width) navigationController.updateRootContainerTransitionOffset(offsetX, transition: transition) } } public func completeWithTransitionProgress(_ transitionFraction: CGFloat, velocity: CGFloat, dismissing: Bool) { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { return } if dismissing { if transitionFraction < 0.7 || velocity < -1000.0 { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.requestDismiss(animated: true, interactive: true) } else { self.statusBar.updateStatusBarStyle(.White, animated: true) self.updateTransitionProgress(1.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in if let self, let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(0.0, transition: .immediate) } }) } } else { if transitionFraction > 0.33 || velocity > 1000.0 { self.statusBar.updateStatusBarStyle(.White, animated: true) self.updateTransitionProgress(1.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in if let self, let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(0.0, transition: .immediate) self.node.requestUpdateLayout(hasAppeared: true, transition: .immediate) self.transitionedIn() } }) } else { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.requestDismiss(animated: true, interactive: true) } } } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { if !flag { self.galleryController?.dismiss(animated: false) } super.dismiss(animated: flag, completion: completion) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.validLayout = layout super.containerLayoutUpdated(layout, transition: transition) if !self.isDismissed { (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: Transition(transition)) } } } private final class DualIconComponent: Component { typealias EnvironmentType = Empty let isSelected: Bool init( isSelected: Bool ) { self.isSelected = isSelected } static func ==(lhs: DualIconComponent, rhs: DualIconComponent) -> Bool { if lhs.isSelected != rhs.isSelected { return false } return true } final class View: UIView { private let iconView = UIImageView() private var component: DualIconComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) let image = generateImage(CGSize(width: 36.0, height: 36.0), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) if let image = UIImage(bundleImageName: "Camera/DualIcon"), let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels((size.height - image.size.height) / 2.0) - 1.0), size: image.size)) } }) let selectedImage = generateImage(CGSize(width: 36.0, height: 36.0), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) context.fillEllipse(in: CGRect(origin: .zero, size: size)) if let image = UIImage(bundleImageName: "Camera/DualIcon"), let cgImage = image.cgImage { context.setBlendMode(.clear) context.clip(to: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels((size.height - image.size.height) / 2.0) - 1.0), size: image.size), mask: cgImage) context.fill(CGRect(origin: .zero, size: size)) } }) self.iconView.image = image self.iconView.highlightedImage = selectedImage self.iconView.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) self.iconView.layer.shadowRadius = 3.0 self.iconView.layer.shadowColor = UIColor.black.cgColor self.iconView.layer.shadowOpacity = 0.25 self.addSubview(self.iconView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: DualIconComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let size = CGSize(width: 36.0, height: 36.0) self.iconView.frame = CGRect(origin: .zero, size: size) self.iconView.isHighlighted = component.isSelected return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private func pipPositionForLocation(layout: ContainerViewLayout, position: CGPoint, velocity: CGPoint) -> CameraScreen.PIPPosition { var layoutInsets = layout.insets(options: [.input]) layoutInsets.bottom += 48.0 var result = CGPoint() if position.x < layout.size.width / 2.0 { result.x = 0.0 } else { result.x = 1.0 } if position.y < layoutInsets.top + (layout.size.height - layoutInsets.bottom - layoutInsets.top) / 2.0 { result.y = 0.0 } else { result.y = 1.0 } let currentPosition = result let angleEpsilon: CGFloat = 30.0 var shouldHide = false if (velocity.x * velocity.x + velocity.y * velocity.y) >= 500.0 * 500.0 { let x = velocity.x let y = velocity.y var angle = atan2(y, x) * 180.0 / CGFloat.pi * -1.0 if angle < 0.0 { angle += 360.0 } if currentPosition.x.isZero && currentPosition.y.isZero { if ((angle > 0 && angle < 90 - angleEpsilon) || angle > 360 - angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (angle > 180 + angleEpsilon && angle < 270 + angleEpsilon) { result.x = 0.0 result.y = 1.0 } else if (angle > 270 + angleEpsilon && angle < 360 - angleEpsilon) { result.x = 1.0 result.y = 1.0 } else { shouldHide = true } } else if !currentPosition.x.isZero && currentPosition.y.isZero { if (angle > 90 + angleEpsilon && angle < 180 + angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (angle > 270 - angleEpsilon && angle < 360 - angleEpsilon) { result.x = 1.0 result.y = 1.0 } else if (angle > 180 + angleEpsilon && angle < 270 - angleEpsilon) { result.x = 0.0 result.y = 1.0 } else { shouldHide = true } } else if currentPosition.x.isZero && !currentPosition.y.isZero { if (angle > 90 - angleEpsilon && angle < 180 - angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (angle < angleEpsilon || angle > 270 + angleEpsilon) { result.x = 1.0 result.y = 1.0 } else if (angle > angleEpsilon && angle < 90 - angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (!shouldHide) { shouldHide = true } } else if !currentPosition.x.isZero && !currentPosition.y.isZero { if (angle > angleEpsilon && angle < 90 + angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (angle > 180 - angleEpsilon && angle < 270 - angleEpsilon) { result.x = 0.0 result.y = 1.0 } else if (angle > 90 + angleEpsilon && angle < 180 - angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (!shouldHide) { shouldHide = true } } } var position: CameraScreen.PIPPosition = .topRight if result.x == 0.0 && result.y == 0.0 { position = .topLeft } else if result.x == 1.0 && result.y == 0.0 { position = .topRight } else if result.x == 0.0 && result.y == 1.0 { position = .bottomLeft } else if result.x == 1.0 && result.y == 1.0 { position = .bottomRight } return position }