2023-04-25 18:38:09 +04:00

747 lines
30 KiB
Swift

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<CameraState>
fileprivate let componentHost: ComponentView<ViewControllerComponentContainer.Environment>
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<CameraState>()
self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
self.componentHost = ComponentView<ViewControllerComponentContainer.Environment>()
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))
}
}