import Foundation import UIKit import CoreServices import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import Postbox import TelegramCore import MultilineTextComponent import DrawingUI import MediaEditor import Photos import LottieAnimationComponent import MessageInputPanelComponent import TextFieldComponent import EntityKeyboard import TooltipUI import PlainButtonComponent import AvatarNode import ShareWithPeersScreen import PresentationDataUtils import ContextUI import BundleIconComponent import CameraButtonComponent import UndoUI import ChatEntityKeyboardInputNode import ChatPresentationInterfaceState import TextFormat import DeviceAccess import LocationUI import LegacyMediaPickerUI import ReactionSelectionNode import VolumeSliderContextItem import TelegramStringFormatting import ForwardInfoPanelComponent import ContextReferenceButtonComponent private let playbackButtonTag = GenericComponentViewTag() private let muteButtonTag = GenericComponentViewTag() private let saveButtonTag = GenericComponentViewTag() private let switchCameraButtonTag = GenericComponentViewTag() private let stickerButtonTag = GenericComponentViewTag() private let dayNightButtonTag = GenericComponentViewTag() final class MediaEditorScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment public final class ExternalState { public fileprivate(set) var derivedInputHeight: CGFloat = 0.0 public init() { } } enum DrawingScreenType { case drawing case text case sticker } let context: AccountContext let externalState: ExternalState let isDisplayingTool: Bool let isInteractingWithEntities: Bool let isSavingAvailable: Bool let hasAppeared: Bool let isDismissing: Bool let bottomSafeInset: CGFloat let mediaEditor: Signal let privacy: MediaEditorResultPrivacy let selectedEntity: DrawingEntity? let entityViewForEntity: (DrawingEntity) -> DrawingEntityView? let openDrawing: (DrawingScreenType) -> Void let openTools: () -> Void init( context: AccountContext, externalState: ExternalState, isDisplayingTool: Bool, isInteractingWithEntities: Bool, isSavingAvailable: Bool, hasAppeared: Bool, isDismissing: Bool, bottomSafeInset: CGFloat, mediaEditor: Signal, privacy: MediaEditorResultPrivacy, selectedEntity: DrawingEntity?, entityViewForEntity: @escaping (DrawingEntity) -> DrawingEntityView?, openDrawing: @escaping (DrawingScreenType) -> Void, openTools: @escaping () -> Void ) { self.context = context self.externalState = externalState self.isDisplayingTool = isDisplayingTool self.isInteractingWithEntities = isInteractingWithEntities self.isSavingAvailable = isSavingAvailable self.hasAppeared = hasAppeared self.isDismissing = isDismissing self.bottomSafeInset = bottomSafeInset self.mediaEditor = mediaEditor self.privacy = privacy self.selectedEntity = selectedEntity self.entityViewForEntity = entityViewForEntity self.openDrawing = openDrawing self.openTools = openTools } static func ==(lhs: MediaEditorScreenComponent, rhs: MediaEditorScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.isDisplayingTool != rhs.isDisplayingTool { return false } if lhs.isInteractingWithEntities != rhs.isInteractingWithEntities { return false } if lhs.isSavingAvailable != rhs.isSavingAvailable { return false } if lhs.hasAppeared != rhs.hasAppeared { return false } if lhs.isDismissing != rhs.isDismissing { return false } if lhs.bottomSafeInset != rhs.bottomSafeInset { return false } if lhs.privacy != rhs.privacy { return false } if lhs.selectedEntity?.uuid != rhs.selectedEntity?.uuid { return false } return true } final class State: ComponentState { enum ImageKey: Hashable { case draw case text case sticker case tools case done } 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 .draw: image = generateTintedImage(image: UIImage(bundleImageName: "Media Editor/Pencil"), color: .white)! case .text: image = generateTintedImage(image: UIImage(bundleImageName: "Media Editor/AddText"), color: .white)! case .sticker: image = generateTintedImage(image: UIImage(bundleImageName: "Media Editor/AddSticker"), color: .white)! case .tools: image = generateTintedImage(image: UIImage(bundleImageName: "Media Editor/Tools"), color: .white)! case .done: image = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(UIColor.white.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) context.setBlendMode(.copy) context.setStrokeColor(UIColor.black.cgColor) context.setLineWidth(2.0) context.setLineCap(.round) context.setLineJoin(.round) context.translateBy(x: 5.45, y: 4.0) context.saveGState() context.translateBy(x: 4.0, y: 4.0) let _ = try? drawSvgPath(context, path: "M1,7 L7,1 L13,7 S ") context.restoreGState() context.saveGState() context.translateBy(x: 10.0, y: 4.0) let _ = try? drawSvgPath(context, path: "M1,16 V1 S ") context.restoreGState() })! } cachedImages[key] = image return image } } let context: AccountContext var playerStateDisposable: Disposable? var playerState: MediaEditorPlayerState? var isPremium = false var isPremiumDisposable: Disposable? init(context: AccountContext, mediaEditor: Signal) { self.context = context super.init() self.playerStateDisposable = (mediaEditor |> mapToSignal { mediaEditor in if let mediaEditor { return mediaEditor.playerState(framesCount: 16) } else { return .complete() } } |> deliverOnMainQueue).start(next: { [weak self] playerState in if let self { self.playerState = playerState self.updated() } }) self.isPremiumDisposable = (context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in if let self { self.isPremium = peer?.isPremium ?? false self.updated() } }) } deinit { self.playerStateDisposable?.dispose() self.isPremiumDisposable?.dispose() } var muteDidChange = false var playbackDidChange = false var dayNightDidChange = false } func makeState() -> State { return State( context: self.context, mediaEditor: self.mediaEditor ) } public final class View: UIView { private let cancelButton = ComponentView() private let drawButton = ComponentView() private let textButton = ComponentView() private let stickerButton = ComponentView() private let toolsButton = ComponentView() private let doneButton = ComponentView() private let fadeView = UIButton() fileprivate let inputPanel = ComponentView() private let inputPanelExternalState = MessageInputPanelComponent.ExternalState() private let inputPanelBackground = ComponentView() private var scrubber: ComponentView? private let playbackButton = ComponentView() private let muteButton = ComponentView() private let saveButton = ComponentView() private let dayNightButton = ComponentView() private let switchCameraButton = ComponentView() private let textCancelButton = ComponentView() private let textDoneButton = ComponentView() private let textSize = ComponentView() private var isDismissed = false private var isEditingCaption = false private var currentInputMode: MessageInputPanelComponent.InputMode = .text private var didInitializeInputMediaNodeDataPromise = false private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? private var inputMediaNodeDataPromise = Promise() private var inputMediaNodeDataDisposable: Disposable? private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? private var inputMediaNode: ChatEntityKeyboardInputNode? private var videoRecorder: EntityVideoRecorder? private var component: MediaEditorScreenComponent? private weak var state: State? private var environment: ViewControllerComponentContainer.Environment? private var currentVisibleTracks: [MediaScrubberComponent.Track]? override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = .clear self.fadeView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.4) self.fadeView.addTarget(self, action: #selector(self.deactivateInput), for: .touchUpInside) self.fadeView.alpha = 0.0 self.addSubview(self.fadeView) self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() |> deliverOnMainQueue).start(next: { [weak self] value in guard let self else { return } self.inputMediaNodeData = value }) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.inputMediaNodeDataDisposable?.dispose() } private func setupIfNeeded() { guard let component = self.component else { return } if !self.didInitializeInputMediaNodeDataPromise { self.didInitializeInputMediaNodeDataPromise = true let context = component.context self.inputMediaNodeDataPromise.set( ChatEntityKeyboardInputNode.inputData( context: context, chatPeerId: nil, areCustomEmojiEnabled: true, hasSearch: true, hideBackground: true, sendGif: nil ) |> map { inputData -> ChatEntityKeyboardInputNode.InputData in return ChatEntityKeyboardInputNode.InputData( emoji: inputData.emoji, stickers: nil, gifs: nil, availableGifSearchEmojies: [] ) } ) self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { [weak self] text, attribute, bool1 in if let self { let _ = self } }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, updateChoosingSticker: { _ in }, switchToTextInput: { [weak self] in if let self { self.activateInput() } }, dismissTextInput: { }, insertText: { [weak self] text in if let self { self.inputPanelExternalState.insertText(text) } }, backwardsDeleteText: { [weak self] in if let self { self.inputPanelExternalState.deleteBackward() } }, presentController: { [weak self] c, a in if let self { self.environment?.controller()?.present(c, in: .window(.root), with: a) } }, presentGlobalOverlayController: { [weak self] c, a in if let self { self.environment?.controller()?.presentInGlobalOverlay(c, with: a) } }, getNavigationController: { [weak self] in if let self { return self.environment?.controller()?.navigationController as? NavigationController } else { return nil } }, requestLayout: { [weak self] transition in if let self { (self.environment?.controller() as? MediaEditorScreen)?.node.requestLayout(forceUpdate: true, transition: Transition(transition)) } } ) self.inputMediaInteraction?.forceTheme = defaultDarkColorPresentationTheme } } private func activateInput() { self.currentInputMode = .text if !hasFirstResponder(self) { if let view = self.inputPanel.view as? MessageInputPanelComponent.View { view.activateInput() } } else { self.state?.updated(transition: .immediate) } } private var nextTransitionUserData: Any? @objc private func deactivateInput() { guard let _ = self.inputPanel.view as? MessageInputPanelComponent.View else { return } self.currentInputMode = .text if hasFirstResponder(self) { if let view = self.inputPanel.view as? MessageInputPanelComponent.View { self.nextTransitionUserData = TextFieldComponent.AnimationHint(kind: .textFocusChanged) if view.isActive { view.deactivateInput(force: true) } else { self.endEditing(true) } } } else { self.state?.updated(transition: .spring(duration: 0.4).withUserData(TextFieldComponent.AnimationHint(kind: .textFocusChanged))) } } private var animatingButtons = false enum TransitionAnimationSource { case camera case gallery } func animateIn(from source: TransitionAnimationSource, completion: @escaping () -> Void = {}) { let buttons = [ self.drawButton, self.textButton, self.stickerButton, self.toolsButton ] if let view = self.cancelButton.view { view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } if let view = self.doneButton.view { view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } if case .camera = source { self.animatingButtons = true var delay: Double = 0.0 for button in buttons { if let view = button.view { view.alpha = 0.0 Queue.mainQueue().after(delay, { view.alpha = 1.0 view.layer.animatePosition(from: CGPoint(x: 0.0, y: 64.0), to: .zero, duration: 0.3, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.0) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2, delay: 0.0) }) delay += 0.03 } } Queue.mainQueue().after(0.45, { self.animatingButtons = false completion() }) if let view = self.saveButton.view { view.layer.animateAlpha(from: 0.0, to: view.alpha, duration: 0.2) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } if let view = self.muteButton.view { view.layer.animateAlpha(from: 0.0, to: view.alpha, duration: 0.2) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } if let view = self.playbackButton.view { view.layer.animateAlpha(from: 0.0, to: view.alpha, duration: 0.2) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } if let view = self.inputPanel.view { view.layer.animatePosition(from: CGPoint(x: 0.0, y: 44.0), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.6, to: 1.0, duration: 0.2) } if let view = self.scrubber?.view { view.layer.animatePosition(from: CGPoint(x: 0.0, y: 44.0), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) view.layer.animateScale(from: 0.6, to: 1.0, duration: 0.2) } } } func animateOut(to source: TransitionAnimationSource) { self.isDismissed = true let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) if let view = self.cancelButton.view { transition.setAlpha(view: view, alpha: 0.0) transition.setScale(view: view, scale: 0.1) } let buttons = [ self.drawButton, self.textButton, self.stickerButton, self.toolsButton ] for button in buttons { if let view = button.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 64.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) view.layer.animateAlpha(from: view.alpha, to: 0.0, duration: 0.15, removeOnCompletion: false) view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } } if let view = self.doneButton.view { transition.setAlpha(view: view, alpha: 0.0) transition.setScale(view: view, scale: 0.1) } if case .camera = source { if let view = self.inputPanel.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) view.layer.animateAlpha(from: view.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } if let view = self.scrubber?.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) view.layer.animateAlpha(from: view.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } } if let view = self.saveButton.view { transition.setAlpha(view: view, alpha: 0.0) transition.setScale(view: view, scale: 0.1) } if let view = self.muteButton.view { transition.setAlpha(view: view, alpha: 0.0) transition.setScale(view: view, scale: 0.1) } if let view = self.playbackButton.view { transition.setAlpha(view: view, alpha: 0.0) transition.setScale(view: view, scale: 0.1) } if let view = self.scrubber?.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) view.layer.animateAlpha(from: view.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } } func animateOutToTool(transition: Transition) { if let view = self.cancelButton.view { view.alpha = 0.0 } let buttons = [ self.drawButton, self.textButton, self.stickerButton, self.toolsButton ] for button in buttons { if let view = button.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } } if let view = self.doneButton.view { transition.setScale(view: view, scale: 0.1) } if let view = self.inputPanel.view { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } if let view = self.scrubber?.view { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) } } func animateInFromTool(transition: Transition) { if let view = self.cancelButton.view { view.alpha = 1.0 } if let buttonView = self.cancelButton.view as? Button.View, let view = buttonView.content as? LottieAnimationComponent.View { view.playOnce() } let buttons = [ self.drawButton, self.textButton, self.stickerButton, self.toolsButton ] for button in buttons { if let view = button.view { view.layer.animatePosition(from: CGPoint(x: 0.0, y: -44.0), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) } } if let view = self.doneButton.view { transition.setScale(view: view, scale: 1.0) } if let view = self.inputPanel.view { view.layer.animateScale(from: 0.0, to: 1.0, duration: 0.2) } if let view = self.scrubber?.view { view.layer.animateScale(from: 0.0, to: 1.0, duration: 0.2) } } func getInputText() -> NSAttributedString { guard let inputPanelView = self.inputPanel.view as? MessageInputPanelComponent.View else { return NSAttributedString() } var inputText = NSAttributedString() switch inputPanelView.getSendMessageInput() { case let .text(text): inputText = text } return inputText } func update(component: MediaEditorScreenComponent, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { guard !self.isDismissed else { return availableSize } let environment = environment[ViewControllerComponentContainer.Environment.self].value guard let controller = environment.controller() as? MediaEditorScreen else { return availableSize } self.environment = environment var transition = transition if let nextTransitionUserData = self.nextTransitionUserData { self.nextTransitionUserData = nil transition = transition.withUserData(nextTransitionUserData) } let isEditingStory = controller.isEditingStory if self.component == nil { if let initialCaption = controller.initialCaption { self.inputPanelExternalState.initialText = initialCaption } else if case let .draft(draft, _) = controller.node.actualSubject { self.inputPanelExternalState.initialText = draft.caption } } let isRecordingAdditionalVideo = controller.node.recording.isActive self.component = component self.state = state self.setupIfNeeded() let isTablet = environment.metrics.isTablet let openDrawing = component.openDrawing let openTools = component.openTools let buttonSideInset: CGFloat let buttonBottomInset: CGFloat = 8.0 var controlsBottomInset: CGFloat = 0.0 let previewSize: CGSize var topInset: CGFloat = environment.statusBarHeight + 5.0 if isTablet { let previewHeight = availableSize.height - topInset - 75.0 previewSize = CGSize(width: floorToScreenPixels(previewHeight / 1.77778), height: previewHeight) buttonSideInset = 30.0 } else { previewSize = CGSize(width: availableSize.width, height: floorToScreenPixels(availableSize.width * 1.77778)) buttonSideInset = 10.0 if availableSize.height < previewSize.height + 30.0 { topInset = 0.0 controlsBottomInset = -50.0 } } let topButtonsAlpha: CGFloat = isRecordingAdditionalVideo ? 0.3 : 1.0 let bottomButtonsAlpha: CGFloat = isRecordingAdditionalVideo ? 0.3 : 1.0 let buttonsAreHidden = component.isDisplayingTool || component.isDismissing || component.isInteractingWithEntities let cancelButtonSize = self.cancelButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: "media_backToCancel", mode: .still(position: .end), range: (0.5, 1.0) ), colors: ["__allcolors__": .white], size: CGSize(width: 33.0, height: 33.0) ) ), action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } controller.maybePresentDiscardAlert() } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let cancelButtonFrame = CGRect( origin: CGPoint(x: buttonSideInset, y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset), size: cancelButtonSize ) if let cancelButtonView = self.cancelButton.view { if cancelButtonView.superview == nil { self.addSubview(cancelButtonView) } transition.setPosition(view: cancelButtonView, position: cancelButtonFrame.center) transition.setBounds(view: cancelButtonView, bounds: CGRect(origin: .zero, size: cancelButtonFrame.size)) transition.setAlpha(view: cancelButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } let doneButtonTitle = isEditingStory ? environment.strings.Story_Editor_Done : environment.strings.Story_Editor_Next let doneButtonSize = self.doneButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(DoneButtonContentComponent( backgroundColor: UIColor(rgb: 0x007aff), icon: UIImage(bundleImageName: "Media Editor/Next")!, title: doneButtonTitle.uppercased())), effectAlignment: .center, action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } guard controller.checkCaptionLimit() else { return } if controller.isEditingStory { controller.requestCompletion(animated: true) } else { if controller.checkIfCompletionIsAllowed() { controller.openPrivacySettings(completion: { [weak controller] in controller?.requestCompletion(animated: true) }) } } } )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 44.0) ) let doneButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - buttonSideInset - doneButtonSize.width, y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset), size: doneButtonSize ) if let doneButtonView = self.doneButton.view { if doneButtonView.superview == nil { self.addSubview(doneButtonView) } transition.setPosition(view: doneButtonView, position: doneButtonFrame.center) transition.setBounds(view: doneButtonView, bounds: CGRect(origin: .zero, size: doneButtonFrame.size)) transition.setAlpha(view: doneButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } let buttonsAvailableWidth: CGFloat let buttonsLeftOffset: CGFloat if isTablet { buttonsAvailableWidth = previewSize.width + 180.0 buttonsLeftOffset = floorToScreenPixels((availableSize.width - buttonsAvailableWidth) / 2.0) } else { buttonsAvailableWidth = floor(availableSize.width - cancelButtonSize.width * 0.66 - (doneButtonSize.width - cancelButtonSize.width * 0.33) - buttonSideInset * 2.0) buttonsLeftOffset = floorToScreenPixels(buttonSideInset + cancelButtonSize.width * 0.66) } let drawButtonSize = self.drawButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Image( image: state.image(.draw), size: CGSize(width: 30.0, height: 30.0) )), action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } openDrawing(.drawing) } )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) let drawButtonFrame = CGRect( origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 - drawButtonSize.width / 2.0 - 3.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 1.0), size: drawButtonSize ) if let drawButtonView = self.drawButton.view { if drawButtonView.superview == nil { self.addSubview(drawButtonView) } transition.setPosition(view: drawButtonView, position: drawButtonFrame.center) transition.setBounds(view: drawButtonView, bounds: CGRect(origin: .zero, size: drawButtonFrame.size)) if !self.animatingButtons { transition.setAlpha(view: drawButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } } let textButtonSize = self.textButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Image( image: state.image(.text), size: CGSize(width: 30.0, height: 30.0) )), action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } openDrawing(.text) } )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) let textButtonFrame = CGRect( origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 2.0 - textButtonSize.width / 2.0 - 1.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0), size: textButtonSize ) if let textButtonView = self.textButton.view { if textButtonView.superview == nil { self.addSubview(textButtonView) } transition.setPosition(view: textButtonView, position: textButtonFrame.center) transition.setBounds(view: textButtonView, bounds: CGRect(origin: .zero, size: textButtonFrame.size)) if !self.animatingButtons { transition.setAlpha(view: textButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } } let stickerButtonSize = self.stickerButton.update( transition: transition, component: AnyComponent(ContextReferenceButtonComponent( content: AnyComponent(Image( image: state.image(.sticker), size: CGSize(width: 30.0, height: 30.0) )), tag: stickerButtonTag, minSize: CGSize(width: 30.0, height: 30.0), action: { [weak self] view, gesture in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } if let gesture { controller.presentEntityShortcuts(sourceView: view, gesture: gesture) } else { openDrawing(.sticker) } } )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) let stickerButtonFrame = CGRect( origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 3.0 - stickerButtonSize.width / 2.0 + 1.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0), size: stickerButtonSize ) if let stickerButtonView = self.stickerButton.view { if stickerButtonView.superview == nil { self.addSubview(stickerButtonView) } transition.setPosition(view: stickerButtonView, position: stickerButtonFrame.center) transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: .zero, size: stickerButtonFrame.size)) if !self.animatingButtons { transition.setAlpha(view: stickerButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } } let toolsButtonSize = self.toolsButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent(Image( image: state.image(.tools), size: CGSize(width: 30.0, height: 30.0) )), action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } openTools() } )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) let toolsButtonFrame = CGRect( origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 4.0 - toolsButtonSize.width / 2.0 + 3.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 1.0), size: toolsButtonSize ) if let toolsButtonView = self.toolsButton.view { if toolsButtonView.superview == nil { self.addSubview(toolsButtonView) } transition.setPosition(view: toolsButtonView, position: toolsButtonFrame.center) transition.setBounds(view: toolsButtonView, bounds: CGRect(origin: .zero, size: toolsButtonFrame.size)) if !self.animatingButtons { transition.setAlpha(view: toolsButtonView, alpha: buttonsAreHidden ? 0.0 : bottomButtonsAlpha) } } let mediaEditor = controller.node.mediaEditor var timeoutValue: String switch component.privacy.timeout { case 21600: timeoutValue = "6" case 43200: timeoutValue = "12" case 86400: timeoutValue = "24" case 172800: timeoutValue = "48" default: timeoutValue = "24" } var inputPanelAvailableWidth = previewSize.width var inputPanelAvailableHeight = 103.0 if case .regular = environment.metrics.widthClass { if (self.inputPanelExternalState.isEditing || self.inputPanelExternalState.hasText) { inputPanelAvailableWidth += 200.0 } } let keyboardWasHidden = self.inputPanelExternalState.isKeyboardHidden if environment.inputHeight > 0.0 || self.currentInputMode == .emoji || keyboardWasHidden { inputPanelAvailableHeight = 200.0 } var inputHeight = environment.inputHeight var keyboardHeight = environment.deviceMetrics.standardInputHeight(inLandscape: false) if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { let inputMediaNode: ChatEntityKeyboardInputNode if let current = self.inputMediaNode { inputMediaNode = current } else { inputMediaNode = ChatEntityKeyboardInputNode( context: component.context, currentInputData: inputData, updatedInputData: self.inputMediaNodeDataPromise.get(), defaultToEmojiTab: true, opaqueTopPanelBackground: false, interaction: self.inputMediaInteraction, chatPeerId: nil, stateContext: self.inputMediaNodeStateContext ) inputMediaNode.externalTopPanelContainerImpl = nil inputMediaNode.useExternalSearchContainer = true if let inputPanelView = self.inputPanel.view, inputMediaNode.view.superview == nil { self.insertSubview(inputMediaNode.view, belowSubview: inputPanelView) } self.inputMediaNode = inputMediaNode } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkPresentationTheme) let presentationInterfaceState = ChatPresentationInterfaceState( chatWallpaper: .builtin(WallpaperSettings()), theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, fontSize: presentationData.chatFontSize, bubbleCorners: presentationData.chatBubbleCorners, accountPeerId: component.context.account.peerId, mode: .standard(.default), chatLocation: .peer(id: component.context.account.peerId), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil, threadData: nil, isGeneralThreadClosed: nil, replyMessage: nil, accountPeerColor: nil ) let availableInputMediaWidth = previewSize.width let heightAndOverflow = inputMediaNode.updateLayout(width: availableInputMediaWidth, leftInset: 0.0, rightInset: 0.0, bottomInset: component.bottomSafeInset, standardInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: environment.inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: environment.metrics, deviceMetrics: environment.deviceMetrics, isVisible: true, isExpanded: false) let inputNodeHeight = heightAndOverflow.0 let inputNodeFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - availableInputMediaWidth) / 2.0), y: availableSize.height - inputNodeHeight), size: CGSize(width: availableInputMediaWidth, height: inputNodeHeight)) transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) if inputNodeHeight > 0.0 { inputHeight = inputNodeHeight } } else if let inputMediaNode = self.inputMediaNode { self.inputMediaNode = nil var dismissingInputHeight = environment.inputHeight if self.currentInputMode == .emoji || (dismissingInputHeight.isZero && keyboardWasHidden) { dismissingInputHeight = max(inputHeight, environment.deviceMetrics.standardInputHeight(inLandscape: false)) } if let animationHint = transition.userData(TextFieldComponent.AnimationHint.self), case .textFocusChanged = animationHint.kind { dismissingInputHeight = 0.0 } var targetFrame = inputMediaNode.frame if dismissingInputHeight > 0.0 { targetFrame.origin.y = availableSize.height - dismissingInputHeight } else { targetFrame.origin.y = availableSize.height } transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in if let inputMediaNode { Queue.mainQueue().after(0.2) { inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in inputMediaNode?.view.removeFromSuperview() }) } } }) } var header: AnyComponent? if let (forwardAuthor, forwardStory) = controller.forwardSource, !forwardStory.text.isEmpty { let authorName = forwardAuthor.displayTitle(strings: environment.strings, displayOrder: .firstLast) header = AnyComponent( ForwardInfoPanelComponent( authorName: authorName, text: forwardStory.text, isChannel: forwardAuthor.id.isGroupOrChannel, isVibrant: true, fillsWidth: true ) ) } let nextInputMode: MessageInputPanelComponent.InputMode switch self.currentInputMode { case .text: nextInputMode = .emoji case .emoji: nextInputMode = .text default: nextInputMode = .emoji } var canRecordVideo = true if let subject = controller.node.subject { if case let .video(_, _, _, additionalPath, _, _, _, _, _) = subject, additionalPath != nil { canRecordVideo = false } } self.inputPanel.parentState = state let inputPanelSize = self.inputPanel.update( transition: transition, component: AnyComponent(MessageInputPanelComponent( externalState: self.inputPanelExternalState, context: component.context, theme: environment.theme, strings: environment.strings, style: .editor, placeholder: .plain(environment.strings.Story_Editor_InputPlaceholderAddCaption), maxLength: Int(component.context.userLimits.maxStoryCaptionLength), queryTypes: [.mention], alwaysDarkWhenHasText: false, resetInputContents: nil, nextInputMode: { _ in return nextInputMode }, areVoiceMessagesAvailable: false, presentController: { [weak controller] c in guard let controller else { return } controller.present(c, in: .window(.root)) }, presentInGlobalOverlay: { [weak controller] c in guard let controller else { return } controller.presentInGlobalOverlay(c) }, sendMessageAction: { [weak self] in guard let self else { return } self.deactivateInput() }, sendMessageOptionsAction: nil, sendStickerAction: { _ in }, setMediaRecordingActive: canRecordVideo ? { [weak controller] isActive, _, finished, sourceView in guard let controller else { return } controller.node.recording.setMediaRecordingActive(isActive, finished: finished, sourceView: sourceView) } : nil, lockMediaRecording: { [weak controller, weak self] in guard let controller, let self else { return } controller.node.recording.isLocked = true self.state?.updated(transition: .easeInOut(duration: 0.2)) }, stopAndPreviewMediaRecording: { [weak controller] in guard let controller else { return } controller.node.recording.setMediaRecordingActive(false, finished: true, sourceView: nil) }, discardMediaRecordingPreview: nil, attachmentAction: nil, myReaction: nil, likeAction: nil, likeOptionsAction: nil, inputModeAction: { [weak self] in if let self { switch self.currentInputMode { case .text: self.currentInputMode = .emoji case .emoji: self.currentInputMode = .text default: self.currentInputMode = .emoji } if self.currentInputMode == .text { self.activateInput() } else { self.state?.updated(transition: .immediate) } } }, timeoutAction: isEditingStory ? nil : { [weak controller] view, gesture in guard let controller else { return } controller.presentTimeoutSetup(sourceView: view, gesture: gesture) }, forwardAction: nil, moreAction: nil, presentVoiceMessagesUnavailableTooltip: nil, presentTextLengthLimitTooltip: { [weak controller] in guard let controller else { return } controller.presentCaptionLimitPremiumSuggestion(isPremium: controller.context.isPremium) }, presentTextFormattingTooltip: { [weak controller] in guard let controller else { return } controller.presentCaptionEntitiesPremiumSuggestion() }, paste: { [weak self, weak controller] data in guard let self, let controller else { return } switch data { case let .sticker(image, _): if max(image.size.width, image.size.height) > 1.0 { let entity = DrawingStickerEntity(content: .image(image, .sticker)) controller.node.interaction?.insertEntity(entity, scale: 1.0) self.deactivateInput() } case let .images(images): if images.count == 1, let image = images.first, max(image.size.width, image.size.height) > 1.0 { let entity = DrawingStickerEntity(content: .image(image, .rectangle)) controller.node.interaction?.insertEntity(entity, scale: 2.5) self.deactivateInput() } case .text: Queue.mainQueue().after(0.1) { let text = self.getInputText() if text.length > component.context.userLimits.maxStoryCaptionLength { controller.presentCaptionLimitPremiumSuggestion(isPremium: self.state?.isPremium ?? false) } } default: break } }, audioRecorder: nil, videoRecordingStatus: controller.node.recording.status, isRecordingLocked: controller.node.recording.isLocked, hasRecordedVideo: mediaEditor?.values.additionalVideoPath != nil, recordedAudioPreview: nil, hasRecordedVideoPreview: false, wasRecordingDismissed: false, timeoutValue: timeoutValue, timeoutSelected: false, displayGradient: false, bottomInset: 0.0, isFormattingLocked: !state.isPremium, hideKeyboard: self.currentInputMode == .emoji, customInputView: nil, forceIsEditing: self.currentInputMode == .emoji, disabledPlaceholder: nil, header: header, isChannel: false, storyItem: nil, chatLocation: nil )), environment: {}, containerSize: CGSize(width: inputPanelAvailableWidth, height: inputPanelAvailableHeight) ) if self.inputPanelExternalState.isEditing && controller.node.entitiesView.hasSelection { Queue.mainQueue().justDispatch { controller.node.entitiesView.selectEntity(nil) } } if self.inputPanelExternalState.isEditing { if self.currentInputMode == .emoji || (inputHeight.isZero && keyboardWasHidden) { inputHeight = max(inputHeight, environment.deviceMetrics.standardInputHeight(inLandscape: false)) } } keyboardHeight = inputHeight let fadeTransition = Transition(animation: .curve(duration: 0.3, curve: .easeInOut)) if self.inputPanelExternalState.isEditing { fadeTransition.setAlpha(view: self.fadeView, alpha: 1.0) } else { fadeTransition.setAlpha(view: self.fadeView, alpha: 0.0) } transition.setFrame(view: self.fadeView, frame: CGRect(origin: .zero, size: availableSize)) let isEditingCaption = self.inputPanelExternalState.isEditing if self.isEditingCaption != isEditingCaption { self.isEditingCaption = isEditingCaption if isEditingCaption { controller.dismissAllTooltips() mediaEditor?.maybePauseVideo() } else { mediaEditor?.maybeUnpauseVideo() } } let inputPanelBackgroundSize = self.inputPanelBackground.update( transition: transition, component: AnyComponent(BlurredGradientComponent(position: .bottom, tag: nil)), environment: {}, containerSize: CGSize(width: availableSize.width, height: keyboardHeight + 60.0) ) if let inputPanelBackgroundView = self.inputPanelBackground.view { if inputPanelBackgroundView.superview == nil { self.addSubview(inputPanelBackgroundView) } let isVisible = isEditingCaption && inputHeight > 44.0 transition.setFrame(view: inputPanelBackgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: isVisible ? availableSize.height - inputPanelBackgroundSize.height : availableSize.height), size: inputPanelBackgroundSize)) if !self.animatingButtons { transition.setAlpha(view: inputPanelBackgroundView, alpha: isVisible ? 1.0 : 0.0, delay: isVisible ? 0.0 : 0.4) } } var isEditingTextEntity = false var sizeSliderVisible = false var sizeValue: CGFloat? if let textEntity = component.selectedEntity as? DrawingTextEntity, let entityView = component.entityViewForEntity(textEntity) as? DrawingTextEntityView { sizeSliderVisible = true isEditingTextEntity = entityView.isEditing sizeValue = textEntity.fontSize } var inputPanelBottomInset: CGFloat = -controlsBottomInset if inputHeight > 0.0 { inputPanelBottomInset = inputHeight - environment.safeInsets.bottom } let inputPanelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - inputPanelSize.width) / 2.0), y: availableSize.height - environment.safeInsets.bottom - inputPanelBottomInset - inputPanelSize.height - 3.0), size: inputPanelSize) if let inputPanelView = self.inputPanel.view { if inputPanelView.superview == nil { self.addSubview(inputPanelView) } transition.setFrame(view: inputPanelView, frame: inputPanelFrame) transition.setAlpha(view: inputPanelView, alpha: isEditingTextEntity || component.isDisplayingTool || component.isDismissing || component.isInteractingWithEntities ? 0.0 : 1.0) } if let playerState = state.playerState { let scrubberInset: CGFloat = 9.0 let minDuration: Double let maxDuration: Double if playerState.isAudioOnly { minDuration = 5.0 maxDuration = 15.0 } else { minDuration = 1.0 maxDuration = storyMaxVideoDuration } let previousTrackCount = self.currentVisibleTracks?.count let visibleTracks = playerState.tracks.filter { $0.visibleInTimeline }.map { MediaScrubberComponent.Track($0) } self.currentVisibleTracks = visibleTracks var scrubberTransition = transition if let previousTrackCount, previousTrackCount != visibleTracks.count { scrubberTransition = .easeInOut(duration: 0.2) } let isAudioOnly = playerState.isAudioOnly let hasMainVideoTrack = playerState.tracks.contains(where: { $0.id == 0 }) let scrubber: ComponentView if let current = self.scrubber { scrubber = current } else { scrubber = ComponentView() self.scrubber = scrubber } let scrubberSize = scrubber.update( transition: scrubberTransition, component: AnyComponent(MediaScrubberComponent( context: component.context, generationTimestamp: playerState.generationTimestamp, position: playerState.position, minDuration: minDuration, maxDuration: maxDuration, isPlaying: playerState.isPlaying, tracks: visibleTracks, positionUpdated: { [weak mediaEditor] position, apply in if let mediaEditor { mediaEditor.seek(position, andPlay: apply) } }, trackTrimUpdated: { [weak mediaEditor] trackId, start, end, updatedEnd, apply in guard let mediaEditor else { return } let trimRange = start..= upperBound { start = lowerBound } else if start < lowerBound { start = lowerBound } } mediaEditor.seek(start, andPlay: true) mediaEditor.play() } else { mediaEditor.stop() } } } else if trackId == 1 { mediaEditor.setAdditionalVideoOffset(offset, apply: apply) } }, trackLongPressed: { [weak self] trackId, sourceView in guard let self, let controller = self.environment?.controller() as? MediaEditorScreen else { return } controller.node.presentTrackOptions(trackId: trackId, sourceView: sourceView) } )), environment: {}, containerSize: CGSize(width: previewSize.width - scrubberInset * 2.0, height: availableSize.height) ) let scrubberFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - scrubberSize.width) / 2.0), y: availableSize.height - environment.safeInsets.bottom - scrubberSize.height + controlsBottomInset - inputPanelSize.height + 3.0), size: scrubberSize) if let scrubberView = scrubber.view { var animateIn = false if scrubberView.superview == nil { animateIn = true if let inputPanelBackgroundView = self.inputPanelBackground.view, inputPanelBackgroundView.superview != nil { self.insertSubview(scrubberView, belowSubview: inputPanelBackgroundView) } else { self.addSubview(scrubberView) } } if animateIn { scrubberView.frame = scrubberFrame } else { scrubberTransition.setFrame(view: scrubberView, frame: scrubberFrame) } if !self.animatingButtons && !(!hasMainVideoTrack && animateIn) { transition.setAlpha(view: scrubberView, alpha: component.isDisplayingTool || component.isDismissing || component.isInteractingWithEntities || isEditingCaption || isRecordingAdditionalVideo || isEditingTextEntity ? 0.0 : 1.0) } else if animateIn { scrubberView.layer.animatePosition(from: CGPoint(x: 0.0, y: 44.0), to: .zero, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) scrubberView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) scrubberView.layer.animateScale(from: 0.6, to: 1.0, duration: 0.2) } } } else { if let scrubber = self.scrubber { self.scrubber = nil if let scrubberView = scrubber.view { scrubberView.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) scrubberView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in scrubberView.removeFromSuperview() }) scrubberView.layer.animateScale(from: 1.0, to: 0.6, duration: 0.2, removeOnCompletion: false) } } } let displayTopButtons = !(self.inputPanelExternalState.isEditing || isEditingTextEntity || component.isDisplayingTool) let saveContentComponent: AnyComponentWithIdentity if component.hasAppeared { saveContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: "anim_storysave", mode: .still(position: .begin), range: nil ), colors: ["__allcolors__": .white], size: CGSize(width: 30.0, height: 30.0) ).tagged(saveButtonTag) ) ) } else { saveContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: "Media Editor/SaveIcon", tintColor: nil ) ) ) } let saveButtonSize = self.saveButton.update( transition: transition, component: AnyComponent(CameraButton( content: saveContentComponent, action: { [weak self] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } if let view = self?.saveButton.findTaggedView(tag: saveButtonTag) as? LottieAnimationComponent.View { view.playOnce() } controller.requestSave() } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let saveButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - 20.0 - saveButtonSize.width, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: saveButtonSize ) if let saveButtonView = self.saveButton.view { if saveButtonView.superview == nil { setupButtonShadow(saveButtonView) self.addSubview(saveButtonView) } let saveButtonAlpha = component.isSavingAvailable ? topButtonsAlpha : 0.3 saveButtonView.isUserInteractionEnabled = component.isSavingAvailable transition.setPosition(view: saveButtonView, position: saveButtonFrame.center) transition.setBounds(view: saveButtonView, bounds: CGRect(origin: .zero, size: saveButtonFrame.size)) transition.setScale(view: saveButtonView, scale: displayTopButtons ? 1.0 : 0.01) transition.setAlpha(view: saveButtonView, alpha: displayTopButtons && !component.isDismissing && !component.isInteractingWithEntities ? saveButtonAlpha : 0.0) } var topButtonOffsetX: CGFloat = 0.0 if let subject = controller.node.subject, case .message = subject { let isNightTheme = mediaEditor?.values.nightTheme == true let dayNightContentComponent: AnyComponentWithIdentity if component.hasAppeared { dayNightContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: isNightTheme ? "anim_sun" : "anim_sun_reverse", mode: state.dayNightDidChange ? .animating(loop: false) : .still(position: .end) ), colors: ["__allcolors__": .white], size: CGSize(width: 30.0, height: 30.0) ).tagged(dayNightButtonTag) ) ) } else { dayNightContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: "Media Editor/MuteIcon", tintColor: nil ) ) ) } let dayNightButtonSize = self.dayNightButton.update( transition: transition, component: AnyComponent(CameraButton( content: dayNightContentComponent, action: { [weak self, weak state, weak mediaEditor] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } if let mediaEditor { state?.dayNightDidChange = true if let snapshotView = controller.node.previewContainerView.snapshotView(afterScreenUpdates: false) { controller.node.previewContainerView.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, delay: 0.1, removeOnCompletion: false, completion: { _ in snapshotView.removeFromSuperview() }) } Queue.mainQueue().after(0.1) { mediaEditor.toggleNightTheme() controller.node.entitiesView.eachView { view in if let stickerEntityView = view as? DrawingStickerEntityView { stickerEntityView.isNightTheme = mediaEditor.values.nightTheme } } } } } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let dayNightButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - 20.0 - dayNightButtonSize.width - 50.0, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: dayNightButtonSize ) if let dayNightButtonView = self.dayNightButton.view { if dayNightButtonView.superview == nil { setupButtonShadow(dayNightButtonView) self.addSubview(dayNightButtonView) dayNightButtonView.layer.animateAlpha(from: 0.0, to: dayNightButtonView.alpha, duration: self.animatingButtons ? 0.1 : 0.2) dayNightButtonView.layer.animateScale(from: 0.4, to: 1.0, duration: self.animatingButtons ? 0.1 : 0.2) } transition.setPosition(view: dayNightButtonView, position: dayNightButtonFrame.center) transition.setBounds(view: dayNightButtonView, bounds: CGRect(origin: .zero, size: dayNightButtonFrame.size)) transition.setScale(view: dayNightButtonView, scale: displayTopButtons ? 1.0 : 0.01) transition.setAlpha(view: dayNightButtonView, alpha: displayTopButtons && !component.isDismissing && !component.isInteractingWithEntities ? topButtonsAlpha : 0.0) } topButtonOffsetX += 50.0 } else { if let dayNightButtonView = self.dayNightButton.view, dayNightButtonView.superview != nil { dayNightButtonView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak dayNightButtonView] _ in dayNightButtonView?.removeFromSuperview() }) dayNightButtonView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) } } if let playerState = state.playerState, playerState.hasAudio { let isVideoMuted = mediaEditor?.values.videoIsMuted ?? false let muteContentComponent: AnyComponentWithIdentity if component.hasAppeared { muteContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: "anim_storymute", mode: state.muteDidChange ? .animating(loop: false) : .still(position: .begin), range: isVideoMuted ? (0.0, 0.5) : (0.5, 1.0) ), colors: ["__allcolors__": .white], size: CGSize(width: 30.0, height: 30.0) ).tagged(muteButtonTag) ) ) } else { muteContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: "Media Editor/MuteIcon", tintColor: nil ) ) ) } let muteButtonSize = self.muteButton.update( transition: transition, component: AnyComponent(CameraButton( content: muteContentComponent, action: { [weak self, weak state, weak mediaEditor] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } if let mediaEditor { state?.muteDidChange = true let isMuted = !mediaEditor.values.videoIsMuted mediaEditor.setVideoIsMuted(isMuted) state?.updated() controller.node.presentMutedTooltip() } } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let muteButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - 20.0 - muteButtonSize.width - 50.0 - topButtonOffsetX, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: muteButtonSize ) if let muteButtonView = self.muteButton.view { if muteButtonView.superview == nil { setupButtonShadow(muteButtonView) self.addSubview(muteButtonView) muteButtonView.layer.animateAlpha(from: 0.0, to: muteButtonView.alpha, duration: self.animatingButtons ? 0.1 : 0.2) muteButtonView.layer.animateScale(from: 0.4, to: 1.0, duration: self.animatingButtons ? 0.1 : 0.2) } transition.setPosition(view: muteButtonView, position: muteButtonFrame.center) transition.setBounds(view: muteButtonView, bounds: CGRect(origin: .zero, size: muteButtonFrame.size)) transition.setScale(view: muteButtonView, scale: displayTopButtons ? 1.0 : 0.01) transition.setAlpha(view: muteButtonView, alpha: displayTopButtons && !component.isDismissing && !component.isInteractingWithEntities ? topButtonsAlpha : 0.0) } topButtonOffsetX += 50.0 } else { if let muteButtonView = self.muteButton.view, muteButtonView.superview != nil { muteButtonView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak muteButtonView] _ in muteButtonView?.removeFromSuperview() }) muteButtonView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) } } if let playerState = state.playerState { let playbackContentComponent: AnyComponentWithIdentity if component.hasAppeared { playbackContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: "anim_storyplayback", mode: state.playbackDidChange ? .animating(loop: false) : .still(position: .end), range: playerState.isPlaying ? (0.5, 1.0) : (0.0, 0.5) ), colors: ["__allcolors__": .white], size: CGSize(width: 30.0, height: 30.0) ).tagged(playbackButtonTag) ) ) } else { playbackContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: playerState.isPlaying ? "Media Editor/Pause" : "Media Editor/Play", tintColor: nil ) ) ) } let playbackButtonSize = self.playbackButton.update( transition: transition, component: AnyComponent(CameraButton( content: playbackContentComponent, action: { [weak self, weak mediaEditor, weak state] in guard let environment = self?.environment, let controller = environment.controller() as? MediaEditorScreen else { return } guard !controller.node.recording.isActive else { return } if let mediaEditor { state?.playbackDidChange = true mediaEditor.togglePlayback() } } )), environment: {}, containerSize: CGSize(width: 44.0, height: 44.0) ) let playbackButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - 20.0 - playbackButtonSize.width - 50.0 - topButtonOffsetX, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: playbackButtonSize ) if let playbackButtonView = self.playbackButton.view { if playbackButtonView.superview == nil { setupButtonShadow(playbackButtonView) self.addSubview(playbackButtonView) playbackButtonView.layer.animateAlpha(from: 0.0, to: playbackButtonView.alpha, duration: self.animatingButtons ? 0.1 : 0.2) playbackButtonView.layer.animateScale(from: 0.4, to: 1.0, duration: self.animatingButtons ? 0.1 : 0.2) } transition.setPosition(view: playbackButtonView, position: playbackButtonFrame.center) transition.setBounds(view: playbackButtonView, bounds: CGRect(origin: .zero, size: playbackButtonFrame.size)) transition.setScale(view: playbackButtonView, scale: displayTopButtons ? 1.0 : 0.01) transition.setAlpha(view: playbackButtonView, alpha: displayTopButtons && !component.isDismissing && !component.isInteractingWithEntities ? topButtonsAlpha : 0.0) } topButtonOffsetX += 50.0 } else { if let playbackButtonView = self.playbackButton.view, playbackButtonView.superview != nil { playbackButtonView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak playbackButtonView] _ in playbackButtonView?.removeFromSuperview() }) playbackButtonView.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) } } let switchCameraButtonSize = self.switchCameraButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent( FlipButtonContentComponent(tag: switchCameraButtonTag) ), action: { [weak self] in if let self, let environment = self.environment, let controller = environment.controller() as? MediaEditorScreen { controller.node.recording.togglePosition() if let view = self.switchCameraButton.findTaggedView(tag: switchCameraButtonTag) as? FlipButtonContentComponent.View { view.playAnimation() } } } ).withIsExclusive(false)), environment: {}, containerSize: CGSize(width: 48.0, height: 48.0) ) let switchCameraButtonFrame = CGRect( origin: CGPoint(x: 12.0, y: max(environment.statusBarHeight + 10.0, inputPanelFrame.minY - switchCameraButtonSize.height - 3.0)), size: switchCameraButtonSize ) if let switchCameraButtonView = self.switchCameraButton.view { if switchCameraButtonView.superview == nil { self.addSubview(switchCameraButtonView) } transition.setPosition(view: switchCameraButtonView, position: switchCameraButtonFrame.center) transition.setBounds(view: switchCameraButtonView, bounds: CGRect(origin: .zero, size: switchCameraButtonFrame.size)) transition.setScale(view: switchCameraButtonView, scale: isRecordingAdditionalVideo ? 1.0 : 0.01) transition.setAlpha(view: switchCameraButtonView, alpha: isRecordingAdditionalVideo ? 1.0 : 0.0) } let textCancelButtonSize = self.textCancelButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent( Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: .white) ), action: { [weak self] in if let self, let environment = self.environment, let controller = environment.controller() as? MediaEditorScreen { controller.node.interaction?.endTextEditing(reset: true) } } )), environment: {}, containerSize: CGSize(width: 100.0, height: 30.0) ) let textCancelButtonFrame = CGRect( origin: CGPoint(x: 13.0, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: textCancelButtonSize ) if let textCancelButtonView = self.textCancelButton.view { if textCancelButtonView.superview == nil { self.addSubview(textCancelButtonView) } transition.setPosition(view: textCancelButtonView, position: textCancelButtonFrame.center) transition.setBounds(view: textCancelButtonView, bounds: CGRect(origin: .zero, size: textCancelButtonFrame.size)) transition.setScale(view: textCancelButtonView, scale: isEditingTextEntity ? 1.0 : 0.01) transition.setAlpha(view: textCancelButtonView, alpha: isEditingTextEntity ? 1.0 : 0.0) } let textDoneButtonSize = self.textDoneButton.update( transition: transition, component: AnyComponent(Button( content: AnyComponent( Text(text: environment.strings.Common_Done, font: Font.regular(17.0), color: .white) ), action: { [weak self] in if let self, let environment = self.environment, let controller = environment.controller() as? MediaEditorScreen { controller.node.interaction?.endTextEditing(reset: false) } } )), environment: {}, containerSize: CGSize(width: 100.0, height: 30.0) ) let textDoneButtonFrame = CGRect( origin: CGPoint(x: availableSize.width - textDoneButtonSize.width - 13.0, y: max(environment.statusBarHeight + 10.0, environment.safeInsets.top + 20.0)), size: textDoneButtonSize ) if let textDoneButtonView = self.textDoneButton.view { if textDoneButtonView.superview == nil { self.addSubview(textDoneButtonView) } transition.setPosition(view: textDoneButtonView, position: textDoneButtonFrame.center) transition.setBounds(view: textDoneButtonView, bounds: CGRect(origin: .zero, size: textDoneButtonFrame.size)) transition.setScale(view: textDoneButtonView, scale: isEditingTextEntity ? 1.0 : 0.01) transition.setAlpha(view: textDoneButtonView, alpha: isEditingTextEntity ? 1.0 : 0.0) } let textSizeSize = self.textSize.update( transition: transition, component: AnyComponent(TextSizeSliderComponent( value: sizeValue ?? 0.5, tag: nil, updated: { [weak self] size in if let self, let environment = self.environment, let controller = environment.controller() as? MediaEditorScreen { controller.node.interaction?.updateEntitySize(size) self.state?.updated() } }, released: { } )), environment: {}, containerSize: CGSize(width: 30.0, height: 240.0) ) let textSizeTopInset = max(environment.safeInsets.top, environment.statusBarHeight) let bottomInset: CGFloat = inputHeight > 0.0 ? inputHeight : environment.safeInsets.bottom let textSizeFrame = CGRect( origin: CGPoint(x: 0.0, y: textSizeTopInset + (availableSize.height - textSizeTopInset - bottomInset) / 2.0 - textSizeSize.height / 2.0), size: textSizeSize ) if let textSizeView = self.textSize.view { if textSizeView.superview == nil { self.addSubview(textSizeView) } transition.setPosition(view: textSizeView, position: textSizeFrame.center) transition.setBounds(view: textSizeView, bounds: CGRect(origin: .zero, size: textSizeFrame.size)) transition.setAlpha(view: textSizeView, alpha: sizeSliderVisible && !component.isInteractingWithEntities ? 1.0 : 0.0) } component.externalState.derivedInputHeight = inputHeight return availableSize } } func makeView() -> View { return View() } 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) } } let storyDimensions = CGSize(width: 1080.0, height: 1920.0) let storyMaxVideoDuration: Double = 60.0 public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate { public enum TransitionIn { public final class GalleryTransitionIn { public weak var sourceView: UIView? public let sourceRect: CGRect public let sourceImage: UIImage? public init( sourceView: UIView, sourceRect: CGRect, sourceImage: UIImage? ) { self.sourceView = sourceView self.sourceRect = sourceRect self.sourceImage = sourceImage } } case camera case gallery(GalleryTransitionIn) } 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 } } struct State { var privacy: MediaEditorResultPrivacy = MediaEditorResultPrivacy( sendAsPeerId: nil, privacy: EngineStoryPrivacy(base: .everyone, additionallyIncludePeers: []), timeout: 86400, isForwardingDisabled: false, pin: true ) } var state = State() { didSet { if self.isNodeLoaded { self.node.requestUpdate() } } } final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { private weak var controller: MediaEditorScreen? private let context: AccountContext fileprivate var interaction: DrawingToolsInteraction? private let initializationTimestamp = CACurrentMediaTime() var subject: MediaEditorScreen.Subject? var actualSubject: MediaEditorScreen.Subject? private var subjectDisposable: Disposable? private var appInForegroundDisposable: Disposable? private let backgroundDimView: UIView fileprivate let containerView: UIView fileprivate let componentExternalState = MediaEditorScreenComponent.ExternalState() fileprivate let componentHost: ComponentView fileprivate let storyPreview: ComponentView fileprivate let toolValue: ComponentView fileprivate let previewContainerView: UIView private var transitionInView: UIImageView? private let gradientView: UIImageView private var gradientColorsDisposable: Disposable? fileprivate let entitiesContainerView: UIView let entitiesView: DrawingEntitiesView fileprivate let selectionContainerView: DrawingSelectionContainerView fileprivate let drawingView: DrawingView fileprivate let previewView: MediaEditorPreviewView var mediaEditor: MediaEditor? fileprivate var mediaEditorPromise = Promise() let ciContext = CIContext(options: [.workingColorSpace : NSNull()]) private let stickerPickerInputData = Promise() private var availableReactions: [ReactionItem] = [] private var availableReactionsDisposable: Disposable? private var panGestureRecognizer: UIPanGestureRecognizer? private var dismissPanGestureRecognizer: UIPanGestureRecognizer? private var isDisplayingTool = false private var isInteractingWithEntities = false private var isEnhancing = false private var hasAppeared = false private var isDismissing = false private var dismissOffset: CGFloat = 0.0 private var isDismissed = false private var isDismissBySwipeSuppressed = false private (set) var hasAnyChanges = false private var playbackPositionDisposable: Disposable? var recording: MediaEditorScreen.Recording private var presentationData: PresentationData private var validLayout: ContainerViewLayout? private let readyValue = Promise() init(controller: MediaEditorScreen) { self.controller = controller self.context = controller.context self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.backgroundDimView = UIView() self.backgroundDimView.isHidden = true self.backgroundDimView.backgroundColor = .black self.containerView = UIView() self.containerView.clipsToBounds = true self.componentHost = ComponentView() self.storyPreview = ComponentView() self.toolValue = ComponentView() self.previewContainerView = UIView() self.previewContainerView.alpha = 0.0 self.previewContainerView.clipsToBounds = true self.previewContainerView.layer.cornerRadius = 12.0 if #available(iOS 13.0, *) { self.previewContainerView.layer.cornerCurve = .continuous } self.gradientView = UIImageView() self.entitiesContainerView = UIView(frame: CGRect(origin: .zero, size: storyDimensions)) self.entitiesView = DrawingEntitiesView(context: controller.context, size: storyDimensions, hasBin: true) self.entitiesView.getEntityCenterPosition = { return CGPoint(x: storyDimensions.width / 2.0, y: storyDimensions.height / 2.0) } self.entitiesView.getEntityEdgePositions = { return UIEdgeInsets(top: 160.0, left: 36.0, bottom: storyDimensions.height - 160.0, right: storyDimensions.width - 36.0) } self.previewView = MediaEditorPreviewView(frame: .zero) self.drawingView = DrawingView(size: storyDimensions) self.drawingView.isUserInteractionEnabled = false self.selectionContainerView = DrawingSelectionContainerView(frame: .zero) self.entitiesView.selectionContainerView = self.selectionContainerView self.recording = MediaEditorScreen.Recording(controller: controller) super.init() self.backgroundColor = .clear self.view.addSubview(self.backgroundDimView) self.view.addSubview(self.containerView) self.containerView.addSubview(self.previewContainerView) self.previewContainerView.addSubview(self.gradientView) self.previewContainerView.addSubview(self.previewView) self.previewContainerView.addSubview(self.entitiesContainerView) self.entitiesContainerView.addSubview(self.entitiesView) self.entitiesView.addSubview(self.drawingView) self.previewContainerView.addSubview(self.selectionContainerView) self.subjectDisposable = ( controller.subject |> filter { $0 != nil } |> take(1) |> deliverOnMainQueue ).start(next: { [weak self] subject in if let self, let subject { self.setup(with: subject) } }) let stickerPickerInputData = self.stickerPickerInputData Queue.concurrentDefaultQueue().after(0.5, { let emojiItems = EmojiPagerContentComponent.emojiInputData( context: controller.context, animationCache: controller.context.animationCache, animationRenderer: controller.context.animationRenderer, isStandalone: false, subject: .emoji, hasTrending: true, topReactionItems: [], areUnicodeEmojiEnabled: true, areCustomEmojiEnabled: true, chatPeerId: controller.context.account.peerId, hasSearch: true, forceHasPremium: true ) let stickerItems = EmojiPagerContentComponent.stickerInputData( context: controller.context, animationCache: controller.context.animationCache, animationRenderer: controller.context.animationRenderer, stickerNamespaces: [Namespaces.ItemCollection.CloudStickerPacks], stickerOrderedItemListCollectionIds: [Namespaces.OrderedItemList.CloudSavedStickers, Namespaces.OrderedItemList.CloudRecentStickers, Namespaces.OrderedItemList.CloudAllPremiumStickers], chatPeerId: controller.context.account.peerId, hasSearch: true, hasTrending: true, forceHasPremium: true ) let signal = combineLatest( queue: .mainQueue(), emojiItems, stickerItems ) |> map { emoji, stickers -> StickerPickerInputData in return StickerPickerInputData(emoji: emoji, stickers: stickers, gifs: nil) } |> afterNext { [weak self] _ in if let self { self.controller?.checkPostingAvailability() } } stickerPickerInputData.set(signal) }) self.entitiesView.edgePreviewUpdated = { [weak self] preview in if let self { let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) if let storyPreviewView = self.storyPreview.view { transition.updateAlpha(layer: storyPreviewView.layer, alpha: preview ? 0.4 : 0.0) } } } self.appInForegroundDisposable = (controller.context.sharedContext.applicationBindings.applicationInForeground |> deliverOnMainQueue).start(next: { [weak self] inForeground in if let self, let mediaEditor = self.mediaEditor { if inForeground { mediaEditor.maybeUnpauseVideo() } else { mediaEditor.maybePauseVideo() } } }) self.entitiesView.getAvailableReactions = { [weak self] in return self?.availableReactions ?? [] } self.entitiesView.present = { [weak self] c in if let self { self.controller?.dismissAllTooltips() self.controller?.present(c, in: .current) } } self.entitiesView.push = { [weak self] c in if let self { self.controller?.push(c) } } self.entitiesView.externalEntityRemoved = { [weak self] entity in if let self, let stickerEntity = entity as? DrawingStickerEntity, case let .dualVideoReference(isAdditional) = stickerEntity.content, isAdditional { self.mediaEditor?.setAdditionalVideo(nil, positionChanges: []) } } self.entitiesView.canInteract = { [weak self] in if let self, let controller = self.controller { return !controller.node.recording.isActive } else { return true } } self.availableReactionsDisposable = (allowedStoryReactions(context: controller.context) |> deliverOnMainQueue).start(next: { [weak self] reactions in if let self { self.availableReactions = reactions } }) } deinit { self.subjectDisposable?.dispose() self.gradientColorsDisposable?.dispose() self.appInForegroundDisposable?.dispose() self.playbackPositionDisposable?.dispose() self.availableReactionsDisposable?.dispose() } private func setup(with subject: MediaEditorScreen.Subject) { self.actualSubject = subject var effectiveSubject = subject if case let .draft(draft, _ ) = subject { for entity in draft.values.entities { if case let .sticker(sticker) = entity, case let .message(ids, _, _, _, _) = sticker.content { effectiveSubject = .message(ids) break } } } self.subject = effectiveSubject guard let controller = self.controller else { return } Queue.mainQueue().justDispatch { controller.setupAudioSessionIfNeeded() } if case let .draft(draft, _) = subject, let privacy = draft.privacy { controller.state.privacy = privacy } var isFromCamera = false let isSavingAvailable: Bool switch subject { case .image, .video: isSavingAvailable = !controller.isEmbeddedEditor isFromCamera = true case .draft: isSavingAvailable = true case .message: isSavingAvailable = true default: isSavingAvailable = false } controller.isSavingAvailable = isSavingAvailable controller.requestLayout(transition: .immediate) let mediaDimensions = effectiveSubject.dimensions let maxSide: CGFloat = 1920.0 / UIScreen.main.scale let fittedSize = mediaDimensions.cgSize.fitted(CGSize(width: maxSide, height: maxSide)) let mediaEntity = DrawingMediaEntity(size: fittedSize) mediaEntity.position = CGPoint(x: storyDimensions.width / 2.0, y: storyDimensions.height / 2.0) if fittedSize.height > fittedSize.width { mediaEntity.scale = max(storyDimensions.width / fittedSize.width, storyDimensions.height / fittedSize.height) } else { mediaEntity.scale = storyDimensions.width / fittedSize.width } let initialPosition = mediaEntity.position let initialScale = mediaEntity.scale let initialRotation = mediaEntity.rotation if isFromCamera && mediaDimensions.width > mediaDimensions.height { mediaEntity.scale = storyDimensions.height / fittedSize.height } self.entitiesView.add(mediaEntity, announce: false) let initialValues: MediaEditorValues? if case let .draft(draft, _) = subject { initialValues = draft.values for entity in draft.values.entities { self.entitiesView.add(entity.entity.duplicate(copy: true), announce: false) } if let drawingData = initialValues?.drawing?.pngData() { self.drawingView.setup(withDrawing: drawingData) } } else { initialValues = nil } if let entityView = self.entitiesView.getView(for: mediaEntity.uuid) as? DrawingMediaEntityView { self.entitiesView.sendSubviewToBack(entityView) entityView.updated = { [weak self, weak mediaEntity] in if let self, let mediaEntity { let rotationDelta = mediaEntity.rotation - initialRotation let positionDelta = CGPoint(x: mediaEntity.position.x - initialPosition.x, y: mediaEntity.position.y - initialPosition.y) let scaleDelta = mediaEntity.scale / initialScale self.mediaEditor?.setCrop(offset: positionDelta, scale: scaleDelta, rotation: rotationDelta, mirroring: false) } } if let initialValues { mediaEntity.position = mediaEntity.position.offsetBy(dx: initialValues.cropOffset.x, dy: initialValues.cropOffset.y) mediaEntity.rotation = mediaEntity.rotation + initialValues.cropRotation mediaEntity.scale = mediaEntity.scale * initialValues.cropScale } } let mediaEditor = MediaEditor(context: self.context, subject: effectiveSubject.editorSubject, values: initialValues, hasHistogram: true) if let initialVideoPosition = controller.initialVideoPosition { mediaEditor.seek(initialVideoPosition, andPlay: true) } if case .message = subject, self.context.sharedContext.currentPresentationData.with({$0}).autoNightModeTriggered { mediaEditor.setNightTheme(true) } mediaEditor.attachPreviewView(self.previewView) mediaEditor.valuesUpdated = { [weak self] values in if let self, let controller = self.controller, values.gradientColors != nil, controller.previousSavedValues != values { if !isSavingAvailable && controller.previousSavedValues == nil { controller.previousSavedValues = values controller.isSavingAvailable = false } else { self.hasAnyChanges = true controller.isSavingAvailable = true } controller.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut)) } } if case .message = effectiveSubject { } else { self.readyValue.set(.single(true)) } if case let .image(_, _, additionalImage, position) = effectiveSubject, let additionalImage { let image = generateImage(CGSize(width: additionalImage.size.width, height: additionalImage.size.width), contextGenerator: { size, context in let bounds = CGRect(origin: .zero, size: size) context.clear(bounds) context.addEllipse(in: bounds) context.clip() if let cgImage = additionalImage.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: (size.width - additionalImage.size.width) / 2.0, y: (size.height - additionalImage.size.height) / 2.0), size: additionalImage.size)) } }, scale: 1.0) let imageEntity = DrawingStickerEntity(content: .image(image ?? additionalImage, .dualPhoto)) imageEntity.referenceDrawingSize = storyDimensions imageEntity.scale = 1.625 imageEntity.position = position.getPosition(storyDimensions) self.entitiesView.add(imageEntity, announce: false) } else if case let .video(_, _, mirror, additionalVideoPath, _, _, _, changes, position) = effectiveSubject { mediaEditor.setVideoIsMirrored(mirror) if let additionalVideoPath { let videoEntity = DrawingStickerEntity(content: .dualVideoReference(false)) videoEntity.referenceDrawingSize = storyDimensions videoEntity.scale = 1.625 videoEntity.position = position.getPosition(storyDimensions) self.entitiesView.add(videoEntity, announce: false) mediaEditor.setAdditionalVideo(additionalVideoPath, isDual: true, positionChanges: changes.map { VideoPositionChange(additional: $0.0, timestamp: $0.1) }) mediaEditor.setAdditionalVideoPosition(videoEntity.position, scale: videoEntity.scale, rotation: videoEntity.rotation) if let entityView = self.entitiesView.getView(for: videoEntity.uuid) as? DrawingStickerEntityView { entityView.updated = { [weak videoEntity, weak self] in if let self, let videoEntity { self.mediaEditor?.setAdditionalVideoPosition(videoEntity.position, scale: videoEntity.scale, rotation: videoEntity.rotation) } } } } } else if case let .message(messageIds) = effectiveSubject { let isNightTheme = mediaEditor.values.nightTheme let _ = ((self.context.engine.data.get( EngineDataMap(messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init(id:))) )) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } var messages: [Message] = [] for id in messageIds { if let maybeMessage = result[id], let message = maybeMessage { messages.append(message._asMessage()) } } var messageFile: TelegramMediaFile? if let maybeFile = messages.first?.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, maybeFile.isVideo, let _ = self.context.account.postbox.mediaBox.completedResourcePath(maybeFile.resource, pathExtension: nil) { messageFile = maybeFile } if "".isEmpty { messageFile = nil } let renderer = DrawingMessageRenderer(context: self.context, messages: messages) renderer.render(completion: { result in if case .draft = subject, let existingEntityView = self.entitiesView.getView(where: { entityView in if let stickerEntityView = entityView as? DrawingStickerEntityView, case .message = (stickerEntityView.entity as! DrawingStickerEntity).content { return true } else { return false } }) as? DrawingStickerEntityView { existingEntityView.isNightTheme = isNightTheme let messageEntity = existingEntityView.entity as! DrawingStickerEntity messageEntity.renderImage = result.dayImage messageEntity.secondaryRenderImage = result.nightImage messageEntity.overlayRenderImage = result.overlayImage existingEntityView.update(animated: false) } else { let messageEntity = DrawingStickerEntity(content: .message(messageIds, result.size, messageFile, result.mediaFrame?.rect, result.mediaFrame?.cornerRadius)) messageEntity.renderImage = result.dayImage messageEntity.secondaryRenderImage = result.nightImage messageEntity.overlayRenderImage = result.overlayImage messageEntity.referenceDrawingSize = storyDimensions messageEntity.position = CGPoint(x: storyDimensions.width / 2.0, y: storyDimensions.height / 2.0) let fraction = max(result.size.width, result.size.height) / 353.0 messageEntity.scale = min(6.0, 3.3 * fraction) if let entityView = self.entitiesView.add(messageEntity, announce: false) as? DrawingStickerEntityView { if isNightTheme { entityView.isNightTheme = true } } } self.readyValue.set(.single(true)) }) }) } self.gradientColorsDisposable = mediaEditor.gradientColors.start(next: { [weak self] colors in if let self, let colors { let gradientImage = generateGradientImage(size: CGSize(width: 5.0, height: 640.0), colors: colors.array, locations: [0.0, 1.0]) Queue.mainQueue().async { self.gradientView.image = gradientImage if self.controller?.isEmbeddedEditor == true { } else { self.previewContainerView.alpha = 1.0 if CACurrentMediaTime() - self.initializationTimestamp > 0.2, case .image = subject { self.previewContainerView.layer.allowsGroupOpacity = true self.previewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { _ in self.previewContainerView.layer.allowsGroupOpacity = false self.previewContainerView.alpha = 1.0 self.backgroundDimView.isHidden = false }) } else { self.backgroundDimView.isHidden = false } } } } }) self.mediaEditor = mediaEditor self.mediaEditorPromise.set(.single(mediaEditor)) if controller.isEmbeddedEditor == true { mediaEditor.onFirstDisplay = { [weak self] in if let self { if effectiveSubject.isPhoto { self.previewContainerView.layer.allowsGroupOpacity = true self.previewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { _ in self.previewContainerView.layer.allowsGroupOpacity = false self.previewContainerView.alpha = 1.0 self.backgroundDimView.isHidden = false }) } else { self.previewContainerView.alpha = 1.0 self.backgroundDimView.isHidden = false } } } } mediaEditor.onPlaybackAction = { [weak self] action in if let self { switch action { case .play: self.entitiesView.play() case .pause: self.entitiesView.pause() case let .seek(timestamp): self.entitiesView.seek(to: timestamp) } } } } override func didLoad() { super.didLoad() self.view.disablesInteractiveModalDismiss = true self.view.disablesInteractiveKeyboardGestureRecognizer = true let dismissPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handleDismissPan(_:))) dismissPanGestureRecognizer.delegate = self dismissPanGestureRecognizer.maximumNumberOfTouches = 1 self.previewContainerView.addGestureRecognizer(dismissPanGestureRecognizer) self.dismissPanGestureRecognizer = dismissPanGestureRecognizer let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:))) panGestureRecognizer.delegate = self panGestureRecognizer.minimumNumberOfTouches = 1 panGestureRecognizer.maximumNumberOfTouches = 2 self.view.addGestureRecognizer(panGestureRecognizer) self.panGestureRecognizer = panGestureRecognizer let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinch(_:))) pinchGestureRecognizer.delegate = self self.previewContainerView.addGestureRecognizer(pinchGestureRecognizer) let rotateGestureRecognizer = UIRotationGestureRecognizer(target: self, action: #selector(self.handleRotate(_:))) rotateGestureRecognizer.delegate = self self.previewContainerView.addGestureRecognizer(rotateGestureRecognizer) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) tapGestureRecognizer.delegate = self self.previewContainerView.addGestureRecognizer(tapGestureRecognizer) self.interaction = DrawingToolsInteraction( context: self.context, drawingView: self.drawingView, entitiesView: self.entitiesView, contentWrapperView: self.previewContainerView, selectionContainerView: self.selectionContainerView, isVideo: false, autoselectEntityOnPan: true, updateSelectedEntity: { [weak self] _ in if let self { self.requestUpdate(transition: .easeInOut(duration: 0.2)) } }, updateVideoPlayback: { [weak self] isPlaying in if let self, let mediaEditor = self.mediaEditor { if isPlaying { mediaEditor.play() } else { mediaEditor.stop() } } }, updateColor: { [weak self] color in if let self, let selectedEntityView = self.entitiesView.selectedEntityView { let selectedEntity = selectedEntityView.entity if let textEntity = selectedEntity as? DrawingTextEntity, let textEntityView = selectedEntityView as? DrawingTextEntityView, textEntityView.isEditing { textEntity.setColor(color, range: textEntityView.selectedRange) textEntityView.update(animated: false, keepSelectedRange: true) } else { selectedEntity.color = color selectedEntityView.update(animated: false) } } }, onInteractionUpdated: { [weak self] isInteracting in if let self { if let selectedEntityView = self.entitiesView.selectedEntityView as? DrawingStickerEntityView, let entity = selectedEntityView.entity as? DrawingStickerEntity, case .dualVideoReference = entity.content { if isInteracting { self.mediaEditor?.maybePauseVideo() } else { self.mediaEditor?.maybeUnpauseVideo() } } else if self.mediaEditor?.sourceIsVideo == true { if isInteracting { self.mediaEditor?.maybePauseVideo() } else { self.mediaEditor?.maybeUnpauseVideo() } } self.isInteractingWithEntities = isInteracting if !isInteracting { self.controller?.isSavingAvailable = true self.hasAnyChanges = true } self.requestUpdate(transition: .easeInOut(duration: 0.2)) } }, onTextEditingEnded: { [weak self] reset in if let self, !reset, let entity = self.entitiesView.selectedEntityView?.entity as? DrawingTextEntity, !entity.text.string.isEmpty { let _ = updateMediaEditorStoredStateInteractively(engine: self.context.engine, { current in let textSettings = MediaEditorStoredTextSettings(style: entity.style, font: entity.font, fontSize: entity.fontSize, alignment: entity.alignment) if let current { return current.withUpdatedTextSettings(textSettings) } else { return MediaEditorStoredState(privacy: nil, textSettings: textSettings) } }).start() } }, editEntity: { [weak self] entity in if let self { if let location = entity as? DrawingLocationEntity { self.presentLocationPicker(location) } } }, shouldDeleteEntity: { [weak self] entity in if let self { if let stickerEntity = entity as? DrawingStickerEntity, case .dualVideoReference(true) = stickerEntity.content { self.presentVideoRemoveConfirmation() return false } } return true }, getCurrentImage: { [weak self] in guard let mediaEditor = self?.mediaEditor else { return nil } let colorSpace = CGColorSpaceCreateDeviceRGB() let imageSize = CGSize(width: 1080, height: 1920) if let context = DrawingContext(size: imageSize, scale: 1.0, opaque: true, colorSpace: colorSpace) { context.withFlippedContext { context in if let image = mediaEditor.resultImage?.cgImage { context.draw(image, in: CGRect(origin: .zero, size: imageSize)) } } return context.generateImage(colorSpace: colorSpace) } return nil }, getControllerNode: { [weak self] in return self }, present: { [weak self] c, i, a in if let self { self.controller?.present(c, in: i, with: a) } }, addSubview: { [weak self] view in if let self { self.view.addSubview(view) } } ) } @objc func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let panRecognizer = gestureRecognizer as? UIPanGestureRecognizer, panRecognizer.minimumNumberOfTouches == 1, panRecognizer.state == .changed { if otherGestureRecognizer is UIPinchGestureRecognizer || otherGestureRecognizer is UIRotationGestureRecognizer { return true } else { return false } } else if let panRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, panRecognizer.minimumNumberOfTouches == 1, panRecognizer.state == .changed { return false } else if gestureRecognizer is UITapGestureRecognizer, (otherGestureRecognizer is UIPinchGestureRecognizer || otherGestureRecognizer is UIRotationGestureRecognizer) && otherGestureRecognizer.state == .changed { return false } return true } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if self.entitiesView.isEditingText { return false } if gestureRecognizer === self.dismissPanGestureRecognizer { let location = gestureRecognizer.location(in: self.entitiesView) if self.controller?.isEmbeddedEditor == true || self.isDisplayingTool || self.entitiesView.hasSelection || self.entitiesView.getView(at: location) != nil { return false } return true } else if gestureRecognizer === self.panGestureRecognizer { let location = gestureRecognizer.location(in: self.view) if location.x > self.view.frame.width - 44.0 && location.y > self.view.frame.height - 180.0 { return false } if let reactionNode = self.view.subviews.last?.asyncdisplaykit_node as? ReactionContextNode { if let hitTestResult = self.view.hitTest(location, with: nil), hitTestResult.isDescendant(of: reactionNode.view) { return false } } if self.stickerScreen != nil { return false } return true } else { return true } } private var canEnhance: Bool { if case .message = self.subject { return false } return true } private var enhanceInitialTranslation: Float? @objc func handleDismissPan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let controller = self.controller, let layout = self.validLayout, (layout.inputHeight ?? 0.0).isZero else { return } var hasSwipeToDismiss = false if let subject = self.subject { if case .asset = subject { hasSwipeToDismiss = true } else if case .draft = subject { hasSwipeToDismiss = true } } let translation = gestureRecognizer.translation(in: self.view) let velocity = gestureRecognizer.velocity(in: self.view) switch gestureRecognizer.state { case .changed: if abs(translation.y) > 10.0 && !self.isEnhancing && hasSwipeToDismiss { if !self.isDismissing { self.isDismissing = true self.isDismissBySwipeSuppressed = controller.isEligibleForDraft() controller.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut)) } } else if abs(translation.x) > 10.0 && !self.isDismissing && !self.isEnhancing && self.canEnhance { self.isEnhancing = true controller.requestLayout(transition: .animated(duration: 0.3, curve: .easeInOut)) } if self.isDismissing { self.dismissOffset = translation.y controller.requestLayout(transition: .immediate) if abs(self.dismissOffset) > 20.0, controller.isEligibleForDraft() { gestureRecognizer.isEnabled = false gestureRecognizer.isEnabled = true controller.maybePresentDiscardAlert() } } else if self.isEnhancing { if let mediaEditor = self.mediaEditor { let value = mediaEditor.getToolValue(.enhance) as? Float ?? 0.0 if self.enhanceInitialTranslation == nil && value != 0.0 { self.enhanceInitialTranslation = value } let delta = Float((translation.x / self.frame.width) * 1.8) var updatedValue = max(-1.0, min(1.0, value + delta)) if let enhanceInitialTranslation = self.enhanceInitialTranslation { if enhanceInitialTranslation > 0.0 { updatedValue = max(0.0, updatedValue) } else { updatedValue = min(0.0, updatedValue) } } mediaEditor.setToolValue(.enhance, value: updatedValue) } self.requestUpdate() gestureRecognizer.setTranslation(.zero, in: self.view) } case .ended, .cancelled: self.enhanceInitialTranslation = nil if self.isDismissing { if abs(translation.y) > self.view.frame.height * 0.33 || abs(velocity.y) > 1000.0, !controller.isEligibleForDraft() { controller.requestDismiss(saveDraft: false, animated: true) } else { self.dismissOffset = 0.0 self.isDismissing = false controller.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) } } else if self.isEnhancing { self.isEnhancing = false Queue.mainQueue().after(0.5) { controller.requestLayout(transition: .animated(duration: 0.3, curve: .easeInOut)) } } default: break } } @objc func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { if gestureRecognizer.numberOfTouches == 2, let subject = self.subject, case .message = subject, !self.entitiesView.hasSelection { return } self.entitiesView.handlePan(gestureRecognizer) } @objc func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { if gestureRecognizer.numberOfTouches == 2, let subject = self.subject, case .message = subject, !self.entitiesView.hasSelection { return } self.entitiesView.handlePinch(gestureRecognizer) } @objc func handleRotate(_ gestureRecognizer: UIRotationGestureRecognizer) { if gestureRecognizer.numberOfTouches == 2, let subject = self.subject, case .message = subject, !self.entitiesView.hasSelection { return } self.entitiesView.handleRotate(gestureRecognizer) } @objc func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard !self.recording.isActive else { return } let location = gestureRecognizer.location(in: self.view) var entitiesHitTestResult = self.entitiesView.hitTest(self.view.convert(location, to: self.entitiesView), with: nil) if entitiesHitTestResult is DrawingMediaEntityView { entitiesHitTestResult = nil } let selectionHitTestResult = self.selectionContainerView.hitTest(self.view.convert(location, to: self.selectionContainerView), with: nil) if entitiesHitTestResult == nil && selectionHitTestResult == nil { if self.entitiesView.hasSelection { self.entitiesView.selectEntity(nil) self.view.endEditing(true) } else { if let layout = self.validLayout, (layout.inputHeight ?? 0.0) > 0.0 { self.view.endEditing(true) } else { self.insertTextEntity() } } } } private func insertTextEntity() { let _ = (mediaEditorStoredState(engine: self.context.engine) |> deliverOnMainQueue).start(next: { [weak self] state in guard let self else { return } var style: DrawingTextEntity.Style = .filled var font: DrawingTextEntity.Font = .sanFrancisco var alignment: DrawingTextEntity.Alignment = .center var fontSize: CGFloat = 1.0 if let textSettings = state?.textSettings { style = textSettings.style font = textSettings.font alignment = textSettings.alignment fontSize = textSettings.fontSize } let textEntity = DrawingTextEntity(text: NSAttributedString(), style: style, animation: .none, font: font, alignment: alignment, fontSize: fontSize, color: DrawingColor(color: .white)) self.interaction?.insertEntity(textEntity) }) } private func setupTransitionImage(_ image: UIImage) { self.previewContainerView.alpha = 1.0 let transitionInView = UIImageView(image: image) transitionInView.contentMode = .scaleAspectFill var initialScale: CGFloat if image.size.height > image.size.width { initialScale = max(self.previewContainerView.bounds.width / image.size.width, self.previewContainerView.bounds.height / image.size.height) } else { initialScale = self.previewContainerView.bounds.width / image.size.width } transitionInView.center = CGPoint(x: self.previewContainerView.bounds.width / 2.0, y: self.previewContainerView.bounds.height / 2.0) transitionInView.transform = CGAffineTransformMakeScale(initialScale, initialScale) self.previewContainerView.addSubview(transitionInView) self.transitionInView = transitionInView self.mediaEditor?.onFirstDisplay = { [weak self] in if let self, let transitionInView = self.transitionInView { self.transitionInView = nil transitionInView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak transitionInView] _ in transitionInView?.removeFromSuperview() }) } } } func animateIn() { let completion: () -> Void = { [weak self] in Queue.mainQueue().after(0.1) { self?.requestUpdate(hasAppeared: true, transition: .immediate) } } if let transitionIn = self.controller?.transitionIn { switch transitionIn { case .camera: if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateIn(from: .camera, completion: completion) } if let subject = self.subject, case let .video(_, mainTransitionImage, _, _, additionalTransitionImage, _, _, positionChangeTimestamps, pipPosition) = subject, let mainTransitionImage { var transitionImage = mainTransitionImage if let additionalTransitionImage { var backgroundImage = mainTransitionImage var foregroundImage = additionalTransitionImage if let change = positionChangeTimestamps.first, change.0 { backgroundImage = additionalTransitionImage foregroundImage = mainTransitionImage } if let combinedTransitionImage = generateImage(CGSize(width: 1080, height: 1920), scale: 1.0, rotatedContext: { size, context in UIGraphicsPushContext(context) backgroundImage.draw(in: CGRect(origin: CGPoint(x: (size.width - backgroundImage.size.width) / 2.0, y: (size.height - backgroundImage.size.height) / 2.0), size: backgroundImage.size)) let ellipsePosition = pipPosition.getPosition(storyDimensions) let ellipseSize = CGSize(width: 439.0, height: 439.0) let ellipseRect = CGRect(origin: CGPoint(x: ellipsePosition.x - ellipseSize.width / 2.0, y: ellipsePosition.y - ellipseSize.height / 2.0), size: ellipseSize) let foregroundSize = foregroundImage.size.aspectFilled(ellipseSize) let foregroundRect = CGRect(origin: CGPoint(x: ellipseRect.center.x - foregroundSize.width / 2.0, y: ellipseRect.center.y - foregroundSize.height / 2.0), size: foregroundSize) context.addEllipse(in: ellipseRect) context.clip() foregroundImage.draw(in: foregroundRect) UIGraphicsPopContext() }) { transitionImage = combinedTransitionImage } } self.setupTransitionImage(transitionImage) } case let .gallery(transitionIn): if let sourceImage = transitionIn.sourceImage { self.setupTransitionImage(sourceImage) } if let sourceView = transitionIn.sourceView { if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateIn(from: .gallery) } let sourceLocalFrame = sourceView.convert(transitionIn.sourceRect, to: self.view) let sourceScale = sourceLocalFrame.width / self.previewContainerView.frame.width let sourceAspectRatio = sourceLocalFrame.height / sourceLocalFrame.width let duration: Double = 0.4 self.previewContainerView.layer.animatePosition(from: sourceLocalFrame.center, to: self.previewContainerView.center, duration: duration, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in completion() }) self.previewContainerView.layer.animateScale(from: sourceScale, to: 1.0, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) self.previewContainerView.layer.animateBounds(from: CGRect(origin: CGPoint(x: 0.0, y: (self.previewContainerView.bounds.height - self.previewContainerView.bounds.width * sourceAspectRatio) / 2.0), size: CGSize(width: self.previewContainerView.bounds.width, height: self.previewContainerView.bounds.width * sourceAspectRatio)), to: self.previewContainerView.bounds, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) self.backgroundDimView.isHidden = false self.backgroundDimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) if let componentView = self.componentHost.view { componentView.layer.animatePosition(from: sourceLocalFrame.center, to: componentView.center, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) componentView.layer.animateScale(from: sourceScale, to: 1.0, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) componentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } } } } else { if case .message = self.actualSubject, let layout = self.validLayout { self.layer.animatePosition(from: CGPoint(x: 0.0, y: layout.size.height), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) completion() } else if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateIn(from: .camera, completion: completion) } } } func animateOut(finished: Bool, saveDraft: Bool, completion: @escaping () -> Void) { guard let controller = self.controller else { return } self.isDismissed = true controller.statusBar.statusBarStyle = .Ignore self.isUserInteractionEnabled = false if self.entitiesView.hasSelection { self.entitiesView.selectEntity(nil) } let previousDimAlpha = self.backgroundDimView.alpha self.backgroundDimView.alpha = 0.0 self.backgroundDimView.layer.animateAlpha(from: previousDimAlpha, to: 0.0, duration: 0.15) var isNew: Bool? = false if let subject = self.actualSubject { if saveDraft { isNew = true } if case .draft = subject, !saveDraft { isNew = nil } } if isNew == true { self.entitiesView.seek(to: 0.0) } if let transitionOut = controller.transitionOut(finished, isNew), let destinationView = transitionOut.destinationView { var destinationTransitionView: UIView? if !finished { if let transitionIn = controller.transitionIn, case let .gallery(galleryTransitionIn) = transitionIn, let sourceImage = galleryTransitionIn.sourceImage, isNew != true { let sourceSuperView = galleryTransitionIn.sourceView?.superview?.superview let destinationTransitionOutView = UIImageView(image: sourceImage) destinationTransitionOutView.clipsToBounds = true destinationTransitionOutView.contentMode = .scaleAspectFill destinationTransitionOutView.frame = self.previewContainerView.convert(self.previewContainerView.bounds, to: sourceSuperView) sourceSuperView?.addSubview(destinationTransitionOutView) destinationTransitionView = destinationTransitionOutView } if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateOut(to: .gallery) } } let destinationLocalFrame = destinationView.convert(transitionOut.destinationRect, to: self.view) let destinationScale = destinationLocalFrame.width / self.previewContainerView.frame.width let destinationAspectRatio = destinationLocalFrame.height / destinationLocalFrame.width var destinationSnapshotView: UIView? if let destinationNode = destinationView.asyncdisplaykit_node as? AvatarNode { let destinationTransitionView: UIView? if let image = destinationNode.unroundedImage { destinationTransitionView = UIImageView(image: image) destinationTransitionView?.bounds = destinationNode.bounds destinationTransitionView?.layer.cornerRadius = destinationNode.bounds.width / 2.0 } else if let snapshotView = destinationView.snapshotView(afterScreenUpdates: false) { destinationTransitionView = snapshotView } else { destinationTransitionView = nil } destinationView.isHidden = true if let destinationTransitionView { destinationTransitionView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) let snapshotScale = self.previewContainerView.bounds.width / destinationTransitionView.frame.width destinationTransitionView.center = CGPoint(x: 0.0, y: self.previewContainerView.bounds.height / 2.0) destinationTransitionView.layer.transform = CATransform3DMakeScale(snapshotScale, snapshotScale, 1.0) destinationTransitionView.alpha = 0.0 Queue.mainQueue().after(0.15) { destinationTransitionView.alpha = 1.0 destinationTransitionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } self.previewContainerView.addSubview(destinationTransitionView) destinationSnapshotView = destinationTransitionView } } else if let destinationNode = destinationView.asyncdisplaykit_node as? AvatarNode.ContentNode { let destinationTransitionView: UIView? if let image = destinationNode.unroundedImage { destinationTransitionView = UIImageView(image: image) destinationTransitionView?.bounds = destinationNode.bounds destinationTransitionView?.layer.cornerRadius = destinationNode.bounds.width / 2.0 } else if let snapshotView = destinationView.snapshotView(afterScreenUpdates: false) { destinationTransitionView = snapshotView } else { destinationTransitionView = nil } destinationView.isHidden = true if let destinationTransitionView { destinationTransitionView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) let snapshotScale = self.previewContainerView.bounds.width / destinationTransitionView.frame.width destinationTransitionView.center = CGPoint(x: 0.0, y: self.previewContainerView.bounds.height / 2.0) destinationTransitionView.layer.transform = CATransform3DMakeScale(snapshotScale, snapshotScale, 1.0) destinationTransitionView.alpha = 0.0 Queue.mainQueue().after(0.15) { destinationTransitionView.alpha = 1.0 destinationTransitionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } self.previewContainerView.addSubview(destinationTransitionView) destinationSnapshotView = destinationTransitionView } } self.previewContainerView.layer.animatePosition(from: self.previewContainerView.center, to: destinationLocalFrame.center, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in destinationView.isHidden = false destinationSnapshotView?.removeFromSuperview() completion() if let view = self.entitiesView.getView(where: { $0 is DrawingMediaEntityView }) as? DrawingMediaEntityView { view.previewView = nil } }) self.previewContainerView.layer.animateScale(from: 1.0, to: destinationScale, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) self.previewContainerView.layer.animateBounds(from: self.previewContainerView.bounds, to: CGRect(origin: CGPoint(x: 0.0, y: (self.previewContainerView.bounds.height - self.previewContainerView.bounds.width * destinationAspectRatio) / 2.0), size: CGSize(width: self.previewContainerView.bounds.width, height: self.previewContainerView.bounds.width * destinationAspectRatio)), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) if let destinationTransitionView { self.previewContainerView.layer.allowsGroupOpacity = true self.previewContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) destinationTransitionView.layer.animateFrame(from: destinationTransitionView.frame, to: destinationView.convert(destinationView.bounds, to: destinationTransitionView.superview), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak destinationTransitionView] _ in destinationTransitionView?.removeFromSuperview() }) } let targetCornerRadius: CGFloat if transitionOut.destinationCornerRadius > 0.0 { targetCornerRadius = self.previewContainerView.bounds.width } else { targetCornerRadius = 0.0 } self.previewContainerView.layer.animate( from: self.previewContainerView.layer.cornerRadius as NSNumber, to: targetCornerRadius / 2.0 as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4, removeOnCompletion: false ) if let componentView = self.componentHost.view { componentView.clipsToBounds = true componentView.layer.animatePosition(from: componentView.center, to: destinationLocalFrame.center, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) componentView.layer.animateScale(from: 1.0, to: destinationScale, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) componentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) if finished { componentView.layer.animateBounds(from: componentView.bounds, to: CGRect(origin: CGPoint(x: 0.0, y: (componentView.bounds.height - componentView.bounds.width) / 2.0), size: CGSize(width: componentView.bounds.width, height: componentView.bounds.width)), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) componentView.layer.animate( from: componentView.layer.cornerRadius as NSNumber, to: componentView.bounds.width / 2.0 as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4, removeOnCompletion: false ) } } } else if let transitionIn = controller.transitionIn, case .camera = transitionIn { if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateOut(to: .camera) } let transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) transition.setAlpha(view: self.previewContainerView, alpha: 0.0, completion: { _ in completion() }) } else { if controller.isEmbeddedEditor { if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateOut(to: .gallery) } self.layer.allowsGroupOpacity = true self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, removeOnCompletion: false, completion: { _ in completion() }) } else { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, removeOnCompletion: false) self.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) self.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: self.bounds.height), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: { _ in completion() }) } } } func animateOutToTool() { self.isDisplayingTool = true let transition: Transition = .easeInOut(duration: 0.2) if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateOutToTool(transition: transition) } self.requestUpdate(transition: transition) } func animateInFromTool() { self.isDisplayingTool = false let transition: Transition = .easeInOut(duration: 0.2) if let view = self.componentHost.view as? MediaEditorScreenComponent.View { view.animateInFromTool(transition: transition) } self.requestUpdate(transition: transition) } private weak var muteTooltip: ViewController? func presentMutedTooltip() { guard let mediaEditor = self.mediaEditor, let sourceView = self.componentHost.findTaggedView(tag: muteButtonTag) else { return } if let muteTooltip = self.muteTooltip { muteTooltip.dismiss() self.muteTooltip = nil } let isMuted = self.mediaEditor?.values.videoIsMuted ?? false 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.maxY + 3.0), size: CGSize()) let text: String if mediaEditor.values.audioTrack != nil || (mediaEditor.sourceIsVideo && mediaEditor.values.additionalVideoPath != nil) { if isMuted { text = self.presentationData.strings.Story_Editor_TooltipMutedWithAudio } else { text = self.presentationData.strings.Story_Editor_TooltipUnmutedWithAudio } } else { if isMuted { text = self.presentationData.strings.Story_Editor_TooltipMuted } else { text = self.presentationData.strings.Story_Editor_TooltipUnmuted } } let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: text), location: .point(location, .top), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in return .ignore }) self.muteTooltip = tooltipController self.controller?.present(tooltipController, in: .current) } private weak var saveTooltip: SaveProgressScreen? func presentSaveTooltip() { guard let controller = self.controller else { return } if let saveTooltip = self.saveTooltip { if case .completion = saveTooltip.content { saveTooltip.dismiss() self.saveTooltip = nil } } let text: String let isVideo = self.mediaEditor?.resultIsVideo ?? false if isVideo { text = self.presentationData.strings.Story_Editor_TooltipVideoSavedToPhotos } else { text = self.presentationData.strings.Story_Editor_TooltipImageSavedToPhotos } if let tooltipController = self.saveTooltip { tooltipController.content = .completion(text) } else { let tooltipController = SaveProgressScreen(context: self.context, content: .completion(text)) controller.present(tooltipController, in: .current) self.saveTooltip = tooltipController } } func updateEditProgress(_ progress: Float, cancel: @escaping () -> Void) { guard let controller = self.controller else { return } if let saveTooltip = self.saveTooltip { if case .completion = saveTooltip.content { saveTooltip.dismiss() self.saveTooltip = nil } } let text = self.presentationData.strings.Story_Editor_Uploading if let tooltipController = self.saveTooltip { tooltipController.content = .progress(text, progress) } else { let tooltipController = SaveProgressScreen(context: self.context, content: .progress(text, 0.0)) tooltipController.cancelled = { [weak self] in cancel() if let self, let controller = self.controller { controller.cancelVideoExport() } } controller.present(tooltipController, in: .current) self.saveTooltip = tooltipController } } func updateVideoExportProgress(_ progress: Float) { guard let controller = self.controller else { return } if let saveTooltip = self.saveTooltip { if case .completion = saveTooltip.content { saveTooltip.dismiss() self.saveTooltip = nil } } let text = self.presentationData.strings.Story_Editor_PreparingVideo if let tooltipController = self.saveTooltip { tooltipController.content = .progress(text, progress) } else { let tooltipController = SaveProgressScreen(context: self.context, content: .progress(text, 0.0)) tooltipController.cancelled = { [weak self] in if let self, let controller = self.controller { controller.isSavingAvailable = true controller.cancelVideoExport() controller.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut)) } } controller.present(tooltipController, in: .current) self.saveTooltip = tooltipController } } func presentGallery(parentController: ViewController? = nil) { guard let controller = self.controller else { return } let parentController = parentController ?? controller let galleryController = self.context.sharedContext.makeMediaPickerScreen(context: self.context, hasSearch: true, completion: { [weak self] result in guard let self else { return } func roundedImageWithTransparentCorners(image: UIImage, cornerRadius: CGFloat) -> UIImage? { let rect = CGRect(origin: .zero, size: image.size) UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) let context = UIGraphicsGetCurrentContext() if let context = context { let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) context.addPath(path.cgPath) context.clip() image.draw(in: rect) } let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return newImage } let completeWithImage: (UIImage) -> Void = { [weak self] image in let updatedImage = roundedImageWithTransparentCorners(image: image, cornerRadius: floor(image.size.width * 0.03))! let entity = DrawingStickerEntity(content: .image(updatedImage, .rectangle)) entity.canCutOut = false self?.interaction?.insertEntity(entity, scale: 2.5) } if let asset = result as? PHAsset { let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in if let image { Queue.mainQueue().async { completeWithImage(image) } } } } else if let image = result as? UIImage { completeWithImage(image) } }) galleryController.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak galleryController] transition in if let self, let galleryController { let transitionFactor = galleryController.modalStyleOverlayTransitionFactor self.updateModalTransitionFactor(transitionFactor, transition: transition) } } parentController.push(galleryController) } private let staticEmojiPack = Promise() private var didSetupStaticEmojiPack = false func presentLocationPicker(_ existingEntity: DrawingLocationEntity? = nil) { guard let controller = self.controller else { return } if !self.didSetupStaticEmojiPack { self.staticEmojiPack.set(self.context.engine.stickers.loadedStickerPack(reference: .name("staticemoji"), forceActualized: false)) } var location: CLLocationCoordinate2D? if let subject = self.actualSubject { if case let .asset(asset) = subject { location = asset.location?.coordinate } else if case let .draft(draft, _) = subject { location = draft.location } } let locationController = storyLocationPickerController( context: self.context, location: location, dismissed: { [weak self] in if let self { self.mediaEditor?.play() } }, completion: { [weak self] location, queryId, resultId, address, countryCode in if let self { let emojiFile: Signal if let countryCode { let flag = flagEmoji(countryCode: countryCode) emojiFile = self.staticEmojiPack.get() |> filter { result in if case .result = result { return true } else { return false } } |> take(1) |> map { result -> TelegramMediaFile? in if case let .result(_, items, _) = result, let match = items.first(where: { item in var displayText: String? for attribute in item.file.attributes { if case let .CustomEmoji(_, _, alt, _) = attribute { displayText = alt break } } if let displayText, displayText.hasPrefix(flag) { return true } else { return false } }) { return match.file } else { return nil } } } else { emojiFile = .single(nil) } let _ = emojiFile.start(next: { [weak self] emojiFile in guard let self else { return } let title: String if let venueTitle = location.venue?.title { title = venueTitle } else { title = address ?? "Location" } let position = existingEntity?.position let scale = existingEntity?.scale ?? 1.0 if let existingEntity { self.entitiesView.remove(uuid: existingEntity.uuid, animated: true) } self.interaction?.insertEntity( DrawingLocationEntity( title: title, style: existingEntity?.style ?? .white, location: location, icon: emojiFile, queryId: queryId, resultId: resultId ), scale: scale, position: position ) }) } }) locationController.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak locationController] transition in if let self, let locationController { let transitionFactor = locationController.modalStyleOverlayTransitionFactor self.updateModalTransitionFactor(transitionFactor, transition: transition) } } controller.push(locationController) } func presentAudioPicker() { var isSettingTrack = false self.controller?.present(legacyICloudFilePicker(theme: self.presentationData.theme, mode: .import, documentTypes: ["public.mp3", "public.mpeg-4-audio", "public.aac-audio", "org.xiph.flac"], forceDarkTheme: true, dismissed: { [weak self] in if let self { Queue.mainQueue().after(0.1) { if !isSettingTrack { self.mediaEditor?.play() } } } }, completion: { [weak self] urls in guard let self, let mediaEditor = self.mediaEditor, !urls.isEmpty, let url = urls.first else { return } isSettingTrack = true try? FileManager.default.createDirectory(atPath: draftPath(engine: self.context.engine), withIntermediateDirectories: true) let isScopedResource = url.startAccessingSecurityScopedResource() Logger.shared.log("MediaEditor", "isScopedResource = \(isScopedResource)") let coordinator = NSFileCoordinator(filePresenter: nil) var error: NSError? coordinator.coordinate(readingItemAt: url, options: .forUploading, error: &error, byAccessor: { sourceUrl in let fileName = "audio_\(sourceUrl.lastPathComponent)" let copyPath = fullDraftPath(peerId: self.context.account.peerId, path: fileName) try? FileManager.default.removeItem(atPath: copyPath) do { try FileManager.default.copyItem(at: sourceUrl, to: URL(fileURLWithPath: copyPath)) } catch let e { Logger.shared.log("MediaEditor", "copy file error \(e)") if isScopedResource { url.stopAccessingSecurityScopedResource() } return } Queue.mainQueue().async { let audioAsset = AVURLAsset(url: URL(fileURLWithPath: copyPath)) func loadValues(asset: AVAsset, retryCount: Int, completion: @escaping () -> Void) { asset.loadValuesAsynchronously(forKeys: ["tracks", "duration"], completionHandler: { if asset.statusOfValue(forKey: "tracks", error: nil) == .loading { if retryCount < 2 { Queue.mainQueue().after(0.1, { loadValues(asset: asset, retryCount: retryCount + 1, completion: completion) }) } else { completion() } } else { completion() } }) } loadValues(asset: audioAsset, retryCount: 0, completion: { var audioDuration: Double = 0.0 guard let track = audioAsset.tracks(withMediaType: .audio).first else { Logger.shared.log("MediaEditor", "track is nil") if isScopedResource { url.stopAccessingSecurityScopedResource() } return } audioDuration = track.timeRange.duration.seconds if audioDuration.isZero { Logger.shared.log("MediaEditor", "duration is zero") if isScopedResource { url.stopAccessingSecurityScopedResource() } return } func maybeFixMisencodedText(_ text: String) -> String { let charactersToSearchFor = CharacterSet(charactersIn: "àåèîóûþÿ") if text.lowercased().rangeOfCharacter(from: charactersToSearchFor) != nil { if let data = text.data(using: .windowsCP1252), let string = String(data: data, encoding: .windowsCP1251) { return string } else { return text } } else { return text } } var artist: String? var title: String? for data in audioAsset.commonMetadata { if data.commonKey == .commonKeyArtist, let value = data.stringValue { artist = maybeFixMisencodedText(value) } if data.commonKey == .commonKeyTitle, let value = data.stringValue { title = maybeFixMisencodedText(value) } } Queue.mainQueue().async { var audioTrimRange: Range? var audioOffset: Double? if let videoDuration = mediaEditor.originalDuration { if let videoStart = mediaEditor.values.videoTrimRange?.lowerBound { audioOffset = -videoStart } else if let _ = mediaEditor.values.additionalVideoPath, let videoStart = mediaEditor.values.additionalVideoTrimRange?.lowerBound { audioOffset = -videoStart } audioTrimRange = 0 ..< min(videoDuration, audioDuration) } else { audioTrimRange = 0 ..< min(15, audioDuration) } mediaEditor.setAudioTrack(MediaAudioTrack(path: fileName, artist: artist, title: title, duration: audioDuration), trimRange: audioTrimRange, offset: audioOffset) mediaEditor.seek(mediaEditor.values.videoTrimRange?.lowerBound ?? 0.0, andPlay: true) self.requestUpdate(transition: .easeInOut(duration: 0.2)) if isScopedResource { url.stopAccessingSecurityScopedResource() } mediaEditor.play() } }) } }) if let error { Logger.shared.log("MediaEditor", "coordinator error \(error)") } }), in: .window(.root)) } func presentVideoRemoveConfirmation() { guard let controller = self.controller else { return } let presentationData = controller.context.sharedContext.currentPresentationData.with { $0 } let alertController = textAlertController( context: controller.context, forceTheme: defaultDarkColorPresentationTheme, title: nil, text: presentationData.strings.MediaEditor_VideoRemovalConfirmation, actions: [ TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { }), TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: { [weak mediaEditor, weak entitiesView] in mediaEditor?.setAdditionalVideo(nil, positionChanges: []) if let entityView = entitiesView?.getView(where: { entityView in if let entity = entityView.entity as? DrawingStickerEntity, case .dualVideoReference = entity.content { return true } else { return false } }) { entitiesView?.remove(uuid: entityView.entity.uuid, animated: false) } }) ] ) controller.present(alertController, in: .window(.root)) } func presentTrackOptions(trackId: Int32, sourceView: UIView) { let isVideo = trackId != 2 let actionTitle: String = isVideo ? self.presentationData.strings.MediaEditor_RemoveVideo : self.presentationData.strings.MediaEditor_RemoveAudio let value: CGFloat if trackId == 0 { value = self.mediaEditor?.values.videoVolume ?? 1.0 } else if trackId == 1 { value = self.mediaEditor?.values.additionalVideoVolume ?? 1.0 } else if trackId == 2 { value = self.mediaEditor?.values.audioTrackVolume ?? 1.0 } else { value = 1.0 } var items: [ContextMenuItem] = [] items.append( .custom(VolumeSliderContextItem(minValue: 0.0, maxValue: 1.5, value: value, valueChanged: { [weak self] value, _ in if let self, let mediaEditor = self.mediaEditor { if trackId == 0 { if mediaEditor.values.videoIsMuted { mediaEditor.setVideoIsMuted(false) } mediaEditor.setVideoVolume(value) } else if trackId == 1 { mediaEditor.setAdditionalVideoVolume(value) } else if trackId == 2 { mediaEditor.setAudioTrackVolume(value) } } }), false) ) if trackId != 0 { items.append( .action( ContextMenuActionItem( text: actionTitle, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.primaryColor)}, action: { [weak self] f in f.dismissWithResult(.default) if let self, let mediaEditor = self.mediaEditor { if trackId == 1 { self.presentVideoRemoveConfirmation() } else { mediaEditor.setAudioTrack(nil) if !mediaEditor.sourceIsVideo && !mediaEditor.isPlaying { mediaEditor.play() } } self.requestUpdate(transition: .easeInOut(duration: 0.25)) } } ) ) ) } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkPresentationTheme) let contextController = ContextController(presentationData: presentationData, source: .reference(ReferenceContentSource(sourceView: sourceView, contentArea: UIScreen.main.bounds, customPosition: CGPoint(x: 0.0, y: -3.0))), items: .single(ContextController.Items(content: .list(items)))) self.controller?.present(contextController, in: .window(.root)) } func addReaction() { guard let controller = self.controller else { return } let maxReactionCount = self.context.userLimits.maxStoriesSuggestedReactions var currentReactionCount = 0 self.entitiesView.eachView { entityView in if let stickerEntity = entityView.entity as? DrawingStickerEntity, case let .file(_, type) = stickerEntity.content, case .reaction = type { currentReactionCount += 1 } } if currentReactionCount >= maxReactionCount { controller.presentReactionPremiumSuggestion() return } let heart = "❤️".strippedEmoji if let reaction = self.availableReactions.first(where: { reaction in return reaction.reaction.rawValue == .builtin(heart) }) { let stickerEntity = DrawingStickerEntity(content: .file(reaction.stillAnimation, .reaction(.builtin(heart), .white))) self.interaction?.insertEntity(stickerEntity, scale: 1.175) } self.mediaEditor?.play() } 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) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.componentHost.view { let point = self.view.convert(point, to: self.previewContainerView) return self.previewContainerView.hitTest(point, with: event) } return result } func requestUpdate(hasAppeared: Bool = false, transition: Transition = .immediate) { if let layout = self.validLayout { self.containerLayoutUpdated(layout: layout, hasAppeared: hasAppeared, transition: transition) } } fileprivate var drawingScreen: DrawingScreen? fileprivate var stickerScreen: StickerPickerScreen? private var defaultToEmoji = false private var previousDrawingData: Data? private var previousDrawingEntities: [DrawingEntity]? func requestLayout(forceUpdate: Bool, transition: Transition) { guard let layout = self.validLayout else { return } self.containerLayoutUpdated(layout: layout, forceUpdate: forceUpdate, hasAppeared: self.hasAppeared, transition: transition) } func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, hasAppeared: Bool = false, transition: Transition) { guard let controller = self.controller, !self.isDismissed else { return } let isFirstTime = self.validLayout == nil self.validLayout = layout let isTablet = layout.metrics.isTablet var topInset: CGFloat = (layout.statusBarHeight ?? 0.0) + 5.0 let previewSize: CGSize if isTablet { let previewHeight = layout.size.height - topInset - 75.0 previewSize = CGSize(width: floorToScreenPixels(previewHeight / 1.77778), height: previewHeight) } 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 = max(0.0, layout.size.height - previewSize.height - topInset) var layoutInputHeight = layout.inputHeight ?? 0.0 if self.stickerScreen != nil { layoutInputHeight = 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: layoutInputHeight, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, orientation: nil, isVisible: true, theme: defaultDarkPresentationTheme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, controller: { [weak self] in return self?.controller } ) if hasAppeared && !self.hasAppeared { self.hasAppeared = hasAppeared } let componentSize = self.componentHost.update( transition: transition, component: AnyComponent( MediaEditorScreenComponent( context: self.context, externalState: self.componentExternalState, isDisplayingTool: self.isDisplayingTool, isInteractingWithEntities: self.isInteractingWithEntities, isSavingAvailable: controller.isSavingAvailable, hasAppeared: self.hasAppeared, isDismissing: self.isDismissing && !self.isDismissBySwipeSuppressed, bottomSafeInset: layout.intrinsicInsets.bottom, mediaEditor: self.mediaEditorPromise.get(), privacy: controller.state.privacy, selectedEntity: self.isDisplayingTool ? nil : self.entitiesView.selectedEntityView?.entity, entityViewForEntity: { [weak self] entity in if let self { return self.entitiesView.getView(for: entity.uuid) } else { return nil } }, openDrawing: { [weak self] mode in if let self { if self.entitiesView.hasSelection { self.entitiesView.selectEntity(nil) } switch mode { case .sticker: self.mediaEditor?.maybePauseVideo() let controller = StickerPickerScreen(context: self.context, inputData: self.stickerPickerInputData.get(), defaultToEmoji: self.defaultToEmoji, hasGifs: true) controller.completion = { [weak self] content in if let self { if let content { if case let .file(file, _) = content { if file.isCustomEmoji { self.defaultToEmoji = true } else { self.defaultToEmoji = false } } let stickerEntity = DrawingStickerEntity(content: content) let scale: CGFloat if case .image = content { scale = 2.5 } else if case .video = content { scale = 2.5 } else { scale = 1.33 } self.interaction?.insertEntity(stickerEntity, scale: scale) self.hasAnyChanges = true self.controller?.isSavingAvailable = true self.controller?.requestLayout(transition: .immediate) } self.stickerScreen = nil self.mediaEditor?.maybeUnpauseVideo() } return true } controller.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak controller] transition in if let self, let controller { let transitionFactor = controller.modalStyleOverlayTransitionFactor self.updateModalTransitionFactor(transitionFactor, transition: transition) } } controller.presentGallery = { [weak self] in if let self { self.stickerScreen = nil self.presentGallery() } } controller.presentLocationPicker = { [weak self, weak controller] in if let self { self.stickerScreen = nil controller?.dismiss(animated: true) self.presentLocationPicker() } } controller.presentAudioPicker = { [weak self, weak controller] in if let self { self.stickerScreen = nil controller?.dismiss(animated: true) self.presentAudioPicker() } } controller.addReaction = { [weak self, weak controller] in if let self { self.addReaction() self.stickerScreen = nil controller?.dismiss(animated: true) } } controller.pushController = { [weak self] c in self?.controller?.push(c) } self.stickerScreen = controller self.controller?.present(controller, in: .current) case .text: self.mediaEditor?.maybePauseVideo() self.insertTextEntity() self.hasAnyChanges = true self.controller?.isSavingAvailable = true self.controller?.requestLayout(transition: .immediate) case .drawing: self.previousDrawingData = self.drawingView.drawingData self.previousDrawingEntities = self.entitiesView.entities self.interaction?.deactivate() let controller = DrawingScreen( context: self.context, sourceHint: .storyEditor, size: self.previewContainerView.frame.size, originalSize: storyDimensions, isVideo: self.mediaEditor?.sourceIsVideo ?? false, isAvatar: false, drawingView: self.drawingView, entitiesView: self.entitiesView, selectionContainerView: self.selectionContainerView, existingStickerPickerInputData: self.stickerPickerInputData ) controller.presentGallery = { [weak self] in if let self { self.presentGallery() } } controller.getCurrentImage = { [weak self] in return self?.interaction?.getCurrentImage() } controller.updateVideoPlayback = { [weak self] play in guard let self else { return } if play { self.mediaEditor?.play() } else { self.mediaEditor?.stop() } } self.drawingScreen = controller self.drawingView.isUserInteractionEnabled = true controller.requestDismiss = { [weak controller, weak self] in guard let self else { return } self.drawingScreen = nil controller?.animateOut({ controller?.dismiss() }) self.drawingView.isUserInteractionEnabled = false self.animateInFromTool() self.interaction?.reset() self.interaction?.activate() self.entitiesView.selectEntity(nil) self.drawingView.setup(withDrawing: self.previousDrawingData) self.entitiesView.setup(with: self.previousDrawingEntities ?? []) self.previousDrawingData = nil self.previousDrawingEntities = nil } controller.requestApply = { [weak controller, weak self] in guard let self else { return } self.drawingScreen = nil controller?.animateOut({ controller?.dismiss() }) self.drawingView.isUserInteractionEnabled = false self.animateInFromTool() self.interaction?.reset() if let result = controller?.generateDrawingResultData() { self.mediaEditor?.setDrawingAndEntities(data: result.data, image: result.drawingImage, entities: result.entities) } else { self.mediaEditor?.setDrawingAndEntities(data: nil, image: nil, entities: []) } self.interaction?.activate() self.entitiesView.selectEntity(nil) } self.controller?.present(controller, in: .current) self.animateOutToTool() } } }, openTools: { [weak self] in if let self, let mediaEditor = self.mediaEditor { if self.entitiesView.hasSelection { self.entitiesView.selectEntity(nil) } let controller = MediaToolsScreen(context: self.context, mediaEditor: mediaEditor, hiddenTools: !self.canEnhance ? [.enhance] : []) controller.dismissed = { [weak self] in if let self { self.animateInFromTool() } } self.controller?.present(controller, in: .window(.root)) self.animateOutToTool() } } ) ), environment: { environment }, forceUpdate: forceUpdate, containerSize: layout.size ) if let componentView = self.componentHost.view { if componentView.superview == nil { self.containerView.addSubview(componentView) componentView.clipsToBounds = true } transition.setFrame(view: componentView, frame: CGRect(origin: CGPoint(x: 0.0, y: self.dismissOffset), size: componentSize)) } let inputHeight = self.componentExternalState.derivedInputHeight let storyPreviewSize = self.storyPreview.update( transition: transition, component: AnyComponent( StoryPreviewComponent( context: self.context, caption: "" ) ), environment: {}, forceUpdate: false, containerSize: previewSize ) if let storyPreviewView = self.storyPreview.view { if storyPreviewView.superview == nil { storyPreviewView.alpha = 0.0 storyPreviewView.isUserInteractionEnabled = false self.previewContainerView.addSubview(storyPreviewView) } transition.setFrame(view: storyPreviewView, frame: CGRect(origin: CGPoint(x: 0.0, y: self.dismissOffset), size: storyPreviewSize)) } let enhanceValue = self.mediaEditor?.getToolValue(.enhance) as? Float ?? 0.0 let toolValueSize = self.toolValue.update( transition: transition, component: AnyComponent( ToolValueComponent( title: environment.strings.Story_Editor_Tool_Enhance, value: "\(Int(abs(enhanceValue) * 100.0))" ) ), environment: {}, forceUpdate: false, containerSize: CGSize(width: previewSize.width, height: 120.0) ) if let toolValueView = self.toolValue.view { if toolValueView.superview == nil { toolValueView.alpha = 0.0 toolValueView.isUserInteractionEnabled = false self.previewContainerView.addSubview(toolValueView) } transition.setFrame(view: toolValueView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((previewSize.width - toolValueSize.width) / 2.0), y: 88.0), size: toolValueSize)) transition.setAlpha(view: toolValueView, alpha: self.isEnhancing ? 1.0 : 0.0) } transition.setFrame(view: self.backgroundDimView, frame: CGRect(origin: .zero, size: layout.size)) transition.setAlpha(view: self.backgroundDimView, alpha: self.isDismissing && !self.isDismissBySwipeSuppressed ? 0.0 : 1.0) var bottomInputOffset: CGFloat = 0.0 if inputHeight > 0.0 { if self.stickerScreen == nil { if self.entitiesView.selectedEntityView != nil || self.isDisplayingTool { bottomInputOffset = inputHeight / 2.0 } else { bottomInputOffset = 0.0 } } } 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)) let previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - previewSize.width) / 2.0), y: topInset - bottomInputOffset + self.dismissOffset), size: previewSize) transition.setFrame(view: self.previewContainerView, frame: previewFrame) transition.setFrame(view: self.previewView, frame: CGRect(origin: .zero, size: previewSize)) let entitiesViewScale = previewSize.width / storyDimensions.width self.entitiesContainerView.transform = CGAffineTransformMakeScale(entitiesViewScale, entitiesViewScale) self.entitiesContainerView.frame = CGRect(origin: .zero, size: previewFrame.size) transition.setFrame(view: self.gradientView, frame: CGRect(origin: .zero, size: previewFrame.size)) transition.setFrame(view: self.drawingView, frame: CGRect(origin: .zero, size: self.entitiesView.bounds.size)) transition.setFrame(view: self.selectionContainerView, frame: CGRect(origin: .zero, size: previewFrame.size)) self.interaction?.containerLayoutUpdated(layout: layout, transition: transition) var layout = layout layout.intrinsicInsets.top = topInset controller.presentationContext.containerLayoutUpdated(layout, transition: transition.containedViewLayoutTransition) if isFirstTime { self.isHidden = true let _ = (self.readyValue.get() |> take(1)).start(next: { [weak self] _ in if let self { self.isHidden = false self.animateIn() } }) } } } var node: Node { return self.displayNode as! Node } public enum PIPPosition { case topLeft case topRight case bottomLeft case bottomRight func getPosition(_ size: CGSize) -> CGPoint { let topOffset = CGPoint(x: 267.0, y: 438.0) let bottomOffset = CGPoint(x: 267.0, y: 438.0) switch self { case .topLeft: return CGPoint(x: topOffset.x, y: topOffset.y) case .topRight: return CGPoint(x: size.width - topOffset.x, y: topOffset.y) case .bottomLeft: return CGPoint(x: bottomOffset.x, y: size.height - bottomOffset.y) case .bottomRight: return CGPoint(x: size.width - bottomOffset.x, y: size.height - bottomOffset.y) } } } public enum Subject { case image(UIImage, PixelDimensions, UIImage?, PIPPosition) case video(String, UIImage?, Bool, String?, UIImage?, PixelDimensions, Double, [(Bool, Double)], PIPPosition) case asset(PHAsset) case draft(MediaEditorDraft, Int64?) case message([MessageId]) var dimensions: PixelDimensions { switch self { case let .image(_, dimensions, _, _), let .video(_, _, _, _, _, dimensions, _, _, _): return dimensions case let .asset(asset): return PixelDimensions(width: Int32(asset.pixelWidth), height: Int32(asset.pixelHeight)) case let .draft(draft, _): return draft.dimensions case .message: return PixelDimensions(width: 1080, height: 1920) } } var editorSubject: MediaEditor.Subject { switch self { case let .image(image, dimensions, _, _): return .image(image, dimensions) case let .video(videoPath, transitionImage, mirror, additionalVideoPath, _, dimensions, duration, _, _): return .video(videoPath, transitionImage, mirror, additionalVideoPath, dimensions, duration) case let .asset(asset): return .asset(asset) case let .draft(draft, _): return .draft(draft) case let .message(messageIds): return .message(messageIds.first!) } } var isPhoto: Bool { return !self.isVideo } var isVideo: Bool { switch self { case .image: return false case .video: return true case let .asset(asset): return asset.mediaType == .video case let .draft(draft, _): return draft.isVideo case .message: return false } } } public enum MediaResult { public enum VideoResult { case imageFile(path: String) case videoFile(path: String) case asset(localIdentifier: String) } case image(image: UIImage, dimensions: PixelDimensions) case video(video: VideoResult, coverImage: UIImage?, values: MediaEditorValues, duration: Double, dimensions: PixelDimensions) } public struct Result { public let media: MediaResult? public let mediaAreas: [MediaArea] public let caption: NSAttributedString public let options: MediaEditorResultPrivacy public let stickers: [TelegramMediaFile] public let randomId: Int64 } let context: AccountContext let subject: Signal let isEditingStory: Bool fileprivate let customTarget: EnginePeer.Id? let forwardSource: (EnginePeer, EngineStoryItem)? fileprivate let initialCaption: NSAttributedString? fileprivate let initialPrivacy: EngineStoryPrivacy? fileprivate let initialMediaAreas: [MediaArea]? fileprivate let initialVideoPosition: Double? fileprivate let transitionIn: TransitionIn? fileprivate let transitionOut: (Bool, Bool?) -> TransitionOut? public var cancelled: (Bool) -> Void = { _ in } public var completion: (MediaEditorScreen.Result, @escaping (@escaping () -> Void) -> Void) -> Void = { _, _ in } public var dismissed: () -> Void = { } public var willDismiss: () -> Void = { } private var adminedChannels = Promise<[EnginePeer]>() private var closeFriends = Promise<[EnginePeer]>() private let storiesBlockedPeers: BlockedPeersContext private let hapticFeedback = HapticFeedback() private var audioSessionDisposable: Disposable? private let postingAvailabilityPromise = Promise() private var postingAvailabilityDisposable: Disposable? public init( context: AccountContext, subject: Signal, customTarget: EnginePeer.Id? = nil, isEditing: Bool = false, forwardSource: (EnginePeer, EngineStoryItem)? = nil, initialCaption: NSAttributedString? = nil, initialPrivacy: EngineStoryPrivacy? = nil, initialMediaAreas: [MediaArea]? = nil, initialVideoPosition: Double? = nil, transitionIn: TransitionIn?, transitionOut: @escaping (Bool, Bool?) -> TransitionOut?, completion: @escaping (MediaEditorScreen.Result, @escaping (@escaping () -> Void) -> Void) -> Void ) { self.context = context self.subject = subject self.customTarget = customTarget self.isEditingStory = isEditing self.forwardSource = forwardSource self.initialCaption = initialCaption self.initialPrivacy = initialPrivacy self.initialMediaAreas = initialMediaAreas self.initialVideoPosition = initialVideoPosition self.transitionIn = transitionIn self.transitionOut = transitionOut self.completion = completion self.storiesBlockedPeers = BlockedPeersContext(account: context.account, subject: .stories) if let transitionIn, case .camera = transitionIn { self.isSavingAvailable = true } super.init(navigationBarPresentationData: nil) self.automaticallyControlPresentationContextLayout = false self.navigationPresentation = .flatModal self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.statusBar.statusBarStyle = .White if isEditing { if let initialPrivacy { self.state.privacy = MediaEditorResultPrivacy( sendAsPeerId: nil, privacy: initialPrivacy, timeout: 86400, isForwardingDisabled: false, pin: false ) } } else { let _ = combineLatest( queue: Queue.mainQueue(), mediaEditorStoredState(engine: self.context.engine), self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) ).start(next: { [weak self] state, peer in if let self, var privacy = state?.privacy { if case let .user(user) = peer, !user.isPremium && privacy.timeout != 86400 { privacy = MediaEditorResultPrivacy(sendAsPeerId: nil, privacy: privacy.privacy, timeout: 86400, isForwardingDisabled: privacy.isForwardingDisabled, pin: privacy.pin) } else { privacy = MediaEditorResultPrivacy(sendAsPeerId: nil, privacy: privacy.privacy, timeout: privacy.timeout, isForwardingDisabled: privacy.isForwardingDisabled, pin: privacy.pin) } self.state.privacy = privacy } }) } updateStorySources(engine: self.context.engine) updateStoryDrafts(engine: self.context.engine) } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.exportDisposable.dispose() self.audioSessionDisposable?.dispose() self.postingAvailabilityDisposable?.dispose() } fileprivate func setupAudioSessionIfNeeded() { guard let subject = self.node.subject else { return } var needsAudioSession = false var checkPostingAvailability = false if self.forwardSource != nil { needsAudioSession = true checkPostingAvailability = true } if self.isEditingStory { needsAudioSession = true } if case .message = subject { needsAudioSession = true checkPostingAvailability = true } if needsAudioSession { 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()) }) } if checkPostingAvailability { self.postingAvailabilityPromise.set(self.context.engine.messages.checkStoriesUploadAvailability(target: .myStories)) } } fileprivate func checkPostingAvailability() { guard self.postingAvailabilityDisposable == nil else { return } self.postingAvailabilityDisposable = (self.postingAvailabilityPromise.get() |> deliverOnMainQueue).start(next: { [weak self] availability in guard let self, availability != .available else { return } let subject: PremiumLimitSubject switch availability { case .expiringLimit: subject = .expiringStories case .weeklyLimit: subject = .storiesWeekly case .monthlyLimit: subject = .storiesMonthly default: subject = .expiringStories } let context = self.context var replaceImpl: ((ViewController) -> Void)? let controller = self.context.sharedContext.makePremiumLimitController(context: self.context, subject: subject, count: 10, forceDark: true, cancel: { [weak self] in self?.requestDismiss(saveDraft: false, animated: true) }, action: { [weak self] in let controller = context.sharedContext.makePremiumIntroController(context: context, source: .stories, forceDark: true, dismissed: { [weak self] in guard let self else { return } let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self else { return } let isPremium = peer?.isPremium ?? false if !isPremium { self.requestDismiss(saveDraft: false, animated: true) } }) }) replaceImpl?(controller) return true }) replaceImpl = { [weak controller] c in controller?.replace(with: c) } if let navigationController = self.context.sharedContext.mainWindow?.viewController as? NavigationController { navigationController.pushViewController(controller) } }) } override public func loadDisplayNode() { self.displayNode = Node(controller: self) super.displayNodeDidLoad() let dropInteraction = UIDropInteraction(delegate: self) self.displayNode.view.addInteraction(dropInteraction) Queue.mainQueue().after(0.4) { self.adminedChannels.set(.single([]) |> then(self.context.engine.peers.channelsForStories())) self.closeFriends.set(self.context.engine.data.get(TelegramEngine.EngineData.Item.Contacts.CloseFriends())) } } fileprivate var isEmbeddedEditor: Bool { return self.isEditingStory || self.forwardSource != nil } func openPrivacySettings(_ privacy: MediaEditorResultPrivacy? = nil, completion: @escaping () -> Void = {}) { self.node.mediaEditor?.maybePauseVideo() self.hapticFeedback.impact(.light) let privacy = privacy ?? self.state.privacy let text = self.getCaption().string let mentions = generateTextEntities(text, enabledTypes: [.mention], currentEntities: []).map { (text as NSString).substring(with: NSRange(location: $0.range.lowerBound + 1, length: $0.range.upperBound - $0.range.lowerBound - 1)) } let stateContext = ShareWithPeersScreen.StateContext( context: self.context, subject: .stories(editing: false), editing: false, initialPeerIds: Set(privacy.privacy.additionallyIncludePeers), closeFriends: self.closeFriends.get(), adminedChannels: self.adminedChannels.get(), blockedPeersContext: self.storiesBlockedPeers ) let _ = (stateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in guard let self else { return } let sendAsPeerId = privacy.sendAsPeerId let initialPrivacy = privacy.privacy let timeout = privacy.timeout let controller = ShareWithPeersScreen( context: self.context, initialPrivacy: initialPrivacy, initialSendAsPeerId: self.customTarget, allowScreenshots: !privacy.isForwardingDisabled, pin: privacy.pin, timeout: privacy.timeout, mentions: mentions, stateContext: stateContext, completion: { [weak self] sendAsPeerId, privacy, allowScreenshots, pin, _, completed in guard let self else { return } self.state.privacy = MediaEditorResultPrivacy( sendAsPeerId: sendAsPeerId, privacy: privacy, timeout: timeout, isForwardingDisabled: !allowScreenshots, pin: pin ) if completed { completion() } }, editCategory: { [weak self] privacy, allowScreenshots, pin in guard let self else { return } self.openEditCategory(privacy: privacy, isForwardingDisabled: !allowScreenshots, pin: pin, blockedPeers: false, completion: { [weak self] privacy in guard let self else { return } self.openPrivacySettings(MediaEditorResultPrivacy( sendAsPeerId: sendAsPeerId, privacy: privacy, timeout: timeout, isForwardingDisabled: !allowScreenshots, pin: pin ), completion: completion) }) }, editBlockedPeers: { [weak self] privacy, allowScreenshots, pin in guard let self else { return } self.openEditCategory(privacy: privacy, isForwardingDisabled: !allowScreenshots, pin: pin, blockedPeers: true, completion: { [weak self] privacy in guard let self else { return } self.openPrivacySettings(MediaEditorResultPrivacy( sendAsPeerId: sendAsPeerId, privacy: privacy, timeout: timeout, isForwardingDisabled: !allowScreenshots, pin: pin ), completion: completion) }) } ) controller.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak controller] transition in if let self, let controller { let transitionFactor = controller.modalStyleOverlayTransitionFactor self.node.updateModalTransitionFactor(transitionFactor, transition: transition) } } controller.dismissed = { self.node.mediaEditor?.play() } self.push(controller) }) } private func openEditCategory(privacy: EngineStoryPrivacy, isForwardingDisabled: Bool, pin: Bool, blockedPeers: Bool, completion: @escaping (EngineStoryPrivacy) -> Void) { let subject: ShareWithPeersScreen.StateContext.Subject if blockedPeers { subject = .chats(blocked: true) } else if privacy.base == .nobody { subject = .chats(blocked: false) } else { subject = .contacts(base: privacy.base) } let stateContext = ShareWithPeersScreen.StateContext( context: self.context, subject: subject, editing: false, initialPeerIds: Set(privacy.additionallyIncludePeers), blockedPeersContext: self.storiesBlockedPeers ) let _ = (stateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in guard let self else { return } let controller = ShareWithPeersScreen( context: self.context, initialPrivacy: privacy, allowScreenshots: !isForwardingDisabled, pin: pin, stateContext: stateContext, completion: { [weak self] _, result, isForwardingDisabled, pin, peers, completed in guard let self, completed else { return } if blockedPeers { let _ = self.storiesBlockedPeers.updatePeerIds(result.additionallyIncludePeers).start() completion(privacy) } else if case .closeFriends = privacy.base { let _ = self.context.engine.privacy.updateCloseFriends(peerIds: result.additionallyIncludePeers).start() self.closeFriends.set(.single(peers)) completion(EngineStoryPrivacy(base: .closeFriends, additionallyIncludePeers: [])) } else { completion(result) } }, editCategory: { _, _, _ in }, editBlockedPeers: { _, _, _ in } ) controller.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak controller] transition in if let self, let controller { let transitionFactor = controller.modalStyleOverlayTransitionFactor self.node.updateModalTransitionFactor(transitionFactor, transition: transition) } } controller.dismissed = { self.node.mediaEditor?.play() } self.push(controller) }) } func presentEntityShortcuts(sourceView: UIView, gesture: ContextGesture) { self.hapticFeedback.impact(.light) let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkPresentationTheme) var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaEditor_Shortcut_Image, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Image"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) self?.node.presentGallery() }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaEditor_Shortcut_Location, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Media Editor/LocationSmall"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) self?.node.presentLocationPicker() }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaEditor_Shortcut_Reaction, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reactions"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) self?.node.addReaction() }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaEditor_Shortcut_Audio, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Media Editor/AudioSmall"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in a(.default) self?.node.presentAudioPicker() }))) let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) self.present(contextController, in: .window(.root)) } func presentTimeoutSetup(sourceView: UIView, gesture: ContextGesture?) { self.hapticFeedback.impact(.light) let hasPremium = self.context.isPremium let presentationData = self.context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkPresentationTheme) let title = presentationData.strings.Story_Editor_ExpirationText let currentValue = self.state.privacy.timeout let emptyAction: ((ContextMenuActionItem.Action) -> Void)? = nil let updateTimeout: (Int?) -> Void = { [weak self] timeout in guard let self else { return } self.state.privacy = MediaEditorResultPrivacy( sendAsPeerId: self.state.privacy.sendAsPeerId, privacy: self.state.privacy.privacy, timeout: timeout ?? 86400, isForwardingDisabled: self.state.privacy.isForwardingDisabled, pin: self.state.privacy.pin ) } var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: title, textLayout: .multiline, textFont: .small, icon: { _ in return nil }, action: emptyAction))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Story_Editor_ExpirationValue(6), icon: { theme in if !hasPremium { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) } else { return currentValue == 3600 * 6 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil } }, action: { [weak self] _, a in a(.default) if hasPremium { updateTimeout(3600 * 6) } else { self?.presentTimeoutPremiumSuggestion() } }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Story_Editor_ExpirationValue(12), icon: { theme in if !hasPremium { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) } else { return currentValue == 3600 * 12 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil } }, action: { [weak self] _, a in a(.default) if hasPremium { updateTimeout(3600 * 12) } else { self?.presentTimeoutPremiumSuggestion() } }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Story_Editor_ExpirationValue(24), icon: { theme in return currentValue == 86400 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil }, action: { _, a in a(.default) updateTimeout(86400) }))) items.append(.action(ContextMenuActionItem(text: presentationData.strings.Story_Editor_ExpirationValue(48), icon: { theme in if !hasPremium { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) } else { return currentValue == 86400 * 2 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil } }, action: { [weak self] _, a in a(.default) if hasPremium { updateTimeout(86400 * 2) } else { self?.presentTimeoutPremiumSuggestion() } }))) let contextController = ContextController(presentationData: presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) self.present(contextController, in: .window(.root)) } fileprivate func presentTimeoutPremiumSuggestion() { self.dismissAllTooltips() let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let text = presentationData.strings.Story_Editor_TooltipPremiumExpiration let controller = UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: true, title: nil, text: text, customUndoText: nil), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { [weak self] action in if case .info = action, let self { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .storiesExpirationDurations, forceDark: true, dismissed: nil) self.push(controller) } return false }) self.present(controller, in: .current) } fileprivate func presentReactionPremiumSuggestion() { self.hapticFeedback.impact(.light) self.dismissAllTooltips() let context = self.context let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: true)) |> deliverOnMainQueue).start(next: { [weak self] premiumLimits in guard let self else { return } let presentationData = context.sharedContext.currentPresentationData.with { $0 } let limit = context.userLimits.maxStoriesSuggestedReactions let content: UndoOverlayContent if context.isPremium { let value = presentationData.strings.Story_Editor_TooltipPremiumReactionLimitValue(limit) content = .info( title: presentationData.strings.Story_Editor_TooltipReachedReactionLimitTitle, text: presentationData.strings.Story_Editor_TooltipReachedReactionLimitText(value).string, timeout: nil, customUndoText: nil ) } else { let value = presentationData.strings.Story_Editor_TooltipPremiumReactionLimitValue(premiumLimits.maxStoriesSuggestedReactions) content = .premiumPaywall( title: presentationData.strings.Story_Editor_TooltipPremiumReactionLimitTitle, text: presentationData.strings.Story_Editor_TooltipPremiumReactionLimitText(value).string, customUndoText: nil, timeout: nil, linkAction: nil ) } let controller = UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: true, position: .top, animateInAsReplacement: false, action: { [weak self] action in if case .info = action, let self { if let stickerScreen = self.node.stickerScreen { self.node.stickerScreen = nil stickerScreen.dismiss(animated: true) } let controller = context.sharedContext.makePremiumIntroController(context: context, source: .storiesSuggestedReactions, forceDark: true, dismissed: nil) self.push(controller) } return true }) self.present(controller, in: .window(.root)) }) } fileprivate func presentUnavailableReactionPremiumSuggestion(file: TelegramMediaFile) { self.hapticFeedback.error() self.dismissAllTooltips() let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.Story_Editor_TooltipPremiumReaction, undoText: nil, customAction: nil), elevatedLayout: true, position: .top, animateInAsReplacement: false, blurred: true, action: { [weak self] action in if case .info = action, let self { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .storiesExpirationDurations, forceDark: true, dismissed: nil) self.push(controller) } return false }) self.present(controller, in: .window(.root)) } fileprivate func presentCaptionLimitPremiumSuggestion(isPremium: Bool) { self.dismissAllTooltips() let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let title = presentationData.strings.Story_Editor_TooltipPremiumCaptionLimitTitle let text = presentationData.strings.Story_Editor_TooltipPremiumCaptionLimitText let controller = UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_read", scale: 0.25, colors: [:], title: title, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { [weak self] action in if case .info = action, let self { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .stories, forceDark: true, dismissed: { }) self.push(controller) } return false }) self.present(controller, in: .current) } fileprivate func presentCaptionEntitiesPremiumSuggestion() { self.dismissAllTooltips() let context = self.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let text = presentationData.strings.Story_Editor_TooltipPremiumCaptionEntities let controller = UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: text), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { [weak self] action in if case .info = action, let self { let controller = context.sharedContext.makePremiumIntroController(context: context, source: .storiesFormatting, forceDark: true, dismissed: nil) self.push(controller) } return false } ) self.present(controller, in: .current) } func maybePresentDiscardAlert() { self.hapticFeedback.impact(.light) if !self.isEligibleForDraft() { self.requestDismiss(saveDraft: false, animated: true) return } let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } let title: String let save: String if case .draft = self.node.actualSubject { title = presentationData.strings.Story_Editor_DraftDiscardDraft save = presentationData.strings.Story_Editor_DraftKeepDraft } else { title = presentationData.strings.Story_Editor_DraftDiscardMedia save = presentationData.strings.Story_Editor_DraftKeepMedia } let controller = textAlertController( context: self.context, forceTheme: defaultDarkPresentationTheme, title: title, text: presentationData.strings.Story_Editor_DraftDiscaedText, actions: [ TextAlertAction(type: .destructiveAction, title: presentationData.strings.Story_Editor_DraftDiscard, action: { [weak self] in if let self { self.requestDismiss(saveDraft: false, animated: true) } }), TextAlertAction(type: .genericAction, title: save, action: { [weak self] in if let self { self.requestDismiss(saveDraft: true, animated: true) } }), TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: { }) ], actionLayout: .vertical ) self.present(controller, in: .window(.root)) } func requestDismiss(saveDraft: Bool, animated: Bool) { self.dismissAllTooltips() var showDraftTooltip = saveDraft if let subject = self.node.actualSubject, case .draft = subject { showDraftTooltip = false } if saveDraft { self.saveDraft(id: nil) } else { if case let .draft(draft, id) = self.node.actualSubject, id == nil { removeStoryDraft(engine: self.context.engine, path: draft.path, delete: true) } } if let mediaEditor = self.node.mediaEditor { mediaEditor.invalidate() } self.node.entitiesView.invalidate() self.cancelled(showDraftTooltip) self.willDismiss() self.node.animateOut(finished: false, saveDraft: saveDraft, completion: { [weak self] in self?.dismiss() self?.dismissed() }) } func getCaption() -> NSAttributedString { return (self.node.componentHost.view as? MediaEditorScreenComponent.View)?.getInputText() ?? NSAttributedString() } fileprivate func checkCaptionLimit() -> Bool { let caption = self.getCaption() if caption.length > self.context.userLimits.maxStoryCaptionLength { let _ = (self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in if let self { self.presentCaptionLimitPremiumSuggestion(isPremium: peer?.isPremium ?? false) } }) return false } return true } func checkIfCompletionIsAllowed() -> Bool { if !self.context.isPremium { let entities = self.node.entitiesView.entities.filter { !($0 is DrawingMediaEntity) } for entity in entities { if let stickerEntity = entity as? DrawingStickerEntity, case let .file(file, type) = stickerEntity.content, case let .reaction(reaction, _) = type, case .custom = reaction { self.presentUnavailableReactionPremiumSuggestion(file: file) return false } } } return true } private var didComplete = false func requestCompletion(animated: Bool) { guard let mediaEditor = self.node.mediaEditor, let subject = self.node.subject, let actualSubject = self.node.actualSubject, !self.didComplete else { return } self.didComplete = true self.dismissAllTooltips() mediaEditor.stop() mediaEditor.invalidate() self.node.entitiesView.invalidate() let context = self.context if let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(0.0, transition: .immediate) } let entities = self.node.entitiesView.entities.filter { !($0 is DrawingMediaEntity) } let codableEntities = DrawingEntitiesView.encodeEntities(entities, entitiesView: self.node.entitiesView) mediaEditor.setDrawingAndEntities(data: nil, image: mediaEditor.values.drawing, entities: codableEntities) var caption = self.getCaption() caption = convertMarkdownToAttributes(caption) var hasEntityChanges = false let randomId: Int64 if case let .draft(_, id) = actualSubject, let id { randomId = id } else { randomId = Int64.random(in: .min ... .max) } var mediaAreas: [MediaArea] = [] if case let .draft(draft, _) = actualSubject { if draft.values.entities != codableEntities { hasEntityChanges = true } } else { mediaAreas = self.initialMediaAreas ?? [] } var stickers: [TelegramMediaFile] = [] for entity in codableEntities { switch entity { case let .sticker(stickerEntity): if case let .file(file, fileType) = stickerEntity.content, case .sticker = fileType { stickers.append(file) } case let .text(textEntity): if let subEntities = textEntity.renderSubEntities { for entity in subEntities { if let stickerEntity = entity as? DrawingStickerEntity, case let .file(file, fileType) = stickerEntity.content, case .sticker = fileType { stickers.append(file) } } } default: break } if let mediaArea = entity.mediaArea { mediaAreas.append(mediaArea) } } if self.isEmbeddedEditor && !(self.node.hasAnyChanges || hasEntityChanges) { self.completion(MediaEditorScreen.Result(media: nil, mediaAreas: [], caption: caption, options: self.state.privacy, stickers: stickers, randomId: randomId), { [weak self] finished in self?.node.animateOut(finished: true, saveDraft: false, completion: { [weak self] in self?.dismiss() Queue.mainQueue().justDispatch { finished() } }) }) return } if !self.isEditingStory { let privacy = self.state.privacy let _ = updateMediaEditorStoredStateInteractively(engine: self.context.engine, { current in if let current { return current.withUpdatedPrivacy(privacy) } else { return MediaEditorStoredState(privacy: privacy, textSettings: nil) } }).start() } if mediaEditor.resultIsVideo { self.saveDraft(id: randomId) var firstFrame: Signal<(UIImage?, UIImage?), NoError> let firstFrameTime = CMTime(seconds: mediaEditor.values.videoTrimRange?.lowerBound ?? 0.0, preferredTimescale: CMTimeScale(60)) let videoResult: Signal var videoIsMirrored = false let duration: Double switch subject { case let .image(image, _, _, _): let tempImagePath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).jpg" if let data = image.jpegData(compressionQuality: 0.85) { try? data.write(to: URL(fileURLWithPath: tempImagePath)) } videoResult = .single(.imageFile(path: tempImagePath)) duration = 5.0 firstFrame = .single((image, nil)) case let .video(path, _, mirror, additionalPath, _, _, durationValue, _, _): videoIsMirrored = mirror videoResult = .single(.videoFile(path: path)) if let videoTrimRange = mediaEditor.values.videoTrimRange { duration = videoTrimRange.upperBound - videoTrimRange.lowerBound } else { duration = durationValue } var additionalPath = additionalPath if additionalPath == nil, let valuesAdditionalPath = mediaEditor.values.additionalVideoPath { additionalPath = valuesAdditionalPath } firstFrame = Signal<(UIImage?, UIImage?), NoError> { subscriber in let avAsset = AVURLAsset(url: URL(fileURLWithPath: path)) let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, cgImage, _, _, _ in if let cgImage { if let additionalPath { let avAsset = AVURLAsset(url: URL(fileURLWithPath: additionalPath)) let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, additionalCGImage, _, _, _ in if let additionalCGImage { subscriber.putNext((UIImage(cgImage: cgImage), UIImage(cgImage: additionalCGImage))) subscriber.putCompletion() } else { subscriber.putNext((UIImage(cgImage: cgImage), nil)) subscriber.putCompletion() } }) } else { subscriber.putNext((UIImage(cgImage: cgImage), nil)) subscriber.putCompletion() } } }) return ActionDisposable { avAssetGenerator.cancelAllCGImageGeneration() } } case let .asset(asset): videoResult = .single(.asset(localIdentifier: asset.localIdentifier)) if asset.mediaType == .video { if let videoTrimRange = mediaEditor.values.videoTrimRange { duration = videoTrimRange.upperBound - videoTrimRange.lowerBound } else { duration = min(asset.duration, storyMaxVideoDuration) } } else { duration = 5.0 } var additionalPath: String? if let valuesAdditionalPath = mediaEditor.values.additionalVideoPath { additionalPath = valuesAdditionalPath } firstFrame = Signal<(UIImage?, UIImage?), NoError> { subscriber in if asset.mediaType == .video { PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in if let avAsset { let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, cgImage, _, _, _ in if let cgImage { if let additionalPath { let avAsset = AVURLAsset(url: URL(fileURLWithPath: additionalPath)) let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, additionalCGImage, _, _, _ in if let additionalCGImage { subscriber.putNext((UIImage(cgImage: cgImage), UIImage(cgImage: additionalCGImage))) subscriber.putCompletion() } else { subscriber.putNext((UIImage(cgImage: cgImage), nil)) subscriber.putCompletion() } }) } else { subscriber.putNext((UIImage(cgImage: cgImage), nil)) subscriber.putCompletion() } } }) } } } else { let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in if let image { if let additionalPath { let avAsset = AVURLAsset(url: URL(fileURLWithPath: additionalPath)) let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, additionalCGImage, _, _, _ in if let additionalCGImage { subscriber.putNext((image, UIImage(cgImage: additionalCGImage))) subscriber.putCompletion() } else { subscriber.putNext((image, nil)) subscriber.putCompletion() } }) } else { subscriber.putNext((image, nil)) subscriber.putCompletion() } } } } return EmptyDisposable } case let .draft(draft, _): let draftPath = draft.fullPath(engine: context.engine) if draft.isVideo { videoResult = .single(.videoFile(path: draftPath)) if let videoTrimRange = mediaEditor.values.videoTrimRange { duration = videoTrimRange.upperBound - videoTrimRange.lowerBound } else { duration = min(draft.duration ?? 5.0, storyMaxVideoDuration) } firstFrame = Signal<(UIImage?, UIImage?), NoError> { subscriber in let avAsset = AVURLAsset(url: URL(fileURLWithPath: draftPath)) let avAssetGenerator = AVAssetImageGenerator(asset: avAsset) avAssetGenerator.appliesPreferredTrackTransform = true avAssetGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: firstFrameTime)], completionHandler: { _, cgImage, _, _, _ in if let cgImage { subscriber.putNext((UIImage(cgImage: cgImage), nil)) subscriber.putCompletion() } }) return ActionDisposable { avAssetGenerator.cancelAllCGImageGeneration() } } } else { videoResult = .single(.imageFile(path: draftPath)) duration = 5.0 if let image = UIImage(contentsOfFile: draftPath) { firstFrame = .single((image, nil)) } else { firstFrame = .single((UIImage(), nil)) } } case let .message(messages): let isNightTheme = mediaEditor.values.nightTheme let wallpaper = getChatWallpaperImage(context: self.context, messageId: messages.first!) |> map { _, image, nightImage -> UIImage? in if isNightTheme { return nightImage ?? image } else { return image } } videoResult = wallpaper |> mapToSignal { image in if let image { let tempImagePath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).jpg" if let data = image.jpegData(compressionQuality: 0.85) { try? data.write(to: URL(fileURLWithPath: tempImagePath)) } return .single(.imageFile(path: tempImagePath)) } else { return .complete() } } firstFrame = wallpaper |> map { image in return (image, nil) } duration = 5.0 } let _ = combineLatest(queue: Queue.mainQueue(), firstFrame, videoResult) .start(next: { [weak self] images, videoResult in if let self { let (image, additionalImage) = images var currentImage = mediaEditor.resultImage if let image { mediaEditor.replaceSource(image, additionalImage: additionalImage, time: firstFrameTime, mirror: true) if let updatedImage = mediaEditor.getResultImage(mirror: videoIsMirrored) { currentImage = updatedImage } } var inputImage: UIImage if let currentImage { inputImage = currentImage } else if let image { inputImage = image } else { inputImage = UIImage() } makeEditorImageComposition(context: self.node.ciContext, postbox: self.context.account.postbox, inputImage: inputImage, dimensions: storyDimensions, values: mediaEditor.values, time: firstFrameTime, textScale: 2.0, completion: { [weak self] coverImage in if let self { Logger.shared.log("MediaEditor", "Completed with video \(videoResult)") self.completion(MediaEditorScreen.Result(media: .video(video: videoResult, coverImage: coverImage, values: mediaEditor.values, duration: duration, dimensions: mediaEditor.values.resultDimensions), mediaAreas: mediaAreas, caption: caption, options: self.state.privacy, stickers: stickers, randomId: randomId), { [weak self] finished in self?.node.animateOut(finished: true, saveDraft: false, completion: { [weak self] in self?.dismiss() Queue.mainQueue().justDispatch { finished() } }) }) } }) } }) if case let .draft(draft, id) = actualSubject, id == nil { removeStoryDraft(engine: self.context.engine, path: draft.path, delete: false) } } else { if let image = mediaEditor.resultImage { self.saveDraft(id: randomId) makeEditorImageComposition(context: self.node.ciContext, postbox: self.context.account.postbox, inputImage: image, dimensions: storyDimensions, values: mediaEditor.values, time: .zero, textScale: 2.0, completion: { [weak self] resultImage in if let self, let resultImage { Logger.shared.log("MediaEditor", "Completed with image \(resultImage)") self.completion(MediaEditorScreen.Result(media: .image(image: resultImage, dimensions: PixelDimensions(resultImage.size)), mediaAreas: mediaAreas, caption: caption, options: self.state.privacy, stickers: stickers, randomId: randomId), { [weak self] finished in self?.node.animateOut(finished: true, saveDraft: false, completion: { [weak self] in self?.dismiss() Queue.mainQueue().justDispatch { finished() } }) }) if case let .draft(draft, id) = actualSubject, id == nil { removeStoryDraft(engine: self.context.engine, path: draft.path, delete: true) } } }) } } } private var videoExport: MediaEditorVideoExport? private var exportDisposable = MetaDisposable() fileprivate var isSavingAvailable = false private var previousSavedValues: MediaEditorValues? func requestSave() { let context = self.context DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in context.sharedContext.presentGlobalController(c, a) }, openSettings: context.sharedContext.applicationBindings.openSettings, { [weak self] authorized in if !authorized { return } self?.performSave() }) } private func performSave() { guard let mediaEditor = self.node.mediaEditor, let subject = self.node.subject, self.isSavingAvailable else { return } let context = self.context let entities = self.node.entitiesView.entities.filter { !($0 is DrawingMediaEntity) } let codableEntities = DrawingEntitiesView.encodeEntities(entities, entitiesView: self.node.entitiesView) mediaEditor.setDrawingAndEntities(data: nil, image: mediaEditor.values.drawing, entities: codableEntities) self.hapticFeedback.impact(.light) self.previousSavedValues = mediaEditor.values self.isSavingAvailable = false self.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut)) let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4" let saveToPhotos: (String, Bool) -> Void = { path, isVideo in PHPhotoLibrary.shared().performChanges({ if isVideo { if let _ = try? FileManager.default.copyItem(atPath: path, toPath: tempVideoPath) { PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: path)) } } else { if let fileData = try? Data(contentsOf: URL(fileURLWithPath: path)) { PHAssetCreationRequest.forAsset().addResource(with: .photo, data: fileData, options: nil) } } }, completionHandler: { _, error in if let error = error { print("\(error)") } let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) }) } if mediaEditor.resultIsVideo { mediaEditor.maybePauseVideo() self.node.entitiesView.pause() let exportSubject: Signal switch subject { case let .video(path, _, _, _, _, _, _, _, _): let asset = AVURLAsset(url: NSURL(fileURLWithPath: path) as URL) exportSubject = .single(.video(asset: asset, isStory: true)) case let .image(image, _, _, _): exportSubject = .single(.image(image: image)) case let .asset(asset): exportSubject = Signal { subscriber in if asset.mediaType == .video { PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in if let avAsset { subscriber.putNext(.video(asset: avAsset, isStory: true)) subscriber.putCompletion() } } } else { let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in if let image { subscriber.putNext(.image(image: image)) subscriber.putCompletion() } } } return EmptyDisposable } case let .draft(draft, _): if draft.isVideo { let asset = AVURLAsset(url: NSURL(fileURLWithPath: draft.fullPath(engine: context.engine)) as URL) exportSubject = .single(.video(asset: asset, isStory: true)) } else { if let image = UIImage(contentsOfFile: draft.fullPath(engine: context.engine)) { exportSubject = .single(.image(image: image)) } else { fatalError() } } case let .message(messages): let isNightTheme = mediaEditor.values.nightTheme exportSubject = getChatWallpaperImage(context: self.context, messageId: messages.first!) |> mapToSignal { _, image, nightImage -> Signal in if isNightTheme { let effectiveImage = nightImage ?? image return effectiveImage.flatMap({ .single(.image(image: $0)) }) ?? .complete() } else { return image.flatMap({ .single(.image(image: $0)) }) ?? .complete() } } } let _ = exportSubject.start(next: { [weak self] exportSubject in guard let self else { return } var duration: Double = 0.0 if case let .video(video, _) = exportSubject { duration = video.duration.seconds } let configuration = recommendedVideoExportConfiguration(values: mediaEditor.values, duration: duration, forceFullHd: true, frameRate: 60.0) let outputPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).mp4" let videoExport = MediaEditorVideoExport(postbox: self.context.account.postbox, subject: exportSubject, configuration: configuration, outputPath: outputPath, textScale: 2.0) self.videoExport = videoExport videoExport.start() self.exportDisposable.set((videoExport.status |> deliverOnMainQueue).start(next: { [weak self] status in if let self { switch status { case .completed: self.videoExport = nil saveToPhotos(outputPath, true) self.node.presentSaveTooltip() if let mediaEditor = self.node.mediaEditor, mediaEditor.maybeUnpauseVideo() { self.node.entitiesView.play() } case let .progress(progress): if self.videoExport != nil { self.node.updateVideoExportProgress(progress) } case .failed: self.videoExport = nil if let mediaEditor = self.node.mediaEditor, mediaEditor.maybeUnpauseVideo() { self.node.entitiesView.play() } case .unknown: break } } })) }) } else { if let image = mediaEditor.resultImage { Queue.concurrentDefaultQueue().async { makeEditorImageComposition(context: self.node.ciContext, postbox: self.context.account.postbox, inputImage: image, dimensions: storyDimensions, values: mediaEditor.values, time: .zero, textScale: 2.0, completion: { resultImage in if let data = resultImage?.jpegData(compressionQuality: 0.8) { let outputPath = NSTemporaryDirectory() + "\(Int64.random(in: 0 ..< .max)).jpg" try? data.write(to: URL(fileURLWithPath: outputPath)) Queue.mainQueue().async { saveToPhotos(outputPath, false) } } }) } self.node.presentSaveTooltip() } } } fileprivate func cancelVideoExport() { if let videoExport = self.videoExport { self.previousSavedValues = nil videoExport.cancel() self.videoExport = nil self.exportDisposable.set(nil) self.node.mediaEditor?.play() self.node.entitiesView.play() } } public func updateEditProgress(_ progress: Float, cancel: @escaping () -> Void) { self.node.updateEditProgress(progress, cancel: cancel) } fileprivate func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } if let controller = controller as? UndoOverlayController { controller.dismiss() } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } if let controller = controller as? UndoOverlayController { controller.dismiss() } if let controller = controller as? SaveProgressScreen { controller.dismiss() } return true }) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: Transition(transition)) } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool { return session.hasItemsConforming(toTypeIdentifiers: [kUTTypeImage as String]) } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal { let operation: UIDropOperation operation = .copy return UIDropProposal(operation: operation) } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) { session.loadObjects(ofClass: UIImage.self) { [weak self] imageItems in guard let self else { return } let images = imageItems as! [UIImage] if images.count == 1, let image = images.first, max(image.size.width, image.size.height) > 1.0 { self.node.interaction?.insertEntity(DrawingStickerEntity(content: .image(image, .sticker)), scale: 2.5) } } } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidExit session: UIDropSession) { } @available(iOSApplicationExtension 11.0, iOS 11.0, *) public func dropInteraction(_ interaction: UIDropInteraction, sessionDidEnd session: UIDropSession) { } } final class DoneButtonContentComponent: CombinedComponent { let backgroundColor: UIColor let icon: UIImage let title: String? init( backgroundColor: UIColor, icon: UIImage, title: String? ) { self.backgroundColor = backgroundColor self.icon = icon self.title = title } static func ==(lhs: DoneButtonContentComponent, rhs: DoneButtonContentComponent) -> Bool { if lhs.backgroundColor != rhs.backgroundColor { return false } if lhs.title != rhs.title { return false } return true } static var body: Body { let background = Child(RoundedRectangle.self) let icon = Child(Image.self) let text = Child(Text.self) return { context in let icon = icon.update( component: Image(image: context.component.icon, tintColor: .white, size: CGSize(width: 10.0, height: 16.0)), availableSize: CGSize(width: 180.0, height: 100.0), transition: .immediate ) let backgroundHeight: CGFloat = 33.0 var backgroundSize = CGSize(width: backgroundHeight, height: backgroundHeight) let textSpacing: CGFloat = 8.0 var title: _UpdatedChildComponent? var hideTitle = false if let titleText = context.component.title { title = text.update( component: Text( text: titleText, font: Font.with(size: 16.0, design: .round, weight: .semibold), color: .white ), availableSize: CGSize(width: 180.0, height: 100.0), transition: .immediate ) let updatedBackgroundWidth = backgroundSize.width + textSpacing + title!.size.width if updatedBackgroundWidth < 126.0 { backgroundSize.width = updatedBackgroundWidth } else { hideTitle = true } } let background = background.update( component: RoundedRectangle(color: context.component.backgroundColor, cornerRadius: backgroundHeight / 2.0), availableSize: backgroundSize, transition: .immediate ) context.add(background .position(CGPoint(x: backgroundSize.width / 2.0, y: backgroundSize.height / 2.0)) .cornerRadius(min(backgroundSize.width, backgroundSize.height) / 2.0) .clipsToBounds(true) ) if let title { context.add(title .position(CGPoint(x: title.size.width / 2.0 + 15.0, y: backgroundHeight / 2.0)) .opacity(hideTitle ? 0.0 : 1.0) ) } context.add(icon .position(CGPoint(x: background.size.width - 16.0, y: backgroundSize.height / 2.0)) ) return backgroundSize } } } private final class HeaderContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceView: UIView var keepInPlace: Bool { return true } init(controller: ViewController, sourceView: UIView) { self.controller = controller self.sourceView = sourceView } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds, actionsPosition: .top) } } private final class ToolValueComponent: Component { typealias EnvironmentType = Empty let title: String let value: String init( title: String, value: String ) { self.title = title self.value = value } static func ==(lhs: ToolValueComponent, rhs: ToolValueComponent) -> Bool { if lhs.title != rhs.title { return false } if lhs.value != rhs.value { return false } return true } public final class View: UIView { private let title = ComponentView() private let value = ComponentView() private let hapticFeedback = HapticFeedback() private var component: ToolValueComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = .clear } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ToolValueComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let previousValue = self.component?.value self.component = component self.state = state let titleSize = self.title.update( transition: .immediate, component: AnyComponent(Text( text: component.title, font: Font.light(34.0), color: .white )), environment: {}, containerSize: CGSize(width: 180.0, height: 44.0) ) let titleFrame = CGRect( origin: CGPoint(x: floorToScreenPixels((availableSize.width - titleSize.width) / 2.0), y: 0.0), size: titleSize ) if let titleView = self.title.view { if titleView.superview == nil { setupButtonShadow(titleView, radius: 3.0) self.addSubview(titleView) } transition.setPosition(view: titleView, position: titleFrame.center) titleView.bounds = CGRect(origin: .zero, size: titleFrame.size) } let valueSize = self.value.update( transition: .immediate, component: AnyComponent(Text( text: component.value, font: Font.with(size: 90.0, weight: .thin, traits: .monospacedNumbers), color: .white )), environment: {}, containerSize: CGSize(width: 180.0, height: 44.0) ) let valueFrame = CGRect( origin: CGPoint(x: floorToScreenPixels((availableSize.width - valueSize.width) / 2.0), y: 40.0), size: valueSize ) if let valueView = self.value.view { if valueView.superview == nil { setupButtonShadow(valueView, radius: 3.0) self.addSubview(valueView) } transition.setPosition(view: valueView, position: valueFrame.center) valueView.bounds = CGRect(origin: .zero, size: valueFrame.size) } if let previousValue, component.value != previousValue, self.alpha > 0.0 { if component.value == "100" || component.value == "0" { self.hapticFeedback.impact(.medium) } else { self.hapticFeedback.impact(.click05) } } return availableSize } } func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } public final class BlurredGradientComponent: Component { public enum Position { case top case bottom } let position: Position let dark: Bool let tag: AnyObject? public init( position: Position, dark: Bool = false, tag: AnyObject? ) { self.position = position self.dark = dark self.tag = tag } public static func ==(lhs: BlurredGradientComponent, rhs: BlurredGradientComponent) -> Bool { if lhs.position != rhs.position { return false } if lhs.dark != rhs.dark { return false } return true } public final class View: BlurredBackgroundView, ComponentTaggedView { private var component: BlurredGradientComponent? public func matches(tag: Any) -> Bool { if let component = self.component, let componentTag = component.tag { let tag = tag as AnyObject if componentTag === tag { return true } } return false } private var gradientMask = UIImageView() private var gradientBackground = SimpleLayer() private var gradientForeground = SimpleGradientLayer() public func update(component: BlurredGradientComponent, availableSize: CGSize, transition: Transition) -> CGSize { self.component = component self.isUserInteractionEnabled = false self.updateColor(color: UIColor(rgb: 0x000000, alpha: component.position == .top ? 0.15 : 0.25), transition: transition.containedViewLayoutTransition) let gradientHeight: CGFloat = 100.0 if self.mask == nil { self.mask = self.gradientMask self.gradientMask.image = generateGradientImage( size: CGSize(width: 1.0, height: gradientHeight), colors: [UIColor(rgb: 0xffffff, alpha: 1.0), UIColor(rgb: 0xffffff, alpha: 1.0), UIColor(rgb: 0xffffff, alpha: 0.0)], locations: component.position == .top ? [0.0, 0.8, 1.0] : [1.0, 0.20, 0.0], direction: .vertical ) self.gradientMask.layer.addSublayer(self.gradientBackground) self.gradientBackground.backgroundColor = UIColor(rgb: 0xffffff).cgColor if component.dark { self.gradientForeground.colors = [UIColor(rgb: 0x000000, alpha: 0.4).cgColor, UIColor(rgb: 0x000000, alpha: 0.4).cgColor, UIColor(rgb: 0x000000, alpha: 0.0).cgColor] self.gradientForeground.locations = [0.0, 0.8, 1.0] } else { self.gradientForeground.colors = [UIColor(rgb: 0x000000, alpha: 0.35).cgColor, UIColor(rgb: 0x000000, alpha: 0.0).cgColor] } self.gradientForeground.startPoint = CGPoint(x: 0.5, y: component.position == .top ? 0.0 : 1.0) self.gradientForeground.endPoint = CGPoint(x: 0.5, y: component.position == .top ? 1.0 : 0.0) self.layer.addSublayer(self.gradientForeground) } transition.setFrame(view: self.gradientMask, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: gradientHeight))) transition.setFrame(layer: self.gradientBackground, frame: CGRect(origin: CGPoint(x: 0.0, y: gradientHeight), size: availableSize)) transition.setFrame(layer: self.gradientForeground, frame: CGRect(origin: .zero, size: availableSize)) self.update(size: availableSize, transition: transition.containedViewLayoutTransition) return availableSize } } public func makeView() -> View { return View(color: nil, enableBlur: true) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } func draftPath(engine: TelegramEngine) -> String { return NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + "/storyDrafts_\(engine.account.peerId.toInt64())" } private func fullDraftPath(peerId: EnginePeer.Id, path: String) -> String { return NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + "/storyDrafts_\(peerId.toInt64())/" + path } func hasFirstResponder(_ view: UIView) -> Bool { if view.isFirstResponder { return true } for subview in view.subviews { if hasFirstResponder(subview) { return true } } return false } private func allowedStoryReactions(context: AccountContext) -> Signal<[ReactionItem], NoError> { let viewKey: PostboxViewKey = .orderedItemList(id: Namespaces.OrderedItemList.CloudTopReactions) let topReactions = context.account.postbox.combinedView(keys: [viewKey]) |> map { views -> [RecentReactionItem] in guard let view = views.views[viewKey] as? OrderedItemListView else { return [] } return view.items.compactMap { item -> RecentReactionItem? in return item.contents.get(RecentReactionItem.self) } } return combineLatest( context.engine.stickers.availableReactions(), topReactions ) |> take(1) |> map { availableReactions, topReactions -> [ReactionItem] in guard let availableReactions = availableReactions else { return [] } var result: [ReactionItem] = [] var existingIds = Set() for topReaction in topReactions { switch topReaction.content { case let .builtin(value): if let reaction = availableReactions.reactions.first(where: { $0.value == .builtin(value) }) { guard let centerAnimation = reaction.centerAnimation else { continue } guard let aroundAnimation = reaction.aroundAnimation else { continue } if existingIds.contains(reaction.value) { continue } existingIds.insert(reaction.value) result.append(ReactionItem( reaction: ReactionItem.Reaction(rawValue: reaction.value), appearAnimation: reaction.appearAnimation, stillAnimation: reaction.selectAnimation, listAnimation: centerAnimation, largeListAnimation: reaction.activateAnimation, applicationAnimation: aroundAnimation, largeApplicationAnimation: reaction.effectAnimation, isCustom: false )) } else { continue } case let .custom(file): if existingIds.contains(.custom(file.fileId.id)) { continue } existingIds.insert(.custom(file.fileId.id)) result.append(ReactionItem( reaction: ReactionItem.Reaction(rawValue: .custom(file.fileId.id)), appearAnimation: file, stillAnimation: file, listAnimation: file, largeListAnimation: file, applicationAnimation: nil, largeApplicationAnimation: nil, isCustom: true )) } } for reaction in availableReactions.reactions { guard let centerAnimation = reaction.centerAnimation else { continue } guard let aroundAnimation = reaction.aroundAnimation else { continue } if !reaction.isEnabled { continue } if existingIds.contains(reaction.value) { continue } existingIds.insert(reaction.value) result.append(ReactionItem( reaction: ReactionItem.Reaction(rawValue: reaction.value), appearAnimation: reaction.appearAnimation, stillAnimation: reaction.selectAnimation, listAnimation: centerAnimation, largeListAnimation: reaction.activateAnimation, applicationAnimation: aroundAnimation, largeApplicationAnimation: reaction.effectAnimation, isCustom: false )) } return result } } private final class ReferenceContentSource: ContextReferenceContentSource { private let sourceView: UIView private let contentArea: CGRect private let customPosition: CGPoint init(sourceView: UIView, contentArea: CGRect, customPosition: CGPoint) { self.sourceView = sourceView self.contentArea = contentArea self.customPosition = customPosition } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: self.contentArea, customPosition: self.customPosition, actionsPosition: .top) } } private func setupButtonShadow(_ view: UIView, radius: CGFloat = 2.0) { view.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) view.layer.shadowRadius = radius view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOpacity = 0.35 }