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 let videoRedColor = UIColor(rgb: 0xff3b30) enum CameraMode: Equatable { case photo case video } private struct CameraState { enum Recording: Equatable { case none case holding case handsFree } let mode: CameraMode let flashMode: Camera.FlashMode let recording: Recording let duration: Double func updatedMode(_ mode: CameraMode) -> CameraState { return CameraState(mode: mode, flashMode: self.flashMode, recording: self.recording, duration: self.duration) } func updatedFlashMode(_ flashMode: Camera.FlashMode) -> CameraState { return CameraState(mode: self.mode, flashMode: flashMode, recording: self.recording, duration: self.duration) } func updatedRecording(_ recording: Recording) -> CameraState { return CameraState(mode: self.mode, flashMode: self.flashMode, recording: recording, duration: self.duration) } func updatedDuration(_ duration: Double) -> CameraState { return CameraState(mode: self.mode, flashMode: self.flashMode, recording: self.recording, duration: duration) } } enum CameraScreenTransition { case animateIn case animateOut } private let cancelButtonTag = GenericComponentViewTag() private let flashButtonTag = GenericComponentViewTag() private let shutterButtonTag = GenericComponentViewTag() private let flipButtonTag = GenericComponentViewTag() private let zoomControlTag = GenericComponentViewTag() private final class CameraScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let camera: Camera let present: (ViewController) -> Void let push: (ViewController) -> Void let completion: (CameraScreen.Result) -> Void init( context: AccountContext, camera: Camera, present: @escaping (ViewController) -> Void, push: @escaping (ViewController) -> Void, completion: @escaping (CameraScreen.Result) -> Void ) { self.context = context self.camera = camera self.present = present self.push = push self.completion = completion } static func ==(lhs: CameraScreenComponent, rhs: CameraScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } final class State: ComponentState { enum ImageKey: Hashable { case cancel case flip case flash } 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")! case .flash: image = UIImage(bundleImageName: "Camera/FlashIcon")! } cachedImages[key] = image return image } } private let context: AccountContext fileprivate let camera: Camera private let present: (ViewController) -> Void private let completion: (CameraScreen.Result) -> Void private var cameraStateDisposable: Disposable? private var resultDisposable = MetaDisposable() private var mediaAssetsContext: MediaAssetsContext fileprivate var lastGalleryAsset: PHAsset? private var lastGalleryAssetsDisposable: Disposable? var cameraState = CameraState(mode: .photo, flashMode: .off, recording: .none, duration: 0.0) var swipeHint: CaptureControlsComponent.SwipeHint = .none init(context: AccountContext, camera: Camera, present: @escaping (ViewController) -> Void, completion: @escaping (CameraScreen.Result) -> Void) { self.context = context self.camera = camera self.present = present self.completion = completion self.mediaAssetsContext = MediaAssetsContext() super.init() self.cameraStateDisposable = (camera.flashMode |> deliverOnMainQueue).start(next: { [weak self] flashMode in guard let self else { return } self.cameraState = self.cameraState.updatedFlashMode(flashMode) self.updated(transition: .easeInOut(duration: 0.2)) }) self.lastGalleryAssetsDisposable = (self.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)) }) } deinit { self.cameraStateDisposable?.dispose() self.lastGalleryAssetsDisposable?.dispose() self.resultDisposable.dispose() } func updateCameraMode(_ mode: CameraMode) { self.cameraState = self.cameraState.updatedMode(mode) self.updated(transition: .spring(duration: 0.3)) } func updateSwipeHint(_ hint: CaptureControlsComponent.SwipeHint) { self.swipeHint = hint self.updated(transition: .easeInOut(duration: 0.2)) } func takePhoto() { self.resultDisposable.set((self.camera.takePhoto() |> deliverOnMainQueue).start(next: { [weak self] value in if let self { switch value { case .began: print("blink") case let .finished(image): self.completion(.image(image)) case .failed: print("failed") } } })) } func startVideoRecording(pressing: Bool) { self.cameraState = self.cameraState.updatedDuration(0.0).updatedRecording(pressing ? .holding : .handsFree) self.resultDisposable.set((self.camera.startRecording() |> deliverOnMainQueue).start(next: { [weak self] duration in if let self { self.cameraState = self.cameraState.updatedDuration(duration) self.updated(transition: .immediate) } })) self.updated(transition: .spring(duration: 0.4)) } func stopVideoRecording() { self.cameraState = self.cameraState.updatedRecording(.none).updatedDuration(0.0) self.resultDisposable.set((self.camera.stopRecording() |> deliverOnMainQueue).start(next: { [weak self] path in if let self, let path { self.completion(.video(path)) } })) self.updated(transition: .spring(duration: 0.4)) } func lockVideoRecording() { self.cameraState = self.cameraState.updatedRecording(.handsFree) self.updated(transition: .spring(duration: 0.4)) } } func makeState() -> State { return State(context: self.context, camera: self.camera, present: self.present, completion: self.completion) } static var body: Body { let cancelButton = Child(Button.self) let captureControls = Child(CaptureControlsComponent.self) let zoomControl = Child(ZoomComponent.self) let flashButton = Child(Button.self) let modeControl = Child(ModeComponent.self) let hintLabel = Child(MultilineTextComponent.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 let accountContext = component.context let push = component.push let completion = component.completion let topControlInset: CGFloat = 20.0 if case .none = state.cameraState.recording { let cancelButton = cancelButton.update( component: Button( content: AnyComponent(Image( image: state.image(.cancel), size: CGSize(width: 40.0, height: 40.0) )), action: { guard let controller = controller() as? CameraScreen else { return } controller.dismiss(animated: true) } ).tagged(cancelButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(cancelButton .position(CGPoint(x: topControlInset + cancelButton.size.width / 2.0, y: environment.safeInsets.top + topControlInset + cancelButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) .cornerRadius(20.0) ) let flashButton = flashButton.update( component: Button( content: AnyComponent(Image(image: state.image(.flash))), action: { [weak state] in guard let state else { return } if state.cameraState.flashMode == .off { state.camera.setFlashMode(.on) } else { state.camera.setFlashMode(.off) } } ).tagged(flashButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(flashButton .position(CGPoint(x: availableSize.width - topControlInset - flashButton.size.width / 2.0, y: environment.safeInsets.top + topControlInset + flashButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) .cornerRadius(20.0) ) } 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 - 187.0 - environment.safeInsets.bottom)) ) let shutterState: ShutterButtonState switch state.cameraState.recording { case .handsFree: shutterState = .stopRecording case .holding: shutterState = .holdRecording(progress: min(1.0, Float(state.cameraState.duration / 60.0))) case .none: switch state.cameraState.mode { case .photo: shutterState = .generic case .video: shutterState = .video } } let captureControls = captureControls.update( component: CaptureControlsComponent( shutterState: shutterState, lastGalleryAsset: state.lastGalleryAsset, tag: shutterButtonTag, shutterTapped: { [weak state] in guard let state else { return } if case .none = state.cameraState.recording { if state.cameraState.mode == .photo { state.takePhoto() } else if state.cameraState.mode == .video { state.startVideoRecording(pressing: false) } } else { state.stopVideoRecording() } }, shutterPressed: { [weak state] in guard let state, case .none = state.cameraState.recording else { return } state.startVideoRecording(pressing: true) }, shutterReleased: { [weak state] in guard let state, state.cameraState.recording != .none else { return } state.stopVideoRecording() }, lockRecording: { [weak state] in guard let state, state.cameraState.recording != .none else { return } state.lockVideoRecording() }, flipTapped: { [weak state] in guard let state else { return } state.camera.togglePosition() }, galleryTapped: { let controller = accountContext.sharedContext.makeMediaPickerScreen(context: accountContext, completion: { asset in completion(.asset(asset)) }) push(controller) }, swipeHintUpdated: { hint in state.updateSwipeHint(hint) } ), availableSize: context.availableSize, transition: context.transition ) context.add(captureControls .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - captureControls.size.height / 2.0 - 77.0 - environment.safeInsets.bottom)) ) var isVideoRecording = false if case .video = state.cameraState.mode { isVideoRecording = true } else if state.cameraState.recording != .none { isVideoRecording = true } if isVideoRecording { let duration = Int(state.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 ) if state.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(CGPoint(x: context.availableSize.width / 2.0, y: environment.safeInsets.top + 40.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } context.add(timeLabel .position(CGPoint(x: context.availableSize.width / 2.0, y: environment.safeInsets.top + 40.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) if case .holding = state.cameraState.recording { let hintText: String? switch state.swipeHint { case .none: hintText = nil 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: MultilineTextComponent( text: .plain(NSAttributedString(string: hintText.uppercased(), font: Font.with(size: 14.0, design: .camera, weight: .semibold), textColor: .white)), horizontalAlignment: .center ), availableSize: context.availableSize, transition: .immediate ) context.add(hintLabel .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - 35.0 - hintLabel.size.height - environment.safeInsets.bottom)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } } } if case .none = state.cameraState.recording { let modeControl = modeControl.update( component: ModeComponent( availableModes: [.photo, .video], currentMode: state.cameraState.mode, updatedMode: { [weak state] mode in if let state { state.updateCameraMode(mode) } } ), availableSize: context.availableSize, transition: context.transition ) context.add(modeControl .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - 7.0 - modeControl.size.height - environment.safeInsets.bottom)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } return context.availableSize } } } public class CameraScreen: ViewController { public enum Mode { case generic case story case instantVideo } public enum Result { case image(UIImage) case video(String) case asset(PHAsset) } fileprivate final class Node: ViewControllerTracingNode { private weak var controller: CameraScreen? private let context: AccountContext private let updateState: ActionSlot fileprivate let componentHost: ComponentView private let previewContainerView: UIView fileprivate let previewView: CameraPreviewView fileprivate let blurView: UIVisualEffectView fileprivate let camera: Camera private var presentationData: PresentationData private let hapticFeedback = HapticFeedback() private var validLayout: ContainerViewLayout? private var changingPositionDisposable: Disposable? init(controller: CameraScreen) { self.controller = controller self.context = controller.context self.updateState = ActionSlot() self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.componentHost = ComponentView() self.previewContainerView = UIView() self.previewContainerView.clipsToBounds = true self.previewContainerView.layer.cornerRadius = 12.0 self.blurView = UIVisualEffectView(effect: nil) self.blurView.isUserInteractionEnabled = false if let holder = controller.holder { self.previewView = holder.previewView self.camera = holder.camera } else { self.previewView = CameraPreviewView(test: false)! self.camera = Camera(configuration: Camera.Configuration(preset: .hd1920x1080, position: .back, audio: true, photo: true, metadata: false)) self.camera.attachPreviewView(self.previewView) } self.previewView.clipsToBounds = true super.init() self.backgroundColor = .black self.view.addSubview(self.previewContainerView) self.previewContainerView.addSubview(self.previewView) self.previewContainerView.addSubview(self.blurView) self.changingPositionDisposable = (self.camera.changingPosition |> deliverOnMainQueue).start(next: { [weak self] value in if let self { UIView.animate(withDuration: 0.5) { if value { if #available(iOS 13.0, *) { self.blurView.effect = UIBlurEffect(style: .systemThinMaterialDark) } } else { self.blurView.effect = nil } } } }) } deinit { self.changingPositionDisposable?.dispose() } override func didLoad() { super.didLoad() self.view.disablesInteractiveModalDismiss = true self.view.disablesInteractiveKeyboardGestureRecognizer = true let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinch(_:))) self.previewView.addGestureRecognizer(pinchGestureRecognizer) } @objc private func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { switch gestureRecognizer.state { case .began: gestureRecognizer.scale = 1.0 case .changed: let scale = gestureRecognizer.scale self.camera.setZoomLevel(scale) default: break } } func animateIn() { guard let layout = self.validLayout else { return } // if let view = self.componentHost.findTaggedView(tag: topGradientTag) { // view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) // } self.camera.focus(at: CGPoint(x: 0.5, y: 0.5)) self.camera.startCapture() self.layer.animatePosition(from: CGPoint(x: 0.0, y: layout.size.height), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } func animateOut(completion: @escaping () -> Void) { // if let (layout, orientation) = self.validLayout { // self.containerLayoutUpdated(layout: layout, orientation: orientation, animateOut: true, transition: .easeInOut(duration: 0.2)) // } // // if let view = self.componentHost.findTaggedView(tag: topGradientTag) { // view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) // } //self.layer.animatePosition(from: CGPoint(x: 0.0, y: self.frame.height), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.componentHost.view { return self.previewView } return result } func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, animateOut: Bool = false, transition: Transition) { guard let controller = self.controller else { return } let isFirstTime = self.validLayout == nil self.validLayout = layout let topInset: CGFloat = 60.0 //layout.intrinsicInsets.top + layout.safeInsets.top let environment = ViewControllerComponentContainer.Environment( statusBarHeight: layout.statusBarHeight ?? 0.0, navigationHeight: 0.0, safeInsets: UIEdgeInsets( top: topInset, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + layout.safeInsets.bottom, 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 animateOut { transition = transition.withUserData(CameraScreenTransition.animateOut) } let componentSize = self.componentHost.update( transition: transition, component: AnyComponent( CameraScreenComponent( context: self.context, camera: self.camera, present: { [weak self] c in self?.controller?.present(c, in: .window(.root)) }, push: { [weak self] c in self?.controller?.push(c) }, completion: controller.completion ) ), environment: { environment }, forceUpdate: forceUpdate || animateOut, containerSize: layout.size ) if let componentView = self.componentHost.view { if componentView.superview == nil { self.view.insertSubview(componentView, at: 1) componentView.clipsToBounds = true } let componentFrame = CGRect(origin: .zero, size: componentSize) transition.setFrame(view: componentView, frame: CGRect(origin: componentFrame.origin, size: CGSize(width: componentFrame.width, height: componentFrame.height))) if isFirstTime { self.animateIn() } } let previewSize = CGSize(width: layout.size.width, height: floorToScreenPixels(layout.size.width * 1.77778)) let previewFrame = CGRect(origin: CGPoint(x: 0.0, y: 60.0), size: previewSize) transition.setFrame(view: self.previewContainerView, frame: previewFrame) transition.setFrame(view: self.previewView, frame: CGRect(origin: .zero, size: previewFrame.size)) transition.setFrame(view: self.blurView, frame: CGRect(origin: .zero, size: previewFrame.size)) } } fileprivate var node: Node { return self.displayNode as! Node } private let context: AccountContext fileprivate let mode: Mode fileprivate let holder: CameraHolder? fileprivate let completion: (CameraScreen.Result) -> Void public init(context: AccountContext, mode: Mode, holder: CameraHolder? = nil, completion: @escaping (CameraScreen.Result) -> Void) { self.context = context self.mode = mode self.holder = holder self.completion = completion super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .White self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.navigationPresentation = .flatModal } required public init(coder: NSCoder) { preconditionFailure() } override public func loadDisplayNode() { self.displayNode = Node(controller: self) super.displayNodeDidLoad() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: Transition(transition)) } }