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() 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, 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, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }