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 import GlassBackgroundComponent private let blueColor = UIColor(rgb: 0x55b3f8) 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 ScheduledState: Equatable { case start case toggleSubscription(isSubscribed: Bool) } enum Content: Equatable { case connecting case muted(forced: Bool) case unmuted(pushToTalk: Bool) case raiseHand(isRaised: Bool) case scheduled(state: ScheduledState) } let call: VideoChatCall let strings: PresentationStrings let content: Content let isCollapsed: Bool let isCompact: Bool let updateUnmutedStateIsPushToTalk: (Bool?) -> Void let raiseHand: () -> Void let scheduleAction: () -> Void init( call: VideoChatCall, strings: PresentationStrings, content: Content, isCollapsed: Bool, isCompact: Bool, updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void, raiseHand: @escaping () -> Void, scheduleAction: @escaping () -> Void ) { self.call = call self.strings = strings self.content = content self.isCollapsed = isCollapsed self.isCompact = isCompact self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk self.raiseHand = raiseHand self.scheduleAction = scheduleAction } static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool { if lhs.call != rhs.call { return false } if lhs.content != rhs.content { return false } if lhs.isCollapsed != rhs.isCollapsed { return false } if lhs.isCompact != rhs.isCompact { 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 var subtitle: ComponentView? private let icon: VoiceChatActionButtonIconNode private var glowView: GlowView? private var blobView: BlobView? private var chromeView: UIImageView? 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, .unmuted, .raiseHand, .scheduled: self.beginTrackingWasPushToTalk = false case let .muted(forced): if forced { self.beginTrackingWasPushToTalk = false } else { self.beginTrackingWasPushToTalk = true component.updateUnmutedStateIsPushToTalk(true) } } } 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 let .muted(forced): if forced { } else { 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) } case .raiseHand: self.icon.playRandomAnimation() component.raiseHand() case .scheduled: component.scheduleAction() } } } 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 if let previousComponent, previousComponent.call != component.call { self.audioLevelDisposable?.dispose() self.audioLevelDisposable = nil } let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) var titleText: String var subtitleText: String? var isEnabled = true switch component.content { case .connecting: titleText = component.strings.VoiceChat_Connecting isEnabled = false case let .muted(forced): if forced { titleText = component.strings.VoiceChat_MutedByAdmin } else { titleText = component.strings.VoiceChat_Unmute } case let .unmuted(isPushToTalk): titleText = isPushToTalk ? component.strings.VoiceChat_Live : component.strings.VoiceChat_MuteShort case let .raiseHand(isRaised): if isRaised { titleText = component.strings.VoiceChat_AskedToSpeak subtitleText = component.strings.VoiceChat_AskedToSpeakHelp } else { titleText = component.strings.VoiceChat_MutedByAdmin subtitleText = component.strings.VoiceChat_MutedByAdminHelp } case let .scheduled(state): switch state { case .start: titleText = component.strings.VoiceChat_StartNow case let .toggleSubscription(isSubscribed): if isSubscribed { titleText = component.strings.VoiceChat_CancelReminder } else { titleText = component.strings.VoiceChat_SetReminder } } } self.isEnabled = isEnabled if component.isCompact { titleText = titleText.lowercased() } let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: titleText, font: Font.regular(13.0), textColor: .white)) )), environment: {}, containerSize: CGSize(width: 180.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: blueColor, 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, .raiseHand, .scheduled: let colors: [UIColor] if case .muted(forced: true) = component.content { colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] } else if case .muted(forced: false) = component.content { colors = [blueColor, UIColor(rgb: 0x55adfe)] } else if case .raiseHand = component.content { colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] } else if case .scheduled = component.content { colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] } 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) } var titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize) if component.isCompact { titleFrame.origin.y -= 11.0 } else { if subtitleText != nil { titleFrame.origin.y -= 5.0 } } 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 let subtitleText { let subtitle: ComponentView var subtitleTransition = transition if let current = self.subtitle { subtitle = current } else { subtitleTransition = subtitleTransition.withAnimation(.none) subtitle = ComponentView() self.subtitle = subtitle } let subtitleSize = subtitle.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: subtitleText, font: Font.regular(13.0), textColor: .white)) )), environment: {}, containerSize: CGSize(width: 180.0, height: 100.0) ) let subtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) * 0.5), y: titleFrame.maxY + 1.0), size: subtitleSize) if let subtitleView = subtitle.view { if subtitleView.superview == nil { subtitleView.isUserInteractionEnabled = false self.addSubview(subtitleView) subtitleView.alpha = 0.0 transition.animateScale(view: subtitleView, from: 0.001, to: 1.0) } subtitleTransition.setPosition(view: subtitleView, position: subtitleFrame.center) subtitleView.bounds = CGRect(origin: CGPoint(), size: subtitleFrame.size) alphaTransition.setAlpha(view: subtitleView, alpha: component.isCollapsed || component.isCompact ? 0.0 : 1.0) } } else if let subtitle = self.subtitle { self.subtitle = nil if let subtitleView = subtitle.view { transition.setScale(view: subtitleView, scale: 0.001) alphaTransition.setAlpha(view: subtitleView, alpha: 0.0, completion: { [weak subtitleView] _ in subtitleView?.removeFromSuperview() }) } } 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 || component.isCompact ? ((iconSize.width - 34.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) case .raiseHand: self.icon.enqueueState(.hand) case let .scheduled(state): switch state { case .start: self.icon.enqueueState(.start) case let .toggleSubscription(isSubscribed): if isSubscribed { self.icon.enqueueState(.unsubscribe) } else { self.icon.enqueueState(.subscribe) } } } switch component.content { case .muted, .unmuted, .raiseHand, .scheduled: 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) let blobsColor: UIColor if case .muted(forced: true) = component.content { blobsColor = UIColor(rgb: 0x914BAD) } else if case .muted(forced: false) = component.content { blobsColor = UIColor(rgb: 0x397cd8) } else if case .raiseHand = component.content { blobsColor = UIColor(rgb: 0x914BAD) } else if case .scheduled = component.content { blobsColor = UIColor(rgb: 0x914BAD) } else { blobsColor = UIColor(rgb: 0x33C758) } blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: blobsColor) switch component.content { case .unmuted: if self.audioLevelDisposable == nil { self.audioLevelDisposable = (component.call.myAudioLevelAndSpeaking |> 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, .raiseHand, .scheduled: 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 if case .muted(forced: true) = component.content { glowColor = UIColor(rgb: 0x3252EF) } else if case .muted(forced: false) = component.content { glowColor = blueColor } else if case .raiseHand = component.content { glowColor = UIColor(rgb: 0x3252EF) } else if case .scheduled = component.content { glowColor = UIColor(rgb: 0x3252EF) } else { glowColor = 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() } } if component.isCompact { let chromeView: UIImageView var chromeTransition = transition if let current = self.chromeView { chromeView = current } else { chromeTransition = .immediate chromeView = UIImageView() self.chromeView = chromeView self.addSubview(chromeView) chromeView.layer.compositingFilter = "overlayBlendMode" chromeView.alpha = 0.8 chromeView.image = GlassBackgroundView.generateForegroundImage(size: size, isDark: false, fillColor: .clear) } chromeTransition.setFrame(view: chromeView, frame: CGRect(origin: .zero, size: size)) } else if let chromeView = self.chromeView { self.chromeView = nil chromeView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in chromeView.removeFromSuperview() }) } 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) } }