import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import TelegramStringFormatting import TelegramVoip import TelegramAudio import AccountContext import Postbox import TelegramCore import MergeLists import ItemListUI import AppBundle import ContextUI import ShareController import DeleteChatPeerActionSheetItem import UndoUI import AlertUI import PresentationDataUtils import DirectionalPanGesture import PeerInfoUI import AvatarNode import TooltipUI import LegacyUI import LegacyComponents import LegacyMediaPickerUI import WebSearchUI import MapResourceToAvatarSizes import SolidRoundedButtonNode import AudioBlob import DeviceAccess let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) let fullscreenBackgroundColor = UIColor(rgb: 0x000000) let smallButtonSize = CGSize(width: 36.0, height: 36.0) let sideButtonSize = CGSize(width: 56.0, height: 56.0) let topPanelHeight: CGFloat = 63.0 let bottomAreaHeight: CGFloat = 206.0 let fullscreenBottomAreaHeight: CGFloat = 80.0 let bottomGradientHeight: CGFloat = 70.0 func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { if !top && !bottom { return nil } return generateImage(CGSize(width: 50.0, height: 50.0), rotatedContext: { (size, context) in let bounds = CGRect(origin: CGPoint(), size: size) context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) context.fill(bounds) context.setBlendMode(.clear) var corners: UIRectCorner = [] if top { corners.insert(.topLeft) corners.insert(.topRight) } if bottom { corners.insert(.bottomLeft) corners.insert(.bottomRight) } let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) context.addPath(path.cgPath) context.fillPath() })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 25) } func decorationTopCornersImage(dark: Bool) -> UIImage? { return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in let bounds = CGRect(origin: CGPoint(), size: size) context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) context.fill(bounds) context.setBlendMode(.clear) var corners: UIRectCorner = [] corners.insert(.topLeft) corners.insert(.topRight) let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 60.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) context.addPath(path.cgPath) context.fillPath() })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 32) } func decorationBottomCornersImage(dark: Bool) -> UIImage? { return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in let bounds = CGRect(origin: CGPoint(), size: size) context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) context.fill(bounds) context.setBlendMode(.clear) var corners: UIRectCorner = [] corners.insert(.bottomLeft) corners.insert(.bottomRight) let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) context.addPath(path.cgPath) context.fillPath() })?.resizableImage(withCapInsets: UIEdgeInsets(top: 25.0, left: 25.0, bottom: 0.0, right: 25.0), resizingMode: .stretch) } private func decorationBottomGradientImage(dark: Bool) -> UIImage? { return generateImage(CGSize(width: 24.0, height: bottomGradientHeight), rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) let color = dark ? fullscreenBackgroundColor : panelBackgroundColor let colorsArray = [color.withAlphaComponent(0.0).cgColor, color.cgColor] as CFArray var locations: [CGFloat] = [1.0, 0.0] let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) }) } struct VoiceChatPeerEntry: Identifiable { enum State { case listening case speaking case invited case raisedHand } var peer: Peer var about: String? var isMyPeer: Bool var videoEndpointId: String? var videoPaused: Bool var presentationEndpointId: String? var presentationPaused: Bool var effectiveSpeakerVideoEndpointId: String? var state: State var muteState: GroupCallParticipantsContext.Participant.MuteState? var canManageCall: Bool var volume: Int32? var raisedHand: Bool var displayRaisedHandStatus: Bool var active: Bool var isLandscape: Bool var effectiveVideoEndpointId: String? { return self.presentationEndpointId ?? self.videoEndpointId } init( peer: Peer, about: String?, isMyPeer: Bool, videoEndpointId: String?, videoPaused: Bool, presentationEndpointId: String?, presentationPaused: Bool, effectiveSpeakerVideoEndpointId: String?, state: State, muteState: GroupCallParticipantsContext.Participant.MuteState?, canManageCall: Bool, volume: Int32?, raisedHand: Bool, displayRaisedHandStatus: Bool, active: Bool, isLandscape: Bool ) { self.peer = peer self.about = about self.isMyPeer = isMyPeer self.videoEndpointId = videoEndpointId self.videoPaused = videoPaused self.presentationEndpointId = presentationEndpointId self.presentationPaused = presentationPaused self.effectiveSpeakerVideoEndpointId = effectiveSpeakerVideoEndpointId self.state = state self.muteState = muteState self.canManageCall = canManageCall self.volume = volume self.raisedHand = raisedHand self.displayRaisedHandStatus = displayRaisedHandStatus self.active = active self.isLandscape = isLandscape } var stableId: PeerId { return self.peer.id } static func ==(lhs: VoiceChatPeerEntry, rhs: VoiceChatPeerEntry) -> Bool { if !lhs.peer.isEqual(rhs.peer) { return false } if lhs.about != rhs.about { return false } if lhs.isMyPeer != rhs.isMyPeer { return false } if lhs.videoEndpointId != rhs.videoEndpointId { return false } if lhs.videoPaused != rhs.videoPaused { return false } if lhs.presentationEndpointId != rhs.presentationEndpointId { return false } if lhs.presentationPaused != rhs.presentationPaused { return false } if lhs.effectiveSpeakerVideoEndpointId != rhs.effectiveSpeakerVideoEndpointId { return false } if lhs.state != rhs.state { return false } if lhs.muteState != rhs.muteState { return false } if lhs.canManageCall != rhs.canManageCall { return false } if lhs.volume != rhs.volume { return false } if lhs.raisedHand != rhs.raisedHand { return false } if lhs.displayRaisedHandStatus != rhs.displayRaisedHandStatus { return false } if lhs.active != rhs.active { return false } if lhs.isLandscape != rhs.isLandscape { return false } return true } } public protocol VoiceChatController: ViewController { var call: PresentationGroupCall { get } var currentOverlayController: VoiceChatOverlayController? { get } var parentNavigationController: NavigationController? { get set } func dismiss(closing: Bool, manual: Bool) } public final class VoiceChatControllerImpl: ViewController, VoiceChatController { enum DisplayMode { case modal(isExpanded: Bool, isFilled: Bool) case fullscreen(controlsHidden: Bool) } fileprivate final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { private struct ListTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let isLoading: Bool let isEmpty: Bool let canInvite: Bool let crossFade: Bool let count: Int let animated: Bool } private final class Interaction { let updateIsMuted: (PeerId, Bool) -> Void let switchToPeer: (PeerId, String?, Bool) -> Void let openInvite: () -> Void let peerContextAction: (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void let getPeerVideo: (String, GroupVideoNode.Position) -> GroupVideoNode? var isExpanded: Bool = false private var audioLevels: [PeerId: ValuePipe] = [:] var updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) init( updateIsMuted: @escaping (PeerId, Bool) -> Void, switchToPeer: @escaping (PeerId, String?, Bool) -> Void, openInvite: @escaping () -> Void, peerContextAction: @escaping (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void, getPeerVideo: @escaping (String, GroupVideoNode.Position) -> GroupVideoNode? ) { self.updateIsMuted = updateIsMuted self.switchToPeer = switchToPeer self.openInvite = openInvite self.peerContextAction = peerContextAction self.getPeerVideo = getPeerVideo } func getAudioLevel(_ peerId: PeerId) -> Signal { let signal: Signal if let current = self.audioLevels[peerId] { signal = current.signal() } else { let value = ValuePipe() self.audioLevels[peerId] = value signal = value.signal() } return signal |> mapToSignal { value in return .single(value) } } func updateAudioLevels(_ levels: [(PeerId, UInt32, Float, Bool)], reset: Bool = false) { var updated = Set() for (peerId, _, level, _) in levels { if let pipe = self.audioLevels[peerId] { if reset { pipe.putNext(level) } else { pipe.putNext(max(0.001, level)) } updated.insert(peerId) } } if !reset { for (peerId, pipe) in self.audioLevels { if !updated.contains(peerId) { pipe.putNext(0.0) } } } } } private enum EntryId: Hashable { case tiles case invite case peerId(PeerId) static func <(lhs: EntryId, rhs: EntryId) -> Bool { return lhs.hashValue < rhs.hashValue } static func ==(lhs: EntryId, rhs: EntryId) -> Bool { switch lhs { case .tiles: switch rhs { case .tiles: return true default: return false } case .invite: switch rhs { case .invite: return true default: return false } case let .peerId(lhsId): switch rhs { case let .peerId(rhsId): return lhsId == rhsId default: return false } } } } private enum ListEntry: Comparable, Identifiable { case tiles([VoiceChatTileItem], VoiceChatTileLayoutMode, Int32, Bool) case invite(PresentationTheme, PresentationStrings, String, Bool) case peer(VoiceChatPeerEntry, Int32) var stableId: EntryId { switch self { case .tiles: return .tiles case .invite: return .invite case let .peer(peerEntry, _): return .peerId(peerEntry.peer.id) } } static func ==(lhs: ListEntry, rhs: ListEntry) -> Bool { switch lhs { case let .tiles(lhsTiles, lhsLayoutMode, lhsVideoLimit, lhsReachedLimit): if case let .tiles(rhsTiles, rhsLayoutMode, rhsVideoLimit, rhsReachedLimit) = rhs, lhsTiles == rhsTiles, lhsLayoutMode == rhsLayoutMode, lhsVideoLimit == rhsVideoLimit, lhsReachedLimit == rhsReachedLimit { return true } else { return false } case let .invite(lhsTheme, lhsStrings, lhsText, lhsIsLink): if case let .invite(rhsTheme, rhsStrings, rhsText, rhsIsLink) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsText == rhsText, lhsIsLink == rhsIsLink { return true } else { return false } case let .peer(lhsPeerEntry, lhsIndex): switch rhs { case let .peer(rhsPeerEntry, rhsIndex): return lhsPeerEntry == rhsPeerEntry && lhsIndex == rhsIndex default: return false } } } static func <(lhs: ListEntry, rhs: ListEntry) -> Bool { switch lhs { case .tiles: return true case .invite: return false case let .peer(_, lhsIndex): switch rhs { case .tiles: return false case let .peer(_, rhsIndex): return lhsIndex < rhsIndex case .invite: return true } } } func tileItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction, isTablet: Bool, videoEndpointId: String, videoReady: Bool, videoTimeouted: Bool, videoIsPaused: Bool, showAsPresentation: Bool, secondary: Bool) -> VoiceChatTileItem? { guard case let .peer(peerEntry, _) = self else { return nil } let peer = peerEntry.peer let icon: VoiceChatTileItem.Icon var text: VoiceChatParticipantItem.ParticipantText var additionalText: VoiceChatParticipantItem.ParticipantText? var speaking = false var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() let yourText: String if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio } else if peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhoto } else if (peerEntry.about?.isEmpty ?? true) { yourText = presentationData.strings.VoiceChat_TapToAddBio } else { yourText = presentationData.strings.VoiceChat_You } var state = peerEntry.state if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { state = .listening } switch state { case .listening: if peerEntry.isMyPeer { text = .text(yourText, textIcon, .accent) } else if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) } else if let about = peerEntry.about, !about.isEmpty { text = .text(about, textIcon, .generic) } else { text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) } if let muteState = peerEntry.muteState, muteState.mutedByYou { icon = .microphone(true) additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) } else { icon = .microphone(peerEntry.muteState != nil) } case .speaking: if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) icon = .microphone(true) additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) } else { if peerEntry.volume != nil { textIcon.insert(.volume) } let volumeValue = peerEntry.volume.flatMap { $0 / 100 } if let volume = volumeValue, volume != 100 { text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) } else { text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) } icon = .microphone(false) speaking = true } case .raisedHand, .invited: text = .none icon = .none } if let about = peerEntry.about, !about.isEmpty { textIcon = [] text = .text(about, textIcon, .generic) } return VoiceChatTileItem(account: context.account, peer: EnginePeer(peerEntry.peer), videoEndpointId: videoEndpointId, videoReady: videoReady, videoTimeouted: videoTimeouted, isVideoLimit: false, videoLimit: 0, isPaused: videoIsPaused, isOwnScreencast: peerEntry.presentationEndpointId == videoEndpointId && peerEntry.isMyPeer, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, speaking: speaking, secondary: secondary, isTablet: isTablet, icon: showAsPresentation ? .presentation : icon, text: text, additionalText: additionalText, action: { interaction.switchToPeer(peer.id, videoEndpointId, !secondary) }, contextAction: { node, gesture in interaction.peerContextAction(peerEntry, node, gesture, false) }, getVideo: { position in return interaction.getPeerVideo(videoEndpointId, position) }, getAudioLevel: { return interaction.getAudioLevel(peerEntry.peer.id) }) } func fullscreenItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { switch self { case .tiles: return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .none, action: { }) case .invite: return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .generic(UIImage(bundleImageName: "Chat/Context Menu/AddUser")!), action: { interaction.openInvite() }) case let .peer(peerEntry, _): let peer = peerEntry.peer var textColor: VoiceChatFullscreenParticipantItem.Color = .generic var color: VoiceChatFullscreenParticipantItem.Color = .generic let icon: VoiceChatFullscreenParticipantItem.Icon var text: VoiceChatParticipantItem.ParticipantText var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() let yourText: String if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio } else if peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhoto } else if (peerEntry.about?.isEmpty ?? true) { yourText = presentationData.strings.VoiceChat_TapToAddBio } else { yourText = presentationData.strings.VoiceChat_You } var state = peerEntry.state if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { state = .listening } switch state { case .listening: if peerEntry.isMyPeer { text = .text(yourText, textIcon, .accent) } else if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) } else if let about = peerEntry.about, !about.isEmpty { text = .text(about, textIcon, .generic) } else { text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) } if let muteState = peerEntry.muteState, muteState.mutedByYou { textColor = .destructive color = .destructive icon = .microphone(true, UIColor(rgb: 0xff3b30)) } else { icon = .microphone(peerEntry.muteState != nil, UIColor.white) color = .accent } case .speaking: if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) textColor = .destructive color = .destructive icon = .microphone(true, UIColor(rgb: 0xff3b30)) } else { if peerEntry.volume != nil { textIcon.insert(.volume) } let volumeValue = peerEntry.volume.flatMap { $0 / 100 } if let volume = volumeValue, volume != 100 { text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) } else { text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) } icon = .microphone(false, UIColor(rgb: 0x34c759)) textColor = .constructive color = .constructive } case .raisedHand: text = .none textColor = .accent icon = .wantsToSpeak case .invited: text = .none icon = .none } if let about = peerEntry.about, !about.isEmpty { textIcon = [] text = .text(about, textIcon, .generic) } var videoEndpointId = peerEntry.effectiveVideoEndpointId var otherVideoEndpointId: String? let hasBothVideos = peerEntry.presentationEndpointId != nil && peerEntry.videoEndpointId != nil if hasBothVideos { if let effectiveVideoEndpointId = peerEntry.effectiveSpeakerVideoEndpointId { if effectiveVideoEndpointId == peerEntry.videoEndpointId { videoEndpointId = peerEntry.presentationEndpointId otherVideoEndpointId = videoEndpointId } else if effectiveVideoEndpointId == peerEntry.presentationEndpointId { videoEndpointId = peerEntry.videoEndpointId otherVideoEndpointId = videoEndpointId } } } var isPaused = false if videoEndpointId == peerEntry.videoEndpointId { isPaused = peerEntry.videoPaused } else if videoEndpointId == peerEntry.presentationEndpointId { isPaused = peerEntry.presentationPaused } return VoiceChatFullscreenParticipantItem(presentationData: ItemListPresentationData(presentationData), nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peerEntry.peer, videoEndpointId: videoEndpointId, isPaused: isPaused, icon: icon, text: text, textColor: textColor, color: color, isLandscape: peerEntry.isLandscape, active: peerEntry.active, showVideoWhenActive: otherVideoEndpointId != nil, getAudioLevel: { return interaction.getAudioLevel(peerEntry.peer.id) }, getVideo: { if let videoEndpointId = videoEndpointId { return interaction.getPeerVideo(videoEndpointId, .list) } else { return nil } }, action: { _ in interaction.switchToPeer(peerEntry.peer.id, otherVideoEndpointId, false) }, contextAction: { node, gesture in interaction.peerContextAction(peerEntry, node, gesture, true) }, getUpdatingAvatar: { return interaction.updateAvatarPromise.get() }) } } func item(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { switch self { case let .tiles(tiles, layoutMode, videoLimit, reachedLimit): return VoiceChatTilesGridItem(context: context, tiles: tiles, layoutMode: layoutMode, videoLimit: videoLimit, reachedLimit: reachedLimit, getIsExpanded: { return interaction.isExpanded }) case let .invite(_, _, text, isLink): return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: text, icon: .generic(UIImage(bundleImageName: isLink ? "Chat/Context Menu/Link" : "Chat/Context Menu/AddUser")!), action: { interaction.openInvite() }) case let .peer(peerEntry, _): let peer = peerEntry.peer var text: VoiceChatParticipantItem.ParticipantText var expandedText: VoiceChatParticipantItem.ParticipantText? let icon: VoiceChatParticipantItem.Icon var state = peerEntry.state if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { state = .listening } var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() let yourText: String if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio } else if peer.smallProfileImage == nil { yourText = presentationData.strings.VoiceChat_TapToAddPhoto } else if (peerEntry.about?.isEmpty ?? true) { yourText = presentationData.strings.VoiceChat_TapToAddBio } else { yourText = presentationData.strings.VoiceChat_You } switch state { case .listening: if peerEntry.isMyPeer { text = .text(yourText, textIcon, .accent) } else if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) } else if let about = peerEntry.about, !about.isEmpty { text = .text(about, textIcon, .generic) } else { text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) } let microphoneColor: UIColor if let muteState = peerEntry.muteState, !muteState.canUnmute || muteState.mutedByYou { microphoneColor = UIColor(rgb: 0xff3b30) } else { microphoneColor = UIColor(rgb: 0x979797) } icon = .microphone(peerEntry.muteState != nil, microphoneColor) case .speaking: if let muteState = peerEntry.muteState, muteState.mutedByYou { text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) icon = .microphone(true, UIColor(rgb: 0xff3b30)) } else { if peerEntry.volume != nil { textIcon.insert(.volume) } let volumeValue = peerEntry.volume.flatMap { $0 / 100 } if let volume = volumeValue, volume != 100 { text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) } else { text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) } icon = .microphone(false, UIColor(rgb: 0x34c759)) } case .invited: text = .text(presentationData.strings.VoiceChat_StatusInvited, textIcon, .generic) icon = .invite(true) case .raisedHand: if peerEntry.isMyPeer && !peerEntry.displayRaisedHandStatus { text = .text(yourText, textIcon, .accent) } else if let about = peerEntry.about, !about.isEmpty && !peerEntry.displayRaisedHandStatus { text = .text(about, textIcon, .generic) } else { text = .text(presentationData.strings.VoiceChat_StatusWantsToSpeak, textIcon, .accent) } icon = .wantsToSpeak } if let about = peerEntry.about, !about.isEmpty { textIcon = [] expandedText = .text(about, textIcon, .generic) } return VoiceChatParticipantItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: EnginePeer(peer), text: text, expandedText: expandedText, icon: icon, getAudioLevel: { return interaction.getAudioLevel(peer.id) }, action: { node in if let node = node { interaction.peerContextAction(peerEntry, node, nil, false) } }, contextAction: { node, gesture in interaction.peerContextAction(peerEntry, node, gesture, false) }, getIsExpanded: { return interaction.isExpanded }, getUpdatingAvatar: { return interaction.updateAvatarPromise.get() }) } } } private func preparedTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) } private func preparedFullscreenTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) } private let currentAvatarMixin = Atomic(value: nil) private var configuration: VoiceChatConfiguration? private weak var controller: VoiceChatControllerImpl? private let sharedContext: SharedAccountContext private let context: AccountContext private let call: PresentationGroupCall private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private var darkTheme: PresentationTheme private let dimNode: ASDisplayNode private let contentContainer: ASDisplayNode private let backgroundNode: ASDisplayNode private let listContainer: ASDisplayNode private let listNode: ListView private let fullscreenListContainer: ASDisplayNode private let fullscreenListNode: ListView private let tileGridNode: VoiceChatTileGridNode private let topPanelNode: ASDisplayNode private let topPanelEdgeNode: ASDisplayNode private let topPanelBackgroundNode: ASDisplayNode private let optionsButton: VoiceChatHeaderButton private let closeButton: VoiceChatHeaderButton private let panelButton: VoiceChatHeaderButton private let topCornersNode: ASImageNode fileprivate let bottomPanelNode: ASDisplayNode private let bottomGradientNode: ASDisplayNode private let bottomPanelBackgroundNode: ASDisplayNode private let bottomCornersNode: ASImageNode fileprivate let audioButton: CallControllerButtonItemNode fileprivate let cameraButton: CallControllerButtonItemNode fileprivate let switchCameraButton: CallControllerButtonItemNode fileprivate let leaveButton: CallControllerButtonItemNode fileprivate let actionButton: VoiceChatActionButton private let leftBorderNode: ASDisplayNode private let rightBorderNode: ASDisplayNode private let mainStageContainerNode: ASDisplayNode private let mainStageBackgroundNode: ASDisplayNode private let mainStageNode: VoiceChatMainStageNode private let transitionMaskView: UIView private let transitionMaskTopFillLayer: CALayer private let transitionMaskFillLayer: CALayer private let transitionMaskGradientLayer: CAGradientLayer private let transitionMaskBottomFillLayer: CALayer private let transitionContainerNode: ASDisplayNode private var isScheduling = false private let timerNode: VoiceChatTimerNode private var pickerView: UIDatePicker? private let dateFormatter: DateFormatter private let scheduleTextNode: ImmediateTextNode private let scheduleCancelButton: SolidRoundedButtonNode private var scheduleButtonTitle = "" private let titleNode: VoiceChatTitleNode private let participantsNode: VoiceChatTimerNode private var enqueuedTransitions: [ListTransition] = [] private var enqueuedFullscreenTransitions: [ListTransition] = [] private var validLayout: (ContainerViewLayout, CGFloat)? private var didSetContentsReady: Bool = false private var didSetDataReady: Bool = false private var isFirstTime = true private var topInset: CGFloat? private var animatingInsertion = false private var animatingExpansion = false private var animatingAppearance = false private var animatingButtonsSwap = false private var animatingMainStage = false private var animatingContextMenu = false private var panGestureArguments: (topInset: CGFloat, offset: CGFloat)? private var isPanning = false private var peer: Peer? private var currentTitle: String = "" private var currentTitleIsCustom = false private var currentSubtitle: String = "" private var currentSpeakingSubtitle: String? private var currentCallMembers: ([GroupCallParticipantsContext.Participant], String?)? private var currentTotalCount: Int32 = 0 private var currentInvitedPeers: [EnginePeer]? private var currentSpeakingPeers: Set? private var currentContentOffset: CGFloat? private var currentNormalButtonColor: UIColor? private var currentActiveButtonColor: UIColor? private var myEntry: VoiceChatPeerEntry? private var mainEntry: VoiceChatPeerEntry? private var currentEntries: [ListEntry] = [] private var currentFullscreenEntries: [ListEntry] = [] private var currentTileItems: [VoiceChatTileItem] = [] private var displayPanelVideos = false private var joinedVideo: Bool? private var peerViewDisposable: Disposable? private let leaveDisposable = MetaDisposable() private var isMutedDisposable: Disposable? private var isNoiseSuppressionEnabled: Bool = true private var isNoiseSuppressionEnabledDisposable: Disposable? private var callStateDisposable: Disposable? private var pushingToTalk = false private var temporaryPushingToTalk = false private let hapticFeedback = HapticFeedback() private var callState: PresentationGroupCallState? private var currentLoadToken: String? private var scrollAtTop = true private var effectiveMuteState: GroupCallParticipantsContext.Participant.MuteState? { if self.pushingToTalk { return nil } else { return self.callState?.muteState } } private var audioOutputStateDisposable: Disposable? private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? private var audioLevelsDisposable: Disposable? private var myAudioLevelDisposable: Disposable? private var isSpeakingDisposable: Disposable? private var memberStatesDisposable: Disposable? private var actionButtonColorDisposable: Disposable? private var itemInteraction: Interaction? private let inviteDisposable = MetaDisposable() private let memberEventsDisposable = MetaDisposable() private let reconnectedAsEventsDisposable = MetaDisposable() private let stateVersionDisposable = MetaDisposable() private var applicationStateDisposable: Disposable? private let displayAsPeersPromise = Promise<[FoundPeer]>([]) private let inviteLinksPromise = Promise(nil) private var raisedHandDisplayDisposables: [PeerId: Disposable] = [:] private var displayedRaisedHands = Set() { didSet { self.displayedRaisedHandsPromise.set(self.displayedRaisedHands) } } private let displayedRaisedHandsPromise = ValuePromise>(Set()) private var requestedVideoSources = Set() private var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] private var videoRenderingContext: VideoRenderingContext private var videoNodes: [String: GroupVideoNode] = [:] private var wideVideoNodes = Set() private var videoOrder: [String] = [] private var readyVideoEndpointIds = Set() private var readyVideoEndpointIdsPromise = ValuePromise>(Set()) private var timeoutedEndpointIds = Set() private var readyVideoDisposables = DisposableDict() private var myPeerVideoReadyDisposable = MetaDisposable() private var peerIdToEndpointId: [PeerId: String] = [:] private var currentSpeakers: [PeerId] = [] private var currentDominantSpeaker: (PeerId, String?, Double)? private var currentForcedSpeaker: (PeerId, String?)? private var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? private var updateAvatarDisposable = MetaDisposable() private let updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) private var currentUpdatingAvatar: TelegramMediaImageRepresentation? private var connectedOnce = false private var ignoreConnecting = false private var ignoreConnectingTimer: SwiftSignalKit.Timer? private var displayUnmuteTooltipTimer: SwiftSignalKit.Timer? private var dismissUnmuteTooltipTimer: SwiftSignalKit.Timer? private var lastUnmuteTooltipDisplayTimestamp: Double? private var panelHidden = false private var displayMode: DisplayMode = .modal(isExpanded: false, isFilled: false) { didSet { if case let .modal(isExpanded, _) = self.displayMode { self.itemInteraction?.isExpanded = isExpanded } else { self.itemInteraction?.isExpanded = true } } } private var isExpanded: Bool { switch self.displayMode { case .modal(true, _), .fullscreen: return true default: return false } } private var statsDisposable: Disposable? init(controller: VoiceChatControllerImpl, sharedContext: SharedAccountContext, call: PresentationGroupCall) { self.controller = controller self.sharedContext = sharedContext self.context = call.accountContext self.call = call self.videoRenderingContext = VideoRenderingContext() self.isScheduling = call.schedulePending let presentationData = sharedContext.currentPresentationData.with { $0 } self.presentationData = presentationData self.darkTheme = defaultDarkColorPresentationTheme self.currentSubtitle = self.presentationData.strings.SocksProxySetup_ProxyStatusConnecting self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) self.contentContainer = ASDisplayNode() self.contentContainer.isHidden = true self.backgroundNode = ASDisplayNode() self.backgroundNode.backgroundColor = self.isScheduling ? panelBackgroundColor : secondaryPanelBackgroundColor self.backgroundNode.clipsToBounds = false self.listContainer = ASDisplayNode() self.listNode = ListView() self.listNode.alpha = self.isScheduling ? 0.0 : 1.0 self.listNode.isUserInteractionEnabled = !self.isScheduling self.listNode.verticalScrollIndicatorColor = UIColor(white: 1.0, alpha: 0.3) self.listNode.clipsToBounds = true self.listNode.scroller.bounces = false self.listNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } self.fullscreenListContainer = ASDisplayNode() self.fullscreenListContainer.isHidden = true self.fullscreenListNode = ListView() self.fullscreenListNode.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) self.fullscreenListNode.clipsToBounds = true self.fullscreenListNode.accessibilityPageScrolledString = { row, count in return presentationData.strings.VoiceOver_ScrollStatus(row, count).string } self.tileGridNode = VoiceChatTileGridNode(context: self.context) self.topPanelNode = ASDisplayNode() self.topPanelNode.clipsToBounds = false self.topPanelBackgroundNode = ASDisplayNode() self.topPanelBackgroundNode.backgroundColor = panelBackgroundColor self.topPanelBackgroundNode.isUserInteractionEnabled = false self.topPanelEdgeNode = ASDisplayNode() self.topPanelEdgeNode.backgroundColor = panelBackgroundColor self.topPanelEdgeNode.cornerRadius = 12.0 self.topPanelEdgeNode.isUserInteractionEnabled = false if #available(iOS 11.0, *) { self.topPanelEdgeNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] } self.optionsButton = VoiceChatHeaderButton(context: self.context) self.optionsButton.setContent(.more(optionsCircleImage(dark: false))) self.closeButton = VoiceChatHeaderButton(context: self.context) self.closeButton.setContent(.image(closeButtonImage(dark: false))) self.panelButton = VoiceChatHeaderButton(context: self.context, wide: true) self.panelButton.setContent(.image(panelButtonImage(dark: false))) self.titleNode = VoiceChatTitleNode(theme: self.presentationData.theme) self.topCornersNode = ASImageNode() self.topCornersNode.displaysAsynchronously = false self.topCornersNode.displayWithoutProcessing = true self.topCornersNode.image = decorationTopCornersImage(dark: false) self.topCornersNode.isUserInteractionEnabled = false self.bottomPanelNode = ASDisplayNode() self.bottomPanelNode.clipsToBounds = false self.bottomPanelBackgroundNode = ASDisplayNode() self.bottomPanelBackgroundNode.backgroundColor = panelBackgroundColor self.bottomPanelBackgroundNode.isUserInteractionEnabled = false self.bottomGradientNode = ASDisplayNode() self.bottomGradientNode.displaysAsynchronously = false self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: false).flatMap { UIColor(patternImage: $0) } self.bottomCornersNode = ASImageNode() self.bottomCornersNode.displaysAsynchronously = false self.bottomCornersNode.displayWithoutProcessing = true self.bottomCornersNode.image = decorationBottomCornersImage(dark: false) self.bottomCornersNode.isUserInteractionEnabled = false self.audioButton = CallControllerButtonItemNode() self.cameraButton = CallControllerButtonItemNode(largeButtonSize: sideButtonSize.width) self.switchCameraButton = CallControllerButtonItemNode() self.switchCameraButton.alpha = 0.0 self.switchCameraButton.isUserInteractionEnabled = false self.leaveButton = CallControllerButtonItemNode() self.actionButton = VoiceChatActionButton() self.actionButton.animationsEnabled = sharedContext.energyUsageSettings.fullTranslucency if self.isScheduling { self.cameraButton.alpha = 0.0 self.cameraButton.isUserInteractionEnabled = false self.audioButton.alpha = 0.0 self.audioButton.isUserInteractionEnabled = false self.leaveButton.alpha = 0.0 self.leaveButton.isUserInteractionEnabled = false } self.leftBorderNode = ASDisplayNode() self.leftBorderNode.backgroundColor = panelBackgroundColor self.leftBorderNode.isUserInteractionEnabled = false self.leftBorderNode.clipsToBounds = false self.rightBorderNode = ASDisplayNode() self.rightBorderNode.backgroundColor = panelBackgroundColor self.rightBorderNode.isUserInteractionEnabled = false self.rightBorderNode.clipsToBounds = false self.mainStageContainerNode = ASDisplayNode() self.mainStageContainerNode.clipsToBounds = true self.mainStageContainerNode.isUserInteractionEnabled = false self.mainStageContainerNode.isHidden = true self.mainStageBackgroundNode = ASDisplayNode() self.mainStageBackgroundNode.backgroundColor = .black self.mainStageBackgroundNode.alpha = 0.0 self.mainStageBackgroundNode.isUserInteractionEnabled = false self.mainStageNode = VoiceChatMainStageNode(context: self.context, call: self.call) self.transitionMaskView = UIView() self.transitionMaskTopFillLayer = CALayer() self.transitionMaskTopFillLayer.backgroundColor = UIColor.white.cgColor self.transitionMaskTopFillLayer.opacity = 0.0 self.transitionMaskFillLayer = CALayer() self.transitionMaskFillLayer.backgroundColor = UIColor.white.cgColor self.transitionMaskGradientLayer = CAGradientLayer() self.transitionMaskGradientLayer.colors = [UIColor.white.cgColor, UIColor.white.withAlphaComponent(0.0).cgColor] self.transitionMaskGradientLayer.locations = [0.0, 1.0] self.transitionMaskGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) self.transitionMaskGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) self.transitionMaskBottomFillLayer = CALayer() self.transitionMaskBottomFillLayer.backgroundColor = UIColor.white.cgColor self.transitionMaskBottomFillLayer.opacity = 0.0 self.transitionMaskView.layer.addSublayer(self.transitionMaskTopFillLayer) self.transitionMaskView.layer.addSublayer(self.transitionMaskFillLayer) self.transitionMaskView.layer.addSublayer(self.transitionMaskGradientLayer) self.transitionMaskView.layer.addSublayer(self.transitionMaskBottomFillLayer) self.transitionContainerNode = ASDisplayNode() self.transitionContainerNode.clipsToBounds = true self.transitionContainerNode.isUserInteractionEnabled = false self.transitionContainerNode.view.mask = self.transitionMaskView // self.transitionContainerNode.view.addSubview(self.transitionMaskView) self.scheduleTextNode = ImmediateTextNode() self.scheduleTextNode.isHidden = !self.isScheduling self.scheduleTextNode.isUserInteractionEnabled = false self.scheduleTextNode.textAlignment = .center self.scheduleTextNode.maximumNumberOfLines = 4 self.scheduleCancelButton = SolidRoundedButtonNode(title: self.presentationData.strings.Common_Cancel, theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0x2b2b2f), foregroundColor: .white), height: 52.0, cornerRadius: 10.0) self.scheduleCancelButton.isHidden = !self.isScheduling self.dateFormatter = DateFormatter() self.dateFormatter.timeStyle = .none self.dateFormatter.dateStyle = .short self.dateFormatter.timeZone = TimeZone.current self.timerNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) self.timerNode.isHidden = true self.participantsNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) super.init() let context = self.context let currentAccountPeer = self.context.account.postbox.loadedPeerWithId(context.account.peerId) |> map { peer in return [FoundPeer(peer: peer, subscribers: nil)] } self.isNoiseSuppressionEnabledDisposable = (call.isNoiseSuppressionEnabled |> deliverOnMainQueue).start(next: { [weak self] value in guard let strongSelf = self else { return } strongSelf.isNoiseSuppressionEnabled = value }) let displayAsPeers: Signal<[FoundPeer], NoError> = currentAccountPeer |> then( combineLatest(currentAccountPeer, context.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: call.peerId)) |> map { currentAccountPeer, availablePeers -> [FoundPeer] in var result = currentAccountPeer result.append(contentsOf: availablePeers) return result } ) self.displayAsPeersPromise.set(displayAsPeers) self.inviteLinksPromise.set(.single(nil) |> then(call.inviteLinks)) self.itemInteraction = Interaction(updateIsMuted: { [weak self] peerId, isMuted in let _ = self?.call.updateMuteState(peerId: peerId, isMuted: isMuted) }, switchToPeer: { [weak self] peerId, videoEndpointId, expand in if let strongSelf = self, strongSelf.connectedOnce { if expand, let videoEndpointId = videoEndpointId { strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime() + 3.0) strongSelf.updateDisplayMode(.fullscreen(controlsHidden: false)) } else { strongSelf.currentForcedSpeaker = nil if peerId != strongSelf.currentDominantSpeaker?.0 || (videoEndpointId != nil && videoEndpointId != strongSelf.currentDominantSpeaker?.1) { strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) } strongSelf.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true) } } }, openInvite: { [weak self] in guard let strongSelf = self else { return } let groupPeer = strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId)) let _ = combineLatest(queue: Queue.mainQueue(), groupPeer, strongSelf.inviteLinksPromise.get() |> take(1)).start(next: { groupPeer, inviteLinks in guard let strongSelf = self else { return } guard let groupPeer = groupPeer else { return } if case let .channel(groupPeer) = groupPeer { var canInviteMembers = true if case .broadcast = groupPeer.info, !(groupPeer.addressName?.isEmpty ?? true) { canInviteMembers = false } if !canInviteMembers { if let inviteLinks = inviteLinks { strongSelf.presentShare(inviteLinks) } return } } var filters: [ChannelMembersSearchFilter] = [] if let (currentCallMembers, _) = strongSelf.currentCallMembers { filters.append(.disable(Array(currentCallMembers.map { $0.peer.id }))) } if case let .channel(groupPeer) = groupPeer { if !groupPeer.hasPermission(.inviteMembers) && inviteLinks?.listenerLink == nil { filters.append(.excludeNonMembers) } } else if case let .legacyGroup(groupPeer) = groupPeer { if groupPeer.hasBannedPermission(.banAddMembers) { filters.append(.excludeNonMembers) } } filters.append(.excludeBots) var dismissController: (() -> Void)? let controller = ChannelMembersSearchController(context: strongSelf.context, peerId: groupPeer.id, forceTheme: strongSelf.darkTheme, mode: .inviteToCall, filters: filters, openPeer: { peer, participant in guard let strongSelf = self else { dismissController?() return } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } if peer.id == strongSelf.callState?.myPeerId { return } if let participant = participant { dismissController?() if strongSelf.call.invitePeer(participant.peer.id) { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } else { text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(participant.peer), text: text, action: nil, duration: 3), action: { _ in return false }) } } else { if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { let text = strongSelf.presentationData.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in dismissController?() if let strongSelf = self { let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) |> deliverOnMainQueue).start(next: { [weak self] _ in if let strongSelf = self { strongSelf.presentUndoOverlay(content: .forward(savedMessages: false, text: strongSelf.presentationData.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) } }) } })]), in: .window(.root)) } else { let text: String if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } else { text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), groupPeer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { guard let strongSelf = self else { return } if case let .channel(groupPeer) = groupPeer { let selfController = strongSelf.controller let inviteDisposable = strongSelf.inviteDisposable var inviteSignal = strongSelf.context.peerChannelMemberCategoriesContextsManager.addMembers(engine: strongSelf.context.engine, peerId: groupPeer.id, memberIds: [peer.id]) var cancelImpl: (() -> Void)? let progressSignal = Signal { [weak selfController] subscriber in let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) selfController?.present(controller, in: .window(.root)) return ActionDisposable { [weak controller] in Queue.mainQueue().async() { controller?.dismiss() } } } |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() inviteSignal = inviteSignal |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() } } cancelImpl = { inviteDisposable.set(nil) } inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { error in dismissController?() guard let strongSelf = self else { return } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let text: String switch error { case .limitExceeded: text = presentationData.strings.Channel_ErrorAddTooMuch case .tooMuchJoined: text = presentationData.strings.Invite_ChannelsTooMuch case .generic: text = presentationData.strings.Login_UnknownError case .restricted: text = presentationData.strings.Channel_ErrorAddBlocked case .notMutualContact: if case .broadcast = groupPeer.info { text = presentationData.strings.Channel_AddUserLeftError } else { text = presentationData.strings.GroupInfo_AddUserLeftError } case .botDoesntSupportGroups: text = presentationData.strings.Channel_BotDoesntSupportGroups case .tooMuchBots: text = presentationData.strings.Channel_TooMuchBots case .bot: text = presentationData.strings.Login_UnknownError case .kicked: text = presentationData.strings.Channel_AddUserKickedError } strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) }, completed: { guard let strongSelf = self else { dismissController?() return } dismissController?() if strongSelf.call.invitePeer(peer.id) { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } else { text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil, duration: 3), action: { _ in return false }) } })) } else if case let .legacyGroup(groupPeer) = groupPeer { let selfController = strongSelf.controller let inviteDisposable = strongSelf.inviteDisposable var inviteSignal = strongSelf.context.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id) var cancelImpl: (() -> Void)? let progressSignal = Signal { [weak selfController] subscriber in let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) selfController?.present(controller, in: .window(.root)) return ActionDisposable { [weak controller] in Queue.mainQueue().async() { controller?.dismiss() } } } |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() inviteSignal = inviteSignal |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() } } cancelImpl = { inviteDisposable.set(nil) } inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { error in dismissController?() guard let strongSelf = self else { return } let context = strongSelf.context let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } switch error { case .privacy: let _ = (strongSelf.context.account.postbox.loadedPeerWithId(peer.id) |> deliverOnMainQueue).start(next: { peer in self?.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) }) case .notMutualContact: strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) case .tooManyChannels: strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) case .groupFull, .generic: strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) } }, completed: { guard let strongSelf = self else { dismissController?() return } dismissController?() if strongSelf.call.invitePeer(peer.id) { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } else { text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil, duration: 3), action: { _ in return false }) } })) } })]), in: .window(.root)) } } }) controller.copyInviteLink = { dismissController?() guard let strongSelf = self else { return } let callPeerId = strongSelf.call.peerId let _ = (strongSelf.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: callPeerId), TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: callPeerId) ) |> map { peer, exportedInvitation -> String? in if let link = inviteLinks?.listenerLink { return link } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { return "https://t.me/\(addressName)" } else if let link = exportedInvitation?.link { return link } else { return nil } } |> deliverOnMainQueue).start(next: { link in guard let strongSelf = self else { return } if let link = link { UIPasteboard.general.string = link strongSelf.presentUndoOverlay(content: .linkCopied(text: strongSelf.presentationData.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) } }) } dismissController = { [weak controller] in controller?.dismiss() } strongSelf.controller?.push(controller) }) }, peerContextAction: { [weak self] entry, sourceNode, gesture, fullscreen in guard let strongSelf = self, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { return } let muteStatePromise = Promise(entry.muteState) let itemsForEntry: (VoiceChatPeerEntry, GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { entry, muteState in var items: [ContextMenuItem] = [] var hasVolumeSlider = false let peer = entry.peer if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { } else { if entry.canManageCall || !entry.isMyPeer { hasVolumeSlider = true let minValue: CGFloat if let callState = strongSelf.callState, callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { minValue = 0.01 } else { minValue = 0.0 } items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: entry.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { newValue, finished in if finished && newValue.isZero { let updatedMuteState = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) muteStatePromise.set(.single(updatedMuteState)) } else { strongSelf.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) } }), true)) } } if entry.isMyPeer && !hasVolumeSlider && ((entry.about?.isEmpty ?? true) || entry.peer.smallProfileImage == nil) { items.append(.custom(VoiceChatInfoContextItem(text: strongSelf.presentationData.strings.VoiceChat_ImproveYourProfileText, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) }), true)) } if peer.id == strongSelf.callState?.myPeerId { if entry.raisedHand { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_CancelSpeakRequest, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.lowerHand() f(.default) }))) } items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? strongSelf.presentationData.strings.VoiceChat_AddPhoto : strongSelf.presentationData.strings.VoiceChat_ChangePhoto, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } f(.default) Queue.mainQueue().after(0.1) { strongSelf.openAvatarForEditing(fromGallery: false, completion: {}) } }))) items.append(.action(ContextMenuActionItem(text: (entry.about?.isEmpty ?? true) ? strongSelf.presentationData.strings.VoiceChat_AddBio : strongSelf.presentationData.strings.VoiceChat_EditBio, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } f(.default) Queue.mainQueue().after(0.1) { let maxBioLength: Int if peer.id.namespace == Namespaces.Peer.CloudUser { maxBioLength = 70 } else { maxBioLength = 100 } let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_EditBioTitle, text: presentationData.strings.VoiceChat_EditBioText, placeholder: presentationData.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, value: entry.about, maxLength: maxBioLength, apply: { bio in if let strongSelf = self, let bio = bio { if peer.id.namespace == Namespaces.Peer.CloudUser { let _ = (strongSelf.context.engine.accountData.updateAbout(about: bio) |> `catch` { _ -> Signal in return .complete() }).start() } else { let _ = (strongSelf.context.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) |> `catch` { _ -> Signal in return .complete() }).start() } strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditBioSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) } }) self?.controller?.present(controller, in: .window(.root)) } }))) if let peer = peer as? TelegramUser { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ChangeName, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } f(.default) Queue.mainQueue().after(0.1) { let controller = voiceChatUserNameController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: presentationData.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: presentationData.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { firstAndLastName in if let strongSelf = self, let (firstName, lastName) = firstAndLastName { let _ = context.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).start() strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditNameSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) } }) self?.controller?.present(controller, in: .window(.root)) } }))) } } else { if let callState = strongSelf.callState, (callState.canManageCall || callState.adminIds.contains(strongSelf.context.account.peerId)) { if callState.adminIds.contains(peer.id) { if let _ = muteState { } else { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) f(.default) }))) } } else { if let muteState = muteState, !muteState.canUnmute { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmutePeer, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: entry.raisedHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) f(.default) strongSelf.presentUndoOverlay(content: .voiceChatCanSpeak(text: presentationData.strings.VoiceChat_UserCanNowSpeak(EnginePeer(entry.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) }))) } else { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) f(.default) }))) } } } else { if let muteState = muteState, muteState.mutedByYou { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmuteForMe, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) f(.default) }))) } else { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MuteForMe, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self else { return } let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) f(.default) }))) } } let openTitle: String let openIcon: UIImage? if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) { if let peer = peer as? TelegramChannel, case .broadcast = peer.info { openTitle = strongSelf.presentationData.strings.VoiceChat_OpenChannel openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") } else { openTitle = strongSelf.presentationData.strings.VoiceChat_OpenGroup openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") } } else { openTitle = strongSelf.presentationData.strings.Conversation_ContextMenuSendMessage openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message") } items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor) }, action: { _, f in guard let strongSelf = self, let navigationController = strongSelf.controller?.parentNavigationController else { return } let context = strongSelf.context strongSelf.controller?.dismiss(completion: { Queue.mainQueue().after(0.3) { context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) } }) f(.dismissWithoutContent) }))) if let callState = strongSelf.callState, (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != strongSelf.call.peerId { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) }, action: { [weak self] c, _ in c.dismiss(completion: { guard let strongSelf = self else { return } let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.call.peerId) |> deliverOnMainQueue).start(next: { [weak self] chatPeer in guard let strongSelf = self else { return } let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme)) var items: [ActionSheetItem] = [] items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } let _ = strongSelf.context.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: strongSelf.context.engine, peerId: strongSelf.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() strongSelf.call.removedPeer(peer.id) strongSelf.presentUndoOverlay(content: .banned(text: strongSelf.presentationData.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return false }) })) actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ]) ]) strongSelf.controller?.present(actionSheet, in: .window(.root)) }) }) }))) } } return items } let items = muteStatePromise.get() |> map { muteState -> [ContextMenuItem] in return itemsForEntry(entry, muteState) } var centerVertically = entry.peer.smallProfileImage != nil || (!fullscreen && entry.effectiveVideoEndpointId != nil) if let (layout, _) = strongSelf.validLayout, case .regular = layout.metrics.widthClass { centerVertically = false } var useMaskView = true if case .fullscreen = strongSelf.displayMode { useMaskView = false } let dismissPromise = ValuePromise(false) let source = VoiceChatContextExtractedContentSource(sourceNode: sourceNode, maskView: useMaskView ? strongSelf.transitionMaskView : nil, keepInPlace: false, blurBackground: true, centerVertically: centerVertically, shouldBeDismissed: dismissPromise.get(), animateTransitionIn: { [weak self] in if let strongSelf = self { strongSelf.animatingContextMenu = true strongSelf.updateDecorationsLayout(transition: .immediate) if strongSelf.isLandscape { strongSelf.transitionMaskTopFillLayer.opacity = 1.0 } strongSelf.transitionContainerNode.view.mask = nil strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in Queue.mainQueue().after(0.3) { self?.transitionMaskTopFillLayer.opacity = 0.0 self?.transitionMaskBottomFillLayer.removeAllAnimations() self?.animatingContextMenu = false self?.updateDecorationsLayout(transition: .immediate) } }) } }, animateTransitionOut: { [weak self] in if let strongSelf = self { strongSelf.animatingContextMenu = true strongSelf.updateDecorationsLayout(transition: .immediate) strongSelf.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, completion: { [weak self] _ in self?.animatingContextMenu = false self?.updateDecorationsLayout(transition: .immediate) self?.transitionContainerNode.view.mask = self?.transitionMaskView }) } }) sourceNode.requestDismiss = { dismissPromise.set(true) } let contextController = ContextController(presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme), source: .extracted(source), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) contextController.useComplexItemsTransitionAnimation = true strongSelf.controller?.presentInGlobalOverlay(contextController) }, getPeerVideo: { [weak self] endpointId, position in guard let strongSelf = self else { return nil } var ignore = false if case .mainstage = position { ignore = false } else if case .fullscreen = strongSelf.displayMode, !strongSelf.isPanning { ignore = ![.mainstage, .list].contains(position) } else { ignore = position != .tile } if ignore { return nil } if !strongSelf.readyVideoEndpointIds.contains(endpointId) { return nil } for (listEndpointId, videoNode) in strongSelf.videoNodes { if listEndpointId == endpointId { if position != .mainstage && videoNode.isMainstageExclusive { return nil } return videoNode } } return nil }) self.itemInteraction?.updateAvatarPromise = self.updateAvatarPromise self.topPanelNode.addSubnode(self.topPanelEdgeNode) self.topPanelNode.addSubnode(self.topPanelBackgroundNode) self.topPanelNode.addSubnode(self.titleNode) self.topPanelNode.addSubnode(self.optionsButton) self.topPanelNode.addSubnode(self.closeButton) self.topPanelNode.addSubnode(self.panelButton) self.bottomPanelNode.addSubnode(self.cameraButton) self.bottomPanelNode.addSubnode(self.audioButton) self.bottomPanelNode.addSubnode(self.switchCameraButton) self.bottomPanelNode.addSubnode(self.leaveButton) self.bottomPanelNode.addSubnode(self.actionButton) self.bottomPanelNode.addSubnode(self.scheduleCancelButton) self.addSubnode(self.dimNode) self.addSubnode(self.contentContainer) self.contentContainer.addSubnode(self.backgroundNode) self.contentContainer.addSubnode(self.listContainer) self.contentContainer.addSubnode(self.topPanelNode) self.listContainer.addSubnode(self.listNode) self.listContainer.addSubnode(self.leftBorderNode) self.listContainer.addSubnode(self.rightBorderNode) self.listContainer.addSubnode(self.bottomCornersNode) self.listContainer.addSubnode(self.topCornersNode) self.contentContainer.addSubnode(self.bottomGradientNode) self.contentContainer.addSubnode(self.bottomPanelBackgroundNode) // self.contentContainer.addSubnode(self.participantsNode) self.contentContainer.addSubnode(self.tileGridNode) self.contentContainer.addSubnode(self.mainStageContainerNode) self.contentContainer.addSubnode(self.transitionContainerNode) self.contentContainer.addSubnode(self.bottomPanelNode) self.contentContainer.addSubnode(self.timerNode) self.contentContainer.addSubnode(self.scheduleTextNode) self.contentContainer.addSubnode(self.fullscreenListContainer) self.fullscreenListContainer.addSubnode(self.fullscreenListNode) self.mainStageContainerNode.addSubnode(self.mainStageBackgroundNode) self.mainStageContainerNode.addSubnode(self.mainStageNode) self.updateDecorationsColors() let invitedPeers: Signal<[EnginePeer], NoError> = self.call.invitedPeers |> mapToSignal { ids -> Signal<[EnginePeer], NoError> in return context.engine.data.get(EngineDataList( ids.map(TelegramEngine.EngineData.Item.Peer.Peer.init) )) |> map { itemList -> [EnginePeer] in return itemList.compactMap { $0 } } } self.presentationDataDisposable = (sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { strongSelf.presentationData = presentationData let sourceColor = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor let color: UIColor if sourceColor.alpha < 1.0 { color = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor.mixedWith(sourceColor.withAlphaComponent(1.0), alpha: sourceColor.alpha) } else { color = sourceColor } strongSelf.actionButton.connectingColor = color } }) self.memberStatesDisposable = (combineLatest(queue: .mainQueue(), self.call.state, self.call.members, invitedPeers, self.displayAsPeersPromise.get(), self.context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration]) ) |> mapToThrottled { values in return .single(values) |> then(.complete() |> delay(0.1, queue: Queue.mainQueue())) }).start(next: { [weak self] state, callMembers, invitedPeers, displayAsPeers, preferencesView in guard let strongSelf = self else { return } let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue let configuration = VoiceChatConfiguration.with(appConfiguration: appConfiguration) strongSelf.configuration = configuration var animate = false if strongSelf.callState != state { if let previousCallState = strongSelf.callState { var networkStateUpdated = false if case .connecting = previousCallState.networkState, case .connected = state.networkState { networkStateUpdated = true strongSelf.connectedOnce = true } var canUnmuteUpdated = false if previousCallState.muteState?.canUnmute != state.muteState?.canUnmute { canUnmuteUpdated = true } if previousCallState.isVideoEnabled != state.isVideoEnabled || (state.isVideoEnabled && networkStateUpdated) || canUnmuteUpdated { strongSelf.animatingButtonsSwap = true animate = true } } strongSelf.callState = state strongSelf.mainStageNode.callState = state if let muteState = state.muteState, !muteState.canUnmute { if strongSelf.pushingToTalk { strongSelf.pushingToTalk = false strongSelf.actionButton.pressing = false strongSelf.actionButton.isUserInteractionEnabled = false strongSelf.actionButton.isUserInteractionEnabled = true } } } strongSelf.updateMembers(muteState: strongSelf.effectiveMuteState, callMembers: (callMembers?.participants ?? [], callMembers?.loadMoreToken), invitedPeers: invitedPeers, speakingPeers: callMembers?.speakingParticipants ?? []) let totalCount = Int32(max(1, callMembers?.totalCount ?? 0)) strongSelf.currentTotalCount = totalCount let subtitle = strongSelf.presentationData.strings.VoiceChat_Panel_Members(totalCount) strongSelf.currentSubtitle = subtitle if strongSelf.isScheduling { strongSelf.optionsButton.isUserInteractionEnabled = false strongSelf.optionsButton.alpha = 0.0 strongSelf.closeButton.isUserInteractionEnabled = false strongSelf.closeButton.alpha = 0.0 strongSelf.panelButton.isUserInteractionEnabled = false strongSelf.panelButton.alpha = 0.0 } else { if let (layout, _) = strongSelf.validLayout { if case .regular = layout.metrics.widthClass, !strongSelf.peerIdToEndpointId.isEmpty { strongSelf.panelButton.isUserInteractionEnabled = true } else { strongSelf.panelButton.isUserInteractionEnabled = false } } if let callState = strongSelf.callState, callState.canManageCall { strongSelf.optionsButton.isUserInteractionEnabled = true } else if displayAsPeers.count > 1 { strongSelf.optionsButton.isUserInteractionEnabled = true } else { strongSelf.optionsButton.isUserInteractionEnabled = true } } if let (layout, navigationHeight) = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: animate ? .animated(duration: 0.4, curve: .spring) : .immediate) } }) let titleAndRecording: Signal<(String?, Bool), NoError> = self.call.state |> map { state -> (String?, Bool) in return (state.title, state.recordingStartTimestamp != nil) } self.peerViewDisposable = combineLatest(queue: Queue.mainQueue(), self.context.account.viewTracker.peerView(self.call.peerId), titleAndRecording).start(next: { [weak self] view, titleAndRecording in guard let strongSelf = self else { return } let (title, isRecording) = titleAndRecording if let peer = peerViewMainPeer(view) { let isLivestream: Bool if let channel = peer as? TelegramChannel, case .broadcast = channel.info { isLivestream = true } else { isLivestream = false } strongSelf.participantsNode.isHidden = !isLivestream || strongSelf.isScheduled let hadPeer = strongSelf.peer != nil strongSelf.peer = peer strongSelf.currentTitleIsCustom = title != nil strongSelf.currentTitle = title ?? EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder) strongSelf.updateTitle(transition: .immediate) strongSelf.titleNode.isRecording = isRecording if strongSelf.isScheduling && !hadPeer { strongSelf.updateScheduleButtonTitle() } } if !strongSelf.didSetDataReady { strongSelf.didSetDataReady = true strongSelf.updateMembers() strongSelf.controller?.dataReady.set(true) } }) self.audioOutputStateDisposable = (self.call.audioOutputState |> deliverOnMainQueue).start(next: { [weak self] state in guard let strongSelf = self else { return } var existingOutputs = Set() var filteredOutputs: [AudioSessionOutput] = [] for output in state.0 { if case let .port(port) = output { if !existingOutputs.contains(port.name) { existingOutputs.insert(port.name) filteredOutputs.append(output) } } else { filteredOutputs.append(output) } } let wasEmpty = strongSelf.audioOutputState == nil strongSelf.audioOutputState = (filteredOutputs, state.1) if let (layout, navigationHeight) = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) } if wasEmpty { strongSelf.controller?.audioOutputStateReady.set(true) } }) self.audioLevelsDisposable = (self.call.audioLevels |> deliverOnMainQueue).start(next: { [weak self] levels in guard let strongSelf = self else { return } var levels = levels if strongSelf.effectiveMuteState != nil { levels = levels.filter { $0.0 != strongSelf.callState?.myPeerId } } var maxLevelWithVideo: (PeerId, Float)? for (peerId, source, level, hasSpeech) in levels { let hasVideo = strongSelf.peerIdToEndpointId[peerId] != nil if hasSpeech && source != 0 && hasVideo { if let (_, currentLevel) = maxLevelWithVideo { if currentLevel < level { maxLevelWithVideo = (peerId, level) } } else { maxLevelWithVideo = (peerId, level) } } } if maxLevelWithVideo == nil { if let (peerId, _, _) = strongSelf.currentDominantSpeaker { maxLevelWithVideo = (peerId, 0.0) } else if strongSelf.peerIdToEndpointId.count > 0 { for entry in strongSelf.currentFullscreenEntries { if case let .peer(peerEntry, _) = entry { if let _ = peerEntry.effectiveVideoEndpointId { maxLevelWithVideo = (peerEntry.peer.id, 0.0) break } } } } } if case .fullscreen = strongSelf.displayMode, !strongSelf.mainStageNode.animating && !strongSelf.animatingExpansion { if let (peerId, _) = maxLevelWithVideo { if let (currentPeerId, _, timestamp) = strongSelf.currentDominantSpeaker { if CACurrentMediaTime() - timestamp > 2.5 && peerId != currentPeerId { strongSelf.currentDominantSpeaker = (peerId, nil, CACurrentMediaTime()) strongSelf.updateMainVideo(waitForFullSize: true) } } } } strongSelf.itemInteraction?.updateAudioLevels(levels) }) self.myAudioLevelDisposable = (self.call.myAudioLevel |> deliverOnMainQueue).start(next: { [weak self] level in guard let strongSelf = self else { return } var effectiveLevel: Float = 0.0 if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { effectiveLevel = level } else if level > 0.1 { effectiveLevel = level * 0.5 } strongSelf.actionButton.updateLevel(CGFloat(effectiveLevel)) }) self.isSpeakingDisposable = (self.call.isSpeaking |> deliverOnMainQueue).start(next: { [weak self] isSpeaking in guard let strongSelf = self else { return } if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { strongSelf.displayUnmuteTooltipTimer?.invalidate() strongSelf.displayUnmuteTooltipTimer = nil strongSelf.dismissUnmuteTooltipTimer?.invalidate() strongSelf.dismissUnmuteTooltipTimer = nil } else { if isSpeaking { var shouldDisplayTooltip = false if let previousTimstamp = strongSelf.lastUnmuteTooltipDisplayTimestamp, CACurrentMediaTime() > previousTimstamp + 45.0 { shouldDisplayTooltip = true } else if strongSelf.lastUnmuteTooltipDisplayTimestamp == nil { shouldDisplayTooltip = true } if shouldDisplayTooltip { strongSelf.dismissUnmuteTooltipTimer?.invalidate() strongSelf.dismissUnmuteTooltipTimer = nil if strongSelf.displayUnmuteTooltipTimer == nil { let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.lastUnmuteTooltipDisplayTimestamp = CACurrentMediaTime() strongSelf.displayUnmuteTooltip() strongSelf.displayUnmuteTooltipTimer?.invalidate() strongSelf.displayUnmuteTooltipTimer = nil strongSelf.dismissUnmuteTooltipTimer?.invalidate() strongSelf.dismissUnmuteTooltipTimer = nil }, queue: Queue.mainQueue()) timer.start() strongSelf.displayUnmuteTooltipTimer = timer } } } else if strongSelf.dismissUnmuteTooltipTimer == nil && strongSelf.displayUnmuteTooltipTimer != nil { let timer = SwiftSignalKit.Timer(timeout: 0.4, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.displayUnmuteTooltipTimer?.invalidate() strongSelf.displayUnmuteTooltipTimer = nil strongSelf.dismissUnmuteTooltipTimer?.invalidate() strongSelf.dismissUnmuteTooltipTimer = nil }, queue: Queue.mainQueue()) timer.start() strongSelf.dismissUnmuteTooltipTimer = timer } } }) self.leaveButton.addTarget(self, action: #selector(self.leavePressed), forControlEvents: .touchUpInside) self.actionButton.addTarget(self, action: #selector(self.actionPressed), forControlEvents: .touchUpInside) self.audioButton.addTarget(self, action: #selector(self.audioPressed), forControlEvents: .touchUpInside) self.cameraButton.addTarget(self, action: #selector(self.cameraPressed), forControlEvents: .touchUpInside) self.switchCameraButton.addTarget(self, action: #selector(self.switchCameraPressed), forControlEvents: .touchUpInside) self.optionsButton.contextAction = { [weak self] sourceNode, gesture in self?.openSettingsMenu(sourceNode: sourceNode, gesture: gesture) } self.optionsButton.addTarget(self, action: #selector(self.optionsPressed), forControlEvents: .touchUpInside) self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) self.panelButton.addTarget(self, action: #selector(self.panelPressed), forControlEvents: .touchUpInside) self.actionButtonColorDisposable = (self.actionButton.outerColor |> deliverOnMainQueue).start(next: { [weak self] normalColor, activeColor in if let strongSelf = self { let animated = strongSelf.currentNormalButtonColor != nil || strongSelf.currentActiveButtonColor == nil strongSelf.currentNormalButtonColor = normalColor strongSelf.currentActiveButtonColor = activeColor strongSelf.updateButtons(transition: animated ? .animated(duration: 0.3, curve: .linear) : .immediate) } }) self.fullscreenListNode.updateFloatingHeaderOffset = { [weak self] _, _ in guard let strongSelf = self else { return } var visiblePeerIds = Set() strongSelf.fullscreenListNode.forEachVisibleItemNode { itemNode in if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { if item.videoEndpointId == nil { visiblePeerIds.insert(item.peer.id) } } } strongSelf.mainStageNode.update(visiblePeerIds: visiblePeerIds) } self.listNode.updateFloatingHeaderOffset = { [weak self] offset, transition in if let strongSelf = self { strongSelf.currentContentOffset = offset if !(strongSelf.animatingExpansion || strongSelf.animatingInsertion || strongSelf.animatingAppearance) && (strongSelf.panGestureArguments == nil || strongSelf.isExpanded) { strongSelf.updateDecorationsLayout(transition: transition) } } } self.listNode.visibleContentOffsetChanged = { [weak self] offset in guard let strongSelf = self else { return } var scrollAtTop = false if case let .known(value) = offset, value < 180.0 { scrollAtTop = true } else { scrollAtTop = false } if scrollAtTop != strongSelf.scrollAtTop { strongSelf.scrollAtTop = scrollAtTop strongSelf.updateTitle(transition: .immediate) } } self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in guard let strongSelf = self else { return } if case let .known(value) = offset, value < 200.0 { if let loadMoreToken = strongSelf.currentCallMembers?.1 { strongSelf.currentLoadToken = loadMoreToken strongSelf.call.loadMoreMembers(token: loadMoreToken) } } } self.memberEventsDisposable.set((self.call.memberEvents |> deliverOnMainQueue).start(next: { [weak self] event in guard let strongSelf = self else { return } if event.joined { if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { return } let text = strongSelf.presentationData.strings.VoiceChat_PeerJoinedText(event.peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: event.peer, text: text, action: nil, duration: 3), action: { _ in return false }) } })) self.reconnectedAsEventsDisposable.set((self.call.reconnectedAsEvents |> deliverOnMainQueue).start(next: { [weak self] peer in guard let strongSelf = self else { return } let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_DisplayAsSuccess(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } else { text = strongSelf.presentationData.strings.VoiceChat_DisplayAsSuccess(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string } strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil, duration: 3), action: { _ in return false }) })) self.stateVersionDisposable.set((self.call.stateVersion |> distinctUntilChanged |> deliverOnMainQueue).start(next: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.callStateDidReset() })) self.titleNode.tapped = { [weak self] in if let strongSelf = self, !strongSelf.isScheduling { if strongSelf.callState?.canManageCall ?? false { strongSelf.openTitleEditing() } else if !strongSelf.titleNode.recordingIconNode.isHidden { var hasTooltipAlready = false strongSelf.controller?.forEachController { controller -> Bool in if controller is TooltipScreen { hasTooltipAlready = true } return true } if !hasTooltipAlready { let location = strongSelf.titleNode.recordingIconNode.convert(strongSelf.titleNode.recordingIconNode.bounds, to: nil) let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = presentationData.strings.LiveStream_RecordingInProgress } else { text = presentationData.strings.VoiceChat_RecordingInProgress } strongSelf.controller?.present(TooltipScreen(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, text: .plain(text: text), icon: nil, location: .point(location.offsetBy(dx: 1.0, dy: 0.0), .top), displayDuration: .custom(3.0), shouldDismissOnTouch: { _, _ in return .dismiss(consume: true) }), in: .window(.root)) } } } } self.scheduleCancelButton.pressed = { [weak self] in if let strongSelf = self { strongSelf.dismissScheduled() } } self.mainStageNode.controlsHidden = { [weak self] hidden in if let strongSelf = self { if hidden { strongSelf.fullscreenListNode.alpha = 0.0 } else { strongSelf.fullscreenListNode.alpha = 1.0 strongSelf.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } } self.mainStageNode.tapped = { [weak self] in if let strongSelf = self, let (layout, navigationHeight) = strongSelf.validLayout, !strongSelf.animatingExpansion && !strongSelf.animatingMainStage && !strongSelf.mainStageNode.animating { if case .regular = layout.metrics.widthClass { strongSelf.panelHidden = !strongSelf.panelHidden strongSelf.animatingExpansion = true let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) strongSelf.updateDecorationsLayout(transition: transition) } else { let effectiveDisplayMode = strongSelf.displayMode let nextDisplayMode: DisplayMode switch effectiveDisplayMode { case .modal: nextDisplayMode = effectiveDisplayMode case let .fullscreen(controlsHidden): if controlsHidden { nextDisplayMode = .fullscreen(controlsHidden: false) } else { nextDisplayMode = .fullscreen(controlsHidden: true) } } strongSelf.updateDisplayMode(nextDisplayMode) } } } self.mainStageNode.stopScreencast = { [weak self] in if let strongSelf = self { strongSelf.call.disableScreencast() } } self.mainStageNode.back = { [weak self] in if let strongSelf = self, !strongSelf.isPanning && !strongSelf.animatingExpansion && !strongSelf.mainStageNode.animating { strongSelf.currentForcedSpeaker = nil strongSelf.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) strongSelf.effectiveSpeaker = nil } } self.mainStageNode.togglePin = { [weak self] in if let strongSelf = self { if let (peerId, videoEndpointId, _, _, _) = strongSelf.effectiveSpeaker { if let _ = strongSelf.currentForcedSpeaker { strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) strongSelf.currentForcedSpeaker = nil } else { strongSelf.currentForcedSpeaker = (peerId, videoEndpointId) } } strongSelf.updateMembers() } } self.mainStageNode.switchTo = { [weak self] peerId in if let strongSelf = self, let interaction = strongSelf.itemInteraction { interaction.switchToPeer(peerId, nil, false) } } self.mainStageNode.getAudioLevel = { [weak self] peerId in return self?.itemInteraction?.getAudioLevel(peerId) ?? .single(0.0) } self.mainStageNode.getVideo = { [weak self] endpointId, isMyPeer, completion in if let strongSelf = self { if isMyPeer { if strongSelf.readyVideoEndpointIds.contains(endpointId) { completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) } else { strongSelf.myPeerVideoReadyDisposable.set((strongSelf.readyVideoEndpointIdsPromise.get() |> filter { $0.contains(endpointId) } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in if let strongSelf = self { completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) } })) } } else { if let input = (strongSelf.call as! PresentationGroupCallImpl).video(endpointId: endpointId) { if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeView(input: input, blur: true))) } } /*strongSelf.call.makeIncomingVideoView(endpointId: endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { videoView, backdropVideoView in if let videoView = videoView { completion(GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView)) } else { completion(nil) } })*/ } } } self.applicationStateDisposable = (self.context.sharedContext.applicationBindings.applicationIsActive |> deliverOnMainQueue).start(next: { [weak self] active in guard let strongSelf = self else { return } strongSelf.appIsActive = active }) if self.context.sharedContext.immediateExperimentalUISettings.enableDebugDataDisplay { self.statsDisposable = ((call as! PresentationGroupCallImpl).getStats() |> deliverOnMainQueue |> then(.complete() |> delay(1.0, queue: .mainQueue())) |> restart).start(next: { [weak self] stats in guard let strongSelf = self else { return } for (endpointId, videoNode) in strongSelf.videoNodes { if let incomingVideoStats = stats.incomingVideoStats[endpointId] { videoNode.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") } } if let (_, maybeEndpointId, _, _, _) = strongSelf.mainStageNode.currentPeer, let endpointId = maybeEndpointId { if let incomingVideoStats = stats.incomingVideoStats[endpointId] { strongSelf.mainStageNode.currentVideoNode?.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") } } }) } } deinit { self.presentationDataDisposable?.dispose() self.peerViewDisposable?.dispose() self.leaveDisposable.dispose() self.isMutedDisposable?.dispose() self.isNoiseSuppressionEnabledDisposable?.dispose() self.callStateDisposable?.dispose() self.audioOutputStateDisposable?.dispose() self.memberStatesDisposable?.dispose() self.audioLevelsDisposable?.dispose() self.myAudioLevelDisposable?.dispose() self.isSpeakingDisposable?.dispose() self.inviteDisposable.dispose() self.memberEventsDisposable.dispose() self.reconnectedAsEventsDisposable.dispose() self.stateVersionDisposable.dispose() self.updateAvatarDisposable.dispose() self.ignoreConnectingTimer?.invalidate() self.readyVideoDisposables.dispose() self.applicationStateDisposable?.dispose() self.myPeerVideoReadyDisposable.dispose() self.statsDisposable?.dispose() } private func openSettingsMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems() if let controller = self.controller { let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme), source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceNode: self.optionsButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) controller.presentInGlobalOverlay(contextController) } } private func contextMenuMainItems() -> Signal<[ContextMenuItem], NoError> { guard let myPeerId = self.callState?.myPeerId else { return .single([]) } let canManageCall = self.callState?.canManageCall == true let avatarSize = CGSize(width: 28.0, height: 28.0) return combineLatest(self.displayAsPeersPromise.get(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.inviteLinksPromise.get()) |> take(1) |> deliverOnMainQueue |> map { [weak self] peers, chatPeer, inviteLinks -> [ContextMenuItem] in guard let strongSelf = self else { return [] } var items: [ContextMenuItem] = [] if peers.count > 1 { for peer in peers { if peer.peer.id == myPeerId { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { c, _ in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuDisplayAsItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) items.append(.separator) break } } } if let (availableOutputs, currentOutput) = strongSelf.audioOutputState, availableOutputs.count > 1 { var currentOutputTitle = "" for output in availableOutputs { if output == currentOutput { let title: String switch output { case .builtin: title = UIDevice.current.model case .speaker: title = strongSelf.presentationData.strings.Call_AudioRouteSpeaker case .headphones: title = strongSelf.presentationData.strings.Call_AudioRouteHeadphones case let .port(port): title = port.name } currentOutputTitle = title break } } items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) }, action: { c, _ in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuAudioItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) } if canManageCall { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_EditTitle } else { text = strongSelf.presentationData.strings.VoiceChat_EditTitle } items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) guard let strongSelf = self else { return } strongSelf.openTitleEditing() }))) var hasPermissions = true if let chatPeer = chatPeer as? TelegramChannel { if case .broadcast = chatPeer.info { hasPermissions = false } else if chatPeer.flags.contains(.isGigagroup) { hasPermissions = false } } if hasPermissions { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) }, action: { c, _ in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuPermissionItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) } } if let inviteLinks = inviteLinks { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_Share, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) self?.presentShare(inviteLinks) }))) } let isScheduled = strongSelf.isScheduled let canSpeak: Bool if let callState = strongSelf.callState { if let muteState = callState.muteState { canSpeak = muteState.canUnmute } else { canSpeak = true } } else { canSpeak = false } if !isScheduled && canSpeak { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_NoiseSuppression, textColor: .primary, textLayout: .secondLineWithValue(strongSelf.isNoiseSuppressionEnabled ? strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionEnabled : strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionDisabled), icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) if let strongSelf = self { strongSelf.call.setIsNoiseSuppressionEnabled(!strongSelf.isNoiseSuppressionEnabled) } }))) } if let callState = strongSelf.callState, callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { if #available(iOS 12.0, *) { if strongSelf.call.hasScreencast { items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_StopScreenSharing, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.default) self?.call.disableScreencast() }))) } else { items.append(.custom(VoiceChatShareScreenContextItem(context: strongSelf.context, text: strongSelf.presentationData.strings.VoiceChat_ShareScreen, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) }, action: { _, _ in }), false)) } } } if canManageCall { if let recordingStartTimestamp = strongSelf.callState?.recordingStartTimestamp { items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: strongSelf.presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.VoiceChat_StopRecordingStop, action: { if let strongSelf = self { strongSelf.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) Queue.mainQueue().after(0.88) { strongSelf.hapticFeedback.success() } let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_RecordingSaved } else { text = strongSelf.presentationData.strings.VideoChat_RecordingSaved } strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController { let context = strongSelf.context strongSelf.controller?.dismiss(completion: { Queue.mainQueue().justDispatch { let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { peer in guard let peer = peer else { return } context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) }) } }) return true } return false }) } })]) self?.controller?.present(alertController, in: .window(.root)) }), false)) } else { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_StartRecording } else { text = strongSelf.presentationData.strings.VoiceChat_StartRecording } if strongSelf.callState?.scheduleTimestamp == nil { items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) }, action: { _, f in f(.dismissWithoutContent) guard let strongSelf = self, let peer = strongSelf.peer else { return } let controller = VoiceChatRecordingSetupController(context: strongSelf.context, peer: EnginePeer(peer), completion: { [weak self] videoOrientation in if let strongSelf = self { let title: String let text: String let placeholder: String if let _ = videoOrientation { placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo } else { placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholder } if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { title = strongSelf.presentationData.strings.LiveStream_StartRecordingTitle if let _ = videoOrientation { text = strongSelf.presentationData.strings.LiveStream_StartRecordingTextVideo } else { text = strongSelf.presentationData.strings.LiveStream_StartRecordingText } } else { title = strongSelf.presentationData.strings.VoiceChat_StartRecordingTitle if let _ = videoOrientation { text = strongSelf.presentationData.strings.VoiceChat_StartRecordingTextVideo } else { text = strongSelf.presentationData.strings.VoiceChat_StartRecordingText } } let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { title in if let strongSelf = self, let title = title { strongSelf.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = strongSelf.presentationData.strings.LiveStream_RecordingStarted } else { text = strongSelf.presentationData.strings.VoiceChat_RecordingStarted } strongSelf.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) strongSelf.call.playTone(.recordingStarted) } }) strongSelf.controller?.present(controller, in: .window(.root)) } }) self?.controller?.present(controller, in: .window(.root)) }))) } } } if canManageCall { let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelLiveStream : strongSelf.presentationData.strings.VoiceChat_EndLiveStream } else { text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelVoiceChat : strongSelf.presentationData.strings.VoiceChat_EndVoiceChat } items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) }, action: { _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } let action: () -> Void = { guard let strongSelf = self else { return } let _ = (strongSelf.call.leave(terminateIfPossible: true) |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(completed: { self?.controller?.dismiss() }) } let title: String let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { title = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle text = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText } else { title = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText } let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { action() })]) strongSelf.controller?.present(alertController, in: .window(.root)) }))) } else { let leaveText: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { leaveText = strongSelf.presentationData.strings.LiveStream_LeaveVoiceChat } else { leaveText = strongSelf.presentationData.strings.VoiceChat_LeaveVoiceChat } items.append(.action(ContextMenuActionItem(text: leaveText, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) }, action: { _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } let _ = (strongSelf.call.leave(terminateIfPossible: false) |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(completed: { self?.controller?.dismiss() }) }))) } return items } } private func contextMenuAudioItems() -> Signal<[ContextMenuItem], NoError> { guard let (availableOutputs, currentOutput) = self.audioOutputState else { return .single([]) } var items: [ContextMenuItem] = [] for output in availableOutputs { let title: String switch output { case .builtin: title = UIDevice.current.model case .speaker: title = self.presentationData.strings.Call_AudioRouteSpeaker case .headphones: title = self.presentationData.strings.Call_AudioRouteHeadphones case let .port(port): title = port.name } items.append(.action(ContextMenuActionItem(text: title, icon: { theme in if output == currentOutput { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) } else { return nil } }, action: { [weak self] _, f in f(.default) self?.call.setCurrentAudioOutput(output) }))) } items.append(.separator) items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconPosition: .left, action: { [weak self] (c, _) in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) return .single(items) } private func contextMenuDisplayAsItems() -> Signal<[ContextMenuItem], NoError> { guard let myPeerId = self.callState?.myPeerId else { return .single([]) } let avatarSize = CGSize(width: 28.0, height: 28.0) let darkTheme = self.darkTheme return self.displayAsPeersPromise.get() |> take(1) |> map { [weak self] peers -> [ContextMenuItem] in guard let strongSelf = self else { return [] } var items: [ContextMenuItem] = [] var isGroup = false for peer in peers { if peer.peer is TelegramGroup { isGroup = true break } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { isGroup = true break } } items.append(.custom(VoiceChatInfoContextItem(text: isGroup ? strongSelf.presentationData.strings.VoiceChat_DisplayAsInfoGroup : strongSelf.presentationData.strings.VoiceChat_DisplayAsInfo, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) }), true)) for peer in peers { var subtitle: String? if peer.peer.id.namespace == Namespaces.Peer.CloudUser { subtitle = strongSelf.presentationData.strings.VoiceChat_PersonalAccount } else if let subscribers = peer.subscribers { if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) } else { subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) } } let isSelected = peer.peer.id == myPeerId let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) let avatarSignal = peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize) |> map { image -> UIImage? in if isSelected, let image = image { return generateImage(extendedAvatarSize, rotatedContext: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) context.translateBy(x: size.width / 2.0, y: size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) context.draw(image.cgImage!, in: CGRect(x: (extendedAvatarSize.width - avatarSize.width) / 2.0, y: (extendedAvatarSize.height - avatarSize.height) / 2.0, width: avatarSize.width, height: avatarSize.height)) let lineWidth = 1.0 + UIScreenPixel context.setLineWidth(lineWidth) context.setStrokeColor(darkTheme.actionSheet.controlAccentColor.cgColor) context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) }) } else { return image } } items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { _, f in f(.default) guard let strongSelf = self else { return } if peer.peer.id != myPeerId { strongSelf.call.reconnect(as: peer.peer.id) } }))) if peer.peer.id.namespace == Namespaces.Peer.CloudUser { items.append(.separator) } } items.append(.separator) items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconPosition: .left, action: { (c, _) in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) return items } } private func contextMenuPermissionItems() -> Signal<[ContextMenuItem], NoError> { var items: [ContextMenuItem] = [] if let callState = self.callState, callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { let isMuted = defaultParticipantMuteState == .muted items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.VoiceChat_SpeakPermissionEveryone, icon: { theme in if isMuted { return nil } else { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) } }, action: { [weak self] _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: false) }))) items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.VoiceChat_SpeakPermissionAdmin, icon: { theme in if !isMuted { return nil } else { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) } }, action: { [weak self] _, f in f(.dismissWithoutContent) guard let strongSelf = self else { return } strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: true) }))) items.append(.separator) items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) }, iconPosition: .left, action: { [weak self] (c, _) in guard let strongSelf = self else { return } c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) }))) } return .single(items) } override func didLoad() { super.didLoad() self.view.disablesInteractiveTransitionGestureRecognizer = true self.view.disablesInteractiveModalDismiss = true self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) let longTapRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.actionButtonPressGesture(_:))) longTapRecognizer.minimumPressDuration = 0.001 longTapRecognizer.delegate = self self.actionButton.view.addGestureRecognizer(longTapRecognizer) let panRecognizer = DirectionalPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panRecognizer.delegate = self panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.view.addGestureRecognizer(panRecognizer) if self.isScheduling { self.setupSchedulePickerView() self.updateScheduleButtonTitle() } } private func updateSchedulePickerLimits() { let timeZone = TimeZone(secondsFromGMT: 0)! var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone let currentDate = Date() var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate) components.second = 0 let roundedDate = calendar.date(from: components)! let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: roundedDate) let minute = components.minute ?? 0 components.minute = 0 let roundedToHourDate = calendar.date(from: components)! components.hour = 0 let roundedToMidnightDate = calendar.date(from: components)! let nextTwoHourDate = calendar.date(byAdding: .hour, value: minute > 30 ? 4 : 3, to: roundedToHourDate) let maxDate = calendar.date(byAdding: .day, value: 8, to: roundedToMidnightDate) if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) { self.pickerView?.maximumDate = date } if let next1MinDate = next1MinDate, let nextTwoHourDate = nextTwoHourDate { self.pickerView?.minimumDate = next1MinDate self.pickerView?.maximumDate = maxDate self.pickerView?.date = nextTwoHourDate } } private func setupSchedulePickerView() { var currentDate: Date? if let pickerView = self.pickerView { currentDate = pickerView.date pickerView.removeFromSuperview() } let textColor = UIColor.white UILabel.setDateLabel(textColor) let pickerView = UIDatePicker() pickerView.timeZone = TimeZone(secondsFromGMT: 0) pickerView.datePickerMode = .countDownTimer pickerView.datePickerMode = .dateAndTime pickerView.locale = Locale.current pickerView.timeZone = TimeZone.current pickerView.minuteInterval = 1 self.contentContainer.view.addSubview(pickerView) pickerView.addTarget(self, action: #selector(self.scheduleDatePickerUpdated), for: .valueChanged) if #available(iOS 13.4, *) { pickerView.preferredDatePickerStyle = .wheels } pickerView.setValue(textColor, forKey: "textColor") self.pickerView = pickerView self.updateSchedulePickerLimits() if let currentDate = currentDate { pickerView.date = currentDate } } private let calendar = Calendar(identifier: .gregorian) private func updateScheduleButtonTitle() { guard let date = self.pickerView?.date else { return } let calendar = Calendar(identifier: .gregorian) let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) let timestamp = Int32(date.timeIntervalSince1970) let time = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: self.presentationData.dateTimeFormat) let buttonTitle: String if calendar.isDateInToday(date) { buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleToday(time).string } else if calendar.isDateInTomorrow(date) { buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleTomorrow(time).string } else { buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleOn(self.dateFormatter.string(from: date), time).string } self.scheduleButtonTitle = buttonTitle let delta = timestamp - currentTimestamp var isGroup = true if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { isGroup = false } let intervalString = scheduledTimeIntervalString(strings: self.presentationData.strings, value: max(60, delta)) self.scheduleTextNode.attributedText = NSAttributedString(string: isGroup ? self.presentationData.strings.ScheduleVoiceChat_GroupText(intervalString).string : self.presentationData.strings.ScheduleLiveStream_ChannelText(intervalString).string, font: Font.regular(14.0), textColor: UIColor(rgb: 0x8e8e93)) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } } @objc private func scheduleDatePickerUpdated() { self.updateScheduleButtonTitle() } private func schedule() { if let date = self.pickerView?.date, date > Date() { self.call.schedule(timestamp: Int32(date.timeIntervalSince1970)) self.isScheduling = false self.transitionToScheduled() if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } } } private func dismissScheduled() { self.leaveDisposable.set((self.call.leave(terminateIfPossible: true) |> deliverOnMainQueue).start(completed: { [weak self] in self?.controller?.dismiss(closing: true) })) } private func transitionToScheduled() { let springDuration: Double = 0.6 let springDamping: CGFloat = 100.0 self.optionsButton.alpha = 1.0 self.optionsButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.optionsButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) self.optionsButton.isUserInteractionEnabled = true self.closeButton.alpha = 1.0 self.closeButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.closeButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) self.closeButton.isUserInteractionEnabled = true self.audioButton.alpha = 1.0 self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) self.audioButton.isUserInteractionEnabled = true self.leaveButton.alpha = 1.0 self.leaveButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.leaveButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) self.leaveButton.isUserInteractionEnabled = true self.scheduleCancelButton.alpha = 0.0 self.scheduleCancelButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) self.scheduleCancelButton.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: 26.0), duration: 0.2, removeOnCompletion: false, additive: true) self.actionButton.titleLabel.layer.animatePosition(from: CGPoint(x: 0.0, y: -26.0), to: CGPoint(), duration: 0.2, additive: true) if let pickerView = self.pickerView { self.pickerView = nil pickerView.alpha = 0.0 pickerView.layer.animateScale(from: 1.0, to: 0.25, duration: 0.15, removeOnCompletion: false) pickerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak pickerView] _ in pickerView?.removeFromSuperview() }) pickerView.isUserInteractionEnabled = false } self.timerNode.isHidden = false self.timerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) self.timerNode.animateIn() self.scheduleTextNode.alpha = 0.0 self.scheduleTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) self.updateTitle(slide: true, transition: .animated(duration: 0.2, curve: .easeInOut)) } private func transitionToCall() { self.updateDecorationsColors() self.listNode.alpha = 1.0 self.listNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.listNode.isUserInteractionEnabled = true self.timerNode.alpha = 0.0 self.timerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in self?.timerNode.isHidden = true }) if self.audioButton.isHidden { self.audioButton.isHidden = false self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0) } self.updateTitle(transition: .animated(duration: 0.2, curve: .easeInOut)) } @objc private func optionsPressed() { self.optionsButton.play() self.optionsButton.contextAction?(self.optionsButton.containerNode, nil) } @objc private func closePressed() { self.controller?.dismiss(closing: false) self.controller?.dismissAllTooltips() } @objc private func panelPressed() { guard let (layout, navigationHeight) = self.validLayout, !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { return } self.panelHidden = !self.panelHidden self.animatingExpansion = true let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) self.updateDecorationsLayout(transition: transition) } @objc private func leavePressed() { self.hapticFeedback.impact(.light) self.controller?.dismissAllTooltips() if let callState = self.callState, callState.canManageCall { let action: () -> Void = { [weak self] in guard let strongSelf = self else { return } strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: true) |> deliverOnMainQueue).start(completed: { self?.controller?.dismiss() })) } let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) var items: [ActionSheetItem] = [] let leaveTitle: String let leaveAndCancelTitle: String if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { leaveTitle = self.presentationData.strings.LiveStream_LeaveConfirmation leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.LiveStream_LeaveAndCancelVoiceChat : self.presentationData.strings.LiveStream_LeaveAndEndVoiceChat } else { leaveTitle = self.presentationData.strings.VoiceChat_LeaveConfirmation leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.VoiceChat_LeaveAndCancelVoiceChat : self.presentationData.strings.VoiceChat_LeaveAndEndVoiceChat } items.append(ActionSheetTextItem(title: leaveTitle)) items.append(ActionSheetButtonItem(title: leaveAndCancelTitle, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { let title: String let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { title = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle text = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText } else { title = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle text = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText } if let (members, _) = strongSelf.currentCallMembers, members.count >= 10 || true { let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { action() })]) strongSelf.controller?.present(alertController, in: .window(.root)) } else { action() } } })) let leaveText: String if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { leaveText = self.presentationData.strings.LiveStream_LeaveVoiceChat } else { leaveText = self.presentationData.strings.VoiceChat_LeaveVoiceChat } items.append(ActionSheetButtonItem(title: leaveText, color: .accent, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: false) |> deliverOnMainQueue).start(completed: { [weak self] in self?.controller?.dismiss(closing: true) })) })) actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ]) ]) self.controller?.present(actionSheet, in: .window(.root)) } else { self.leaveDisposable.set((self.call.leave(terminateIfPossible: false) |> deliverOnMainQueue).start(completed: { [weak self] in self?.controller?.dismiss(closing: true) })) } } @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if self.isScheduling { self.dismissScheduled() } else { self.controller?.dismiss(closing: false) self.controller?.dismissAllTooltips() } } } private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { var animateInAsReplacement = false self.controller?.forEachController { c in if let c = c as? UndoOverlayController { animateInAsReplacement = true c.dismiss() } return true } self.controller?.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) } private func presentShare(_ inviteLinks: GroupCallInviteLinks) { let formatSendTitle: (String) -> String = { string in var string = string if string.contains("[") && string.contains("]") { if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { string.removeSubrange(startIndex ... endIndex) } } else { string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) } return string } let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) |> deliverOnMainQueue).start(next: { [weak self] peer in if let strongSelf = self { var inviteLinks = inviteLinks if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let callState = strongSelf.callState, let defaultParticipantMuteState = callState.defaultParticipantMuteState { let isMuted = defaultParticipantMuteState == .muted if !isMuted { inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) } } let presentationData = strongSelf.presentationData var segmentedValues: [ShareControllerSegmentedValue]? if let speakerLink = inviteLinks.speakerLink { segmentedValues = [ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Speaker, subject: .url(speakerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopySpeakerLink, formatSendTitle: { count in return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteSpeakers(Int32(count))) }), ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Listener, subject: .url(inviteLinks.listenerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopyListenerLink, formatSendTitle: { count in return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteListeners(Int32(count))) })] } let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: strongSelf.darkTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink) shareController.completed = { [weak self] peerIds in if let strongSelf = self { let _ = (strongSelf.context.engine.data.get( EngineDataList( peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) ) ) |> deliverOnMainQueue).start(next: { [weak self] peerList in if let strongSelf = self { let peers = peerList.compactMap { $0 } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let text: String var isSavedMessages = false if peers.count == 1, let peer = peers.first { isSavedMessages = peer.id == strongSelf.context.account.peerId let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string } else if let peer = peers.first { let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string } else { text = "" } strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) } }) } } shareController.actionCompleted = { [weak self] in if let strongSelf = self { let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) } } strongSelf.controller?.present(shareController, in: .window(.root)) } }) } private var actionButtonPressTimer: SwiftSignalKit.Timer? private var actionButtonPressedTimestamp: Double? private func startActionButtonPressTimer() { self.actionButtonPressTimer?.invalidate() let pressTimer = SwiftSignalKit.Timer(timeout: 0.185, repeat: false, completion: { [weak self] in self?.actionButtonPressedTimestamp = CACurrentMediaTime() self?.actionButtonPressTimerFired() self?.actionButtonPressTimer = nil }, queue: Queue.mainQueue()) self.actionButtonPressTimer = pressTimer pressTimer.start() } private func stopActionButtonPressTimer() { self.actionButtonPressTimer?.invalidate() self.actionButtonPressTimer = nil } private func actionButtonPressTimerFired() { guard let callState = self.callState else { return } if callState.muteState != nil { self.pushingToTalk = true self.call.setIsMuted(action: .muted(isPushToTalkActive: true)) } if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } self.updateMembers() } @objc private func actionButtonPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer) { guard let callState = self.callState else { return } if case .connecting = callState.networkState, callState.scheduleTimestamp == nil && !self.isScheduling { return } if callState.scheduleTimestamp != nil || self.isScheduling { switch gestureRecognizer.state { case .began: self.actionButton.pressing = true self.hapticFeedback.impact(.light) case .ended, .cancelled: self.actionButton.pressing = false let location = gestureRecognizer.location(in: self.actionButton.view) if self.actionButton.hitTest(location, with: nil) != nil { if self.isScheduling { self.schedule() } else if callState.canManageCall { self.call.startScheduled() } else { if !callState.subscribedToScheduled { let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center let point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) self.controller?.present(TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: self.presentationData.strings.VoiceChat_ReminderNotify), style: .gradient(UIColor(rgb: 0x262c5a), UIColor(rgb: 0x5d2835)), icon: nil, location: .point(point, .bottom), displayDuration: .custom(3.0), shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) }), in: .window(.root)) } self.call.toggleScheduledSubscription(!callState.subscribedToScheduled) } } default: break } return } if let muteState = callState.muteState { if !muteState.canUnmute { switch gestureRecognizer.state { case .began: self.actionButton.pressing = true self.hapticFeedback.impact(.light) case .ended, .cancelled: self.actionButton.pressing = false let location = gestureRecognizer.location(in: self.actionButton.view) if self.actionButton.hitTest(location, with: nil) != nil { self.call.raiseHand() self.actionButton.playAnimation() } default: break } return } } switch gestureRecognizer.state { case .began: self.actionButton.pressing = true self.hapticFeedback.impact(.light) self.actionButtonPressedTimestamp = nil self.startActionButtonPressTimer() if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } case .ended, .cancelled: if self.actionButtonPressTimer != nil { self.pushingToTalk = false self.actionButton.pressing = false self.stopActionButtonPressTimer() self.call.toggleIsMuted() } else { self.hapticFeedback.impact(.light) if self.pushingToTalk, let timestamp = self.actionButtonPressedTimestamp, CACurrentMediaTime() < timestamp + 0.5 { self.pushingToTalk = false self.temporaryPushingToTalk = true self.call.setIsMuted(action: .unmuted) Queue.mainQueue().after(0.1) { self.temporaryPushingToTalk = false self.actionButton.pressing = false if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } } } else { self.pushingToTalk = false self.actionButton.pressing = false self.call.setIsMuted(action: .muted(isPushToTalkActive: false)) } } if let callState = self.callState { self.itemInteraction?.updateAudioLevels([(callState.myPeerId, 0, 0.0, false)], reset: true) } if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) } self.updateMembers() default: break } } @objc private func actionPressed() { if self.isScheduling { self.schedule() } } @objc private func audioPressed() { self.hapticFeedback.impact(.light) if let _ = self.callState?.scheduleTimestamp { if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { return } let _ = (self.inviteLinksPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] inviteLinks in guard let strongSelf = self else { return } let _ = (strongSelf.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId), TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId) ) |> map { peer, exportedInvitation -> GroupCallInviteLinks? in if let inviteLinks = inviteLinks { return inviteLinks } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil) } else if let link = exportedInvitation?.link { return GroupCallInviteLinks(listenerLink: link, speakerLink: nil) } return nil } |> deliverOnMainQueue).start(next: { links in guard let strongSelf = self else { return } if let links = links { strongSelf.presentShare(links) } }) }) return } guard let (availableOutputs, currentOutput) = self.audioOutputState else { return } guard availableOutputs.count >= 2 else { return } if availableOutputs.count == 2 { for output in availableOutputs { if output != currentOutput { self.call.setCurrentAudioOutput(output) break } } } else { let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) var items: [ActionSheetItem] = [] for output in availableOutputs { let title: String var icon: UIImage? switch output { case .builtin: title = UIDevice.current.model case .speaker: title = self.presentationData.strings.Call_AudioRouteSpeaker icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) case .headphones: title = self.presentationData.strings.Call_AudioRouteHeadphones case let .port(port): title = port.name if port.type == .bluetooth { var image = UIImage(bundleImageName: "Call/CallBluetoothButton") let portName = port.name.lowercased() if portName.contains("airpods max") { image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton") } else if portName.contains("airpods pro") { image = UIImage(bundleImageName: "Call/CallAirpodsProButton") } else if portName.contains("airpods") { image = UIImage(bundleImageName: "Call/CallAirpodsButton") } icon = generateScaledImage(image: image, size: CGSize(width: 48.0, height: 48.0), opaque: false) } } items.append(CallRouteActionSheetItem(title: title, icon: icon, selected: output == currentOutput, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() self?.call.setCurrentAudioOutput(output) })) } actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: self.presentationData.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ]) ]) self.controller?.present(actionSheet, in: .window(.calls)) } } @objc private func cameraPressed() { self.hapticFeedback.impact(.light) if self.call.hasVideo { self.call.disableVideo() if let (layout, navigationHeight) = self.validLayout { self.animatingButtonsSwap = true self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) } } else { DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: self.presentationData.withUpdated(theme: self.darkTheme), present: { [weak self] c, a in self?.controller?.present(c, in: .window(.root), with: a) }, openSettings: { [weak self] in self?.context.sharedContext.applicationBindings.openSettings() }, _: { [weak self] ready in guard let strongSelf = self, ready else { return } var isFrontCamera = true let videoCapturer = OngoingCallVideoCapturer() let input = videoCapturer.video() if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { videoView.updateIsEnabled(true) let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil) let controller = VoiceChatCameraPreviewController(sharedContext: strongSelf.context.sharedContext, cameraNode: cameraNode, shareCamera: { [weak self] _, unmuted in if let strongSelf = self { strongSelf.call.setIsMuted(action: unmuted ? .unmuted : .muted(isPushToTalkActive: false)) (strongSelf.call as! PresentationGroupCallImpl).requestVideo(capturer: videoCapturer, useFrontCamera: isFrontCamera) if let (layout, navigationHeight) = strongSelf.validLayout { strongSelf.animatingButtonsSwap = true strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) } } }, switchCamera: { Queue.mainQueue().after(0.1) { isFrontCamera = !isFrontCamera videoCapturer.switchVideoInput(isFront: isFrontCamera) } }) strongSelf.controller?.present(controller, in: .window(.root)) } }) } } @objc private func switchCameraPressed() { self.hapticFeedback.impact(.light) Queue.mainQueue().after(0.1) { self.call.switchVideoCamera() } if let callState = self.callState { for entry in self.currentFullscreenEntries { if case let .peer(peerEntry, _) = entry { if peerEntry.peer.id == callState.myPeerId { if let videoEndpointId = peerEntry.videoEndpointId, let videoNode = self.videoNodes[videoEndpointId] { videoNode.flip(withBackground: false) } break } } } } self.mainStageNode.flipVideoIfNeeded() let springDuration: Double = 0.7 let springDamping: CGFloat = 100.0 self.switchCameraButton.isUserInteractionEnabled = false self.switchCameraButton.layer.animateSpring(from: 0.0 as NSNumber, to: CGFloat.pi as NSNumber, keyPath: "transform.rotation.z", duration: springDuration, damping: springDamping, completion: { [weak self] _ in self?.switchCameraButton.isUserInteractionEnabled = true }) } private var isLandscape: Bool { if let (layout, _) = self.validLayout, layout.size.width > layout.size.height, case .compact = layout.metrics.widthClass { return true } else { return false } } private var effectiveBottomAreaHeight: CGFloat { if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { return bottomAreaHeight } switch self.displayMode { case .modal: return bottomAreaHeight case let .fullscreen(controlsHidden): return controlsHidden ? 0.0 : fullscreenBottomAreaHeight } } private var isFullscreen: Bool { switch self.displayMode { case .fullscreen(_), .modal(_, true): return true default: return false } } private func updateDecorationsLayout(transition: ContainedViewLayoutTransition, completion: (() -> Void)? = nil) { guard let (layout, _) = self.validLayout else { return } let isLandscape = self.isLandscape let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) let listTopInset = isLandscape ? topPanelHeight : layoutTopInset + topPanelHeight let bottomPanelHeight = isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom let size = layout.size let contentWidth: CGFloat var contentLeftInset: CGFloat = 0.0 var forceUpdate = false if case .regular = layout.metrics.widthClass { contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) if self.peerIdToEndpointId.isEmpty { contentLeftInset = 0.0 } else { contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth } forceUpdate = true } else { contentWidth = isLandscape ? min(530.0, size.width - 210.0) : size.width } let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) let topInset: CGFloat if let (panInitialTopInset, panOffset) = self.panGestureArguments { if self.isExpanded { topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) } else { topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) } } else if case .regular = layout.metrics.widthClass { topInset = 0.0 } else if let currentTopInset = self.topInset { topInset = self.isExpanded ? 0.0 : currentTopInset } else { topInset = listSize.height - 46.0 - floor(56.0 * 3.5) } var bottomEdge: CGFloat = 0.0 if case .regular = layout.metrics.widthClass { bottomEdge = size.height } else { self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ListViewItemNode { let convertedFrame = self.listNode.view.convert(itemNode.frame, to: self.contentContainer.view) if convertedFrame.maxY > bottomEdge { bottomEdge = convertedFrame.maxY } } } if bottomEdge.isZero { bottomEdge = self.listNode.frame.minY + 46.0 + 56.0 } } let rawPanelOffset = topInset + listTopInset - topPanelHeight let panelOffset = max(layoutTopInset, rawPanelOffset) let topPanelFrame: CGRect if isLandscape { topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: topPanelHeight)) } else { topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: panelOffset), size: CGSize(width: size.width, height: topPanelHeight)) } let sideInset: CGFloat = 14.0 let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom var bottomGradientFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: size.width, height: bottomGradientHeight)) if isLandscape { bottomGradientFrame.origin.y = layout.size.height } let transitionContainerFrame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) transition.updateFrame(node: self.transitionContainerNode, frame: transitionContainerFrame) transition.updateFrame(view: self.transitionMaskView, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: transitionContainerFrame.height)) let updateMaskLayers = { var topPanelFrame = topPanelFrame if self.animatingContextMenu { topPanelFrame.origin.y = 0.0 } transition.updateFrame(layer: self.transitionMaskTopFillLayer, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: topPanelFrame.maxY)) transition.updateFrame(layer: self.transitionMaskFillLayer, frame: CGRect(x: 0.0, y: topPanelFrame.maxY, width: transitionContainerFrame.width, height: bottomGradientFrame.minY - topPanelFrame.maxY)) transition.updateFrame(layer: self.transitionMaskGradientLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: bottomGradientFrame.height)) transition.updateFrame(layer: self.transitionMaskBottomFillLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: max(0.0, transitionContainerFrame.height - bottomGradientFrame.minY))) } if transition.isAnimated { updateMaskLayers() } else { CATransaction.begin() CATransaction.setDisableActions(true) updateMaskLayers() CATransaction.commit() } var bottomInset: CGFloat = 0.0 if case .compact = layout.metrics.widthClass, case let .fullscreen(controlsHidden) = self.displayMode { if !controlsHidden { bottomInset = 80.0 } } transition.updateAlpha(node: self.bottomGradientNode, alpha: self.isLandscape ? 0.0 : 1.0) var isTablet = false let videoFrame: CGRect let videoContainerFrame: CGRect if case .regular = layout.metrics.widthClass { isTablet = true let videoTopEdgeY = topPanelFrame.maxY let videoBottomEdgeY = layout.size.height - layout.intrinsicInsets.bottom videoFrame = CGRect(x: sideInset, y: 0.0, width: contentLeftInset - sideInset, height: videoBottomEdgeY - videoTopEdgeY) videoContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: videoTopEdgeY), size: CGSize(width: contentLeftInset, height: layout.size.height)) } else { let videoTopEdgeY = isLandscape ? 0.0 : layoutTopInset let videoBottomEdgeY = self.isLandscape ? layout.size.height : layout.size.height - layout.intrinsicInsets.bottom - 92.0 videoFrame = CGRect(x: 0.0, y: videoTopEdgeY, width: isLandscape ? max(0.0, layout.size.width - layout.safeInsets.right - 92.0) : layout.size.width, height: videoBottomEdgeY - videoTopEdgeY) videoContainerFrame = CGRect(origin: CGPoint(), size: layout.size) } transition.updateFrame(node: self.mainStageContainerNode, frame: videoContainerFrame) transition.updateFrame(node: self.mainStageBackgroundNode, frame: videoFrame) if !self.mainStageNode.animating { transition.updateFrame(node: self.mainStageNode, frame: videoFrame) } self.mainStageNode.update(size: videoFrame.size, sideInset: layout.safeInsets.left, bottomInset: self.isLandscape ? 0.0 : bottomInset, isLandscape: videoFrame.width > videoFrame.height, isTablet: isTablet, transition: transition) let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY), size: CGSize(width: size.width, height: layout.size.height)) let leftBorderFrame: CGRect let rightBorderFrame: CGRect let additionalInset: CGFloat = 60.0 let additionalSideInset = (size.width - contentWidth) / 2.0 let additionalLeftInset = size.width / 2.0 if isLandscape { leftBorderFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) rightBorderFrame = CGRect(origin: CGPoint(x: size.width - (size.width - contentWidth) / 2.0 - sideInset, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: layout.safeInsets.right + (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) } else { var isFullscreen = false if case .fullscreen = self.displayMode { isFullscreen = true forceUpdate = true } leftBorderFrame = CGRect(origin: CGPoint(x: -additionalInset - additionalLeftInset, y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + (contentLeftInset.isZero ? additionalSideInset : contentLeftInset), height: layout.size.height)) rightBorderFrame = CGRect(origin: CGPoint(x: size.width - sideInset - (contentLeftInset.isZero ? additionalSideInset : 0.0), y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + additionalSideInset, height: layout.size.height)) } let topCornersFrame = CGRect(x: sideInset + (contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset), y: topPanelFrame.maxY - 60.0, width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0) let previousTopPanelFrame = self.topPanelNode.frame let previousBackgroundFrame = self.backgroundNode.frame let previousLeftBorderFrame = self.leftBorderNode.frame let previousRightBorderFrame = self.rightBorderNode.frame if !topPanelFrame.equalTo(previousTopPanelFrame) || forceUpdate { if topPanelFrame.width != previousTopPanelFrame.width { transition.updateFrame(node: self.topPanelNode, frame: topPanelFrame) transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) transition.updateFrame(node: self.leftBorderNode, frame: leftBorderFrame) transition.updateFrame(node: self.rightBorderNode, frame: rightBorderFrame) } else { self.topPanelNode.frame = topPanelFrame let positionDelta = CGPoint(x: 0.0, y: topPanelFrame.minY - previousTopPanelFrame.minY) transition.animateOffsetAdditive(layer: self.topPanelNode.layer, offset: positionDelta.y, completion: completion) transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) self.backgroundNode.frame = backgroundFrame let backgroundPositionDelta = CGPoint(x: 0.0, y: previousBackgroundFrame.minY - backgroundFrame.minY) transition.animatePositionAdditive(node: self.backgroundNode, offset: backgroundPositionDelta) self.leftBorderNode.frame = leftBorderFrame let leftBorderPositionDelta = CGPoint(x: previousLeftBorderFrame.maxX - leftBorderFrame.maxX, y: previousLeftBorderFrame.minY - leftBorderFrame.minY) transition.animatePositionAdditive(node: self.leftBorderNode, offset: leftBorderPositionDelta) self.rightBorderNode.frame = rightBorderFrame let rightBorderPositionDelta = CGPoint(x: previousRightBorderFrame.minX - rightBorderFrame.minX, y: previousRightBorderFrame.minY - rightBorderFrame.minY) transition.animatePositionAdditive(node: self.rightBorderNode, offset: rightBorderPositionDelta) } } else { completion?() } self.topPanelBackgroundNode.frame = CGRect(x: 0.0, y: topPanelHeight - 24.0, width: size.width, height: min(topPanelFrame.height, 24.0)) let listMaxY = listTopInset + listSize.height let bottomOffset = min(0.0, bottomEdge - listMaxY) + layout.size.height - bottomPanelHeight let bottomCornersFrame = CGRect(origin: CGPoint(x: sideInset + floorToScreenPixels((size.width - contentWidth) / 2.0), y: -50.0 + bottomOffset + bottomGradientHeight), size: CGSize(width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0)) let bottomPanelBackgroundFrame = CGRect(x: 0.0, y: bottomOffset + bottomGradientHeight, width: size.width, height: 2000.0) let previousBottomCornersFrame = self.bottomCornersNode.frame if !bottomCornersFrame.equalTo(previousBottomCornersFrame) { if bottomCornersFrame.width != previousBottomCornersFrame.width { transition.updateFrame(node: self.bottomCornersNode, frame: bottomCornersFrame) transition.updateFrame(node: self.bottomPanelBackgroundNode, frame: bottomPanelBackgroundFrame) } else { self.bottomCornersNode.frame = bottomCornersFrame self.bottomPanelBackgroundNode.frame = bottomPanelBackgroundFrame let positionDelta = CGPoint(x: 0.0, y: previousBottomCornersFrame.minY - bottomCornersFrame.minY) transition.animatePositionAdditive(node: self.bottomCornersNode, offset: positionDelta) transition.animatePositionAdditive(node: self.bottomPanelBackgroundNode, offset: positionDelta) } } let participantsFrame = CGRect(x: 0.0, y: bottomCornersFrame.maxY - 100.0, width: size.width, height: 216.0) transition.updateFrame(node: self.participantsNode, frame: participantsFrame) self.participantsNode.update(size: participantsFrame.size, participants: self.currentTotalCount, groupingSeparator: self.presentationData.dateTimeFormat.groupingSeparator, transition: .immediate) } private var decorationsAreDark: Bool? private var ignoreLayout = false private func updateDecorationsColors() { guard let (layout, _) = self.validLayout else { return } let isFullscreen = self.isFullscreen let effectiveDisplayMode = self.displayMode self.ignoreLayout = true self.controller?.statusBar.updateStatusBarStyle(isFullscreen ? .White : .Ignore, animated: true) self.ignoreLayout = false let size = layout.size let topEdgeFrame: CGRect if isFullscreen { let offset: CGFloat if let statusBarHeight = layout.statusBarHeight { offset = statusBarHeight } else { offset = 44.0 } topEdgeFrame = CGRect(x: 0.0, y: -offset, width: size.width, height: topPanelHeight + offset) } else { topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) } let backgroundColor: UIColor if case .fullscreen = effectiveDisplayMode { backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor } else if self.isScheduling || self.callState?.scheduleTimestamp != nil { backgroundColor = panelBackgroundColor } else { backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor } let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) transition.updateCornerRadius(node: self.topPanelEdgeNode, cornerRadius: isFullscreen ? layout.deviceMetrics.screenCornerRadius - 0.5 : 12.0) transition.updateBackgroundColor(node: self.topPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) transition.updateBackgroundColor(node: self.topPanelEdgeNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) transition.updateBackgroundColor(node: self.backgroundNode, color: backgroundColor) transition.updateBackgroundColor(node: self.bottomPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) transition.updateBackgroundColor(node: self.leftBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) transition.updateBackgroundColor(node: self.rightBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) var gridNode: VoiceChatTilesGridItemNode? self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { gridNode = itemNode } } if let gridNode = gridNode { transition.updateBackgroundColor(node: gridNode.backgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) } let previousDark = self.decorationsAreDark self.decorationsAreDark = isFullscreen if previousDark != self.decorationsAreDark { if let snapshotView = self.topCornersNode.view.snapshotContentTree() { snapshotView.frame = self.topCornersNode.bounds self.topCornersNode.view.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } self.topCornersNode.image = decorationTopCornersImage(dark: isFullscreen) if let snapshotView = self.bottomCornersNode.view.snapshotContentTree() { snapshotView.frame = self.bottomCornersNode.bounds self.bottomCornersNode.view.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } self.bottomCornersNode.image = decorationBottomCornersImage(dark: isFullscreen) if let gridNode = gridNode { if let snapshotView = gridNode.cornersNode.view.snapshotContentTree() { snapshotView.frame = gridNode.cornersNode.bounds gridNode.cornersNode.view.addSubview(snapshotView) snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } gridNode.cornersNode.image = decorationCornersImage(top: true, bottom: false, dark: isFullscreen) gridNode.supernode?.addSubnode(gridNode) } UIView.transition(with: self.bottomGradientNode.view, duration: 0.3, options: [.transitionCrossDissolve, .curveLinear]) { self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: isFullscreen).flatMap { UIColor(patternImage: $0) } } completion: { _ in } self.closeButton.setContent(.image(closeButtonImage(dark: isFullscreen)), animated: transition.isAnimated) self.optionsButton.setContent(.more(optionsCircleImage(dark: isFullscreen)), animated: transition.isAnimated) self.panelButton.setContent(.image(panelButtonImage(dark: isFullscreen)), animated: transition.isAnimated) } self.updateTitle(transition: transition) } private func updateTitle(slide: Bool = false, transition: ContainedViewLayoutTransition) { guard let _ = self.validLayout else { return } var title = self.currentTitle if self.isScheduling { if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { title = self.presentationData.strings.ScheduleLiveStream_Title } else { title = self.presentationData.strings.ScheduleVoiceChat_Title } } else if case .modal(_, false) = self.displayMode, !self.currentTitleIsCustom { if let navigationController = self.controller?.navigationController as? NavigationController { for controller in navigationController.viewControllers.reversed() { if let controller = controller as? ChatController, case let .peer(peerId) = controller.chatLocation, peerId == self.call.peerId { if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { title = self.presentationData.strings.VoiceChatChannel_Title } else { title = self.presentationData.strings.VoiceChat_Title } } } } } var subtitle = "" var speaking = false if self.scrollAtTop { subtitle = self.currentSubtitle speaking = false } else { subtitle = self.currentSpeakingSubtitle ?? self.currentSubtitle speaking = self.currentSpeakingSubtitle != nil } if self.isScheduling { subtitle = "" speaking = false } else if self.callState?.scheduleTimestamp != nil { if self.callState?.canManageCall ?? false { subtitle = self.presentationData.strings.VoiceChat_TapToEditTitle } else { subtitle = self.presentationData.strings.VoiceChat_Scheduled } speaking = false } self.titleNode.update(size: CGSize(width: self.titleNode.bounds.width, height: 44.0), title: title, subtitle: subtitle, speaking: speaking, slide: slide, transition: transition) } private func updateButtons(transition: ContainedViewLayoutTransition) { guard let (layout, _) = self.validLayout else { return } var audioMode: CallControllerButtonsSpeakerMode = .none //var hasAudioRouteMenu: Bool = false if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { //hasAudioRouteMenu = availableOutputs.count > 2 switch currentOutput { case .builtin: audioMode = .builtin case .speaker: audioMode = .speaker case .headphones: audioMode = .headphones case let .port(port): var type: CallControllerButtonsSpeakerMode.BluetoothType = .generic let portName = port.name.lowercased() if portName.contains("airpods max") { type = .airpodsMax } else if portName.contains("airpods pro") { type = .airpodsPro } else if portName.contains("airpods") { type = .airpods } audioMode = .bluetooth(type) } if availableOutputs.count <= 1 { audioMode = .none } } let normalButtonAppearance: CallControllerButtonItemNode.Content.Appearance let activeButtonAppearance: CallControllerButtonItemNode.Content.Appearance if let color = self.currentNormalButtonColor { normalButtonAppearance = .color(.custom(color.rgb, 1.0)) } else { normalButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) } if let color = self.currentActiveButtonColor { activeButtonAppearance = .color(.custom(color.rgb, 1.0)) } else { activeButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) } var soundImage: CallControllerButtonItemNode.Content.Image var soundAppearance: CallControllerButtonItemNode.Content.Appearance = normalButtonAppearance var soundTitle: String = self.presentationData.strings.Call_Speaker switch audioMode { case .none, .builtin: soundImage = .speaker case .speaker: soundImage = .speaker soundAppearance = activeButtonAppearance case .headphones: soundImage = .headphones soundTitle = self.presentationData.strings.Call_Audio case let .bluetooth(type): switch type { case .generic: soundImage = .bluetooth case .airpods: soundImage = .airpods case .airpodsPro: soundImage = .airpodsPro case .airpodsMax: soundImage = .airpodsMax } soundTitle = self.presentationData.strings.Call_Audio } let isScheduled = self.isScheduling || self.callState?.scheduleTimestamp != nil var isSoundEnabled = true if isScheduled { if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { isSoundEnabled = false } else { soundImage = .share soundTitle = self.presentationData.strings.VoiceChat_ShareShort soundAppearance = normalButtonAppearance } } let audioButtonSize: CGSize var buttonsTitleAlpha: CGFloat let effectiveDisplayMode = self.displayMode let hasCameraButton = self.cameraButton.isUserInteractionEnabled let hasVideo = self.call.hasVideo switch effectiveDisplayMode { case .modal: audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize buttonsTitleAlpha = 1.0 case .fullscreen: if case .regular = layout.metrics.widthClass { audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize } else { audioButtonSize = sideButtonSize } if case .regular = layout.metrics.widthClass { buttonsTitleAlpha = 1.0 } else { buttonsTitleAlpha = 0.0 } } self.cameraButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: hasVideo ? activeButtonAppearance : normalButtonAppearance, image: hasVideo ? .cameraOn : .cameraOff), text: self.presentationData.strings.VoiceChat_Video, transition: transition) self.switchCameraButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .flipCamera), text: "", transition: transition) transition.updateAlpha(node: self.switchCameraButton, alpha: hasCameraButton && hasVideo ? 1.0 : 0.0) transition.updateTransformScale(node: self.switchCameraButton, scale: hasCameraButton && hasVideo ? 1.0 : 0.0) transition.updateTransformScale(node: self.cameraButton, scale: hasCameraButton ? 1.0 : 0.0) let hasAudioButton = !self.isScheduling transition.updateAlpha(node: self.audioButton, alpha: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) transition.updateTransformScale(node: self.audioButton, scale: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) self.audioButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: soundAppearance, image: soundImage, isEnabled: isSoundEnabled), text: soundTitle, transition: transition) self.audioButton.isUserInteractionEnabled = isSoundEnabled self.leaveButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: .color(.custom(0xff3b30, 0.3)), image: .cancel), text: self.presentationData.strings.VoiceChat_Leave, transition: .immediate) transition.updateAlpha(node: self.cameraButton.textNode, alpha: buttonsTitleAlpha) transition.updateAlpha(node: self.switchCameraButton.textNode, alpha: buttonsTitleAlpha) transition.updateAlpha(node: self.audioButton.textNode, alpha: buttonsTitleAlpha) transition.updateAlpha(node: self.leaveButton.textNode, alpha: buttonsTitleAlpha) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { guard !self.ignoreLayout else { return } let isFirstTime = self.validLayout == nil let previousLayout = self.validLayout?.0 self.validLayout = (layout, navigationHeight) let size = layout.size let contentWidth: CGFloat let headerWidth: CGFloat let contentLeftInset: CGFloat if case .regular = layout.metrics.widthClass { contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) headerWidth = size.width if self.peerIdToEndpointId.isEmpty { contentLeftInset = 0.0 } else { contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth } } else { contentWidth = self.isLandscape ? min(530.0, size.width - 210.0) : size.width headerWidth = contentWidth contentLeftInset = 0.0 } var previousIsLandscape = false if let previousLayout = previousLayout, case .compact = previousLayout.metrics.widthClass, previousLayout.size.width > previousLayout.size.height { previousIsLandscape = true } var shouldSwitchToExpanded = false if case let .modal(isExpanded, _) = self.displayMode { if previousIsLandscape != self.isLandscape && !isExpanded { shouldSwitchToExpanded = true } else if case .regular = layout.metrics.widthClass, !isExpanded { shouldSwitchToExpanded = true } } if shouldSwitchToExpanded { self.displayMode = .modal(isExpanded: true, isFilled: true) self.updateDecorationsColors() self.updateDecorationsLayout(transition: transition) self.updateMembers() } else if case .fullscreen = self.displayMode, previousIsLandscape != self.isLandscape { self.updateMembers() } let effectiveDisplayMode = self.displayMode transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - headerWidth) / 2.0), y: 10.0), size: CGSize(width: headerWidth, height: 44.0))) self.updateTitle(transition: transition) transition.updateFrame(node: self.optionsButton, frame: CGRect(origin: CGPoint(x: 20.0 + floorToScreenPixels((size.width - headerWidth) / 2.0), y: 18.0), size: CGSize(width: 28.0, height: 28.0))) transition.updateFrame(node: self.panelButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0 - 38.0 - 24.0, y: 18.0), size: CGSize(width: 38.0, height: 28.0))) transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0, y: 18.0), size: CGSize(width: 28.0, height: 28.0))) transition.updateAlpha(node: self.optionsButton, alpha: self.optionsButton.isUserInteractionEnabled ? 1.0 : 0.0) transition.updateAlpha(node: self.panelButton, alpha: self.panelButton.isUserInteractionEnabled ? 1.0 : 0.0) transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) transition.updateFrame(node: self.contentContainer, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - size.width) / 2.0), y: 0.0), size: size)) let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) let sideInset: CGFloat = 14.0 var listInsets = UIEdgeInsets() listInsets.left = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.left) listInsets.right = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.right) let topEdgeOffset: CGFloat if let statusBarHeight = layout.statusBarHeight { topEdgeOffset = statusBarHeight } else { topEdgeOffset = 44.0 } if self.isLandscape { transition.updateFrame(node: self.topPanelEdgeNode, frame: CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset)) } else if let _ = self.panGestureArguments { } else { let topEdgeFrame: CGRect if self.isFullscreen { topEdgeFrame = CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset) } else { topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) } transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) } let bottomPanelHeight = self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom var listTopInset = layoutTopInset + topPanelHeight if self.isLandscape { listTopInset = topPanelHeight } let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - (self.isLandscape ? layout.intrinsicInsets.bottom : bottomPanelHeight) + bottomGradientHeight) let topInset: CGFloat if let (panInitialTopInset, panOffset) = self.panGestureArguments { if self.isExpanded { topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) } else { topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) } } else if case .regular = layout.metrics.widthClass { topInset = 0.0 } else if let currentTopInset = self.topInset { topInset = self.isExpanded ? 0.0 : currentTopInset } else { topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight } transition.updateFrameAsPositionAndBounds(node: self.listContainer, frame: CGRect(origin: CGPoint(), size: size)) transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(x: contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset, y: listTopInset + topInset), size: listSize)) let tileGridSize = CGSize(width: max(0.0, contentLeftInset - sideInset), height: size.height - layout.intrinsicInsets.bottom - listTopInset - topInset) if contentLeftInset > 0.0 { self.tileGridNode.isHidden = false } if !self.tileGridNode.isHidden { let _ = self.tileGridNode.update(size: tileGridSize, layoutMode: .grid, items: self.currentTileItems, transition: transition, completion: { [weak self] in if contentLeftInset.isZero && transition.isAnimated { self?.tileGridNode.isHidden = true } }) } transition.updateFrame(node: self.tileGridNode, frame: CGRect(origin: CGPoint(x: sideInset, y: listTopInset + topInset), size: tileGridSize)) self.tileGridNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: tileGridSize), within: tileGridSize) listInsets.bottom = bottomGradientHeight let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: listSize, insets: listInsets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) let fullscreenListWidth: CGFloat let fullscreenListHeight: CGFloat = 84.0 let fullscreenListTransform: CATransform3D let fullscreenListInset: CGFloat = 14.0 let fullscreenListUpdateSizeAndInsets: ListViewUpdateSizeAndInsets let fullscreenListContainerFrame: CGRect if self.isLandscape { fullscreenListWidth = layout.size.height fullscreenListTransform = CATransform3DIdentity fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.height), insets: UIEdgeInsets(top: fullscreenListInset, left: 0.0, bottom: fullscreenListInset, right: 0.0), duration: duration, curve: curve) fullscreenListContainerFrame = CGRect(x: layout.size.width - min(self.effectiveBottomAreaHeight, fullscreenBottomAreaHeight) - layout.safeInsets.right - fullscreenListHeight - 4.0, y: 0.0, width: fullscreenListHeight, height: layout.size.height) } else { fullscreenListWidth = layout.size.width fullscreenListTransform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.width), insets: UIEdgeInsets(top: fullscreenListInset + layout.safeInsets.left, left: 0.0, bottom: fullscreenListInset + layout.safeInsets.left, right: 0.0), duration: duration, curve: curve) fullscreenListContainerFrame = CGRect(x: 0.0, y: layout.size.height - min(bottomPanelHeight, fullscreenBottomAreaHeight + layout.intrinsicInsets.bottom) - fullscreenListHeight - 4.0, width: layout.size.width, height: fullscreenListHeight) } transition.updateFrame(node: self.fullscreenListContainer, frame: fullscreenListContainerFrame) self.fullscreenListNode.bounds = CGRect(x: 0.0, y: 0.0, width: fullscreenListHeight, height: fullscreenListWidth) transition.updatePosition(node: self.fullscreenListNode, position: CGPoint(x: fullscreenListContainerFrame.width / 2.0, y: fullscreenListContainerFrame.height / 2.0)) self.fullscreenListNode.transform = fullscreenListTransform self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: fullscreenListUpdateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) if case .regular = layout.metrics.widthClass { self.transitionContainerNode.view.mask = nil } else { self.transitionContainerNode.view.mask = self.transitionMaskView } var childrenLayout = layout var childrenInsets = childrenLayout.intrinsicInsets var childrenSafeInsets = childrenLayout.safeInsets if case .regular = layout.metrics.widthClass { let childrenLayoutWidth: CGFloat = 375.0 if contentLeftInset.isZero { childrenSafeInsets.left = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) childrenSafeInsets.right = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) } else { childrenSafeInsets.left = floorToScreenPixels((contentLeftInset - childrenLayoutWidth) / 2.0) childrenSafeInsets.right = childrenSafeInsets.left + (size.width - contentLeftInset) } } else if !self.isLandscape, case .fullscreen = effectiveDisplayMode { childrenInsets.bottom += self.effectiveBottomAreaHeight + fullscreenListHeight + 36.0 } childrenLayout.safeInsets = childrenSafeInsets childrenLayout.intrinsicInsets = childrenInsets self.controller?.presentationContext.containerLayoutUpdated(childrenLayout, transition: transition) var bottomPanelLeftInset = contentLeftInset var bottomPanelWidth = size.width - contentLeftInset if case .regular = layout.metrics.widthClass, bottomPanelLeftInset.isZero { bottomPanelLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) bottomPanelWidth = contentWidth } var bottomPanelFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelHeight), size: CGSize(width: bottomPanelWidth, height: bottomPanelHeight)) let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom if self.isLandscape { bottomPanelFrame = CGRect(origin: CGPoint(x: layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right, y: 0.0), size: CGSize(width: fullscreenBottomAreaHeight + layout.safeInsets.right, height: layout.size.height)) } let bottomGradientFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: bottomPanelWidth, height: bottomGradientHeight)) transition.updateFrame(node: self.bottomGradientNode, frame: bottomGradientFrame) transition.updateFrame(node: self.bottomPanelNode, frame: bottomPanelFrame) if let pickerView = self.pickerView { transition.updateFrame(view: pickerView, frame: CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0)) } let timerFrame = CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0) transition.updateFrame(node: self.timerNode, frame: timerFrame) self.timerNode.update(size: timerFrame.size, scheduleTime: self.callState?.scheduleTimestamp, transition: .immediate) let scheduleTextSize = self.scheduleTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude)) self.scheduleTextNode.frame = CGRect(origin: CGPoint(x: floor((size.width - scheduleTextSize.width) / 2.0), y: layout.size.height - layout.intrinsicInsets.bottom - scheduleTextSize.height - 145.0), size: scheduleTextSize) let centralButtonSide = min(contentWidth, size.height) - 32.0 let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) let cameraButtonSize = smallButtonSize let sideButtonMinimalInset: CGFloat = 16.0 let sideButtonOffset = min(42.0, floor((((contentWidth - 112.0) / 2.0) - sideButtonSize.width) / 2.0)) let sideButtonOrigin = max(sideButtonMinimalInset, floor((contentWidth - 112.0) / 2.0) - sideButtonOffset - sideButtonSize.width) let smallButtons: Bool if case .regular = layout.metrics.widthClass { smallButtons = false } else { switch effectiveDisplayMode { case .modal: smallButtons = self.isLandscape case .fullscreen: smallButtons = true } } let actionButtonState: VoiceChatActionButton.State let actionButtonTitle: String let actionButtonSubtitle: String var actionButtonEnabled = true if let callState = self.callState, !self.isScheduling { if callState.scheduleTimestamp != nil { self.ignoreConnecting = true if callState.canManageCall { actionButtonState = .scheduled(state: .start) actionButtonTitle = self.presentationData.strings.VoiceChat_StartNow actionButtonSubtitle = "" } else { if callState.subscribedToScheduled { actionButtonState = .scheduled(state: .unsubscribe) actionButtonTitle = self.presentationData.strings.VoiceChat_CancelReminder } else { actionButtonState = .scheduled(state: .subscribe) actionButtonTitle = self.presentationData.strings.VoiceChat_SetReminder } actionButtonSubtitle = "" } } else { let connected = self.ignoreConnecting || callState.networkState == .connected if case .connected = callState.networkState { self.ignoreConnecting = false self.ignoreConnectingTimer?.invalidate() self.ignoreConnectingTimer = nil } else if self.ignoreConnecting { if self.ignoreConnectingTimer == nil { let timer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in if let strongSelf = self { strongSelf.ignoreConnecting = false strongSelf.ignoreConnectingTimer?.invalidate() strongSelf.ignoreConnectingTimer = nil if let (layout, navigationHeight) = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) } } }, queue: Queue.mainQueue()) self.ignoreConnectingTimer = timer timer.start() } } if connected { if let muteState = callState.muteState, !self.pushingToTalk && !self.temporaryPushingToTalk { if muteState.canUnmute { actionButtonState = .active(state: .muted) actionButtonTitle = self.presentationData.strings.VoiceChat_Unmute actionButtonSubtitle = "" } else { actionButtonState = .active(state: .cantSpeak) if callState.raisedHand { actionButtonTitle = self.presentationData.strings.VoiceChat_AskedToSpeak actionButtonSubtitle = self.presentationData.strings.VoiceChat_AskedToSpeakHelp } else { actionButtonTitle = self.presentationData.strings.VoiceChat_MutedByAdmin actionButtonSubtitle = self.presentationData.strings.VoiceChat_MutedByAdminHelp } } } else { actionButtonState = .active(state: .on) actionButtonTitle = self.pushingToTalk ? self.presentationData.strings.VoiceChat_Live : self.presentationData.strings.VoiceChat_Mute actionButtonSubtitle = "" } } else { actionButtonState = .connecting actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting actionButtonSubtitle = "" actionButtonEnabled = false } } } else { if self.isScheduling { actionButtonState = .button(text: self.scheduleButtonTitle) actionButtonTitle = "" actionButtonSubtitle = "" actionButtonEnabled = true } else { actionButtonState = .connecting actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting actionButtonSubtitle = "" actionButtonEnabled = false } } self.actionButton.isDisabled = !actionButtonEnabled self.actionButton.update(size: centralButtonSize, buttonSize: CGSize(width: 112.0, height: 112.0), state: actionButtonState, title: actionButtonTitle, subtitle: actionButtonSubtitle, dark: self.isFullscreen, small: smallButtons, animated: true) let isVideoEnabled = self.callState?.isVideoEnabled ?? false var hasCameraButton = isVideoEnabled if let joinedVideo = self.joinedVideo { hasCameraButton = joinedVideo } if !isVideoEnabled { hasCameraButton = false } switch actionButtonState { case let .active(state): switch state { case .cantSpeak: hasCameraButton = false case .on, .muted: break } case .connecting: if !self.connectedOnce { hasCameraButton = false } case .scheduled, .button: hasCameraButton = false } let hasVideo = hasCameraButton && self.call.hasVideo let upperButtonDistance: CGFloat = 12.0 let firstButtonFrame: CGRect let secondButtonFrame: CGRect let thirdButtonFrame: CGRect let forthButtonFrame: CGRect let leftButtonFrame: CGRect if self.isScheduled || !hasVideo { leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) } else { leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height - upperButtonDistance - cameraButtonSize.height) / 2.0) + upperButtonDistance + cameraButtonSize.height), size: sideButtonSize) } let rightButtonFrame = CGRect(origin: CGPoint(x: contentWidth - sideButtonOrigin - sideButtonSize.width, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) var centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) if case .regular = layout.metrics.widthClass { centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentWidth - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) if hasCameraButton { firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) } else { firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) } secondButtonFrame = leftButtonFrame thirdButtonFrame = centerButtonFrame forthButtonFrame = rightButtonFrame } else { switch effectiveDisplayMode { case .modal: if self.isLandscape { let sideInset: CGFloat let buttonsCount: Int if hasVideo { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let x = floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) if hasCameraButton { firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) } else { firstButtonFrame = secondButtonFrame } } else { if hasCameraButton { firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) } else { firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) } secondButtonFrame = leftButtonFrame thirdButtonFrame = centerButtonFrame forthButtonFrame = rightButtonFrame } case let .fullscreen(controlsHidden): if self.isLandscape { let sideInset: CGFloat let buttonsCount: Int if hasVideo { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let x = controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) if hasVideo { firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) } else { firstButtonFrame = secondButtonFrame } } else { let sideInset: CGFloat let buttonsCount: Int if hasVideo { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let y = controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0) if hasVideo { firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) } else { firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) secondButtonFrame = firstButtonFrame } let thirdButtonPreFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) forthButtonFrame = CGRect(origin: CGPoint(x: thirdButtonPreFrame.maxX + spacing, y: y), size: sideButtonSize) } } } let buttonWidth = min(size.width - 32.0, centralButtonSize.width) let buttonHeight = self.scheduleCancelButton.updateLayout(width: buttonWidth, transition: .immediate) self.scheduleCancelButton.frame = CGRect(x: floorToScreenPixels(centerButtonFrame.midX - buttonWidth / 2.0), y: 137.0, width: buttonWidth, height: buttonHeight) if self.actionButton.supernode === self.bottomPanelNode { transition.updateFrame(node: self.actionButton, frame: thirdButtonFrame, completion: transition.isAnimated ? { [weak self] _ in self?.animatingExpansion = false } : nil) } self.cameraButton.isUserInteractionEnabled = hasCameraButton var buttonsTransition: ContainedViewLayoutTransition = .immediate if !isFirstTime { if case .animated(_, .spring) = transition { buttonsTransition = transition } else { buttonsTransition = .animated(duration: 0.3, curve: .linear) } } self.updateButtons(transition: buttonsTransition) if self.audioButton.supernode === self.bottomPanelNode { transition.updateAlpha(node: self.cameraButton, alpha: hasCameraButton ? 1.0 : 0.0) transition.updateFrameAsPositionAndBounds(node: self.switchCameraButton, frame: firstButtonFrame) if !self.animatingButtonsSwap || transition.isAnimated { transition.updateFrameAsPositionAndBounds(node: self.audioButton, frame: secondButtonFrame, completion: { [weak self] _ in self?.animatingButtonsSwap = false }) transition.updateFrameAsPositionAndBounds(node: self.cameraButton, frame: secondButtonFrame) } transition.updateFrameAsPositionAndBounds(node: self.leaveButton, frame: forthButtonFrame) } if isFirstTime { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } while !self.enqueuedFullscreenTransitions.isEmpty { self.dequeueFullscreenTransition() } } } private var appIsActive = true { didSet { if self.appIsActive != oldValue { self.updateVisibility() self.updateRequestedVideoChannels() } } } private var visibility = false { didSet { if self.visibility != oldValue { self.updateVisibility() self.updateRequestedVideoChannels() } } } private func updateVisibility() { let visible = self.appIsActive && self.visibility if self.tileGridNode.isHidden { self.tileGridNode.visibility = false } else { self.tileGridNode.visibility = visible } self.mainStageNode.visibility = visible self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { itemNode.gridVisibility = visible } } self.fullscreenListNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode { itemNode.gridVisibility = visible } } self.videoRenderingContext.updateVisibility(isVisible: visible) } func animateIn() { guard let (layout, navigationHeight) = self.validLayout else { return } self.visibility = true self.updateDecorationsLayout(transition: .immediate) self.animatingAppearance = true let initialBounds = self.contentContainer.bounds let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) self.contentContainer.bounds = initialBounds.offsetBy(dx: 0.0, dy: -(layout.size.height - topPanelFrame.minY)) self.contentContainer.isHidden = false let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) transition.animateView({ self.contentContainer.view.bounds = initialBounds }, completion: { _ in self.animatingAppearance = false if self.actionButton.supernode !== self.bottomPanelNode { self.actionButton.ignoreHierarchyChanges = true self.audioButton.isHidden = false self.cameraButton.isHidden = false self.leaveButton.isHidden = false self.audioButton.layer.removeAllAnimations() self.cameraButton.layer.removeAllAnimations() self.leaveButton.layer.removeAllAnimations() self.bottomPanelNode.addSubnode(self.cameraButton) self.bottomPanelNode.addSubnode(self.audioButton) self.bottomPanelNode.addSubnode(self.leaveButton) self.bottomPanelNode.addSubnode(self.actionButton) self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) self.actionButton.ignoreHierarchyChanges = false } self.controller?.currentOverlayController?.dismiss() self.controller?.currentOverlayController = nil }) self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } func animateOut(completion: (() -> Void)?) { guard let (layout, _) = self.validLayout else { return } var offsetCompleted = false let internalCompletion: () -> Void = { [weak self] in if offsetCompleted { if let strongSelf = self { strongSelf.contentContainer.layer.removeAllAnimations() strongSelf.dimNode.layer.removeAllAnimations() var bounds = strongSelf.contentContainer.bounds bounds.origin.y = 0.0 strongSelf.contentContainer.bounds = bounds strongSelf.visibility = false } completion?() } } let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) self.contentContainer.layer.animateBoundsOriginYAdditive(from: self.contentContainer.bounds.origin.y, to: -(layout.size.height - topPanelFrame.minY) - 44.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in offsetCompleted = true internalCompletion() }) self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } private func enqueueTransition(_ transition: ListTransition) { self.enqueuedTransitions.append(transition) if let _ = self.validLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } private func enqueueFullscreenTransition(_ transition: ListTransition) { self.enqueuedFullscreenTransitions.append(transition) if let _ = self.validLayout { while !self.enqueuedFullscreenTransitions.isEmpty { self.dequeueFullscreenTransition() } } } private func dequeueTransition() { guard let (layout, _) = self.validLayout, let transition = self.enqueuedTransitions.first else { return } self.enqueuedTransitions.remove(at: 0) if let callState = self.callState { if callState.scheduleTimestamp != nil && self.listNode.alpha > 0.0 { self.timerNode.isHidden = false self.cameraButton.alpha = 0.0 self.cameraButton.isUserInteractionEnabled = false self.listNode.alpha = 0.0 self.listNode.isUserInteractionEnabled = false self.backgroundNode.backgroundColor = panelBackgroundColor self.updateDecorationsColors() } else if callState.scheduleTimestamp == nil && !self.isScheduling && self.listNode.alpha == 0.0 { self.transitionToCall() } } var options = ListViewDeleteAndInsertOptions() let isFirstTime = self.isFirstTime if isFirstTime { self.isFirstTime = false } else { if transition.crossFade { options.insert(.AnimateCrossfade) } if transition.animated { options.insert(.AnimateInsertion) } } options.insert(.LowLatency) options.insert(.PreferSynchronousResourceLoading) var size = layout.size if case .regular = layout.metrics.widthClass { size.width = floor(min(size.width, size.height) * 0.5) } let bottomPanelHeight = self.isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) let listTopInset = layoutTopInset + topPanelHeight let listSize = CGSize(width: size.width, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) self.topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight if transition.animated { self.animatingInsertion = true } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in guard let strongSelf = self else { return } if isFirstTime { strongSelf.updateDecorationsLayout(transition: .immediate) } else if strongSelf.animatingInsertion { strongSelf.updateDecorationsLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) } strongSelf.animatingInsertion = false if !strongSelf.didSetContentsReady { strongSelf.didSetContentsReady = true strongSelf.controller?.contentsReady.set(true) } strongSelf.updateVisibility() }) } private func dequeueFullscreenTransition() { guard let _ = self.validLayout, let transition = self.enqueuedFullscreenTransitions.first else { return } self.enqueuedFullscreenTransitions.remove(at: 0) var options = ListViewDeleteAndInsertOptions() let isFirstTime = self.isFirstTime if !isFirstTime { if transition.animated { options.insert(.AnimateInsertion) } } self.fullscreenListNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in }) } private func updateMembers(maybeUpdateVideo: Bool = true, force: Bool = false) { self.updateMembers(muteState: self.effectiveMuteState, callMembers: self.currentCallMembers ?? ([], nil), invitedPeers: self.currentInvitedPeers ?? [], speakingPeers: self.currentSpeakingPeers ?? Set(), maybeUpdateVideo: maybeUpdateVideo, force: force) } private func updateMembers(muteState: GroupCallParticipantsContext.Participant.MuteState?, callMembers: ([GroupCallParticipantsContext.Participant], String?), invitedPeers: [EnginePeer], speakingPeers: Set, maybeUpdateVideo: Bool = true, force: Bool = false) { var disableAnimation = false if self.currentCallMembers?.1 != callMembers.1 { disableAnimation = true } let speakingPeersUpdated = self.currentSpeakingPeers != speakingPeers self.currentCallMembers = callMembers self.currentInvitedPeers = invitedPeers var entries: [ListEntry] = [] var fullscreenEntries: [ListEntry] = [] var index: Int32 = 0 var fullscreenIndex: Int32 = 0 var processedPeerIds = Set() var processedFullscreenPeerIds = Set() var peerIdToCameraEndpointId: [PeerId: String] = [:] var peerIdToEndpointId: [PeerId: String] = [:] var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] var gridTileItems: [VoiceChatTileItem] = [] var tileItems: [VoiceChatTileItem] = [] var gridTileByVideoEndpoint: [String: VoiceChatTileItem] = [:] var tileByVideoEndpoint: [String: VoiceChatTileItem] = [:] var entryByPeerId: [PeerId: VoiceChatPeerEntry] = [:] var latestWideVideo: String? = nil var isTablet = false var displayPanelVideos = false if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { isTablet = true displayPanelVideos = self.displayPanelVideos } // let isLivestream: Bool // if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { // isLivestream = true // } else { // isLivestream = false // } let canManageCall = self.callState?.canManageCall ?? false var joinedVideo = self.joinedVideo ?? true var myEntry: VoiceChatPeerEntry? var mainEntry: VoiceChatPeerEntry? for member in callMembers.0 { if processedPeerIds.contains(member.peer.id) { continue } processedPeerIds.insert(member.peer.id) let memberState: VoiceChatPeerEntry.State var memberMuteState: GroupCallParticipantsContext.Participant.MuteState? if member.hasRaiseHand && !(member.muteState?.canUnmute ?? true) { // if isLivestream && !canManageCall { // continue // } memberState = .raisedHand memberMuteState = member.muteState if self.raisedHandDisplayDisposables[member.peer.id] == nil { var displayedRaisedHands = self.displayedRaisedHands displayedRaisedHands.insert(member.peer.id) self.displayedRaisedHands = displayedRaisedHands let signal: Signal = Signal.complete() |> delay(3.0, queue: Queue.mainQueue()) self.raisedHandDisplayDisposables[member.peer.id] = signal.start(completed: { [weak self] in if let strongSelf = self { var displayedRaisedHands = strongSelf.displayedRaisedHands displayedRaisedHands.remove(member.peer.id) strongSelf.displayedRaisedHands = displayedRaisedHands strongSelf.updateMembers() } }) } } else { if member.peer.id == self.callState?.myPeerId { if muteState == nil { memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening } else { memberState = .listening memberMuteState = member.muteState } } else { memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening memberMuteState = member.muteState } if let disposable = self.raisedHandDisplayDisposables[member.peer.id] { disposable.dispose() self.raisedHandDisplayDisposables[member.peer.id] = nil } // if isLivestream && !(memberMuteState?.canUnmute ?? true) { // continue // } } var memberPeer = member.peer if member.peer.id == self.callState?.myPeerId { joinedVideo = member.joinedVideo if let user = memberPeer as? TelegramUser, let photo = self.currentUpdatingAvatar { memberPeer = user.withUpdatedPhoto([photo]) } } joinedVideo = true if let videoEndpointId = member.videoEndpointId { peerIdToCameraEndpointId[member.peer.id] = videoEndpointId } if let anyEndpointId = member.presentationEndpointId ?? member.videoEndpointId { peerIdToEndpointId[member.peer.id] = anyEndpointId } let peerEntry = VoiceChatPeerEntry( peer: memberPeer, about: member.about, isMyPeer: self.callState?.myPeerId == member.peer.id, videoEndpointId: member.videoEndpointId, videoPaused: member.videoDescription?.isPaused ?? false, presentationEndpointId: member.presentationEndpointId, presentationPaused: member.presentationDescription?.isPaused ?? false, effectiveSpeakerVideoEndpointId: self.effectiveSpeaker?.1, state: memberState, muteState: memberMuteState, canManageCall: canManageCall, volume: member.volume, raisedHand: member.hasRaiseHand, displayRaisedHandStatus: self.displayedRaisedHands.contains(member.peer.id), active: memberPeer.id == self.effectiveSpeaker?.0, isLandscape: self.isLandscape ) if peerEntry.isMyPeer { myEntry = peerEntry } if peerEntry.active { mainEntry = peerEntry } entryByPeerId[peerEntry.peer.id] = peerEntry var isTile = false if let interaction = self.itemInteraction { if let videoEndpointId = member.presentationEndpointId { if !self.videoOrder.contains(videoEndpointId) { if peerEntry.isMyPeer { self.videoOrder.insert(videoEndpointId, at: 0) } else { self.videoOrder.append(videoEndpointId) } } if isTablet { if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: false) { isTile = true gridTileByVideoEndpoint[videoEndpointId] = tileItem } } if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: displayPanelVideos) { isTile = true tileByVideoEndpoint[videoEndpointId] = tileItem } if self.wideVideoNodes.contains(videoEndpointId) { latestWideVideo = videoEndpointId } } if let videoEndpointId = member.videoEndpointId { if !self.videoOrder.contains(videoEndpointId) { if peerEntry.isMyPeer { self.videoOrder.insert(videoEndpointId, at: 0) } else { self.videoOrder.append(videoEndpointId) } } if isTablet { if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: false) { isTile = true gridTileByVideoEndpoint[videoEndpointId] = tileItem } } if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: displayPanelVideos) { isTile = true tileByVideoEndpoint[videoEndpointId] = tileItem } if self.wideVideoNodes.contains(videoEndpointId) { latestWideVideo = videoEndpointId } } } if !isTile || isTablet || !joinedVideo { entries.append(.peer(peerEntry, index)) } index += 1 if self.callState?.networkState == .connecting { } else { if var videoChannel = member.requestedVideoChannel(minQuality: .thumbnail, maxQuality: .medium) { if self.effectiveSpeaker?.1 == videoChannel.endpointId { videoChannel.maxQuality = .full } requestedVideoChannels.append(videoChannel) } if member.peer.id != self.callState?.myPeerId { if var presentationChannel = member.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: .thumbnail) { if self.effectiveSpeaker?.1 == presentationChannel.endpointId { presentationChannel.minQuality = .full presentationChannel.maxQuality = .full } requestedVideoChannels.append(presentationChannel) } } } } var temporaryList: [String] = [] for tileVideoEndpoint in self.videoOrder { if let _ = tileByVideoEndpoint[tileVideoEndpoint] { temporaryList.append(tileVideoEndpoint) } } if (tileByVideoEndpoint.count % 2) != 0, let last = temporaryList.last, !self.wideVideoNodes.contains(last), let latestWide = latestWideVideo { self.videoOrder.removeAll(where: { $0 == latestWide }) self.videoOrder.append(latestWide) } for tileVideoEndpoint in self.videoOrder { if let tileItem = gridTileByVideoEndpoint[tileVideoEndpoint] { gridTileItems.append(tileItem) } if let tileItem = tileByVideoEndpoint[tileVideoEndpoint] { if displayPanelVideos && tileItem.peer.id == self.effectiveSpeaker?.0 { } else { tileItems.append(tileItem) } if let fullscreenEntry = entryByPeerId[tileItem.peer.id] { if processedFullscreenPeerIds.contains(tileItem.peer.id) { continue } fullscreenEntries.append(.peer(fullscreenEntry, fullscreenIndex)) processedFullscreenPeerIds.insert(fullscreenEntry.peer.id) fullscreenIndex += 1 } } } self.joinedVideo = joinedVideo let configuration = self.configuration ?? VoiceChatConfiguration.defaultValue var reachedLimit = false if !joinedVideo && (!tileItems.isEmpty || !gridTileItems.isEmpty), let peer = self.peer { tileItems.removeAll() gridTileItems.removeAll() tileItems.append(VoiceChatTileItem(account: self.context.account, peer: EnginePeer(peer), videoEndpointId: "", videoReady: false, videoTimeouted: true, isVideoLimit: true, videoLimit: configuration.videoParticipantsMaxCount, isPaused: false, isOwnScreencast: false, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, speaking: false, secondary: false, isTablet: false, icon: .none, text: .none, additionalText: nil, action: {}, contextAction: nil, getVideo: { _ in return nil }, getAudioLevel: nil)) } else if let callState = self.callState, !tileItems.isEmpty && callState.isVideoWatchersLimitReached && self.connectedOnce && (callState.canManageCall || callState.adminIds.contains(self.context.account.peerId)) { reachedLimit = true } for member in callMembers.0 { if processedFullscreenPeerIds.contains(member.peer.id) { continue } processedFullscreenPeerIds.insert(member.peer.id) if let peerEntry = entryByPeerId[member.peer.id] { fullscreenEntries.append(.peer(peerEntry, fullscreenIndex)) fullscreenIndex += 1 } } for peer in invitedPeers { if processedPeerIds.contains(peer.id) { continue } processedPeerIds.insert(peer.id) entries.append(.peer(VoiceChatPeerEntry( peer: peer._asPeer(), about: nil, isMyPeer: false, videoEndpointId: nil, videoPaused: false, presentationEndpointId: nil, presentationPaused: false, effectiveSpeakerVideoEndpointId: nil, state: .invited, muteState: nil, canManageCall: false, volume: nil, raisedHand: false, displayRaisedHandStatus: false, active: false, isLandscape: false ), index)) index += 1 } self.requestedVideoChannels = requestedVideoChannels var myVideoUpdated = false if let previousMyEntry = self.myEntry, let myEntry = myEntry, previousMyEntry.effectiveVideoEndpointId == nil && myEntry.effectiveVideoEndpointId != nil && self.currentForcedSpeaker == nil { self.currentDominantSpeaker = (myEntry.peer.id, myEntry.effectiveVideoEndpointId, CACurrentMediaTime()) myVideoUpdated = true } self.myEntry = myEntry guard self.didSetDataReady && (force || (!self.isPanning && !self.animatingExpansion && !self.animatingMainStage)) else { return } let previousMainEntry = self.mainEntry self.mainEntry = mainEntry if let mainEntry = mainEntry { self.mainStageNode.update(peerEntry: mainEntry, pinned: self.currentForcedSpeaker != nil) if let previousMainEntry = previousMainEntry, maybeUpdateVideo { if previousMainEntry.effectiveVideoEndpointId != mainEntry.effectiveVideoEndpointId || previousMainEntry.videoPaused != mainEntry.videoPaused || myVideoUpdated { self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) return } } } else if self.effectiveSpeaker != nil, !fullscreenEntries.isEmpty { self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) return } self.updateRequestedVideoChannels() self.currentSpeakingPeers = speakingPeers self.peerIdToEndpointId = peerIdToEndpointId var updateLayout = false var animatingLayout = false if self.currentTileItems.isEmpty != gridTileItems.isEmpty { animatingLayout = true updateLayout = true } if isTablet { updateLayout = true self.currentTileItems = gridTileItems if displayPanelVideos && !tileItems.isEmpty { entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) } } else { if !tileItems.isEmpty { entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) } } var canInvite = true var inviteIsLink = false if let peer = self.peer as? TelegramChannel { if peer.flags.contains(.isGigagroup) { if peer.flags.contains(.isCreator) || peer.adminRights != nil { } else { canInvite = false } } if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) { inviteIsLink = true } } if canInvite { entries.append(.invite(self.presentationData.theme, self.presentationData.strings, inviteIsLink ? self.presentationData.strings.VoiceChat_Share : self.presentationData.strings.VoiceChat_InviteMember, inviteIsLink)) } let previousEntries = self.currentEntries let previousFullscreenEntries = self.currentFullscreenEntries self.currentEntries = entries self.currentFullscreenEntries = fullscreenEntries if previousEntries.count == entries.count { var allEqual = true for i in 0 ..< previousEntries.count { if previousEntries[i].stableId != entries[i].stableId { if case let .peer(lhsPeer, _) = previousEntries[i], case let .peer(rhsPeer, _) = entries[i] { if lhsPeer.isMyPeer != rhsPeer.isMyPeer { allEqual = false break } } else { allEqual = false break } } } if allEqual { disableAnimation = true } } else if abs(previousEntries.count - entries.count) > 10 { disableAnimation = true } let presentationData = self.presentationData.withUpdated(theme: self.darkTheme) let transition = self.preparedTransition(from: previousEntries, to: entries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: !disableAnimation, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) self.enqueueTransition(transition) let fullscreenTransition = self.preparedFullscreenTransition(from: previousFullscreenEntries, to: fullscreenEntries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: true, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) if !isTablet { self.enqueueFullscreenTransition(fullscreenTransition) } if speakingPeersUpdated { var speakingPeers = speakingPeers var updatedSpeakers: [PeerId] = [] for peerId in self.currentSpeakers { if speakingPeers.contains(peerId) { updatedSpeakers.append(peerId) speakingPeers.remove(peerId) } } var currentSpeakingSubtitle = "" for peerId in Array(speakingPeers) { updatedSpeakers.append(peerId) if let peer = entryByPeerId[peerId]?.peer { let displayName = speakingPeers.count == 1 ? EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) : EnginePeer(peer).compactDisplayTitle if currentSpeakingSubtitle.isEmpty { currentSpeakingSubtitle.append(displayName) } else { currentSpeakingSubtitle.append(", \(displayName)") } } } self.currentSpeakers = updatedSpeakers self.currentSpeakingSubtitle = currentSpeakingSubtitle.isEmpty ? nil : currentSpeakingSubtitle self.updateTitle(transition: .immediate) } if case .fullscreen = self.displayMode, !self.mainStageNode.animating { if speakingPeersUpdated { self.mainStageNode.update(speakingPeerId: self.currentSpeakers.first) } } else { self.mainStageNode.update(speakingPeerId: nil) } if updateLayout, let (layout, navigationHeight) = self.validLayout { let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .easeInOut) if animatingLayout { self.animatingExpansion = true } self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) self.updateDecorationsLayout(transition: transition) } } private func callStateDidReset() { self.requestedVideoSources.removeAll() self.filterRequestedVideoChannels(channels: []) self.updateRequestedVideoChannels() } private func filterRequestedVideoChannels(channels: [PresentationGroupCallRequestedVideo]) { var validSources = Set() for channel in channels { validSources.insert(channel.endpointId) if !self.requestedVideoSources.contains(channel.endpointId) { self.requestedVideoSources.insert(channel.endpointId) let input = (self.call as! PresentationGroupCallImpl).video(endpointId: channel.endpointId) if let input = input, let videoView = self.videoRenderingContext.makeView(input: input, blur: false) { let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeView(input: input, blur: true)) self.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) |> deliverOnMainQueue ).start(next: { [weak self, weak videoNode] ready, timeouted in if let strongSelf = self, let videoNode = videoNode { Queue.mainQueue().after(0.1) { if timeouted && !ready { strongSelf.timeoutedEndpointIds.insert(channel.endpointId) strongSelf.readyVideoEndpointIds.remove(channel.endpointId) strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) strongSelf.wideVideoNodes.remove(channel.endpointId) strongSelf.updateMembers() } else if ready { strongSelf.readyVideoEndpointIds.insert(channel.endpointId) strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) strongSelf.timeoutedEndpointIds.remove(channel.endpointId) if videoNode.aspectRatio <= 0.77 { strongSelf.wideVideoNodes.insert(channel.endpointId) } else { strongSelf.wideVideoNodes.remove(channel.endpointId) } strongSelf.updateMembers() if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { if let interaction = strongSelf.itemInteraction { loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { let entry = strongSelf.currentFullscreenEntries[i] switch entry { case let .peer(peerEntry, _): if peerEntry.effectiveVideoEndpointId == channel.endpointId { let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) break loop } default: break } } } } } } } }), forKey: channel.endpointId) self.videoNodes[channel.endpointId] = videoNode if let _ = self.validLayout { self.updateMembers() } } /*self.call.makeIncomingVideoView(endpointId: channel.endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { [weak self] videoView, backdropVideoView in Queue.mainQueue().async { guard let strongSelf = self, let videoView = videoView else { return } let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView) strongSelf.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) |> deliverOnMainQueue ).start(next: { [weak self, weak videoNode] ready, timeouted in if let strongSelf = self, let videoNode = videoNode { Queue.mainQueue().after(0.1) { if timeouted && !ready { strongSelf.timeoutedEndpointIds.insert(channel.endpointId) strongSelf.readyVideoEndpointIds.remove(channel.endpointId) strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) strongSelf.wideVideoNodes.remove(channel.endpointId) strongSelf.updateMembers() } else if ready { strongSelf.readyVideoEndpointIds.insert(channel.endpointId) strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) strongSelf.timeoutedEndpointIds.remove(channel.endpointId) if videoNode.aspectRatio <= 0.77 { strongSelf.wideVideoNodes.insert(channel.endpointId) } else { strongSelf.wideVideoNodes.remove(channel.endpointId) } strongSelf.updateMembers() if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { if let interaction = strongSelf.itemInteraction { loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { let entry = strongSelf.currentFullscreenEntries[i] switch entry { case let .peer(peerEntry, _): if peerEntry.effectiveVideoEndpointId == channel.endpointId { let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) break loop } default: break } } } } } } } }), forKey: channel.endpointId) strongSelf.videoNodes[channel.endpointId] = videoNode if let _ = strongSelf.validLayout { strongSelf.updateMembers() } } })*/ } } var removeRequestedVideoSources: [String] = [] for source in self.requestedVideoSources { if !validSources.contains(source) { removeRequestedVideoSources.append(source) } } for source in removeRequestedVideoSources { self.requestedVideoSources.remove(source) } for (videoEndpointId, _) in self.videoNodes { if !validSources.contains(videoEndpointId) { self.videoNodes[videoEndpointId] = nil self.videoOrder.removeAll(where: { $0 == videoEndpointId }) self.readyVideoEndpointIds.remove(videoEndpointId) self.readyVideoEndpointIdsPromise.set(self.readyVideoEndpointIds) self.readyVideoDisposables.set(nil, forKey: videoEndpointId) } } } private func updateMainVideo(waitForFullSize: Bool, entries: [ListEntry]? = nil, updateMembers: Bool = true, force: Bool = false, completion: (() -> Void)? = nil) { let effectiveMainSpeaker = self.currentForcedSpeaker ?? self.currentDominantSpeaker.flatMap { ($0.0, $0.1) } guard effectiveMainSpeaker?.0 != self.effectiveSpeaker?.0 || effectiveMainSpeaker?.1 != self.effectiveSpeaker?.1 || force else { return } let currentEntries = entries ?? self.currentFullscreenEntries var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? = nil var anySpeakerWithVideo: (PeerId, String?, Bool, Bool, Bool)? = nil var anySpeaker: (PeerId, Bool)? = nil if let (peerId, preferredVideoEndpointId) = effectiveMainSpeaker { for entry in currentEntries { switch entry { case let .peer(peer, _): if peer.peer.id == peerId { if let preferredVideoEndpointId = preferredVideoEndpointId, peer.videoEndpointId == preferredVideoEndpointId || peer.presentationEndpointId == preferredVideoEndpointId { var isPaused = false if peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId { isPaused = peer.presentationPaused } else if peer.videoEndpointId != nil && preferredVideoEndpointId == peer.videoEndpointId { isPaused = peer.videoPaused } effectiveSpeaker = (peerId, preferredVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId, isPaused) } else { var isPaused = false if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId { isPaused = peer.presentationPaused } else if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.videoEndpointId { isPaused = peer.videoPaused } effectiveSpeaker = (peerId, peer.effectiveVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId, isPaused) } } else if anySpeakerWithVideo == nil, let videoEndpointId = peer.effectiveVideoEndpointId { var isPaused = false if videoEndpointId == peer.presentationEndpointId { isPaused = peer.presentationPaused } else if videoEndpointId == peer.videoEndpointId { isPaused = peer.videoPaused } anySpeakerWithVideo = (peer.peer.id, videoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && videoEndpointId == peer.presentationEndpointId, isPaused) } else if anySpeaker == nil { anySpeaker = (peer.peer.id, peer.isMyPeer) } default: break } } } if effectiveSpeaker == nil { self.currentForcedSpeaker = nil effectiveSpeaker = anySpeakerWithVideo ?? anySpeaker.flatMap { ($0.0, nil, $0.1, false, false) } if let (peerId, videoEndpointId, _, _, _) = effectiveSpeaker { self.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) } else { self.currentDominantSpeaker = nil } } self.effectiveSpeaker = effectiveSpeaker if updateMembers { self.updateMembers(maybeUpdateVideo: false, force: force) } var waitForFullSize = waitForFullSize var isReady = false if let (_, maybeVideoEndpointId, _, _, _) = effectiveSpeaker, let videoEndpointId = maybeVideoEndpointId { isReady = true if !self.readyVideoEndpointIds.contains(videoEndpointId) { isReady = false if entries == nil { waitForFullSize = false } } } self.mainStageNode.update(peer: effectiveSpeaker, isReady: isReady, waitForFullSize: waitForFullSize, completion: { completion?() }) } private func updateRequestedVideoChannels() { Queue.mainQueue().after(0.3) { let enableVideo = self.appIsActive && self.visibility self.call.setRequestedVideoList(items: enableVideo ? self.requestedVideoChannels : []) self.filterRequestedVideoChannels(channels: self.requestedVideoChannels) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UILongPressGestureRecognizer { return !self.isScheduling } else if gestureRecognizer is DirectionalPanGestureRecognizer { if self.mainStageNode.animating || self.animatingMainStage { return false } let bottomPanelLocation = gestureRecognizer.location(in: self.bottomPanelNode.view) let containerLocation = gestureRecognizer.location(in: self.contentContainer.view) let mainStageLocation = gestureRecognizer.location(in: self.mainStageNode.view) if self.isLandscape && self.mainStageContainerNode.isUserInteractionEnabled && mainStageLocation.x > self.mainStageNode.frame.width - 80.0 { return false } if self.audioButton.frame.contains(bottomPanelLocation) || (!self.cameraButton.isHidden && self.cameraButton.frame.contains(bottomPanelLocation)) || self.leaveButton.frame.contains(bottomPanelLocation) || self.pickerView?.frame.contains(containerLocation) == true || (self.mainStageContainerNode.isUserInteractionEnabled && (mainStageLocation.y < 44.0 || mainStageLocation.y > self.mainStageNode.frame.height - 100.0)) { return false } } return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { return true } return false } private var isPanningList = false @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { guard let (layout, _) = self.validLayout else { return } let contentOffset = self.listNode.visibleContentOffset() switch recognizer.state { case .began: let topInset: CGFloat if case .regular = layout.metrics.widthClass { topInset = 0.0 } else if self.isExpanded { topInset = 0.0 } else if let currentTopInset = self.topInset { topInset = currentTopInset } else { topInset = self.listNode.frame.height } self.panGestureArguments = (topInset, 0.0) self.controller?.dismissAllTooltips() let location = recognizer.location(in: self.listContainer.view) let isPanningList: Bool if self.listNode.frame.contains(location) { if case let .known(value) = contentOffset, value <= 0.5 { isPanningList = false } else { isPanningList = true } } else { isPanningList = false } self.isPanningList = isPanningList if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { self.isPanning = true self.mainStageBackgroundNode.alpha = 0.0 self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) self.mainStageNode.setControlsHidden(true, animated: true) self.fullscreenListNode.alpha = 0.0 self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] finished in self?.attachTileVideos() self?.fullscreenListContainer.subnodeTransform = CATransform3DIdentity }) self.listContainer.transform = CATransform3DMakeScale(0.86, 0.86, 1.0) self.contentContainer.insertSubnode(self.mainStageContainerNode, aboveSubnode: self.bottomPanelNode) } case .changed: var translation = recognizer.translation(in: self.contentContainer.view).y if self.isScheduled && translation < 0.0 { return } var translateBounds = false if case .regular = layout.metrics.widthClass { if !self.isPanningList { translateBounds = true } } else { switch self.displayMode { case let .modal(isExpanded, previousIsFilled): var topInset: CGFloat = 0.0 if let (currentTopInset, currentPanOffset) = self.panGestureArguments { topInset = currentTopInset if case let .known(value) = contentOffset, value <= 0.5 { } else { translation = currentPanOffset if self.isExpanded { recognizer.setTranslation(CGPoint(), in: self.contentContainer.view) } } self.panGestureArguments = (currentTopInset, translation) } let currentOffset = topInset + translation var isFilled = previousIsFilled if currentOffset < 20.0 { isFilled = true } else if currentOffset > 40.0 { isFilled = false } if isFilled != previousIsFilled { self.displayMode = .modal(isExpanded: isExpanded, isFilled: isFilled) self.updateDecorationsColors() } if self.isExpanded { } else { if currentOffset > 0.0 { self.listNode.scroller.panGestureRecognizer.setTranslation(CGPoint(), in: self.listNode.scroller) } } case .fullscreen: if abs(translation) > 32.0 { if self.fullscreenListNode.layer.animationKeys()?.contains("opacity") == true { self.fullscreenListNode.layer.removeAllAnimations() } } var bounds = self.mainStageContainerNode.bounds bounds.origin.y = -translation self.mainStageContainerNode.bounds = bounds var backgroundFrame = self.mainStageNode.frame backgroundFrame.origin.y += -translation self.mainStageBackgroundNode.frame = backgroundFrame self.fullscreenListContainer.subnodeTransform = CATransform3DMakeTranslation(0.0, translation, 0.0) } translateBounds = !self.isExpanded } if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) self.updateDecorationsLayout(transition: .immediate) } if translateBounds { var bounds = self.contentContainer.bounds bounds.origin.y = -translation bounds.origin.y = min(0.0, bounds.origin.y) self.contentContainer.bounds = bounds } case .ended: let translation = recognizer.translation(in: self.contentContainer.view) var velocity = recognizer.velocity(in: self.contentContainer.view) if self.isScheduled && (translation.y < 0.0 || velocity.y < 0.0) { return } if case let .known(value) = contentOffset, value > 0.0 { velocity = CGPoint() } else if case .unknown = contentOffset { velocity = CGPoint() } var bounds = self.contentContainer.bounds bounds.origin.y = -translation.y bounds.origin.y = min(0.0, bounds.origin.y) let offset: CGFloat if let (inset, panOffset) = self.panGestureArguments { offset = inset + panOffset } else { offset = 0.0 } let topInset: CGFloat if let currentTopInset = self.topInset { topInset = currentTopInset } else { topInset = self.listNode.frame.height } if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { self.panGestureArguments = nil self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity if abs(translation.y) > 100.0 || abs(velocity.y) > 300.0 { self.mainStageBackgroundNode.layer.removeAllAnimations() self.currentForcedSpeaker = nil self.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) self.effectiveSpeaker = nil } else { self.isPanning = false self.mainStageBackgroundNode.alpha = 1.0 self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in self?.attachFullscreenVideos() }) self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) self.fullscreenListNode.alpha = 1.0 self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) var bounds = self.mainStageContainerNode.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.mainStageContainerNode.bounds = bounds self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in if let strongSelf = self { strongSelf.listContainer.transform = CATransform3DIdentity strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) strongSelf.updateMembers() } }) } } else if case .modal(true, _) = self.displayMode, case .compact = layout.metrics.widthClass { self.panGestureArguments = nil if velocity.y > 300.0 || offset > topInset / 2.0 { self.displayMode = .modal(isExpanded: false, isFilled: false) self.updateDecorationsColors() self.animatingExpansion = true self.listNode.scroller.setContentOffset(CGPoint(), animated: false) let distance: CGFloat if let topInset = self.topInset { distance = topInset - offset } else { distance = 0.0 } let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) } self.updateDecorationsLayout(transition: transition, completion: { self.animatingExpansion = false }) } else { self.displayMode = .modal(isExpanded: true, isFilled: true) self.updateDecorationsColors() self.animatingExpansion = true if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) } self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { self.animatingExpansion = false }) } } else { self.panGestureArguments = nil var dismissing = false if case .regular = layout.metrics.widthClass, self.isPanningList { } else { if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) { if self.isScheduling { self.dismissScheduled() dismissing = true } else if case .regular = layout.metrics.widthClass { self.controller?.dismiss(closing: false, manual: true) dismissing = true } else { if case .fullscreen = self.displayMode { } else { self.controller?.dismiss(closing: false, manual: true) dismissing = true } } } else if !self.isScheduling && (velocity.y < -300.0 || offset < topInset / 2.0) { if velocity.y > -2200.0 && !self.isFullscreen { DispatchQueue.main.async { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } } let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) if case .modal = self.displayMode { self.displayMode = .modal(isExpanded: true, isFilled: true) } self.updateDecorationsColors() self.animatingExpansion = true if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) } self.updateDecorationsLayout(transition: transition, completion: { self.animatingExpansion = false }) } else if !self.isScheduling { self.updateDecorationsColors() self.animatingExpansion = true self.listNode.scroller.setContentOffset(CGPoint(), animated: false) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) } self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { self.animatingExpansion = false }) } } if !dismissing { var bounds = self.contentContainer.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.contentContainer.bounds = bounds self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) } } case .cancelled: self.panGestureArguments = nil let previousBounds = self.contentContainer.bounds var bounds = self.contentContainer.bounds bounds.origin.y = 0.0 self.contentContainer.bounds = bounds self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) } self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { self.animatingExpansion = false }) if case .fullscreen = self.displayMode, case .regular = layout.metrics.widthClass { self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity self.isPanning = false self.mainStageBackgroundNode.alpha = 1.0 self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in self?.attachFullscreenVideos() }) self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) self.fullscreenListNode.alpha = 1.0 self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) var bounds = self.mainStageContainerNode.bounds let previousBounds = bounds bounds.origin.y = 0.0 self.mainStageContainerNode.bounds = bounds self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in if let strongSelf = self { strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) strongSelf.updateMembers() strongSelf.listContainer.transform = CATransform3DIdentity } }) } default: break } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result === self.topPanelNode.view { return self.view } if result === self.bottomPanelNode.view { return self.view } if !self.bounds.contains(point) { return nil } if point.y < self.topPanelNode.frame.minY { return self.dimNode.view } return result } fileprivate func scrollToTop() { if self.isExpanded { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } } private func openTitleEditing() { let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) |> deliverOnMainQueue).start(next: { [weak self] chatPeer in guard let strongSelf = self else { return } let initialTitle = strongSelf.callState?.title ?? "" let title: String let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { title = strongSelf.presentationData.strings.LiveStream_EditTitle text = strongSelf.presentationData.strings.LiveStream_EditTitleText } else { title = strongSelf.presentationData.strings.VoiceChat_EditTitle text = strongSelf.presentationData.strings.VoiceChat_EditTitleText } let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { title in if let strongSelf = self, let title = title, title != initialTitle { strongSelf.call.updateTitle(title) let text: String if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { text = title.isEmpty ? strongSelf.presentationData.strings.LiveStream_EditTitleRemoveSuccess : strongSelf.presentationData.strings.LiveStream_EditTitleSuccess(title).string } else { text = title.isEmpty ? strongSelf.presentationData.strings.VoiceChat_EditTitleRemoveSuccess : strongSelf.presentationData.strings.VoiceChat_EditTitleSuccess(title).string } strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) } }) strongSelf.controller?.present(controller, in: .window(.root)) }) } private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { guard let peerId = self.callState?.myPeerId else { return } let _ = (self.context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), TelegramEngine.EngineData.Item.Configuration.SearchBots() ) |> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in guard let strongSelf = self, let peer = peer else { return } let presentationData = strongSelf.presentationData let legacyController = LegacyController(presentation: .custom, theme: strongSelf.darkTheme) legacyController.statusBar.statusBarStyle = .Ignore let emptyController = LegacyEmptyController(context: legacyController.context)! let navigationController = makeLegacyNavigationController(rootController: emptyController) navigationController.setNavigationBarHidden(true, animated: false) navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) legacyController.bind(controller: navigationController) strongSelf.view.endEditing(true) strongSelf.controller?.present(legacyController, in: .window(.root)) var hasPhotos = false if !peer.profileImageRepresentations.isEmpty { hasPhotos = true } let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)! mixin.forceDark = true mixin.stickersContext = LegacyPaintStickersContext(context: strongSelf.context) let _ = strongSelf.currentAvatarMixin.swap(mixin) mixin.requestSearchController = { [weak self] assetsController in guard let strongSelf = self else { return } let controller = WebSearchController(context: strongSelf.context, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in assetsController?.dismiss() self?.updateProfilePhoto(result) })) controller.navigationPresentation = .modal strongSelf.controller?.push(controller) if fromGallery { completion() } } mixin.didFinishWithImage = { [weak self] image in if let image = image { completion() self?.updateProfilePhoto(image) } } mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in if let image = image, let asset = asset { completion() self?.updateProfileVideo(image, asset: asset, adjustments: adjustments) } } mixin.didFinishWithDelete = { guard let strongSelf = self else { return } let proceed = { let _ = strongSelf.currentAvatarMixin.swap(nil) let postbox = strongSelf.context.account.postbox strongSelf.updateAvatarDisposable.set((strongSelf.context.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) }) |> deliverOnMainQueue).start()) } let actionSheet = ActionSheetController(presentationData: presentationData.withUpdated(theme: strongSelf.darkTheme)) let items: [ActionSheetItem] = [ ActionSheetButtonItem(title: presentationData.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() proceed() }) ] actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ]) ]) strongSelf.controller?.present(actionSheet, in: .window(.root)) } mixin.didDismiss = { [weak legacyController] in guard let strongSelf = self else { return } let _ = strongSelf.currentAvatarMixin.swap(nil) legacyController?.dismiss() } let menuController = mixin.present() if let menuController = menuController { menuController.customRemoveFromParentViewController = { [weak legacyController] in legacyController?.dismiss() } } }) } private func updateProfilePhoto(_ image: UIImage) { guard let data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { return } let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) self.call.account.postbox.mediaBox.storeResourceData(resource.id, data: data) let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) self.currentUpdatingAvatar = representation self.updateAvatarPromise.set(.single((representation, 0.0))) let postbox = self.call.account.postbox let signal = peerId.namespace == Namespaces.Peer.CloudUser ? self.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) }) : self.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: self.call.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) }) self.updateAvatarDisposable.set((signal |> deliverOnMainQueue).start(next: { [weak self] result in guard let strongSelf = self else { return } switch result { case .complete: strongSelf.updateAvatarPromise.set(.single(nil)) case let .progress(value): strongSelf.updateAvatarPromise.set(.single((representation, value))) } })) self.updateMembers() } private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { guard let data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { return } let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) self.context.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) self.currentUpdatingAvatar = representation self.updateAvatarPromise.set(.single((representation, 0.0))) var videoStartTimestamp: Double? = nil if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue } let context = self.context let account = self.context.account let signal = Signal { [weak self] subscriber in let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in if let paintingData = adjustments.paintingData, paintingData.hasAnimation { return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments) } else { return nil } } let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") let uploadInterface = LegacyLiveUploadInterface(context: context) let signal: SSignal if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer { let durationSignal: SSignal = SSignal(generator: { subscriber in let disposable = (entityRenderer.duration()).start(next: { duration in subscriber.putNext(duration) subscriber.putCompletion() }) return SBlockDisposable(block: { disposable.dispose() }) }) signal = durationSignal.map(toSignal: { duration -> SSignal in if let duration = duration as? Double { return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)! } else { return SSignal.single(nil) } }) } else if let asset = asset as? AVAsset { signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)! } else { signal = SSignal.complete() } let signalDisposable = signal.start(next: { next in if let result = next as? TGMediaVideoConversionResult { if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) { account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) } if let timestamp = videoStartTimestamp { videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05)) } var value = stat() if stat(result.fileURL.path, &value) == 0 { if let data = try? Data(contentsOf: result.fileURL) { let resource: TelegramMediaResource if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult { resource = LocalFileMediaResource(fileId: liveUploadData.id) } else { resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) } account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true) subscriber.putNext(resource) EngineTempBox.shared.dispose(tempFile) } } subscriber.putCompletion() } else if let strongSelf = self, let progress = next as? NSNumber { Queue.mainQueue().async { strongSelf.updateAvatarPromise.set(.single((representation, Float(truncating: progress) * 0.25))) } } }, error: { _ in }, completed: nil) let disposable = ActionDisposable { signalDisposable?.dispose() } return ActionDisposable { disposable.dispose() } } self.updateAvatarDisposable.set((signal |> mapToSignal { videoResource -> Signal in if peerId.namespace == Namespaces.Peer.CloudUser { return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) }) } else { return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) }) } } |> deliverOnMainQueue).start(next: { [weak self] result in guard let strongSelf = self else { return } switch result { case .complete: strongSelf.updateAvatarPromise.set(.single(nil)) case let .progress(value): strongSelf.updateAvatarPromise.set(.single((representation, 0.25 + value * 0.75))) } })) } private func displayUnmuteTooltip() { guard let (layout, _) = self.validLayout else { return } let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center var point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) var position: TooltipScreen.ArrowPosition = .bottom if case .compact = layout.metrics.widthClass { if self.isLandscape { point.origin.x = location.x - 5.0 - 36.0 point.origin.y = location.y - 5.0 position = .right } else if case .fullscreen = self.displayMode { point.origin.y += 32.0 } } self.controller?.present(TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: self.presentationData.strings.VoiceChat_UnmuteSuggestion), style: .gradient(UIColor(rgb: 0x1d446c), UIColor(rgb: 0x193e63)), icon: nil, location: .point(point, position), displayDuration: .custom(8.0), shouldDismissOnTouch: { _, _ in return .dismiss(consume: false) }), in: .window(.root)) } private var isScheduled: Bool { return self.isScheduling || self.callState?.scheduleTimestamp != nil } private func attachFullscreenVideos() { guard let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass else { return } var verticalItemNodes: [String: ASDisplayNode] = [:] self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { for tileNode in itemNode.tileNodes { if let item = tileNode.item { verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode } if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { tileNode.isHidden = false } } } } self.fullscreenListNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { let otherItemNode = verticalItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] itemNode.transitionIn(from: otherItemNode) } } } private func attachTileVideos() { var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] var tileNodes: [VoiceChatTileItemNode] = [] if !self.tileGridNode.isHidden { tileNodes = self.tileGridNode.tileNodes } else { self.fullscreenListNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode } } self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { tileNodes = itemNode.tileNodes } } } for tileNode in tileNodes { if let item = tileNode.item { let otherItemNode = fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] tileNode.transitionIn(from: otherItemNode) if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { tileNode.isHidden = true } } } } private func updateDisplayMode(_ displayMode: DisplayMode, fromPan: Bool = false) { guard !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { return } self.updateMembers() let previousDisplayMode = self.displayMode var isFullscreen = false if case .fullscreen = displayMode { isFullscreen = true } if case .fullscreen = previousDisplayMode, case .fullscreen = displayMode { self.animatingExpansion = true } else { self.animatingMainStage = true } var hasFullscreenList = false if let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass { hasFullscreenList = true } let completion = { self.displayMode = displayMode self.updateDecorationsColors() self.mainStageContainerNode.isHidden = false self.mainStageContainerNode.isUserInteractionEnabled = isFullscreen let transition: ContainedViewLayoutTransition = .animated(duration: 0.55, curve: .spring) if case .modal = previousDisplayMode, case .fullscreen = self.displayMode { self.mainStageNode.alpha = 0.0 self.updateDecorationsLayout(transition: .immediate) var verticalItemNodes: [String: ASDisplayNode] = [:] var tileNodes: [VoiceChatTileItemNode] = [] if !self.tileGridNode.isHidden { tileNodes = self.tileGridNode.tileNodes } else { self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { tileNodes = itemNode.tileNodes } } } for tileNode in tileNodes { if let item = tileNode.item { verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode } } let completion = { let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 if hasFullscreenList { self.fullscreenListContainer.isHidden = false self.fullscreenListNode.alpha = 0.0 } var gridSnapshotView: UIView? if !hasFullscreenList, let snapshotView = self.tileGridNode.view.snapshotView(afterScreenUpdates: false) { gridSnapshotView = snapshotView self.tileGridNode.view.addSubview(snapshotView) self.displayPanelVideos = true self.updateMembers(maybeUpdateVideo: false, force: true) } let completion = { if hasFullscreenList { self.attachFullscreenVideos() self.fullscreenListNode.alpha = 1.0 self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } if let effectiveSpeakerPeerId = effectiveSpeakerPeerId, let otherItemNode = verticalItemNodes[String(effectiveSpeakerPeerId.toInt64()) + "_" + (self.effectiveSpeaker?.1 ?? "")] { if hasFullscreenList { let transitionStartPosition = otherItemNode.view.convert(CGPoint(x: otherItemNode.frame.width / 2.0, y: otherItemNode.frame.height), to: self.fullscreenListContainer.view.superview) self.fullscreenListContainer.layer.animatePosition(from: transitionStartPosition, to: self.fullscreenListContainer.position, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) } self.mainStageNode.animateTransitionIn(from: otherItemNode, transition: transition, completion: { [weak self] in self?.animatingMainStage = false }) self.mainStageNode.alpha = 1.0 self.mainStageBackgroundNode.alpha = 1.0 self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: hasFullscreenList ? 0.13 : 0.3, completion: { [weak otherItemNode] _ in otherItemNode?.alpha = 0.0 gridSnapshotView?.removeFromSuperview() completion() }) } else { completion() } if hasFullscreenList { self.listContainer.layer.animateScale(from: 1.0, to: 0.86, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) } if self.isLandscape { self.transitionMaskTopFillLayer.opacity = 1.0 } self.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in Queue.mainQueue().after(0.3) { self?.transitionMaskTopFillLayer.opacity = 0.0 self?.transitionMaskBottomFillLayer.removeAllAnimations() } }) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) self.updateDecorationsLayout(transition: transition) } } let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 var index = 0 for item in self.currentFullscreenEntries { if case let .peer(entry, _) = item, entry.peer.id == effectiveSpeakerPeerId { break } else { index += 1 } } let position: ListViewScrollPosition if index > self.currentFullscreenEntries.count - 3 { index = self.currentFullscreenEntries.count - 1 position = .bottom(0.0) } else { position = .center(.bottom) } self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: position, animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in completion() }) } else if case .fullscreen = previousDisplayMode, case .modal = self.displayMode { var minimalVisiblePeerid: (PeerId, CGFloat)? var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] self.fullscreenListNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { let convertedFrame = itemNode.view.convert(itemNode.bounds, to: self.transitionContainerNode.view) if let (_, x) = minimalVisiblePeerid { if convertedFrame.minX >= 0.0 && convertedFrame.minX < x { minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) } } else if convertedFrame.minX >= 0.0 { minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) } fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode } } let completion = { let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 var targetTileNode: VoiceChatTileItemNode? self.transitionContainerNode.addSubnode(self.mainStageNode) self.listContainer.transform = CATransform3DIdentity var tileNodes: [VoiceChatTileItemNode] = [] if !self.tileGridNode.isHidden { tileNodes = self.tileGridNode.tileNodes } else { self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { tileNodes = itemNode.tileNodes } } } for tileNode in tileNodes { if let item = tileNode.item { if item.peer.id == effectiveSpeakerPeerId, item.videoEndpointId == self.effectiveSpeaker?.1 { targetTileNode = tileNode } } } var transitionOffset = -self.mainStageContainerNode.bounds.minY if transitionOffset.isZero, let (layout, _) = self.validLayout { if case .regular = layout.metrics.widthClass { transitionOffset += 87.0 } if let targetTileNode = targetTileNode { let transitionTargetPosition = targetTileNode.view.convert(CGPoint(x: targetTileNode.frame.width / 2.0, y: targetTileNode.frame.height), to: self.fullscreenListContainer.view.superview) self.fullscreenListContainer.layer.animatePosition(from: self.fullscreenListContainer.position, to: transitionTargetPosition, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) } if !hasFullscreenList { self.displayPanelVideos = false self.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? VoiceChatTilesGridItemNode { itemNode.snapshotForDismissal() } } self.updateMembers(maybeUpdateVideo: false, force: true) self.attachTileVideos() self.mainStageBackgroundNode.alpha = 0.0 self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) } else { self.fullscreenListNode.alpha = 0.0 self.mainStageBackgroundNode.alpha = 1.0 self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] _ in if let strongSelf = self { strongSelf.fullscreenListContainer.isHidden = true strongSelf.fullscreenListNode.alpha = 1.0 strongSelf.attachTileVideos() strongSelf.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) strongSelf.mainStageBackgroundNode.alpha = 0.0 } }) } } self.mainStageNode.animateTransitionOut(to: targetTileNode, offset: transitionOffset, transition: transition, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.effectiveSpeaker = nil strongSelf.mainStageNode.update(peer: nil, waitForFullSize: false) strongSelf.mainStageNode.setControlsHidden(false, animated: false) strongSelf.fullscreenListContainer.isHidden = true strongSelf.mainStageContainerNode.isHidden = true strongSelf.mainStageContainerNode.addSubnode(strongSelf.mainStageNode) var bounds = strongSelf.mainStageContainerNode.bounds bounds.origin.y = 0.0 strongSelf.mainStageContainerNode.bounds = bounds strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) strongSelf.isPanning = false strongSelf.animatingMainStage = false }) if hasFullscreenList { self.listContainer.layer.animateScale(from: 0.86, to: 1.0, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) } self.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) if !transitionOffset.isZero { self.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) } if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) self.updateDecorationsLayout(transition: transition) } } if false, let (peerId, _) = minimalVisiblePeerid { var index = 0 for item in self.currentEntries { if case let .peer(entry, _) = item, entry.peer.id == peerId { break } else { index += 1 } } self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in completion() }) } else { completion() } } else if case .fullscreen = self.displayMode { if let (layout, navigationHeight) = self.validLayout { let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) self.updateDecorationsLayout(transition: transition) } } } if case .fullscreen(false) = displayMode, case .modal = previousDisplayMode { self.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true, completion: { completion() }) } else { completion() } } fileprivate var actionButtonPosition: CGPoint { guard let (layout, _) = self.validLayout else { return CGPoint() } let size = layout.size let hasCameraButton = self.cameraButton.isUserInteractionEnabled let centralButtonSide = min(size.width, size.height) - 32.0 let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) if case .regular = layout.metrics.widthClass { let contentWidth: CGFloat = max(320.0, min(375.0, floor(size.width * 0.3))) let contentLeftInset: CGFloat if self.peerIdToEndpointId.isEmpty { contentLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) } else { contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth } return CGPoint(x: contentLeftInset + floorToScreenPixels(contentWidth / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor(self.effectiveBottomAreaHeight / 2.0) - 3.0) } else { switch self.displayMode { case .modal: if self.isLandscape { let sideInset: CGFloat let buttonsCount: Int if hasCameraButton { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) return actionButtonFrame.center } else { let actionButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) return actionButtonFrame.center } case let .fullscreen(controlsHidden): if self.isLandscape { let sideInset: CGFloat let buttonsCount: Int if hasCameraButton { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + (controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0)) let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) return actionButtonFrame.center } else { let sideInset: CGFloat let buttonsCount: Int if hasCameraButton { sideInset = 26.0 buttonsCount = 4 } else { sideInset = 42.0 buttonsCount = 3 } let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) let y = layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + (controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)) let secondButtonFrame: CGRect if hasCameraButton { let firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) } else { secondButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) } let actionButtonFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) return actionButtonFrame.center } } } } } private let sharedContext: SharedAccountContext public let call: PresentationGroupCall private let presentationData: PresentationData public var parentNavigationController: NavigationController? fileprivate let contentsReady = ValuePromise(false, ignoreRepeated: true) fileprivate let dataReady = ValuePromise(false, ignoreRepeated: true) fileprivate let audioOutputStateReady = ValuePromise(false, ignoreRepeated: true) private let _ready = Promise(false) override public var ready: Promise { return self._ready } public var onViewDidAppear: (() -> Void)? public var onViewDidDisappear: (() -> Void)? private var reclaimActionButton: (() -> Void)? private var didAppearOnce: Bool = false private var isDismissed: Bool = false private var isDisconnected: Bool = false private var controllerNode: Node { return self.displayNode as! Node } private let idleTimerExtensionDisposable = MetaDisposable() public weak var currentOverlayController: VoiceChatOverlayController? private var validLayout: ContainerViewLayout? public init(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) { self.sharedContext = sharedContext self.call = call self.presentationData = sharedContext.currentPresentationData.with { $0 } super.init(navigationBarPresentationData: nil) self.automaticallyControlPresentationContextLayout = false self.blocksBackgroundWhenInOverlay = true self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .all) self.statusBar.statusBarStyle = .Ignore self._ready.set(combineLatest([ self.contentsReady.get(), self.dataReady.get(), self.audioOutputStateReady.get() ]) |> map { values -> Bool in for value in values { if !value { return false } } return true } |> filter { $0 }) self.scrollToTop = { [weak self] in self?.controllerNode.scrollToTop() } } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.idleTimerExtensionDisposable.dispose() if let currentOverlayController = self.currentOverlayController { currentOverlayController.animateOut(reclaim: false, targetPosition: CGPoint(), completion: { _ in }) } } override public func loadDisplayNode() { self.displayNode = Node(controller: self, sharedContext: self.sharedContext, call: self.call) self.displayNodeDidLoad() } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.isDismissed = false if !self.didAppearOnce { self.didAppearOnce = true self.reclaimActionButton?() self.controllerNode.animateIn() self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) } DispatchQueue.main.async { self.onViewDidAppear?() } } override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.idleTimerExtensionDisposable.set(nil) DispatchQueue.main.async { self.didAppearOnce = false self.isDismissed = true self.detachActionButton() self.onViewDidDisappear?() } } private var dismissedManually: Bool = false public func dismiss(closing: Bool, manual: Bool = false) { if closing { self.isDisconnected = true } else { if let navigationController = self.navigationController as? NavigationController { let count = navigationController.viewControllers.count if count == 2 || navigationController.viewControllers[count - 2] is ChatController { if case .active(.cantSpeak) = self.controllerNode.actionButton.stateValue { } else if case .button = self.controllerNode.actionButton.stateValue { } else if case .scheduled = self.controllerNode.actionButton.stateValue { } else if let chatController = navigationController.viewControllers[count - 2] as? ChatController, chatController.isSendButtonVisible { } else if let tabBarController = navigationController.viewControllers[count - 2] as? TabBarController, let chatListController = tabBarController.controllers[tabBarController.selectedIndex] as? ChatListController, chatListController.isSearchActive { } else { if manual { self.dismissedManually = true Queue.mainQueue().after(0.05) { self.detachActionButton() } } else { self.detachActionButton() } } } } } self.dismiss() } private func dismissAllTooltips() { self.window?.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismissWithCommitAction() } }) self.forEachController({ controller in if let controller = controller as? UndoOverlayController { controller.dismissWithCommitAction() } if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) } private func detachActionButton() { guard self.currentOverlayController == nil && !self.isDisconnected else { return } let overlayController = VoiceChatOverlayController(actionButton: self.controllerNode.actionButton, audioOutputNode: self.controllerNode.audioButton, cameraNode: self.controllerNode.cameraButton, leaveNode: self.controllerNode.leaveButton, navigationController: self.navigationController as? NavigationController, initiallyHidden: self.dismissedManually) if let navigationController = self.navigationController as? NavigationController { navigationController.presentOverlay(controller: overlayController, inGlobal: true, blockInteraction: false) } self.currentOverlayController = overlayController self.dismissedManually = false self.reclaimActionButton = { [weak self, weak overlayController] in if let strongSelf = self { overlayController?.animateOut(reclaim: true, targetPosition: strongSelf.controllerNode.actionButtonPosition, completion: { immediate in if let strongSelf = self, immediate { strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = true strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.cameraButton) strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.audioButton) strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.leaveButton) strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.actionButton) if immediate, let layout = strongSelf.validLayout { strongSelf.containerLayoutUpdated(layout, transition: .immediate) } strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = false } }) strongSelf.reclaimActionButton = nil } } } override public func dismiss(completion: (() -> Void)? = nil) { if !self.isDismissed { self.isDismissed = true self.didAppearOnce = false self.controllerNode.animateOut(completion: { [weak self] in completion?() self?.dismiss(animated: false) }) DispatchQueue.main.async { self.onViewDidDisappear?() } } } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.validLayout = layout self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } } private final class VoiceChatContextExtractedContentSource: ContextExtractedContentSource { var keepInPlace: Bool let ignoreContentTouches: Bool = false let blurBackground: Bool let maskView: UIView? private var animateTransitionIn: () -> Void private var animateTransitionOut: () -> Void private let sourceNode: ContextExtractedContentContainingNode var centerVertically: Bool var shouldBeDismissed: Signal init(sourceNode: ContextExtractedContentContainingNode, maskView: UIView?, keepInPlace: Bool, blurBackground: Bool, centerVertically: Bool, shouldBeDismissed: Signal, animateTransitionIn: @escaping () -> Void, animateTransitionOut: @escaping () -> Void) { self.sourceNode = sourceNode self.maskView = maskView self.keepInPlace = keepInPlace self.blurBackground = blurBackground self.centerVertically = centerVertically self.shouldBeDismissed = shouldBeDismissed self.animateTransitionIn = animateTransitionIn self.animateTransitionOut = animateTransitionOut } func takeView() -> ContextControllerTakeViewInfo? { self.animateTransitionIn() return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) } func putBack() -> ContextControllerPutBackViewInfo? { self.animateTransitionOut() return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) } } private final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource { private let controller: ViewController private let sourceNode: ContextReferenceContentNode init(controller: ViewController, sourceNode: ContextReferenceContentNode) { self.controller = controller self.sourceNode = sourceNode } func transitionInfo() -> ContextControllerReferenceViewInfo? { return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) } }