diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index c232006fb2..d3e44953ea 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -335,14 +335,14 @@ public enum PresentationGroupCallTone { case recordingStarted } -public struct PresentationGroupCallRequestedVideo { +public struct PresentationGroupCallRequestedVideo: Equatable { public enum Quality { case thumbnail case medium case full } - public struct SsrcGroup { + public struct SsrcGroup: Equatable { public var semantics: String public var ssrcs: [UInt32] } @@ -441,6 +441,7 @@ public protocol PresentationGroupCall: AnyObject { func updateDefaultParticipantsAreMuted(isMuted: Bool) func setVolume(peerId: EnginePeer.Id, volume: Int32, sync: Bool) func setRequestedVideoList(items: [PresentationGroupCallRequestedVideo]) + func setSuspendVideoChannelRequests(_ value: Bool) func setCurrentAudioOutput(_ output: AudioSessionOutput) func playTone(_ tone: PresentationGroupCallTone) diff --git a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift index 2c6777b076..53b5ccaea6 100644 --- a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift +++ b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import Display public final class RoundedRectangle: Component { public enum GradientDirection: Equatable { @@ -117,3 +118,136 @@ public final class RoundedRectangle: Component { return view.update(component: self, availableSize: availableSize, transition: transition) } } + +public final class FilledRoundedRectangleComponent: Component { + public let color: UIColor + public let cornerRadius: CGFloat + public let smoothCorners: Bool + + public init( + color: UIColor, + cornerRadius: CGFloat, + smoothCorners: Bool + ) { + self.color = color + self.cornerRadius = cornerRadius + self.smoothCorners = smoothCorners + } + + public static func ==(lhs: FilledRoundedRectangleComponent, rhs: FilledRoundedRectangleComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.color != rhs.color { + return false + } + if lhs.cornerRadius != rhs.cornerRadius { + return false + } + if lhs.smoothCorners != rhs.smoothCorners { + return false + } + return true + } + + public final class View: UIImageView { + private var component: FilledRoundedRectangleComponent? + + private var currentCornerRadius: CGFloat? + private var cornerImage: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyStaticCornerRadius() { + guard let component = self.component else { + return + } + guard let cornerRadius = self.currentCornerRadius else { + return + } + if cornerRadius == 0.0 { + if let cornerImage = self.cornerImage, cornerImage.size.width == 1.0 { + } else { + self.cornerImage = generateImage(CGSize(width: 1.0, height: 1.0), rotatedContext: { size, context in + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + if component.smoothCorners { + let size = CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius * 2.0 + 10.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath) + context.setFillColor(UIColor.white.cgColor) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate) + } + } else { + let size = CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0) + if let cornerImage = self.cornerImage, cornerImage.size == size { + } else { + self.cornerImage = generateStretchableFilledCircleImage(diameter: size.width, color: UIColor.white)?.withRenderingMode(.alwaysTemplate) + } + } + } + self.image = self.cornerImage + self.clipsToBounds = false + self.backgroundColor = nil + self.layer.cornerRadius = 0.0 + } + + func update(component: FilledRoundedRectangleComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize { + self.component = component + + transition.setTintColor(view: self, color: component.color) + + if self.currentCornerRadius != component.cornerRadius { + let previousCornerRadius = self.currentCornerRadius + self.currentCornerRadius = component.cornerRadius + if transition.animation.isImmediate { + self.applyStaticCornerRadius() + } else { + self.image = nil + self.clipsToBounds = true + self.backgroundColor = component.color + if let previousCornerRadius, self.layer.animation(forKey: "cornerRadius") == nil { + self.layer.cornerRadius = previousCornerRadius + } + if #available(iOS 13.0, *) { + if component.smoothCorners { + self.layer.cornerCurve = .continuous + } else { + self.layer.cornerCurve = .circular + } + + } + transition.setCornerRadius(layer: self.layer, cornerRadius: component.cornerRadius, completion: { [weak self] completed in + guard let self, completed else { + return + } + self.applyStaticCornerRadius() + }) + } + } + + 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, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 4317f1c4fd..4f9a225a04 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -664,6 +664,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { private var ssrcMapping: [UInt32: SsrcMapping] = [:] private var requestedVideoChannels: [OngoingGroupCallContext.VideoChannel] = [] + private var suspendVideoChannelRequests: Bool = false private var pendingVideoSubscribers = Bag<(String, MetaDisposable, (OngoingGroupCallContext.VideoFrameData) -> Void)>() private var summaryInfoState = Promise(nil) @@ -1699,7 +1700,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.genericCallContext = genericCallContext self.stateVersionValue += 1 - genericCallContext.setRequestedVideoChannels(self.requestedVideoChannels) + genericCallContext.setRequestedVideoChannels(self.suspendVideoChannelRequests ? [] : self.requestedVideoChannels) self.connectPendingVideoSubscribers() } @@ -3090,11 +3091,21 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { maxQuality: mappedMaxQuality ) } - if let genericCallContext = self.genericCallContext { + if let genericCallContext = self.genericCallContext, !self.suspendVideoChannelRequests { genericCallContext.setRequestedVideoChannels(self.requestedVideoChannels) } } + public func setSuspendVideoChannelRequests(_ value: Bool) { + if self.suspendVideoChannelRequests != value { + self.suspendVideoChannelRequests = value + + if let genericCallContext = self.genericCallContext { + genericCallContext.setRequestedVideoChannels(self.suspendVideoChannelRequests ? [] : self.requestedVideoChannels) + } + } + } + public func setCurrentAudioOutput(_ output: AudioSessionOutput) { guard self.currentSelectedAudioOutputValue != output else { return diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index bf403fa4ce..d6e2865658 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -34,13 +34,16 @@ final class VideoChatActionButtonComponent: Component { let content: Content let microphoneState: MicrophoneState + let isCollapsed: Bool init( content: Content, - microphoneState: MicrophoneState + microphoneState: MicrophoneState, + isCollapsed: Bool ) { self.content = content self.microphoneState = microphoneState + self.isCollapsed = isCollapsed } static func ==(lhs: VideoChatActionButtonComponent, rhs: VideoChatActionButtonComponent) -> Bool { @@ -50,6 +53,9 @@ final class VideoChatActionButtonComponent: Component { if lhs.microphoneState != rhs.microphoneState { return false } + if lhs.isCollapsed != rhs.isCollapsed { + return false + } return true } @@ -80,6 +86,8 @@ final class VideoChatActionButtonComponent: Component { let previousComponent = self.component self.component = component + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + let titleText: String let backgroundColor: UIColor let iconDiameter: CGFloat @@ -138,9 +146,10 @@ final class VideoChatActionButtonComponent: Component { let _ = self.background.update( transition: transition, - component: AnyComponent(RoundedRectangle( + component: AnyComponent(FilledRoundedRectangleComponent( color: backgroundColor, - cornerRadius: nil + cornerRadius: size.width * 0.5, + smoothCorners: false )), environment: {}, containerSize: size @@ -159,6 +168,7 @@ final class VideoChatActionButtonComponent: Component { } 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) } let iconSize = self.icon.update( diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift index a69b8110b1..e427172117 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift @@ -14,17 +14,23 @@ final class VideoChatMicButtonComponent: Component { } let content: Content + let isCollapsed: Bool init( - content: Content + content: Content, + isCollapsed: Bool ) { self.content = content + self.isCollapsed = isCollapsed } static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool { if lhs.content != rhs.content { return false } + if lhs.isCollapsed != rhs.isCollapsed { + return false + } return true } @@ -52,6 +58,8 @@ final class VideoChatMicButtonComponent: Component { self.component = component + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + let titleText: String let backgroundColor: UIColor switch component.content { @@ -79,9 +87,10 @@ final class VideoChatMicButtonComponent: Component { let _ = self.background.update( transition: transition, - component: AnyComponent(RoundedRectangle( + component: AnyComponent(FilledRoundedRectangleComponent( color: backgroundColor, - cornerRadius: nil + cornerRadius: size.width * 0.5, + smoothCorners: false )), environment: {}, containerSize: size @@ -100,6 +109,7 @@ final class VideoChatMicButtonComponent: Component { } 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) } let iconSize = self.icon.update( @@ -118,7 +128,9 @@ final class VideoChatMicButtonComponent: Component { if iconView.superview == nil { self.addSubview(iconView) } - transition.setFrame(view: iconView, frame: iconFrame) + transition.setPosition(view: iconView, position: iconFrame.center) + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setScale(view: iconView, scale: component.isCollapsed ? ((iconSize.width - 24.0) / iconSize.width) : 1.0) } return size diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index 39d3299ecb..5c59f214d3 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -12,18 +12,32 @@ import AccountContext import SwiftSignalKit final class VideoChatParticipantVideoComponent: Component { + struct ExpandedState: Equatable { + var isPinned: Bool + + init(isPinned: Bool) { + self.isPinned = isPinned + } + } + let call: PresentationGroupCall let participant: GroupCallParticipantsContext.Participant let isPresentation: Bool + let expandedState: ExpandedState? + let action: (() -> Void)? init( call: PresentationGroupCall, participant: GroupCallParticipantsContext.Participant, - isPresentation: Bool + isPresentation: Bool, + expandedState: ExpandedState?, + action: (() -> Void)? ) { self.call = call self.participant = participant self.isPresentation = isPresentation + self.expandedState = expandedState + self.action = action } static func ==(lhs: VideoChatParticipantVideoComponent, rhs: VideoChatParticipantVideoComponent) -> Bool { @@ -33,6 +47,12 @@ final class VideoChatParticipantVideoComponent: Component { if lhs.isPresentation != rhs.isPresentation { return false } + if lhs.expandedState != rhs.expandedState { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } return true } @@ -46,9 +66,9 @@ final class VideoChatParticipantVideoComponent: Component { } } - final class View: UIView { + final class View: HighlightTrackingButton { private var component: VideoChatParticipantVideoComponent? - private weak var state: EmptyComponentState? + private weak var componentState: EmptyComponentState? private var isUpdating: Bool = false private let title = ComponentView() @@ -62,8 +82,11 @@ final class VideoChatParticipantVideoComponent: Component { override init(frame: CGRect) { super.init(frame: frame) + //TODO:release optimize self.clipsToBounds = true self.layer.cornerRadius = 10.0 + + self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) } required init?(coder: NSCoder) { @@ -74,6 +97,13 @@ final class VideoChatParticipantVideoComponent: Component { self.videoDisposable?.dispose() } + @objc private func pressed() { + guard let component = self.component, let action = component.action else { + return + } + action() + } + func update(component: VideoChatParticipantVideoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -81,7 +111,7 @@ final class VideoChatParticipantVideoComponent: Component { } self.component = component - self.state = state + self.componentState = state let nameColor = component.participant.peer.nameColor ?? .blue let nameColors = component.call.accountContext.peerNameColors.get(nameColor, dark: true) @@ -146,14 +176,14 @@ final class VideoChatParticipantVideoComponent: Component { if self.videoSpec != videoSpec { self.videoSpec = videoSpec if !self.isUpdating { - self.state?.updated(transition: .immediate, isLocal: true) + self.componentState?.updated(transition: .immediate, isLocal: true) } } } else { if self.videoSpec != nil { self.videoSpec = nil if !self.isUpdating { - self.state?.updated(transition: .immediate, isLocal: true) + self.componentState?.updated(transition: .immediate, isLocal: true) } } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index 156815b3f9..b96cdd57ac 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -12,36 +12,93 @@ import TelegramPresentationData import PeerListItemComponent final class VideoChatParticipantsComponent: Component { + struct VideoParticipantKey: Hashable { + var id: EnginePeer.Id + var isPresentation: Bool + + init(id: EnginePeer.Id, isPresentation: Bool) { + self.id = id + self.isPresentation = isPresentation + } + } + + final class ExpandedVideoState: Equatable { + let mainParticipant: VideoParticipantKey + let isMainParticipantPinned: Bool + + init(mainParticipant: VideoParticipantKey, isMainParticipantPinned: Bool) { + self.mainParticipant = mainParticipant + self.isMainParticipantPinned = isMainParticipantPinned + } + + static func ==(lhs: ExpandedVideoState, rhs: ExpandedVideoState) -> Bool { + if lhs === rhs { + return true + } + if lhs.mainParticipant != rhs.mainParticipant { + return false + } + if lhs.isMainParticipantPinned != rhs.isMainParticipantPinned { + return false + } + return true + } + } + let call: PresentationGroupCall let members: PresentationGroupCallMembers? + let expandedVideoState: ExpandedVideoState? let theme: PresentationTheme let strings: PresentationStrings + let collapsedContainerInsets: UIEdgeInsets + let expandedContainerInsets: UIEdgeInsets let sideInset: CGFloat + let updateMainParticipant: (VideoParticipantKey?) -> Void + let updateIsMainParticipantPinned: (Bool) -> Void init( call: PresentationGroupCall, members: PresentationGroupCallMembers?, + expandedVideoState: ExpandedVideoState?, theme: PresentationTheme, strings: PresentationStrings, - sideInset: CGFloat + collapsedContainerInsets: UIEdgeInsets, + expandedContainerInsets: UIEdgeInsets, + sideInset: CGFloat, + updateMainParticipant: @escaping (VideoParticipantKey?) -> Void, + updateIsMainParticipantPinned: @escaping (Bool) -> Void ) { self.call = call self.members = members + self.expandedVideoState = expandedVideoState self.theme = theme self.strings = strings + self.collapsedContainerInsets = collapsedContainerInsets + self.expandedContainerInsets = expandedContainerInsets self.sideInset = sideInset + self.updateMainParticipant = updateMainParticipant + self.updateIsMainParticipantPinned = updateIsMainParticipantPinned } static func ==(lhs: VideoChatParticipantsComponent, rhs: VideoChatParticipantsComponent) -> Bool { if lhs.members != rhs.members { return false } + if lhs.expandedVideoState != rhs.expandedVideoState { + return false + } if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } + if lhs.collapsedContainerInsets != rhs.collapsedContainerInsets { + return false + } + if lhs.expandedContainerInsets != rhs.expandedContainerInsets { + return false + } if lhs.sideInset != rhs.sideInset { return false } @@ -116,6 +173,20 @@ final class VideoChatParticipantsComponent: Component { } } + struct ExpandedGrid { + let containerSize: CGSize + let containerInsets: UIEdgeInsets + + init(containerSize: CGSize, containerInsets: UIEdgeInsets) { + self.containerSize = containerSize + self.containerInsets = containerInsets + } + + func itemContainerFrame() -> CGRect { + return CGRect(origin: CGPoint(x: self.containerInsets.left, y: self.containerInsets.top), size: CGSize(width: self.containerSize.width - self.containerInsets.left - self.containerInsets.right, height: self.containerSize.height - self.containerInsets.top - containerInsets.bottom)) + } + } + struct List { let containerSize: CGSize let sideInset: CGFloat @@ -168,19 +239,24 @@ final class VideoChatParticipantsComponent: Component { let containerSize: CGSize let sideInset: CGFloat let grid: Grid + let expandedGrid: ExpandedGrid let list: List let spacing: CGFloat + let gridOffsetY: CGFloat let listOffsetY: CGFloat - init(containerSize: CGSize, sideInset: CGFloat, gridItemCount: Int, listItemCount: Int, listItemHeight: CGFloat, listTrailingItemHeight: CGFloat) { + init(containerSize: CGSize, sideInset: CGFloat, collapsedContainerInsets: UIEdgeInsets, expandedContainerInsets: UIEdgeInsets, gridItemCount: Int, listItemCount: Int, listItemHeight: CGFloat, listTrailingItemHeight: CGFloat) { self.containerSize = containerSize self.sideInset = sideInset - self.grid = Grid(containerSize: containerSize, sideInset: sideInset, itemCount: gridItemCount) - self.list = List(containerSize: containerSize, sideInset: sideInset, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) + self.grid = Grid(containerSize: CGSize(width: containerSize.width - sideInset * 2.0, height: containerSize.height), sideInset: 0.0, itemCount: gridItemCount) + self.expandedGrid = ExpandedGrid(containerSize: containerSize, containerInsets: expandedContainerInsets) + self.list = List(containerSize: CGSize(width: containerSize.width - sideInset * 2.0, height: containerSize.height), sideInset: 0.0, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) self.spacing = 4.0 - var listOffsetY: CGFloat = 0.0 + self.gridOffsetY = collapsedContainerInsets.top + + var listOffsetY: CGFloat = self.gridOffsetY if self.grid.itemCount != 0 { listOffsetY += self.grid.contentHeight() listOffsetY += self.spacing @@ -199,42 +275,40 @@ final class VideoChatParticipantsComponent: Component { } func visibleGridItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { - return self.grid.visibleItemRange(for: rect) + return self.grid.visibleItemRange(for: rect.offsetBy(dx: 0.0, dy: -self.gridOffsetY)) } func gridItemFrame(at index: Int) -> CGRect { return self.grid.frame(at: index) } + func gridItemContainerFrame() -> CGRect { + return CGRect(origin: CGPoint(x: self.sideInset, y: self.gridOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.grid.contentHeight())) + } + func visibleListItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { return self.list.visibleItemRange(for: rect.offsetBy(dx: 0.0, dy: -self.listOffsetY)) } func listItemFrame(at index: Int) -> CGRect { - return self.list.frame(at: index).offsetBy(dx: 0.0, dy: self.listOffsetY) + return self.list.frame(at: index) + } + + func listItemContainerFrame() -> CGRect { + return CGRect(origin: CGPoint(x: self.sideInset, y: self.listOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.list.contentHeight())) } func listTrailingItemFrame() -> CGRect { - return self.list.trailingItemFrame().offsetBy(dx: 0.0, dy: self.listOffsetY) + return self.list.trailingItemFrame() } } private final class VideoParticipant: Equatable { - struct Key: Hashable { - var id: EnginePeer.Id - var isPresentation: Bool - - init(id: EnginePeer.Id, isPresentation: Bool) { - self.id = id - self.isPresentation = isPresentation - } - } - let participant: GroupCallParticipantsContext.Participant let isPresentation: Bool - var key: Key { - return Key(id: self.participant.peer.id, isPresentation: self.isPresentation) + var key: VideoParticipantKey { + return VideoParticipantKey(id: self.participant.peer.id, isPresentation: self.isPresentation) } init(participant: GroupCallParticipantsContext.Participant, isPresentation: Bool) { @@ -252,8 +326,19 @@ final class VideoChatParticipantsComponent: Component { return true } } + + private final class GridItem { + let key: VideoParticipantKey + let view = ComponentView() + var isCollapsing: Bool = false + + init(key: VideoParticipantKey) { + self.key = key + } + } final class View: UIView, UIScrollViewDelegate { + private let scollViewClippingContainer: UIView private let scrollView: ScrollView private var component: VideoChatParticipantsComponent? @@ -267,16 +352,35 @@ final class VideoChatParticipantsComponent: Component { private let measureListItemView = ComponentView() private let inviteListItemView = ComponentView() - private var gridItemViews: [VideoParticipant.Key: ComponentView] = [:] + private var gridItemViews: [VideoParticipantKey: GridItem] = [:] + private let gridItemViewContainer: UIView + + private let expandedGridItemContainer: UIView + private var expandedGridItemView: GridItem? private var listItemViews: [EnginePeer.Id: ComponentView] = [:] + private let listItemViewContainer: UIView private let listItemsBackround = ComponentView() private var itemLayout: ItemLayout? + private var appliedGridIsEmpty: Bool = true + override init(frame: CGRect) { + self.scollViewClippingContainer = UIView() + self.scollViewClippingContainer.clipsToBounds = true + self.scrollView = ScrollView() + self.gridItemViewContainer = UIView() + self.gridItemViewContainer.layer.anchorPoint = CGPoint(x: 0.5, y: 0.0) + + self.listItemViewContainer = UIView() + self.listItemViewContainer.clipsToBounds = true + + self.expandedGridItemContainer = UIView() + self.expandedGridItemContainer.clipsToBounds = true + super.init(frame: frame) self.scrollView.delaysContentTouches = false @@ -294,13 +398,38 @@ final class VideoChatParticipantsComponent: Component { self.scrollView.delegate = self self.scrollView.clipsToBounds = true - self.addSubview(self.scrollView) + self.scollViewClippingContainer.addSubview(self.scrollView) + self.addSubview(self.scollViewClippingContainer) + + self.scrollView.addSubview(self.listItemViewContainer) + self.scrollView.addSubview(self.gridItemViewContainer) + self.addSubview(self.expandedGridItemContainer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let component = self.component else { + return nil + } + + if component.expandedVideoState != nil { + if let result = self.expandedGridItemContainer.hitTest(self.convert(point, to: self.expandedGridItemContainer), with: event) { + return result + } else { + return self + } + } else { + if let result = self.scollViewClippingContainer.hitTest(self.convert(point, to: self.scollViewClippingContainer), with: event) { + return result + } else { + return nil + } + } + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate) @@ -312,51 +441,158 @@ final class VideoChatParticipantsComponent: Component { return } - var validGridItemIds: [VideoParticipant.Key] = [] + let gridWasEmpty = self.appliedGridIsEmpty + let gridIsEmpty = self.gridParticipants.isEmpty + self.appliedGridIsEmpty = gridIsEmpty + + var expandedGridItemContainerFrame: CGRect + if component.expandedVideoState != nil { + expandedGridItemContainerFrame = itemLayout.expandedGrid.itemContainerFrame() + } else { + expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + if expandedGridItemContainerFrame.origin.y < component.collapsedContainerInsets.top { + expandedGridItemContainerFrame.size.height -= component.collapsedContainerInsets.top - expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.origin.y = component.collapsedContainerInsets.top + } + if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height - component.collapsedContainerInsets.bottom { + expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height - component.collapsedContainerInsets.bottom) + } + if expandedGridItemContainerFrame.size.height < 0.0 { + expandedGridItemContainerFrame.size.height = 0.0 + } + } + + let commonGridItemTransition: ComponentTransition = (gridIsEmpty == gridWasEmpty) ? transition : .immediate + + var validGridItemIds: [VideoParticipantKey] = [] + var validGridItemIndices: [Int] = [] + let visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.scrollView.bounds) if visibleGridItemRange.maxIndex >= visibleGridItemRange.minIndex { - for i in visibleGridItemRange.minIndex ... visibleGridItemRange.maxIndex { - let videoParticipant = self.gridParticipants[i] - validGridItemIds.append(videoParticipant.key) - - var itemTransition = transition - let itemView: ComponentView - if let current = self.gridItemViews[videoParticipant.key] { - itemView = current - } else { - itemTransition = itemTransition.withAnimation(.none) - itemView = ComponentView() - self.gridItemViews[videoParticipant.key] = itemView - } - - let itemFrame = itemLayout.gridItemFrame(at: i) - - let _ = itemView.update( - transition: itemTransition, - component: AnyComponent(VideoChatParticipantVideoComponent( - call: component.call, - participant: videoParticipant.participant, - isPresentation: videoParticipant.isPresentation - )), - environment: {}, - containerSize: itemFrame.size - ) - if let itemComponentView = itemView.view { - if itemComponentView.superview == nil { - self.scrollView.addSubview(itemComponentView) + for index in visibleGridItemRange.minIndex ... visibleGridItemRange.maxIndex { + let videoParticipant = self.gridParticipants[index] + let videoParticipantKey = videoParticipant.key + validGridItemIds.append(videoParticipantKey) + validGridItemIndices.append(index) + } + } + if let expandedVideoState = component.expandedVideoState { + if !validGridItemIds.contains(expandedVideoState.mainParticipant), let index = self.gridParticipants.firstIndex(where: { $0.key == expandedVideoState.mainParticipant }) { + validGridItemIds.append(expandedVideoState.mainParticipant) + validGridItemIndices.append(index) + } + } + + for index in validGridItemIndices { + let videoParticipant = self.gridParticipants[index] + let videoParticipantKey = videoParticipant.key + validGridItemIds.append(videoParticipantKey) + + var itemTransition = commonGridItemTransition + let itemView: GridItem + if let current = self.gridItemViews[videoParticipantKey] { + itemView = current + } else { + itemTransition = itemTransition.withAnimation(.none) + itemView = GridItem(key: videoParticipant.key) + self.gridItemViews[videoParticipantKey] = itemView + } + + var expandedItemState: VideoChatParticipantVideoComponent.ExpandedState? + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant == videoParticipantKey { + expandedItemState = VideoChatParticipantVideoComponent.ExpandedState(isPinned: expandedVideoState.isMainParticipantPinned) + } + + let itemFrame: CGRect + if expandedItemState != nil { + itemFrame = CGRect(origin: CGPoint(), size: itemLayout.expandedGrid.itemContainerFrame().size) + } else { + itemFrame = itemLayout.gridItemFrame(at: index) + } + + let _ = itemView.view.update( + transition: itemTransition, + component: AnyComponent(VideoChatParticipantVideoComponent( + call: component.call, + participant: videoParticipant.participant, + isPresentation: videoParticipant.isPresentation, + expandedState: expandedItemState, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + if component.expandedVideoState?.mainParticipant == videoParticipantKey { + component.updateMainParticipant(nil) + } else { + component.updateMainParticipant(videoParticipantKey) + } } - itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemComponentView = itemView.view.view { + if itemComponentView.superview == nil { + if expandedItemState != nil { + self.expandedGridItemContainer.addSubview(itemComponentView) + } else { + self.gridItemViewContainer.addSubview(itemComponentView) + } + + itemComponentView.frame = itemFrame + + if !commonGridItemTransition.animation.isImmediate { + commonGridItemTransition.animateScale(view: itemComponentView, from: 0.001, to: 1.0) + } + if !transition.animation.isImmediate { + itemComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + } + } else if expandedItemState != nil && itemComponentView.superview != self.expandedGridItemContainer { + let fromFrame = itemComponentView.convert(itemComponentView.bounds, to: self.expandedGridItemContainer) + itemComponentView.center = fromFrame.center + self.expandedGridItemContainer.addSubview(itemComponentView) + } else if expandedItemState == nil && itemComponentView.superview != self.gridItemViewContainer { + if !itemView.isCollapsing { + itemView.isCollapsing = true + let targetLocalItemFrame = itemLayout.gridItemFrame(at: index) + var targetItemFrame = self.gridItemViewContainer.convert(targetLocalItemFrame, to: self) + targetItemFrame.origin.y -= expandedGridItemContainerFrame.minY + targetItemFrame.origin.x -= expandedGridItemContainerFrame.minX + commonGridItemTransition.setPosition(view: itemComponentView, position: targetItemFrame.center) + commonGridItemTransition.setBounds(view: itemComponentView, bounds: CGRect(origin: CGPoint(), size: targetItemFrame.size), completion: { [weak self, weak itemView, weak itemComponentView] _ in + guard let self, let itemView, let itemComponentView else { + return + } + itemView.isCollapsing = false + self.gridItemViewContainer.addSubview(itemComponentView) + itemComponentView.center = targetLocalItemFrame.center + itemComponentView.bounds = CGRect(origin: CGPoint(), size: targetLocalItemFrame.size) + }) + } + } + if !itemView.isCollapsing { + commonGridItemTransition.setPosition(view: itemComponentView, position: itemFrame.center) + commonGridItemTransition.setBounds(view: itemComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) } } } - var removedGridItemIds: [VideoParticipant.Key] = [] + var removedGridItemIds: [VideoParticipantKey] = [] for (itemId, itemView) in self.gridItemViews { if !validGridItemIds.contains(itemId) { removedGridItemIds.append(itemId) - if let itemComponentView = itemView.view { - itemComponentView.removeFromSuperview() + if let itemComponentView = itemView.view.view { + if !transition.animation.isImmediate { + if commonGridItemTransition.animation.isImmediate == transition.animation.isImmediate { + transition.setScale(view: itemComponentView, scale: 0.001) + } + itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } else { + itemComponentView.removeFromSuperview() + } } } } @@ -418,9 +654,16 @@ final class VideoChatParticipantsComponent: Component { ) if let itemComponentView = itemView.view { if itemComponentView.superview == nil { - self.scrollView.addSubview(itemComponentView) + itemComponentView.clipsToBounds = true + + self.listItemViewContainer.addSubview(itemComponentView) + + if !transition.animation.isImmediate { + itemComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + itemComponentView.frame = CGRect(origin: itemFrame.origin, size: CGSize(width: itemFrame.width, height: 0.0)) + } } - itemTransition.setFrame(view: itemComponentView, frame: itemFrame) + transition.setFrame(view: itemComponentView, frame: itemFrame) } } } @@ -431,7 +674,13 @@ final class VideoChatParticipantsComponent: Component { removedListItemIds.append(itemId) if let itemComponentView = itemView.view { - itemComponentView.removeFromSuperview() + if !transition.animation.isImmediate { + itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in + itemComponentView?.removeFromSuperview() + }) + } else { + itemComponentView.removeFromSuperview() + } } } } @@ -448,11 +697,18 @@ final class VideoChatParticipantsComponent: Component { if let itemComponentView = itemView.view { if itemComponentView.superview == nil { itemTransition = itemTransition.withAnimation(.none) - self.scrollView.addSubview(itemComponentView) + self.listItemViewContainer.addSubview(itemComponentView) } itemTransition.setFrame(view: itemComponentView, frame: itemFrame) } } + + transition.setScale(view: self.gridItemViewContainer, scale: gridIsEmpty ? 0.001 : 1.0) + transition.setPosition(view: self.gridItemViewContainer, position: CGPoint(x: itemLayout.gridItemContainerFrame().midX, y: itemLayout.gridItemContainerFrame().minY)) + transition.setBounds(view: self.gridItemViewContainer, bounds: CGRect(origin: CGPoint(), size: itemLayout.gridItemContainerFrame().size)) + transition.setFrame(view: self.listItemViewContainer, frame: itemLayout.listItemContainerFrame()) + + transition.setFrame(view: self.expandedGridItemContainer, frame: expandedGridItemContainerFrame) } func update(component: VideoChatParticipantsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { @@ -533,6 +789,8 @@ final class VideoChatParticipantsComponent: Component { let itemLayout = ItemLayout( containerSize: availableSize, sideInset: component.sideInset, + collapsedContainerInsets: component.collapsedContainerInsets, + expandedContainerInsets: component.expandedContainerInsets, gridItemCount: gridParticipants.count, listItemCount: listParticipants.count, listItemHeight: measureListItemSize.height, @@ -549,10 +807,10 @@ final class VideoChatParticipantsComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - itemLayout.sideInset * 2.0, height: itemLayout.list.contentHeight()) ) - let listItemsBackroundFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: itemLayout.listOffsetY), size: listItemsBackroundSize) + let listItemsBackroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: listItemsBackroundSize) if let listItemsBackroundView = self.listItemsBackround.view { if listItemsBackroundView.superview == nil { - self.scrollView.addSubview(listItemsBackroundView) + self.listItemViewContainer.addSubview(listItemsBackroundView) } transition.setFrame(view: listItemsBackroundView, frame: listItemsBackroundFrame) } @@ -560,18 +818,38 @@ final class VideoChatParticipantsComponent: Component { var requestedVideo: [PresentationGroupCallRequestedVideo] = [] if let members = component.members { for participant in members.participants { - if let videoChannel = participant.requestedVideoChannel(minQuality: .thumbnail, maxQuality: .medium) { - requestedVideo.append(videoChannel) + var maxVideoQuality: PresentationGroupCallRequestedVideo.Quality = .medium + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant.id == participant.peer.id, !expandedVideoState.mainParticipant.isPresentation { + maxVideoQuality = .full } - if let videoChannel = participant.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: .medium) { - requestedVideo.append(videoChannel) + + var maxPresentationQuality: PresentationGroupCallRequestedVideo.Quality = .medium + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant.id == participant.peer.id, expandedVideoState.mainParticipant.isPresentation { + maxPresentationQuality = .full + } + + if let videoChannel = participant.requestedVideoChannel(minQuality: .thumbnail, maxQuality: maxVideoQuality) { + if !requestedVideo.contains(videoChannel) { + requestedVideo.append(videoChannel) + } + } + if let videoChannel = participant.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: maxPresentationQuality) { + if !requestedVideo.contains(videoChannel) { + requestedVideo.append(videoChannel) + } } } } (component.call as! PresentationGroupCallImpl).setRequestedVideoList(items: requestedVideo) + let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: component.collapsedContainerInsets.top), size: CGSize(width: availableSize.width, height: availableSize.height - component.collapsedContainerInsets.top - component.collapsedContainerInsets.bottom)) + transition.setPosition(view: self.scollViewClippingContainer, position: scrollClippingFrame.center) + transition.setBounds(view: self.scollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) + self.ignoreScrolling = true - transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) + if self.scrollView.bounds.size != availableSize { + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentHeight()) if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index daf7fcfdbe..f851188df0 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -73,6 +73,11 @@ private final class VideoChatScreenComponent: Component { private var members: PresentationGroupCallMembers? private var membersDisposable: Disposable? + private let isPresentedValue = ValuePromise(false, ignoreRepeated: true) + private var applicationStateDisposable: Disposable? + + private var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + override init(frame: CGRect) { self.containerView = UIView() self.containerView.clipsToBounds = true @@ -96,6 +101,7 @@ private final class VideoChatScreenComponent: Component { deinit { self.stateDisposable?.dispose() self.membersDisposable?.dispose() + self.applicationStateDisposable?.dispose() } func animateIn() { @@ -272,7 +278,7 @@ private final class VideoChatScreenComponent: Component { self.members = members if !self.isUpdating { - self.state?.updated(transition: .immediate) + self.state?.updated(transition: .spring(duration: 0.4)) } } }) @@ -290,8 +296,22 @@ private final class VideoChatScreenComponent: Component { } } }) + + self.applicationStateDisposable = (combineLatest(queue: .mainQueue(), + component.call.accountContext.sharedContext.applicationBindings.applicationIsActive, + self.isPresentedValue.get() + ) + |> deliverOnMainQueue).startStrict(next: { [weak self] applicationIsActive, isPresented in + guard let self, let component = self.component else { + return + } + let suspendVideoChannelRequests = !applicationIsActive || !isPresented + component.call.setSuspendVideoChannelRequests(suspendVideoChannelRequests) + }) } + self.isPresentedValue.set(environment.isVisible) + self.component = component self.environment = environment self.state = state @@ -419,7 +439,7 @@ private final class VideoChatScreenComponent: Component { } let actionButtonDiameter: CGFloat = 56.0 - let microphoneButtonDiameter: CGFloat = 116.0 + let microphoneButtonDiameter: CGFloat = self.expandedParticipantsVideoState == nil ? 116.0 : actionButtonDiameter let maxActionMicrophoneButtonSpacing: CGFloat = 38.0 let buttonsSideInset: CGFloat = 42.0 @@ -428,23 +448,60 @@ private final class VideoChatScreenComponent: Component { let remainingButtonsSpace: CGFloat = availableSize.width - buttonsSideInset * 2.0 - buttonsWidth let actionMicrophoneButtonSpacing = min(maxActionMicrophoneButtonSpacing, floor(remainingButtonsSpace * 0.5)) - let microphoneButtonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - microphoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - microphoneButtonDiameter), size: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter)) + let microphoneButtonFrame: CGRect + if self.expandedParticipantsVideoState == nil { + microphoneButtonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - microphoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - microphoneButtonDiameter), size: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter)) + } else { + microphoneButtonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - microphoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - microphoneButtonDiameter - 12.0), size: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter)) + } + + let participantsClippingY: CGFloat + if self.expandedParticipantsVideoState == nil { + participantsClippingY = microphoneButtonFrame.minY + } else { + participantsClippingY = microphoneButtonFrame.minY - 24.0 + } + let leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) let rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) - let participantsSize = self.participants.update( + let participantsSize = availableSize + let participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - participantsClippingY, right: environment.safeInsets.right) + let participantsExpandedInsets = UIEdgeInsets(top: environment.statusBarHeight, left: environment.safeInsets.left, bottom: availableSize.height - participantsClippingY, right: environment.safeInsets.right) + + let _ = self.participants.update( transition: transition, component: AnyComponent(VideoChatParticipantsComponent( call: component.call, members: self.members, + expandedVideoState: self.expandedParticipantsVideoState, theme: environment.theme, strings: environment.strings, - sideInset: sideInset + collapsedContainerInsets: participantsCollapsedInsets, + expandedContainerInsets: participantsExpandedInsets, + sideInset: sideInset, + updateMainParticipant: { [weak self] key in + guard let self else { + return + } + if let key { + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.mainParticipant == key { + return + } + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: key, isMainParticipantPinned: false) + self.state?.updated(transition: .spring(duration: 0.4)) + } else if self.expandedParticipantsVideoState != nil { + self.expandedParticipantsVideoState = nil + self.state?.updated(transition: .spring(duration: 0.4)) + } + }, + updateIsMainParticipantPinned: { isPinned in + } )), environment: {}, - containerSize: CGSize(width: availableSize.width, height: microphoneButtonFrame.minY - navigationHeight) + containerSize: participantsSize ) - let participantsFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight), size: participantsSize) + let participantsFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: participantsSize) if let participantsView = self.participants.view { if participantsView.superview == nil { self.containerView.addSubview(participantsView) @@ -477,7 +534,8 @@ private final class VideoChatScreenComponent: Component { transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(VideoChatMicButtonComponent( - content: micButtonContent + content: micButtonContent, + isCollapsed: self.expandedParticipantsVideoState != nil )), effectAlignment: .center, action: { [weak self] in @@ -514,7 +572,8 @@ private final class VideoChatScreenComponent: Component { component: AnyComponent(PlainButtonComponent( content: AnyComponent(VideoChatActionButtonComponent( content: .video(isActive: false), - microphoneState: actionButtonMicrophoneState + microphoneState: actionButtonMicrophoneState, + isCollapsed: self.expandedParticipantsVideoState != nil )), effectAlignment: .center, action: { [weak self] in @@ -541,7 +600,8 @@ private final class VideoChatScreenComponent: Component { component: AnyComponent(PlainButtonComponent( content: AnyComponent(VideoChatActionButtonComponent( content: .leave, - microphoneState: actionButtonMicrophoneState + microphoneState: actionButtonMicrophoneState, + isCollapsed: self.expandedParticipantsVideoState != nil )), effectAlignment: .center, action: { [weak self] in @@ -665,7 +725,9 @@ final class VideoChatScreenV2Impl: ViewControllerComponentContainer, VoiceChatCo self.idleTimerExtensionDisposable = self.call.accountContext.sharedContext.applicationBindings.pushIdleTimerExtension() } - self.onViewDidAppear?() + DispatchQueue.main.async { + self.onViewDidAppear?() + } } override public func viewDidDisappear(_ animated: Bool) {