import Foundation import UIKit import Display import ComponentFlow import AppBundle import TextFieldComponent import BundleIconComponent import AccountContext import TelegramPresentationData import ChatPresentationInterfaceState import SwiftSignalKit import LottieComponent import HierarchyTrackingLayer public final class MediaRecordingPanelComponent: Component { public let theme: PresentationTheme public let strings: PresentationStrings public let audioRecorder: ManagedAudioRecorder? public let videoRecordingStatus: InstantVideoControllerRecordingStatus? public let isRecordingLocked: Bool public let cancelFraction: CGFloat public let inputInsets: UIEdgeInsets public let insets: UIEdgeInsets public let cancelAction: () -> Void public init( theme: PresentationTheme, strings: PresentationStrings, audioRecorder: ManagedAudioRecorder?, videoRecordingStatus: InstantVideoControllerRecordingStatus?, isRecordingLocked: Bool, cancelFraction: CGFloat, inputInsets: UIEdgeInsets, insets: UIEdgeInsets, cancelAction: @escaping () -> Void ) { self.theme = theme self.strings = strings self.audioRecorder = audioRecorder self.videoRecordingStatus = videoRecordingStatus self.isRecordingLocked = isRecordingLocked self.cancelFraction = cancelFraction self.inputInsets = inputInsets self.insets = insets self.cancelAction = cancelAction } public static func ==(lhs: MediaRecordingPanelComponent, rhs: MediaRecordingPanelComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.audioRecorder !== rhs.audioRecorder { return false } if lhs.videoRecordingStatus !== rhs.videoRecordingStatus { return false } if lhs.isRecordingLocked != rhs.isRecordingLocked { return false } if lhs.cancelFraction != rhs.cancelFraction { return false } if lhs.inputInsets != rhs.inputInsets { return false } if lhs.insets != rhs.insets { return false } return true } public final class View: UIView { private var component: MediaRecordingPanelComponent? private weak var state: EmptyComponentState? public let vibrancyContainer: UIView private let trackingLayer: HierarchyTrackingLayer private let indicator = ComponentView() private let cancelContainerView: UIView private let vibrancyCancelContainerView: UIView private let cancelIconView: UIImageView private let vibrancyCancelIconView: UIImageView private let vibrancyCancelText = ComponentView() private let cancelText = ComponentView() private let vibrancyCancelButtonText = ComponentView() private let cancelButtonText = ComponentView() private var cancelButton: HighlightableButton? private let timerFont: UIFont private let timerText = ComponentView() private var timerTextDisposable: Disposable? private var timerTextValue: String = "0:00,00" override init(frame: CGRect) { self.trackingLayer = HierarchyTrackingLayer() self.cancelIconView = UIImageView() self.vibrancyCancelIconView = UIImageView() self.timerFont = Font.with(size: 15.0, design: .camera, traits: .monospacedNumbers) self.vibrancyContainer = UIView() self.cancelContainerView = UIView() self.vibrancyCancelContainerView = UIView() super.init(frame: frame) self.layer.addSublayer(self.trackingLayer) self.cancelContainerView.addSubview(self.cancelIconView) self.vibrancyCancelContainerView.addSubview(self.vibrancyCancelIconView) self.vibrancyContainer.addSubview(self.vibrancyCancelContainerView) self.addSubview(self.cancelContainerView) self.trackingLayer.didEnterHierarchy = { [weak self] in guard let self else { return } self.updateAnimations() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.timerTextDisposable?.dispose() } private func updateAnimations() { guard let component = self.component else { return } if let indicatorView = self.indicator.view { if indicatorView.layer.animation(forKey: "recording") == nil { let animation = CAKeyframeAnimation(keyPath: "opacity") animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.0 as NSNumber] animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] animation.duration = 0.5 animation.autoreverses = true animation.repeatCount = Float.infinity indicatorView.layer.add(animation, forKey: "recording") } } if !component.isRecordingLocked, self.cancelContainerView.layer.animation(forKey: "recording") == nil { let animation = CAKeyframeAnimation(keyPath: "position.x") animation.values = [-5.0 as NSNumber, 5.0 as NSNumber, 0.0 as NSNumber] animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] animation.duration = 1.5 animation.autoreverses = true animation.isAdditive = true animation.repeatCount = Float.infinity self.cancelContainerView.layer.add(animation, forKey: "recording") self.vibrancyCancelContainerView.layer.add(animation, forKey: "recording") } } public func animateIn() { guard let component = self.component else { return } if let indicatorView = self.indicator.view { indicatorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) indicatorView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } if let timerTextView = self.timerText.view { timerTextView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) timerTextView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } self.cancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.vibrancyCancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } public func animateOut(transition: ComponentTransition, dismissRecording: Bool, completion: @escaping () -> Void) { guard let component = self.component else { completion() return } if let indicatorView = self.indicator.view as? LottieComponent.View, let _ = indicatorView.layer.animation(forKey: "recording") { let fromAlpha = indicatorView.layer.presentation()?.opacity ?? indicatorView.layer.opacity indicatorView.layer.removeAnimation(forKey: "recording") indicatorView.layer.animateAlpha(from: CGFloat(fromAlpha), to: 1.0, duration: 0.2) } if dismissRecording { if let indicatorView = self.indicator.view as? LottieComponent.View { indicatorView.playOnce(completion: { [weak indicatorView] in if let indicatorView { let transition = ComponentTransition(animation: .curve(duration: 0.3, curve: .spring)) transition.setScale(view: indicatorView, scale: 0.001) } completion() }) } else { completion() } } else { if let indicatorView = self.indicator.view as? LottieComponent.View { transition.setPosition(view: indicatorView, position: indicatorView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0)) transition.setAlpha(view: indicatorView, alpha: 0.0) } } if let timerTextView = self.timerText.view { transition.setAlpha(view: timerTextView, alpha: 0.0, completion: { _ in if !dismissRecording { completion() } }) transition.setScale(view: timerTextView, scale: 0.001) transition.setPosition(view: timerTextView, position: timerTextView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0)) } transition.setAlpha(view: self.cancelContainerView, alpha: 0.0) transition.setAlpha(view: self.vibrancyCancelContainerView, alpha: 0.0) } @objc private func cancelButtonPressed() { guard let component = self.component else { return } component.cancelAction() } func update(component: MediaRecordingPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let previousComponent = self.component self.component = component self.state = state if previousComponent?.audioRecorder !== component.audioRecorder || previousComponent?.videoRecordingStatus !== component.videoRecordingStatus { self.timerTextDisposable?.dispose() if let audioRecorder = component.audioRecorder { var updateNow = false self.timerTextDisposable = audioRecorder.recordingState.start(next: { [weak self] state in Queue.mainQueue().async { guard let self else { return } switch state { case .paused(let duration), .recording(let duration, _): let currentAudioDurationSeconds = Int(duration) let currentAudioDurationMilliseconds = Int(duration * 100.0) % 100 let text: String if currentAudioDurationSeconds >= 60 * 60 { text = String(format: "%d:%02d:%02d,%02d", currentAudioDurationSeconds / 3600, currentAudioDurationSeconds / 60 % 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds) } else { text = String(format: "%d:%02d,%02d", currentAudioDurationSeconds / 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds) } if self.timerTextValue != text { self.timerTextValue = text } if updateNow { self.state?.updated(transition: .immediate) } case .stopped: break } } }) updateNow = true } else if let videoRecordingStatus = component.videoRecordingStatus { var updateNow = false self.timerTextDisposable = videoRecordingStatus.duration.start(next: { [weak self] duration in Queue.mainQueue().async { guard let self else { return } let currentAudioDurationSeconds = Int(duration) let currentAudioDurationMilliseconds = Int(duration * 100.0) % 100 let text: String if currentAudioDurationSeconds >= 60 * 60 { text = String(format: "%d:%02d:%02d,%02d", currentAudioDurationSeconds / 3600, currentAudioDurationSeconds / 60 % 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds) } else { text = String(format: "%d:%02d,%02d", currentAudioDurationSeconds / 60, currentAudioDurationSeconds % 60, currentAudioDurationMilliseconds) } if self.timerTextValue != text { self.timerTextValue = text } if updateNow { self.state?.updated(transition: .immediate) } } }) updateNow = true } } let indicatorSize = self.indicator.update( transition: .immediate, component: AnyComponent(LottieComponent( content: LottieComponent.AppBundleContent(name: "BinRed"), color: UIColor(rgb: 0xFF3B30), startingPosition: .begin )), environment: {}, containerSize: CGSize(width: 40.0, height: 40.0) ) if let indicatorView = self.indicator.view { if indicatorView.superview == nil { self.addSubview(indicatorView) } transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 5.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - indicatorSize.height) * 0.5)), size: indicatorSize)) } let timerTextSize = self.timerText.update( transition: .immediate, component: AnyComponent(Text(text: self.timerTextValue, font: self.timerFont, color: .white)), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) if let timerTextView = self.timerText.view { if timerTextView.superview == nil { self.addSubview(timerTextView) timerTextView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) } let timerTextFrame = CGRect(origin: CGPoint(x: 40.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize) transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY)) timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size) } if self.cancelIconView.image == nil { let image = UIImage(bundleImageName: "Chat/Input/Text/AudioRecordingCancelArrow")?.withRenderingMode(.alwaysTemplate) self.cancelIconView.image = image self.vibrancyCancelIconView.image = image } self.cancelIconView.tintColor = UIColor(white: 1.0, alpha: 0.3) self.vibrancyCancelIconView.tintColor = .black let cancelTextSize = self.cancelText.update( transition: .immediate, component: AnyComponent(Text(text: component.strings.Conversation_SlideToCancel, font: Font.regular(15.0), color: UIColor(rgb: 0xffffff, alpha: 0.3))), environment: {}, containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) let _ = self.vibrancyCancelText.update( transition: .immediate, component: AnyComponent(Text(text: component.strings.Conversation_SlideToCancel, font: Font.regular(15.0), color: .black)), environment: {}, containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) let cancelButtonTextSize = self.cancelButtonText.update( transition: .immediate, component: AnyComponent(Text(text: component.strings.Common_Cancel, font: Font.regular(17.0), color: .white)), environment: {}, containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) let _ = self.vibrancyCancelButtonText.update( transition: .immediate, component: AnyComponent(Text(text: component.strings.Common_Cancel, font: Font.regular(17.0), color: .clear)), environment: {}, containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) var textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelTextSize.height) * 0.5)), size: cancelTextSize) let cancelButtonTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelButtonTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelButtonTextSize.height) * 0.5)), size: cancelButtonTextSize) let bandingStart: CGFloat = 0.0 let bandedOffset = abs(component.cancelFraction) - bandingStart let range: CGFloat = 300.0 let coefficient: CGFloat = 0.4 let mappedCancelFraction = bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range textFrame.origin.x -= mappedCancelFraction * 0.5 if component.isRecordingLocked { if self.cancelContainerView.layer.animation(forKey: "recording") != nil { if let presentation = self.cancelContainerView.layer.presentation() { transition.animatePosition(view: self.cancelContainerView, from: presentation.position, to: CGPoint()) transition.animatePosition(view: self.vibrancyCancelContainerView, from: presentation.position, to: CGPoint()) } self.cancelContainerView.layer.removeAnimation(forKey: "recording") self.vibrancyCancelContainerView.layer.removeAnimation(forKey: "recording") } } if let cancelTextView = self.cancelText.view { if cancelTextView.superview == nil { self.cancelContainerView.addSubview(cancelTextView) } transition.setPosition(view: cancelTextView, position: textFrame.center) transition.setBounds(view: cancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size)) transition.setAlpha(view: cancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: cancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001) } if let vibrancyCancelTextView = self.vibrancyCancelText.view { if vibrancyCancelTextView.superview == nil { self.vibrancyCancelContainerView.addSubview(vibrancyCancelTextView) } transition.setPosition(view: vibrancyCancelTextView, position: textFrame.center) transition.setBounds(view: vibrancyCancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size)) transition.setAlpha(view: vibrancyCancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: vibrancyCancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001) } if let cancelButtonTextView = self.cancelButtonText.view { if cancelButtonTextView.superview == nil { self.cancelContainerView.addSubview(cancelButtonTextView) } transition.setPosition(view: cancelButtonTextView, position: cancelButtonTextFrame.center) transition.setBounds(view: cancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size)) transition.setAlpha(view: cancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: cancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001) } if let vibrancyCancelButtonTextView = self.vibrancyCancelButtonText.view { if vibrancyCancelButtonTextView.superview == nil { self.vibrancyCancelContainerView.addSubview(vibrancyCancelButtonTextView) } transition.setPosition(view: vibrancyCancelButtonTextView, position: cancelButtonTextFrame.center) transition.setBounds(view: vibrancyCancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size)) transition.setAlpha(view: vibrancyCancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: vibrancyCancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001) } if component.isRecordingLocked { let cancelButton: HighlightableButton if let current = self.cancelButton { cancelButton = current } else { cancelButton = HighlightableButton() self.cancelButton = cancelButton self.addSubview(cancelButton) cancelButton.highligthedChanged = { [weak self] highlighted in guard let self else { return } if highlighted { self.cancelContainerView.alpha = 0.6 self.vibrancyCancelContainerView.alpha = 0.6 } else { self.cancelContainerView.alpha = 1.0 self.vibrancyCancelContainerView.alpha = 1.0 self.cancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2) self.vibrancyCancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2) } } cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), for: .touchUpInside) } cancelButton.frame = CGRect(origin: CGPoint(x: cancelButtonTextFrame.minX - 8.0, y: 0.0), size: CGSize(width: cancelButtonTextFrame.width + 8.0 * 2.0, height: availableSize.height)) } else if let cancelButton = self.cancelButton { cancelButton.removeFromSuperview() } if let image = self.cancelIconView.image { let iconFrame = CGRect(origin: CGPoint(x: textFrame.minX - 4.0 - image.size.width, y: textFrame.minY + floor((textFrame.height - image.size.height) * 0.5)), size: image.size) transition.setPosition(view: self.cancelIconView, position: iconFrame.center) transition.setBounds(view: self.cancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) transition.setAlpha(view: self.cancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: self.cancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001) transition.setPosition(view: self.vibrancyCancelIconView, position: iconFrame.center) transition.setBounds(view: self.vibrancyCancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) transition.setAlpha(view: self.vibrancyCancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) transition.setScale(view: self.vibrancyCancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001) } self.updateAnimations() transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: CGPoint(), size: availableSize)) return availableSize } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }