diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index ac84368a67..32f52ea744 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -5823,6 +5823,8 @@ Sorry for the inconvenience."; "VoiceChat.Audio" = "audio"; "VoiceChat.Leave" = "leave"; +"LiveStream.Expand" = "expand"; + "VoiceChat.SpeakPermissionEveryone" = "New participants can speak"; "VoiceChat.SpeakPermissionAdmin" = "New paricipants are muted"; "VoiceChat.Share" = "Share Invite Link"; @@ -5959,7 +5961,9 @@ Sorry for the inconvenience."; "LiveStream.RecordingInProgress" = "Live stream is being recorded"; "VoiceChat.StopRecordingTitle" = "Stop Recording?"; -"VoiceChat.StopRecordingStop" = "Stop"; +"VoiceChat.StopRecordingStop" = "Stop Recording"; + +"LiveStream.StopLiveStream" = "Stop Live Stream"; "VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; @@ -7420,6 +7424,7 @@ Sorry for the inconvenience."; "LiveStream.NoViewers" = "No viewers"; "LiveStream.ViewerCount_1" = "1 viewer"; "LiveStream.ViewerCount_any" = "%@ viewers"; +"LiveStream.Watching" = "watching"; "LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; "LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; diff --git a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift index df3d4fae55..af917c84f2 100644 --- a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift +++ b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift @@ -180,7 +180,7 @@ public final class _UpdatedChildComponent { var _opacity: CGFloat? var _cornerRadius: CGFloat? var _clipsToBounds: Bool? - + fileprivate var transitionAppear: Transition.Appear? fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)? fileprivate var transitionDisappear: Transition.Disappear? @@ -240,7 +240,7 @@ public final class _UpdatedChildComponent { self._position = position return self } - + @discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent { self._scale = scale return self @@ -702,6 +702,7 @@ public extension CombinedComponent { } else { updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint()) } + updatedChild.view.alpha = updatedChild._opacity ?? 1.0 updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false updatedChild.view.layer.cornerRadius = updatedChild._cornerRadius ?? 0.0 diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 59b7b962c4..740e9d710c 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -46,6 +46,9 @@ public enum ContextMenuActionItemTextColor { public enum ContextMenuActionResult { case `default` case dismissWithoutContent + /// Temporary + static var safeStreamRecordingDismissWithoutContent: ContextMenuActionResult { .dismissWithoutContent } + case custom(ContainedViewLayoutTransition) } diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index d50dd29b60..e29ebf1ed3 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -499,16 +499,19 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C private let getController: () -> ContextControllerProtocol? private let item: ContextMenuCustomItem + private let requestDismiss: (ContextMenuActionResult) -> Void private var presentationData: PresentationData? private var itemNode: ContextMenuCustomNode? init( getController: @escaping () -> ContextControllerProtocol?, - item: ContextMenuCustomItem + item: ContextMenuCustomItem, + requestDismiss: @escaping (ContextMenuActionResult) -> Void ) { self.getController = getController self.item = item + self.requestDismiss = requestDismiss super.init() } @@ -529,7 +532,12 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C presentationData: presentationData, getController: self.getController, actionSelected: { result in - let _ = result + switch result { + case .dismissWithoutContent/* where ContextMenuActionResult.safeStreamRecordingDismissWithoutContent == .dismissWithoutContent*/: + self.requestDismiss(result) + + default: break + } } ) self.itemNode = itemNode @@ -601,7 +609,8 @@ final class ContextControllerActionsListStackItem: ContextControllerActionsStack return Item( node: ContextControllerActionsListCustomItemNode( getController: getController, - item: customItem + item: customItem, + requestDismiss: requestDismiss ), separatorNode: ASDisplayNode() ) diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift index 8f5178f2b7..57b977e836 100644 --- a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift @@ -50,6 +50,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent let peerId: EnginePeer.Id private(set) var credentials: GroupCallStreamCredentials? + var isDelayingLoadingIndication: Bool = true private var credentialsDisposable: Disposable? private let activeActionDisposable = MetaDisposable() @@ -100,6 +101,13 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent return } + strongSelf.isDelayingLoadingIndication = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak strongSelf] in + guard let strongSelf else { return } + strongSelf.isDelayingLoadingIndication = false + strongSelf.updated(transition: .easeInOut(duration: 0.3)) + } + var cancelImpl: (() -> Void)? let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let progressSignal = Signal { [weak baseController] subscriber in @@ -397,7 +405,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent context.add(credentialsCopyKeyButton .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0)) ) - } else { + } else if !context.state.isDelayingLoadingIndication { let activityIndicator = activityIndicator.update( component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor), availableSize: CGSize(width: 100.0, height: 100.0), diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index a527784fa8..37163a13ff 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -477,6 +477,35 @@ public final class StandaloneShimmerEffect { self.updateLayer() } + public func updateHorizontal(background: UIColor, foreground: UIColor) { + if self.background == background && self.foreground == foreground { + return + } + self.background = background + self.foreground = foreground + + self.image = generateImage(CGSize(width: 320, height: 1), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(background.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + + context.clip(to: CGRect(origin: CGPoint(), size: size)) + + let transparentColor = foreground.withAlphaComponent(0.0).cgColor + let peakColor = foreground.cgColor + + var locations: [CGFloat] = [0.0, 0.44, 0.55, 1.0] + let colors: [CGColor] = [transparentColor, peakColor, peakColor, transparentColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations) else { return } + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.2), end: CGPoint(x: size.width, y: 0.8), options: CGGradientDrawingOptions()) + }) + + self.updateHorizontalLayer() + } + public func updateLayer() { guard let layer = self.layer, let image = self.image else { return @@ -495,4 +524,24 @@ public final class StandaloneShimmerEffect { layer.add(animation, forKey: "shimmer") } } + + private func updateHorizontalLayer() { + guard let layer = self.layer, let image = self.image else { + return + } + + layer.contents = image.cgImage + + if layer.animation(forKey: "shimmer") == nil { + var delay: TimeInterval { 1.6 } + let animation = CABasicAnimation(keyPath: "contentsRect.origin.x") + animation.fromValue = NSNumber(floatLiteral: delay) + animation.toValue = NSNumber(floatLiteral: -delay) + animation.isAdditive = true + animation.repeatCount = .infinity + animation.duration = 0.8 * delay + animation.timingFunction = .init(name: .easeInEaseOut) + layer.add(animation, forKey: "shimmer") + } + } } diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift new file mode 100644 index 0000000000..8ebe446403 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -0,0 +1,407 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +private let purple = UIColor(rgb: 0xdf44b8) +private let pink = UIColor(rgb: 0x3851eb) + +public final class AnimatedCountView: UIView { + let countLabel = AnimatedCountLabel() + let subtitleLabel = UILabel() + + private let foregroundView = UIView() + private let foregroundGradientLayer = CAGradientLayer() + private let maskingView = UIView() + private var scaleFactor: CGFloat { 0.7 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.foregroundGradientLayer.type = .radial + self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] + self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.foregroundView.mask = self.maskingView + self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) + + self.addSubview(self.foregroundView) + self.addSubview(self.subtitleLabel) + + self.maskingView.addSubview(countLabel) + countLabel.clipsToBounds = false + subtitleLabel.textAlignment = .center + self.clipsToBounds = false + + subtitleLabel.textColor = .white + } + + override public func layoutSubviews() { + super.layoutSubviews() + + self.updateFrames() + } + + func updateFrames(transition: ComponentFlow.Transition? = nil) { + let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height + let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: self.countLabel.attributedText?.length == 0 ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) + if let transition { + transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)) + transition.setFrame(view: self.maskingView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.countLabel, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.subtitleLabel, frame: subtitleFrame) + } else { + self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) + self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) + self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) + + countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) + subtitleLabel.frame = subtitleFrame + } + + } + + func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { + self.setupGradientAnimations() + + let backgroundGradientColors: [CGColor] + if gradientColors.count == 1 { + backgroundGradientColors = [gradientColors[0], gradientColors[0]] + } else { + backgroundGradientColors = gradientColors + } + self.foregroundGradientLayer.colors = backgroundGradientColors + + let text: String = countString + self.countLabel.fontSize = fontSize + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor((fontSize + 4.0) / 3.0), 12.0), weight: .semibold)]) + self.subtitleLabel.isHidden = subtitle.isEmpty + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in + self?.setupGradientAnimations() + } + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } +} + +class AnimatedCharLayer: CATextLayer { + var text: String? { + get { + self.string as? String ?? (self.string as? NSAttributedString)?.string + } + set { + self.string = newValue + } + } + var attributedText: NSAttributedString? { + get { + self.string as? NSAttributedString + } + set { + self.string = newValue + } + } + + var layer: CALayer { self } + + override init() { + super.init() + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + override init(layer: Any) { + super.init(layer: layer) + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class AnimatedCountLabel: UILabel { + override var text: String? { + get { + chars.reduce("") { $0 + ($1.text ?? "") } + } + set { +// update(with: newValue ?? "") + } + } + + override var attributedText: NSAttributedString? { + get { + let string = NSMutableAttributedString() + for char in chars { + string.append(char.attributedText ?? NSAttributedString()) + } + return string + } + set { + udpateAttributed(with: newValue ?? NSAttributedString()) + } + } + + private var chars = [AnimatedCharLayer]() + private let containerView = UIView() + + var itemWidth: CGFloat { 36 * fontSize / 60 } + var commaWidthForSpacing: CGFloat { 12 * fontSize / 60 } + var commaFrameWidth: CGFloat { 36 * fontSize / 60 } + var interItemSpacing: CGFloat { 0 * fontSize / 60 } + var didBegin = false + var fontSize: CGFloat = 60 + var scaleFactor: CGFloat { 1 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + containerView.clipsToBounds = false + addSubview(containerView) + self.clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { + if let characters { + var offset = characters[0.. index && characters[index].string == "," { + if index > 0, ["1", "7"].contains(characters[index - 1].string) { + offset -= commaWidthForSpacing * 0.5 + } else { + offset -= commaWidthForSpacing / 6// 3 + } + } + return offset + } else { + return offsetForChar(at: index, within: self.chars.compactMap(\.attributedText)) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + let countWidth = offsetForChar(at: chars.count) - interItemSpacing + containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height) + chars.enumerated().forEach { (index, char) in + let offset = offsetForChar(at: index) + char.frame.origin.x = offset + char.frame.origin.y = 0 + char.frame.size.height = containerView.bounds.height + } + } + + func udpateAttributed(with newString: NSAttributedString) { + let interItemSpacing: CGFloat = 0 + + let separatedStrings = Array(newString.string).map { String($0) } + var range = NSRange(location: 0, length: 0) + var newChars = [NSAttributedString]() + for string in separatedStrings { + range.length = string.count + let attributedString = newString.attributedSubstring(from: range) + newChars.append(attributedString) + range.location += range.length + } + + let currentChars = chars.map { $0.attributedText ?? .init() } + + let maxAnimationDuration: TimeInterval = 1.2 + var numberOfChanges = abs(newChars.count - currentChars.count) + for index in 0.. self.bounds.width { + let scale = (self.bounds.width - 32) / (countWidth * scaleFactor) + containerView.transform = .init(scaleX: scale, y: scale) + } else { + containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor) + } + } + } else if countWidth > 0 { + containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height) + didBegin = true + } + self.clipsToBounds = false + } + func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { + let beginTimeOffset: CFTimeInterval = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { + let beginTime: CFTimeInterval = 0 + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 1 + opacityInAnimation.toValue = 0 + opacityInAnimation.fillMode = .forwards + opacityInAnimation.isRemovedOnCompletion = false + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 1 + scaleOutAnimation.toValue = 0.0 + + let translate = CABasicAnimation(keyPath: "transform.translation") + translate.fromValue = CGPoint.zero + translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3) + + let group = CAAnimationGroup() + group.animations = [opacityInAnimation, scaleOutAnimation, translate] + group.duration = duration + group.beginTime = beginTimeOffset + beginTime + group.fillMode = .forwards + group.isRemovedOnCompletion = false + group.completion = { _ in + layer.removeFromSuperlayer() + } + layer.add(group, forKey: "out") + } + } + + func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { + + let beginTimeOffset: CFTimeInterval = 0 // CACurrentMediaTime() + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in + let beginTime: CFTimeInterval = 0 + newLayer.opacity = 0 + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 0 + opacityInAnimation.toValue = 1 + opacityInAnimation.duration = duration + opacityInAnimation.beginTime = beginTimeOffset + beginTime + opacityInAnimation.fillMode = .backwards + newLayer.opacity = 1 + newLayer.add(opacityInAnimation, forKey: "opacity") + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 0 + scaleOutAnimation.toValue = 1 + scaleOutAnimation.duration = duration + scaleOutAnimation.beginTime = beginTimeOffset + beginTime + newLayer.add(scaleOutAnimation, forKey: "scalein") + + let animation = CAKeyframeAnimation() + animation.keyPath = "position.y" + animation.values = [20 * fontSize / 60, -6 * fontSize / 60, 0] + animation.keyTimes = [0, 0.64, 1] + animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) + animation.duration = duration / 0.64 + animation.beginTime = beginTimeOffset + beginTime + animation.isAdditive = true + newLayer.add(animation, forKey: "pos") + } + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 70fae82726..4ab979bc37 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -18,504 +18,7 @@ import BundleIconComponent import CreateExternalMediaStreamScreen import HierarchyTrackingLayer import UndoPanelComponent - -final class NavigationBackButtonComponent: Component { - let text: String - let color: UIColor - - init(text: String, color: UIColor) { - self.text = text - self.color = color - } - - static func ==(lhs: NavigationBackButtonComponent, rhs: NavigationBackButtonComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.color != rhs.color { - return false - } - return false - } - - public final class View: UIView { - private let arrowView: UIImageView - private let textView: ComponentHostView - - private var component: NavigationBackButtonComponent? - - override init(frame: CGRect) { - self.arrowView = UIImageView() - self.textView = ComponentHostView() - - super.init(frame: frame) - - self.addSubview(self.arrowView) - self.addSubview(self.textView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: NavigationBackButtonComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let spacing: CGFloat = 6.0 - let innerArrowInset: CGFloat = -8.0 - - if self.component?.color != component.color { - self.arrowView.image = NavigationBarTheme.generateBackArrowImage(color: component.color) - } - - self.component = component - - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.regular(17.0), - color: component.color - )), - environment: {}, - containerSize: availableSize - ) - - var leftInset: CGFloat = 0.0 - var size = textSize - if let arrowImage = self.arrowView.image { - size.width += innerArrowInset + arrowImage.size.width + spacing - size.height = max(size.height, arrowImage.size.height) - - self.arrowView.frame = CGRect(origin: CGPoint(x: innerArrowInset, y: floor((size.height - arrowImage.size.height) / 2.0)), size: arrowImage.size) - leftInset += innerArrowInset + arrowImage.size.width + spacing - } - self.textView.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - -final class StreamTitleComponent: Component { - let text: String - let isRecording: Bool - - init(text: String, isRecording: Bool) { - self.text = text - self.isRecording = isRecording - } - - static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.isRecording != rhs.isRecording { - return false - } - return false - } - - public final class View: UIView { - private let textView: ComponentHostView - private var indicatorView: UIImageView? - - private let trackingLayer: HierarchyTrackingLayer - - override init(frame: CGRect) { - self.textView = ComponentHostView() - - self.trackingLayer = HierarchyTrackingLayer() - - super.init(frame: frame) - - self.addSubview(self.textView) - - self.trackingLayer.didEnterHierarchy = { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.updateIndicatorAnimation() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateIndicatorAnimation() { - guard let indicatorView = self.indicatorView else { - return - } - if indicatorView.layer.animation(forKey: "blink") == nil { - let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber] - animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] - animation.duration = 0.7 - animation.autoreverses = true - animation.repeatCount = Float.infinity - indicatorView.layer.add(animation, forKey: "recording") - } - } - - func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.semibold(17.0), - color: .white - )), - environment: {}, - containerSize: availableSize - ) - - if component.isRecording { - if self.indicatorView == nil { - let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) - self.addSubview(indicatorView) - self.indicatorView = indicatorView - - self.updateIndicatorAnimation() - } - } else { - if let indicatorView = self.indicatorView { - self.indicatorView = nil - indicatorView.removeFromSuperview() - } - } - - let sideInset: CGFloat = 20.0 - let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) - let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - self.textView.frame = textFrame - - if let indicatorView = self.indicatorView, let image = indicatorView.image { - indicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) - } - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - -private final class NavigationBarComponent: CombinedComponent { - let topInset: CGFloat - let sideInset: CGFloat - let leftItem: AnyComponent? - let rightItems: [AnyComponentWithIdentity] - let centerItem: AnyComponent? - - init( - topInset: CGFloat, - sideInset: CGFloat, - leftItem: AnyComponent?, - rightItems: [AnyComponentWithIdentity], - centerItem: AnyComponent? - ) { - self.topInset = topInset - self.sideInset = sideInset - self.leftItem = leftItem - self.rightItems = rightItems - self.centerItem = centerItem - } - - static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { - if lhs.topInset != rhs.topInset { - return false - } - if lhs.sideInset != rhs.sideInset { - return false - } - if lhs.leftItem != rhs.leftItem { - return false - } - if lhs.rightItems != rhs.rightItems { - return false - } - if lhs.centerItem != rhs.centerItem { - return false - } - - return true - } - - static var body: Body { - let background = Child(Rectangle.self) - let leftItem = Child(environment: Empty.self) - let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) - let centerItem = Child(environment: Empty.self) - - return { context in - var availableWidth = context.availableSize.width - let sideInset: CGFloat = 16.0 + context.component.sideInset - - let contentHeight: CGFloat = 44.0 - let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) - - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) - - let leftItem = context.component.leftItem.flatMap { leftItemComponent in - return leftItem.update( - component: leftItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let leftItem = leftItem { - availableWidth -= leftItem.size.width - } - - var rightItemList: [_UpdatedChildComponent] = [] - for item in context.component.rightItems { - let item = rightItems[item.id].update( - component: item.component, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - rightItemList.append(item) - availableWidth -= item.size.width - } - - let centerItem = context.component.centerItem.flatMap { centerItemComponent in - return centerItem.update( - component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let centerItem = centerItem { - availableWidth -= centerItem.size.width - } - - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - - var centerLeftInset = sideInset - if let leftItem = leftItem { - context.add(leftItem - .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - centerLeftInset += leftItem.size.width + 4.0 - } - - var centerRightInset = sideInset - var rightItemX = context.availableSize.width - sideInset - for item in rightItemList.reversed() { - context.add(item - .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - rightItemX -= item.size.width + 8.0 - centerRightInset += item.size.width + 8.0 - } - - let maxCenterInset = max(centerLeftInset, centerRightInset) - if let centerItem = centerItem { - context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: context.component.topInset + contentHeight / 2.0)) - ) - } - - return size - } - } -} - -private final class OriginInfoComponent: CombinedComponent { - let title: String - let subtitle: String - - init( - title: String, - subtitle: String - ) { - self.title = title - self.subtitle = subtitle - } - - static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool { - if lhs.title != rhs.title { - return false - } - if lhs.subtitle != rhs.subtitle { - return false - } - - return true - } - - static var body: Body { - let title = Child(Text.self) - let subtitle = Child(Text.self) - - return { context in - let spacing: CGFloat = 0.0 - - let title = title.update( - component: Text( - text: context.component.title, font: Font.semibold(17.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - let subtitle = subtitle.update( - component: Text( - text: context.component.subtitle, font: Font.regular(14.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - var size = CGSize(width: max(title.size.width, subtitle.size.width), height: title.size.height + spacing + subtitle.size.height) - size.width = min(size.width, context.availableSize.width) - size.height = min(size.height, context.availableSize.height) - - context.add(title - .position(CGPoint(x: size.width / 2.0, y: title.size.height / 2.0)) - ) - context.add(subtitle - .position(CGPoint(x: size.width / 2.0, y: title.size.height + spacing + subtitle.size.height / 2.0)) - ) - - return size - } - } -} - -private final class ToolbarComponent: CombinedComponent { - let bottomInset: CGFloat - let sideInset: CGFloat - let leftItem: AnyComponent? - let rightItem: AnyComponent? - let centerItem: AnyComponent? - - init( - bottomInset: CGFloat, - sideInset: CGFloat, - leftItem: AnyComponent?, - rightItem: AnyComponent?, - centerItem: AnyComponent? - ) { - self.bottomInset = bottomInset - self.sideInset = sideInset - self.leftItem = leftItem - self.rightItem = rightItem - self.centerItem = centerItem - } - - static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool { - if lhs.bottomInset != rhs.bottomInset { - return false - } - if lhs.sideInset != rhs.sideInset { - return false - } - if lhs.leftItem != rhs.leftItem { - return false - } - if lhs.rightItem != rhs.rightItem { - return false - } - if lhs.centerItem != rhs.centerItem { - return false - } - - return true - } - - static var body: Body { - let background = Child(Rectangle.self) - let leftItem = Child(environment: Empty.self) - let rightItem = Child(environment: Empty.self) - let centerItem = Child(environment: Empty.self) - - return { context in - var availableWidth = context.availableSize.width - let sideInset: CGFloat = 16.0 + context.component.sideInset - - let contentHeight: CGFloat = 44.0 - let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) - - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) - - let leftItem = context.component.leftItem.flatMap { leftItemComponent in - return leftItem.update( - component: leftItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let leftItem = leftItem { - availableWidth -= leftItem.size.width - } - - let rightItem = context.component.rightItem.flatMap { rightItemComponent in - return rightItem.update( - component: rightItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let rightItem = rightItem { - availableWidth -= rightItem.size.width - } - - let centerItem = context.component.centerItem.flatMap { centerItemComponent in - return centerItem.update( - component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - if let centerItem = centerItem { - availableWidth -= centerItem.size.width - } - - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - - var centerLeftInset = sideInset - if let leftItem = leftItem { - context.add(leftItem - .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) - ) - centerLeftInset += leftItem.size.width + 4.0 - } - - var centerRightInset = sideInset - if let rightItem = rightItem { - context.add(rightItem - .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) - ) - centerRightInset += rightItem.size.width + 4.0 - } - - let maxCenterInset = max(centerLeftInset, centerRightInset) - if let centerItem = centerItem { - context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) - ) - } - - return size - } - } -} +import AvatarNode public final class MediaStreamComponent: CombinedComponent { struct OriginInfo: Equatable { @@ -550,8 +53,11 @@ public final class MediaStreamComponent: CombinedComponent { private(set) var displayUI: Bool = true var dismissOffset: CGFloat = 0.0 - - var storedIsLandscape: Bool? + var initialOffset: CGFloat = 0.0 + var storedIsFullscreen: Bool? + var isFullscreen: Bool = false + var videoSize: CGSize? + var prevFullscreenOrientation: UIDeviceOrientation? private(set) var canManageCall: Bool = false let isPictureInPictureSupported: Bool @@ -566,16 +72,24 @@ public final class MediaStreamComponent: CombinedComponent { private var isVisibleInHierarchyDisposable: Disposable? private var scheduledDismissUITimer: SwiftSignalKit.Timer? + var videoStalled: Bool = true + + var videoIsPlayable: Bool { + !videoStalled && hasVideo + } +// var wantsPiP: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) + private let infoThrottler = Throttler.init(duration: 5, queue: .main) + init(call: PresentationGroupCallImpl) { self.call = call if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { self.isPictureInPictureSupported = true } else { - self.isPictureInPictureSupported = false + self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported() } super.init() @@ -596,53 +110,6 @@ public final class MediaStreamComponent: CombinedComponent { } strongSelf.hasVideo = true strongSelf.updated(transition: .immediate) - - /*let engine = strongSelf.call.accountContext.engine - guard let info = strongSelf.call.initialCall else { - return - } - let _ = (engine.calls.getAudioBroadcastDataSource(callId: info.id, accessHash: info.accessHash) - |> mapToSignal { source -> Signal in - guard let source else { - return .single(nil) - } - - let time = engine.calls.requestStreamState(dataSource: source, callId: info.id, accessHash: info.accessHash) - |> map { state -> Int64? in - guard let state else { - return nil - } - return state.channels.first?.latestTimestamp - } - - return time - |> mapToSignal { latestTimestamp -> Signal in - guard let latestTimestamp else { - return .single(nil) - } - - let durationMilliseconds: Int64 = 32000 - let bufferOffset: Int64 = 1 * durationMilliseconds - let timestampId = latestTimestamp - bufferOffset - - return engine.calls.getVideoBroadcastPart(dataSource: source, callId: info.id, accessHash: info.accessHash, timestampIdMilliseconds: timestampId, durationMilliseconds: durationMilliseconds, channelId: 2, quality: 0) - |> mapToSignal { result -> Signal in - switch result.status { - case let .data(data): - return .single(data) - case .notReady, .resyncNeeded, .rejoinNeeded: - return .single(nil) - } - } - } - } - |> deliverOnMainQueue).start(next: { [weak self] data in - guard let self, let data else { - return - } - let _ = self - let _ = data - })*/ }) let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) @@ -654,6 +121,24 @@ public final class MediaStreamComponent: CombinedComponent { } var updated = false +// TODO: remove debug timer +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + var shouldReplaceNoViewersWithOne: Bool { true } + let membersCount = members.totalCount // Int.random(in: 0..<10000000) // + strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(membersCount, 1) : membersCount) { [weak strongSelf] latestCount in + let _ = members.totalCount + guard let strongSelf = strongSelf else { return } + var updated = false + let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) + if strongSelf.originInfo != originInfo { + strongSelf.originInfo = originInfo + updated = true + } + if updated { + strongSelf.updated(transition: .immediate) + } + } +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -674,12 +159,6 @@ public final class MediaStreamComponent: CombinedComponent { updated = true } - let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) - if strongSelf.originInfo != originInfo { - strongSelf.originInfo = originInfo - updated = true - } - if updated { strongSelf.updated(transition: .immediate) } @@ -702,6 +181,8 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.deactivatePictureInPictureIfVisible.invoke(Void()) }) + } else { + // MARK: TODO: fullscreen ui toggle } } }) @@ -752,18 +233,46 @@ public final class MediaStreamComponent: CombinedComponent { return State(call: self.call) } - public static var body: Body { + class Local { let background = Child(Rectangle.self) + let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) - let navigationBar = Child(NavigationBarComponent.self) - let toolbar = Child(ToolbarComponent.self) + let sheet = Child(StreamSheetComponent.self) + let topItem = Child(environment: Empty.self) + let fullscreenBottomItem = Child(environment: Empty.self) + let buttonsRow = Child(environment: Empty.self) let activatePictureInPicture = StoredActionSlot(Action.self) let deactivatePictureInPicture = StoredActionSlot(Void.self) let moreButtonTag = GenericComponentViewTag() let moreAnimationTag = GenericComponentViewTag() + } + + public static var body: Body { + let local = Local() return { context in + _body(context, local) // { context in + } + } + + private static func _body(_ context: CombinedComponentContext, _ local: Local) -> CGSize { + let background = local.background + let dismissTapComponent = local.dismissTapComponent + let video = local.video + let sheet = local.sheet + let topItem = local.topItem + let fullscreenBottomItem = local.fullscreenBottomItem + let buttonsRow = local.buttonsRow + + let activatePictureInPicture = local.activatePictureInPicture + let deactivatePictureInPicture = local.deactivatePictureInPicture + let moreButtonTag = local.moreButtonTag + let moreAnimationTag = local.moreAnimationTag + + func makeBody() -> CGSize { + let canEnforceOrientation = UIDevice.current.model != "iPad" + var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { } else { @@ -771,7 +280,7 @@ public final class MediaStreamComponent: CombinedComponent { } let background = background.update( - component: Rectangle(color: .black), + component: Rectangle(color: .black.withAlphaComponent(0.0)), availableSize: context.availableSize, transition: context.transition ) @@ -781,15 +290,65 @@ public final class MediaStreamComponent: CombinedComponent { let controller = environment.controller context.state.deactivatePictureInPictureIfVisible.connect { - guard let controller = controller() else { - return - } - if controller.view.window == nil { + guard let controller = controller(), controller.view.window != nil else { return } + + state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } + let isFullscreen: Bool + let isLandscape = context.availableSize.width > context.availableSize.height + // Always fullscreen in landscape + if forceFullScreenInLandscape && isLandscape && !state.isFullscreen { + state.isFullscreen = true + isFullscreen = true + } else if !isLandscape && state.isFullscreen && canEnforceOrientation { + state.prevFullscreenOrientation = nil + state.isFullscreen = false + isFullscreen = false + } else { + isFullscreen = state.isFullscreen + } + + let videoInset: CGFloat + if !isFullscreen { + videoInset = 16.0 + } else { + videoInset = 0.0 + } + + let videoHeight: CGFloat = forceFullScreenInLandscape + ? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16.0 * 9.0 + let bottomPadding = 32.0 + environment.safeInsets.bottom + let requiredSheetHeight: CGFloat = isFullscreen + ? context.availableSize.height + : (44.0 + videoHeight + 40.0 + 69.0 + 16.0 + 32.0 + 70.0 + bottomPadding + 8.0) + + let safeAreaTopInView: CGFloat + if #available(iOS 16.0, *) { + safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + } else { + safeAreaTopInView = context.view.safeAreaInsets.top + } + + let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30.0 + + var dragOffset = context.state.dismissOffset + if isFullyDragged { + dragOffset = max(context.state.dismissOffset, requiredSheetHeight - context.availableSize.height + safeAreaTopInView) + } + + let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - requiredSheetHeight + dragOffset) + let dismissTapComponent = dismissTapComponent.update( + component: Rectangle(color: .red.withAlphaComponent(0)), + availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), + transition: context.transition + ) + // (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen + // (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden() let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -797,6 +356,9 @@ public final class MediaStreamComponent: CombinedComponent { isVisible: environment.isVisible && context.state.isVisibleInHierarchy, isAdmin: context.state.canManageCall, peerTitle: context.state.peerTitle, + isFullscreen: isFullscreen, + videoLoading: context.state.videoStalled, + callPeer: context.state.chatPeer, activatePictureInPicture: activatePictureInPicture, deactivatePictureInPicture: deactivatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in @@ -806,11 +368,21 @@ public final class MediaStreamComponent: CombinedComponent { } call.accountContext.sharedContext.mainWindow?.inCallNavigate?() - completed() }, pictureInPictureClosed: { [weak call] in let _ = call?.leave(terminateIfPossible: false) + }, + onVideoSizeRetrieved: { [weak state] size in + state?.videoSize = size + }, + onVideoPlaybackLiveChange: { [weak state] isLive in + guard let state else { return } + let wasLive = !state.videoStalled + if isLive != wasLive { + state.videoStalled = !isLive + state.updated() + } } ), availableSize: context.availableSize, @@ -818,31 +390,64 @@ public final class MediaStreamComponent: CombinedComponent { ) var navigationRightItems: [AnyComponentWithIdentity] = [] - if context.state.isPictureInPictureSupported, context.state.hasVideo { + + // let videoIsPlayable = context.state.videoIsPlayable + // if state.wantsPiP && state.hasVideo { + // state.wantsPiP = false + // DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // activatePictureInPicture.invoke(Action { + // guard let controller = controller() as? MediaStreamComponentController else { + // return + // } + // controller.dismiss(closing: false, manual: true) + // }) + // } + // } + + if context.state.isPictureInPictureSupported { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Media Gallery/PictureInPictureButton", - tintColor: .white - )), - action: { + content: AnyComponent(ZStack([ + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( + name: "Call/pip", + tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6) + ))) + ] + )), + action: { [weak state] in + guard let state, state.hasVideo else { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + // state?.wantsPiP = true + controller.dismiss(closing: false, manual: true) + return + } + activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { return } controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) } + var topLeftButton: AnyComponent? if context.state.canManageCall { let whiteColor = UIColor(white: 1.0, alpha: 1.0) - navigationRightItems.append(AnyComponentWithIdentity(id: "more", component: AnyComponent(Button( + topLeftButton = AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - strokeColor: .white, - strokeWidth: 1.5, - size: CGSize(width: 22.0, height: 22.0) + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) ))), AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( @@ -854,7 +459,7 @@ public final class MediaStreamComponent: CombinedComponent { "Point 3.Group 1.Fill 1": whiteColor, "Point 1.Group 1.Fill 1": whiteColor ], - size: CGSize(width: 22.0, height: 22.0) + size: CGSize(width: 32.0, height: 32.0) ).tagged(moreAnimationTag))), ])), action: { [weak call, weak state] in @@ -878,7 +483,7 @@ public final class MediaStreamComponent: CombinedComponent { items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak call, weak controller, weak state] _, a in + }, action: { [weak call, weak controller, weak state] _, dismissWithResult in guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else { return } @@ -886,10 +491,10 @@ public final class MediaStreamComponent: CombinedComponent { let initialTitle = state.callTitle ?? "" let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - + let title: String = presentationData.strings.LiveStream_EditTitle let text: String = presentationData.strings.LiveStream_EditTitleText - + let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in guard let call = call else { return @@ -899,21 +504,20 @@ public final class MediaStreamComponent: CombinedComponent { if let title = title, title != initialTitle { call.updateTitle(title) - + let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string - + let _ = text //strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) } }) controller.present(editController, in: .window(.root)) - a(.default) + dismissWithResult(.default) }))) if let recordingStartTimestamp = state.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, f in - f(.dismissWithoutContent) + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, dismissWithResult in guard let call = call, let controller = controller else { return @@ -948,6 +552,8 @@ public final class MediaStreamComponent: CombinedComponent { })*/ })]) controller.present(alertController, in: .window(.root)) + + dismissWithResult(.dismissWithoutContent) }), false)) } else { let text = presentationData.strings.LiveStream_StartRecording @@ -974,7 +580,6 @@ public final class MediaStreamComponent: CombinedComponent { return } - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } if let title = title { @@ -1007,14 +612,34 @@ public final class MediaStreamComponent: CombinedComponent { a(.default) }))) - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.VoiceChat_StopRecordingStop, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_StopLiveStream, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) }, action: { [weak call] _, a in guard let call = call else { return } - - let _ = call.leave(terminateIfPossible: true).start() + let alertController = textAlertController( + context: call.accountContext, + forceTheme: defaultDarkPresentationTheme, + title: presentationData.strings.LiveStream_EndConfirmationTitle, + text: presentationData.strings.LiveStream_EndConfirmationText, + actions: [ + TextAlertAction( + type: .genericAction, + title: presentationData.strings.Common_Cancel, + action: {} + ), + TextAlertAction( + type: .destructiveAction, + title: presentationData.strings.VoiceChat_EndConfirmationEnd, + action: { [weak call] in + guard let call = call else { + return + } + let _ = call.leave(terminateIfPossible: true).start() + }) + ]) + controller.present(alertController, in: .window(.root)) a(.default) }))) @@ -1066,30 +691,21 @@ public final class MediaStreamComponent: CombinedComponent { }*/ controller.presentInGlobalOverlay(contextController) } - ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)))) + ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)) } - let navigationBar = navigationBar.update( - component: NavigationBarComponent( - topInset: environment.statusBarHeight, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - ), - rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: environment.strings.VoiceChatChannel_Title, isRecording: state.recordingStartTimestamp != nil)) - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition + let navigationComponent = NavigationBarComponent( + topInset: environment.statusBarHeight, + sideInset: environment.safeInsets.left, + backgroundVisible: isFullscreen, + leftItem: topLeftButton, + rightItems: navigationRightItems, + centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isLive: context.state.videoIsPlayable)) ) - let isLandscape = context.availableSize.width > context.availableSize.height - if context.state.storedIsLandscape != isLandscape { - context.state.storedIsLandscape = isLandscape - if isLandscape { + if context.state.storedIsFullscreen != isFullscreen { + context.state.storedIsFullscreen = isFullscreen + if isFullscreen { context.state.scheduleDismissUI() } else { context.state.cancelScheduledDismissUI() @@ -1098,108 +714,326 @@ public final class MediaStreamComponent: CombinedComponent { var infoItem: AnyComponent? if let originInfo = context.state.originInfo { - let memberCountString: String - if originInfo.memberCount == 0 { - memberCountString = environment.strings.LiveStream_NoViewers - } else { - memberCountString = environment.strings.LiveStream_ViewerCount(Int32(originInfo.memberCount)) - } infoItem = AnyComponent(OriginInfoComponent( - title: state.callTitle ?? originInfo.title, - subtitle: memberCountString + memberCount: originInfo.memberCount )) } + let availableSize = context.availableSize + let safeAreaTop = safeAreaTopInView - let toolbar = toolbar.update( - component: ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return + let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in + guard let state = state else { + return + } + switch panState { + case .began: + state.initialOffset = state.dismissOffset + case let .updated(offset): + state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) + case let .ended(velocity): + if velocity.y > 200.0 { + if state.isFullscreen { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + state.dismissOffset = 0.0 + if canEnforceOrientation, let controller = controller() as? MediaStreamComponentController { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) } - controller.presentShare() - } - ).minSize(CGSize(width: 44.0, height: 44.0))), - rightItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isLandscape ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - if let controller = controller() as? MediaStreamComponentController { - controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + } else { + if isFullyDragged || state.initialOffset != 0 { + state.updateDismissOffset(value: 0.0, interactive: false) + } else { + if state.isPictureInPictureSupported { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + if state.hasVideo { + activatePictureInPicture.invoke(Action { + controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } + }) + } else { + // state.wantsPiP = true + controller.dismiss(closing: false, manual: true) + } + } else { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + } } } - ).minSize(CGSize(width: 44.0, height: 44.0))), - centerItem: infoItem - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition - ) + } else { + if isFullyDragged { + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + if velocity.y < -200 { + // Expand + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + state.updateDismissOffset(value: 0.0, interactive: false) + } + } + } + } + } - let height = context.availableSize.height context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) .gesture(.tap { [weak state] in - guard let state = state else { + guard let state = state, state.isFullscreen else { return } state.toggleDisplayUI() }) - .gesture(.pan { [weak state] panState in - guard let state = state else { + .gesture(.pan { panState in + onPanGesture(panState) + }) + ) + + context.add(dismissTapComponent + .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) + .gesture(.tap { + guard let controller = controller() as? MediaStreamComponentController else { return } - switch panState { - case .began: - break - case let .updated(offset): - state.updateDismissOffset(value: offset.y, interactive: true) - case let .ended(velocity): - if abs(velocity.y) > 200.0 { - if state.isPictureInPictureSupported { - activatePictureInPicture.invoke(Action { [weak state] in - guard let state = state, let controller = controller() as? MediaStreamComponentController else { - return - } - state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) - controller.dismiss(closing: false, manual: true) - }) - } else { - if let controller = controller() as? MediaStreamComponentController { - controller.dismiss(closing: false, manual: true) + controller.dismiss(closing: false, manual: true) + }) + .gesture(.pan(onPanGesture)) + ) + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let imageRenderScale = UIScreen.main.scale + let bottomComponent = AnyComponent(ButtonsRowComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor], + image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), + // TODO: localize: + title: presentationData.strings.VoiceChat_ShareShort)), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 65, height: 80))), + rightItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in + context.translateBy(x: size.width / 2, y: size.height / 2) + context.scaleBy(x: 0.4, y: 0.4) + context.translateBy(x: -size.width / 2, y: -size.height / 2) + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + let lineWidth: CGFloat = size.width / 7 + context.setLineWidth(lineWidth - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + }), + title: presentationData.strings.VoiceChat_Leave + )), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + centerItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44.0 * imageRenderScale), opaque: false, rotatedContext: { size, context in + + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + let lineSide = size.width / 5 + let centerOffset = size.width / 20 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) + context.strokePath() + + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) + context.strokePath() + }), + title: presentationData.strings.LiveStream_Expand + )), + action: { [weak state] in + guard let state = state else { return } + + if let controller = controller() as? MediaStreamComponentController { + state.isFullscreen.toggle() + if state.isFullscreen { + state.dismissOffset = 0.0 + let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation + switch currentOrientation { + case .landscapeLeft: + controller.updateOrientation(orientation: .landscapeRight) + case .landscapeRight: + controller.updateOrientation(orientation: .landscapeLeft) + default: + controller.updateOrientation(orientation: .landscapeRight) } + } else { + state.prevFullscreenOrientation = UIDevice.current.orientation + controller.updateOrientation(orientation: .portrait) + } + if !canEnforceOrientation { + state.updated(transition: .easeInOut(duration: 0.25)) } - } else { - state.updateDismissOffset(value: 0.0, interactive: false) } } + ).minSize(CGSize(width: 44.0, height: 44.0))) + )) + + let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight) + let topOffset: CGFloat = isFullscreen + ? max(context.state.dismissOffset, 0) + : (context.availableSize.height - requiredSheetHeight + dragOffset) + + let sheet = sheet.update( + component: StreamSheetComponent( + topOffset: topOffset, + sheetHeight: sheetHeight, + backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: bottomPadding, + participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998) // [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + isFullyExtended: isFullyDragged, + deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, + videoHeight: videoHeight, + isFullscreen: isFullscreen, + fullscreenTopComponent: AnyComponent(navigationComponent), + fullscreenBottomComponent: bottomComponent + ), + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + ) + + var availableWidth: CGFloat { context.availableSize.width } + var contentHeight: CGFloat { 44.0 } + + let topItem = topItem.update( + component: AnyComponent(navigationComponent), + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + rightItem: /*state.hasVideo ?*/ AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) + } + } + } + ).minSize(CGSize(width: 64.0, height: 80.0))), + centerItem: infoItem + )) + + let buttonsRow = buttonsRow.update( + component: bottomComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullscreenBottomItem = fullscreenBottomItem.update( + component: fullScreenToolbarComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let videoPos: CGFloat + + if isFullscreen { + videoPos = context.availableSize.height / 2 + dragOffset + } else { + videoPos = topOffset + 28.0 + 28.0 + videoHeight / 2 + } + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) + ) + + context.add(topItem + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0))) + .opacity((!isFullscreen || state.displayUI) ? 1.0 : 0.0) + .gesture(.pan { panState in + onPanGesture(panState) }) ) - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0 + context.state.dismissOffset)) + context.add(buttonsRow + .opacity(isFullscreen ? 0.0 : 1.0) + .position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50.0 / 2 + topOffset - bottomPadding)) ) - context.add(navigationBar - .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) + context.add(fullscreenBottomItem + .opacity((isFullscreen && state.displayUI) ? 1.0 : 0.0) + .position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0)) ) - - context.add(toolbar - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) - ) - return context.availableSize } + return makeBody() } + } public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { @@ -1243,26 +1077,23 @@ public final class MediaStreamComponentController: ViewControllerComponentContai view.expandFromPictureInPicture() } - if let validLayout = self.validLayout { self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } - self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in - self?.view.layer.cornerRadius = 0.0 + self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in }) - self.view.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in + + self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.view.layer.allowsGroupOpacity = false }) + if backgroundDimView.superview == nil { + guard let superview = view.superview else { return } + superview.insertSubview(backgroundDimView, belowSubview: view) + } } override public func viewDidDisappear(_ animated: Bool) { @@ -1271,20 +1102,37 @@ public final class MediaStreamComponentController: ViewControllerComponentContai DispatchQueue.main.async { self.onViewDidDisappear?() } - - if let initialOrientation = self.initialOrientation { - self.initialOrientation = nil - self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) - } + } + + override public func viewDidLoad() { + super.viewDidLoad() + // TODO: replace with actual color + backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3) + self.view.clipsToBounds = false + } + + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let dimViewSide: CGFloat = max(view.bounds.width, view.bounds.height) + backgroundDimView.frame = .init(x: view.bounds.midX - dimViewSide / 2, y: -view.bounds.height * 3, width: dimViewSide, height: view.bounds.height * 4) } public func dismiss(closing: Bool, manual: Bool) { self.dismiss(completion: nil) } + let backgroundDimView = UIView() + override public func dismiss(completion: (() -> Void)? = nil) { self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in + + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in guard let strongSelf = self else { completion?() return @@ -1292,18 +1140,6 @@ public final class MediaStreamComponentController: ViewControllerComponentContai strongSelf.view.layer.allowsGroupOpacity = false strongSelf.dismissImpl(completion: completion) }) - - if let validLayout = self.validLayout { - self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } - - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in - }) - self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } } private func dismissImpl(completion: (() -> Void)? = nil) { @@ -1430,3 +1266,847 @@ public final class MediaStreamComponentController: ViewControllerComponentContai }) } } + +// MARK: - Subcomponents + +private final class NavigationBarComponent: CombinedComponent { + let topInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItems: [AnyComponentWithIdentity] + let centerItem: AnyComponent? + let backgroundVisible: Bool + + init( + topInset: CGFloat, + sideInset: CGFloat, + backgroundVisible: Bool, + leftItem: AnyComponent?, + rightItems: [AnyComponentWithIdentity], + centerItem: AnyComponent? + ) { + self.topInset = 0 // topInset + self.sideInset = sideInset + self.backgroundVisible = backgroundVisible + + self.leftItem = leftItem + self.rightItems = rightItems + self.centerItem = centerItem + } + + static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { + if lhs.topInset != rhs.topInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItems != rhs.rightItems { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let background = Child(Rectangle.self) + let leftItem = Child(environment: Empty.self) + let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 16.0 + context.component.sideInset + + let contentHeight: CGFloat = 44.0 + let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) + + let background = background.update( + component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), + availableSize: CGSize(width: size.width, height: size.height), + transition: context.transition + ) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + var rightItemList: [_UpdatedChildComponent] = [] + for item in context.component.rightItems { + let item = rightItems[item.id].update( + component: item.component, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + rightItemList.append(item) + availableWidth -= item.size.width + } + + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: availableWidth - 44.0 - 44.0, height: contentHeight), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + .opacity(context.component.backgroundVisible ? 1 : 0) + ) + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var rightItemX = context.availableSize.width - sideInset + for item in rightItemList.reversed() { + context.add(item + .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) + ) + rightItemX -= item.size.width + 8.0 + } + + let accumulatedOffset: CGFloat = 16.0 + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: context.availableSize.width / 2 - accumulatedOffset, y: context.component.topInset + contentHeight / 2.0)) + ) + } + + return size + } + } +} + +private final class StreamTitleComponent: Component { + private final class LiveIndicatorView: UIView { + private let label = UILabel() + private let stalledAnimatedGradient = CAGradientLayer() + private var wasLive = false + + var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.addSubview(label) + + let liveString = NSAttributedString( + string: "LIVE", + attributes: [ + .font: Font.with(size: 11.0, design: .round, weight: .bold), + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }(), + .foregroundColor: UIColor.white, + .kern: -0.6 + ] + ) + self.label.attributedText = liveString + + self.layer.addSublayer(stalledAnimatedGradient) + self.clipsToBounds = true + self.toggle(isLive: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = bounds + stalledAnimatedGradient.frame = bounds + self.layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } + + func toggle(isLive: Bool) { + if isLive { + if !self.wasLive { + self.wasLive = true + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 1.12, 0.9, 1.0] + anim.keyTimes = [0, 0.5, 0.8, 1] + anim.duration = 0.4 + self.layer.add(anim, forKey: "transform") + + UIView.animate(withDuration: 0.15, animations: { + self.toggle(isLive: true) }) + return + } + self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1) + self.stalledAnimatedGradient.opacity = 0 + self.stalledAnimatedGradient.removeAllAnimations() + } else { + if wasLive { + wasLive = false + UIView.animate(withDuration: 0.3) { + self.toggle(isLive: false) + } + return + } + self.backgroundColor = UIColor(white: 0.36, alpha: 1) + stalledAnimatedGradient.opacity = 1 + } + wasLive = isLive + } + } + + private let text: String + private let isRecording: Bool + private let isLive: Bool + + init(text: String, isRecording: Bool, isLive: Bool) { + self.text = text + self.isRecording = isRecording + self.isLive = isLive + } + + static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + if lhs.isRecording != rhs.isRecording { + return false + } + if lhs.isLive != rhs.isLive { + return false + } + return false + } + + public final class View: UIView { + private var indicatorView: UIImageView? + private let liveIndicatorView = LiveIndicatorView() + private let titleLabel = UILabel() + private var titleFadeLayer = CALayer() + + private let trackingLayer: HierarchyTrackingLayer + + override init(frame: CGRect) { + self.trackingLayer = HierarchyTrackingLayer() + + super.init(frame: frame) + + self.addSubview(self.titleLabel) + self.addSubview(self.liveIndicatorView) + + self.trackingLayer.didEnterHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.updateIndicatorAnimation() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateIndicatorAnimation() { + guard let indicatorView = self.indicatorView else { + return + } + if indicatorView.layer.animation(forKey: "blink") == nil { + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber] + animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] + animation.duration = 0.7 + animation.autoreverses = true + animation.repeatCount = Float.infinity + indicatorView.layer.add(animation, forKey: "recording") + } + } + + func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth + let liveIndicatorHeight: CGFloat = 20.0 + + let currentText = self.titleLabel.text + if currentText != component.text { + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2) { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } else { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } + self.titleLabel.font = Font.semibold(17.0) + self.titleLabel.textColor = .white + self.titleLabel.numberOfLines = 1 + + let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) + + if component.isRecording { + if self.indicatorView == nil { + let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) + self.addSubview(indicatorView) + self.indicatorView = indicatorView + + self.updateIndicatorAnimation() + } + } else { + if let indicatorView = self.indicatorView { + self.indicatorView = nil + indicatorView.removeFromSuperview() + } + } + let sideInset: CGFloat = 20.0 + let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) + let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) + + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2, options: .transitionCrossDissolve) { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + } else { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight)) + self.liveIndicatorView.toggle(isLive: component.isLive) + + if let indicatorView = self.indicatorView, let image = indicatorView.image { + indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) + } + + return size + } + + private func updateTitleFadeLayer(constrainedTextFrame: CGRect) { + guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }), + textBounds.width > constrainedTextFrame.width + else { + titleLabel.layer.mask = nil + titleLabel.frame = constrainedTextFrame + self.titleLabel.textAlignment = .center + return + } + + var isRTL: Bool = false + if let string = titleLabel.attributedText { + let coreTextLine = CTLineCreateWithAttributedString(string) + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count > 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + } + + let gradientInset: CGFloat = 0.0 + let gradientRadius: CGFloat = 50.0 + let extraSpaceToFitTruncation: CGFloat = 100.0 + + let solidPartLayer = CALayer() + solidPartLayer.backgroundColor = UIColor.black.cgColor + + let availableWidth: CGFloat = constrainedTextFrame.width - gradientRadius + + if isRTL { + solidPartLayer.frame = CGRect( + origin: CGPoint(x: constrainedTextFrame.width + extraSpaceToFitTruncation - availableWidth, y: 0), + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + + self.titleLabel.textAlignment = .right + + titleLabel.frame = CGRect(x: constrainedTextFrame.minX - extraSpaceToFitTruncation, y: constrainedTextFrame.minY, width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height) + } else { + self.titleLabel.textAlignment = .left + + solidPartLayer.frame = CGRect( + origin: .zero, + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + titleLabel.frame = CGRect(origin: constrainedTextFrame.origin, size: CGSize(width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height)) + } + + titleFadeLayer = CALayer() + titleFadeLayer.addSublayer(solidPartLayer) + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor] + if isRTL { + gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } else { + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } + titleFadeLayer.addSublayer(gradientLayer) + titleFadeLayer.masksToBounds = false + + titleFadeLayer.frame = titleLabel.bounds + + titleLabel.layer.mask = titleFadeLayer + } + + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} + + +private final class OriginInfoComponent: CombinedComponent { + let participantsCount: Int + + init( + memberCount: Int + ) { + self.participantsCount = memberCount + } + + static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool { + if lhs.participantsCount != rhs.participantsCount { + return false + } + + return true + } + + static var body: Body { + let viewerCounter = Child(ParticipantsComponent.self) + + return { context in + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent( + count: context.component.participantsCount, + showsSubtitle: true, + fontSize: 18.0, + gradientColors: [UIColor.white.cgColor] + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: context.transition + ) + let heightReduction: CGFloat = 16.0 + var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction) + size.width = min(size.width, context.availableSize.width) + size.height = min(size.height, context.availableSize.height) + + context.add(viewerCounter + .position(CGPoint(x: size.width / 2.0, y: context.availableSize.height / 2.0 + 16.0 - heightReduction / 2)) + ) + + return size + } + } +} + +private final class ToolbarComponent: CombinedComponent { + let bottomInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItem: AnyComponent? + let centerItem: AnyComponent? + + init( + bottomInset: CGFloat, + sideInset: CGFloat, + leftItem: AnyComponent?, + rightItem: AnyComponent?, + centerItem: AnyComponent? + ) { + self.bottomInset = bottomInset + self.sideInset = sideInset + self.leftItem = leftItem + self.rightItem = rightItem + self.centerItem = centerItem + } + + static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool { + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItem != rhs.rightItem { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let background = Child(Rectangle.self) + let leftItem = Child(environment: Empty.self) + let rightItem = Child(environment: Empty.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 16.0 + context.component.sideInset + + let contentHeight: CGFloat = 44.0 + let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) + + let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + let rightItem = context.component.rightItem.flatMap { rightItemComponent in + return rightItem.update( + component: rightItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + if let rightItem = rightItem { + availableWidth -= rightItem.size.width + } + + let temporaryOffsetForSmallerSubtitle: CGFloat = 12 + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight - temporaryOffsetForSmallerSubtitle / 2), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + ) + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var centerRightInset = sideInset + if let rightItem = rightItem { + context.add(rightItem + .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerRightInset += rightItem.size.width + 4.0 + } + + let maxCenterInset = max(centerLeftInset, centerRightInset) + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0 - temporaryOffsetForSmallerSubtitle)) + ) + } + + return size + } + } +} + +private final class ButtonsRowComponent: CombinedComponent { + let bottomInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItem: AnyComponent? + let centerItem: AnyComponent? + + init( + bottomInset: CGFloat, + sideInset: CGFloat, + leftItem: AnyComponent?, + rightItem: AnyComponent?, + centerItem: AnyComponent? + ) { + self.bottomInset = bottomInset + self.sideInset = sideInset + self.leftItem = leftItem + self.rightItem = rightItem + self.centerItem = centerItem + } + + static func ==(lhs: ButtonsRowComponent, rhs: ButtonsRowComponent) -> Bool { + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItem != rhs.rightItem { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let leftItem = Child(environment: Empty.self) + let rightItem = Child(environment: Empty.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 48.0 + context.component.sideInset + + let contentHeight: CGFloat = 80.0 + let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + let rightItem = context.component.rightItem.flatMap { rightItemComponent in + return rightItem.update( + component: rightItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let rightItem = rightItem { + availableWidth -= rightItem.size.width + } + + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: 50.0, height: contentHeight), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var centerRightInset = sideInset + if let rightItem = rightItem { + context.add(rightItem + .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerRightInset += rightItem.size.width + 4.0 + } + + let maxCenterInset = max(centerLeftInset, centerRightInset) + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) + ) + } + + return size + } + } +} + +final class RoundGradientButtonComponent: Component { + init(gradientColors: [CGColor], icon: String? = nil, image: UIImage? = nil, title: String) { + self.gradientColors = gradientColors + self.icon = icon + self.image = image + self.title = title + } + + static func == (lhs: RoundGradientButtonComponent, rhs: RoundGradientButtonComponent) -> Bool { + if lhs.icon != rhs.icon { + return false + } + if lhs.gradientColors != rhs.gradientColors { + return false + } + return true + } + + let gradientColors: [CGColor] + let icon: String? + let image: UIImage? + let title: String + + final class View: UIView { + let gradientLayer = CAGradientLayer() + let iconView = UIImageView() + let titleLabel = UILabel() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + gradientLayer.type = .radial + gradientLayer.startPoint = .init(x: 1, y: 1) + gradientLayer.endPoint = .init(x: 0, y: 0) + + self.layer.addSublayer(gradientLayer) + self.addSubview(iconView) + self.clipsToBounds = false + + self.addSubview(titleLabel) + titleLabel.textAlignment = .center + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = .white + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + titleLabel.invalidateIntrinsicContentSize() + let heightForIcon = bounds.height - max(round(titleLabel.intrinsicContentSize.height), 12) - 8.0 + iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon) + gradientLayer.masksToBounds = true + gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2 + gradientLayer.frame = iconView.frame + titleLabel.frame = .init(x: 0, y: bounds.height - titleLabel.intrinsicContentSize.height, width: bounds.width, height: titleLabel.intrinsicContentSize.height) + } + } + + func makeView() -> View { + View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) } + let gradientColors: [CGColor] + if self.gradientColors.count == 1 { + gradientColors = [self.gradientColors[0], self.gradientColors[0]] + } else { + gradientColors = self.gradientColors + } + view.gradientLayer.colors = gradientColors + view.titleLabel.text = title + view.setNeedsLayout() + return availableSize + } +} + +public final class Throttler { + public var duration: TimeInterval = 0.25 + public var queue: DispatchQueue = .main + public var isEnabled: Bool { duration > 0 } + + private var isThrottling: Bool = false + private var lastValue: T? + private var accumulator = Set() + private var lastCompletedValue: T? + + public init(duration: TimeInterval = 0.25, queue: DispatchQueue = .main) { + self.duration = duration + self.queue = queue + } + + public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) { + queue.async { [self] in + accumulator.insert(value) + + if !isThrottling { + isThrottling = true + lastValue = nil + completion?(value) + self.lastCompletedValue = value + } else { + lastValue = value + } + + if lastValue == nil { + queue.asyncAfter(deadline: .now() + duration) { [self] in + accumulator.removeAll() + // TODO: quick fix, replace with timer + queue.asyncAfter(deadline: .now() + duration) { [self] in + isThrottling = false + } + + guard + let lastValue = lastValue, + lastCompletedValue != lastValue || includingLatest + else { return } + + accumulator.insert(lastValue) + self.lastValue = nil + completion?(lastValue) + lastCompletedValue = lastValue + } + } + } + } + + public func cancelCurrent() { + lastValue = nil + isThrottling = false + accumulator.removeAll() + } + + public func canEmit(_ value: T) -> Bool { + !accumulator.contains(value) + } +} + +public extension Throttler where T == Bool { + func throttle(includingLatest: Bool = false, _ completion: ((T) -> Void)?) { + publish(true, includingLatest: includingLatest, using: completion) + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 951ffad732..4bda7c0fca 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -1,11 +1,16 @@ import Foundation import UIKit import ComponentFlow -import ActivityIndicatorComponent import AccountContext import AVKit import MultilineTextComponent import Display +import ShimmerEffect + +import TelegramCore +import SwiftSignalKit +import AvatarNode +import Postbox final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl @@ -17,6 +22,11 @@ final class MediaStreamVideoComponent: Component { let deactivatePictureInPicture: ActionSlot let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void let pictureInPictureClosed: () -> Void + let isFullscreen: Bool + let onVideoSizeRetrieved: (CGSize) -> Void + let videoLoading: Bool + let callPeer: Peer? + let onVideoPlaybackLiveChange: (Bool) -> Void init( call: PresentationGroupCallImpl, @@ -24,20 +34,31 @@ final class MediaStreamVideoComponent: Component { isVisible: Bool, isAdmin: Bool, peerTitle: String, + isFullscreen: Bool, + videoLoading: Bool, + callPeer: Peer?, activatePictureInPicture: ActionSlot>, deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, - pictureInPictureClosed: @escaping () -> Void + pictureInPictureClosed: @escaping () -> Void, + onVideoSizeRetrieved: @escaping (CGSize) -> Void, + onVideoPlaybackLiveChange: @escaping (Bool) -> Void ) { self.call = call self.hasVideo = hasVideo self.isVisible = isVisible self.isAdmin = isAdmin self.peerTitle = peerTitle + self.videoLoading = videoLoading self.activatePictureInPicture = activatePictureInPicture self.deactivatePictureInPicture = deactivatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.pictureInPictureClosed = pictureInPictureClosed + self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange + + self.callPeer = callPeer + self.isFullscreen = isFullscreen + self.onVideoSizeRetrieved = onVideoSizeRetrieved } public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool { @@ -56,7 +77,12 @@ final class MediaStreamVideoComponent: Component { if lhs.peerTitle != rhs.peerTitle { return false } - + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + if lhs.videoLoading != rhs.videoLoading { + return false + } return true } @@ -70,7 +96,7 @@ final class MediaStreamVideoComponent: Component { return State() } - public final class View: UIScrollView, AVPictureInPictureControllerDelegate, ComponentTaggedView { + public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView { public final class Tag { } @@ -78,9 +104,11 @@ final class MediaStreamVideoComponent: Component { private let blurTintView: UIView private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? - private var activityIndicatorView: ComponentHostView? - private var noSignalView: ComponentHostView? + private var videoPlaceholderView: UIView? + private var noSignalView: ComponentHostView? + private let loadingBlurView = CustomIntensityVisualEffectView(effect: UIBlurEffect(style: .light), intensity: 0.4) + private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? private var component: MediaStreamVideoComponent? @@ -88,15 +116,50 @@ final class MediaStreamVideoComponent: Component { private var requestedExpansion: Bool = false - private var noSignalTimer: Timer? + private var noSignalTimer: Foundation.Timer? private var noSignalTimeout: Bool = false + private let videoBlurGradientMask = CAGradientLayer() + private let videoBlurSolidMask = CALayer() + + private var wasVisible = true + private var borderShimmer = StandaloneShimmerEffect() + private let shimmerBorderLayer = CALayer() + private let placeholderView = UIImageView() + + private var videoStalled = false { + didSet { + if videoStalled != oldValue { + self.updateVideoStalled(isStalled: self.videoStalled, transition: nil) +// state?.updated() + } + } + } + var onVideoPlaybackChange: ((Bool) -> Void) = { _ in } + + private var frameInputDisposable: Disposable? + + private var stallTimer: Foundation.Timer? + private let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + + private var avatarDisposable: Disposable? + private var didBeginLoadingAvatar = false + private var timeLastFrameReceived: CFAbsoluteTime? + + private var isFullscreen: Bool = false + private let videoLoadingThrottler = Throttler(duration: 1, queue: .main) + private var wasFullscreen: Bool = false + private var isAnimating = false + private var didRequestBringBack = false + private weak var state: State? + private var lastPresentation: UIView? + private var pipTrackDisplayLink: CADisplayLink? + override init(frame: CGRect) { self.blurTintView = UIView() self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) - super.init(frame: frame) self.isUserInteractionEnabled = false @@ -109,6 +172,13 @@ final class MediaStreamVideoComponent: Component { fatalError("init(coder:) has not been implemented") } + deinit { + avatarDisposable?.dispose() + frameInputDisposable?.dispose() + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + public func matches(tag: Any) -> Bool { if let _ = tag as? Tag { return true @@ -123,60 +193,224 @@ final class MediaStreamVideoComponent: Component { } } + private func updateVideoStalled(isStalled: Bool, transition: Transition?) { + if isStalled { + guard let component = self.component else { return } + + if let frameView = lastFrame[component.call.peerId.id.description] { + frameView.removeFromSuperview() + placeholderView.subviews.forEach { $0.removeFromSuperview() } + placeholderView.addSubview(frameView) + frameView.frame = placeholderView.bounds + } + + if !hadVideo && placeholderView.superview == nil { + addSubview(placeholderView) + } + + let needsFadeInAnimation = hadVideo + + if loadingBlurView.superview == nil { + addSubview(loadingBlurView) + if needsFadeInAnimation { + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 0 + anim.toValue = 1 + loadingBlurView.layer.opacity = 1 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + loadingBlurView.layer.add(anim, forKey: "opacity") + } + } + loadingBlurView.layer.zPosition = 998 + self.noSignalView?.layer.zPosition = loadingBlurView.layer.zPosition + 1 + if shimmerBorderLayer.superlayer == nil { + loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) + } + loadingBlurView.clipsToBounds = true + + let cornerRadius = loadingBlurView.layer.cornerRadius + shimmerBorderLayer.cornerRadius = cornerRadius + shimmerBorderLayer.masksToBounds = true + shimmerBorderLayer.compositingFilter = "softLightBlendMode" + + let borderMask = CAShapeLayer() + + shimmerBorderLayer.mask = borderMask + + if let transition, shimmerBorderLayer.mask != nil { + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = initialPath + transition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) + + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + transition.setShapeLayerPath(layer: borderMask, path: borderMaskPath) + } else { + shimmerBorderLayer.frame = loadingBlurView.bounds + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = borderMaskPath + } + + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + borderMask.compositingFilter = "softLightBlendMode" + + borderShimmer = StandaloneShimmerEffect() + borderShimmer.layer = shimmerBorderLayer + borderShimmer.updateHorizontal(background: .clear, foreground: .white) + loadingBlurView.alpha = 1 + } else { + if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 { + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.4 + anim.fromValue = 1.0 + anim.toValue = 0.0 + self.loadingBlurView.layer.opacity = 0 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + isAnimating = true + anim.completion = { [weak self] _ in + guard self?.videoStalled == false else { return } + self?.loadingBlurView.removeFromSuperview() + self?.placeholderView.removeFromSuperview() + self?.isAnimating = false + } + loadingBlurView.layer.add(anim, forKey: "opacity") + } + } + } + func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state + self.component = component + self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange + self.isFullscreen = component.isFullscreen + + if let peer = component.callPeer, !didBeginLoadingAvatar { + didBeginLoadingAvatar = true + + avatarDisposable = peerAvatarCompleteImage(account: component.call.account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true).start(next: { [weak self] image in + DispatchQueue.main.async { + self?.placeholderView.contentMode = .scaleAspectFill + self?.placeholderView.image = image + } + }) + } + + if !component.hasVideo || component.videoLoading || self.videoStalled { + updateVideoStalled(isStalled: true, transition: transition) + } else { + updateVideoStalled(isStalled: false, transition: transition) + } if component.hasVideo, self.videoView == nil { if let input = component.call.video(endpointId: "unified") { + var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in + guard let strongSelf = self else { return timer.invalidate() } + + let currentTime = CFAbsoluteTimeGetCurrent() + if let lastFrameTime = strongSelf.timeLastFrameReceived, + currentTime - lastFrameTime > 0.5 { + strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) + } + } + } } + + // TODO: use mapToThrottled (?) + frameInputDisposable = input.start(next: { [weak self] input in + guard let strongSelf = self else { return } + + strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent() + strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) + } + }) + stallTimer = _stallTimer + self.clipsToBounds = component.isFullscreen // or just true if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) + videoBlurView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoBlurView.alpha = 1 + } + self.videoBlurGradientMask.type = .radial + self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5) + self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0) + + self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor + self.videoBlurGradientMask.addSublayer(videoBlurSolidMask) + } - if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) - + videoView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoView.alpha = 1 + } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { + sampleBufferVideoView.sampleBufferLayer.masksToBounds = true + if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } - if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { - final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { - - } - - func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { - return CMTimeRange(start: .zero, duration: .positiveInfinity) - } - - func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { - completionHandler() - } - - public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } + final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { + var onTransitionFinished: (() -> Void)? + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { + } - let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + return CMTimeRange(start: .zero, duration: .positiveInfinity) + } - pictureInPictureController.delegate = self - pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true - pictureInPictureController.requiresLinearPlayback = true + func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } - self.pictureInPictureController = pictureInPictureController + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + onTransitionFinished?() + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { + completionHandler() + } + + public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } } + var pictureInPictureController: AVPictureInPictureController? = nil + if #available(iOS 15.0, *) { + pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: { + let delegate = PlaybackDelegateImpl() + delegate.onTransitionFinished = { + } + return delegate + }())) + pictureInPictureController?.playerLayer.masksToBounds = false + pictureInPictureController?.playerLayer.cornerRadius = 10 + } else if AVPictureInPictureController.isPictureInPictureSupported() { + pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer())) + } + + pictureInPictureController?.delegate = self + if #available(iOS 14.2, *) { + pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true + } + if #available(iOS 14.0, *) { + pictureInPictureController?.requiresLinearPlayback = true + } + self.pictureInPictureController = pictureInPictureController } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -189,26 +423,86 @@ final class MediaStreamVideoComponent: Component { strongSelf.hadVideo = true - strongSelf.activityIndicatorView?.removeFromSuperview() - strongSelf.activityIndicatorView = nil - strongSelf.noSignalTimer?.invalidate() strongSelf.noSignalTimer = nil strongSelf.noSignalTimeout = false strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView = nil - //strongSelf.translatesAutoresizingMaskIntoConstraints = false - //strongSelf.maximumZoomScale = 4.0 - state?.updated(transition: .immediate) } } } + } else if component.isFullscreen { + if fullScreenBackgroundPlaceholder.superview == nil { + insertSubview(fullScreenBackgroundPlaceholder, at: 0) + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 1) + } + fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5) + } else { + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 0, completion: { didComplete in + if didComplete { + self.fullScreenBackgroundPlaceholder.removeFromSuperview() + } + }) + } + fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) + + let videoInset: CGFloat + if !component.isFullscreen { + videoInset = 16 + } else { + videoInset = 0 + } + + let videoSize: CGSize + let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10 + + let videoFrameUpdateTransition: Transition + if self.wasFullscreen != component.isFullscreen { + videoFrameUpdateTransition = transition + } else { + videoFrameUpdateTransition = transition.withAnimation(.none) } if let videoView = self.videoView { + if videoView.bounds.size.width > 0, + videoView.alpha > 0, + self.hadVideo, + let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { + lastFrame[component.call.peerId.id.description] = snapshot + } + + var aspect = videoView.getAspect() + if component.isFullscreen && self.hadVideo { + if aspect <= 0.01 { + aspect = 16.0 / 9 + } + } else if !self.hadVideo { + aspect = 16.0 / 9 + } + + if component.isFullscreen { + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + } else { + // Limiting by smallest side -- redundant if passing precalculated availableSize + let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2 + let availableVideoHeight = availableVideoWidth * 9.0 / 16 + + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight)) + } + let blurredVideoSize = component.isFullscreen ? availableSize : videoSize.aspectFilled(availableSize) + + component.onVideoSizeRetrieved(videoSize) + var isVideoVisible = component.isVisible + + if !wasVisible && component.isVisible { + videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2) + } else if wasVisible && !component.isVisible { + videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2) + } + if let pictureInPictureController = self.pictureInPictureController { if pictureInPictureController.isPictureInPictureActive { isVideoVisible = true @@ -216,44 +510,81 @@ final class MediaStreamVideoComponent: Component { } videoView.updateIsEnabled(isVideoVisible) + videoView.clipsToBounds = true + videoView.layer.cornerRadius = videoCornerRadius - var aspect = videoView.getAspect() - if aspect <= 0.01 { - aspect = 3.0 / 4.0 - } + self.wasFullscreen = component.isFullscreen + let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(availableSize) - let blurredVideoSize = videoSize.aspectFilled(availableSize) - - transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) + videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil) if let videoBlurView = self.videoBlurView { - videoBlurView.updateIsEnabled(component.isVisible) - transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) + videoBlurView.updateIsEnabled(component.isVisible) + if component.isFullscreen { + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: CGRect( + origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), + size: blurredVideoSize + ), completion: nil) + } else { + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -70.0 * aspect, dy: -70.0)) + } + + videoBlurView.layer.mask = videoBlurGradientMask + + if !component.isFullscreen { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 0) + } else { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 1) + } + + videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds) + videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds) } + } else { + videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } - if !self.hadVideo { - var activityIndicatorTransition = transition - let activityIndicatorView: ComponentHostView - if let current = self.activityIndicatorView { - activityIndicatorView = current - } else { - activityIndicatorTransition = transition.withAnimation(.none) - activityIndicatorView = ComponentHostView() - self.activityIndicatorView = activityIndicatorView - self.addSubview(activityIndicatorView) + let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) + + if loadingBlurView.frame == .zero { + loadingBlurView.frame = loadingBlurViewFrame + } else { + // Using Transition.setFrame on UIVisualEffectView causes instant update of sublayers + switch videoFrameUpdateTransition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [self] in + loadingBlurView.frame = loadingBlurViewFrame + }) + + default: + loadingBlurView.frame = loadingBlurViewFrame } - - let activityIndicatorSize = activityIndicatorView.update( - transition: transition, - component: AnyComponent(ActivityIndicatorComponent(color: .white)), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - let activityIndicatorFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - activityIndicatorSize.width) / 2.0), y: floor((availableSize.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize) - activityIndicatorTransition.setFrame(view: activityIndicatorView, frame: activityIndicatorFrame, completion: nil) + } + videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius) + videoFrameUpdateTransition.setFrame(view: placeholderView, frame: loadingBlurViewFrame) + videoFrameUpdateTransition.setCornerRadius(layer: placeholderView.layer, cornerRadius: videoCornerRadius) + placeholderView.clipsToBounds = true + placeholderView.subviews.forEach { + videoFrameUpdateTransition.setFrame(view: $0, frame: placeholderView.bounds) + } + + let initialShimmerBounds = shimmerBorderLayer.bounds + videoFrameUpdateTransition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) + + let borderMask = CAShapeLayer() + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + borderMask.path = initialPath + + videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)) + + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + shimmerBorderLayer.mask = borderMask + shimmerBorderLayer.cornerRadius = videoCornerRadius + + if !self.hadVideo { if self.noSignalTimer == nil { if #available(iOS 10.0, *) { @@ -278,7 +609,10 @@ final class MediaStreamVideoComponent: Component { noSignalTransition = transition.withAnimation(.none) noSignalView = ComponentHostView() self.noSignalView = noSignalView + self.addSubview(noSignalView) + noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1 + noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } @@ -293,7 +627,7 @@ final class MediaStreamVideoComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0) ) - noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: activityIndicatorFrame.maxY + 24.0), size: noSignalSize), completion: nil) + noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0), size: noSignalSize), completion: nil) } } @@ -320,30 +654,84 @@ final class MediaStreamVideoComponent: Component { return availableSize } + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) { + let presentationParent = self.window ?? self + presentationParent.addSubview(presentation) + presentation.frame = presentationParent.convert(videoView.frame, from: self) + + if let callId = self.component?.call.peerId.id.description { + lastFrame[callId] = presentation + } + + videoView.alpha = 0 + lastPresentation?.removeFromSuperview() + lastPresentation = presentation + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.lastPresentation?.removeFromSuperview() + self.lastPresentation = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + } + UIView.animate(withDuration: 0.1) { [self] in + videoBlurView?.alpha = 0 + } + // TODO: assure player window + UIApplication.shared.windows.first?.layer.cornerRadius = 10.0 + UIApplication.shared.windows.first?.layer.masksToBounds = true + + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = CADisplayLink(target: self, selector: #selector(observePiPWindow)) + self.pipTrackDisplayLink?.add(to: .main, forMode: .default) + } + + @objc func observePiPWindow() { + let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0 + if pipViewDidBecomeVisible { + lastPresentation?.removeFromSuperview() + lastPresentation = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil + } + } + public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { guard let component = self.component else { completionHandler(false) return } - + didRequestBringBack = true component.bringBackControllerForPictureInPictureDeactivation { completionHandler(true) } } func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.didRequestBringBack = false self.state?.updated(transition: .immediate) } func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { if self.requestedExpansion { self.requestedExpansion = false - } else { + } else if !didRequestBringBack { self.component?.pictureInPictureClosed() } + didRequestBringBack = false + // TODO: extract precise animation timing or observe window changes + // Handle minimized case separatelly (can we detect minimized?) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.videoView?.alpha = 1 + } + UIView.animate(withDuration: 0.3) { [self] in + self.videoBlurView?.alpha = 1 + } } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.videoView?.alpha = 1 self.state?.updated(transition: .immediate) } } @@ -356,3 +744,27 @@ final class MediaStreamVideoComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, transition: transition) } } + +// TODO: move to appropriate place +fileprivate var lastFrame: [String: UIView] = [:] + +private final class CustomIntensityVisualEffectView: UIVisualEffectView { + private var animator: UIViewPropertyAnimator! + + init(effect: UIVisualEffect, intensity: CGFloat) { + super.init(effect: nil) + animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [weak self] in self?.effect = effect } + animator.startAnimation() + animator.pauseAnimation() + animator.fractionComplete = intensity + animator.pausesOnCompletion = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + deinit { + animator.stopAnimation(true) + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift new file mode 100644 index 0000000000..a2aefbe5cb --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift @@ -0,0 +1,78 @@ +import Foundation +import Display +import UIKit +import ComponentFlow +import TelegramPresentationData +import TelegramStringFormatting + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xe4436c) + +final class ParticipantsComponent: Component { + private let count: Int + private let showsSubtitle: Bool + private let fontSize: CGFloat + private let gradientColors: [CGColor] + + init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { + self.count = count + self.showsSubtitle = showsSubtitle + self.fontSize = fontSize + self.gradientColors = gradientColors + } + + static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { + if lhs.count != rhs.count { + return false + } + if lhs.showsSubtitle != rhs.showsSubtitle { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + return true + } + + func makeView() -> View { + View(frame: .zero) + } + + func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { + view.counter.update( + countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", + // TODO: localize + subtitle: self.showsSubtitle ? (self.count > 0 ? /*environment.strings.LiveStream_Watching*/"watching" : /*environment.strings.LiveStream_NoViewers.lowercased()*/"no viewers") : "", + fontSize: self.fontSize, + gradientColors: self.gradientColors + ) + switch transition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames(transition: transition) + }) + + default: + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames() + } + return availableSize + } + + final class View: UIView { + let counter = AnimatedCountView() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(counter) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift new file mode 100644 index 0000000000..c9735c3967 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -0,0 +1,262 @@ +import Foundation +import UIKit +import ComponentFlow +import ActivityIndicatorComponent +import AccountContext +import AVKit +import MultilineTextComponent +import Display + +final class StreamSheetComponent: CombinedComponent { + let sheetHeight: CGFloat + let topOffset: CGFloat + let backgroundColor: UIColor + let participantsCount: Int + let bottomPadding: CGFloat + let isFullyExtended: Bool + let deviceCornerRadius: CGFloat + let videoHeight: CGFloat + + let isFullscreen: Bool + let fullscreenTopComponent: AnyComponent + let fullscreenBottomComponent: AnyComponent + + init( + topOffset: CGFloat, + sheetHeight: CGFloat, + backgroundColor: UIColor, + bottomPadding: CGFloat, + participantsCount: Int, + isFullyExtended: Bool, + deviceCornerRadius: CGFloat, + videoHeight: CGFloat, + isFullscreen: Bool, + fullscreenTopComponent: AnyComponent, + fullscreenBottomComponent: AnyComponent + ) { + self.topOffset = topOffset + self.sheetHeight = sheetHeight + self.backgroundColor = backgroundColor + self.bottomPadding = bottomPadding + self.participantsCount = participantsCount + self.isFullyExtended = isFullyExtended + self.deviceCornerRadius = deviceCornerRadius + self.videoHeight = videoHeight + + self.isFullscreen = isFullscreen + self.fullscreenTopComponent = fullscreenTopComponent + self.fullscreenBottomComponent = fullscreenBottomComponent + } + + static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { + if lhs.topOffset != rhs.topOffset { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.sheetHeight != rhs.sheetHeight { + return false + } + if !lhs.backgroundColor.isEqual(rhs.backgroundColor) { + return false + } + if lhs.bottomPadding != rhs.bottomPadding { + return false + } + if lhs.participantsCount != rhs.participantsCount { + return false + } + if lhs.isFullyExtended != rhs.isFullyExtended { + return false + } + if lhs.videoHeight != rhs.videoHeight { + return false + } + + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + + if lhs.fullscreenTopComponent != rhs.fullscreenTopComponent { + return false + } + + if lhs.fullscreenBottomComponent != rhs.fullscreenBottomComponent { + return false + } + + return true + } + + final class View: UIView { + var overlayComponentsFrames = [CGRect]() + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subframe in overlayComponentsFrames { + if subframe.contains(point) { return true } + } + return false + } + + func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { + return availableSize + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + // Debug interactive area +// guard let context = UIGraphicsGetCurrentContext() else { return } +// context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor) +// overlayComponentsFrames.forEach { frame in +// context.addRect(frame) +// context.fillPath() +// } + } + } + + func makeView() -> View { + View() + } + + public final class State: ComponentState { + override init() { + super.init() + } + } + + public func makeState() -> State { + return State() + } + + private weak var state: State? + + static var body: Body { + let background = Child(SheetBackgroundComponent.self) + let viewerCounter = Child(ParticipantsComponent.self) + + return { context in + let size = context.availableSize + + let topOffset = context.component.topOffset + let backgroundExtraOffset: CGFloat + if #available(iOS 16.0, *) { + // In iOS 16 context.view does not inherit safeAreaInsets, quick fix: + let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0 + } else { + backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 + } + + let background = background.update( + component: SheetBackgroundComponent( + color: context.component.backgroundColor, + radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 10.0, + offset: backgroundExtraOffset + ), + availableSize: CGSize(width: size.width, height: context.component.sheetHeight), + transition: context.transition + ) + + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent(count: context.component.participantsCount, fontSize: 44.0), + availableSize: CGSize(width: context.availableSize.width, height: 70), + transition: context.transition + ) + + let isFullscreen = context.component.isFullscreen + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) + ) + + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] + context.view.backgroundColor = .clear + + let videoHeight = context.component.videoHeight + let sheetHeight = context.component.sheetHeight + let animatedParticipantsVisible = !isFullscreen + + context.add(viewerCounter + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0)) + .opacity(animatedParticipantsVisible ? 1 : 0) + ) + + return size + } + } +} + +final class SheetBackgroundComponent: Component { + private let color: UIColor + private let radius: CGFloat + private let offset: CGFloat + + class View: UIView { + private let backgroundView = UIView() + + func update(availableSize: CGSize, color: UIColor, cornerRadius: CGFloat, offset: CGFloat, transition: Transition) { + if backgroundView.superview == nil { + self.addSubview(backgroundView) + } + + let extraBottomForReleaseAnimation: CGFloat = 500 + + if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { + if transition.animation.isImmediate { + UIView.animate(withDuration: 0.4) { [self] in + backgroundView.backgroundColor = color + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) + } + + let anim = CABasicAnimation(keyPath: "cornerRadius") + anim.fromValue = backgroundView.layer.cornerRadius + backgroundView.layer.cornerRadius = cornerRadius + anim.toValue = cornerRadius + anim.duration = 0.4 + backgroundView.layer.add(anim, forKey: "cornerRadius") + } else { + transition.setBackgroundColor(view: backgroundView, color: color) + transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))) + transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius) + } + } else { + backgroundView.backgroundColor = color + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) + backgroundView.layer.cornerRadius = cornerRadius + } + backgroundView.isUserInteractionEnabled = false + backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + backgroundView.clipsToBounds = true + backgroundView.layer.masksToBounds = true + } + } + + func makeView() -> View { + View() + } + + static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool { + if !lhs.color.isEqual(rhs.color) { + return false + } + if lhs.radius != rhs.radius { + return false + } + if lhs.offset != rhs.offset { + return false + } + return true + } + + public init(color: UIColor, radius: CGFloat, offset: CGFloat) { + self.color = color + self.radius = radius + self.offset = offset + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition) + return availableSize + } +} diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift index b6020e48de..00655e1e92 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift @@ -36,12 +36,12 @@ import DeviceAccess let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) let fullscreenBackgroundColor = UIColor(rgb: 0x000000) -private let smallButtonSize = CGSize(width: 36.0, height: 36.0) -private let sideButtonSize = CGSize(width: 56.0, height: 56.0) -private let topPanelHeight: CGFloat = 63.0 +let smallButtonSize = CGSize(width: 36.0, height: 36.0) +let sideButtonSize = CGSize(width: 56.0, height: 56.0) +let topPanelHeight: CGFloat = 63.0 let bottomAreaHeight: CGFloat = 206.0 -private let fullscreenBottomAreaHeight: CGFloat = 80.0 -private let bottomGradientHeight: CGFloat = 70.0 +let fullscreenBottomAreaHeight: CGFloat = 80.0 +let bottomGradientHeight: CGFloat = 70.0 func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { if !top && !bottom { diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json new file mode 100644 index 0000000000..940a6d1b19 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "close.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png new file mode 100644 index 0000000000..76eb185ef2 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png new file mode 100644 index 0000000000..b05e22edd7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png new file mode 100644 index 0000000000..8aee41f11e Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json new file mode 100644 index 0000000000..8a5c7bd6df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "expand.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "expand@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "expand@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png new file mode 100644 index 0000000000..e29cf1f2f7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png new file mode 100644 index 0000000000..c310341294 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png new file mode 100644 index 0000000000..34504618f6 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json new file mode 100644 index 0000000000..8176b1f584 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "more.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "more@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "more@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png new file mode 100644 index 0000000000..514d9875e7 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png new file mode 100644 index 0000000000..7f9d98b485 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png new file mode 100644 index 0000000000..7d4d62533d Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json new file mode 100644 index 0000000000..fde2fc9c91 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png new file mode 100644 index 0000000000..bbb43de088 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png new file mode 100644 index 0000000000..adc3d2036d Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png new file mode 100644 index 0000000000..e58514672f Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json new file mode 100644 index 0000000000..e1308a36f9 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "share.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png new file mode 100644 index 0000000000..1301ea0768 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png new file mode 100644 index 0000000000..004e1d87c6 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png differ diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png new file mode 100644 index 0000000000..5d1a2c011a Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png differ