diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Balloon.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Balloon.tgs deleted file mode 100644 index 72cd87b655..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Balloon.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Fireworks.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Fireworks.tgs deleted file mode 100644 index 193ab6be08..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Fireworks.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Hearts.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Hearts.tgs deleted file mode 100644 index 14940c5f5c..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Hearts.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Joy.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Joy.tgs deleted file mode 100644 index 8afa88c3f1..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Joy.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Money.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Money.tgs deleted file mode 100644 index bc6f9b9f01..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Money.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Party.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Party.tgs deleted file mode 100644 index e84afd8a43..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Party.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/Poo.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/Poo.tgs deleted file mode 100644 index b9d8d04ac8..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/Poo.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsDown.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsDown.tgs deleted file mode 100644 index a37e3e6598..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsDown.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsUp1.tgs b/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsUp1.tgs deleted file mode 100644 index e593b1c182..0000000000 Binary files a/Telegram/Telegram-iOS/Resources/DemoAnimations/SuperThumbsUp1.tgs and /dev/null differ diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 8134bd8295..8e129fba47 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -7343,8 +7343,20 @@ Sorry for the inconvenience."; "LiveStream.ViewerCount_1" = "1 viewer"; "LiveStream.ViewerCount_any" = "%@ viewers"; +"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."; + "Attachment.MyAlbums" = "My Albums"; "Attachment.MediaTypes" = "Media Types"; "Attachment.LocationAccessTitle" = "Access Your Location"; "Attachment.LocationAccessText" = "Share places or your live location."; + +"ChannelInfo.CreateExternalStream" = "Stream With..."; + +"CreateExternalStream.Title" = "Stream With..."; +"CreateExternalStream.Text" = "To stream video with a another app, enter\nthese Server URL and Stream Key in your\nsteaming app."; +"CreateExternalStream.ServerUrl" = "server URL"; +"CreateExternalStream.StreamKey" = "stream key"; +"CreateExternalStream.StartStreamingInfo" = "Once you start broadcasting in your streaming\napp, tap Start Streaming below."; +"CreateExternalStream.StartStreaming" = "Start Streaming"; diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 23e2ad5764..07c1b1339b 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -1664,7 +1664,6 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData) var items: [ActionSheetItem] = [] - //TODO:localize items.append(ActionSheetAnimationAndTextItem(title: strongSelf.presentationData.strings.DownloadList_ClearAlertTitle, text: strongSelf.presentationData.strings.DownloadList_ClearAlertText)) items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.DownloadList_OptionManageDeviceStorage, color: .accent, action: { [weak actionSheet] in diff --git a/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift new file mode 100644 index 0000000000..0d0a73060d --- /dev/null +++ b/submodules/ComponentFlow/Source/Components/RoundedRectangle.swift @@ -0,0 +1,49 @@ +import Foundation +import UIKit + +public final class RoundedRectangle: Component { + public let color: UIColor + public let cornerRadius: CGFloat + + public init(color: UIColor, cornerRadius: CGFloat) { + self.color = color + self.cornerRadius = cornerRadius + } + + public static func ==(lhs: RoundedRectangle, rhs: RoundedRectangle) -> Bool { + if !lhs.color.isEqual(rhs.color) { + return false + } + if lhs.cornerRadius != rhs.cornerRadius { + return false + } + return true + } + + public final class View: UIImageView { + var component: RoundedRectangle? + + func update(component: RoundedRectangle, availableSize: CGSize, transition: Transition) -> CGSize { + if self.component != component { + let imageSize = CGSize(width: component.cornerRadius * 2.0, height: component.cornerRadius * 2.0) + UIGraphicsBeginImageContextWithOptions(imageSize, false, 0.0) + if let context = UIGraphicsGetCurrentContext() { + context.setFillColor(component.color.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: imageSize)) + } + self.image = UIGraphicsGetImageFromCurrentImageContext()?.stretchableImage(withLeftCapWidth: Int(component.cornerRadius), topCapHeight: Int(component.cornerRadius)) + UIGraphicsEndImageContext() + } + + return availableSize + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/Components/ActivityIndicatorComponent/Sources/ActivityIndicatorComponent.swift b/submodules/Components/ActivityIndicatorComponent/Sources/ActivityIndicatorComponent.swift index b40c991289..27311afa4a 100644 --- a/submodules/Components/ActivityIndicatorComponent/Sources/ActivityIndicatorComponent.swift +++ b/submodules/Components/ActivityIndicatorComponent/Sources/ActivityIndicatorComponent.swift @@ -3,11 +3,18 @@ import UIKit import ComponentFlow public final class ActivityIndicatorComponent: Component { + public let color: UIColor + public init( + color: UIColor ) { + self.color = color } public static func ==(lhs: ActivityIndicatorComponent, rhs: ActivityIndicatorComponent) -> Bool { + if lhs.color != rhs.color { + return false + } return true } @@ -21,6 +28,10 @@ public final class ActivityIndicatorComponent: Component { } func update(component: ActivityIndicatorComponent, availableSize: CGSize, transition: Transition) -> CGSize { + if component.color != self.color { + self.color = component.color + } + if !self.isAnimating { self.startAnimating() } diff --git a/submodules/Components/AnimatedStickerComponent/BUILD b/submodules/Components/AnimatedStickerComponent/BUILD new file mode 100644 index 0000000000..a480472de6 --- /dev/null +++ b/submodules/Components/AnimatedStickerComponent/BUILD @@ -0,0 +1,22 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AnimatedStickerComponent", + module_name = "AnimatedStickerComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/AnimatedStickerNode:AnimatedStickerNode", + "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", + "//submodules/Components/HierarchyTrackingLayer:HierarchyTrackingLayer", + + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/AnimatedStickerComponent/Sources/AnimatedStickerComponent.swift b/submodules/Components/AnimatedStickerComponent/Sources/AnimatedStickerComponent.swift new file mode 100644 index 0000000000..f7a1248815 --- /dev/null +++ b/submodules/Components/AnimatedStickerComponent/Sources/AnimatedStickerComponent.swift @@ -0,0 +1,107 @@ +import Foundation +import UIKit +import ComponentFlow +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import HierarchyTrackingLayer + +public final class AnimatedStickerComponent: Component { + public struct Animation: Equatable { + public var name: String + public var loop: Bool + public var isAnimating: Bool + + public init(name: String, loop: Bool, isAnimating: Bool = true) { + self.name = name + self.loop = loop + self.isAnimating = isAnimating + } + } + + public let animation: Animation + public let size: CGSize + + public init(animation: Animation, size: CGSize) { + self.animation = animation + self.size = size + } + + public static func ==(lhs: AnimatedStickerComponent, rhs: AnimatedStickerComponent) -> Bool { + if lhs.animation != rhs.animation { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } + + public final class View: UIView { + private var component: AnimatedStickerComponent? + private var animationNode: AnimatedStickerNode? + + private let hierarchyTrackingLayer: HierarchyTrackingLayer + private var isInHierarchy: Bool = false + + override init(frame: CGRect) { + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + + super.init(frame: frame) + + self.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.isInHierarchy = true + strongSelf.animationNode?.visibility = true + } + + self.hierarchyTrackingLayer.didExitHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.isInHierarchy = false + strongSelf.animationNode?.visibility = false + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: AnimatedStickerComponent, availableSize: CGSize, transition: Transition) -> CGSize { + if self.component?.animation != component.animation { + self.component = component + + self.animationNode?.view.removeFromSuperview() + + let animationNode = AnimatedStickerNode() + animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: component.animation.name), width: Int(component.size.width * 2.0), height: Int(component.size.height * 2.0), playbackMode: .loop, mode: .direct(cachePathPrefix: nil)) + animationNode.visibility = self.isInHierarchy + + self.animationNode = animationNode + self.addSubnode(animationNode) + } + + let animationSize = component.size + + let size = CGSize(width: min(animationSize.width, availableSize.width), height: min(animationSize.height, availableSize.height)) + + if let animationNode = self.animationNode { + animationNode.frame = CGRect(origin: CGPoint(x: floor((size.width - animationSize.width) / 2.0), y: floor((size.height - animationSize.height) / 2.0)), size: animationSize) + animationNode.updateLayout(size: animationSize) + } + + 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) + } +} diff --git a/submodules/Components/BundleIconComponent/BUILD b/submodules/Components/BundleIconComponent/BUILD new file mode 100644 index 0000000000..284b18fac6 --- /dev/null +++ b/submodules/Components/BundleIconComponent/BUILD @@ -0,0 +1,20 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "BundleIconComponent", + module_name = "BundleIconComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/AppBundle:AppBundle", + "//submodules/Display:Display", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/BundleIconComponent/Sources/BundleIconComponent.swift b/submodules/Components/BundleIconComponent/Sources/BundleIconComponent.swift new file mode 100644 index 0000000000..4d68c2df3d --- /dev/null +++ b/submodules/Components/BundleIconComponent/Sources/BundleIconComponent.swift @@ -0,0 +1,60 @@ +import Foundation +import UIKit +import ComponentFlow +import AppBundle +import Display + +public final class BundleIconComponent: Component { + public let name: String + public let tintColor: UIColor? + + public init(name: String, tintColor: UIColor?) { + self.name = name + self.tintColor = tintColor + } + + public static func ==(lhs: BundleIconComponent, rhs: BundleIconComponent) -> Bool { + if lhs.name != rhs.name { + return false + } + if lhs.tintColor != rhs.tintColor { + return false + } + return false + } + + public final class View: UIImageView { + private var component: BundleIconComponent? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: BundleIconComponent, availableSize: CGSize, transition: Transition) -> CGSize { + if self.component?.name != component.name || self.component?.tintColor != component.tintColor { + if let tintColor = component.tintColor { + self.image = generateTintedImage(image: UIImage(bundleImageName: component.name), color: tintColor, backgroundColor: nil) + } else { + self.image = UIImage(bundleImageName: component.name) + } + } + self.component = component + + let imageSize = self.image?.size ?? CGSize() + + return CGSize(width: min(imageSize.width, availableSize.width), height: min(imageSize.height, availableSize.height)) + } + } + + 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) + } +} diff --git a/submodules/Components/HierarchyTrackingLayer/BUILD b/submodules/Components/HierarchyTrackingLayer/BUILD new file mode 100644 index 0000000000..76bae62254 --- /dev/null +++ b/submodules/Components/HierarchyTrackingLayer/BUILD @@ -0,0 +1,17 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "HierarchyTrackingLayer", + module_name = "HierarchyTrackingLayer", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/HierarchyTrackingLayer/Sources/HierarchyTrackingLayer.swift b/submodules/Components/HierarchyTrackingLayer/Sources/HierarchyTrackingLayer.swift new file mode 100644 index 0000000000..7b643425f4 --- /dev/null +++ b/submodules/Components/HierarchyTrackingLayer/Sources/HierarchyTrackingLayer.swift @@ -0,0 +1,22 @@ +import UIKit + +private final class NullActionClass: NSObject, CAAction { + @objc public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { + } +} + +private let nullAction = NullActionClass() + +open class HierarchyTrackingLayer: CALayer { + public var didEnterHierarchy: (() -> Void)? + public var didExitHierarchy: (() -> Void)? + + override open func action(forKey event: String) -> CAAction? { + if event == kCAOnOrderIn { + self.didEnterHierarchy?() + } else if event == kCAOnOrderOut { + self.didExitHierarchy?() + } + return nullAction + } +} diff --git a/submodules/Components/LottieAnimationComponent/BUILD b/submodules/Components/LottieAnimationComponent/BUILD index 93b589e835..21e3d95d70 100644 --- a/submodules/Components/LottieAnimationComponent/BUILD +++ b/submodules/Components/LottieAnimationComponent/BUILD @@ -13,6 +13,7 @@ swift_library( "//submodules/ComponentFlow:ComponentFlow", "//submodules/lottie-ios:Lottie", "//submodules/AppBundle:AppBundle", + "//submodules/Components/HierarchyTrackingLayer:HierarchyTrackingLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift b/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift index 2e42b4cbf2..1668f33593 100644 --- a/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift +++ b/submodules/Components/LottieAnimationComponent/Sources/LottieAnimationComponent.swift @@ -2,27 +2,7 @@ import Foundation import ComponentFlow import Lottie import AppBundle - -private final class NullActionClass: NSObject, CAAction { - @objc public func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { - } -} - -private let nullAction = NullActionClass() - -private final class HierarchyTrackingLayer: CALayer { - var didEnterHierarchy: (() -> Void)? - var didExitHierarchy: (() -> Void)? - - override func action(forKey event: String) -> CAAction? { - if event == kCAOnOrderIn { - self.didEnterHierarchy?() - } else if event == kCAOnOrderOut { - self.didExitHierarchy?() - } - return nullAction - } -} +import HierarchyTrackingLayer public final class LottieAnimationComponent: Component { public struct Animation: Equatable { diff --git a/submodules/Components/MultilineTextComponent/BUILD b/submodules/Components/MultilineTextComponent/BUILD new file mode 100644 index 0000000000..100013f1bb --- /dev/null +++ b/submodules/Components/MultilineTextComponent/BUILD @@ -0,0 +1,22 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MultilineTextComponent", + module_name = "MultilineTextComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display:Display", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AccountContext:AccountContext", + "//submodules/TelegramPresentationData:TelegramPresentationData", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift b/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift new file mode 100644 index 0000000000..3699b75edb --- /dev/null +++ b/submodules/Components/MultilineTextComponent/Sources/MultilineTextComponent.swift @@ -0,0 +1,115 @@ +import Foundation +import UIKit +import ComponentFlow +import Display + +public final class MultilineTextComponent: Component { + public let text: NSAttributedString + public let horizontalAlignment: NSTextAlignment + public let verticalAlignment: TextVerticalAlignment + public var truncationType: CTLineTruncationType + public var maximumNumberOfLines: Int + public var lineSpacing: CGFloat + public var insets: UIEdgeInsets + public var textShadowColor: UIColor? + public var textStroke: (UIColor, CGFloat)? + + public init( + text: NSAttributedString, + horizontalAlignment: NSTextAlignment = .natural, + verticalAlignment: TextVerticalAlignment = .top, + truncationType: CTLineTruncationType = .end, + maximumNumberOfLines: Int = 1, + lineSpacing: CGFloat = 0.0, + insets: UIEdgeInsets = UIEdgeInsets(), + textShadowColor: UIColor? = nil, + textStroke: (UIColor, CGFloat)? = nil + ) { + self.text = text + self.horizontalAlignment = horizontalAlignment + self.verticalAlignment = verticalAlignment + self.truncationType = truncationType + self.maximumNumberOfLines = maximumNumberOfLines + self.lineSpacing = lineSpacing + self.insets = insets + self.textShadowColor = textShadowColor + self.textStroke = textStroke + } + + public static func ==(lhs: MultilineTextComponent, rhs: MultilineTextComponent) -> Bool { + if !lhs.text.isEqual(to: rhs.text) { + return false + } + if lhs.horizontalAlignment != rhs.horizontalAlignment { + return false + } + if lhs.verticalAlignment != rhs.verticalAlignment { + return false + } + if lhs.truncationType != rhs.truncationType { + return false + } + if lhs.maximumNumberOfLines != rhs.maximumNumberOfLines { + return false + } + if lhs.lineSpacing != rhs.lineSpacing { + return false + } + if lhs.insets != rhs.insets { + return false + } + + if let lhsTextShadowColor = lhs.textShadowColor, let rhsTextShadowColor = rhs.textShadowColor { + if !lhsTextShadowColor.isEqual(rhsTextShadowColor) { + return false + } + } else if (lhs.textShadowColor != nil) != (rhs.textShadowColor != nil) { + return false + } + + if let lhsTextStroke = lhs.textStroke, let rhsTextStroke = rhs.textStroke { + if !lhsTextStroke.0.isEqual(rhsTextStroke.0) { + return false + } + if lhsTextStroke.1 != rhsTextStroke.1 { + return false + } + } else if (lhs.textShadowColor != nil) != (rhs.textShadowColor != nil) { + return false + } + + return true + } + + public final class View: TextView { + public func update(component: MultilineTextComponent, availableSize: CGSize) -> CGSize { + let makeLayout = TextView.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments( + attributedString: component.text, + backgroundColor: nil, + maximumNumberOfLines: component.maximumNumberOfLines, + truncationType: component.truncationType, + constrainedSize: availableSize, + alignment: component.horizontalAlignment, + verticalAlignment: component.verticalAlignment, + lineSpacing: component.lineSpacing, + cutout: nil, + insets: component.insets, + textShadowColor: component.textShadowColor, + textStroke: component.textStroke, + displaySpoilers: false + )) + let _ = apply() + + return layout.size + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize) + } +} diff --git a/submodules/Components/SolidRoundedButtonComponent/BUILD b/submodules/Components/SolidRoundedButtonComponent/BUILD new file mode 100644 index 0000000000..5f8cb51169 --- /dev/null +++ b/submodules/Components/SolidRoundedButtonComponent/BUILD @@ -0,0 +1,20 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "SolidRoundedButtonComponent", + module_name = "SolidRoundedButtonComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display:Display", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift new file mode 100644 index 0000000000..7fe9f3bb59 --- /dev/null +++ b/submodules/Components/SolidRoundedButtonComponent/Sources/SolidRoundedButtonComponent.swift @@ -0,0 +1,114 @@ +import Foundation +import UIKit +import ComponentFlow +import Display +import SolidRoundedButtonNode + +public final class SolidRoundedButtonComponent: Component { + public typealias Theme = SolidRoundedButtonTheme + + public let title: String? + public let icon: UIImage? + public let theme: SolidRoundedButtonTheme + public let font: SolidRoundedButtonFont + public let fontSize: CGFloat + public let height: CGFloat + public let cornerRadius: CGFloat + public let gloss: Bool + public let action: () -> Void + + public init( + title: String? = nil, + icon: UIImage? = nil, + theme: SolidRoundedButtonTheme, + font: SolidRoundedButtonFont = .bold, + fontSize: CGFloat = 17.0, + height: CGFloat = 48.0, + cornerRadius: CGFloat = 24.0, + gloss: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.icon = icon + self.theme = theme + self.font = font + self.fontSize = fontSize + self.height = height + self.cornerRadius = cornerRadius + self.gloss = gloss + self.action = action + } + + public static func ==(lhs: SolidRoundedButtonComponent, rhs: SolidRoundedButtonComponent) -> Bool { + if lhs.title != rhs.title { + return false + } + if lhs.icon !== rhs.icon { + return false + } + if lhs.theme != rhs.theme { + return false + } + if lhs.font != rhs.font { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + if lhs.height != rhs.height { + return false + } + if lhs.cornerRadius != rhs.cornerRadius { + return false + } + if lhs.gloss != rhs.gloss { + return false + } + + return true + } + + public final class View: UIView { + private var component: SolidRoundedButtonComponent? + private var button: SolidRoundedButtonView? + + public func update(component: SolidRoundedButtonComponent, availableSize: CGSize, transition: Transition) -> CGSize { + if self.button == nil { + let button = SolidRoundedButtonView( + title: component.title, + icon: component.icon, + theme: component.theme, + font: component.font, + fontSize: component.fontSize, + height: component.height, + cornerRadius: component.cornerRadius, + gloss: component.gloss + ) + self.button = button + self.addSubview(button) + + button.pressed = { [weak self] in + self?.component?.action() + } + } + + if let button = self.button { + button.updateTheme(component.theme) + let height = button.updateLayout(width: availableSize.width, transition: .immediate) + transition.setFrame(view: button, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height)), completion: nil) + } + + self.component = component + + return availableSize + } + } + + public func makeView() -> View { + return View() + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/Components/ViewControllerComponent/BUILD b/submodules/Components/ViewControllerComponent/BUILD new file mode 100644 index 0000000000..0e1e28f483 --- /dev/null +++ b/submodules/Components/ViewControllerComponent/BUILD @@ -0,0 +1,22 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ViewControllerComponent", + module_name = "ViewControllerComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display:Display", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AccountContext:AccountContext", + "//submodules/TelegramPresentationData:TelegramPresentationData", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramCallsUI/Sources/Components/ViewControllerComponent.swift b/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift similarity index 71% rename from submodules/TelegramCallsUI/Sources/Components/ViewControllerComponent.swift rename to submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift index ccc5ae4bee..50f6cc5a8f 100644 --- a/submodules/TelegramCallsUI/Sources/Components/ViewControllerComponent.swift +++ b/submodules/Components/ViewControllerComponent/Sources/ViewControllerComponent.swift @@ -35,23 +35,35 @@ public extension Transition { } open class ViewControllerComponentContainer: ViewController { + public enum NavigationBarAppearance { + case none + case transparent + case `default` + } + public final class Environment: Equatable { public let statusBarHeight: CGFloat + public let navigationHeight: CGFloat public let safeInsets: UIEdgeInsets public let isVisible: Bool + public let theme: PresentationTheme public let strings: PresentationStrings public let controller: () -> ViewController? public init( statusBarHeight: CGFloat, + navigationHeight: CGFloat, safeInsets: UIEdgeInsets, isVisible: Bool, + theme: PresentationTheme, strings: PresentationStrings, controller: @escaping () -> ViewController? ) { self.statusBarHeight = statusBarHeight + self.navigationHeight = navigationHeight self.safeInsets = safeInsets self.isVisible = isVisible + self.theme = theme self.strings = strings self.controller = controller } @@ -64,12 +76,18 @@ open class ViewControllerComponentContainer: ViewController { if lhs.statusBarHeight != rhs.statusBarHeight { return false } + if lhs.navigationHeight != rhs.navigationHeight { + return false + } if lhs.safeInsets != rhs.safeInsets { return false } if lhs.isVisible != rhs.isVisible { return false } + if lhs.theme !== rhs.theme { + return false + } if lhs.strings !== rhs.strings { return false } @@ -78,15 +96,15 @@ open class ViewControllerComponentContainer: ViewController { } } - final class Node: ViewControllerTracingNode { + public final class Node: ViewControllerTracingNode { private var presentationData: PresentationData private weak var controller: ViewControllerComponentContainer? private let component: AnyComponent - let hostView: ComponentHostView + public let hostView: ComponentHostView private var currentIsVisible: Bool = false - private var currentLayout: ContainerViewLayout? + private var currentLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)? init(context: AccountContext, controller: ViewControllerComponentContainer, component: AnyComponent) { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -101,13 +119,15 @@ open class ViewControllerComponentContainer: ViewController { self.view.addSubview(self.hostView) } - func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: Transition) { - self.currentLayout = layout + func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: Transition) { + self.currentLayout = (layout, navigationHeight) let environment = ViewControllerComponentContainer.Environment( statusBarHeight: layout.statusBarHeight ?? 0.0, + navigationHeight: navigationHeight, safeInsets: UIEdgeInsets(top: layout.intrinsicInsets.top + layout.safeInsets.top, left: layout.intrinsicInsets.left + layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + layout.safeInsets.bottom, right: layout.intrinsicInsets.right + layout.safeInsets.right), isVisible: self.currentIsVisible, + theme: self.presentationData.theme, strings: self.presentationData.strings, controller: { [weak self] in return self?.controller @@ -133,22 +153,31 @@ open class ViewControllerComponentContainer: ViewController { guard let currentLayout = self.currentLayout else { return } - self.containerLayoutUpdated(currentLayout, transition: .immediate) + self.containerLayoutUpdated(layout: currentLayout.layout, navigationHeight: currentLayout.navigationHeight, transition: .immediate) } } - var node: Node { + public var node: Node { return self.displayNode as! Node } private let context: AccountContext private let component: AnyComponent - public init(context: AccountContext, component: C) where C.EnvironmentType == ViewControllerComponentContainer.Environment { + public init(context: AccountContext, component: C, navigationBarAppearance: NavigationBarAppearance) where C.EnvironmentType == ViewControllerComponentContainer.Environment { self.context = context self.component = AnyComponent(component) - super.init(navigationBarPresentationData: nil) + let navigationBarPresentationData: NavigationBarPresentationData? + switch navigationBarAppearance { + case .none: + navigationBarPresentationData = nil + case .transparent: + navigationBarPresentationData = NavigationBarPresentationData(presentationData: context.sharedContext.currentPresentationData.with { $0 }, hideBackground: true, hideBadge: false, hideSeparator: true) + case .default: + navigationBarPresentationData = NavigationBarPresentationData(presentationData: context.sharedContext.currentPresentationData.with { $0 }) + } + super.init(navigationBarPresentationData: navigationBarPresentationData) } required public init(coder aDecoder: NSCoder) { @@ -176,6 +205,8 @@ open class ViewControllerComponentContainer: ViewController { override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - self.node.containerLayoutUpdated(layout, transition: Transition(transition)) + let navigationHeight = self.navigationLayout(layout: layout).navigationFrame.maxY + + self.node.containerLayoutUpdated(layout: layout, navigationHeight: navigationHeight, transition: Transition(transition)) } } diff --git a/submodules/Display/Source/ImmediateTextNode.swift b/submodules/Display/Source/ImmediateTextNode.swift index e8cf468842..12799fa47f 100644 --- a/submodules/Display/Source/ImmediateTextNode.swift +++ b/submodules/Display/Source/ImmediateTextNode.swift @@ -224,3 +224,175 @@ public class ASTextNode: ImmediateTextNode { return self.updateLayout(constrainedSize) } } + +public class ImmediateTextView: TextView { + public var attributedText: NSAttributedString? + public var textAlignment: NSTextAlignment = .natural + public var verticalAlignment: TextVerticalAlignment = .top + public var truncationType: CTLineTruncationType = .end + public var maximumNumberOfLines: Int = 1 + public var lineSpacing: CGFloat = 0.0 + public var insets: UIEdgeInsets = UIEdgeInsets() + public var textShadowColor: UIColor? + public var textStroke: (UIColor, CGFloat)? + public var cutout: TextNodeCutout? + public var displaySpoilers = false + + public var truncationMode: NSLineBreakMode { + get { + switch self.truncationType { + case .start: + return .byTruncatingHead + case .middle: + return .byTruncatingMiddle + case .end: + return .byTruncatingTail + @unknown default: + return .byTruncatingTail + } + } set(value) { + switch value { + case .byTruncatingHead: + self.truncationType = .start + case .byTruncatingMiddle: + self.truncationType = .middle + case .byTruncatingTail: + self.truncationType = .end + default: + self.truncationType = .end + } + } + } + + private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer? + private var linkHighlightingNode: LinkHighlightingNode? + + public var linkHighlightColor: UIColor? + + public var trailingLineWidth: CGFloat? + + var constrainedSize: CGSize? + + public var highlightAttributeAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)? { + didSet { + self.updateInteractiveActions() + } + } + + public var tapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)? + public var longTapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)? + + public func updateLayout(_ constrainedSize: CGSize) -> CGSize { + self.constrainedSize = constrainedSize + + let makeLayout = TextView.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.attributedText, backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, textShadowColor: self.textShadowColor, textStroke: self.textStroke, displaySpoilers: self.displaySpoilers)) + let _ = apply() + if layout.numberOfLines > 1 { + self.trailingLineWidth = layout.trailingLineWidth + } else { + self.trailingLineWidth = nil + } + return layout.size + } + + public func updateLayoutInfo(_ constrainedSize: CGSize) -> ImmediateTextNodeLayoutInfo { + self.constrainedSize = constrainedSize + + let makeLayout = TextView.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.attributedText, backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, displaySpoilers: self.displaySpoilers)) + let _ = apply() + return ImmediateTextNodeLayoutInfo(size: layout.size, truncated: layout.truncated) + } + + public func updateLayoutFullInfo(_ constrainedSize: CGSize) -> TextNodeLayout { + self.constrainedSize = constrainedSize + + let makeLayout = TextView.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.attributedText, backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, verticalAlignment: self.verticalAlignment, lineSpacing: self.lineSpacing, cutout: self.cutout, insets: self.insets, displaySpoilers: self.displaySpoilers)) + let _ = apply() + return layout + } + + public func redrawIfPossible() { + if let constrainedSize = self.constrainedSize { + let _ = self.updateLayout(constrainedSize) + } + } + + private func updateInteractiveActions() { + if self.highlightAttributeAction != nil { + if self.tapRecognizer == nil { + let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapAction(_:))) + tapRecognizer.highlight = { [weak self] point in + if let strongSelf = self { + var rects: [CGRect]? + if let point = point { + if let (index, attributes) = strongSelf.attributesAtPoint(CGPoint(x: point.x, y: point.y)) { + if let selectedAttribute = strongSelf.highlightAttributeAction?(attributes) { + let initialRects = strongSelf.lineAndAttributeRects(name: selectedAttribute.rawValue, at: index) + if let initialRects = initialRects, case .center = strongSelf.textAlignment { + var mappedRects: [CGRect] = [] + for i in 0 ..< initialRects.count { + let lineRect = initialRects[i].0 + var itemRect = initialRects[i].1 + itemRect.origin.x = floor((strongSelf.bounds.size.width - lineRect.width) / 2.0) + itemRect.origin.x + mappedRects.append(itemRect) + } + rects = mappedRects + } else { + rects = strongSelf.attributeRects(name: selectedAttribute.rawValue, at: index) + } + } + } + } + + if let rects = rects { + let linkHighlightingNode: LinkHighlightingNode + if let current = strongSelf.linkHighlightingNode { + linkHighlightingNode = current + } else { + linkHighlightingNode = LinkHighlightingNode(color: strongSelf.linkHighlightColor ?? .clear) + strongSelf.linkHighlightingNode = linkHighlightingNode + strongSelf.addSubnode(linkHighlightingNode) + } + linkHighlightingNode.frame = strongSelf.bounds + linkHighlightingNode.updateRects(rects.map { $0.offsetBy(dx: 0.0, dy: 0.0) }) + } else if let linkHighlightingNode = strongSelf.linkHighlightingNode { + strongSelf.linkHighlightingNode = nil + linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in + linkHighlightingNode?.removeFromSupernode() + }) + } + } + } + self.addGestureRecognizer(tapRecognizer) + } + } else if let tapRecognizer = self.tapRecognizer { + self.tapRecognizer = nil + self.removeGestureRecognizer(tapRecognizer) + } + } + + @objc private func tapAction(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + switch recognizer.state { + case .ended: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + switch gesture { + case .tap: + if let (index, attributes) = self.attributesAtPoint(CGPoint(x: location.x, y: location.y)) { + self.tapAttributeAction?(attributes, index) + } + case .longTap: + if let (index, attributes) = self.attributesAtPoint(CGPoint(x: location.x, y: location.y)) { + self.longTapAttributeAction?(attributes, index) + } + default: + break + } + } + default: + break + } + } +} diff --git a/submodules/Display/Source/TextNode.swift b/submodules/Display/Source/TextNode.swift index cbf0ec3e93..5212a62136 100644 --- a/submodules/Display/Source/TextNode.swift +++ b/submodules/Display/Source/TextNode.swift @@ -890,7 +890,7 @@ public class TextNode: ASDisplayNode { } } - private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { if let attributedString = attributedString { let stringLength = attributedString.length @@ -1485,3 +1485,649 @@ public class TextNode: ASDisplayNode { } } } + +open class TextView: UIView { + public internal(set) var cachedLayout: TextNodeLayout? + + override public init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = UIColor.clear + self.isOpaque = false + self.clipsToBounds = false + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool = false) -> (Int, [NSAttributedString.Key: Any])? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.attributesAtPoint(point, orNearest: orNearest) + } else { + return nil + } + } + + public func textRangesRects(text: String) -> [[CGRect]] { + return self.cachedLayout?.textRangesRects(text: text) ?? [] + } + + public func attributeSubstring(name: String, index: Int) -> (String, String)? { + return self.cachedLayout?.attributeSubstring(name: name, index: index) + } + + public func attributeRects(name: String, at index: Int) -> [CGRect]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index)?.map { $0.1 } + } else { + return nil + } + } + + public func rangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.rangeRects(in: range) + } else { + return nil + } + } + + public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index) + } else { + return nil + } + } + + private class func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { + if let attributedString = attributedString { + + let stringLength = attributedString.length + + let font: CTFont + let resolvedAlignment: NSTextAlignment + + if stringLength != 0 { + if let stringFont = attributedString.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) { + font = stringFont as! CTFont + } else { + font = defaultFont + } + if alignment == .center { + resolvedAlignment = .center + } else { + if let paragraphStyle = attributedString.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { + resolvedAlignment = paragraphStyle.alignment + } else { + resolvedAlignment = alignment + } + } + } else { + font = defaultFont + resolvedAlignment = alignment + } + + let fontAscent = CTFontGetAscent(font) + let fontDescent = CTFontGetDescent(font) + let fontLineHeight = floor(fontAscent + fontDescent) + let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + + var lines: [TextNodeLine] = [] + var blockQuotes: [TextNodeBlockQuote] = [] + + var maybeTypesetter: CTTypesetter? + maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) + if maybeTypesetter == nil { + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) + } + + let typesetter = maybeTypesetter! + + var lastLineCharacterIndex: CFIndex = 0 + var layoutSize = CGSize() + + var cutoutEnabled = false + var cutoutMinY: CGFloat = 0.0 + var cutoutMaxY: CGFloat = 0.0 + var cutoutWidth: CGFloat = 0.0 + var cutoutOffset: CGFloat = 0.0 + + var bottomCutoutEnabled = false + var bottomCutoutSize = CGSize() + + if let topLeft = cutout?.topLeft { + cutoutMinY = -fontLineSpacing + cutoutMaxY = topLeft.height + fontLineSpacing + cutoutWidth = topLeft.width + cutoutOffset = cutoutWidth + cutoutEnabled = true + } else if let topRight = cutout?.topRight { + cutoutMinY = -fontLineSpacing + cutoutMaxY = topRight.height + fontLineSpacing + cutoutWidth = topRight.width + cutoutEnabled = true + } + + if let bottomRight = cutout?.bottomRight { + bottomCutoutSize = bottomRight + bottomCutoutEnabled = true + } + + let firstLineOffset = floorToScreenPixels(fontDescent) + + var truncated = false + var first = true + while true { + var strikethroughs: [TextNodeStrikethrough] = [] + var spoilers: [TextNodeSpoiler] = [] + var spoilerWords: [TextNodeSpoiler] = [] + + var lineConstrainedWidth = constrainedSize.width + var lineConstrainedWidthDelta: CGFloat = 0.0 + var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent) + if !first { + lineOriginY += fontLineSpacing + } + var lineCutoutOffset: CGFloat = 0.0 + var lineAdditionalWidth: CGFloat = 0.0 + + if cutoutEnabled { + if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY { + lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth) + lineConstrainedWidthDelta = -cutoutWidth + lineCutoutOffset = cutoutOffset + lineAdditionalWidth = cutoutWidth + } + } + + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth)) + + func addSpoiler(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilers.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset), height: ascent + descent))) + } + + func addSpoilerWord(line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { + var secondaryLeftOffset: CGFloat = 0.0 + let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) + var leftOffset = floor(rawLeftOffset) + if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { + leftOffset = floor(secondaryLeftOffset) + } + + var secondaryRightOffset: CGFloat = 0.0 + let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) + var rightOffset = ceil(rawRightOffset) + if !rawRightOffset.isEqual(to: secondaryRightOffset) { + rightOffset = ceil(secondaryRightOffset) + } + + spoilerWords.append(TextNodeSpoiler(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent))) + } + + var isLastLine = false + if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 { + isLastLine = true + } else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height { + isLastLine = true + } + if isLastLine { + if first { + first = false + } else { + layoutSize.height += fontLineSpacing + } + + let lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) + var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) + if brokenLineRange.location + brokenLineRange.length > attributedString.length { + brokenLineRange.length = attributedString.length - brokenLineRange.location + } + if lineRange.length == 0 { + break + } + + let coreTextLine: CTLine + let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0) + + var lineConstrainedSize = constrainedSize + lineConstrainedSize.width += lineConstrainedWidthDelta + if bottomCutoutEnabled { + lineConstrainedSize.width -= bottomCutoutSize.width + } + + if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(lineConstrainedSize.width) { + coreTextLine = originalLine + } else { + var truncationTokenAttributes: [NSAttributedString.Key : AnyObject] = [:] + truncationTokenAttributes[NSAttributedString.Key.font] = font + truncationTokenAttributes[NSAttributedString.Key(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber + let tokenString = "\u{2026}" + let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) + let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) + + coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(lineConstrainedSize.width), truncationType, truncationToken) ?? truncationToken + let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] + for run in runs { + let runAttributes: NSDictionary = CTRunGetAttributes(run) + if let _ = runAttributes["CTForegroundColorFromContext"] { + brokenLineRange.length = CTRunGetStringRange(run).location + break + } + } + if brokenLineRange.location + brokenLineRange.length > attributedString.length { + brokenLineRange.length = attributedString.length - brokenLineRange.location + } + truncated = true + } + + var headIndent: CGFloat = 0.0 + if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length { + attributedString.enumerateAttributes(in: NSMakeRange(brokenLineRange.location, brokenLineRange.length), options: []) { attributes, range, _ in + if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedString.string as NSString) + nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in + if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + if let currentStartIndex = startIndex { + startIndex = nil + let endIndex = range.location + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) + } + } else if startIndex == nil { + startIndex = range.location + } + currentIndex = range.location + range.length + } + + if let currentStartIndex = startIndex, let currentIndex = currentIndex { + startIndex = nil + let endIndex = currentIndex + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex, rightInset: truncated ? 12.0 : 0.0) + } + + addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) + } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { + headIndent = paragraphStyle.headIndent + + } + } + } + + let lineWidth = min(lineConstrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) + let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) + layoutSize.height += fontLineHeight + fontLineSpacing + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + + if headIndent > 0.0 { + blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) + } + + var isRTL = false + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count != 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords)) + break + } else { + if lineCharacterCount > 0 { + if first { + first = false + } else { + layoutSize.height += fontLineSpacing + } + + var lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount) + if lineRange.location + lineRange.length > attributedString.length { + lineRange.length = attributedString.length - lineRange.location + } + if lineRange.length < 0 { + break + } + + let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0) + lastLineCharacterIndex += lineCharacterCount + + var headIndent: CGFloat = 0.0 + attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in + if attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] != nil || attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] != nil { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedString.string as NSString) + nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in + if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + if let currentStartIndex = startIndex { + startIndex = nil + let endIndex = range.location + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) + } + } else if startIndex == nil { + startIndex = range.location + } + currentIndex = range.location + range.length + } + + if let currentStartIndex = startIndex, let currentIndex = currentIndex { + startIndex = nil + let endIndex = currentIndex + addSpoilerWord(line: coreTextLine, ascent: ascent, descent: descent, startIndex: currentStartIndex, endIndex: endIndex) + } + + addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + strikethroughs.append(TextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) + } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { + headIndent = paragraphStyle.headIndent + } + } + + let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) + let lineFrame = CGRect(x: lineCutoutOffset + headIndent, y: lineOriginY, width: lineWidth, height: fontLineHeight) + layoutSize.height += fontLineHeight + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + + if headIndent > 0.0 { + blockQuotes.append(TextNodeBlockQuote(frame: lineFrame)) + } + + var isRTL = false + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count != 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length), isRTL: isRTL, strikethroughs: strikethroughs, spoilers: spoilers, spoilerWords: spoilerWords)) + } else { + if !lines.isEmpty { + layoutSize.height += fontLineSpacing + } + break + } + } + } + + let rawLayoutSize = layoutSize + if !lines.isEmpty && bottomCutoutEnabled { + let proposedWidth = lines[lines.count - 1].frame.width + bottomCutoutSize.width + if proposedWidth > layoutSize.width { + if proposedWidth <= constrainedSize.width + .ulpOfOne { + layoutSize.width = proposedWidth + } else { + layoutSize.height += bottomCutoutSize.height + } + } + } + + if lines.count < minimumNumberOfLines { + var lineCount = lines.count + while lineCount < minimumNumberOfLines { + if lineCount != 0 { + layoutSize.height += fontLineSpacing + } + layoutSize.height += fontLineHeight + lineCount += 1 + } + } + + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: resolvedAlignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), rawTextSize: CGSize(width: ceil(rawLayoutSize.width) + insets.left + insets.right, height: ceil(rawLayoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, blockQuotes: blockQuotes, backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) + } else { + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], blockQuotes: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textStroke: textStroke, displaySpoilers: displaySpoilers) + } + } + + public override func draw(_ rect: CGRect) { + let bounds = self.bounds + let layout = self.cachedLayout + + let context = UIGraphicsGetCurrentContext()! + + context.setAllowsAntialiasing(true) + + context.setAllowsFontSmoothing(false) + context.setShouldSmoothFonts(false) + + context.setAllowsFontSubpixelPositioning(false) + context.setShouldSubpixelPositionFonts(false) + + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + var clearRects: [CGRect] = [] + if let layout = layout { + if layout.backgroundColor != nil { + context.setBlendMode(.copy) + context.setFillColor((layout.backgroundColor ?? UIColor.clear).cgColor) + context.fill(bounds) + } + + if let textShadowColor = layout.textShadowColor { + context.setTextDrawingMode(.fill) + context.setShadow(offset: CGSize(width: 0.0, height: 1.0), blur: 0.0, color: textShadowColor.cgColor) + } + + if let (textStrokeColor, textStrokeWidth) = layout.textStroke { + context.setBlendMode(.normal) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setStrokeColor(textStrokeColor.cgColor) + context.setFillColor(textStrokeColor.cgColor) + context.setLineWidth(textStrokeWidth) + context.setTextDrawingMode(.fillStroke) + } + + let textMatrix = context.textMatrix + let textPosition = context.textPosition + context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) + + let alignment = layout.resolvedAlignment + var offset = CGPoint(x: layout.insets.left, y: layout.insets.top) + switch layout.verticalAlignment { + case .top: + break + case .middle: + offset.y = floor((bounds.height - layout.size.height) / 2.0) + layout.insets.top + case .bottom: + offset.y = floor(bounds.height - layout.size.height) + layout.insets.top + } + + for i in 0 ..< layout.lines.count { + let line = layout.lines[i] + + var lineFrame = line.frame + lineFrame.origin.y += offset.y + + if alignment == .center { + lineFrame.origin.x = offset.x + floor((bounds.size.width - lineFrame.width) / 2.0) + } else if alignment == .natural, line.isRTL { + lineFrame.origin.x = offset.x + floor(bounds.size.width - lineFrame.width) + + lineFrame = displayLineFrame(frame: lineFrame, isRTL: line.isRTL, boundingRect: CGRect(origin: CGPoint(), size: bounds.size), cutout: layout.cutout) + } + context.textPosition = CGPoint(x: lineFrame.minX, y: lineFrame.minY) + + if layout.displaySpoilers && !line.spoilers.isEmpty { + context.saveGState() + var clipRects: [CGRect] = [] + for spoiler in line.spoilerWords { + var spoilerClipRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel) + spoilerClipRect.size.height += 1.0 + UIScreenPixel + clipRects.append(spoilerClipRect) + } + context.clip(to: clipRects) + } + + let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray + if glyphRuns.count != 0 { + for run in glyphRuns { + let run = run as! CTRun + let glyphCount = CTRunGetGlyphCount(run) + CTRunDraw(run, context, CFRangeMake(0, glyphCount)) + } + } + + if !line.strikethroughs.isEmpty { + for strikethrough in line.strikethroughs { + var textColor: UIColor? + layout.attributedString?.enumerateAttributes(in: NSMakeRange(line.range.location, line.range.length), options: []) { attributes, range, _ in + if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { + textColor = color + } + } + if let textColor = textColor { + context.setFillColor(textColor.cgColor) + } + let frame = strikethrough.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY) + context.fill(CGRect(x: frame.minX, y: frame.minY - 5.0, width: frame.width, height: 1.0)) + } + } + + if !line.spoilers.isEmpty { + if layout.displaySpoilers { + context.restoreGState() + } else { + for spoiler in line.spoilerWords { + var spoilerClearRect = spoiler.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY - UIScreenPixel) + spoilerClearRect.size.height += 1.0 + UIScreenPixel + clearRects.append(spoilerClearRect) + } + } + } + } + + var blockQuoteFrames: [CGRect] = [] + var currentBlockQuoteFrame: CGRect? + for blockQuote in layout.blockQuotes { + if let frame = currentBlockQuoteFrame { + if blockQuote.frame.minY - frame.maxY < 20.0 { + currentBlockQuoteFrame = frame.union(blockQuote.frame) + } else { + blockQuoteFrames.append(frame) + currentBlockQuoteFrame = frame + } + } else { + currentBlockQuoteFrame = blockQuote.frame + } + } + + if let frame = currentBlockQuoteFrame { + blockQuoteFrames.append(frame) + } + + for frame in blockQuoteFrames { + if let lineColor = layout.lineColor { + context.setFillColor(lineColor.cgColor) + } + let rect = UIBezierPath(roundedRect: CGRect(x: frame.minX - 9.0, y: frame.minY - 14.0, width: 2.0, height: frame.height), cornerRadius: 1.0) + context.addPath(rect.cgPath) + context.fillPath() + } + + context.textMatrix = textMatrix + context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y) + } + + context.setBlendMode(.normal) + + for rect in clearRects { + context.clear(rect) + } + } + + public static func asyncLayout(_ maybeView: TextView?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextView) { + let existingLayout: TextNodeLayout? = maybeView?.cachedLayout + + return { arguments in + let layout: TextNodeLayout + + var updated = false + if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) { + let stringMatch: Bool + + var colorMatch: Bool = true + if let backgroundColor = arguments.backgroundColor, let previousBackgroundColor = existingLayout.backgroundColor { + if !backgroundColor.isEqual(previousBackgroundColor) { + colorMatch = false + } + } else if (arguments.backgroundColor != nil) != (existingLayout.backgroundColor != nil) { + colorMatch = false + } + + if !colorMatch { + stringMatch = false + } else if let existingString = existingLayout.attributedString, let string = arguments.attributedString { + stringMatch = existingString.isEqual(to: string) + } else if existingLayout.attributedString == nil && arguments.attributedString == nil { + stringMatch = true + } else { + stringMatch = false + } + + if stringMatch { + layout = existingLayout + } else { + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + updated = true + } + } else { + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + updated = true + } + + let view = maybeView ?? TextView() + + return (layout, { + view.cachedLayout = layout + if updated { + if layout.size.width.isZero && layout.size.height.isZero { + view.layer.contents = nil + } + view.setNeedsDisplay() + } + + return view + }) + } + } +} diff --git a/submodules/PeerInfoUI/BUILD b/submodules/PeerInfoUI/BUILD index cfd857c73d..c6359760fa 100644 --- a/submodules/PeerInfoUI/BUILD +++ b/submodules/PeerInfoUI/BUILD @@ -74,6 +74,7 @@ swift_library( "//submodules/AnimatedStickerNode:AnimatedStickerNode", "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", "//submodules/Components/ReactionImageComponent:ReactionImageComponent", + "//submodules/Components/LottieAnimationComponent:LottieAnimationComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/BUILD b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/BUILD new file mode 100644 index 0000000000..b34e2bac80 --- /dev/null +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/BUILD @@ -0,0 +1,32 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "CreateExternalMediaStreamScreen", + module_name = "CreateExternalMediaStreamScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/AccountContext:AccountContext", + "//submodules/PresentationDataUtils:PresentationDataUtils", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/Components/ViewControllerComponent:ViewControllerComponent", + "//submodules/Components/MultilineTextComponent:MultilineTextComponent", + "//submodules/Components/SolidRoundedButtonComponent:SolidRoundedButtonComponent", + "//submodules/Components/BundleIconComponent:BundleIconComponent", + "//submodules/Components/AnimatedStickerComponent:AnimatedStickerComponent", + "//submodules/Components/ActivityIndicatorComponent:ActivityIndicatorComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift new file mode 100644 index 0000000000..3b24847788 --- /dev/null +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift @@ -0,0 +1,447 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import PresentationDataUtils +import AccountContext +import ComponentFlow +import ViewControllerComponent +import MultilineTextComponent +import SolidRoundedButtonComponent +import BundleIconComponent +import AnimatedStickerComponent +import ActivityIndicatorComponent + +private final class CreateExternalMediaStreamScreenComponent: CombinedComponent { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + let credentialsPromise: Promise? + + init(context: AccountContext, peerId: EnginePeer.Id, credentialsPromise: Promise?) { + self.context = context + self.peerId = peerId + self.credentialsPromise = credentialsPromise + } + + static func ==(lhs: CreateExternalMediaStreamScreenComponent, rhs: CreateExternalMediaStreamScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peerId != rhs.peerId { + return false + } + if lhs.credentialsPromise !== rhs.credentialsPromise { + return false + } + return true + } + + final class State: ComponentState { + let context: AccountContext + let peerId: EnginePeer.Id + + private(set) var credentials: GroupCallStreamCredentials? + + private var credentialsDisposable: Disposable? + private let activeActionDisposable = MetaDisposable() + + init(context: AccountContext, peerId: EnginePeer.Id, credentialsPromise: Promise?) { + self.context = context + self.peerId = peerId + + super.init() + + let credentialsSignal: Signal + if let credentialsPromise = credentialsPromise { + credentialsSignal = credentialsPromise.get() + } else { + credentialsSignal = context.engine.calls.getGroupCallStreamCredentials(peerId: peerId, revokePreviousCredentials: false) + |> `catch` { _ -> Signal in + return .never() + } + } + self.credentialsDisposable = (credentialsSignal |> deliverOnMainQueue).start(next: { [weak self] result in + guard let strongSelf = self else { + return + } + + strongSelf.credentials = result + strongSelf.updated(transition: .immediate) + }) + } + + deinit { + self.credentialsDisposable?.dispose() + self.activeActionDisposable.dispose() + } + + func copyCredentials(_ key: KeyPath) { + guard let credentials = self.credentials else { + return + } + UIPasteboard.general.string = credentials[keyPath: key] + } + + func createAndJoinGroupCall(baseController: ViewController, completion: @escaping () -> Void) { + guard let _ = self.context.sharedContext.callManager else { + return + } + let startCall: (Bool) -> Void = { [weak self, weak baseController] endCurrentIfAny in + guard let strongSelf = self, let baseController = baseController else { + return + } + + var cancelImpl: (() -> Void)? + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + let progressSignal = Signal { [weak baseController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + baseController?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.15, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + let createSignal = strongSelf.context.engine.calls.createGroupCall(peerId: strongSelf.peerId, title: nil, scheduleDate: nil, isExternalStream: true) + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + cancelImpl = { + self?.activeActionDisposable.set(nil) + } + strongSelf.activeActionDisposable.set((createSignal + |> deliverOnMainQueue).start(next: { info in + guard let strongSelf = self else { + return + } + strongSelf.context.joinGroupCall(peerId: strongSelf.peerId, invite: nil, requestJoinAsPeerId: { result in + result(nil) + }, activeCall: EngineGroupCallDescription(id: info.id, accessHash: info.accessHash, title: info.title, scheduleTimestamp: nil, subscribedToScheduled: false, isStream: info.isStream)) + + completion() + }, error: { [weak baseController] error in + guard let strongSelf = self else { + return + } + + let text: String + text = presentationData.strings.Login_UnknownError + baseController?.present(textAlertController(context: strongSelf.context, updatedPresentationData: nil, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + })) + } + + startCall(true) + } + } + + func makeState() -> State { + return State(context: self.context, peerId: self.peerId, credentialsPromise: self.credentialsPromise) + } + + static var body: Body { + let background = Child(Rectangle.self) + + let animation = Child(AnimatedStickerComponent.self) + let text = Child(MultilineTextComponent.self) + let bottomText = Child(MultilineTextComponent.self) + let button = Child(SolidRoundedButtonComponent.self) + + let activityIndicator = Child(ActivityIndicatorComponent.self) + + let credentialsBackground = Child(RoundedRectangle.self) + + let credentialsStripe = Child(Rectangle.self) + let credentialsURLTitle = Child(MultilineTextComponent.self) + let credentialsURLText = Child(MultilineTextComponent.self) + + let credentialsKeyTitle = Child(MultilineTextComponent.self) + let credentialsKeyText = Child(MultilineTextComponent.self) + + let credentialsCopyURLButton = Child(Button.self) + let credentialsCopyKeyButton = Child(Button.self) + + return { context in + let topInset: CGFloat = 16.0 + let sideInset: CGFloat = 16.0 + let credentialsSideInset: CGFloat = 16.0 + let credentialsTopInset: CGFloat = 9.0 + let credentialsTitleSpacing: CGFloat = 5.0 + + let environment = context.environment[ViewControllerComponentContainer.Environment.self].value + let state = context.state + let controller = environment.controller + + let bottomInset: CGFloat + if environment.safeInsets.bottom.isZero { + bottomInset = 16.0 + } else { + bottomInset = 42.0 + } + + let background = background.update( + component: Rectangle(color: environment.theme.list.blocksBackgroundColor), + availableSize: context.availableSize, + transition: context.transition + ) + + let animation = animation.update( + component: AnimatedStickerComponent( + animation: AnimatedStickerComponent.Animation( + name: "CreateStream", + loop: true + ), + size: CGSize(width: 138.0, height: 138.0) + ), + availableSize: CGSize(width: 138.0, height: 138.0), + transition: context.transition + ) + + let text = text.update( + component: MultilineTextComponent( + text: NSAttributedString(string: environment.strings.CreateExternalStream_Text, font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor, paragraphAlignment: .center), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.1 + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), + transition: context.transition + ) + + let bottomText = bottomText.update( + component: MultilineTextComponent( + text: NSAttributedString(string: environment.strings.CreateExternalStream_StartStreamingInfo, font: Font.regular(15.0), textColor: environment.theme.list.itemSecondaryTextColor, paragraphAlignment: .center), + horizontalAlignment: .center, + maximumNumberOfLines: 0, + lineSpacing: 0.1 + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height), + transition: context.transition + ) + + let button = button.update( + component: SolidRoundedButtonComponent( + title: environment.strings.CreateExternalStream_StartStreaming, + theme: SolidRoundedButtonComponent.Theme(theme: environment.theme), + font: .bold, + fontSize: 17.0, + height: 50.0, + cornerRadius: 10.0, + gloss: true, + action: { [weak state] in + guard let state = state, let controller = controller() else { + return + } + + state.createAndJoinGroupCall(baseController: controller, completion: { [weak controller] in + controller?.dismiss() + }) + } + ), + availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: 50.0), + transition: context.transition + ) + + let credentialsItemHeight: CGFloat = 60.0 + let credentialsAreaSize = CGSize(width: context.availableSize.width - sideInset * 2.0, height: credentialsItemHeight * 2.0) + + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + ) + + let animationFrame = CGRect(origin: CGPoint(x: floor((context.availableSize.width - animation.size.width) / 2.0), y: environment.navigationHeight + topInset), size: animation.size) + + context.add(animation + .position(CGPoint(x: animationFrame.midX, y: animationFrame.midY)) + ) + + let textFrame = CGRect(origin: CGPoint(x: floor((context.availableSize.width - text.size.width) / 2.0), y: animationFrame.maxY + 16.0), size: text.size) + + context.add(text + .position(CGPoint(x: textFrame.midX, y: textFrame.midY)) + ) + + let credentialsFrame = CGRect(origin: CGPoint(x: sideInset, y: textFrame.maxY + 30.0), size: credentialsAreaSize) + + if let credentials = context.state.credentials { + let credentialsURLTitle = credentialsURLTitle.update( + component: MultilineTextComponent( + text: NSAttributedString(string: environment.strings.CreateExternalStream_ServerUrl, font: Font.regular(14.0), textColor: environment.theme.list.itemPrimaryTextColor, paragraphAlignment: .left), + horizontalAlignment: .left, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: credentialsAreaSize.width - credentialsSideInset * 2.0, height: credentialsAreaSize.height), + transition: context.transition + ) + + let credentialsKeyTitle = credentialsKeyTitle.update( + component: MultilineTextComponent( + text: NSAttributedString(string: environment.strings.CreateExternalStream_StreamKey, font: Font.regular(14.0), textColor: environment.theme.list.itemPrimaryTextColor, paragraphAlignment: .left), + horizontalAlignment: .left, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: credentialsAreaSize.width - credentialsSideInset * 2.0, height: credentialsAreaSize.height), + transition: context.transition + ) + + let credentialsURLText = credentialsURLText.update( + component: MultilineTextComponent( + text: NSAttributedString(string: credentials.url, font: Font.regular(16.0), textColor: environment.theme.list.itemAccentColor, paragraphAlignment: .left), + horizontalAlignment: .left, + truncationType: .middle, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: credentialsAreaSize.width - credentialsSideInset * 2.0 - 22.0, height: credentialsAreaSize.height), + transition: context.transition + ) + + let credentialsKeyText = credentialsKeyText.update( + component: MultilineTextComponent( + text: NSAttributedString(string: credentials.streamKey, font: Font.regular(16.0), textColor: environment.theme.list.itemAccentColor, paragraphAlignment: .left), + horizontalAlignment: .left, + truncationType: .middle, + maximumNumberOfLines: 1 + ), + availableSize: CGSize(width: credentialsAreaSize.width - credentialsSideInset * 2.0 - 22.0, height: credentialsAreaSize.height), + transition: context.transition + ) + + let credentialsBackground = credentialsBackground.update( + component: RoundedRectangle(color: environment.theme.list.itemBlocksBackgroundColor, cornerRadius: 10.0), + availableSize: credentialsAreaSize, + transition: context.transition + ) + + let credentialsStripe = credentialsStripe.update( + component: Rectangle(color: environment.theme.list.itemPlainSeparatorColor), + availableSize: CGSize(width: credentialsAreaSize.width - credentialsSideInset, height: UIScreenPixel), + transition: context.transition + ) + + let credentialsCopyURLButton = credentialsCopyURLButton.update( + component: Button( + content: AnyComponent(BundleIconComponent(name: "Chat/Context Menu/Copy", tintColor: environment.theme.list.itemAccentColor)), + action: { [weak state] in + guard let state = state else { + return + } + state.copyCredentials(\.url) + } + ).minSize(CGSize(width: 44.0, height: 44.0)), + availableSize: CGSize(width: 44.0, height: 44.0), + transition: context.transition + ) + + let credentialsCopyKeyButton = credentialsCopyKeyButton.update( + component: Button( + content: AnyComponent(BundleIconComponent(name: "Chat/Context Menu/Copy", tintColor: environment.theme.list.itemAccentColor)), + action: { [weak state] in + guard let state = state else { + return + } + state.copyCredentials(\.streamKey) + } + ).minSize(CGSize(width: 44.0, height: 44.0)), + availableSize: CGSize(width: 44.0, height: 44.0), + transition: context.transition + ) + + context.add(credentialsBackground + .position(CGPoint(x: credentialsFrame.midX, y: credentialsFrame.midY)) + ) + + context.add(credentialsStripe + .position(CGPoint(x: credentialsFrame.minX + credentialsSideInset + credentialsStripe.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight)) + ) + + context.add(credentialsURLTitle + .position(CGPoint(x: credentialsFrame.minX + credentialsSideInset + credentialsURLTitle.size.width / 2.0, y: credentialsFrame.minY + credentialsTopInset + credentialsURLTitle.size.height / 2.0)) + ) + context.add(credentialsURLText + .position(CGPoint(x: credentialsFrame.minX + credentialsSideInset + credentialsURLText.size.width / 2.0, y: credentialsFrame.minY + credentialsTopInset + credentialsTitleSpacing + credentialsURLTitle.size.height + credentialsURLText.size.height / 2.0)) + ) + context.add(credentialsCopyURLButton + .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyURLButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight / 2.0)) + ) + + context.add(credentialsKeyTitle + .position(CGPoint(x: credentialsFrame.minX + credentialsSideInset + credentialsKeyTitle.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsTopInset + credentialsKeyTitle.size.height / 2.0)) + ) + context.add(credentialsKeyText + .position(CGPoint(x: credentialsFrame.minX + credentialsSideInset + credentialsKeyText.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsTopInset + credentialsTitleSpacing + credentialsKeyTitle.size.height + credentialsKeyText.size.height / 2.0)) + ) + context.add(credentialsCopyKeyButton + .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0)) + ) + } else { + let activityIndicator = activityIndicator.update( + component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor), + availableSize: CGSize(width: 100.0, height: 100.0), + transition: context.transition + ) + context.add(activityIndicator + .position(CGPoint(x: credentialsFrame.midX, y: credentialsFrame.midY)) + ) + } + + let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: context.availableSize.height - bottomInset - button.size.height), size: button.size) + + context.add(bottomText + .position(CGPoint(x: context.availableSize.width / 2.0, y: buttonFrame.minY - 14.0 - bottomText.size.height / 2.0)) + ) + + context.add(button + .position(CGPoint(x: buttonFrame.midX, y: buttonFrame.midY)) + ) + + return context.availableSize + } + } +} + +public final class CreateExternalMediaStreamScreen: ViewControllerComponentContainer { + private let context: AccountContext + private let peerId: EnginePeer.Id + + public init(context: AccountContext, peerId: EnginePeer.Id, credentialsPromise: Promise?) { + self.context = context + self.peerId = peerId + + super.init(context: context, component: CreateExternalMediaStreamScreenComponent(context: context, peerId: peerId, credentialsPromise: credentialsPromise), navigationBarAppearance: .transparent) + + self.navigationPresentation = .modal + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = presentationData.strings.CreateExternalStream_Title + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + } +} diff --git a/submodules/SolidRoundedButtonNode/BUILD b/submodules/SolidRoundedButtonNode/BUILD index 96b30de710..414ad4772d 100644 --- a/submodules/SolidRoundedButtonNode/BUILD +++ b/submodules/SolidRoundedButtonNode/BUILD @@ -12,6 +12,7 @@ swift_library( deps = [ "//submodules/AsyncDisplayKit:AsyncDisplayKit", "//submodules/Display:Display", + "//submodules/Components/HierarchyTrackingLayer:HierarchyTrackingLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift index eaca96a86b..816c2118ba 100644 --- a/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift +++ b/submodules/SolidRoundedButtonNode/Sources/SolidRoundedButtonNode.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import AsyncDisplayKit import Display +import HierarchyTrackingLayer private func generateIndefiniteActivityIndicatorImage(color: UIColor, diameter: CGFloat = 22.0, lineWidth: CGFloat = 2.0) -> UIImage? { return generateImage(CGSize(width: diameter, height: diameter), rotatedContext: { size, context in @@ -15,7 +16,7 @@ private func generateIndefiniteActivityIndicatorImage(color: UIColor, diameter: }) } -public final class SolidRoundedButtonTheme { +public final class SolidRoundedButtonTheme: Equatable { public let backgroundColor: UIColor public let gradientBackgroundColor: UIColor? public let foregroundColor: UIColor @@ -25,6 +26,19 @@ public final class SolidRoundedButtonTheme { self.gradientBackgroundColor = gradientBackgroundColor self.foregroundColor = foregroundColor } + + public static func ==(lhs: SolidRoundedButtonTheme, rhs: SolidRoundedButtonTheme) -> Bool { + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.gradientBackgroundColor != rhs.gradientBackgroundColor { + return false + } + if lhs.foregroundColor != rhs.foregroundColor { + return false + } + return true + } } public enum SolidRoundedButtonFont { @@ -38,7 +52,7 @@ public final class SolidRoundedButtonNode: ASDisplayNode { private var fontSize: CGFloat private let buttonBackgroundNode: ASDisplayNode - private let buttonGlossNode: SolidRoundedButtonGlossNode? + private let buttonGlossView: SolidRoundedButtonGlossView? private let buttonNode: HighlightTrackingButtonNode private let titleNode: ImmediateTextNode private let subtitleNode: ImmediateTextNode @@ -95,9 +109,9 @@ public final class SolidRoundedButtonNode: ASDisplayNode { self.buttonBackgroundNode.cornerRadius = cornerRadius if gloss { - self.buttonGlossNode = SolidRoundedButtonGlossNode(color: theme.foregroundColor, cornerRadius: cornerRadius) + self.buttonGlossView = SolidRoundedButtonGlossView(color: theme.foregroundColor, cornerRadius: cornerRadius) } else { - self.buttonGlossNode = nil + self.buttonGlossView = nil } self.buttonNode = HighlightTrackingButtonNode() @@ -116,8 +130,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode { super.init() self.addSubnode(self.buttonBackgroundNode) - if let buttonGlossNode = self.buttonGlossNode { - self.addSubnode(buttonGlossNode) + if let buttonGlossView = self.buttonGlossView { + self.view.addSubview(buttonGlossView) } self.addSubnode(self.buttonNode) self.addSubnode(self.titleNode) @@ -211,7 +225,7 @@ public final class SolidRoundedButtonNode: ASDisplayNode { self.theme = theme self.buttonBackgroundNode.backgroundColor = theme.backgroundColor - self.buttonGlossNode?.color = theme.foregroundColor + self.buttonGlossView?.color = theme.foregroundColor self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor) self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: theme.foregroundColor) @@ -237,8 +251,8 @@ public final class SolidRoundedButtonNode: ASDisplayNode { let buttonSize = CGSize(width: width, height: self.buttonHeight) let buttonFrame = CGRect(origin: CGPoint(), size: buttonSize) transition.updateFrame(node: self.buttonBackgroundNode, frame: buttonFrame) - if let buttonGlossNode = self.buttonGlossNode { - transition.updateFrame(node: buttonGlossNode, frame: buttonFrame) + if let buttonGlossView = self.buttonGlossView { + transition.updateFrame(view: buttonGlossView, frame: buttonFrame) } transition.updateFrame(node: self.buttonNode, frame: buttonFrame) @@ -289,7 +303,257 @@ public final class SolidRoundedButtonNode: ASDisplayNode { } } -private final class SolidRoundedButtonGlossNodeParameters: NSObject { +public final class SolidRoundedButtonView: UIView { + private var theme: SolidRoundedButtonTheme + private var font: SolidRoundedButtonFont + private var fontSize: CGFloat + + private let buttonBackgroundNode: UIView + private let buttonGlossView: SolidRoundedButtonGlossView? + private let buttonNode: HighlightTrackingButton + private let titleNode: ImmediateTextView + private let subtitleNode: ImmediateTextView + private let iconNode: UIImageView + private var progressNode: UIImageView? + + private let buttonHeight: CGFloat + private let buttonCornerRadius: CGFloat + + public var pressed: (() -> Void)? + public var validLayout: CGFloat? + + public var title: String? { + didSet { + if let width = self.validLayout { + _ = self.updateLayout(width: width, transition: .immediate) + } + } + } + + public var subtitle: String? { + didSet { + if let width = self.validLayout { + _ = self.updateLayout(width: width, previousSubtitle: oldValue, transition: .immediate) + } + } + } + + public var icon: UIImage? { + didSet { + self.iconNode.image = generateTintedImage(image: self.icon, color: self.theme.foregroundColor) + } + } + + public var iconSpacing: CGFloat = 8.0 { + didSet { + if let width = self.validLayout { + _ = self.updateLayout(width: width, transition: .immediate) + } + } + } + + public init(title: String? = nil, icon: UIImage? = nil, theme: SolidRoundedButtonTheme, font: SolidRoundedButtonFont = .bold, fontSize: CGFloat = 17.0, height: CGFloat = 48.0, cornerRadius: CGFloat = 24.0, gloss: Bool = false) { + self.theme = theme + self.font = font + self.fontSize = fontSize + self.buttonHeight = height + self.buttonCornerRadius = cornerRadius + self.title = title + + self.buttonBackgroundNode = UIView() + self.buttonBackgroundNode.clipsToBounds = true + self.buttonBackgroundNode.backgroundColor = theme.backgroundColor + self.buttonBackgroundNode.layer.cornerRadius = cornerRadius + + if gloss { + self.buttonGlossView = SolidRoundedButtonGlossView(color: theme.foregroundColor, cornerRadius: cornerRadius) + } else { + self.buttonGlossView = nil + } + + self.buttonNode = HighlightTrackingButton() + + self.titleNode = ImmediateTextView() + self.titleNode.isUserInteractionEnabled = false + + self.subtitleNode = ImmediateTextView() + self.subtitleNode.isUserInteractionEnabled = false + + self.iconNode = UIImageView() + self.iconNode.image = generateTintedImage(image: icon, color: self.theme.foregroundColor) + + super.init(frame: CGRect()) + + self.addSubview(self.buttonBackgroundNode) + if let buttonGlossView = self.buttonGlossView { + self.addSubview(buttonGlossView) + } + self.addSubview(self.buttonNode) + self.addSubview(self.titleNode) + self.addSubview(self.subtitleNode) + self.addSubview(self.iconNode) + + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside) + + self.buttonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.buttonBackgroundNode.layer.removeAnimation(forKey: "opacity") + strongSelf.buttonBackgroundNode.alpha = 0.55 + strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode.alpha = 0.55 + strongSelf.subtitleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.subtitleNode.alpha = 0.55 + strongSelf.iconNode.layer.removeAnimation(forKey: "opacity") + strongSelf.iconNode.alpha = 0.55 + } else { + if strongSelf.buttonBackgroundNode.alpha > 0.0 { + strongSelf.buttonBackgroundNode.alpha = 1.0 + strongSelf.buttonBackgroundNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + strongSelf.titleNode.alpha = 1.0 + strongSelf.titleNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + strongSelf.subtitleNode.alpha = 1.0 + strongSelf.subtitleNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + strongSelf.iconNode.alpha = 1.0 + strongSelf.iconNode.layer.animateAlpha(from: 0.55, to: 1.0, duration: 0.2) + } + } + } + } + + if #available(iOS 13.0, *) { + self.buttonBackgroundNode.layer.cornerCurve = .continuous + } + } + + required public init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func transitionToProgress() { + guard self.progressNode == nil else { + return + } + + self.isUserInteractionEnabled = false + + let buttonOffset = self.buttonBackgroundNode.frame.minX + let buttonWidth = self.buttonBackgroundNode.frame.width + + let progressFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(buttonOffset + (buttonWidth - self.buttonHeight) / 2.0), y: 0.0), size: CGSize(width: self.buttonHeight, height: self.buttonHeight)) + let progressNode = UIImageView() + progressNode.frame = progressFrame + progressNode.image = generateIndefiniteActivityIndicatorImage(color: self.buttonBackgroundNode.backgroundColor ?? .clear, diameter: self.buttonHeight, lineWidth: 2.0 + UIScreenPixel) + self.insertSubview(progressNode, at: 0) + self.progressNode = progressNode + + let basicAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + basicAnimation.duration = 0.5 + basicAnimation.fromValue = NSNumber(value: Float(0.0)) + basicAnimation.toValue = NSNumber(value: Float.pi * 2.0) + basicAnimation.repeatCount = Float.infinity + basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) + basicAnimation.beginTime = 1.0 + progressNode.layer.add(basicAnimation, forKey: "progressRotation") + + self.buttonBackgroundNode.layer.cornerRadius = self.buttonHeight / 2.0 + self.buttonBackgroundNode.layer.animate(from: self.buttonCornerRadius as NSNumber, to: self.buttonHeight / 2.0 as NSNumber, keyPath: "cornerRadius", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) + self.buttonBackgroundNode.layer.animateFrame(from: self.buttonBackgroundNode.frame, to: progressFrame, duration: 0.2) + + self.buttonBackgroundNode.alpha = 0.0 + self.buttonBackgroundNode.layer.animateAlpha(from: 0.55, to: 0.0, duration: 0.2, removeOnCompletion: false) + + progressNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false) + + self.titleNode.alpha = 0.0 + self.titleNode.layer.animateAlpha(from: 0.55, to: 0.0, duration: 0.2) + + self.subtitleNode.alpha = 0.0 + self.subtitleNode.layer.animateAlpha(from: 0.55, to: 0.0, duration: 0.2) + } + + public func updateTheme(_ theme: SolidRoundedButtonTheme) { + guard theme !== self.theme else { + return + } + self.theme = theme + + self.buttonBackgroundNode.backgroundColor = theme.backgroundColor + self.buttonGlossView?.color = theme.foregroundColor + self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: theme.foregroundColor) + self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: theme.foregroundColor) + + self.iconNode.image = generateTintedImage(image: self.iconNode.image, color: theme.foregroundColor) + + if let width = self.validLayout { + _ = self.updateLayout(width: width, transition: .immediate) + } + } + + public func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + return self.updateLayout(width: width, previousSubtitle: self.subtitle, transition: transition) + } + + private func updateLayout(width: CGFloat, previousSubtitle: String?, transition: ContainedViewLayoutTransition) -> CGFloat { + self.validLayout = width + + let buttonSize = CGSize(width: width, height: self.buttonHeight) + let buttonFrame = CGRect(origin: CGPoint(), size: buttonSize) + transition.updateFrame(view: self.buttonBackgroundNode, frame: buttonFrame) + if let buttonGlossView = self.buttonGlossView { + transition.updateFrame(view: buttonGlossView, frame: buttonFrame) + } + transition.updateFrame(view: self.buttonNode, frame: buttonFrame) + + if self.title != self.titleNode.attributedText?.string { + self.titleNode.attributedText = NSAttributedString(string: self.title ?? "", font: self.font == .bold ? Font.semibold(self.fontSize) : Font.regular(self.fontSize), textColor: self.theme.foregroundColor) + } + + let iconSize = self.iconNode.image?.size ?? CGSize() + let titleSize = self.titleNode.updateLayout(buttonSize) + + let iconSpacing: CGFloat = self.iconSpacing + + var contentWidth: CGFloat = titleSize.width + if !iconSize.width.isZero { + contentWidth += iconSize.width + iconSpacing + } + var nextContentOrigin = floor((buttonFrame.width - contentWidth) / 2.0) + transition.updateFrame(view: self.iconNode, frame: CGRect(origin: CGPoint(x: buttonFrame.minX + nextContentOrigin, y: floor((buttonFrame.height - iconSize.height) / 2.0)), size: iconSize)) + if !iconSize.width.isZero { + nextContentOrigin += iconSize.width + iconSpacing + } + + let spacingOffset: CGFloat = 9.0 + let verticalInset: CGFloat = self.subtitle == nil ? floor((buttonFrame.height - titleSize.height) / 2.0) : floor((buttonFrame.height - titleSize.height) / 2.0) - spacingOffset + + let titleFrame = CGRect(origin: CGPoint(x: buttonFrame.minX + nextContentOrigin, y: buttonFrame.minY + verticalInset), size: titleSize) + transition.updateFrame(view: self.titleNode, frame: titleFrame) + + if self.subtitle != self.subtitleNode.attributedText?.string { + self.subtitleNode.attributedText = NSAttributedString(string: self.subtitle ?? "", font: Font.regular(14.0), textColor: self.theme.foregroundColor) + } + + let subtitleSize = self.subtitleNode.updateLayout(buttonSize) + let subtitleFrame = CGRect(origin: CGPoint(x: buttonFrame.minX + floor((buttonFrame.width - subtitleSize.width) / 2.0), y: buttonFrame.minY + floor((buttonFrame.height - titleSize.height) / 2.0) + spacingOffset + 2.0), size: subtitleSize) + transition.updateFrame(view: self.subtitleNode, frame: subtitleFrame) + + if previousSubtitle == nil && self.subtitle != nil { + self.titleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: spacingOffset / 2.0), to: CGPoint(), duration: 0.3, additive: true) + self.subtitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -spacingOffset / 2.0), to: CGPoint(), duration: 0.3, additive: true) + self.subtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } + + return buttonSize.height + } + + @objc private func buttonPressed() { + self.pressed?() + } +} + +private final class SolidRoundedButtonGlossViewParameters: NSObject { let gradientColors: NSArray? let cornerRadius: CGFloat let progress: CGFloat @@ -301,7 +565,7 @@ private final class SolidRoundedButtonGlossNodeParameters: NSObject { } } -public final class SolidRoundedButtonGlossNode: ASDisplayNode { +public final class SolidRoundedButtonGlossView: UIView { public var color: UIColor { didSet { self.updateGradientColors() @@ -313,14 +577,19 @@ public final class SolidRoundedButtonGlossNode: ASDisplayNode { private let buttonCornerRadius: CGFloat private var gradientColors: NSArray? + private let trackingLayer: HierarchyTrackingLayer + public init(color: UIColor, cornerRadius: CGFloat) { self.color = color self.buttonCornerRadius = cornerRadius - super.init() + self.trackingLayer = HierarchyTrackingLayer() + + super.init(frame: CGRect()) + + self.layer.addSublayer(self.trackingLayer) self.isOpaque = false - self.isLayerBacked = true var previousTime: CFAbsoluteTime? self.animator = ConstantDisplayLinkAnimator(update: { [weak self] in @@ -347,41 +616,48 @@ public final class SolidRoundedButtonGlossNode: ASDisplayNode { }) self.updateGradientColors() + + self.trackingLayer.didEnterHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.animator?.isPaused = false + } + + self.trackingLayer.didExitHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.animator?.isPaused = true + } + } + + required public init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } private func updateGradientColors() { let transparentColor = self.color.withAlphaComponent(0.0).cgColor self.gradientColors = [transparentColor, transparentColor, self.color.withAlphaComponent(0.12).cgColor, transparentColor, transparentColor] } - - override public func willEnterHierarchy() { - super.willEnterHierarchy() - self.animator?.isPaused = false - } - - override public func didExitHierarchy() { - super.didExitHierarchy() - self.animator?.isPaused = true - } - - override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return SolidRoundedButtonGlossNodeParameters(gradientColors: self.gradientColors, cornerRadius: self.buttonCornerRadius, progress: self.progress) - } - - @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { + + override public func draw(_ rect: CGRect) { + let parameters = SolidRoundedButtonGlossViewParameters(gradientColors: self.gradientColors, cornerRadius: self.buttonCornerRadius, progress: self.progress) + guard let gradientColors = parameters.gradientColors else { + return + } + let context = UIGraphicsGetCurrentContext()! - if let parameters = parameters as? SolidRoundedButtonGlossNodeParameters, let gradientColors = parameters.gradientColors { - let path = UIBezierPath(roundedRect: bounds, cornerRadius: parameters.cornerRadius) - context.addPath(path.cgPath) - context.clip() - - var locations: [CGFloat] = [0.0, 0.15, 0.5, 0.85, 1.0] - let colorSpace = CGColorSpaceCreateDeviceRGB() - let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! - - let x = -4.0 * bounds.size.width + 8.0 * bounds.size.width * parameters.progress - context.drawLinearGradient(gradient, start: CGPoint(x: x, y: 0.0), end: CGPoint(x: x + bounds.size.width, y: 0.0), options: CGGradientDrawingOptions()) - } + let path = UIBezierPath(roundedRect: bounds, cornerRadius: parameters.cornerRadius) + context.addPath(path.cgPath) + context.clip() + + var locations: [CGFloat] = [0.0, 0.15, 0.5, 0.85, 1.0] + let colorSpace = CGColorSpaceCreateDeviceRGB() + let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! + + let x = -4.0 * bounds.size.width + 8.0 * bounds.size.width * parameters.progress + context.drawLinearGradient(gradient, start: CGPoint(x: x, y: 0.0), end: CGPoint(x: x + bounds.size.width, y: 0.0), options: CGGradientDrawingOptions()) } } diff --git a/submodules/TelegramCallsUI/BUILD b/submodules/TelegramCallsUI/BUILD index 591d757de4..7077cca007 100644 --- a/submodules/TelegramCallsUI/BUILD +++ b/submodules/TelegramCallsUI/BUILD @@ -98,6 +98,9 @@ swift_library( "//submodules/ComponentFlow:ComponentFlow", "//submodules/Components/LottieAnimationComponent:LottieAnimationComponent", "//submodules/Components/ActivityIndicatorComponent:ActivityIndicatorComponent", + "//submodules/Components/ViewControllerComponent:ViewControllerComponent", + "//submodules/Components/BundleIconComponent:BundleIconComponent", + "//submodules/Components/MultilineTextComponent:MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 3be60b54c7..5df799a69a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -12,6 +12,8 @@ import UndoUI import TelegramPresentationData import LottieAnimationComponent import ContextUI +import ViewControllerComponent +import BundleIconComponent final class NavigationBackButtonComponent: Component { let text: String @@ -97,61 +99,6 @@ final class NavigationBackButtonComponent: Component { } } -final class BundleIconComponent: Component { - let name: String - let tintColor: UIColor? - - init(name: String, tintColor: UIColor?) { - self.name = name - self.tintColor = tintColor - } - - static func ==(lhs: BundleIconComponent, rhs: BundleIconComponent) -> Bool { - if lhs.name != rhs.name { - return false - } - if lhs.tintColor != rhs.tintColor { - return false - } - return false - } - - public final class View: UIImageView { - private var component: BundleIconComponent? - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: BundleIconComponent, availableSize: CGSize, transition: Transition) -> CGSize { - if self.component?.name != component.name || self.component?.tintColor != component.tintColor { - if let tintColor = component.tintColor { - self.image = generateTintedImage(image: UIImage(bundleImageName: component.name), color: tintColor, backgroundColor: nil) - } else { - self.image = UIImage(bundleImageName: component.name) - } - } - self.component = component - - let imageSize = self.image?.size ?? CGSize() - - return CGSize(width: min(imageSize.width, availableSize.width), height: min(imageSize.height, availableSize.height)) - } - } - - 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 @@ -206,7 +153,7 @@ private final class NavigationBarComponent: CombinedComponent { 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.0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + 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( @@ -389,7 +336,7 @@ private final class ToolbarComponent: CombinedComponent { 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.0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + 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( @@ -495,6 +442,8 @@ public final class MediaStreamComponent: CombinedComponent { private(set) var canManageCall: Bool = false let isPictureInPictureSupported: Bool + private(set) var peerTitle: String = "" + private(set) var isVisibleInHierarchy: Bool = false private var isVisibleInHierarchyDisposable: Disposable? @@ -545,6 +494,10 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.canManageCall = state.canManageCall updated = true } + if strongSelf.peerTitle != callPeer.debugDisplayTitle { + strongSelf.peerTitle = callPeer.debugDisplayTitle + updated = true + } let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) if strongSelf.originInfo != originInfo { @@ -647,6 +600,8 @@ public final class MediaStreamComponent: CombinedComponent { call: context.component.call, hasVideo: context.state.hasVideo, isVisible: environment.isVisible && context.state.isVisibleInHierarchy, + isAdmin: context.state.canManageCall, + peerTitle: context.state.peerTitle, activatePictureInPicture: activatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in guard let call = call else { @@ -924,7 +879,7 @@ public final class MediaStreamComponentController: ViewControllerComponentContai self.context = call.accountContext self.call = call - super.init(context: call.accountContext, component: MediaStreamComponent(call: call as! PresentationGroupCallImpl)) + super.init(context: call.accountContext, component: MediaStreamComponent(call: call as! PresentationGroupCallImpl), navigationBarAppearance: .none) self.statusBar.statusBarStyle = .White self.view.disablesInteractiveModalDismiss = true diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 407d821acd..cba30e1df5 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -4,18 +4,24 @@ import ComponentFlow import ActivityIndicatorComponent import AccountContext import AVKit +import MultilineTextComponent +import Display final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl let hasVideo: Bool let isVisible: Bool + let isAdmin: Bool + let peerTitle: String let activatePictureInPicture: ActionSlot> let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void - init(call: PresentationGroupCallImpl, hasVideo: Bool, isVisible: Bool, activatePictureInPicture: ActionSlot>, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void) { + init(call: PresentationGroupCallImpl, hasVideo: Bool, isVisible: Bool, isAdmin: Bool, peerTitle: String, activatePictureInPicture: ActionSlot>, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void) { self.call = call self.hasVideo = hasVideo self.isVisible = isVisible + self.isAdmin = isAdmin + self.peerTitle = peerTitle self.activatePictureInPicture = activatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation } @@ -30,6 +36,12 @@ final class MediaStreamVideoComponent: Component { if lhs.isVisible != rhs.isVisible { return false } + if lhs.isAdmin != rhs.isAdmin { + return false + } + if lhs.peerTitle != rhs.peerTitle { + return false + } return true } @@ -44,33 +56,30 @@ final class MediaStreamVideoComponent: Component { return State() } - public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView { + public final class View: UIScrollView, AVPictureInPictureControllerDelegate, ComponentTaggedView { public final class Tag { } private let videoRenderingContext = VideoRenderingContext() private var videoView: VideoRenderingView? - private let blurTintView: UIView - private var videoBlurView: VideoRenderingView? private var activityIndicatorView: ComponentHostView? + private var noSignalView: ComponentHostView? private var pictureInPictureController: AVPictureInPictureController? private var component: MediaStreamVideoComponent? private var hadVideo: Bool = false + private var noSignalTimer: Timer? + private var noSignalTimeout: Bool = false + private weak var state: State? override init(frame: CGRect) { - self.blurTintView = UIView() - self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) - super.init(frame: frame) self.isUserInteractionEnabled = false self.clipsToBounds = true - - self.addSubview(self.blurTintView) } required init?(coder: NSCoder) { @@ -93,48 +102,49 @@ final class MediaStreamVideoComponent: Component { if component.hasVideo, self.videoView == nil { if let input = component.call.video(endpointId: "unified") { - if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { - self.videoBlurView = videoBlurView - self.insertSubview(videoBlurView, belowSubview: self.blurTintView) - } - if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) - if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported(), let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { - 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 - } + if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { + if #available(iOS 13.0, *) { + sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } - let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) - - pictureInPictureController.delegate = self - pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true - pictureInPictureController.requiresLinearPlayback = true - - self.pictureInPictureController = pictureInPictureController + 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 + } + } + + let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + + pictureInPictureController.delegate = self + pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true + pictureInPictureController.requiresLinearPlayback = true + + self.pictureInPictureController = pictureInPictureController + } } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -146,9 +156,19 @@ 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) } } @@ -171,15 +191,8 @@ final class MediaStreamVideoComponent: Component { } 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) - - 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) - } } if !self.hadVideo { @@ -196,11 +209,53 @@ final class MediaStreamVideoComponent: Component { let activityIndicatorSize = activityIndicatorView.update( transition: transition, - component: AnyComponent(ActivityIndicatorComponent()), + component: AnyComponent(ActivityIndicatorComponent(color: .white)), environment: {}, containerSize: CGSize(width: 100.0, height: 100.0) ) - activityIndicatorTransition.setFrame(view: activityIndicatorView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - activityIndicatorSize.width) / 2.0), y: floor((availableSize.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize), completion: nil) + 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) + + if self.noSignalTimer == nil { + if #available(iOS 10.0, *) { + let noSignalTimer = Timer(timeInterval: 20.0, repeats: false, block: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.noSignalTimeout = true + strongSelf.state?.updated(transition: .immediate) + }) + self.noSignalTimer = noSignalTimer + RunLoop.main.add(noSignalTimer, forMode: .common) + } + } + + if self.noSignalTimeout { + var noSignalTransition = transition + let noSignalView: ComponentHostView + if let current = self.noSignalView { + noSignalView = current + } else { + noSignalTransition = transition.withAnimation(.none) + noSignalView = ComponentHostView() + self.noSignalView = noSignalView + self.addSubview(noSignalView) + noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with { $0 } + let noSignalSize = noSignalView.update( + transition: transition, + component: AnyComponent(MultilineTextComponent( + text: NSAttributedString(string: component.isAdmin ? presentationData.strings.LiveStream_NoSignalAdminText : presentationData.strings.LiveStream_NoSignalUserText(component.peerTitle).string, font: Font.regular(16.0), textColor: .white, paragraphAlignment: .center), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + )), + 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) + } } self.component = component diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 584a7806f5..fb01d186d2 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -273,6 +273,168 @@ private extension PresentationGroupCallState { } } +private enum CurrentImpl { + case call(OngoingGroupCallContext) + case mediaStream(WrappedMediaStreamingContext) +} + +private extension CurrentImpl { + var joinPayload: Signal<(String, UInt32), NoError> { + switch self { + case let .call(callContext): + return callContext.joinPayload + case .mediaStream: + let ssrcId = UInt32.random(in: 0 ..< UInt32(Int32.max - 1)) + let dict: [String: Any] = [ + "fingerprints": [], + "ufrag": "", + "pwd": "", + "ssrc": Int32(bitPattern: ssrcId), + "ssrc-groups": [] + ] + guard let jsonString = (try? JSONSerialization.data(withJSONObject: dict, options: [])).flatMap({ String(data: $0, encoding: .utf8) }) else { + return .never() + } + return .single((jsonString, ssrcId)) + } + } + + var networkState: Signal { + switch self { + case let .call(callContext): + return callContext.networkState + case .mediaStream: + return .single(OngoingGroupCallContext.NetworkState(isConnected: true, isTransitioningFromBroadcastToRtc: false)) + } + } + + var audioLevels: Signal<[(OngoingGroupCallContext.AudioLevelKey, Float, Bool)], NoError> { + switch self { + case let .call(callContext): + return callContext.audioLevels + case .mediaStream: + return .single([]) + } + } + + var isMuted: Signal { + switch self { + case let .call(callContext): + return callContext.isMuted + case .mediaStream: + return .single(true) + } + } + + var isNoiseSuppressionEnabled: Signal { + switch self { + case let .call(callContext): + return callContext.isNoiseSuppressionEnabled + case .mediaStream: + return .single(false) + } + } + + func stop() { + switch self { + case let .call(callContext): + callContext.stop() + case .mediaStream: + break + } + } + + func setIsMuted(_ isMuted: Bool) { + switch self { + case let .call(callContext): + callContext.setIsMuted(isMuted) + case .mediaStream: + break + } + } + + func setIsNoiseSuppressionEnabled(_ isNoiseSuppressionEnabled: Bool) { + switch self { + case let .call(callContext): + callContext.setIsNoiseSuppressionEnabled(isNoiseSuppressionEnabled) + case .mediaStream: + break + } + } + + func requestVideo(_ capturer: OngoingCallVideoCapturer?) { + switch self { + case let .call(callContext): + callContext.requestVideo(capturer) + case .mediaStream: + break + } + } + + func disableVideo() { + switch self { + case let .call(callContext): + callContext.disableVideo() + case .mediaStream: + break + } + } + + func setVolume(ssrc: UInt32, volume: Double) { + switch self { + case let .call(callContext): + callContext.setVolume(ssrc: ssrc, volume: volume) + case .mediaStream: + break + } + } + + func setRequestedVideoChannels(_ channels: [OngoingGroupCallContext.VideoChannel]) { + switch self { + case let .call(callContext): + callContext.setRequestedVideoChannels(channels) + case .mediaStream: + break + } + } + + func makeIncomingVideoView(endpointId: String, requestClone: Bool, completion: @escaping (OngoingCallContextPresentationCallVideoView?, OngoingCallContextPresentationCallVideoView?) -> Void) { + switch self { + case let .call(callContext): + callContext.makeIncomingVideoView(endpointId: endpointId, requestClone: requestClone, completion: completion) + case .mediaStream: + break + } + } + + func video(endpointId: String) -> Signal { + switch self { + case let .call(callContext): + return callContext.video(endpointId: endpointId) + case let .mediaStream(mediaStreamContext): + return mediaStreamContext.video() + } + } + + func addExternalAudioData(data: Data) { + switch self { + case let .call(callContext): + callContext.addExternalAudioData(data: data) + case .mediaStream: + break + } + } + + func getStats(completion: @escaping (OngoingGroupCallContext.Stats) -> Void) { + switch self { + case let .call(callContext): + callContext.getStats(completion: completion) + case .mediaStream: + break + } + } +} + public final class PresentationGroupCallImpl: PresentationGroupCall { private enum InternalState { case requesting @@ -430,7 +592,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { private var currentLocalSsrc: UInt32? private var currentLocalEndpointId: String? - private var genericCallContext: OngoingGroupCallContext? + private var genericCallContext: CurrentImpl? private var currentConnectionMode: OngoingGroupCallContext.ConnectionMode = .none private var didInitializeConnectionMode: Bool = false @@ -827,7 +989,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } } if !removedSsrc.isEmpty { - strongSelf.genericCallContext?.removeSsrcs(ssrcs: removedSsrc) + if case let .call(callContext) = strongSelf.genericCallContext { + callContext.removeSsrcs(ssrcs: removedSsrc) + } } } }) @@ -1411,39 +1575,57 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } if shouldJoin, let callInfo = activeCallInfo { - let genericCallContext: OngoingGroupCallContext + let genericCallContext: CurrentImpl if let current = self.genericCallContext { genericCallContext = current } else { - var outgoingAudioBitrateKbit: Int32? - let appConfiguration = self.accountContext.currentAppConfiguration.with({ $0 }) - if let data = appConfiguration.data, let value = data["voice_chat_send_bitrate"] as? Double { - outgoingAudioBitrateKbit = Int32(value) - } + if self.isStream, !"".isEmpty { + genericCallContext = .mediaStream(WrappedMediaStreamingContext(rejoinNeeded: { [weak self] in + Queue.mainQueue().async { + guard let strongSelf = self else { + return + } + if strongSelf.leaving { + return + } + if case .established = strongSelf.internalState { + strongSelf.requestCall(movingFromBroadcastToRtc: false) + } + } + })) + } else { + var outgoingAudioBitrateKbit: Int32? + let appConfiguration = self.accountContext.currentAppConfiguration.with({ $0 }) + if let data = appConfiguration.data, let value = data["voice_chat_send_bitrate"] as? Double { + outgoingAudioBitrateKbit = Int32(value) + } - genericCallContext = OngoingGroupCallContext(video: self.videoCapturer, requestMediaChannelDescriptions: { [weak self] ssrcs, completion in - let disposable = MetaDisposable() - Queue.mainQueue().async { - guard let strongSelf = self else { - return + genericCallContext = .call(OngoingGroupCallContext(video: self.videoCapturer, requestMediaChannelDescriptions: { [weak self] ssrcs, completion in + let disposable = MetaDisposable() + Queue.mainQueue().async { + guard let strongSelf = self else { + return + } + disposable.set(strongSelf.requestMediaChannelDescriptions(ssrcs: ssrcs, completion: completion)) } - disposable.set(strongSelf.requestMediaChannelDescriptions(ssrcs: ssrcs, completion: completion)) - } - return disposable - }, rejoinNeeded: { [weak self] in - Queue.mainQueue().async { - guard let strongSelf = self else { - return + return disposable + }, rejoinNeeded: { [weak self] in + Queue.mainQueue().async { + guard let strongSelf = self else { + return + } + if case .established = strongSelf.internalState { + strongSelf.requestCall(movingFromBroadcastToRtc: false) + } } - if case .established = strongSelf.internalState { - strongSelf.requestCall(movingFromBroadcastToRtc: false) - } - } - }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, preferX264: self.accountContext.sharedContext.immediateExperimentalUISettings.preferredVideoCodec == "H264") + }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, preferX264: self.accountContext.sharedContext.immediateExperimentalUISettings.preferredVideoCodec == "H264" + )) + } self.genericCallContext = genericCallContext self.stateVersionValue += 1 } + self.joinDisposable.set((genericCallContext.joinPayload |> distinctUntilChanged(isEqual: { lhs, rhs in if lhs.0 != rhs.0 { @@ -1528,15 +1710,28 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } } - switch joinCallResult.connectionMode { - case .rtc: - strongSelf.currentConnectionMode = .rtc - strongSelf.genericCallContext?.setConnectionMode(.rtc, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: false) - strongSelf.genericCallContext?.setJoinResponse(payload: clientParams) - case .broadcast: - strongSelf.currentConnectionMode = .broadcast - strongSelf.genericCallContext?.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: callInfo.isStream)) - strongSelf.genericCallContext?.setConnectionMode(.broadcast, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: callInfo.isStream) + if let genericCallContext = strongSelf.genericCallContext { + switch genericCallContext { + case let .call(callContext): + switch joinCallResult.connectionMode { + case .rtc: + strongSelf.currentConnectionMode = .rtc + callContext.setConnectionMode(.rtc, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: false) + callContext.setJoinResponse(payload: clientParams) + case .broadcast: + strongSelf.currentConnectionMode = .broadcast + callContext.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: callInfo.isStream)) + callContext.setConnectionMode(.broadcast, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: callInfo.isStream) + } + case let .mediaStream(mediaStreamContext): + switch joinCallResult.connectionMode { + case .rtc: + strongSelf.currentConnectionMode = .rtc + case .broadcast: + strongSelf.currentConnectionMode = .broadcast + mediaStreamContext.setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData(engine: strongSelf.accountContext.engine, callId: callInfo.id, accessHash: callInfo.accessHash, isExternalStream: callInfo.isStream)) + } + } } strongSelf.updateSessionState(internalState: .established(info: joinCallResult.callInfo, connectionMode: joinCallResult.connectionMode, clientParams: clientParams, localSsrc: ssrc, initialState: joinCallResult.state), audioSessionControl: strongSelf.audioSessionControl) @@ -2952,7 +3147,15 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { if !self.didInitializeConnectionMode || self.currentConnectionMode != .none { self.didInitializeConnectionMode = true self.currentConnectionMode = .none - self.genericCallContext?.setConnectionMode(.none, keepBroadcastConnectedIfWasEnabled: movingFromBroadcastToRtc, isUnifiedBroadcast: false) + if let genericCallContext = self.genericCallContext { + switch genericCallContext { + case let .call(callContext): + callContext.setConnectionMode(.none, keepBroadcastConnectedIfWasEnabled: movingFromBroadcastToRtc, isUnifiedBroadcast: false) + case .mediaStream: + assertionFailure() + break + } + } } self.internalState = .requesting diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 9628e0b0e6..8c1aa97946 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -265,6 +265,7 @@ swift_library( "//submodules/ChatTextLinkEditUI:ChatTextLinkEditUI", "//submodules/MediaPickerUI:MediaPickerUI", "//submodules/ChatMessageBackground:ChatMessageBackground", + "//submodules/PeerInfoUI/CreateExternalMediaStreamScreen:CreateExternalMediaStreamScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Resources/Animations/CreateStream.tgs b/submodules/TelegramUI/Resources/Animations/CreateStream.tgs new file mode 100644 index 0000000000..34215d5492 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/CreateStream.tgs differ diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 950b273623..285a0d770b 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -65,6 +65,7 @@ import TooltipUI import QrCodeUI import Translate import ChatPresentationInterfaceState +import CreateExternalMediaStreamScreen protocol PeerInfoScreenItem: AnyObject { var id: AnyHashable { get } @@ -4106,6 +4107,10 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate self.context.scheduleGroupCall(peerId: self.peerId) } + private func createExternalStream(credentialsPromise: Promise?) { + self.controller?.push(CreateExternalMediaStreamScreen(context: self.context, peerId: self.peerId, credentialsPromise: credentialsPromise)) + } + private func createAndJoinGroupCall(peerId: PeerId, joinAsPeerId: PeerId?) { if let _ = self.context.sharedContext.callManager { let startCall: (Bool) -> Void = { [weak self] endCurrentIfAny in @@ -4113,12 +4118,6 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate return } - #if DEBUG - let isExternalStream: Bool = true - #else - let isExternalStream: Bool = false - #endif - var cancelImpl: (() -> Void)? let presentationData = strongSelf.presentationData let progressSignal = Signal { [weak self] subscriber in @@ -4135,7 +4134,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate |> runOn(Queue.mainQueue()) |> delay(0.15, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - let createSignal = strongSelf.context.engine.calls.createGroupCall(peerId: peerId, title: nil, scheduleDate: nil, isExternalStream: isExternalStream) + let createSignal = strongSelf.context.engine.calls.createGroupCall(peerId: peerId, title: nil, scheduleDate: nil, isExternalStream: false) |> afterDisposed { Queue.mainQueue().async { progressDisposable.dispose() @@ -4467,6 +4466,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate } private func openVoiceChatOptions(defaultJoinAsPeerId: PeerId?, gesture: ContextGesture? = nil, contextController: ContextControllerProtocol? = nil) { + guard let chatPeer = self.data?.peer else { + return + } let context = self.context let peerId = self.peerId let defaultJoinAsPeerId = defaultJoinAsPeerId ?? self.context.account.peerId @@ -4534,6 +4536,31 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate self?.scheduleGroupCall() }))) + var credentialsPromise: Promise? + var canCreateStream = false + switch chatPeer { + case let group as TelegramGroup: + if case .creator = group.role { + canCreateStream = true + } + case let channel as TelegramChannel: + if channel.flags.contains(.isCreator) { + canCreateStream = true + credentialsPromise = Promise() + credentialsPromise?.set(context.engine.calls.getGroupCallStreamCredentials(peerId: peerId, revokePreviousCredentials: false) |> `catch` { _ -> Signal in return .never() }) + } + default: + break + } + + if canCreateStream { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.ChannelInfo_CreateExternalStream, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/VoiceChat"), color: theme.contextMenu.primaryColor) }, action: { _, f in + f(.dismissWithoutContent) + + self?.createExternalStream(credentialsPromise: credentialsPromise) + }))) + } + if let contextController = contextController { contextController.setItems(.single(ContextController.Items(content: .list(items))), minHeight: nil) } else { diff --git a/submodules/TelegramVoip/Sources/GroupCallContext.swift b/submodules/TelegramVoip/Sources/GroupCallContext.swift index 1d534dd233..f3165ef105 100644 --- a/submodules/TelegramVoip/Sources/GroupCallContext.swift +++ b/submodules/TelegramVoip/Sources/GroupCallContext.swift @@ -2,8 +2,9 @@ import Foundation import SwiftSignalKit import TgVoipWebrtc import TelegramCore +import Postbox -private final class ContextQueueImpl: NSObject, OngoingCallThreadLocalContextQueueWebrtc { +final class ContextQueueImpl: NSObject, OngoingCallThreadLocalContextQueueWebrtc { private let queue: Queue init(queue: Queue) { @@ -27,17 +28,17 @@ private final class ContextQueueImpl: NSObject, OngoingCallThreadLocalContextQue } } -private enum BroadcastPartSubject { +enum BroadcastPartSubject { case audio case video(channelId: Int32, quality: OngoingGroupCallContext.VideoChannel.Quality) } -private protocol BroadcastPartSource: AnyObject { +protocol BroadcastPartSource: AnyObject { func requestTime(completion: @escaping (Int64) -> Void) -> Disposable func requestPart(timestampMilliseconds: Int64, durationMilliseconds: Int64, subject: BroadcastPartSubject, completion: @escaping (OngoingGroupCallBroadcastPart) -> Void, rejoinNeeded: @escaping () -> Void) -> Disposable } -private final class NetworkBroadcastPartSource: BroadcastPartSource { +final class NetworkBroadcastPartSource: BroadcastPartSource { private let queue: Queue private let engine: TelegramEngine private let callId: Int64 @@ -45,6 +46,10 @@ private final class NetworkBroadcastPartSource: BroadcastPartSource { private let isExternalStream: Bool private var dataSource: AudioBroadcastDataSource? + #if DEBUG + private let debugDumpDirectory = TempBox.shared.tempDirectory() + #endif + init(queue: Queue, engine: TelegramEngine, callId: Int64, accessHash: Int64, isExternalStream: Bool) { self.queue = queue self.engine = engine @@ -139,6 +144,9 @@ private final class NetworkBroadcastPartSource: BroadcastPartSource { } |> deliverOn(self.queue) + #if DEBUG + let debugDumpDirectory = self.debugDumpDirectory + #endif return signal.start(next: { result in guard let result = result else { completion(OngoingGroupCallBroadcastPart(timestampMilliseconds: timestampIdMilliseconds, responseTimestamp: Double(timestampIdMilliseconds), status: .notReady, oggData: Data())) @@ -147,11 +155,11 @@ private final class NetworkBroadcastPartSource: BroadcastPartSource { let part: OngoingGroupCallBroadcastPart switch result.status { case let .data(dataValue): - /*#if DEBUG - let tempFile = EngineTempBox.shared.tempFile(fileName: "part.mp4") - let _ = try? dataValue.write(to: URL(fileURLWithPath: tempFile.path)) - print("Dump stream part: \(tempFile.path)") - #endif*/ + #if DEBUG + let tempFilePath = debugDumpDirectory.path + "/\(timestampMilliseconds).mp4" + let _ = try? dataValue.subdata(in: 32 ..< dataValue.count).write(to: URL(fileURLWithPath: tempFilePath)) + print("Dump stream part: \(tempFilePath)") + #endif part = OngoingGroupCallBroadcastPart(timestampMilliseconds: timestampIdMilliseconds, responseTimestamp: result.responseTimestamp, status: .success, oggData: dataValue) case .notReady: part = OngoingGroupCallBroadcastPart(timestampMilliseconds: timestampIdMilliseconds, responseTimestamp: result.responseTimestamp, status: .notReady, oggData: Data()) @@ -167,7 +175,7 @@ private final class NetworkBroadcastPartSource: BroadcastPartSource { } } -private final class OngoingGroupCallBroadcastPartTaskImpl : NSObject, OngoingGroupCallBroadcastPartTask { +final class OngoingGroupCallBroadcastPartTaskImpl: NSObject, OngoingGroupCallBroadcastPartTask { private let disposable: Disposable? init(disposable: Disposable?) { @@ -209,6 +217,11 @@ public final class OngoingGroupCallContext { public struct NetworkState: Equatable { public var isConnected: Bool public var isTransitioningFromBroadcastToRtc: Bool + + public init(isConnected: Bool, isTransitioningFromBroadcastToRtc: Bool) { + self.isConnected = isConnected + self.isTransitioningFromBroadcastToRtc = isTransitioningFromBroadcastToRtc + } } public enum AudioLevelKey: Hashable { diff --git a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift new file mode 100644 index 0000000000..3ae1b27e2e --- /dev/null +++ b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift @@ -0,0 +1,134 @@ +import Foundation +import SwiftSignalKit +import TgVoipWebrtc +import TelegramCore + +public final class WrappedMediaStreamingContext { + private final class Impl { + let queue: Queue + let context: MediaStreamingContext + + private let broadcastPartsSource = Atomic(value: nil) + + init(queue: Queue, rejoinNeeded: @escaping () -> Void) { + self.queue = queue + + var getBroadcastPartsSource: (() -> BroadcastPartSource?)? + + self.context = MediaStreamingContext( + queue: ContextQueueImpl(queue: queue), + requestCurrentTime: { completion in + let disposable = MetaDisposable() + + queue.async { + if let source = getBroadcastPartsSource?() { + disposable.set(source.requestTime(completion: completion)) + } else { + completion(0) + } + } + + return OngoingGroupCallBroadcastPartTaskImpl(disposable: disposable) + }, + requestAudioBroadcastPart: { timestampMilliseconds, durationMilliseconds, completion in + let disposable = MetaDisposable() + + queue.async { + disposable.set(getBroadcastPartsSource?()?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .audio, completion: completion, rejoinNeeded: { + rejoinNeeded() + })) + } + + return OngoingGroupCallBroadcastPartTaskImpl(disposable: disposable) + }, + requestVideoBroadcastPart: { timestampMilliseconds, durationMilliseconds, channelId, quality, completion in + let disposable = MetaDisposable() + + queue.async { + let mappedQuality: OngoingGroupCallContext.VideoChannel.Quality + switch quality { + case .thumbnail: + mappedQuality = .thumbnail + case .medium: + mappedQuality = .medium + case .full: + mappedQuality = .full + @unknown default: + mappedQuality = .thumbnail + } + disposable.set(getBroadcastPartsSource?()?.requestPart(timestampMilliseconds: timestampMilliseconds, durationMilliseconds: durationMilliseconds, subject: .video(channelId: channelId, quality: mappedQuality), completion: completion, rejoinNeeded: { + rejoinNeeded() + })) + } + + return OngoingGroupCallBroadcastPartTaskImpl(disposable: disposable) + } + ) + + let broadcastPartsSource = self.broadcastPartsSource + getBroadcastPartsSource = { + return broadcastPartsSource.with { $0 } + } + } + + deinit { + } + + func setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData?) { + if let audioStreamData = audioStreamData { + let broadcastPartsSource = NetworkBroadcastPartSource(queue: self.queue, engine: audioStreamData.engine, callId: audioStreamData.callId, accessHash: audioStreamData.accessHash, isExternalStream: audioStreamData.isExternalStream) + let _ = self.broadcastPartsSource.swap(broadcastPartsSource) + self.context.start() + } + } + + func video() -> Signal { + let queue = self.queue + return Signal { [weak self] subscriber in + let disposable = MetaDisposable() + + queue.async { + guard let strongSelf = self else { + return + } + let innerDisposable = strongSelf.context.addVideoOutput() { videoFrameData in + subscriber.putNext(OngoingGroupCallContext.VideoFrameData(frameData: videoFrameData)) + } + disposable.set(ActionDisposable { + innerDisposable.dispose() + }) + } + + return disposable + } + } + } + + private let queue = Queue() + private let impl: QueueLocalObject + + public init(rejoinNeeded: @escaping () -> Void) { + let queue = self.queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, rejoinNeeded: rejoinNeeded) + }) + } + + public func setAudioStreamData(audioStreamData: OngoingGroupCallContext.AudioStreamData?) { + self.impl.with { impl in + impl.setAudioStreamData(audioStreamData: audioStreamData) + } + } + + public func video() -> Signal { + return Signal { subscriber in + let disposable = MetaDisposable() + self.impl.with { impl in + disposable.set(impl.video().start(next: { value in + subscriber.putNext(value) + })) + } + return disposable + } + } +} diff --git a/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/MediaStreaming.h b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/MediaStreaming.h new file mode 100644 index 0000000000..8a5db63a01 --- /dev/null +++ b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/MediaStreaming.h @@ -0,0 +1,23 @@ +#ifndef TgVoipWebrtc_MediaStreaming_h +#define TgVoipWebrtc_MediaStreaming_h + +#import + +#import + +@interface MediaStreamingContext : NSObject + +- (instancetype _Nonnull)initWithQueue:(id _Nonnull)queue + requestCurrentTime:(id _Nonnull (^ _Nonnull)(void (^ _Nonnull)(int64_t)))requestAudioBroadcastPart + requestAudioBroadcastPart:(id _Nonnull (^ _Nonnull)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestAudioBroadcastPart + requestVideoBroadcastPart:(id _Nonnull (^ _Nonnull)(int64_t, int64_t, int32_t, OngoingGroupCallRequestedVideoQuality, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestVideoBroadcastPart; + +- (void)start; +- (void)stop; + +- (GroupCallDisposable * _Nonnull)addVideoOutput:(void (^_Nonnull)(CallVideoFrameData * _Nonnull))sink; +- (void)getAudio:(int16_t * _Nonnull)audioSamples numSamples:(NSInteger)numSamples numChannels:(NSInteger)numChannels samplesPerSecond:(NSInteger)samplesPerSecond; + +@end + +#endif diff --git a/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h index bc7dbff2cb..5736fdaa25 100644 --- a/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h +++ b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h @@ -112,6 +112,7 @@ typedef NS_ENUM(int32_t, OngoingCallDataSavingWebrtc) { @interface GroupCallDisposable : NSObject +- (instancetype _Nonnull)initWithBlock:(dispatch_block_t _Nonnull)block; - (void)dispose; @end diff --git a/submodules/TgVoipWebrtc/Sources/MediaStreaming.mm b/submodules/TgVoipWebrtc/Sources/MediaStreaming.mm new file mode 100644 index 0000000000..5468c637b6 --- /dev/null +++ b/submodules/TgVoipWebrtc/Sources/MediaStreaming.mm @@ -0,0 +1,273 @@ +#import + +#import "MediaUtils.h" + +#include "StaticThreads.h" +#include "group/StreamingMediaContext.h" + +#include "api/video/video_sink_interface.h" +#include "sdk/objc/native/src/objc_frame_buffer.h" +#include "api/video/video_frame.h" + +#import "components/video_frame_buffer/RTCCVPixelBuffer.h" +#import "platform/darwin/TGRTCCVPixelBuffer.h" + +#include + +namespace { + +class BroadcastPartTaskImpl : public tgcalls::BroadcastPartTask { +public: + BroadcastPartTaskImpl(id task) { + _task = task; + } + + virtual ~BroadcastPartTaskImpl() { + } + + virtual void cancel() override { + [_task cancel]; + } + +private: + id _task; +}; + +class VideoSinkAdapter : public rtc::VideoSinkInterface { +public: + VideoSinkAdapter(void (^frameReceived)(webrtc::VideoFrame const &)) { + _frameReceived = [frameReceived copy]; + } + + void OnFrame(const webrtc::VideoFrame& nativeVideoFrame) override { + @autoreleasepool { + if (_frameReceived) { + _frameReceived(nativeVideoFrame); + } + } + } + +private: + void (^_frameReceived)(webrtc::VideoFrame const &); +}; + +} + +@interface MediaStreamingVideoSink : NSObject { + std::shared_ptr _adapter; +} + +@end + + +@implementation MediaStreamingVideoSink + +- (instancetype)initWithSink:(void (^_Nonnull)(CallVideoFrameData * _Nonnull))sink { + self = [super init]; + if (self != nil) { + void (^storedSink)(CallVideoFrameData * _Nonnull) = [sink copy]; + + _adapter.reset(new VideoSinkAdapter(^(webrtc::VideoFrame const &videoFrame) { + id mappedBuffer = nil; + + bool mirrorHorizontally = false; + bool mirrorVertically = false; + + if (videoFrame.video_frame_buffer()->type() == webrtc::VideoFrameBuffer::Type::kNative) { + id nativeBuffer = static_cast(videoFrame.video_frame_buffer().get())->wrapped_frame_buffer(); + if ([nativeBuffer isKindOfClass:[RTC_OBJC_TYPE(RTCCVPixelBuffer) class]]) { + RTCCVPixelBuffer *pixelBuffer = (RTCCVPixelBuffer *)nativeBuffer; + mappedBuffer = [[CallVideoFrameNativePixelBuffer alloc] initWithPixelBuffer:pixelBuffer.pixelBuffer]; + } + if ([nativeBuffer isKindOfClass:[TGRTCCVPixelBuffer class]]) { + if (((TGRTCCVPixelBuffer *)nativeBuffer).shouldBeMirrored) { + switch (videoFrame.rotation()) { + case webrtc::kVideoRotation_0: + case webrtc::kVideoRotation_180: + mirrorHorizontally = true; + break; + case webrtc::kVideoRotation_90: + case webrtc::kVideoRotation_270: + mirrorVertically = true; + break; + default: + break; + } + } + } + } else if (videoFrame.video_frame_buffer()->type() == webrtc::VideoFrameBuffer::Type::kNV12) { + rtc::scoped_refptr nv12Buffer = (webrtc::NV12BufferInterface *)videoFrame.video_frame_buffer().get(); + mappedBuffer = [[CallVideoFrameNV12Buffer alloc] initWithBuffer:nv12Buffer]; + } else if (videoFrame.video_frame_buffer()->type() == webrtc::VideoFrameBuffer::Type::kI420) { + rtc::scoped_refptr i420Buffer = (webrtc::I420BufferInterface *)videoFrame.video_frame_buffer().get(); + mappedBuffer = [[CallVideoFrameI420Buffer alloc] initWithBuffer:i420Buffer]; + } + + if (storedSink && mappedBuffer) { + storedSink([[CallVideoFrameData alloc] initWithBuffer:mappedBuffer frame:videoFrame mirrorHorizontally:mirrorHorizontally mirrorVertically:mirrorVertically]); + } + })); + } + return self; +} + +- (std::shared_ptr>)sink { + return _adapter; +} + +@end + +@interface MediaStreamingContext () { + id _queue; + + id _Nonnull (^ _Nonnull _requestCurrentTime)(void (^ _Nonnull)(int64_t)); + id _Nonnull (^ _Nonnull _requestAudioBroadcastPart)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)); + id _Nonnull (^ _Nonnull _requestVideoBroadcastPart)(int64_t, int64_t, int32_t, OngoingGroupCallRequestedVideoQuality, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)); + + std::unique_ptr _context; + + int _nextSinkId; + NSMutableDictionary *_sinks; +} + +@end + +@implementation MediaStreamingContext + +- (instancetype _Nonnull)initWithQueue:(id _Nonnull)queue + requestCurrentTime:(id _Nonnull (^ _Nonnull)(void (^ _Nonnull)(int64_t)))requestCurrentTime + requestAudioBroadcastPart:(id _Nonnull (^ _Nonnull)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestAudioBroadcastPart + requestVideoBroadcastPart:(id _Nonnull (^ _Nonnull)(int64_t, int64_t, int32_t, OngoingGroupCallRequestedVideoQuality, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestVideoBroadcastPart { + self = [super init]; + if (self != nil) { + _queue = queue; + + _requestCurrentTime = [requestCurrentTime copy]; + _requestAudioBroadcastPart = [requestAudioBroadcastPart copy]; + _requestVideoBroadcastPart = [requestVideoBroadcastPart copy]; + + _sinks = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)dealloc { +} + +- (void)resetContext { + tgcalls::StreamingMediaContext::StreamingMediaContextArguments arguments; + arguments.threads = tgcalls::StaticThreads::getThreads(); + arguments.isUnifiedBroadcast = true; + arguments.requestCurrentTime = [requestCurrentTime = _requestCurrentTime](std::function completion) -> std::shared_ptr { + id task = requestCurrentTime(^(int64_t result) { + completion(result); + }); + return std::make_shared(task); + }; + arguments.requestAudioBroadcastPart = nullptr; + arguments.requestVideoBroadcastPart = [requestVideoBroadcastPart = _requestVideoBroadcastPart](int64_t timestampMilliseconds, int64_t durationMilliseconds, int32_t channelId, tgcalls::VideoChannelDescription::Quality quality, std::function completion) -> std::shared_ptr { + OngoingGroupCallRequestedVideoQuality mappedQuality; + switch (quality) { + case tgcalls::VideoChannelDescription::Quality::Thumbnail: { + mappedQuality = OngoingGroupCallRequestedVideoQualityThumbnail; + break; + } + case tgcalls::VideoChannelDescription::Quality::Medium: { + mappedQuality = OngoingGroupCallRequestedVideoQualityMedium; + break; + } + case tgcalls::VideoChannelDescription::Quality::Full: { + mappedQuality = OngoingGroupCallRequestedVideoQualityFull; + break; + } + default: { + mappedQuality = OngoingGroupCallRequestedVideoQualityThumbnail; + break; + } + } + id task = requestVideoBroadcastPart(timestampMilliseconds, durationMilliseconds, channelId, mappedQuality, ^(OngoingGroupCallBroadcastPart * _Nullable part) { + tgcalls::BroadcastPart parsedPart; + parsedPart.timestampMilliseconds = part.timestampMilliseconds; + + parsedPart.responseTimestamp = part.responseTimestamp; + + tgcalls::BroadcastPart::Status mappedStatus; + switch (part.status) { + case OngoingGroupCallBroadcastPartStatusSuccess: { + mappedStatus = tgcalls::BroadcastPart::Status::Success; + break; + } + case OngoingGroupCallBroadcastPartStatusNotReady: { + mappedStatus = tgcalls::BroadcastPart::Status::NotReady; + break; + } + case OngoingGroupCallBroadcastPartStatusResyncNeeded: { + mappedStatus = tgcalls::BroadcastPart::Status::ResyncNeeded; + break; + } + default: { + mappedStatus = tgcalls::BroadcastPart::Status::NotReady; + break; + } + } + parsedPart.status = mappedStatus; + + parsedPart.data.resize(part.oggData.length); + [part.oggData getBytes:parsedPart.data.data() length:part.oggData.length]; + + completion(std::move(parsedPart)); + }); + return std::make_shared(task); + }; + + arguments.updateAudioLevel = nullptr; + + _context = std::make_unique(std::move(arguments)); + + for (MediaStreamingVideoSink *storedSink in _sinks.allValues) { + _context->addVideoSink("unified", [storedSink sink]); + } +} + +- (void)start { + [self resetContext]; +} + +- (void)stop { + _context.reset(); +} + +- (GroupCallDisposable * _Nonnull)addVideoOutput:(void (^_Nonnull)(CallVideoFrameData * _Nonnull))sink { + int sinkId = _nextSinkId; + _nextSinkId += 1; + + MediaStreamingVideoSink *storedSink = [[MediaStreamingVideoSink alloc] initWithSink:sink]; + _sinks[@(sinkId)] = storedSink; + + if (_context) { + _context->addVideoSink("unified", [storedSink sink]); + } + + __weak MediaStreamingContext *weakSelf = self; + id queue = _queue; + return [[GroupCallDisposable alloc] initWithBlock:^{ + [queue dispatch:^{ + __strong MediaStreamingContext *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + [strongSelf->_sinks removeObjectForKey:@(sinkId)]; + }]; + }]; +} + +- (void)getAudio:(int16_t * _Nonnull)audioSamples numSamples:(NSInteger)numSamples numChannels:(NSInteger)numChannels samplesPerSecond:(NSInteger)samplesPerSecond { + if (_context) { + _context->getAudio(audioSamples, (size_t)numSamples, (size_t)numChannels, (uint32_t)samplesPerSecond); + } else { + memset(audioSamples, 0, numSamples * numChannels * sizeof(int16_t)); + } +} + +@end diff --git a/submodules/TgVoipWebrtc/Sources/MediaUtils.h b/submodules/TgVoipWebrtc/Sources/MediaUtils.h new file mode 100644 index 0000000000..060651018f --- /dev/null +++ b/submodules/TgVoipWebrtc/Sources/MediaUtils.h @@ -0,0 +1,58 @@ +#import + + +#import "Instance.h" +#import "InstanceImpl.h" +#import "v2/InstanceV2Impl.h" +#include "StaticThreads.h" + +#import "VideoCaptureInterface.h" +#import "platform/darwin/VideoCameraCapturer.h" + +#ifndef WEBRTC_IOS +#import "platform/darwin/VideoMetalViewMac.h" +#import "platform/darwin/GLVideoViewMac.h" +#import "platform/darwin/VideoSampleBufferViewMac.h" +#define UIViewContentModeScaleAspectFill kCAGravityResizeAspectFill +#define UIViewContentModeScaleAspect kCAGravityResizeAspect + +#else +#import "platform/darwin/VideoMetalView.h" +#import "platform/darwin/GLVideoView.h" +#import "platform/darwin/VideoSampleBufferView.h" +#import "platform/darwin/VideoCaptureView.h" +#import "platform/darwin/CustomExternalCapturer.h" +#endif + +#import "group/GroupInstanceImpl.h" +#import "group/GroupInstanceCustomImpl.h" + +#import "VideoCaptureInterfaceImpl.h" + +#include "sdk/objc/native/src/objc_frame_buffer.h" +#import "components/video_frame_buffer/RTCCVPixelBuffer.h" +#import "platform/darwin/TGRTCCVPixelBuffer.h" + +@interface CallVideoFrameNativePixelBuffer (Initialization) + +- (instancetype _Nonnull)initWithPixelBuffer:(CVPixelBufferRef _Nonnull)pixelBuffer; + +@end + +@interface CallVideoFrameI420Buffer (Initialization) + +- (instancetype _Nonnull)initWithBuffer:(rtc::scoped_refptr)i420Buffer; + +@end + +@interface CallVideoFrameNV12Buffer (Initialization) + +- (instancetype _Nonnull)initWithBuffer:(rtc::scoped_refptr)nv12Buffer; + +@end + +@interface CallVideoFrameData (Initialization) + +- (instancetype _Nonnull)initWithBuffer:(id _Nonnull)buffer frame:(webrtc::VideoFrame const &)frame mirrorHorizontally:(bool)mirrorHorizontally mirrorVertically:(bool)mirrorVertically; + +@end diff --git a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm index 1b862efe8a..53f9aaf5ef 100644 --- a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm +++ b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm @@ -1,5 +1,6 @@ #import +#import "MediaUtils.h" #import "Instance.h" #import "InstanceImpl.h" diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index d5d8fc5467..4f3f4025b9 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit d5d8fc5467d490319572fbccd864fa6bd78b7877 +Subproject commit 4f3f4025b9b4ad9662612636af10e6fd5d204535