Swiftgram/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift
2024-09-06 21:41:16 +08:00

575 lines
24 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import LottieComponent
import VoiceChatActionButton
import CallScreen
import MetalEngine
import SwiftSignalKit
import AccountContext
import RadialStatusNode
private final class BlobView: UIView {
let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
}
}
private(set) var isAnimating = false
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let self else {
return
}
if !self.isCurrentlyInHierarchy {
return
}
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1
self.updateAudioLevel()
}
updateInHierarchy = { [weak self] value in
guard let self else {
return
}
self.isCurrentlyInHierarchy = value
if value {
self.startAnimating()
} else {
self.stopAnimating()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
let blobAmplificationFactor: CGFloat = 2.0
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(blobScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = true
}
private func updateBlobsState() {
/*if self.isAnimating {
if self.mediumBlob.frame.size != .zero {
self.mediumBlob.startAnimating()
self.bigBlob.startAnimating()
}
} else {
self.mediumBlob.stopAnimating()
self.bigBlob.stopAnimating()
}*/
}
override public func layoutSubviews() {
super.layoutSubviews()
//self.mediumBlob.frame = bounds
//self.bigBlob.frame = bounds
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
self.updateBlobsState()
}
}
private final class GlowView: UIView {
let maskGradientLayer: SimpleGradientLayer
override init(frame: CGRect) {
self.maskGradientLayer = SimpleGradientLayer()
self.maskGradientLayer.type = .radial
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
super.init(frame: frame)
self.layer.addSublayer(self.maskGradientLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, color: UIColor, transition: ComponentTransition, colorTransition: ComponentTransition) {
transition.setFrame(layer: self.maskGradientLayer, frame: CGRect(origin: CGPoint(), size: size))
colorTransition.setGradientColors(layer: self.maskGradientLayer, colors: [color.withMultipliedAlpha(1.0), color.withMultipliedAlpha(0.0)])
}
}
final class VideoChatMicButtonComponent: Component {
enum Content: Equatable {
case connecting
case muted
case unmuted(pushToTalk: Bool)
}
let call: PresentationGroupCall
let content: Content
let isCollapsed: Bool
let updateUnmutedStateIsPushToTalk: (Bool?) -> Void
init(
call: PresentationGroupCall,
content: Content,
isCollapsed: Bool,
updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void
) {
self.call = call
self.content = content
self.isCollapsed = isCollapsed
self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk
}
static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool {
if lhs.content != rhs.content {
return false
}
if lhs.isCollapsed != rhs.isCollapsed {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let background: UIImageView
private var disappearingBackgrounds: [UIImageView] = []
private var progressIndicator: RadialStatusNode?
private let title = ComponentView<Empty>()
private let icon: VoiceChatActionButtonIconNode
private var glowView: GlowView?
private var blobView: BlobView?
private var component: VideoChatMicButtonComponent?
private var isUpdating: Bool = false
private var beginTrackingTimestamp: Double = 0.0
private var beginTrackingWasPushToTalk: Bool = false
private var audioLevelDisposable: Disposable?
override init(frame: CGRect) {
self.background = UIImageView()
self.icon = VoiceChatActionButtonIconNode(isColored: false)
super.init(frame: frame)
}
deinit {
self.audioLevelDisposable?.dispose()
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent()
if let component = self.component {
switch component.content {
case .connecting:
self.beginTrackingWasPushToTalk = false
case .muted:
self.beginTrackingWasPushToTalk = true
component.updateUnmutedStateIsPushToTalk(true)
case .unmuted:
self.beginTrackingWasPushToTalk = false
}
}
return super.beginTracking(touch, with: event)
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
performEndOrCancelTracking()
return super.endTracking(touch, with: event)
}
override func cancelTracking(with event: UIEvent?) {
performEndOrCancelTracking()
return super.cancelTracking(with: event)
}
private func performEndOrCancelTracking() {
if let component = self.component {
let timestamp = CFAbsoluteTimeGetCurrent()
switch component.content {
case .connecting:
break
case .muted:
component.updateUnmutedStateIsPushToTalk(false)
case .unmuted:
if self.beginTrackingWasPushToTalk {
if timestamp < self.beginTrackingTimestamp + 0.15 {
component.updateUnmutedStateIsPushToTalk(false)
} else {
component.updateUnmutedStateIsPushToTalk(nil)
}
} else {
component.updateUnmutedStateIsPushToTalk(nil)
}
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: VideoChatMicButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
let titleText: String
var isEnabled = true
switch component.content {
case .connecting:
titleText = "Connecting..."
isEnabled = false
case .muted:
titleText = "Unmute"
case let .unmuted(isPushToTalk):
titleText = isPushToTalk ? "You are Live" : "Tap to Mute"
}
self.isEnabled = isEnabled
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleText, font: Font.regular(15.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: 120.0, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: availableSize.height)
if self.background.superview == nil {
self.background.isUserInteractionEnabled = false
self.addSubview(self.background)
self.background.frame = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0))
}
if case .connecting = component.content {
let progressIndicator: RadialStatusNode
if let current = self.progressIndicator {
progressIndicator = current
} else {
progressIndicator = RadialStatusNode(backgroundNodeColor: .clear)
self.progressIndicator = progressIndicator
}
progressIndicator.transitionToState(.progress(color: UIColor(rgb: 0x0080FF), lineWidth: 3.0, value: nil, cancelEnabled: false, animateRotation: true))
let progressIndicatorView = progressIndicator.view
if progressIndicatorView.superview == nil {
self.addSubview(progressIndicatorView)
progressIndicatorView.center = CGRect(origin: CGPoint(), size: size).center
progressIndicatorView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0))
progressIndicatorView.layer.transform = CATransform3DMakeScale(size.width / 116.0, size.width / 116.0, 1.0)
} else {
transition.setPosition(view: progressIndicatorView, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: progressIndicatorView, scale: size.width / 116.0)
}
} else if let progressIndicator = self.progressIndicator {
self.progressIndicator = nil
if !transition.animation.isImmediate {
let progressIndicatorView = progressIndicator.view
progressIndicatorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak progressIndicatorView] _ in
progressIndicatorView?.removeFromSuperview()
})
} else {
progressIndicator.view.removeFromSuperview()
}
}
if previousComponent?.content != component.content {
let backgroundContentsTransition: ComponentTransition
if !transition.animation.isImmediate {
backgroundContentsTransition = .easeInOut(duration: 0.2)
} else {
backgroundContentsTransition = .immediate
}
let backgroundImage = generateImage(CGSize(width: 200.0, height: 200.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
context.clip()
switch component.content {
case .connecting:
context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
case .muted, .unmuted:
let colors: [UIColor]
if case .muted = component.content {
colors = [UIColor(rgb: 0x0080FF), UIColor(rgb: 0x00A1FE)]
} else {
colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)]
}
let gradientColors = colors.map { $0.cgColor } as CFArray
let colorSpace = DeviceGraphicsContextSettings.shared.colorSpace
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
}
})!
if let previousImage = self.background.image {
let previousBackground = UIImageView()
previousBackground.center = self.background.center
previousBackground.bounds = self.background.bounds
previousBackground.layer.transform = self.background.layer.transform
previousBackground.image = previousImage
self.insertSubview(previousBackground, aboveSubview: self.background)
self.disappearingBackgrounds.append(previousBackground)
self.background.image = backgroundImage
backgroundContentsTransition.setAlpha(view: previousBackground, alpha: 0.0, completion: { [weak self, weak previousBackground] _ in
guard let self, let previousBackground else {
return
}
previousBackground.removeFromSuperview()
self.disappearingBackgrounds.removeAll(where: { $0 === previousBackground })
})
} else {
self.background.image = backgroundImage
}
if !transition.animation.isImmediate, let previousComponent, case .connecting = previousComponent.content {
self.layer.animateSublayerScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak self] completed in
if let self, completed {
self.layer.removeAnimation(forKey: "sublayerTransform.scale")
self.layer.animateSublayerScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: true)
}
})
}
}
transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: self.background, scale: size.width / 116.0)
for disappearingBackground in self.disappearingBackgrounds {
transition.setPosition(view: disappearingBackground, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: disappearingBackground, scale: size.width / 116.0)
}
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0)
}
if self.icon.view.superview == nil {
self.icon.view.isUserInteractionEnabled = false
self.addSubview(self.icon.view)
}
let iconSize = CGSize(width: 100.0, height: 100.0)
let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
transition.setPosition(view: self.icon.view, position: iconFrame.center)
transition.setBounds(view: self.icon.view, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
transition.setScale(view: self.icon.view, scale: component.isCollapsed ? ((iconSize.width - 24.0) / iconSize.width) : 1.0)
switch component.content {
case .connecting:
self.icon.enqueueState(.mute)
case .muted:
self.icon.enqueueState(.mute)
case .unmuted:
self.icon.enqueueState(.unmute)
}
switch component.content {
case .muted, .unmuted:
let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size
let blobTintTransition: ComponentTransition
let blobView: BlobView
if let current = self.blobView {
blobView = current
blobTintTransition = .easeInOut(duration: 0.2)
} else {
blobTintTransition = .immediate
blobView = BlobView(frame: CGRect(), maxLevel: 1.5)
blobView.isUserInteractionEnabled = false
self.blobView = blobView
self.insertSubview(blobView, at: 0)
blobView.center = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5)
blobView.bounds = CGRect(origin: CGPoint(), size: blobSize)
ComponentTransition.immediate.setScale(view: blobView, scale: 0.001)
if !transition.animation.isImmediate {
blobView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5))
transition.setScale(view: blobView, scale: availableSize.width / 116.0)
blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758))
switch component.content {
case .unmuted:
if self.audioLevelDisposable == nil {
self.audioLevelDisposable = (component.call.myAudioLevel
|> deliverOnMainQueue).startStrict(next: { [weak self] value in
guard let self, let blobView = self.blobView else {
return
}
blobView.updateLevel(CGFloat(value), immediately: false)
})
}
case .connecting, .muted:
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
blobView.updateLevel(0.0, immediately: false)
}
}
var glowFrame = CGRect(origin: CGPoint(), size: availableSize)
if component.isCollapsed {
glowFrame = glowFrame.insetBy(dx: -20.0, dy: -20.0)
} else {
glowFrame = glowFrame.insetBy(dx: -60.0, dy: -60.0)
}
let glowView: GlowView
if let current = self.glowView {
glowView = current
} else {
glowView = GlowView(frame: CGRect())
glowView.isUserInteractionEnabled = false
self.glowView = glowView
self.insertSubview(glowView, aboveSubview: blobView)
transition.animateScale(view: glowView, from: 0.001, to: 1.0)
glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
let glowColor: UIColor = component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758)
glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition)
transition.setFrame(view: glowView, frame: glowFrame)
default:
if let blobView = self.blobView {
self.blobView = nil
transition.setScale(view: blobView, scale: 0.001, completion: { [weak blobView] _ in
blobView?.removeFromSuperview()
})
}
if let glowView = self.glowView {
self.glowView = nil
transition.setScale(view: glowView, scale: 0.001, completion: { [weak glowView] _ in
glowView?.removeFromSuperview()
})
}
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
}
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}