mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
953624e278
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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";
|
||||
|
@ -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
|
||||
|
@ -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<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, transition: transition)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
22
submodules/Components/AnimatedStickerComponent/BUILD
Normal file
22
submodules/Components/AnimatedStickerComponent/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, transition: transition)
|
||||
}
|
||||
}
|
20
submodules/Components/BundleIconComponent/BUILD
Normal file
20
submodules/Components/BundleIconComponent/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, transition: transition)
|
||||
}
|
||||
}
|
17
submodules/Components/HierarchyTrackingLayer/BUILD
Normal file
17
submodules/Components/HierarchyTrackingLayer/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ swift_library(
|
||||
"//submodules/ComponentFlow:ComponentFlow",
|
||||
"//submodules/lottie-ios:Lottie",
|
||||
"//submodules/AppBundle:AppBundle",
|
||||
"//submodules/Components/HierarchyTrackingLayer:HierarchyTrackingLayer",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -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 {
|
||||
|
22
submodules/Components/MultilineTextComponent/BUILD
Normal file
22
submodules/Components/MultilineTextComponent/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize)
|
||||
}
|
||||
}
|
20
submodules/Components/SolidRoundedButtonComponent/BUILD
Normal file
20
submodules/Components/SolidRoundedButtonComponent/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<Empty>, transition: Transition) -> CGSize {
|
||||
return view.update(component: self, availableSize: availableSize, transition: transition)
|
||||
}
|
||||
}
|
22
submodules/Components/ViewControllerComponent/BUILD
Normal file
22
submodules/Components/ViewControllerComponent/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<ViewControllerComponentContainer.Environment>
|
||||
let hostView: ComponentHostView<ViewControllerComponentContainer.Environment>
|
||||
public let hostView: ComponentHostView<ViewControllerComponentContainer.Environment>
|
||||
|
||||
private var currentIsVisible: Bool = false
|
||||
private var currentLayout: ContainerViewLayout?
|
||||
private var currentLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
|
||||
|
||||
init(context: AccountContext, controller: ViewControllerComponentContainer, component: AnyComponent<ViewControllerComponentContainer.Environment>) {
|
||||
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<ViewControllerComponentContainer.Environment>
|
||||
|
||||
public init<C: Component>(context: AccountContext, component: C) where C.EnvironmentType == ViewControllerComponentContainer.Environment {
|
||||
public init<C: Component>(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))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,6 +74,7 @@ swift_library(
|
||||
"//submodules/AnimatedStickerNode:AnimatedStickerNode",
|
||||
"//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode",
|
||||
"//submodules/Components/ReactionImageComponent:ReactionImageComponent",
|
||||
"//submodules/Components/LottieAnimationComponent:LottieAnimationComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
32
submodules/PeerInfoUI/CreateExternalMediaStreamScreen/BUILD
Normal file
32
submodules/PeerInfoUI/CreateExternalMediaStreamScreen/BUILD
Normal file
@ -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",
|
||||
],
|
||||
)
|
@ -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<GroupCallStreamCredentials>?
|
||||
|
||||
init(context: AccountContext, peerId: EnginePeer.Id, credentialsPromise: Promise<GroupCallStreamCredentials>?) {
|
||||
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<GroupCallStreamCredentials>?) {
|
||||
self.context = context
|
||||
self.peerId = peerId
|
||||
|
||||
super.init()
|
||||
|
||||
let credentialsSignal: Signal<GroupCallStreamCredentials, NoError>
|
||||
if let credentialsPromise = credentialsPromise {
|
||||
credentialsSignal = credentialsPromise.get()
|
||||
} else {
|
||||
credentialsSignal = context.engine.calls.getGroupCallStreamCredentials(peerId: peerId, revokePreviousCredentials: false)
|
||||
|> `catch` { _ -> Signal<GroupCallStreamCredentials, NoError> 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<GroupCallStreamCredentials, String>) {
|
||||
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<Never, NoError> { [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<GroupCallStreamCredentials>?) {
|
||||
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)
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ swift_library(
|
||||
deps = [
|
||||
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
|
||||
"//submodules/Display:Display",
|
||||
"//submodules/Components/HierarchyTrackingLayer:HierarchyTrackingLayer",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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<Empty>, 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
|
||||
|
@ -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<Action<Void>>
|
||||
let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void
|
||||
|
||||
init(call: PresentationGroupCallImpl, hasVideo: Bool, isVisible: Bool, activatePictureInPicture: ActionSlot<Action<Void>>, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void) {
|
||||
init(call: PresentationGroupCallImpl, hasVideo: Bool, isVisible: Bool, isAdmin: Bool, peerTitle: String, activatePictureInPicture: ActionSlot<Action<Void>>, 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<Empty>?
|
||||
private var noSignalView: ComponentHostView<Empty>?
|
||||
|
||||
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<Empty>
|
||||
if let current = self.noSignalView {
|
||||
noSignalView = current
|
||||
} else {
|
||||
noSignalTransition = transition.withAnimation(.none)
|
||||
noSignalView = ComponentHostView<Empty>()
|
||||
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
|
||||
|
@ -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<OngoingGroupCallContext.NetworkState, NoError> {
|
||||
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<Bool, NoError> {
|
||||
switch self {
|
||||
case let .call(callContext):
|
||||
return callContext.isMuted
|
||||
case .mediaStream:
|
||||
return .single(true)
|
||||
}
|
||||
}
|
||||
|
||||
var isNoiseSuppressionEnabled: Signal<Bool, NoError> {
|
||||
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<OngoingGroupCallContext.VideoFrameData, NoError> {
|
||||
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
|
||||
|
@ -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,
|
||||
|
BIN
submodules/TelegramUI/Resources/Animations/CreateStream.tgs
Normal file
BIN
submodules/TelegramUI/Resources/Animations/CreateStream.tgs
Normal file
Binary file not shown.
@ -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<GroupCallStreamCredentials>?) {
|
||||
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<Never, NoError> { [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<GroupCallStreamCredentials>?
|
||||
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<GroupCallStreamCredentials, NoError> 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 {
|
||||
|
@ -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 {
|
||||
|
@ -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<BroadcastPartSource?>(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<OngoingGroupCallContext.VideoFrameData, NoError> {
|
||||
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<Impl>
|
||||
|
||||
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<OngoingGroupCallContext.VideoFrameData, NoError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
#ifndef TgVoipWebrtc_MediaStreaming_h
|
||||
#define TgVoipWebrtc_MediaStreaming_h
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#import <TgVoipWebrtc/OngoingCallThreadLocalContext.h>
|
||||
|
||||
@interface MediaStreamingContext : NSObject
|
||||
|
||||
- (instancetype _Nonnull)initWithQueue:(id<OngoingCallThreadLocalContextQueueWebrtc> _Nonnull)queue
|
||||
requestCurrentTime:(id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull)(void (^ _Nonnull)(int64_t)))requestAudioBroadcastPart
|
||||
requestAudioBroadcastPart:(id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestAudioBroadcastPart
|
||||
requestVideoBroadcastPart:(id<OngoingGroupCallBroadcastPartTask> _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
|
@ -112,6 +112,7 @@ typedef NS_ENUM(int32_t, OngoingCallDataSavingWebrtc) {
|
||||
|
||||
@interface GroupCallDisposable : NSObject
|
||||
|
||||
- (instancetype _Nonnull)initWithBlock:(dispatch_block_t _Nonnull)block;
|
||||
- (void)dispose;
|
||||
|
||||
@end
|
||||
|
273
submodules/TgVoipWebrtc/Sources/MediaStreaming.mm
Normal file
273
submodules/TgVoipWebrtc/Sources/MediaStreaming.mm
Normal file
@ -0,0 +1,273 @@
|
||||
#import <TgVoipWebrtc/MediaStreaming.h>
|
||||
|
||||
#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 <memory>
|
||||
|
||||
namespace {
|
||||
|
||||
class BroadcastPartTaskImpl : public tgcalls::BroadcastPartTask {
|
||||
public:
|
||||
BroadcastPartTaskImpl(id<OngoingGroupCallBroadcastPartTask> task) {
|
||||
_task = task;
|
||||
}
|
||||
|
||||
virtual ~BroadcastPartTaskImpl() {
|
||||
}
|
||||
|
||||
virtual void cancel() override {
|
||||
[_task cancel];
|
||||
}
|
||||
|
||||
private:
|
||||
id<OngoingGroupCallBroadcastPartTask> _task;
|
||||
};
|
||||
|
||||
class VideoSinkAdapter : public rtc::VideoSinkInterface<webrtc::VideoFrame> {
|
||||
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<VideoSinkAdapter> _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<CallVideoFrameBuffer> mappedBuffer = nil;
|
||||
|
||||
bool mirrorHorizontally = false;
|
||||
bool mirrorVertically = false;
|
||||
|
||||
if (videoFrame.video_frame_buffer()->type() == webrtc::VideoFrameBuffer::Type::kNative) {
|
||||
id<RTC_OBJC_TYPE(RTCVideoFrameBuffer)> nativeBuffer = static_cast<webrtc::ObjCFrameBuffer *>(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<webrtc::NV12BufferInterface> 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<webrtc::I420BufferInterface> 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<rtc::VideoSinkInterface<webrtc::VideoFrame>>)sink {
|
||||
return _adapter;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface MediaStreamingContext () {
|
||||
id<OngoingCallThreadLocalContextQueueWebrtc> _queue;
|
||||
|
||||
id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull _requestCurrentTime)(void (^ _Nonnull)(int64_t));
|
||||
id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull _requestAudioBroadcastPart)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable));
|
||||
id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull _requestVideoBroadcastPart)(int64_t, int64_t, int32_t, OngoingGroupCallRequestedVideoQuality, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable));
|
||||
|
||||
std::unique_ptr<tgcalls::StreamingMediaContext> _context;
|
||||
|
||||
int _nextSinkId;
|
||||
NSMutableDictionary<NSNumber *, MediaStreamingVideoSink *> *_sinks;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation MediaStreamingContext
|
||||
|
||||
- (instancetype _Nonnull)initWithQueue:(id<OngoingCallThreadLocalContextQueueWebrtc> _Nonnull)queue
|
||||
requestCurrentTime:(id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull)(void (^ _Nonnull)(int64_t)))requestCurrentTime
|
||||
requestAudioBroadcastPart:(id<OngoingGroupCallBroadcastPartTask> _Nonnull (^ _Nonnull)(int64_t, int64_t, void (^ _Nonnull)(OngoingGroupCallBroadcastPart * _Nullable)))requestAudioBroadcastPart
|
||||
requestVideoBroadcastPart:(id<OngoingGroupCallBroadcastPartTask> _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<void(int64_t)> completion) -> std::shared_ptr<tgcalls::BroadcastPartTask> {
|
||||
id<OngoingGroupCallBroadcastPartTask> task = requestCurrentTime(^(int64_t result) {
|
||||
completion(result);
|
||||
});
|
||||
return std::make_shared<BroadcastPartTaskImpl>(task);
|
||||
};
|
||||
arguments.requestAudioBroadcastPart = nullptr;
|
||||
arguments.requestVideoBroadcastPart = [requestVideoBroadcastPart = _requestVideoBroadcastPart](int64_t timestampMilliseconds, int64_t durationMilliseconds, int32_t channelId, tgcalls::VideoChannelDescription::Quality quality, std::function<void(tgcalls::BroadcastPart &&)> completion) -> std::shared_ptr<tgcalls::BroadcastPartTask> {
|
||||
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<OngoingGroupCallBroadcastPartTask> 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<BroadcastPartTaskImpl>(task);
|
||||
};
|
||||
|
||||
arguments.updateAudioLevel = nullptr;
|
||||
|
||||
_context = std::make_unique<tgcalls::StreamingMediaContext>(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<OngoingCallThreadLocalContextQueueWebrtc> 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
|
58
submodules/TgVoipWebrtc/Sources/MediaUtils.h
Normal file
58
submodules/TgVoipWebrtc/Sources/MediaUtils.h
Normal file
@ -0,0 +1,58 @@
|
||||
#import <TgVoipWebrtc/OngoingCallThreadLocalContext.h>
|
||||
|
||||
|
||||
#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<webrtc::I420BufferInterface>)i420Buffer;
|
||||
|
||||
@end
|
||||
|
||||
@interface CallVideoFrameNV12Buffer (Initialization)
|
||||
|
||||
- (instancetype _Nonnull)initWithBuffer:(rtc::scoped_refptr<webrtc::NV12BufferInterface>)nv12Buffer;
|
||||
|
||||
@end
|
||||
|
||||
@interface CallVideoFrameData (Initialization)
|
||||
|
||||
- (instancetype _Nonnull)initWithBuffer:(id<CallVideoFrameBuffer> _Nonnull)buffer frame:(webrtc::VideoFrame const &)frame mirrorHorizontally:(bool)mirrorHorizontally mirrorVertically:(bool)mirrorVertically;
|
||||
|
||||
@end
|
@ -1,5 +1,6 @@
|
||||
#import <TgVoipWebrtc/OngoingCallThreadLocalContext.h>
|
||||
|
||||
#import "MediaUtils.h"
|
||||
|
||||
#import "Instance.h"
|
||||
#import "InstanceImpl.h"
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit d5d8fc5467d490319572fbccd864fa6bd78b7877
|
||||
Subproject commit 4f3f4025b9b4ad9662612636af10e6fd5d204535
|
Loading…
x
Reference in New Issue
Block a user