2024-06-12 23:04:04 +04:00

495 lines
26 KiB
Swift

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<Empty>()
private let cancelContainerView: UIView
private let vibrancyCancelContainerView: UIView
private let cancelIconView: UIImageView
private let vibrancyCancelIconView: UIImageView
private let vibrancyCancelText = ComponentView<Empty>()
private let cancelText = ComponentView<Empty>()
private let vibrancyCancelButtonText = ComponentView<Empty>()
private let cancelButtonText = ComponentView<Empty>()
private var cancelButton: HighlightableButton?
private let timerFont: UIFont
private let timerText = ComponentView<Empty>()
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<Empty>, 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 = .white
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: .white)),
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<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}