Video chat UI

This commit is contained in:
Isaac 2024-09-13 11:10:49 +08:00
parent 001755ad63
commit 5e356fd7b6
5 changed files with 848 additions and 353 deletions

View File

@ -5,19 +5,54 @@ import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import TelegramAudio
final class VideoChatActionButtonComponent: Component {
enum Content: Equatable {
fileprivate enum IconType {
enum BluetoothType: Equatable {
case generic
case airpods
case airpodsPro
case airpodsMax
}
enum Audio: Equatable {
case none
case builtin
case speaker
case headphones
case bluetooth(BluetoothType)
}
fileprivate enum IconType: Equatable {
enum Audio: Equatable {
case speaker
case headphones
case bluetooth(BluetoothType)
}
case audio(audio: Audio)
case video
case leave
}
case audio(audio: Audio)
case video(isActive: Bool)
case leave
fileprivate var iconType: IconType {
switch self {
case let .audio(audio):
let mappedAudio: IconType.Audio
switch audio {
case .none, .builtin, .speaker:
mappedAudio = .speaker
case .headphones:
mappedAudio = .headphones
case let .bluetooth(type):
mappedAudio = .bluetooth(type)
}
return .audio(audio: mappedAudio)
case .video:
return .video
case .leave:
@ -30,23 +65,30 @@ final class VideoChatActionButtonComponent: Component {
case connecting
case muted
case unmuted
case raiseHand
}
let strings: PresentationStrings
let content: Content
let microphoneState: MicrophoneState
let isCollapsed: Bool
init(
strings: PresentationStrings,
content: Content,
microphoneState: MicrophoneState,
isCollapsed: Bool
) {
self.strings = strings
self.content = content
self.microphoneState = microphoneState
self.isCollapsed = isCollapsed
}
static func ==(lhs: VideoChatActionButtonComponent, rhs: VideoChatActionButtonComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.content != rhs.content {
return false
}
@ -94,6 +136,30 @@ final class VideoChatActionButtonComponent: Component {
let backgroundColor: UIColor
let iconDiameter: CGFloat
switch component.content {
case let .audio(audio):
var isActive = false
switch audio {
case .none, .builtin:
titleText = component.strings.Call_Speaker
case .speaker:
isActive = true
titleText = component.strings.Call_Speaker
case .headphones:
titleText = component.strings.Call_Audio
case .bluetooth:
titleText = component.strings.Call_Audio
}
switch component.microphoneState {
case .connecting:
backgroundColor = UIColor(white: 0.1, alpha: 1.0)
case .muted:
backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF)
case .unmuted:
backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659)
case .raiseHand:
backgroundColor = UIColor(rgb: 0x3252EF)
}
iconDiameter = 60.0
case let .video(isActive):
titleText = "video"
switch component.microphoneState {
@ -103,6 +169,8 @@ final class VideoChatActionButtonComponent: Component {
backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF)
case .unmuted:
backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659)
case .raiseHand:
backgroundColor = UIColor(rgb: 0x3252EF)
}
iconDiameter = 60.0
case .leave:
@ -113,6 +181,26 @@ final class VideoChatActionButtonComponent: Component {
if self.contentImage == nil || previousComponent?.content.iconType != component.content.iconType {
switch component.content.iconType {
case let .audio(audio):
let iconName: String
switch audio {
case .speaker:
iconName = "Call/CallSpeakerButton"
case .headphones:
iconName = "Call/CallHeadphonesButton"
case let .bluetooth(type):
switch type {
case .generic:
iconName = "Call/CallBluetoothButton"
case .airpods:
iconName = "Call/CallAirpodsButton"
case .airpodsPro:
iconName = "Call/CallAirpodsProButton"
case .airpodsMax:
iconName = "Call/CallAirpodsMaxButton"
}
}
self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate)
case .video:
self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate)
case .leave:

View File

@ -179,23 +179,27 @@ final class VideoChatMicButtonComponent: Component {
case connecting
case muted
case unmuted(pushToTalk: Bool)
case raiseHand
}
let call: PresentationGroupCall
let content: Content
let isCollapsed: Bool
let updateUnmutedStateIsPushToTalk: (Bool?) -> Void
let raiseHand: () -> Void
init(
call: PresentationGroupCall,
content: Content,
isCollapsed: Bool,
updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void
updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void,
raiseHand: @escaping () -> Void
) {
self.call = call
self.content = content
self.isCollapsed = isCollapsed
self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk
self.raiseHand = raiseHand
}
static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool {
@ -241,13 +245,11 @@ final class VideoChatMicButtonComponent: Component {
self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent()
if let component = self.component {
switch component.content {
case .connecting:
case .connecting, .unmuted, .raiseHand:
self.beginTrackingWasPushToTalk = false
case .muted:
self.beginTrackingWasPushToTalk = true
component.updateUnmutedStateIsPushToTalk(true)
case .unmuted:
self.beginTrackingWasPushToTalk = false
}
}
@ -285,6 +287,10 @@ final class VideoChatMicButtonComponent: Component {
} else {
component.updateUnmutedStateIsPushToTalk(nil)
}
case .raiseHand:
self.icon.playRandomAnimation()
component.raiseHand()
}
}
}
@ -314,6 +320,8 @@ final class VideoChatMicButtonComponent: Component {
titleText = "Unmute"
case let .unmuted(isPushToTalk):
titleText = isPushToTalk ? "You are Live" : "Tap to Mute"
case .raiseHand:
titleText = "Raise Hand"
}
self.isEnabled = isEnabled
@ -382,10 +390,12 @@ final class VideoChatMicButtonComponent: Component {
case .connecting:
context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
case .muted, .unmuted:
case .muted, .unmuted, .raiseHand:
let colors: [UIColor]
if case .muted = component.content {
colors = [UIColor(rgb: 0x0080FF), UIColor(rgb: 0x00A1FE)]
} else if case .raiseHand = component.content {
colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)]
} else {
colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)]
}
@ -465,10 +475,12 @@ final class VideoChatMicButtonComponent: Component {
self.icon.enqueueState(.mute)
case .unmuted:
self.icon.enqueueState(.unmute)
case .raiseHand:
self.icon.enqueueState(.hand)
}
switch component.content {
case .muted, .unmuted:
case .muted, .unmuted, .raiseHand:
let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size
let blobTintTransition: ComponentTransition
@ -495,7 +507,15 @@ final class VideoChatMicButtonComponent: Component {
transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5))
transition.setScale(view: blobView, scale: availableSize.width / 116.0)
blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758))
let blobsColor: UIColor
if case .muted = component.content {
blobsColor = UIColor(rgb: 0x0086FF)
} else if case .raiseHand = component.content {
blobsColor = UIColor(rgb: 0x914BAD)
} else {
blobsColor = UIColor(rgb: 0x33C758)
}
blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: blobsColor)
switch component.content {
case .unmuted:
@ -508,7 +528,7 @@ final class VideoChatMicButtonComponent: Component {
blobView.updateLevel(CGFloat(value), immediately: false)
})
}
case .connecting, .muted:
case .connecting, .muted, .raiseHand:
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
@ -536,7 +556,14 @@ final class VideoChatMicButtonComponent: Component {
glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
let glowColor: UIColor = component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758)
let glowColor: UIColor
if case .muted = component.content {
glowColor = UIColor(rgb: 0x0086FF)
} else if case .raiseHand = component.content {
glowColor = UIColor(rgb: 0x3252EF)
} else {
glowColor = UIColor(rgb: 0x33C758)
}
glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition)
transition.setFrame(view: glowView, frame: glowFrame)
default:

View File

@ -4,18 +4,22 @@ import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import LottieComponent
final class VideoChatParticipantStatusComponent: Component {
let muteState: GroupCallParticipantsContext.Participant.MuteState?
let hasRaiseHand: Bool
let isSpeaking: Bool
let theme: PresentationTheme
init(
muteState: GroupCallParticipantsContext.Participant.MuteState?,
hasRaiseHand: Bool,
isSpeaking: Bool,
theme: PresentationTheme
) {
self.muteState = muteState
self.hasRaiseHand = hasRaiseHand
self.isSpeaking = isSpeaking
self.theme = theme
}
@ -24,6 +28,9 @@ final class VideoChatParticipantStatusComponent: Component {
if lhs.muteState != rhs.muteState {
return false
}
if lhs.hasRaiseHand != rhs.hasRaiseHand {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
@ -34,7 +41,8 @@ final class VideoChatParticipantStatusComponent: Component {
}
final class View: UIView {
private let muteStatus = ComponentView<Empty>()
private var muteStatus: ComponentView<Empty>?
private var raiseHandStatus: ComponentView<Empty>?
private var component: VideoChatParticipantStatusComponent?
private var isUpdating: Bool = false
@ -56,47 +64,149 @@ final class VideoChatParticipantStatusComponent: Component {
self.isUpdating = false
}
let alphaTransition: ComponentTransition
if !transition.animation.isImmediate {
alphaTransition = .easeInOut(duration: 0.2)
} else {
alphaTransition = .immediate
}
let size = CGSize(width: 44.0, height: 44.0)
let muteStatusSize = self.muteStatus.update(
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking)
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize)
if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View {
if muteStatusView.superview == nil {
self.addSubview(muteStatusView)
}
transition.setFrame(view: muteStatusView, frame: muteStatusFrame)
let tintTransition: ComponentTransition
if !transition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
let isRaiseHand: Bool
if let muteState = component.muteState {
if muteState.canUnmute {
isRaiseHand = false
} else {
tintTransition = .immediate
isRaiseHand = component.hasRaiseHand
}
if let iconView = muteStatusView.iconView {
let iconTintColor: UIColor
if component.isSpeaking {
iconTintColor = UIColor(rgb: 0x33C758)
} else {
isRaiseHand = false
}
if !isRaiseHand {
let muteStatus: ComponentView<Empty>
var muteStatusTransition = transition
if let current = self.muteStatus {
muteStatus = current
} else {
muteStatusTransition = muteStatusTransition.withAnimation(.none)
muteStatus = ComponentView()
self.muteStatus = muteStatus
}
let muteStatusSize = muteStatus.update(
transition: muteStatusTransition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking)
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize)
if let muteStatusView = muteStatus.view as? VideoChatMuteIconComponent.View {
var animateIn = false
if muteStatusView.superview == nil {
animateIn = true
self.addSubview(muteStatusView)
}
muteStatusTransition.setFrame(view: muteStatusView, frame: muteStatusFrame)
let tintTransition: ComponentTransition
if !muteStatusTransition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
} else {
if let muteState = component.muteState {
if muteState.canUnmute {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
} else {
iconTintColor = UIColor(rgb: 0xFF3B30)
}
tintTransition = .immediate
}
if let iconView = muteStatusView.iconView {
let iconTintColor: UIColor
if component.isSpeaking {
iconTintColor = UIColor(rgb: 0x33C758)
} else {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
if let muteState = component.muteState {
if muteState.canUnmute {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
} else {
iconTintColor = UIColor(rgb: 0xFF3B30)
}
} else {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
}
}
tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor)
}
tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor)
if animateIn, !transition.animation.isImmediate {
transition.animateScale(view: muteStatusView, from: 0.001, to: 1.0)
alphaTransition.animateAlpha(view: muteStatusView, from: 0.0, to: 1.0)
}
}
} else if let muteStatus = self.muteStatus {
self.muteStatus = nil
if let muteStatusView = muteStatus.view {
if !transition.animation.isImmediate {
transition.setScale(view: muteStatusView, scale: 0.001)
alphaTransition.setAlpha(view: muteStatusView, alpha: 0.0, completion: { [weak muteStatusView] _ in
muteStatusView?.removeFromSuperview()
})
} else {
muteStatusView.removeFromSuperview()
}
}
}
if isRaiseHand {
let raiseHandStatus: ComponentView<Empty>
var raiseHandStatusTransition = transition
if let current = self.raiseHandStatus {
raiseHandStatus = current
} else {
raiseHandStatusTransition = raiseHandStatusTransition.withAnimation(.none)
raiseHandStatus = ComponentView()
self.raiseHandStatus = raiseHandStatus
}
let raiseHandStatusSize = raiseHandStatus.update(
transition: raiseHandStatusTransition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: "anim_hand1"
),
color: component.theme.list.itemAccentColor,
size: CGSize(width: 48.0, height: 48.0)
)),
environment: {},
containerSize: CGSize(width: 48.0, height: 48.0)
)
let raiseHandStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - raiseHandStatusSize.width) * 0.5) - 2.0, y: floor((size.height - raiseHandStatusSize.height) * 0.5)), size: raiseHandStatusSize)
if let raiseHandStatusView = raiseHandStatus.view {
var animateIn = false
if raiseHandStatusView.superview == nil {
animateIn = true
self.addSubview(raiseHandStatusView)
}
raiseHandStatusTransition.setFrame(view: raiseHandStatusView, frame: raiseHandStatusFrame)
if animateIn, !transition.animation.isImmediate {
transition.animateScale(view: raiseHandStatusView, from: 0.001, to: 1.0)
alphaTransition.animateAlpha(view: raiseHandStatusView, from: 0.0, to: 1.0)
}
}
} else if let raiseHandStatus = self.raiseHandStatus {
self.raiseHandStatus = nil
if let raiseHandStatusView = raiseHandStatus.view {
if !transition.animation.isImmediate {
transition.setScale(view: raiseHandStatusView, scale: 0.001)
alphaTransition.setAlpha(view: raiseHandStatusView, alpha: 0.0, completion: { [weak raiseHandStatusView] _ in
raiseHandStatusView?.removeFromSuperview()
})
} else {
raiseHandStatusView.removeFromSuperview()
}
}
}

View File

@ -35,16 +35,23 @@ final class VideoChatParticipantsComponent: Component {
}
final class Participants: Equatable {
enum InviteType {
case invite
case shareLink
}
let myPeerId: EnginePeer.Id
let participants: [GroupCallParticipantsContext.Participant]
let totalCount: Int
let loadMoreToken: String?
let inviteType: InviteType?
init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?) {
init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?, inviteType: InviteType?) {
self.myPeerId = myPeerId
self.participants = participants
self.totalCount = totalCount
self.loadMoreToken = loadMoreToken
self.inviteType = inviteType
}
static func ==(lhs: Participants, rhs: Participants) -> Bool {
@ -63,6 +70,9 @@ final class VideoChatParticipantsComponent: Component {
if lhs.loadMoreToken != rhs.loadMoreToken {
return false
}
if lhs.inviteType != rhs.inviteType {
return false
}
return true
}
}
@ -1046,6 +1056,7 @@ final class VideoChatParticipantsComponent: Component {
let rightAccessoryComponent: AnyComponent<Empty> = AnyComponent(VideoChatParticipantStatusComponent(
muteState: participant.muteState,
hasRaiseHand: participant.hasRaiseHand,
isSpeaking: component.speakingParticipants.contains(participant.peer.id),
theme: component.theme
))
@ -1393,10 +1404,21 @@ final class VideoChatParticipantsComponent: Component {
containerSize: CGSize(width: availableSize.width, height: 1000.0)
)
let inviteText: String
if let participants = component.participants, let inviteType = participants.inviteType {
switch inviteType {
case .invite:
inviteText = "Invite Members"
case .shareLink:
inviteText = "Share Invite Link"
}
} else {
inviteText = "Invite Members"
}
let inviteListItemSize = self.inviteListItemView.update(
transition: transition,
component: AnyComponent(VideoChatListInviteComponent(
title: "Invite Members",
title: inviteText,
theme: component.theme,
action: { [weak self] in
guard let self, let component = self.component else {

View File

@ -75,6 +75,7 @@ private final class VideoChatScreenComponent: Component {
private let title = ComponentView<Empty>()
private let navigationLeftButton = ComponentView<Empty>()
private let navigationRightButton = ComponentView<Empty>()
private var navigationSidebarButton: ComponentView<Empty>?
private let videoButton = ComponentView<Empty>()
private let leaveButton = ComponentView<Empty>()
@ -1432,298 +1433,332 @@ private final class VideoChatScreenComponent: Component {
return
}
let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId))
let _ = (groupPeer
|> deliverOnMainQueue).start(next: { [weak self] groupPeer in
guard let self, let component = self.component, let environment = self.environment, let groupPeer else {
return
}
let inviteLinks = self.inviteLinks
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 {
self.presentShare(inviteLinks)
}
return
}
}
var filters: [ChannelMembersSearchFilter] = []
if let members = self.members {
filters.append(.disable(Array(members.participants.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: component.call.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
guard let callState = self.callState else {
return
}
let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
if peer.id == callState.myPeerId {
return
}
if let participant {
dismissController?()
if component.call.invitePeer(participant.peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: EnginePeer(participant.peer), title: nil, text: text, action: nil, duration: 3), action: { _ in return false })
}
var canInvite = true
var inviteIsLink = false
if case let .channel(peer) = self.peer {
if peer.flags.contains(.isGigagroup) {
if peer.flags.contains(.isCreator) || peer.adminRights != nil {
} else {
if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) {
let text = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in
dismissController?()
guard let self, let component = self.component else {
return
}
let _ = (enqueueMessages(account: component.call.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])])
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.presentUndoOverlay(content: .forward(savedMessages: false, text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true })
})
})]), in: .window(.root))
} else {
let text: String
if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info {
text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
return
}
if case let .channel(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = component.call.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: component.call.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id])
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [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: { [weak self] error in
dismissController?()
guard let self, let component = self.component, let environment = self.environment else {
return
}
let text: String
switch error {
case .limitExceeded:
text = environment.strings.Channel_ErrorAddTooMuch
case .tooMuchJoined:
text = environment.strings.Invite_ChannelsTooMuch
case .generic:
text = environment.strings.Login_UnknownError
case .restricted:
text = environment.strings.Channel_ErrorAddBlocked
case .notMutualContact:
if case .broadcast = groupPeer.info {
text = environment.strings.Channel_AddUserLeftError
} else {
text = environment.strings.GroupInfo_AddUserLeftError
}
case .botDoesntSupportGroups:
text = environment.strings.Channel_BotDoesntSupportGroups
case .tooMuchBots:
text = environment.strings.Channel_TooMuchBots
case .bot:
text = environment.strings.Login_UnknownError
case .kicked:
text = environment.strings.Channel_AddUserKickedError
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}, completed: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
dismissController?()
if component.call.invitePeer(peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false })
}
}))
} else if case let .legacyGroup(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = component.call.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id)
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [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: { [weak self] error in
dismissController?()
guard let self, let component = self.component, let environment = self.environment else {
return
}
let context = component.call.accountContext
switch error {
case .privacy:
let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(peer.id)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let component = self.component, let environment = self.environment else {
return
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
})
case .notMutualContact:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .tooManyChannels:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .groupFull, .generic:
environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}
}, completed: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
dismissController?()
if component.call.invitePeer(peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false })
}
}))
}
})]), in: .window(.root))
}
canInvite = false
}
})
controller.copyInviteLink = { [weak self] in
dismissController?()
guard let self, let component = self.component else {
}
if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) {
inviteIsLink = true
}
}
var inviteType: VideoChatParticipantsComponent.Participants.InviteType?
if canInvite {
if inviteIsLink {
inviteType = .shareLink
} else {
inviteType = .invite
}
}
guard let inviteType else {
return
}
switch inviteType {
case .invite:
let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId))
let _ = (groupPeer
|> deliverOnMainQueue).start(next: { [weak self] groupPeer in
guard let self, let component = self.component, let environment = self.environment, let groupPeer else {
return
}
let callPeerId = component.call.peerId
let inviteLinks = self.inviteLinks
let _ = (component.call.accountContext.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
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 {
self.presentShare(inviteLinks)
}
return
}
}
|> deliverOnMainQueue).start(next: { [weak self] link in
guard let self, let environment = self.environment else {
var filters: [ChannelMembersSearchFilter] = []
if let members = self.members {
filters.append(.disable(Array(members.participants.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: component.call.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
guard let callState = self.callState else {
return
}
if let link {
UIPasteboard.general.string = link
let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
if peer.id == callState.myPeerId {
return
}
if let participant {
dismissController?()
self.presentUndoOverlay(content: .linkCopied(text: environment.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false })
if component.call.invitePeer(participant.peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: EnginePeer(participant.peer), title: nil, 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 = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in
dismissController?()
guard let self, let component = self.component else {
return
}
let _ = (enqueueMessages(account: component.call.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])])
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.presentUndoOverlay(content: .forward(savedMessages: false, text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true })
})
})]), in: .window(.root))
} else {
let text: String
if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info {
text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
return
}
if case let .channel(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = component.call.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: component.call.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id])
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [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: { [weak self] error in
dismissController?()
guard let self, let component = self.component, let environment = self.environment else {
return
}
let text: String
switch error {
case .limitExceeded:
text = environment.strings.Channel_ErrorAddTooMuch
case .tooMuchJoined:
text = environment.strings.Invite_ChannelsTooMuch
case .generic:
text = environment.strings.Login_UnknownError
case .restricted:
text = environment.strings.Channel_ErrorAddBlocked
case .notMutualContact:
if case .broadcast = groupPeer.info {
text = environment.strings.Channel_AddUserLeftError
} else {
text = environment.strings.GroupInfo_AddUserLeftError
}
case .botDoesntSupportGroups:
text = environment.strings.Channel_BotDoesntSupportGroups
case .tooMuchBots:
text = environment.strings.Channel_TooMuchBots
case .bot:
text = environment.strings.Login_UnknownError
case .kicked:
text = environment.strings.Channel_AddUserKickedError
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}, completed: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
dismissController?()
if component.call.invitePeer(peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false })
}
}))
} else if case let .legacyGroup(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = component.call.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id)
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [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: { [weak self] error in
dismissController?()
guard let self, let component = self.component, let environment = self.environment else {
return
}
let context = component.call.accountContext
switch error {
case .privacy:
let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(peer.id)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let component = self.component, let environment = self.environment else {
return
}
environment.controller()?.present(textAlertController(context: component.call.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
})
case .notMutualContact:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .tooManyChannels:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .groupFull, .generic:
environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}
}, completed: { [weak self] in
guard let self, let component = self.component, let environment = self.environment else {
dismissController?()
return
}
dismissController?()
if component.call.invitePeer(peer.id) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false })
}
}))
}
})]), in: .window(.root))
}
}
})
controller.copyInviteLink = { [weak self] in
dismissController?()
guard let self, let component = self.component else {
return
}
let callPeerId = component.call.peerId
let _ = (component.call.accountContext.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: { [weak self] link in
guard let self, let environment = self.environment else {
return
}
if let link {
UIPasteboard.general.string = link
self.presentUndoOverlay(content: .linkCopied(text: environment.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false })
}
})
}
dismissController = { [weak controller] in
controller?.dismiss()
}
environment.controller()?.push(controller)
})
case .shareLink:
guard let inviteLinks = self.inviteLinks else {
return
}
dismissController = { [weak controller] in
controller?.dismiss()
}
environment.controller()?.push(controller)
})
self.presentShare(inviteLinks)
}
}
private func presentShare(_ inviteLinks: GroupCallInviteLinks) {
@ -1871,6 +1906,79 @@ private final class VideoChatScreenComponent: Component {
}
}
private func onAudioRoutePressed() {
guard let component = self.component, let environment = self.environment else {
return
}
HapticFeedback().impact(.light)
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 {
component.call.setCurrentAudioOutput(output)
break
}
}
} else {
let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
for output in availableOutputs {
let title: String
var icon: UIImage?
switch output {
case .builtin:
title = UIDevice.current.model
case .speaker:
title = environment.strings.Call_AudioRouteSpeaker
icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false)
case .headphones:
title = environment.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()
guard let self, let component = self.component else {
return
}
component.call.setCurrentAudioOutput(output)
}))
}
actionSheet.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: environment.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
environment.controller()?.present(actionSheet, in: .window(.root))
}
}
func update(component: VideoChatScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
@ -1979,6 +2087,9 @@ private final class VideoChatScreenComponent: Component {
if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members {
if !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in
if let callState = self.callState, participant.peer.id == callState.myPeerId {
return false
}
if participant.videoDescription != nil || participant.presentationDescription != nil {
if members.speakingParticipants.contains(participant.peer.id) {
return true
@ -2140,6 +2251,51 @@ private final class VideoChatScreenComponent: Component {
self.containerView.backgroundColor = .black
}
var mappedParticipants: VideoChatParticipantsComponent.Participants?
if let members = self.members, let callState = self.callState {
var canInvite = true
var inviteIsLink = false
if case let .channel(peer) = self.peer {
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
}
}
var inviteType: VideoChatParticipantsComponent.Participants.InviteType?
if canInvite {
if inviteIsLink {
inviteType = .shareLink
} else {
inviteType = .invite
}
}
mappedParticipants = VideoChatParticipantsComponent.Participants(
myPeerId: callState.myPeerId,
participants: members.participants,
totalCount: members.totalCount,
loadMoreToken: members.loadMoreToken,
inviteType: inviteType
)
}
let maxSingleColumnWidth: CGFloat = 620.0
let isTwoColumnLayout: Bool
if availableSize.width > maxSingleColumnWidth {
if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) {
isTwoColumnLayout = true
} else {
isTwoColumnLayout = false
}
} else {
isTwoColumnLayout = false
}
var containerOffset: CGFloat = 0.0
if let panGestureState = self.panGestureState {
containerOffset = panGestureState.offsetFraction * availableSize.height
@ -2246,6 +2402,66 @@ private final class VideoChatScreenComponent: Component {
transition.setFrame(view: navigationRightButtonView, frame: navigationRightButtonFrame)
}
if isTwoColumnLayout, !"".isEmpty {
var navigationSidebarButtonTransition = transition
let navigationSidebarButton: ComponentView<Empty>
if let current = self.navigationSidebarButton {
navigationSidebarButton = current
} else {
navigationSidebarButtonTransition = navigationSidebarButtonTransition.withAnimation(.none)
navigationSidebarButton = ComponentView()
self.navigationSidebarButton = navigationSidebarButton
}
let navigationSidebarButtonSize = navigationSidebarButton.update(
transition: .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(BundleIconComponent(
name: "Call/PanelIcon",
tintColor: .white
)),
background: AnyComponent(Circle(
fillColor: UIColor(white: 1.0, alpha: 0.1),
size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter)
)),
effectAlignment: .center,
action: { [weak self] in
guard let self else {
return
}
if let expandedParticipantsVideoState = self.expandedParticipantsVideoState {
self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(
mainParticipant: expandedParticipantsVideoState.mainParticipant,
isMainParticipantPinned: expandedParticipantsVideoState.isMainParticipantPinned,
isUIHidden: !expandedParticipantsVideoState.isUIHidden
)
self.state?.updated(transition: .spring(duration: 0.4))
} else {
}
}
)),
environment: {},
containerSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter)
)
let navigationSidebarButtonFrame = CGRect(origin: CGPoint(x: navigationRightButtonFrame.minX - 32.0 - navigationSidebarButtonSize.width, y: topInset + floor((navigationBarHeight - navigationSidebarButtonSize.height) * 0.5)), size: navigationSidebarButtonSize)
if let navigationSidebarButtonView = navigationSidebarButton.view {
if navigationSidebarButtonView.superview == nil {
if let navigationRightButtonView = self.navigationRightButton.view {
self.containerView.insertSubview(navigationSidebarButtonView, aboveSubview: navigationRightButtonView)
}
}
transition.setFrame(view: navigationSidebarButtonView, frame: navigationSidebarButtonFrame)
}
} else if let navigationSidebarButton = self.navigationSidebarButton {
self.navigationSidebarButton = nil
if let navigationSidebarButtonView = navigationSidebarButton.view {
transition.setScale(view: navigationSidebarButtonView, scale: 0.001)
transition.setAlpha(view: navigationSidebarButtonView, alpha: 0.0, completion: { [weak navigationSidebarButtonView] _ in
navigationSidebarButtonView?.removeFromSuperview()
})
}
}
let idleTitleStatusText: String
if let callState = self.callState, callState.networkState == .connected, let members = self.members {
idleTitleStatusText = environment.strings.VoiceChat_Panel_Members(Int32(max(1, members.totalCount)))
@ -2270,28 +2486,6 @@ private final class VideoChatScreenComponent: Component {
transition.setFrame(view: titleView, frame: titleFrame)
}
var mappedParticipants: VideoChatParticipantsComponent.Participants?
if let members = self.members, let callState = self.callState {
mappedParticipants = VideoChatParticipantsComponent.Participants(
myPeerId: callState.myPeerId,
participants: members.participants,
totalCount: members.totalCount,
loadMoreToken: members.loadMoreToken
)
}
let maxSingleColumnWidth: CGFloat = 620.0
let isTwoColumnLayout: Bool
if availableSize.width > maxSingleColumnWidth {
if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) {
isTwoColumnLayout = true
} else {
isTwoColumnLayout = false
}
} else {
isTwoColumnLayout = false
}
let areButtonsCollapsed: Bool
let mainColumnWidth: CGFloat
let mainColumnSideInset: CGFloat
@ -2528,13 +2722,18 @@ private final class VideoChatScreenComponent: Component {
micButtonContent = .connecting
actionButtonMicrophoneState = .connecting
case .connected:
if let _ = callState.muteState {
if self.isPushToTalkActive {
micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive)
actionButtonMicrophoneState = .unmuted
if let callState = callState.muteState {
if callState.canUnmute {
if self.isPushToTalkActive {
micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive)
actionButtonMicrophoneState = .unmuted
} else {
micButtonContent = .muted
actionButtonMicrophoneState = .muted
}
} else {
micButtonContent = .muted
actionButtonMicrophoneState = .muted
micButtonContent = .raiseHand
actionButtonMicrophoneState = .raiseHand
}
} else {
micButtonContent = .unmuted(pushToTalk: false)
@ -2587,6 +2786,17 @@ private final class VideoChatScreenComponent: Component {
self.isPushToTalkActive = false
self.state?.updated(transition: .spring(duration: 0.5))
}
},
raiseHand: { [weak self] in
guard let self, let component = self.component else {
return
}
guard let callState = self.callState else {
return
}
if !callState.raisedHand {
component.call.raiseHand()
}
}
)),
environment: {},
@ -2600,11 +2810,44 @@ private final class VideoChatScreenComponent: Component {
transition.setBounds(view: microphoneButtonView, bounds: CGRect(origin: CGPoint(), size: microphoneButtonFrame.size))
}
let videoButtonContent: VideoChatActionButtonComponent.Content
if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute {
var buttonAudio: VideoChatActionButtonComponent.Content.Audio = .speaker
if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput {
switch currentOutput {
case .builtin:
buttonAudio = .builtin
case .speaker:
buttonAudio = .speaker
case .headphones:
buttonAudio = .headphones
case let .port(port):
var type: VideoChatActionButtonComponent.Content.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
}
buttonAudio = .bluetooth(type)
}
if availableOutputs.count <= 1 {
buttonAudio = .none
}
}
videoButtonContent = .audio(audio: buttonAudio)
} else {
//TODO:release
videoButtonContent = .video(isActive: false)
}
let _ = self.videoButton.update(
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(VideoChatActionButtonComponent(
content: .video(isActive: false),
strings: environment.strings,
content: videoButtonContent,
microphoneState: actionButtonMicrophoneState,
isCollapsed: areButtonsCollapsed
)),
@ -2613,7 +2856,11 @@ private final class VideoChatScreenComponent: Component {
guard let self else {
return
}
self.onCameraPressed()
if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute {
self.onAudioRoutePressed()
} else {
self.onCameraPressed()
}
},
animateAlpha: false
)),
@ -2632,6 +2879,7 @@ private final class VideoChatScreenComponent: Component {
transition: transition,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(VideoChatActionButtonComponent(
strings: environment.strings,
content: .leave,
microphoneState: actionButtonMicrophoneState,
isCollapsed: areButtonsCollapsed