diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 4d79fa094d..b42f21063e 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -471,7 +471,12 @@ public struct ComponentTransition { layer.removeAnimation(forKey: "opacity") completion?(true) case .curve: - let previousAlpha = layer.presentation()?.opacity ?? layer.opacity + let previousAlpha: Float + if layer.animation(forKey: "opacity") != nil { + previousAlpha = layer.presentation()?.opacity ?? layer.opacity + } else { + previousAlpha = layer.opacity + } layer.opacity = Float(alpha) self.animateAlpha(layer: layer, from: CGFloat(previousAlpha), to: alpha, delay: delay, completion: completion) } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift index 7d4edf2fa1..40fde2f502 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedControlsComponent.swift @@ -10,16 +10,22 @@ import BackButtonComponent final class VideoChatExpandedControlsComponent: Component { let theme: PresentationTheme let strings: PresentationStrings + let isPinned: Bool let backAction: () -> Void + let pinAction: () -> Void init( theme: PresentationTheme, strings: PresentationStrings, - backAction: @escaping () -> Void + isPinned: Bool, + backAction: @escaping () -> Void, + pinAction: @escaping () -> Void ) { self.theme = theme self.strings = strings + self.isPinned = isPinned self.backAction = backAction + self.pinAction = pinAction } static func ==(lhs: VideoChatExpandedControlsComponent, rhs: VideoChatExpandedControlsComponent) -> Bool { @@ -29,11 +35,15 @@ final class VideoChatExpandedControlsComponent: Component { if lhs.strings !== rhs.strings { return false } + if lhs.isPinned != rhs.isPinned { + return false + } return true } final class View: UIView { private let backButton = ComponentView() + private let pinStatus = ComponentView() private var component: VideoChatExpandedControlsComponent? private var isUpdating: Bool = false @@ -52,6 +62,9 @@ final class VideoChatExpandedControlsComponent: Component { if let backButtonView = self.backButton.view, let result = backButtonView.hitTest(self.convert(point, to: backButtonView), with: event) { return result } + if let pinStatusView = self.pinStatus.view, let result = pinStatusView.hitTest(self.convert(point, to: pinStatusView), with: event) { + return result + } return nil } @@ -86,6 +99,30 @@ final class VideoChatExpandedControlsComponent: Component { transition.setFrame(view: backButtonView, frame: backButtonFrame) } + let pinStatusSize = self.pinStatus.update( + transition: transition, + component: AnyComponent(VideoChatPinStatusComponent( + theme: component.theme, + strings: component.strings, + isPinned: component.isPinned, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.pinAction() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 100.0) + ) + let pinStatusFrame = CGRect(origin: CGPoint(x: availableSize.width - 0.0 - pinStatusSize.width, y: 0.0), size: pinStatusSize) + if let pinStatusView = self.pinStatus.view { + if pinStatusView.superview == nil { + self.addSubview(pinStatusView) + } + transition.setFrame(view: pinStatusView, frame: pinStatusFrame) + } + return availableSize } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift index e955fbcbf5..564c11822f 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift @@ -10,6 +10,7 @@ import CallScreen import MetalEngine import SwiftSignalKit import AccountContext +import RadialStatusNode private final class BlobView: UIView { let blobsLayer: CallBlobsLayer @@ -209,6 +210,8 @@ final class VideoChatMicButtonComponent: Component { final class View: HighlightTrackingButton { private let background: UIImageView + private var disappearingBackgrounds: [UIImageView] = [] + private var progressIndicator: RadialStatusNode? private let title = ComponentView() private let icon: VoiceChatActionButtonIconNode @@ -330,8 +333,38 @@ final class VideoChatMicButtonComponent: Component { self.addSubview(self.background) self.background.frame = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)) } - transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center) - transition.setScale(view: self.background, scale: size.width / 116.0) + + if case .connecting = component.content { + let progressIndicator: RadialStatusNode + if let current = self.progressIndicator { + progressIndicator = current + } else { + progressIndicator = RadialStatusNode(backgroundNodeColor: .clear) + self.progressIndicator = progressIndicator + } + progressIndicator.transitionToState(.progress(color: UIColor(rgb: 0x0080FF), lineWidth: 3.0, value: nil, cancelEnabled: false, animateRotation: true)) + + let progressIndicatorView = progressIndicator.view + if progressIndicatorView.superview == nil { + self.addSubview(progressIndicatorView) + progressIndicatorView.center = CGRect(origin: CGPoint(), size: size).center + progressIndicatorView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)) + progressIndicatorView.layer.transform = CATransform3DMakeScale(size.width / 116.0, size.width / 116.0, 1.0) + } else { + transition.setPosition(view: progressIndicatorView, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: progressIndicatorView, scale: size.width / 116.0) + } + } else if let progressIndicator = self.progressIndicator { + self.progressIndicator = nil + if !transition.animation.isImmediate { + let progressIndicatorView = progressIndicator.view + progressIndicatorView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak progressIndicatorView] _ in + progressIndicatorView?.removeFromSuperview() + }) + } else { + progressIndicator.view.removeFromSuperview() + } + } if previousComponent?.content != component.content { let backgroundContentsTransition: ComponentTransition @@ -340,7 +373,7 @@ final class VideoChatMicButtonComponent: Component { } else { backgroundContentsTransition = .immediate } - let backgroundImage = generateImage(CGSize(width: 116.0, height: 116.0), rotatedContext: { size, context in + let backgroundImage = generateImage(CGSize(width: 200.0, height: 200.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.addEllipse(in: CGRect(origin: CGPoint(), size: size)) context.clip() @@ -366,11 +399,41 @@ final class VideoChatMicButtonComponent: Component { } })! if let previousImage = self.background.image { + let previousBackground = UIImageView() + previousBackground.center = self.background.center + previousBackground.bounds = self.background.bounds + previousBackground.layer.transform = self.background.layer.transform + previousBackground.image = previousImage + self.insertSubview(previousBackground, aboveSubview: self.background) + self.disappearingBackgrounds.append(previousBackground) + self.background.image = backgroundImage - backgroundContentsTransition.animateContentsImage(layer: self.background.layer, from: previousImage.cgImage!, to: backgroundImage.cgImage!, duration: 0.2, curve: .easeInOut) + backgroundContentsTransition.setAlpha(view: previousBackground, alpha: 0.0, completion: { [weak self, weak previousBackground] _ in + guard let self, let previousBackground else { + return + } + previousBackground.removeFromSuperview() + self.disappearingBackgrounds.removeAll(where: { $0 === previousBackground }) + }) } else { self.background.image = backgroundImage } + + if !transition.animation.isImmediate, let previousComponent, case .connecting = previousComponent.content { + self.layer.animateSublayerScale(from: 1.0, to: 1.07, duration: 0.12, removeOnCompletion: false, completion: { [weak self] completed in + if let self, completed { + self.layer.removeAnimation(forKey: "sublayerTransform.scale") + self.layer.animateSublayerScale(from: 1.07, to: 1.0, duration: 0.12, removeOnCompletion: true) + } + }) + } + } + + transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: self.background, scale: size.width / 116.0) + for disappearingBackground in self.disappearingBackgrounds { + transition.setPosition(view: disappearingBackground, position: CGRect(origin: CGPoint(), size: size).center) + transition.setScale(view: disappearingBackground, scale: size.width / 116.0) } let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize) @@ -434,14 +497,23 @@ final class VideoChatMicButtonComponent: Component { blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758)) - if self.audioLevelDisposable == nil { - self.audioLevelDisposable = (component.call.myAudioLevel - |> deliverOnMainQueue).startStrict(next: { [weak self] value in - guard let self, let blobView = self.blobView else { - return - } - blobView.updateLevel(CGFloat(value), immediately: false) - }) + switch component.content { + case .unmuted: + if self.audioLevelDisposable == nil { + self.audioLevelDisposable = (component.call.myAudioLevel + |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self, let blobView = self.blobView else { + return + } + blobView.updateLevel(CGFloat(value), immediately: false) + }) + } + case .connecting, .muted: + if let audioLevelDisposable = self.audioLevelDisposable { + self.audioLevelDisposable = nil + audioLevelDisposable.dispose() + blobView.updateLevel(0.0, immediately: false) + } } var glowFrame = CGRect(origin: CGPoint(), size: availableSize) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index bea427b684..211064e0c5 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -41,6 +41,7 @@ final class VideoChatParticipantVideoComponent: Component { let isSpeaking: Bool let isExpanded: Bool let bottomInset: CGFloat + weak var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? let action: (() -> Void)? init( @@ -50,6 +51,7 @@ final class VideoChatParticipantVideoComponent: Component { isSpeaking: Bool, isExpanded: Bool, bottomInset: CGFloat, + rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView?, action: (() -> Void)? ) { self.call = call @@ -58,6 +60,7 @@ final class VideoChatParticipantVideoComponent: Component { self.isSpeaking = isSpeaking self.isExpanded = isExpanded self.bottomInset = bottomInset + self.rootVideoLoadingEffectView = rootVideoLoadingEffectView self.action = action } @@ -113,6 +116,8 @@ final class VideoChatParticipantVideoComponent: Component { private var activityBorderView: UIImageView? + private var loadingEffectView: PortalView? + override init(frame: CGRect) { super.init(frame: frame) @@ -429,6 +434,18 @@ final class VideoChatParticipantVideoComponent: Component { self.videoSpec = nil } + if self.loadingEffectView == nil, let rootVideoLoadingEffectView = component.rootVideoLoadingEffectView { + if let loadingEffectView = PortalView(matchPosition: true) { + self.loadingEffectView = loadingEffectView + self.addSubview(loadingEffectView.view) + rootVideoLoadingEffectView.portalSource.addPortal(view: loadingEffectView) + loadingEffectView.view.frame = CGRect(origin: CGPoint(), size: availableSize) + } + } + if let loadingEffectView = self.loadingEffectView { + transition.setFrame(view: loadingEffectView.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + if component.isSpeaking && !component.isExpanded { let activityBorderView: UIImageView if let current = self.activityBorderView { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index 9077eed388..9561b0117c 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -12,6 +12,23 @@ import TelegramPresentationData import PeerListItemComponent final class VideoChatParticipantsComponent: Component { + enum LayoutType: Equatable { + struct Horizontal: Equatable { + var rightColumnWidth: CGFloat + var columnSpacing: CGFloat + var isCentered: Bool + + init(rightColumnWidth: CGFloat, columnSpacing: CGFloat, isCentered: Bool) { + self.rightColumnWidth = rightColumnWidth + self.columnSpacing = columnSpacing + self.isCentered = isCentered + } + } + + case vertical + case horizontal(Horizontal) + } + final class Participants: Equatable { let myPeerId: EnginePeer.Id let participants: [GroupCallParticipantsContext.Participant] @@ -84,6 +101,7 @@ final class VideoChatParticipantsComponent: Component { let expandedVideoState: ExpandedVideoState? let theme: PresentationTheme let strings: PresentationStrings + let layoutType: LayoutType let collapsedContainerInsets: UIEdgeInsets let expandedContainerInsets: UIEdgeInsets let sideInset: CGFloat @@ -97,6 +115,7 @@ final class VideoChatParticipantsComponent: Component { expandedVideoState: ExpandedVideoState?, theme: PresentationTheme, strings: PresentationStrings, + layoutType: LayoutType, collapsedContainerInsets: UIEdgeInsets, expandedContainerInsets: UIEdgeInsets, sideInset: CGFloat, @@ -109,6 +128,7 @@ final class VideoChatParticipantsComponent: Component { self.expandedVideoState = expandedVideoState self.theme = theme self.strings = strings + self.layoutType = layoutType self.collapsedContainerInsets = collapsedContainerInsets self.expandedContainerInsets = expandedContainerInsets self.sideInset = sideInset @@ -132,6 +152,9 @@ final class VideoChatParticipantsComponent: Component { if lhs.strings !== rhs.strings { return false } + if lhs.layoutType != rhs.layoutType { + return false + } if lhs.collapsedContainerInsets != rhs.collapsedContainerInsets { return false } @@ -214,15 +237,22 @@ final class VideoChatParticipantsComponent: Component { struct ExpandedGrid { let containerSize: CGSize + let layoutType: LayoutType let containerInsets: UIEdgeInsets - init(containerSize: CGSize, containerInsets: UIEdgeInsets) { + init(containerSize: CGSize, layoutType: LayoutType, containerInsets: UIEdgeInsets) { self.containerSize = containerSize + self.layoutType = layoutType self.containerInsets = containerInsets } func itemContainerFrame() -> CGRect { - return CGRect(origin: CGPoint(x: self.containerInsets.left, y: self.containerInsets.top), size: CGSize(width: self.containerSize.width - self.containerInsets.left - self.containerInsets.right, height: self.containerSize.height - self.containerInsets.top - containerInsets.bottom)) + switch self.layoutType { + case .vertical: + return CGRect(origin: CGPoint(x: self.containerInsets.left, y: self.containerInsets.top), size: CGSize(width: self.containerSize.width - self.containerInsets.left - self.containerInsets.right, height: self.containerSize.height - self.containerInsets.top - containerInsets.bottom)) + case .horizontal: + return CGRect(origin: CGPoint(x: self.containerInsets.left, y: self.containerInsets.top), size: CGSize(width: self.containerSize.width - self.containerInsets.left - self.containerInsets.right, height: self.containerSize.height - self.containerInsets.top)) + } } } @@ -276,6 +306,7 @@ final class VideoChatParticipantsComponent: Component { } let containerSize: CGSize + let layoutType: LayoutType let collapsedContainerInsets: UIEdgeInsets let sideInset: CGFloat let grid: Grid @@ -284,36 +315,93 @@ final class VideoChatParticipantsComponent: Component { let spacing: CGFloat let gridOffsetY: CGFloat let listOffsetY: CGFloat + let listFrame: CGRect + let separateVideoGridFrame: CGRect + let scrollClippingFrame: CGRect + let separateVideoScrollClippingFrame: CGRect - init(containerSize: CGSize, sideInset: CGFloat, collapsedContainerInsets: UIEdgeInsets, expandedContainerInsets: UIEdgeInsets, gridItemCount: Int, listItemCount: Int, listItemHeight: CGFloat, listTrailingItemHeight: CGFloat) { + init(containerSize: CGSize, layoutType: LayoutType, sideInset: CGFloat, collapsedContainerInsets: UIEdgeInsets, expandedContainerInsets: UIEdgeInsets, gridItemCount: Int, listItemCount: Int, listItemHeight: CGFloat, listTrailingItemHeight: CGFloat) { self.containerSize = containerSize + self.layoutType = layoutType self.collapsedContainerInsets = collapsedContainerInsets self.sideInset = sideInset - self.grid = Grid(containerSize: CGSize(width: containerSize.width - sideInset * 2.0, height: containerSize.height), sideInset: 0.0, itemCount: gridItemCount) - self.expandedGrid = ExpandedGrid(containerSize: containerSize, containerInsets: expandedContainerInsets) - self.list = List(containerSize: CGSize(width: containerSize.width - sideInset * 2.0, height: containerSize.height), sideInset: 0.0, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) + let gridWidth: CGFloat + let listWidth: CGFloat + switch layoutType { + case .vertical: + listWidth = containerSize.width - sideInset * 2.0 + gridWidth = listWidth + case let .horizontal(horizontal): + listWidth = horizontal.rightColumnWidth + gridWidth = max(10.0, containerSize.width - sideInset * 2.0 - horizontal.rightColumnWidth - horizontal.columnSpacing) + } + + self.grid = Grid(containerSize: CGSize(width: gridWidth, height: containerSize.height), sideInset: 0.0, itemCount: gridItemCount) + self.expandedGrid = ExpandedGrid(containerSize: containerSize, layoutType: layoutType, containerInsets: expandedContainerInsets) + self.list = List(containerSize: CGSize(width: listWidth, height: containerSize.height), sideInset: 0.0, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) self.spacing = 4.0 self.gridOffsetY = collapsedContainerInsets.top var listOffsetY: CGFloat = self.gridOffsetY - if self.grid.itemCount != 0 { - listOffsetY += self.grid.contentHeight() - listOffsetY += self.spacing + if case .vertical = layoutType { + if self.grid.itemCount != 0 { + listOffsetY += self.grid.contentHeight() + listOffsetY += self.spacing + } } self.listOffsetY = listOffsetY + + switch layoutType { + case .vertical: + self.scrollClippingFrame = CGRect(origin: CGPoint(x: self.sideInset, y: collapsedContainerInsets.top), size: CGSize(width: containerSize.width - self.sideInset * 2.0, height: containerSize.height - collapsedContainerInsets.top - collapsedContainerInsets.bottom)) + self.listFrame = CGRect(origin: CGPoint(), size: containerSize) + + self.separateVideoGridFrame = CGRect(origin: CGPoint(x: -containerSize.width, y: 0.0), size: containerSize) + self.separateVideoScrollClippingFrame = CGRect(origin: CGPoint(x: self.separateVideoGridFrame.minX, y: collapsedContainerInsets.top), size: CGSize(width: self.separateVideoGridFrame.width, height: containerSize.height - collapsedContainerInsets.top)) + case let .horizontal(horizontal): + if horizontal.isCentered { + self.listFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - horizontal.rightColumnWidth) * 0.5), y: 0.0), size: CGSize(width: horizontal.rightColumnWidth, height: containerSize.height)) + } else { + self.listFrame = CGRect(origin: CGPoint(x: containerSize.width - self.sideInset - horizontal.rightColumnWidth, y: 0.0), size: CGSize(width: horizontal.rightColumnWidth, height: containerSize.height)) + } + self.scrollClippingFrame = CGRect(origin: CGPoint(x: self.listFrame.minX, y: collapsedContainerInsets.top), size: CGSize(width: self.listFrame.width, height: containerSize.height - collapsedContainerInsets.top)) + + self.separateVideoGridFrame = CGRect(origin: CGPoint(x: min(self.sideInset, self.scrollClippingFrame.minX - horizontal.columnSpacing - gridWidth), y: 0.0), size: CGSize(width: gridWidth, height: containerSize.height)) + self.separateVideoScrollClippingFrame = CGRect(origin: CGPoint(x: self.separateVideoGridFrame.minX, y: collapsedContainerInsets.top), size: CGSize(width: self.separateVideoGridFrame.width, height: containerSize.height - collapsedContainerInsets.top)) + } } func contentHeight() -> CGFloat { var result: CGFloat = self.gridOffsetY - if self.grid.itemCount != 0 { - result += self.grid.contentHeight() - result += self.spacing + switch self.layoutType { + case .vertical: + if self.grid.itemCount != 0 { + result += self.grid.contentHeight() + result += self.spacing + } + case .horizontal: + break } result += self.list.contentHeight() result += self.collapsedContainerInsets.bottom - result += 32.0 + result += 24.0 + return result + } + + func separateVideoGridContentHeight() -> CGFloat { + var result: CGFloat = self.gridOffsetY + switch self.layoutType { + case .vertical: + break + case .horizontal: + if self.grid.itemCount != 0 { + result += self.grid.contentHeight() + } + } + result += self.collapsedContainerInsets.bottom + result += 24.0 return result } @@ -326,7 +414,12 @@ final class VideoChatParticipantsComponent: Component { } func gridItemContainerFrame() -> CGRect { - return CGRect(origin: CGPoint(x: self.sideInset, y: self.gridOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.grid.contentHeight())) + switch self.layoutType { + case .vertical: + return CGRect(origin: CGPoint(x: self.sideInset, y: self.gridOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.grid.contentHeight())) + case .horizontal: + return CGRect(origin: CGPoint(x: 0.0, y: self.gridOffsetY), size: CGSize(width: self.separateVideoGridFrame.width, height: self.grid.contentHeight())) + } } func visibleListItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { @@ -338,7 +431,12 @@ final class VideoChatParticipantsComponent: Component { } func listItemContainerFrame() -> CGRect { - return CGRect(origin: CGPoint(x: self.sideInset, y: self.listOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.list.contentHeight())) + switch self.layoutType { + case .vertical: + return CGRect(origin: CGPoint(x: self.sideInset, y: self.listOffsetY), size: CGSize(width: self.containerSize.width - self.sideInset * 2.0, height: self.list.contentHeight())) + case .horizontal: + return CGRect(origin: CGPoint(x: 0.0, y: self.listOffsetY), size: CGSize(width: self.listFrame.width, height: self.list.contentHeight())) + } } func listTrailingItemFrame() -> CGRect { @@ -389,9 +487,13 @@ final class VideoChatParticipantsComponent: Component { } final class View: UIView, UIScrollViewDelegate { + private var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? + private let scrollViewClippingContainer: SolidRoundedCornersContainer private let scrollView: ScrollView - private let scrollViewClippingShadowView: UIImageView + + private let separateVideoScrollViewClippingContainer: SolidRoundedCornersContainer + private let separateVideoScrollView: ScrollView private var component: VideoChatParticipantsComponent? private var isUpdating: Bool = false @@ -422,10 +524,11 @@ final class VideoChatParticipantsComponent: Component { override init(frame: CGRect) { self.scrollViewClippingContainer = SolidRoundedCornersContainer() - self.scrollViewClippingShadowView = UIImageView() - self.scrollView = ScrollView() + self.separateVideoScrollViewClippingContainer = SolidRoundedCornersContainer() + self.separateVideoScrollView = ScrollView() + self.gridItemViewContainer = UIView() self.gridItemViewContainer.layer.anchorPoint = CGPoint(x: 0.5, y: 0.0) @@ -453,13 +556,30 @@ final class VideoChatParticipantsComponent: Component { self.scrollView.delegate = self self.scrollView.clipsToBounds = true + self.separateVideoScrollView.delaysContentTouches = false + self.separateVideoScrollView.canCancelContentTouches = true + self.separateVideoScrollView.clipsToBounds = false + self.separateVideoScrollView.contentInsetAdjustmentBehavior = .never + if #available(iOS 13.0, *) { + self.separateVideoScrollView.automaticallyAdjustsScrollIndicatorInsets = false + } + self.separateVideoScrollView.showsVerticalScrollIndicator = false + self.separateVideoScrollView.showsHorizontalScrollIndicator = false + self.separateVideoScrollView.alwaysBounceHorizontal = false + self.separateVideoScrollView.alwaysBounceVertical = false + self.separateVideoScrollView.scrollsToTop = false + self.separateVideoScrollView.delegate = self + self.separateVideoScrollView.clipsToBounds = true + self.scrollViewClippingContainer.addSubview(self.scrollView) self.addSubview(self.scrollViewClippingContainer) self.addSubview(self.scrollViewClippingContainer.cornersView) - self.addSubview(self.scrollViewClippingShadowView) + + self.separateVideoScrollViewClippingContainer.addSubview(self.separateVideoScrollView) + self.addSubview(self.separateVideoScrollViewClippingContainer) + self.addSubview(self.separateVideoScrollViewClippingContainer.cornersView) self.scrollView.addSubview(self.listItemViewContainer) - self.scrollView.addSubview(self.gridItemViewContainer) self.addSubview(self.expandedGridItemContainer) } @@ -481,6 +601,8 @@ final class VideoChatParticipantsComponent: Component { } else { if let result = self.scrollViewClippingContainer.hitTest(self.convert(point, to: self.scrollViewClippingContainer), with: event) { return result + } else if let result = self.separateVideoScrollViewClippingContainer.hitTest(self.convert(point, to: self.separateVideoScrollViewClippingContainer), with: event) { + return result } else { return nil } @@ -515,13 +637,27 @@ final class VideoChatParticipantsComponent: Component { if component.expandedVideoState != nil { expandedGridItemContainerFrame = itemLayout.expandedGrid.itemContainerFrame() } else { - expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) - if expandedGridItemContainerFrame.origin.y < component.collapsedContainerInsets.top { - expandedGridItemContainerFrame.size.height -= component.collapsedContainerInsets.top - expandedGridItemContainerFrame.origin.y - expandedGridItemContainerFrame.origin.y = component.collapsedContainerInsets.top - } - if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height - component.collapsedContainerInsets.bottom { - expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height - component.collapsedContainerInsets.bottom) + switch itemLayout.layoutType { + case .vertical: + expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: 0.0, dy: -self.scrollView.bounds.minY) + + if expandedGridItemContainerFrame.origin.y < component.collapsedContainerInsets.top { + expandedGridItemContainerFrame.size.height -= component.collapsedContainerInsets.top - expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.origin.y = component.collapsedContainerInsets.top + } + if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height - component.collapsedContainerInsets.bottom { + expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height - component.collapsedContainerInsets.bottom) + } + case .horizontal: + expandedGridItemContainerFrame = itemLayout.gridItemContainerFrame().offsetBy(dx: itemLayout.separateVideoScrollClippingFrame.minX, dy: 0.0).offsetBy(dx: 0.0, dy: -self.separateVideoScrollView.bounds.minY) + + if expandedGridItemContainerFrame.origin.y < component.collapsedContainerInsets.top { + expandedGridItemContainerFrame.size.height -= component.collapsedContainerInsets.top - expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.origin.y = component.collapsedContainerInsets.top + } + if expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height > itemLayout.containerSize.height { + expandedGridItemContainerFrame.size.height -= (expandedGridItemContainerFrame.origin.y + expandedGridItemContainerFrame.height) - (itemLayout.containerSize.height) + } } if expandedGridItemContainerFrame.size.height < 0.0 { expandedGridItemContainerFrame.size.height = 0.0 @@ -533,7 +669,13 @@ final class VideoChatParticipantsComponent: Component { var validGridItemIds: [VideoParticipantKey] = [] var validGridItemIndices: [Int] = [] - let visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.scrollView.bounds) + let visibleGridItemRange: (minIndex: Int, maxIndex: Int) + switch itemLayout.layoutType { + case .vertical: + visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.scrollView.bounds) + case .horizontal: + visibleGridItemRange = itemLayout.visibleGridItemRange(for: self.separateVideoScrollView.bounds) + } if visibleGridItemRange.maxIndex >= visibleGridItemRange.minIndex { for index in visibleGridItemRange.minIndex ... visibleGridItemRange.maxIndex { let videoParticipant = self.gridParticipants[index] @@ -592,6 +734,16 @@ final class VideoChatParticipantsComponent: Component { itemFrame = itemLayout.gridItemFrame(at: index) } + var itemBottomInset: CGFloat = isItemExpanded ? 96.0 : 0.0 + switch itemLayout.layoutType { + case .vertical: + break + case .horizontal: + if isItemExpanded { + itemBottomInset += itemLayout.expandedGrid.containerInsets.bottom + } + } + let _ = itemView.view.update( transition: itemTransition, component: AnyComponent(VideoChatParticipantVideoComponent( @@ -600,7 +752,8 @@ final class VideoChatParticipantsComponent: Component { isPresentation: videoParticipant.isPresentation, isSpeaking: component.speakingParticipants.contains(videoParticipant.participant.peer.id), isExpanded: isItemExpanded, - bottomInset: isItemExpanded ? 96.0 : 0.0, + bottomInset: itemBottomInset, + rootVideoLoadingEffectView: self.rootVideoLoadingEffectView, action: { [weak self] in guard let self, let component = self.component else { return @@ -893,7 +1046,13 @@ final class VideoChatParticipantsComponent: Component { environment: {}, containerSize: itemLayout.expandedGrid.itemContainerFrame().size ) - let expandedThumbnailsFrame = CGRect(origin: CGPoint(x: 0.0, y: expandedGridItemContainerFrame.height - expandedThumbnailsSize.height), size: expandedThumbnailsSize) + var expandedThumbnailsFrame = CGRect(origin: CGPoint(x: 0.0, y: expandedGridItemContainerFrame.height - expandedThumbnailsSize.height), size: expandedThumbnailsSize) + switch itemLayout.layoutType { + case .vertical: + break + case .horizontal: + expandedThumbnailsFrame.origin.y -= itemLayout.expandedGrid.containerInsets.bottom + } if let expandedThumbnailsComponentView = expandedThumbnailsView.view { if expandedThumbnailsComponentView.superview == nil { self.expandedGridItemContainer.addSubview(expandedThumbnailsComponentView) @@ -928,11 +1087,22 @@ final class VideoChatParticipantsComponent: Component { component: AnyComponent(VideoChatExpandedControlsComponent( theme: component.theme, strings: component.strings, + isPinned: expandedVideoState.isMainParticipantPinned, backAction: { [weak self] in guard let self, let component = self.component else { return } component.updateMainParticipant(nil) + }, + pinAction: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let expandedVideoState = component.expandedVideoState else { + return + } + + component.updateIsMainParticipantPinned(!expandedVideoState.isMainParticipantPinned) } )), environment: {}, @@ -1011,6 +1181,28 @@ final class VideoChatParticipantsComponent: Component { self.component = component + if !"".isEmpty { + let rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView + if let current = self.rootVideoLoadingEffectView { + rootVideoLoadingEffectView = current + } else { + rootVideoLoadingEffectView = VideoChatVideoLoadingEffectView( + effectAlpha: 0.1, + borderAlpha: 0.0, + gradientWidth: 260.0, + duration: 1.0, + hasCustomBorder: false, + playOnce: false + ) + self.rootVideoLoadingEffectView = rootVideoLoadingEffectView + self.insertSubview(rootVideoLoadingEffectView, at: 0) + rootVideoLoadingEffectView.alpha = 0.0 + rootVideoLoadingEffectView.isUserInteractionEnabled = false + } + + rootVideoLoadingEffectView.update(size: availableSize, transition: transition) + } + let measureListItemSize = self.measureListItemView.update( transition: .immediate, component: AnyComponent(PeerListItemComponent( @@ -1080,6 +1272,7 @@ final class VideoChatParticipantsComponent: Component { let itemLayout = ItemLayout( containerSize: availableSize, + layoutType: component.layoutType, sideInset: component.sideInset, collapsedContainerInsets: component.collapsedContainerInsets, expandedContainerInsets: component.expandedContainerInsets, @@ -1097,7 +1290,7 @@ final class VideoChatParticipantsComponent: Component { cornerRadius: 10.0 )), environment: {}, - containerSize: CGSize(width: availableSize.width - itemLayout.sideInset * 2.0, height: itemLayout.list.contentHeight()) + containerSize: CGSize(width: itemLayout.list.containerSize.width, height: itemLayout.list.contentHeight()) ) let listItemsBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: listItemsBackgroundSize) if let listItemsBackgroundView = self.listItemsBackground.view { @@ -1143,58 +1336,56 @@ final class VideoChatParticipantsComponent: Component { } (component.call as! PresentationGroupCallImpl).setRequestedVideoList(items: requestedVideo) - let scrollClippingFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: component.collapsedContainerInsets.top), size: CGSize(width: availableSize.width - itemLayout.sideInset * 2.0, height: availableSize.height - component.collapsedContainerInsets.top - component.collapsedContainerInsets.bottom)) - transition.setPosition(view: self.scrollViewClippingContainer, position: scrollClippingFrame.center) - transition.setBounds(view: self.scrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) - transition.setFrame(view: self.scrollViewClippingContainer.cornersView, frame: scrollClippingFrame) + transition.setPosition(view: self.scrollViewClippingContainer, position: itemLayout.scrollClippingFrame.center) + transition.setBounds(view: self.scrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: itemLayout.scrollClippingFrame.minX - itemLayout.listFrame.minX, y: itemLayout.scrollClippingFrame.minY - itemLayout.listFrame.minY), size: itemLayout.scrollClippingFrame.size)) + transition.setFrame(view: self.scrollViewClippingContainer.cornersView, frame: itemLayout.scrollClippingFrame) self.scrollViewClippingContainer.update(params: SolidRoundedCornersContainer.Params( - size: scrollClippingFrame.size, + size: itemLayout.scrollClippingFrame.size, color: .black, cornerRadius: 10.0, smoothCorners: false ), transition: transition) - if self.scrollViewClippingShadowView.image == nil { - let height: CGFloat = 24.0 - let baseGradientAlpha: CGFloat = 1.0 - let numSteps = 8 - let firstStep = 0 - let firstLocation = 0.0 - let colors = (0 ..< numSteps).map { i -> UIColor in - if i < firstStep { - return UIColor(white: 1.0, alpha: 1.0) - } else { - let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) - let value: CGFloat = 1.0 - bezierPoint(0.42, 0.0, 0.58, 1.0, step) - return UIColor(white: 0.0, alpha: baseGradientAlpha * value) - } - } - let locations = (0 ..< numSteps).map { i -> CGFloat in - if i < firstStep { - return 0.0 - } else { - let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1) - return (firstLocation + (1.0 - firstLocation) * step) - } - } - - self.scrollViewClippingShadowView.image = generateGradientImage(size: CGSize(width: 8.0, height: height), colors: colors.reversed(), locations: locations.reversed().map { 1.0 - $0 })!.withRenderingMode(.alwaysTemplate).stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(height - 1.0)) - self.scrollViewClippingShadowView.tintColor = .black - } - let scrollViewClippingShadowHeight: CGFloat = 24.0 - let scrollViewClippingShadowOffset: CGFloat = 0.0 - transition.setFrame(view: self.scrollViewClippingShadowView, frame: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.maxY + scrollViewClippingShadowOffset - scrollViewClippingShadowHeight), size: CGSize(width: scrollClippingFrame.width, height: scrollViewClippingShadowHeight))) + transition.setPosition(view: self.separateVideoScrollViewClippingContainer, position: itemLayout.separateVideoScrollClippingFrame.center) + transition.setBounds(view: self.separateVideoScrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: itemLayout.separateVideoScrollClippingFrame.minX - itemLayout.separateVideoGridFrame.minX, y: itemLayout.separateVideoScrollClippingFrame.minY - itemLayout.separateVideoGridFrame.minY), size: itemLayout.separateVideoScrollClippingFrame.size)) + transition.setFrame(view: self.separateVideoScrollViewClippingContainer.cornersView, frame: itemLayout.separateVideoScrollClippingFrame) + self.separateVideoScrollViewClippingContainer.update(params: SolidRoundedCornersContainer.Params( + size: itemLayout.separateVideoScrollClippingFrame.size, + color: .black, + cornerRadius: 10.0, + smoothCorners: false + ), transition: transition) self.ignoreScrolling = true - if self.scrollView.bounds.size != availableSize { - transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) + if self.scrollView.bounds.size != itemLayout.listFrame.size { + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: itemLayout.listFrame.size)) } - let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentHeight()) + let contentSize = CGSize(width: itemLayout.listFrame.width, height: itemLayout.contentHeight()) if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } + + if self.separateVideoScrollView.bounds.size != itemLayout.separateVideoGridFrame.size { + transition.setFrame(view: self.separateVideoScrollView, frame: CGRect(origin: CGPoint(), size: itemLayout.separateVideoGridFrame.size)) + } + let separateVideoContentSize = CGSize(width: itemLayout.separateVideoGridFrame.width, height: itemLayout.separateVideoGridContentHeight()) + if self.separateVideoScrollView.contentSize != separateVideoContentSize { + self.separateVideoScrollView.contentSize = separateVideoContentSize + } + self.ignoreScrolling = false + switch component.layoutType { + case .vertical: + if self.gridItemViewContainer.superview !== self.scrollView { + self.scrollView.addSubview(self.gridItemViewContainer) + } + case .horizontal: + if self.gridItemViewContainer.superview !== self.separateVideoScrollView { + self.separateVideoScrollView.addSubview(self.gridItemViewContainer) + } + } + self.updateScrolling(transition: transition) return availableSize diff --git a/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift new file mode 100644 index 0000000000..68645fed16 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatPinStatusComponent.swift @@ -0,0 +1,99 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import ComponentDisplayAdapters + +final class VideoChatPinStatusComponent: Component { + let theme: PresentationTheme + let strings: PresentationStrings + let isPinned: Bool + let action: () -> Void + + init( + theme: PresentationTheme, + strings: PresentationStrings, + isPinned: Bool, + action: @escaping () -> Void + ) { + self.theme = theme + self.strings = strings + self.isPinned = isPinned + self.action = action + } + + static func ==(lhs: VideoChatPinStatusComponent, rhs: VideoChatPinStatusComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.isPinned != rhs.isPinned { + return false + } + return true + } + + final class View: UIView { + private var pinNode: VoiceChatPinButtonNode? + + private var component: VideoChatPinStatusComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func pinPressed() { + guard let component = self.component else { + return + } + component.action() + } + + func update(component: VideoChatPinStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + self.component = component + + let pinNode: VoiceChatPinButtonNode + if let current = self.pinNode { + pinNode = current + } else { + pinNode = VoiceChatPinButtonNode(theme: component.theme, strings: component.strings) + self.pinNode = pinNode + self.addSubview(pinNode.view) + pinNode.addTarget(self, action: #selector(self.pinPressed), forControlEvents: .touchUpInside) + } + let pinNodeSize = pinNode.update(size: availableSize, transition: transition.containedViewLayoutTransition) + let pinNodeFrame = CGRect(origin: CGPoint(), size: pinNodeSize) + transition.setFrame(view: pinNode.view, frame: pinNodeFrame) + + pinNode.update(pinned: component.isPinned, animated: !transition.animation.isImmediate) + + let size = pinNodeSize + + return size + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index ec458a391b..f8ec1e3325 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -1,4 +1,5 @@ import Foundation +import AVFoundation import UIKit import AsyncDisplayKit import Display @@ -15,6 +16,11 @@ import ContextUI import TelegramPresentationData import DeviceAccess import TelegramVoip +import PresentationDataUtils +import UndoUI +import ShareController +import AvatarNode +import TelegramAudio private final class VideoChatScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -66,10 +72,21 @@ private final class VideoChatScreenComponent: Component { private let participants = ComponentView() + private var reconnectedAsEventsDisposable: Disposable? + private var peer: EnginePeer? private var callState: PresentationGroupCallState? private var stateDisposable: Disposable? + private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? + private var audioOutputStateDisposable: Disposable? + + private var displayAsPeers: [FoundPeer]? + private var displayAsPeersDisposable: Disposable? + + private var inviteLinks: GroupCallInviteLinks? + private var inviteLinksDisposable: Disposable? + private var isPushToTalkActive: Bool = false private var members: PresentationGroupCallMembers? @@ -104,6 +121,10 @@ private final class VideoChatScreenComponent: Component { self.stateDisposable?.dispose() self.membersDisposable?.dispose() self.applicationStateDisposable?.dispose() + self.reconnectedAsEventsDisposable?.dispose() + self.displayAsPeersDisposable?.dispose() + self.audioOutputStateDisposable?.dispose() + self.inviteLinksDisposable?.dispose() } func animateIn() { @@ -158,58 +179,701 @@ private final class VideoChatScreenComponent: Component { guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { return } + guard let peer = self.peer else { + return + } + guard let callState = self.callState else { + return + } + + let canManageCall = callState.canManageCall var items: [ContextMenuItem] = [] - let text: String - let isScheduled = component.call.schedulePending - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream - } else { - text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.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 + + if let displayAsPeers = self.displayAsPeers, displayAsPeers.count > 1 { + for peer in displayAsPeers { + if peer.peer.id == callState.myPeerId { + let avatarSize = CGSize(width: 28.0, height: 28.0) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuDisplayAsItems())))) + }))) + items.append(.separator) + break + } } + } + + if let (availableOutputs, currentOutput) = self.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 = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + currentOutputTitle = title + break + } + } + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuAudioItems())))) + }))) + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_EditTitle + } else { + text = environment.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: { [weak self] _, f in + f(.default) - let action: () -> Void = { - guard let strongSelf = self else { + guard let self else { + return + } + self.openTitleEditing() + }))) + + var hasPermissions = true + if case let .channel(chatPeer) = peer { + if case .broadcast = chatPeer.info { + hasPermissions = false + } else if chatPeer.flags.contains(.isGigagroup) { + hasPermissions = false + } + } + if hasPermissions { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuPermissionItems())))) + }))) + } + } + + if let inviteLinks = self.inviteLinks { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + self.presentShare(inviteLinks) + }))) + } + + //let isScheduled = strongSelf.isScheduled + //TODO:release + let isScheduled: Bool = !"".isEmpty + + let canSpeak: Bool + if let muteState = callState.muteState { + canSpeak = muteState.canUnmute + } else { + canSpeak = true + } + + if !isScheduled && canSpeak { + if #available(iOS 15.0, *) { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MicrophoneModes, textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.dismissWithoutContent) + AVCaptureDevice.showSystemUserInterface(.microphoneModes) + }))) + } + } + + if callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { + if component.call.hasScreencast { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_StopScreenSharing, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + component.call.disableScreencast() + }))) + } else { + items.append(.custom(VoiceChatShareScreenContextItem(context: component.call.accountContext, text: environment.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 = callState.recordingStartTimestamp { + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: environment.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_StopRecordingStop, action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + component.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) + + Queue.mainQueue().after(0.88) { + HapticFeedback().success() + } + + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingSaved + } else { + text = environment.strings.VideoChat_RecordingSaved + } + self.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in + if case .info = value, let self, let component = self.component, let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController { + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + 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, let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) + }) + } + }) + + return true + } + return false + }) + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }), false)) + } else { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_StartRecording + } else { + text = environment.strings.VoiceChat_StartRecording + } + if callState.scheduleTimestamp == nil { + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + + let controller = VoiceChatRecordingSetupController(context: component.call.accountContext, peer: peer, completion: { [weak self] videoOrientation in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + let title: String + let text: String + let placeholder: String + if let _ = videoOrientation { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholderVideo + } else { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholder + } + if case let .channel(channel) = peer, case .broadcast = channel.info { + title = environment.strings.LiveStream_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.LiveStream_StartRecordingTextVideo + } else { + text = environment.strings.LiveStream_StartRecordingText + } + } else { + title = environment.strings.VoiceChat_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.VoiceChat_StartRecordingTextVideo + } else { + text = environment.strings.VoiceChat_StartRecordingText + } + } + + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.account, forceTheme: environment.theme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak self] title in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer, let title else { + return + } + + component.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) + + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingStarted + } else { + text = environment.strings.VoiceChat_RecordingStarted + } + + self.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) + component.call.playTone(.recordingStarted) + }) + environment.controller()?.present(controller, in: .window(.root)) + }) + environment.controller()?.present(controller, in: .window(.root)) + }))) + } + } + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream + } else { + text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.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: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { return } - let _ = (strongSelf.call.leave(terminateIfPossible: true) + let action: () -> Void = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: true) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.controller()?.dismiss() + }) + } + + let title: String + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle + text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText + } else { + title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle + text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: { + action() + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }))) + } else { + let leaveText: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + leaveText = environment.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = environment.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: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: false) |> filter { $0 } |> take(1) - |> deliverOnMainQueue).start(completed: { - self?.controller?.dismiss() + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.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))*/ - }))) + }))) + } let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) let contextController = ContextController(presentationData: presentationData, source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) controller.presentInGlobalOverlay(contextController) } + private func contextMenuDisplayAsItems() -> [ContextMenuItem] { + guard let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + let myPeerId = callState.myPeerId + + let avatarSize = CGSize(width: 28.0, height: 28.0) + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + var isGroup = false + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + 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 ? environment.strings.VoiceChat_DisplayAsInfoGroup : environment.strings.VoiceChat_DisplayAsInfo, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) + }), true)) + + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + var subtitle: String? + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + subtitle = environment.strings.VoiceChat_PersonalAccount + } else if let subscribers = peer.subscribers { + if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { + subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) + } else { + subtitle = environment.strings.Conversation_StatusMembers(subscribers) + } + } + + let isSelected = peer.peer.id == myPeerId + let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) + let theme = environment.theme + let avatarSignal = peerAvatarCompleteImage(account: component.call.accountContext.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(theme.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: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + + if peer.peer.id != myPeerId { + component.call.reconnect(as: peer.peer.id) + } + }))) + + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + items.append(.separator) + } + } + } + return items + } + + private func contextMenuAudioItems() -> [ContextMenuItem] { + guard let environment = self.environment else { + return [] + } + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return [] + } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + for output in availableOutputs { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.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) + + guard let self, let component = self.component else { + return + } + + component.call.setCurrentAudioOutput(output) + }))) + } + + return items + } + + private func contextMenuPermissionItems() -> [ContextMenuItem] { + guard let environment = self.environment, let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + if callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: environment.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 self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: false) + }))) + items.append(.action(ContextMenuActionItem(text: environment.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 self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: true) + }))) + } + return items + } + + private func openTitleEditing() { + guard let component = self.component else { + return + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let callState = self.callState, let peer = self.peer else { + return + } + + let initialTitle = callState.title + + let title: String + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + title = environment.strings.LiveStream_EditTitle + text = environment.strings.LiveStream_EditTitleText + } else { + title = environment.strings.VoiceChat_EditTitle + text = environment.strings.VoiceChat_EditTitleText + } + + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak self] title in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let title = title, title != initialTitle else { + return + } + + component.call.updateTitle(title) + + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = title.isEmpty ? environment.strings.LiveStream_EditTitleRemoveSuccess : environment.strings.LiveStream_EditTitleSuccess(title).string + } else { + text = title.isEmpty ? environment.strings.VoiceChat_EditTitleRemoveSuccess : environment.strings.VoiceChat_EditTitleSuccess(title).string + } + + self.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + }) + } + + private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { + guard let component = self.component, let environment = self.environment else { + return + } + var animateInAsReplacement = false + environment.controller()?.forEachController { c in + if let c = c as? UndoOverlayController { + animateInAsReplacement = true + c.dismiss() + } + return true + } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) + } + + private func presentShare(_ inviteLinks: GroupCallInviteLinks) { + guard let component = self.component else { + return + } + + 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 _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let peer = self.peer else { + return + } + guard let callState = self.callState else { + return + } + var inviteLinks = inviteLinks + + if case let .channel(peer) = peer, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + if !isMuted { + inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) + } + } + + var segmentedValues: [ShareControllerSegmentedValue]? + if let speakerLink = inviteLinks.speakerLink { + segmentedValues = [ShareControllerSegmentedValue(title: environment.strings.VoiceChat_InviteLink_Speaker, subject: .url(speakerLink), actionTitle: environment.strings.VoiceChat_InviteLink_CopySpeakerLink, formatSendTitle: { count in + return formatSendTitle(environment.strings.VoiceChat_InviteLink_InviteSpeakers(Int32(count))) + }), ShareControllerSegmentedValue(title: environment.strings.VoiceChat_InviteLink_Listener, subject: .url(inviteLinks.listenerLink), actionTitle: environment.strings.VoiceChat_InviteLink_CopyListenerLink, formatSendTitle: { count in + return formatSendTitle(environment.strings.VoiceChat_InviteLink_InviteListeners(Int32(count))) + })] + } + let shareController = ShareController(context: component.call.accountContext, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: environment.theme, forcedActionTitle: environment.strings.VoiceChat_CopyInviteLink) + shareController.completed = { [weak self] peerIds in + guard let self, let component = self.component else { + return + } + let _ = (component.call.accountContext.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerList in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let peers = peerList.compactMap { $0 } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let text: String + var isSavedMessages = false + if peers.count == 1, let peer = peers.first { + isSavedMessages = peer.id == component.call.accountContext.account.peerId + let peerName = peer.id == component.call.accountContext.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 == component.call.accountContext.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + let secondPeerName = secondPeer.id == component.call.accountContext.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 = "" + } + + environment.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 + guard let self, let component = self.component, let environment = self.environment else { + return + } + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + environment.controller()?.present(shareController, in: .window(.root)) + }) + } + private func onCameraPressed() { guard let component = self.component, let environment = self.environment else { return @@ -280,8 +944,9 @@ private final class VideoChatScreenComponent: Component { return } if self.members != members { - #if DEBUG var members = members + + #if DEBUG && false if let membersValue = members { var participants = membersValue.participants for i in 1 ... 20 { @@ -335,9 +1000,54 @@ private final class VideoChatScreenComponent: Component { } #endif + if let membersValue = members { + var participants = membersValue.participants + participants = participants.sorted(by: { lhs, rhs in + guard let lhsIndex = membersValue.participants.firstIndex(where: { $0.peer.id == lhs.peer.id }) else { + return false + } + guard let rhsIndex = membersValue.participants.firstIndex(where: { $0.peer.id == rhs.peer.id }) else { + return false + } + + if let lhsActivityRank = lhs.activityRank, let rhsActivityRank = rhs.activityRank { + if lhsActivityRank != rhsActivityRank { + return lhsActivityRank < rhsActivityRank + } + } else if (lhs.activityRank == nil) != (rhs.activityRank == nil) { + return lhs.activityRank != nil + } + + return lhsIndex < rhsIndex + }) + members = PresentationGroupCallMembers( + participants: participants, + speakingParticipants: membersValue.speakingParticipants, + totalCount: membersValue.totalCount, + loadMoreToken: membersValue.loadMoreToken + ) + } + self.members = members if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members { + if !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in + if participant.videoDescription != nil || participant.presentationDescription != nil { + if members.speakingParticipants.contains(participant.peer.id) { + return true + } + } + return false + }) { + if participant.peer.id != expandedParticipantsVideoState.mainParticipant.id { + if participant.presentationDescription != nil { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: true), isMainParticipantPinned: false) + } else { + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false) + } + } + } + if let _ = members.participants.first(where: { participant in if participant.peer.id == expandedParticipantsVideoState.mainParticipant.id { if expandedParticipantsVideoState.mainParticipant.isPresentation { @@ -389,7 +1099,7 @@ private final class VideoChatScreenComponent: Component { self.callState = callState if !self.isUpdating { - self.state?.updated(transition: .immediate) + self.state?.updated(transition: .spring(duration: 0.4)) } } }) @@ -405,6 +1115,72 @@ private final class VideoChatScreenComponent: Component { let suspendVideoChannelRequests = !applicationIsActive || !isPresented component.call.setSuspendVideoChannelRequests(suspendVideoChannelRequests) }) + + self.audioOutputStateDisposable = (component.call.audioOutputState + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let 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) + } + } + + self.audioOutputState = (filteredOutputs, state.1) + self.state?.updated(transition: .spring(duration: 0.4)) + }) + + let currentAccountPeer = component.call.accountContext.account.postbox.loadedPeerWithId(component.call.accountContext.account.peerId) + |> map { peer in + return [FoundPeer(peer: peer, subscribers: nil)] + } + let displayAsPeers: Signal<[FoundPeer], NoError> = currentAccountPeer + |> then( + combineLatest(currentAccountPeer, component.call.accountContext.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: component.call.peerId)) + |> map { currentAccountPeer, availablePeers -> [FoundPeer] in + var result = currentAccountPeer + result.append(contentsOf: availablePeers) + return result + } + ) + self.displayAsPeersDisposable = (displayAsPeers + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + self.displayAsPeers = value + }) + + self.inviteLinksDisposable = (component.call.inviteLinks + |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + self.inviteLinks = value + }) + + self.reconnectedAsEventsDisposable = (component.call.reconnectedAsEvents + |> deliverOnMainQueue).startStrict(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_DisplayAsSuccess(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_DisplayAsSuccess(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + }) } self.isPresentedValue.set(environment.isVisible) @@ -532,7 +1308,7 @@ private final class VideoChatScreenComponent: Component { let titleSize = self.title.update( transition: transition, component: AnyComponent(VideoChatTitleComponent( - title: self.peer?.debugDisplayTitle ?? " ", + title: self.callState?.title ?? self.peer?.debugDisplayTitle ?? " ", status: idleTitleStatusText, strings: environment.strings )), @@ -547,13 +1323,47 @@ 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 participantsLayoutType: VideoChatParticipantsComponent.LayoutType + if availableSize.width > 620.0 { + if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) { + participantsLayoutType = .horizontal(VideoChatParticipantsComponent.LayoutType.Horizontal( + rightColumnWidth: 300.0, + columnSpacing: 8.0, + isCentered: false + )) + } else { + participantsLayoutType = .horizontal(VideoChatParticipantsComponent.LayoutType.Horizontal( + rightColumnWidth: 380.0, + columnSpacing: 0.0, + isCentered: true + )) + } + } else { + participantsLayoutType = .vertical + } + let actionButtonDiameter: CGFloat = 56.0 let expandedMicrophoneButtonDiameter: CGFloat = actionButtonDiameter let collapsedMicrophoneButtonDiameter: CGFloat = 116.0 - - let microphoneButtonDiameter: CGFloat = self.expandedParticipantsVideoState == nil ? collapsedMicrophoneButtonDiameter : expandedMicrophoneButtonDiameter - let maxActionMicrophoneButtonSpacing: CGFloat = 38.0 + + let microphoneButtonDiameter: CGFloat + if case .horizontal = participantsLayoutType { + microphoneButtonDiameter = expandedMicrophoneButtonDiameter + } else { + microphoneButtonDiameter = self.expandedParticipantsVideoState == nil ? collapsedMicrophoneButtonDiameter : expandedMicrophoneButtonDiameter + } + let buttonsSideInset: CGFloat = 42.0 let buttonsWidth: CGFloat = actionButtonDiameter * 2.0 + microphoneButtonDiameter @@ -564,31 +1374,34 @@ private final class VideoChatScreenComponent: Component { let expandedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - expandedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - expandedMicrophoneButtonDiameter - 12.0), size: CGSize(width: expandedMicrophoneButtonDiameter, height: expandedMicrophoneButtonDiameter)) let microphoneButtonFrame: CGRect - if self.expandedParticipantsVideoState == nil { - microphoneButtonFrame = collapsedMicrophoneButtonFrame - } else { + if case .horizontal = participantsLayoutType { microphoneButtonFrame = expandedMicrophoneButtonFrame + } else { + if self.expandedParticipantsVideoState == nil { + microphoneButtonFrame = collapsedMicrophoneButtonFrame + } else { + microphoneButtonFrame = expandedMicrophoneButtonFrame + } } - let collapsedParticipantsClippingY: CGFloat = collapsedMicrophoneButtonFrame.minY + let collapsedParticipantsClippingY: CGFloat = collapsedMicrophoneButtonFrame.minY - 16.0 let expandedParticipantsClippingY: CGFloat = expandedMicrophoneButtonFrame.minY - 24.0 let leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) let rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter)) let participantsSize = availableSize - let participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - collapsedParticipantsClippingY, right: environment.safeInsets.right) - let participantsExpandedInsets = UIEdgeInsets(top: environment.statusBarHeight, left: environment.safeInsets.left, bottom: availableSize.height - expandedParticipantsClippingY, right: environment.safeInsets.right) + let participantsCollapsedInsets: UIEdgeInsets + let participantsExpandedInsets: UIEdgeInsets - 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 - ) + if case .horizontal = participantsLayoutType { + participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - (expandedMicrophoneButtonFrame.minY - 16.0), right: environment.safeInsets.right) + participantsExpandedInsets = participantsCollapsedInsets + } else { + participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - collapsedParticipantsClippingY, right: environment.safeInsets.right) + participantsExpandedInsets = UIEdgeInsets(top: environment.statusBarHeight, left: environment.safeInsets.left, bottom: availableSize.height - expandedParticipantsClippingY, right: environment.safeInsets.right) } + let _ = self.participants.update( transition: transition, component: AnyComponent(VideoChatParticipantsComponent( @@ -598,6 +1411,7 @@ private final class VideoChatScreenComponent: Component { expandedVideoState: self.expandedParticipantsVideoState, theme: environment.theme, strings: environment.strings, + layoutType: participantsLayoutType, collapsedContainerInsets: participantsCollapsedInsets, expandedContainerInsets: participantsExpandedInsets, sideInset: sideInset, @@ -616,7 +1430,21 @@ private final class VideoChatScreenComponent: Component { self.state?.updated(transition: .spring(duration: 0.4)) } }, - updateIsMainParticipantPinned: { isPinned in + updateIsMainParticipantPinned: { [weak self] isPinned in + guard let self else { + return + } + guard let expandedParticipantsVideoState = self.expandedParticipantsVideoState else { + return + } + let updatedExpandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState( + mainParticipant: expandedParticipantsVideoState.mainParticipant, + isMainParticipantPinned: isPinned + ) + if self.expandedParticipantsVideoState != updatedExpandedParticipantsVideoState { + self.expandedParticipantsVideoState = updatedExpandedParticipantsVideoState + self.state?.updated(transition: .spring(duration: 0.4)) + } } )), environment: {}, @@ -656,12 +1484,19 @@ private final class VideoChatScreenComponent: Component { actionButtonMicrophoneState = .connecting } + let areButtonsCollapsed: Bool + if case .horizontal = participantsLayoutType { + areButtonsCollapsed = true + } else { + areButtonsCollapsed = self.expandedParticipantsVideoState != nil + } + let _ = self.microphoneButton.update( transition: transition, component: AnyComponent(VideoChatMicButtonComponent( call: component.call, content: micButtonContent, - isCollapsed: self.expandedParticipantsVideoState != nil, + isCollapsed: areButtonsCollapsed, updateUnmutedStateIsPushToTalk: { [weak self] unmutedStateIsPushToTalk in guard let self, let component = self.component else { return @@ -716,7 +1551,7 @@ private final class VideoChatScreenComponent: Component { content: AnyComponent(VideoChatActionButtonComponent( content: .video(isActive: false), microphoneState: actionButtonMicrophoneState, - isCollapsed: self.expandedParticipantsVideoState != nil + isCollapsed: areButtonsCollapsed )), effectAlignment: .center, action: { [weak self] in @@ -744,7 +1579,7 @@ private final class VideoChatScreenComponent: Component { content: AnyComponent(VideoChatActionButtonComponent( content: .leave, microphoneState: actionButtonMicrophoneState, - isCollapsed: self.expandedParticipantsVideoState != nil + isCollapsed: areButtonsCollapsed )), effectAlignment: .center, action: { [weak self] in diff --git a/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift new file mode 100644 index 0000000000..67b9fc168b --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift @@ -0,0 +1,130 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import HierarchyTrackingLayer + +private let shadowImage: UIImage? = { + UIImage(named: "Stories/PanelGradient") +}() + +final class VideoChatVideoLoadingEffectView: UIView { + private let duration: Double + private let hasCustomBorder: Bool + private let playOnce: Bool + + private let hierarchyTrackingLayer: HierarchyTrackingLayer + + private let gradientWidth: CGFloat + + let portalSource: PortalSourceView + + private let backgroundView: UIImageView + + private let borderGradientView: UIImageView + private let borderContainerView: UIView + let borderMaskLayer: SimpleShapeLayer + + private var didPlayOnce = false + + init(effectAlpha: CGFloat, borderAlpha: CGFloat, gradientWidth: CGFloat = 200.0, duration: Double, hasCustomBorder: Bool, playOnce: Bool) { + self.portalSource = PortalSourceView() + + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + self.duration = duration + self.hasCustomBorder = hasCustomBorder + self.playOnce = playOnce + + self.gradientWidth = gradientWidth + self.backgroundView = UIImageView() + + self.borderGradientView = UIImageView() + self.borderContainerView = UIView() + self.borderMaskLayer = SimpleShapeLayer() + + super.init(frame: .zero) + + self.portalSource.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in + guard let self, self.bounds.width != 0.0 else { + return + } + self.updateAnimations(size: self.bounds.size) + } + + let generateGradient: (CGFloat) -> UIImage? = { baseAlpha in + return generateImage(CGSize(width: self.gradientWidth, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0)) + + if let shadowImage { + UIGraphicsPushContext(context) + + for i in 0 ..< 2 { + let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height)) + + context.saveGState() + context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY) + context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5) + let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width)) + + context.clip(to: adjustedRect, mask: shadowImage.cgImage!) + context.setFillColor(foregroundColor.cgColor) + context.fill(adjustedRect) + + context.restoreGState() + } + + UIGraphicsPopContext() + } + }) + } + self.backgroundView.image = generateGradient(effectAlpha) + self.portalSource.addSubview(self.backgroundView) + + self.borderGradientView.image = generateGradient(borderAlpha) + self.borderContainerView.addSubview(self.borderGradientView) + self.portalSource.addSubview(self.borderContainerView) + self.borderContainerView.layer.mask = self.borderMaskLayer + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateAnimations(size: CGSize) { + if self.backgroundView.layer.animation(forKey: "shimmer") != nil || (self.playOnce && self.didPlayOnce) { + return + } + + let animation = self.backgroundView.layer.makeAnimation(from: 0.0 as NSNumber, to: (size.width + self.gradientWidth + size.width * 0.2) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = self.playOnce ? 1 : Float.infinity + self.backgroundView.layer.add(animation, forKey: "shimmer") + self.borderGradientView.layer.add(animation, forKey: "shimmer") + + self.didPlayOnce = true + } + + func update(size: CGSize, transition: ComponentTransition) { + if self.backgroundView.bounds.size != size { + self.backgroundView.layer.removeAllAnimations() + + if !self.hasCustomBorder { + self.borderMaskLayer.fillColor = nil + self.borderMaskLayer.strokeColor = UIColor.white.cgColor + let lineWidth: CGFloat = 3.0 + self.borderMaskLayer.lineWidth = lineWidth + self.borderMaskLayer.path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: 12.0).cgPath + } + } + + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + + transition.setFrame(view: self.borderContainerView, frame: CGRect(origin: CGPoint(), size: size)) + transition.setFrame(view: self.borderGradientView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + + self.updateAnimations(size: size) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift b/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift index f0869b74fa..83714a88b8 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatMainStageNode.swift @@ -24,18 +24,18 @@ private let backgroundCornerRadius: CGFloat = 11.0 private let fadeColor = UIColor(rgb: 0x000000, alpha: 0.5) private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30) -private class VoiceChatPinButtonNode: HighlightTrackingButtonNode { +class VoiceChatPinButtonNode: HighlightTrackingButtonNode { private let pinButtonIconNode: VoiceChatPinNode private let pinButtonClippingnode: ASDisplayNode private let pinButtonTitleNode: ImmediateTextNode - init(presentationData: PresentationData) { + init(theme: PresentationTheme, strings: PresentationStrings) { self.pinButtonIconNode = VoiceChatPinNode() self.pinButtonClippingnode = ASDisplayNode() self.pinButtonClippingnode.clipsToBounds = true self.pinButtonTitleNode = ImmediateTextNode() - self.pinButtonTitleNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_Unpin, font: Font.regular(17.0), textColor: .white) + self.pinButtonTitleNode.attributedText = NSAttributedString(string: strings.VoiceChat_Unpin, font: Font.regular(17.0), textColor: .white) self.pinButtonTitleNode.alpha = 0.0 super.init() @@ -209,7 +209,7 @@ final class VoiceChatMainStageNode: ASDisplayNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.pinButtonNode = VoiceChatPinButtonNode(presentationData: presentationData) + self.pinButtonNode = VoiceChatPinButtonNode(theme: presentationData.theme, strings: presentationData.strings) self.backdropAvatarNode = ImageNode() self.backdropAvatarNode.contentMode = .scaleAspectFill