GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
@@ -0,0 +1,235 @@
import Foundation
import SwiftSignalKit
import TelegramCore
import AccountContext
public final class AccountGroupCallContextImpl: AccountGroupCallContext {
public final class Proxy {
public let context: AccountGroupCallContextImpl
let removed: () -> Void
public init(context: AccountGroupCallContextImpl, removed: @escaping () -> Void) {
self.context = context
self.removed = removed
}
deinit {
self.removed()
}
public func keep() {
}
}
var disposable: Disposable?
public var participantsContext: GroupCallParticipantsContext?
public final class GroupCallPanelData {
public let peerId: EnginePeer.Id
public let isChannel: Bool
public let info: GroupCallInfo
public let topParticipants: [GroupCallParticipantsContext.Participant]
public let participantCount: Int
public let activeSpeakers: Set<EnginePeer.Id>
public let groupCall: PresentationGroupCall?
public init(
peerId: EnginePeer.Id,
isChannel: Bool,
info: GroupCallInfo,
topParticipants: [GroupCallParticipantsContext.Participant],
participantCount: Int,
activeSpeakers: Set<EnginePeer.Id>,
groupCall: PresentationGroupCall?
) {
self.peerId = peerId
self.isChannel = isChannel
self.info = info
self.topParticipants = topParticipants
self.participantCount = participantCount
self.activeSpeakers = activeSpeakers
self.groupCall = groupCall
}
public func withInfo(_ info: GroupCallInfo) -> GroupCallPanelData {
return GroupCallPanelData(
peerId: self.peerId,
isChannel: self.isChannel,
info: info,
topParticipants: self.topParticipants,
participantCount: self.participantCount,
activeSpeakers: self.activeSpeakers,
groupCall: self.groupCall
)
}
}
private let panelDataPromise = Promise<GroupCallPanelData?>()
public var panelData: Signal<GroupCallPanelData?, NoError> {
return self.panelDataPromise.get()
}
public init(account: Account, engine: TelegramEngine, peerId: EnginePeer.Id?, isChannel: Bool, call: EngineGroupCallDescription) {
self.panelDataPromise.set(.single(nil))
let state = engine.calls.getGroupCallParticipants(reference: .id(id: call.id, accessHash: call.accessHash), offset: "", ssrcs: [], limit: 100, sortAscending: nil)
|> map(Optional.init)
|> `catch` { _ -> Signal<GroupCallParticipantsContext.State?, NoError> in
return .single(nil)
}
let peer: Signal<EnginePeer?, NoError>
if let peerId {
peer = engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
} else {
peer = .single(nil)
}
self.disposable = (combineLatest(queue: .mainQueue(),
state,
peer
)
|> deliverOnMainQueue).start(next: { [weak self] state, peer in
guard let self, let state = state else {
return
}
let context = engine.calls.groupCall(
peerId: peerId,
myPeerId: account.peerId,
id: call.id,
reference: .id(id: call.id, accessHash: call.accessHash),
state: state,
previousServiceState: nil,
e2eContext: nil
)
self.participantsContext = context
if let peerId {
self.panelDataPromise.set(combineLatest(queue: .mainQueue(),
context.state,
context.activeSpeakers
)
|> map { state, activeSpeakers -> GroupCallPanelData in
var topParticipants: [GroupCallParticipantsContext.Participant] = []
for participant in state.participants {
if topParticipants.count >= 3 {
break
}
topParticipants.append(participant)
}
var isChannel = false
if let peer = peer, case let .channel(channel) = peer, case .broadcast = channel.info {
isChannel = true
}
return GroupCallPanelData(
peerId: peerId,
isChannel: isChannel,
info: GroupCallInfo(
id: call.id,
accessHash: call.accessHash,
participantCount: state.totalCount,
streamDcId: nil,
title: state.title,
scheduleTimestamp: state.scheduleTimestamp,
subscribedToScheduled: state.subscribedToScheduled,
recordingStartTimestamp: nil,
sortAscending: state.sortAscending,
defaultParticipantsAreMuted: state.defaultParticipantsAreMuted,
messagesAreEnabled: state.messagesAreEnabled,
isVideoEnabled: state.isVideoEnabled,
unmutedVideoLimit: state.unmutedVideoLimit,
isStream: state.isStream,
isCreator: state.isCreator,
defaultSendAs: state.defaultSendAs
),
topParticipants: topParticipants,
participantCount: state.totalCount,
activeSpeakers: activeSpeakers,
groupCall: nil
)
})
}
})
}
deinit {
self.disposable?.dispose()
}
}
public final class AccountGroupCallContextCacheImpl: AccountGroupCallContextCache {
public class Impl {
private class Record {
let context: AccountGroupCallContextImpl
let subscribers = Bag<Void>()
var removeTimer: SwiftSignalKit.Timer?
init(context: AccountGroupCallContextImpl) {
self.context = context
}
}
private let queue: Queue
private var contexts: [Int64: Record] = [:]
private let leaveDisposables = DisposableSet()
init(queue: Queue) {
self.queue = queue
}
public func get(account: Account, engine: TelegramEngine, peerId: EnginePeer.Id, isChannel: Bool, call: EngineGroupCallDescription) -> AccountGroupCallContextImpl.Proxy {
let result: Record
if let current = self.contexts[call.id] {
result = current
} else {
let context = AccountGroupCallContextImpl(account: account, engine: engine, peerId: peerId, isChannel: isChannel, call: call)
result = Record(context: context)
self.contexts[call.id] = result
}
let index = result.subscribers.add(Void())
result.removeTimer?.invalidate()
result.removeTimer = nil
return AccountGroupCallContextImpl.Proxy(context: result.context, removed: { [weak self, weak result] in
Queue.mainQueue().async {
if let strongResult = result, let self, self.contexts[call.id] === strongResult {
strongResult.subscribers.remove(index)
if strongResult.subscribers.isEmpty {
let removeTimer = SwiftSignalKit.Timer(timeout: 30, repeat: false, completion: { [weak self] in
if let result = result, let self, self.contexts[call.id] === result, result.subscribers.isEmpty {
self.contexts.removeValue(forKey: call.id)
}
}, queue: .mainQueue())
strongResult.removeTimer = removeTimer
removeTimer.start()
}
}
}
})
}
public func leaveInBackground(engine: TelegramEngine, id: Int64, accessHash: Int64, source: UInt32) {
let disposable = engine.calls.leaveGroupCall(callId: id, accessHash: accessHash, source: source).start(completed: { [weak self] in
guard let self else {
return
}
if let context = self.contexts[id] {
context.context.participantsContext?.removeLocalPeerId()
}
})
self.leaveDisposables.add(disposable)
}
}
let queue: Queue = .mainQueue()
public let impl: QueueLocalObject<Impl>
public init() {
let queue = self.queue
self.impl = QueueLocalObject(queue: queue, generate: {
return Impl(queue: queue)
})
}
}
@@ -0,0 +1,596 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramVoip
import TelegramAudio
import AccountContext
import TelegramNotices
import AppBundle
import TooltipUI
import CallScreen
protocol CallControllerNodeProtocol: AnyObject {
var isMuted: Bool { get set }
var toggleMute: (() -> Void)? { get set }
var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)? { get set }
var beginAudioOuputSelection: ((Bool) -> Void)? { get set }
var acceptCall: (() -> Void)? { get set }
var endCall: (() -> Void)? { get set }
var back: (() -> Void)? { get set }
var presentCallRating: ((CallId, Bool) -> Void)? { get set }
var present: ((ViewController) -> Void)? { get set }
var callEnded: ((Bool) -> Void)? { get set }
var willBeDismissedInteractively: (() -> Void)? { get set }
var dismissedInteractively: (() -> Void)? { get set }
var dismissAllTooltips: (() -> Void)? { get set }
func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?)
func updateCallState(_ callState: PresentationCallState)
func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool)
func animateIn()
func animateOut(completion: @escaping () -> Void)
func expandFromPipIfPossible()
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition)
}
public final class CallController: ViewController {
private var controllerNode: CallControllerNodeProtocol {
return self.displayNode as! CallControllerNodeProtocol
}
private let _ready = Promise<Bool>(false)
override public var ready: Promise<Bool> {
return self._ready
}
private let isDataReady = Promise<Bool>(false)
private let isContentsReady = Promise<Bool>(false)
private let sharedContext: SharedAccountContext
private let account: Account
public let call: PresentationCall
private let easyDebugAccess: Bool
private var presentationData: PresentationData
private var didPlayPresentationAnimation = false
private var peer: Peer?
private var peerDisposable: Disposable?
private var disposable: Disposable?
private var callMutedDisposable: Disposable?
private var isMuted: Bool = false
private var presentedCallRating = false
private var audioOutputStateDisposable: Disposable?
private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)?
private let idleTimerExtensionDisposable = MetaDisposable()
public var restoreUIForPictureInPicture: ((@escaping (Bool) -> Void) -> Void)?
public var onViewDidAppear: (() -> Void)?
public var onViewDidDisappear: (() -> Void)?
private var isAnimatingDismiss: Bool = false
private var isDismissed: Bool = false
public init(sharedContext: SharedAccountContext, account: Account, call: PresentationCall, easyDebugAccess: Bool) {
self.sharedContext = sharedContext
self.account = account
self.call = call
self.easyDebugAccess = easyDebugAccess
self.presentationData = sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: nil)
if let data = call.context.currentAppConfiguration.with({ $0 }).data, data["ios_killswitch_modalcalls"] != nil {
} else {
self.navigationPresentation = .flatModal
self.flatReceivesModalTransition = true
}
self._ready.set(combineLatest(queue: .mainQueue(), self.isDataReady.get(), self.isContentsReady.get())
|> map { a, b -> Bool in
return a && b
}
|> filter { $0 }
|> take(1)
|> timeout(2.0, queue: .mainQueue(), alternate: .single(true)))
self.isOpaqueWhenInOverlay = true
self.statusBar.statusBarStyle = .White
self.statusBar.ignoreInCall = true
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .portrait, compactSize: .portrait)
self.disposable = (call.state
|> deliverOnMainQueue).start(next: { [weak self] callState in
self?.callStateUpdated(callState)
})
self.callMutedDisposable = (call.isMuted
|> deliverOnMainQueue).start(next: { [weak self] value in
if let strongSelf = self {
strongSelf.isMuted = value
if strongSelf.isNodeLoaded {
strongSelf.controllerNode.isMuted = value
}
}
})
self.audioOutputStateDisposable = (call.audioOutputState
|> deliverOnMainQueue).start(next: { [weak self] state in
if let strongSelf = self {
strongSelf.audioOutputState = state
if strongSelf.isNodeLoaded {
strongSelf.controllerNode.updateAudioOutputs(availableOutputs: state.0, currentOutput: state.1)
}
}
})
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.peerDisposable?.dispose()
self.disposable?.dispose()
self.callMutedDisposable?.dispose()
self.audioOutputStateDisposable?.dispose()
self.idleTimerExtensionDisposable.dispose()
}
private func callStateUpdated(_ callState: PresentationCallState) {
if self.isNodeLoaded {
self.controllerNode.updateCallState(callState)
}
}
override public func loadDisplayNode() {
let displayNode = CallControllerNodeV2(
sharedContext: self.sharedContext,
account: self.account,
presentationData: self.presentationData,
statusBar: self.statusBar,
debugInfo: self.call.debugInfo(),
easyDebugAccess: self.easyDebugAccess,
call: self.call
)
self.displayNode = displayNode
self.isContentsReady.set(displayNode.isReady.get())
displayNode.restoreUIForPictureInPicture = { [weak self] completion in
guard let self, let restoreUIForPictureInPicture = self.restoreUIForPictureInPicture else {
completion(false)
return
}
restoreUIForPictureInPicture(completion)
}
self.displayNodeDidLoad()
self.controllerNode.toggleMute = { [weak self] in
self?.call.toggleIsMuted()
}
self.controllerNode.setCurrentAudioOutput = { [weak self] output in
self?.call.setCurrentAudioOutput(output)
}
self.controllerNode.beginAudioOuputSelection = { [weak self] hasMute in
guard let strongSelf = self, let (availableOutputs, currentOutput) = strongSelf.audioOutputState else {
return
}
guard availableOutputs.count >= 2 else {
return
}
if availableOutputs.count == 2 {
for output in availableOutputs {
if output != currentOutput {
strongSelf.call.setCurrentAudioOutput(output)
break
}
}
} else {
let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData)
var items: [ActionSheetItem] = []
for output in availableOutputs {
if hasMute, case .builtin = output {
continue
}
let title: String
var icon: UIImage?
switch output {
case .builtin:
title = UIDevice.current.model
case .speaker:
title = strongSelf.presentationData.strings.Call_AudioRouteSpeaker
icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false)
case .headphones:
title = strongSelf.presentationData.strings.Call_AudioRouteHeadphones
case let .port(port):
title = port.name
if port.type == .bluetooth {
var image = UIImage(bundleImageName: "Call/CallBluetoothButton")
let portName = port.name.lowercased()
if portName.contains("airpods max") {
image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton")
} else if portName.contains("airpods pro") {
image = UIImage(bundleImageName: "Call/CallAirpodsProButton")
} else if portName.contains("airpods") {
image = UIImage(bundleImageName: "Call/CallAirpodsButton")
}
icon = generateScaledImage(image: image, size: CGSize(width: 48.0, height: 48.0), opaque: false)
}
}
items.append(CallRouteActionSheetItem(title: title, icon: icon, selected: output == currentOutput, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
self?.call.setCurrentAudioOutput(output)
}))
}
if hasMute {
items.append(CallRouteActionSheetItem(title: strongSelf.presentationData.strings.Call_AudioRouteMute, icon: generateScaledImage(image: UIImage(bundleImageName: "Call/CallMuteButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false), selected: strongSelf.isMuted, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
self?.call.toggleIsMuted()
}))
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: strongSelf.presentationData.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
strongSelf.present(actionSheet, in: .window(.calls))
}
}
self.controllerNode.acceptCall = { [weak self] in
let _ = self?.call.answer()
}
self.controllerNode.endCall = { [weak self] in
let _ = self?.call.hangUp()
}
self.controllerNode.back = { [weak self] in
let _ = self?.dismiss()
}
displayNode.conferenceAddParticipant = { [weak self] in
guard let self else {
return
}
self.conferenceAddParticipant()
}
self.controllerNode.presentCallRating = { [weak self] callId, isVideo in
if let strongSelf = self, !strongSelf.presentedCallRating {
strongSelf.presentedCallRating = true
Queue.mainQueue().after(0.5, {
let window = strongSelf.window
let controller = callRatingController(sharedContext: strongSelf.sharedContext, account: strongSelf.account, callId: callId, userInitiated: false, isVideo: isVideo, present: { c, a in
if let window = window {
c.presentationArguments = a
window.present(c, on: .root, blockInteraction: false, completion: {})
}
}, push: { [weak self] c in
if let strongSelf = self {
strongSelf.push(c)
}
})
strongSelf.present(controller, in: .window(.root))
})
}
}
self.controllerNode.present = { [weak self] controller in
if let strongSelf = self {
strongSelf.present(controller, in: .window(.root))
}
}
self.controllerNode.dismissAllTooltips = { [weak self] in
if let strongSelf = self {
strongSelf.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
}
}
self.controllerNode.callEnded = { [weak self] didPresentRating in
if let strongSelf = self, !didPresentRating {
let _ = (combineLatest(strongSelf.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings]), ApplicationSpecificNotice.getCallsTabTip(accountManager: strongSelf.sharedContext.accountManager))
|> map { sharedData, callsTabTip -> Int32 in
var value = false
if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings]?.get(CallListSettings.self) {
value = settings.showTab
}
if value {
return 3
} else {
return callsTabTip
}
} |> deliverOnMainQueue).start(next: { [weak self] callsTabTip in
if let strongSelf = self {
if callsTabTip == 2 {
Queue.mainQueue().after(1.0) {
let controller = callSuggestTabController(sharedContext: strongSelf.sharedContext)
strongSelf.present(controller, in: .window(.root))
}
}
if callsTabTip < 3 {
let _ = ApplicationSpecificNotice.incrementCallsTabTips(accountManager: strongSelf.sharedContext.accountManager).start()
}
}
})
}
}
self.controllerNode.willBeDismissedInteractively = { [weak self] in
guard let self else {
return
}
self.notifyDismissed()
}
self.controllerNode.dismissedInteractively = { [weak self] in
guard let self else {
return
}
self.didPlayPresentationAnimation = false
self.superDismiss()
}
let callPeerView: Signal<PeerView?, NoError>
callPeerView = self.account.postbox.peerView(id: self.call.peerId) |> map(Optional.init)
self.peerDisposable = (combineLatest(queue: .mainQueue(),
self.account.postbox.peerView(id: self.account.peerId) |> take(1),
callPeerView,
self.sharedContext.activeAccountsWithInfo |> take(1)
)
|> deliverOnMainQueue).start(next: { [weak self] accountView, view, activeAccountsWithInfo in
if let strongSelf = self {
if let view {
if let accountPeer = accountView.peers[accountView.peerId], let peer = view.peers[view.peerId] {
strongSelf.peer = peer
strongSelf.controllerNode.updatePeer(accountPeer: accountPeer, peer: peer, hasOther: activeAccountsWithInfo.accounts.count > 1)
strongSelf.isDataReady.set(.single(true))
}
} else {
strongSelf.isDataReady.set(.single(true))
}
}
})
self.controllerNode.isMuted = self.isMuted
if let audioOutputState = self.audioOutputState {
self.controllerNode.updateAudioOutputs(availableOutputs: audioOutputState.0, currentOutput: audioOutputState.1)
}
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.isDismissed = false
if !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
self.controllerNode.animateIn()
}
self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension())
DispatchQueue.main.async { [weak self] in
self?.onViewDidAppear?()
}
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.idleTimerExtensionDisposable.set(nil)
self.notifyDismissed()
}
func notifyDismissed() {
if !self.isDismissed {
self.isDismissed = true
DispatchQueue.main.async {
self.onViewDidDisappear?()
}
}
}
final class AnimateOutToGroupChat {
let containerView: UIView
let incomingPeerId: EnginePeer.Id
let incomingVideoLayer: CALayer?
let incomingVideoPlaceholder: VideoSource.Output?
init(
containerView: UIView,
incomingPeerId: EnginePeer.Id,
incomingVideoLayer: CALayer?,
incomingVideoPlaceholder: VideoSource.Output?
) {
self.containerView = containerView
self.incomingPeerId = incomingPeerId
self.incomingVideoLayer = incomingVideoLayer
self.incomingVideoPlaceholder = incomingVideoPlaceholder
}
}
func animateOutToGroupChat(completion: @escaping () -> Void) -> AnimateOutToGroupChat? {
return (self.controllerNode as? CallControllerNodeV2)?.animateOutToGroupChat(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
override public func dismiss(completion: (() -> Void)? = nil) {
if !self.isAnimatingDismiss {
self.notifyDismissed()
self.isAnimatingDismiss = true
self.controllerNode.animateOut(completion: { [weak self] in
guard let self else {
return
}
self.isAnimatingDismiss = false
self.superDismiss()
completion?()
})
}
}
public func dismissWithoutAnimation() {
self.superDismiss()
}
private func superDismiss() {
self.didPlayPresentationAnimation = false
if self.navigationPresentation == .flatModal {
super.dismiss()
} else {
self.presentingViewController?.dismiss(animated: false, completion: nil)
}
}
private func conferenceAddParticipant() {
var disablePeerIds: [EnginePeer.Id] = []
disablePeerIds.append(self.call.context.account.peerId)
disablePeerIds.append(self.call.peerId)
let controller = CallController.openConferenceAddParticipant(context: self.call.context, disablePeerIds: disablePeerIds, shareLink: nil, completion: { [weak self] peers in
guard let self else {
return
}
let _ = self.call.upgradeToConference(invitePeers: peers, completion: { _ in
})
})
self.push(controller)
}
static func openConferenceAddParticipant(context: AccountContext, disablePeerIds: [EnginePeer.Id], shareLink: (() -> Void)?, completion: @escaping ([(id: EnginePeer.Id, isVideo: Bool)]) -> Void) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkColorPresentationTheme)
var options: [ContactListAdditionalOption] = []
var openShareLinkImpl: (() -> Void)?
if shareLink != nil {
options.append(ContactListAdditionalOption(title: presentationData.strings.Call_ShareLink, icon: .generic(UIImage(bundleImageName: "Contact List/LinkActionIcon")!), action: {
openShareLinkImpl?()
}, clearHighlightAutomatically: false))
}
let controller = context.sharedContext.makeContactSelectionController(ContactSelectionControllerParams(
context: context,
updatedPresentationData: (initial: presentationData, signal: .single(presentationData)),
mode: .generic,
title: { strings in
return strings.Call_AddMemberTitle
},
options: .single(options),
displayCallIcons: true,
multipleSelection: .disabled,
confirmation: { peer in
switch peer {
case let .peer(peer, _, _):
let peer = EnginePeer(peer)
guard case let .user(user) = peer else {
return .single(false)
}
if disablePeerIds.contains(user.id) {
return .single(false)
}
if user.botInfo != nil {
return .single(false)
}
return .single(true)
default:
return .single(false)
}
},
isPeerEnabled: { peer in
switch peer {
case let .peer(peer, _, _):
let peer = EnginePeer(peer)
guard case let .user(user) = peer else {
return false
}
if disablePeerIds.contains(user.id) {
return false
}
if user.botInfo != nil {
return false
}
return true
default:
return false
}
}
))
openShareLinkImpl = { [weak controller] in
controller?.dismiss()
shareLink?()
}
controller.navigationPresentation = .modal
let _ = (controller.result |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak controller] result in
guard let result, let peer = result.0.first, case let .peer(peer, _, _) = peer else {
controller?.dismiss()
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
controller?.dismiss()
}
var isVideo = false
switch result.1 {
case .videoCall:
isVideo = true
default:
break
}
completion([(peer.id, isVideo)])
})
return controller
}
@objc private func backPressed() {
self.dismiss()
}
public func expandFromPipIfPossible() {
self.controllerNode.expandFromPipIfPossible()
}
}
@@ -0,0 +1,403 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import AppBundle
import SemanticStatusNode
import AnimationUI
private let labelFont = Font.regular(13.0)
final class CallControllerButtonItemNode: HighlightTrackingButtonNode {
struct Content: Equatable {
enum Appearance: Equatable {
enum Color: Equatable {
case red
case green
case custom(UInt32, CGFloat)
}
case blurred(isFilled: Bool)
case color(Color)
var isFilled: Bool {
if case let .blurred(isFilled) = self {
return isFilled
} else {
return false
}
}
}
enum Image {
case cameraOff
case cameraOn
case camera
case mute
case flipCamera
case bluetooth
case speaker
case airpods
case airpodsPro
case airpodsMax
case headphones
case accept
case end
case cancel
case share
case screencast
}
var appearance: Appearance
var image: Image
var isEnabled: Bool
var hasProgress: Bool
init(appearance: Appearance, image: Image, isEnabled: Bool = true, hasProgress: Bool = false) {
self.appearance = appearance
self.image = image
self.isEnabled = isEnabled
self.hasProgress = hasProgress
}
}
private let wrapperNode: ASDisplayNode
private let contentContainer: ASDisplayNode
private let effectView: UIVisualEffectView
private let contentBackgroundNode: ASImageNode
private let contentNode: ASImageNode
private var animationNode: AnimationNode?
private let overlayHighlightNode: ASImageNode
private var statusNode: SemanticStatusNode?
let textNode: ImmediateTextNode
private let largeButtonSize: CGFloat
private var size: CGSize?
private(set) var currentContent: Content?
private(set) var currentText: String = ""
init(largeButtonSize: CGFloat = 72.0) {
self.largeButtonSize = largeButtonSize
self.wrapperNode = ASDisplayNode()
self.contentContainer = ASDisplayNode()
self.effectView = UIVisualEffectView()
self.effectView.effect = UIBlurEffect(style: .light)
self.effectView.layer.cornerRadius = self.largeButtonSize / 2.0
self.effectView.clipsToBounds = true
self.effectView.isUserInteractionEnabled = false
self.contentBackgroundNode = ASImageNode()
self.contentBackgroundNode.isUserInteractionEnabled = false
self.contentNode = ASImageNode()
self.contentNode.isUserInteractionEnabled = false
self.overlayHighlightNode = ASImageNode()
self.overlayHighlightNode.isUserInteractionEnabled = false
self.overlayHighlightNode.alpha = 0.0
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
super.init(pointerStyle: nil)
self.addSubnode(self.wrapperNode)
self.wrapperNode.addSubnode(self.contentContainer)
self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
self.wrapperNode.addSubnode(self.textNode)
self.contentContainer.view.addSubview(self.effectView)
self.contentContainer.addSubnode(self.contentBackgroundNode)
self.contentContainer.addSubnode(self.contentNode)
self.contentContainer.addSubnode(self.overlayHighlightNode)
self.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self else {
return
}
if highlighted {
strongSelf.overlayHighlightNode.alpha = 1.0
let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring)
transition.updateSublayerTransformScale(node: strongSelf, scale: 0.9)
} else {
strongSelf.overlayHighlightNode.alpha = 0.0
strongSelf.overlayHighlightNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
let transition: ContainedViewLayoutTransition = .animated(duration: 0.5, curve: .spring)
transition.updateSublayerTransformScale(node: strongSelf, scale: 1.0)
}
}
}
override func layout() {
super.layout()
self.wrapperNode.frame = self.bounds
}
func update(size: CGSize, content: Content, text: String, transition: ContainedViewLayoutTransition) {
let scaleFactor = size.width / self.largeButtonSize
let isSmall = self.largeButtonSize > size.width
self.effectView.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
self.contentBackgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
self.contentNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
self.overlayHighlightNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
if self.currentContent != content || self.size != size {
let previousContent = self.currentContent
self.currentContent = content
self.size = size
if content.hasProgress {
let statusFrame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
if self.statusNode == nil {
let statusNode = SemanticStatusNode(backgroundNodeColor: .white, foregroundNodeColor: .clear, cutout: statusFrame.insetBy(dx: 8.0, dy: 8.0))
self.statusNode = statusNode
self.contentContainer.insertSubnode(statusNode, belowSubnode: self.contentNode)
statusNode.transitionToState(.progress(value: nil, cancelEnabled: false, appearance: SemanticStatusNodeState.ProgressAppearance(inset: 4.0, lineWidth: 3.0), animateRotation: true), animated: false, completion: {})
}
if let statusNode = self.statusNode {
statusNode.frame = statusFrame
if transition.isAnimated {
statusNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2)
statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
} else if let statusNode = self.statusNode {
self.statusNode = nil
transition.updateAlpha(node: statusNode, alpha: 0.0, completion: { [weak statusNode] _ in
statusNode?.removeFromSupernode()
})
}
switch content.appearance {
case .blurred:
self.effectView.isHidden = false
case .color:
self.effectView.isHidden = true
}
transition.updateAlpha(node: self.wrapperNode, alpha: content.isEnabled ? 1.0 : 0.4)
self.wrapperNode.isUserInteractionEnabled = content.isEnabled
let contentBackgroundImage: UIImage? = nil
var animationName: String?
switch content.image {
case .cameraOff:
animationName = "anim_cameraoff"
case .cameraOn:
animationName = "anim_cameraon"
default:
break
}
if let animationName = animationName {
let animationFrame = CGRect(origin: CGPoint(), size: CGSize(width: self.largeButtonSize, height: self.largeButtonSize))
if self.animationNode == nil {
let animationNode = AnimationNode(animation: animationName, colors: nil, scale: 1.0)
self.animationNode = animationNode
self.contentContainer.insertSubnode(animationNode, aboveSubnode: self.contentNode)
}
if let animationNode = self.animationNode {
animationNode.bounds = animationFrame
animationNode.position = CGPoint(x: self.largeButtonSize / 2.0, y: self.largeButtonSize / 2.0)
if previousContent == nil {
animationNode.seekToEnd()
} else if previousContent?.image != content.image {
animationNode.setAnimation(name: animationName)
animationNode.play()
}
}
}
let contentImage = generateImage(CGSize(width: self.largeButtonSize, height: self.largeButtonSize), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
var ellipseRect = CGRect(origin: CGPoint(), size: size)
var fillColor: UIColor = .clear
let imageColor: UIColor = .white
var drawOverMask = false
context.setBlendMode(.normal)
let imageScale: CGFloat = 1.0
switch content.appearance {
case let .blurred(isFilled):
if content.hasProgress {
fillColor = .white
drawOverMask = true
context.setBlendMode(.copy)
ellipseRect = ellipseRect.insetBy(dx: 7.0, dy: 7.0)
} else {
if isFilled {
fillColor = .white
drawOverMask = true
context.setBlendMode(.copy)
}
}
case let .color(color):
switch color {
case .red:
fillColor = UIColor(rgb: 0xd92326)
case .green:
fillColor = UIColor(rgb: 0x74db58)
case let .custom(color, alpha):
fillColor = UIColor(rgb: color, alpha: alpha)
}
}
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: ellipseRect)
var image: UIImage?
switch content.image {
case .cameraOff, .cameraOn:
image = nil
case .camera:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallCameraButton"), color: imageColor)
case .mute:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallMuteButton"), color: imageColor)
case .flipCamera:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSwitchCameraButton"), color: imageColor)
case .bluetooth:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallBluetoothButton"), color: imageColor)
case .speaker:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), color: imageColor)
case .airpods:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsButton"), color: imageColor)
case .airpodsPro:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsProButton"), color: imageColor)
case .airpodsMax:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAirpodsMaxButton"), color: imageColor)
case .headphones:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallHeadphonesButton"), color: imageColor)
case .accept:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallAcceptButton"), color: imageColor)
case .end:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallDeclineButton"), color: imageColor)
case .cancel:
image = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setLineWidth(4.0 - UIScreenPixel)
context.setLineCap(.round)
context.setStrokeColor(imageColor.cgColor)
context.move(to: CGPoint(x: 2.0 + UIScreenPixel, y: 2.0 + UIScreenPixel))
context.addLine(to: CGPoint(x: 26.0 - UIScreenPixel, y: 26.0 - UIScreenPixel))
context.strokePath()
context.move(to: CGPoint(x: 26.0 - UIScreenPixel, y: 2.0 + UIScreenPixel))
context.addLine(to: CGPoint(x: 2.0 + UIScreenPixel, y: 26.0 - UIScreenPixel))
context.strokePath()
})
case .share:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: imageColor)
case .screencast:
if let iconImage = generateTintedImage(image: UIImage(bundleImageName: "Call/ScreenSharePhone"), color: imageColor) {
image = generateScaledImage(image: iconImage, size: iconImage.size.aspectFitted(CGSize(width: 38.0, height: 38.0)))
}
}
if let image = image {
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: imageScale, y: imageScale)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
let imageRect = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
if drawOverMask {
context.clip(to: imageRect, mask: image.cgImage!)
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
} else {
context.draw(image.cgImage!, in: imageRect)
}
}
})
if transition.isAnimated, let contentBackgroundImage = contentBackgroundImage, let previousContent = self.contentBackgroundNode.image {
self.contentBackgroundNode.image = contentBackgroundImage
self.contentBackgroundNode.layer.animate(from: previousContent.cgImage!, to: contentBackgroundImage.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2)
} else {
self.contentBackgroundNode.image = contentBackgroundImage
}
if transition.isAnimated, let previousContent = previousContent, previousContent.image == .accept && content.image == .end {
let rotation = CGFloat.pi / 4.0 * 3.0
if let snapshotView = self.contentNode.view.snapshotContentTree() {
snapshotView.frame = self.contentNode.view.frame
self.contentContainer.view.addSubview(snapshotView)
snapshotView.layer.animateRotation(from: 0.0, to: rotation, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
}
self.contentNode.image = contentImage
self.contentNode.layer.animateRotation(from: -rotation, to: 0.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
} else if transition.isAnimated, let contentImage = contentImage, let previousContent = self.contentNode.image {
self.contentNode.image = contentImage
self.contentNode.layer.animate(from: previousContent.cgImage!, to: contentImage.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2)
} else {
self.contentNode.image = contentImage
}
self.overlayHighlightNode.image = generateImage(CGSize(width: self.largeButtonSize, height: self.largeButtonSize), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
let fillColor: UIColor
context.setBlendMode(.normal)
switch content.appearance {
case let .blurred(isFilled):
if isFilled {
fillColor = UIColor(white: 0.0, alpha: 0.1)
} else {
fillColor = UIColor(white: 1.0, alpha: 0.2)
}
case let .color(color):
switch color {
case .red:
fillColor = UIColor(rgb: 0xd92326).withMultipliedBrightnessBy(0.2).withAlphaComponent(0.2)
case .green:
fillColor = UIColor(rgb: 0x74db58).withMultipliedBrightnessBy(0.2).withAlphaComponent(0.2)
case let .custom(color, _):
fillColor = UIColor(rgb: color).withMultipliedBrightnessBy(0.2).withAlphaComponent(0.2)
}
}
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
})
}
transition.updatePosition(node: self.contentContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateSublayerTransformScale(node: self.contentContainer, scale: scaleFactor)
if let animationNode = self.animationNode {
transition.updateTransformScale(node: animationNode, scale: isSmall ? 1.35 : 1.12)
}
if self.currentText != text {
self.textNode.attributedText = NSAttributedString(string: text, font: labelFont, textColor: .white)
}
let textSize = self.textNode.updateLayout(CGSize(width: 150.0, height: 100.0))
let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: size.height + (isSmall ? 5.0 : 8.0)), size: textSize)
if self.currentText.isEmpty {
self.textNode.frame = textFrame
if transition.isAnimated {
self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
} else {
transition.updateFrameAdditiveToCenter(node: self.textNode, frame: textFrame)
}
self.currentText = text
}
}
@@ -0,0 +1,631 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import MediaPlayer
import TelegramPresentationData
enum CallControllerButtonsSpeakerMode: Equatable {
enum BluetoothType: Equatable {
case generic
case airpods
case airpodsPro
case airpodsMax
}
case none
case builtin
case speaker
case headphones
case bluetooth(BluetoothType)
}
enum CallControllerButtonsMode: Equatable {
struct VideoState: Equatable {
var isAvailable: Bool
var isCameraActive: Bool
var isScreencastActive: Bool
var canChangeStatus: Bool
var hasVideo: Bool
var isInitializingCamera: Bool
}
case active(speakerMode: CallControllerButtonsSpeakerMode, hasAudioRouteMenu: Bool, videoState: VideoState)
case incoming(speakerMode: CallControllerButtonsSpeakerMode, hasAudioRouteMenu: Bool, videoState: VideoState)
case outgoingRinging(speakerMode: CallControllerButtonsSpeakerMode, hasAudioRouteMenu: Bool, videoState: VideoState)
}
private enum ButtonDescription: Equatable {
enum Key: Hashable {
case accept
case acceptOrEnd
case decline
case enableCamera
case switchCamera
case soundOutput
case mute
}
enum SoundOutput {
case builtin
case speaker
case bluetooth
case airpods
case airpodsPro
case airpodsMax
case headphones
}
enum EndType {
case outgoing
case decline
case end
}
case accept
case end(EndType)
case enableCamera(isActive: Bool, isEnabled: Bool, isLoading: Bool, isScreencast: Bool)
case switchCamera(Bool)
case soundOutput(SoundOutput)
case mute(Bool)
var key: Key {
switch self {
case .accept:
return .acceptOrEnd
case let .end(type):
if type == .decline {
return .decline
} else {
return .acceptOrEnd
}
case .enableCamera:
return .enableCamera
case .switchCamera:
return .switchCamera
case .soundOutput:
return .soundOutput
case .mute:
return .mute
}
}
}
final class CallControllerButtonsNode: ASDisplayNode {
private var buttonNodes: [ButtonDescription.Key: CallControllerButtonItemNode] = [:]
private var mode: CallControllerButtonsMode?
private var validLayout: (CGFloat, CGFloat)?
var isMuted = false
var acceptOrEnd: (() -> Void)?
var decline: (() -> Void)?
var mute: (() -> Void)?
var speaker: (() -> Void)?
var toggleVideo: (() -> Void)?
var rotateCamera: (() -> Void)?
init(strings: PresentationStrings) {
super.init()
}
func updateLayout(strings: PresentationStrings, mode: CallControllerButtonsMode, constrainedWidth: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = (constrainedWidth, bottomInset)
self.mode = mode
if let mode = self.mode {
return self.updateButtonsLayout(strings: strings, mode: mode, width: constrainedWidth, bottomInset: bottomInset, animated: transition.isAnimated)
} else {
return 0.0
}
}
private var appliedMode: CallControllerButtonsMode?
func videoButtonFrame() -> CGRect? {
return self.buttonNodes[.enableCamera]?.frame
}
private func updateButtonsLayout(strings: PresentationStrings, mode: CallControllerButtonsMode, width: CGFloat, bottomInset: CGFloat, animated: Bool) -> CGFloat {
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
let previousMode = self.appliedMode
self.appliedMode = mode
var animatePositionsWithDelay = false
if let previousMode = previousMode {
switch previousMode {
case .incoming, .outgoingRinging:
if case .active = mode {
animatePositionsWithDelay = true
}
default:
break
}
}
let minSmallButtonSideInset: CGFloat = width > 320.0 ? 34.0 : 16.0
let maxSmallButtonSpacing: CGFloat = 34.0
let smallButtonSize: CGFloat = 60.0
let topBottomSpacing: CGFloat = 84.0
let maxLargeButtonSpacing: CGFloat = 115.0
let largeButtonSize: CGFloat = 72.0
let minLargeButtonSideInset: CGFloat = minSmallButtonSideInset - 6.0
struct PlacedButton {
let button: ButtonDescription
let frame: CGRect
}
let height: CGFloat
let speakerMode: CallControllerButtonsSpeakerMode
var videoState: CallControllerButtonsMode.VideoState
let hasAudioRouteMenu: Bool
switch mode {
case .incoming(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue), .outgoingRinging(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue), .active(let speakerModeValue, let hasAudioRouteMenuValue, let videoStateValue):
speakerMode = speakerModeValue
videoState = videoStateValue
hasAudioRouteMenu = hasAudioRouteMenuValue
}
enum MappedState {
case incomingRinging
case outgoingRinging
case active
}
let mappedState: MappedState
switch mode {
case .incoming:
mappedState = .incomingRinging
case .outgoingRinging:
mappedState = .outgoingRinging
case let .active(_, _, videoStateValue):
mappedState = .active
videoState = videoStateValue
}
var buttons: [PlacedButton] = []
switch mappedState {
case .incomingRinging, .outgoingRinging:
var topButtons: [ButtonDescription] = []
var bottomButtons: [ButtonDescription] = []
let soundOutput: ButtonDescription.SoundOutput
switch speakerMode {
case .none, .builtin:
soundOutput = .builtin
case .speaker:
soundOutput = .speaker
case .headphones:
soundOutput = .headphones
case let .bluetooth(type):
switch type {
case .generic:
soundOutput = .bluetooth
case .airpods:
soundOutput = .airpods
case .airpodsPro:
soundOutput = .airpodsPro
case .airpodsMax:
soundOutput = .airpodsMax
}
}
if videoState.isAvailable {
let isCameraActive: Bool
let isScreencastActive: Bool
let isCameraInitializing: Bool
if videoState.hasVideo {
isCameraActive = videoState.isCameraActive
isScreencastActive = videoState.isScreencastActive
isCameraInitializing = videoState.isInitializingCamera
} else {
isCameraActive = false
isScreencastActive = false
isCameraInitializing = videoState.isInitializingCamera
}
topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: false, isLoading: isCameraInitializing, isScreencast: isScreencastActive))
if !videoState.hasVideo {
topButtons.append(.mute(self.isMuted))
topButtons.append(.soundOutput(soundOutput))
} else {
if hasAudioRouteMenu {
topButtons.append(.soundOutput(soundOutput))
} else {
topButtons.append(.mute(self.isMuted))
}
if !isScreencastActive {
topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing))
}
}
} else {
topButtons.append(.mute(self.isMuted))
topButtons.append(.soundOutput(soundOutput))
}
let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize
let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0
let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1))
let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing
var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0)
for button in topButtons {
buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize))))
topButtonsLeftOffset += largeButtonSize + topButtonsSpacing
}
if case .incomingRinging = mappedState {
bottomButtons.append(.end(.decline))
bottomButtons.append(.accept)
} else {
bottomButtons.append(.end(.outgoing))
}
let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize
let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0
let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1))
let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing
var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0)
for button in bottomButtons {
buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize))))
bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing
}
height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0)
case .active:
if videoState.hasVideo {
let isCameraActive: Bool
let isScreencastActive: Bool
let isCameraEnabled: Bool
let isCameraInitializing: Bool
if videoState.hasVideo {
isCameraActive = videoState.isCameraActive
isScreencastActive = videoState.isScreencastActive
isCameraEnabled = videoState.canChangeStatus
isCameraInitializing = videoState.isInitializingCamera
} else {
isCameraActive = false
isScreencastActive = false
isCameraEnabled = videoState.canChangeStatus
isCameraInitializing = videoState.isInitializingCamera
}
var topButtons: [ButtonDescription] = []
let soundOutput: ButtonDescription.SoundOutput
switch speakerMode {
case .none, .builtin:
soundOutput = .builtin
case .speaker:
soundOutput = .speaker
case .headphones:
soundOutput = .headphones
case let .bluetooth(type):
switch type {
case .generic:
soundOutput = .bluetooth
case .airpods:
soundOutput = .airpods
case .airpodsPro:
soundOutput = .airpodsPro
case .airpodsMax:
soundOutput = .airpodsMax
}
}
topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive))
if hasAudioRouteMenu {
topButtons.append(.soundOutput(soundOutput))
} else {
topButtons.append(.mute(isMuted))
}
if !isScreencastActive {
topButtons.append(.switchCamera(isCameraActive && !isCameraInitializing))
}
topButtons.append(.end(.end))
let topButtonsContentWidth = CGFloat(topButtons.count) * smallButtonSize
let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0
let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1))
let topButtonsWidth = CGFloat(topButtons.count) * smallButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing
var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0)
for button in topButtons {
buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: smallButtonSize, height: smallButtonSize))))
topButtonsLeftOffset += smallButtonSize + topButtonsSpacing
}
height = smallButtonSize + max(bottomInset + 19.0, 46.0)
} else {
var topButtons: [ButtonDescription] = []
var bottomButtons: [ButtonDescription] = []
let isCameraActive: Bool
let isScreencastActive: Bool
let isCameraEnabled: Bool
let isCameraInitializing: Bool
if videoState.hasVideo {
isCameraActive = videoState.isCameraActive
isScreencastActive = videoState.isScreencastActive
isCameraEnabled = videoState.canChangeStatus
isCameraInitializing = videoState.isInitializingCamera
} else {
isCameraActive = false
isScreencastActive = false
isCameraEnabled = videoState.canChangeStatus
isCameraInitializing = videoState.isInitializingCamera
}
let soundOutput: ButtonDescription.SoundOutput
switch speakerMode {
case .none, .builtin:
soundOutput = .builtin
case .speaker:
soundOutput = .speaker
case .headphones:
soundOutput = .bluetooth
case let .bluetooth(type):
switch type {
case .generic:
soundOutput = .bluetooth
case .airpods:
soundOutput = .airpods
case .airpodsPro:
soundOutput = .airpodsPro
case .airpodsMax:
soundOutput = .airpodsMax
}
}
topButtons.append(.enableCamera(isActive: isCameraActive || isScreencastActive, isEnabled: isCameraEnabled, isLoading: isCameraInitializing, isScreencast: isScreencastActive))
topButtons.append(.mute(self.isMuted))
topButtons.append(.soundOutput(soundOutput))
let topButtonsContentWidth = CGFloat(topButtons.count) * largeButtonSize
let topButtonsAvailableSpacingWidth = width - topButtonsContentWidth - minSmallButtonSideInset * 2.0
let topButtonsSpacing = min(maxSmallButtonSpacing, topButtonsAvailableSpacingWidth / CGFloat(topButtons.count - 1))
let topButtonsWidth = CGFloat(topButtons.count) * largeButtonSize + CGFloat(topButtons.count - 1) * topButtonsSpacing
var topButtonsLeftOffset = floor((width - topButtonsWidth) / 2.0)
for button in topButtons {
buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: topButtonsLeftOffset, y: 0.0), size: CGSize(width: largeButtonSize, height: largeButtonSize))))
topButtonsLeftOffset += largeButtonSize + topButtonsSpacing
}
bottomButtons.append(.end(.outgoing))
let bottomButtonsContentWidth = CGFloat(bottomButtons.count) * largeButtonSize
let bottomButtonsAvailableSpacingWidth = width - bottomButtonsContentWidth - minLargeButtonSideInset * 2.0
let bottomButtonsSpacing = min(maxLargeButtonSpacing, bottomButtonsAvailableSpacingWidth / CGFloat(bottomButtons.count - 1))
let bottomButtonsWidth = CGFloat(bottomButtons.count) * largeButtonSize + CGFloat(bottomButtons.count - 1) * bottomButtonsSpacing
var bottomButtonsLeftOffset = floor((width - bottomButtonsWidth) / 2.0)
for button in bottomButtons {
buttons.append(PlacedButton(button: button, frame: CGRect(origin: CGPoint(x: bottomButtonsLeftOffset, y: largeButtonSize + topBottomSpacing), size: CGSize(width: largeButtonSize, height: largeButtonSize))))
bottomButtonsLeftOffset += largeButtonSize + bottomButtonsSpacing
}
height = largeButtonSize + topBottomSpacing + largeButtonSize + max(bottomInset + 32.0, 46.0)
}
}
let delayIncrement = 0.015
var validKeys: [ButtonDescription.Key] = []
for button in buttons {
validKeys.append(button.button.key)
var buttonTransition = transition
var animateButtonIn = false
let buttonNode: CallControllerButtonItemNode
if let current = self.buttonNodes[button.button.key] {
buttonNode = current
} else {
buttonNode = CallControllerButtonItemNode()
self.buttonNodes[button.button.key] = buttonNode
self.addSubnode(buttonNode)
buttonNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
buttonTransition = .immediate
animateButtonIn = transition.isAnimated
}
let buttonContent: CallControllerButtonItemNode.Content
let buttonText: String
var buttonAccessibilityLabel = ""
var buttonAccessibilityValue = ""
var buttonAccessibilityTraits: UIAccessibilityTraits = [.button]
switch button.button {
case .accept:
buttonContent = CallControllerButtonItemNode.Content(
appearance: .color(.green),
image: .accept
)
buttonText = strings.Call_Accept
buttonAccessibilityLabel = buttonText
case let .end(type):
buttonContent = CallControllerButtonItemNode.Content(
appearance: .color(.red),
image: .end
)
switch type {
case .outgoing:
buttonText = ""
case .decline:
buttonText = strings.Call_Decline
case .end:
buttonText = strings.Call_End
}
if !buttonText.isEmpty {
buttonAccessibilityLabel = buttonText
} else {
buttonAccessibilityLabel = strings.Call_End
}
case let .enableCamera(isActivated, isEnabled, isInitializing, isScreencastActive):
buttonContent = CallControllerButtonItemNode.Content(
appearance: .blurred(isFilled: isActivated),
image: isScreencastActive ? .screencast : .camera,
isEnabled: isEnabled,
hasProgress: isInitializing
)
buttonText = strings.Call_Camera
buttonAccessibilityLabel = buttonText
if !isEnabled {
buttonAccessibilityTraits.insert(.notEnabled)
}
if isActivated {
buttonAccessibilityTraits.insert(.selected)
}
case let .switchCamera(isEnabled):
buttonContent = CallControllerButtonItemNode.Content(
appearance: .blurred(isFilled: false),
image: .flipCamera,
isEnabled: isEnabled
)
buttonText = strings.Call_Flip
buttonAccessibilityLabel = buttonText
if !isEnabled {
buttonAccessibilityTraits.insert(.notEnabled)
}
case let .soundOutput(value):
let image: CallControllerButtonItemNode.Content.Image
var isFilled = false
var title: String = strings.Call_Speaker
switch value {
case .builtin:
image = .speaker
case .speaker:
image = .speaker
isFilled = true
case .bluetooth:
image = .bluetooth
title = strings.Call_Audio
buttonAccessibilityValue = "Bluetooth"
case .airpods:
image = .airpods
title = strings.Call_Audio
buttonAccessibilityValue = "Airpods"
case .airpodsPro:
image = .airpodsPro
title = strings.Call_Audio
buttonAccessibilityValue = "Airpods Pro"
case .airpodsMax:
image = .airpodsMax
title = strings.Call_Audio
buttonAccessibilityValue = "Airpods Max"
case .headphones:
image = .headphones
title = strings.Call_Audio
buttonAccessibilityValue = strings.Call_AudioRouteHeadphones
}
buttonContent = CallControllerButtonItemNode.Content(
appearance: .blurred(isFilled: isFilled),
image: image
)
buttonText = title
buttonAccessibilityLabel = buttonText
if isFilled {
buttonAccessibilityTraits.insert(.selected)
}
case let .mute(isMuted):
buttonContent = CallControllerButtonItemNode.Content(
appearance: .blurred(isFilled: isMuted),
image: .mute
)
buttonText = strings.Call_Mute
buttonAccessibilityLabel = buttonText
if isMuted {
buttonAccessibilityTraits.insert(.selected)
}
}
var buttonDelay = 0.0
if animatePositionsWithDelay {
switch button.button.key {
case .enableCamera:
buttonDelay = 0.0
case .mute:
buttonDelay = delayIncrement * 1.0
case .switchCamera:
buttonDelay = delayIncrement * 2.0
case .acceptOrEnd:
buttonDelay = delayIncrement * 3.0
default:
break
}
}
buttonTransition.updateFrame(node: buttonNode, frame: button.frame, delay: buttonDelay)
buttonNode.update(size: button.frame.size, content: buttonContent, text: buttonText, transition: buttonTransition)
buttonNode.accessibilityLabel = buttonAccessibilityLabel
buttonNode.accessibilityValue = buttonAccessibilityValue
buttonNode.accessibilityTraits = buttonAccessibilityTraits
if animateButtonIn {
buttonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
var removedKeys: [ButtonDescription.Key] = []
for (key, button) in self.buttonNodes {
if !validKeys.contains(key) {
removedKeys.append(key)
if animated {
if case .decline = key {
transition.updateTransformScale(node: button, scale: 0.1)
transition.updateAlpha(node: button, alpha: 0.0, completion: { [weak button] _ in
button?.removeFromSupernode()
})
} else {
transition.updateAlpha(node: button, alpha: 0.0, completion: { [weak button] _ in
button?.removeFromSupernode()
})
}
} else {
button.removeFromSupernode()
}
}
}
for key in removedKeys {
self.buttonNodes.removeValue(forKey: key)
}
return height
}
@objc func buttonPressed(_ button: CallControllerButtonItemNode) {
for (key, listButton) in self.buttonNodes {
if button === listButton {
switch key {
case .accept:
self.acceptOrEnd?()
case .acceptOrEnd:
self.acceptOrEnd?()
case .decline:
self.decline?()
case .enableCamera:
self.toggleVideo?()
case .switchCamera:
self.rotateCamera?()
case .soundOutput:
self.speaker?()
case .mute:
self.mute?()
}
break
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for (_, button) in self.buttonNodes {
if let result = button.view.hitTest(self.view.convert(point, to: button.view), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,127 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import CallsEmoji
private let labelFont = Font.regular(22.0)
private let animationNodesCount = 3
private class EmojiSlotNode: ASDisplayNode {
var emoji: String = "" {
didSet {
self.node.attributedText = NSAttributedString(string: emoji, font: labelFont, textColor: .black)
let _ = self.node.updateLayout(CGSize(width: 100.0, height: 100.0))
}
}
private let maskNode: ASDisplayNode
private let containerNode: ASDisplayNode
private let node: ImmediateTextNode
private let animationNodes: [ImmediateTextNode]
override init() {
self.maskNode = ASDisplayNode()
self.containerNode = ASDisplayNode()
self.node = ImmediateTextNode()
self.animationNodes = (0 ..< animationNodesCount).map { _ in ImmediateTextNode() }
super.init()
let maskLayer = CAGradientLayer()
maskLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]
maskLayer.locations = [0.0, 0.2, 0.8, 1.0]
maskLayer.startPoint = CGPoint(x: 0.5, y: 0.0)
maskLayer.endPoint = CGPoint(x: 0.5, y: 1.0)
self.maskNode.layer.mask = maskLayer
self.addSubnode(self.maskNode)
self.maskNode.addSubnode(self.containerNode)
self.containerNode.addSubnode(self.node)
self.animationNodes.forEach({ self.containerNode.addSubnode($0) })
}
func animateIn(duration: Double) {
for node in self.animationNodes {
node.attributedText = NSAttributedString(string: randomCallsEmoji(), font: labelFont, textColor: .black)
let _ = node.updateLayout(CGSize(width: 100.0, height: 100.0))
}
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -self.containerNode.frame.height + self.bounds.height), to: CGPoint(), duration: duration, delay: 0.1, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
override func layout() {
super.layout()
let maskInset: CGFloat = 4.0
let maskFrame = self.bounds.insetBy(dx: 0.0, dy: -maskInset)
self.maskNode.frame = maskFrame
self.maskNode.layer.mask?.frame = CGRect(origin: CGPoint(), size: maskFrame.size)
let spacing: CGFloat = 2.0
let containerSize = CGSize(width: self.bounds.width, height: self.bounds.height * CGFloat(animationNodesCount + 1) + spacing * CGFloat(animationNodesCount))
self.containerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: maskInset), size: containerSize)
self.node.frame = CGRect(origin: CGPoint(), size: self.bounds.size)
var offset: CGFloat = self.bounds.height + spacing
for node in self.animationNodes {
node.frame = CGRect(origin: CGPoint(x: 0.0, y: offset), size: self.bounds.size)
offset += self.bounds.height + spacing
}
}
}
final class CallControllerKeyButton: HighlightableButtonNode {
private let containerNode: ASDisplayNode
private let nodes: [EmojiSlotNode]
var key: String = "" {
didSet {
var index = 0
for emoji in self.key {
guard index < 4 else {
return
}
self.nodes[index].emoji = String(emoji)
index += 1
}
}
}
init() {
self.containerNode = ASDisplayNode()
self.nodes = (0 ..< 4).map { _ in EmojiSlotNode() }
super.init(pointerStyle: nil)
self.addSubnode(self.containerNode)
self.nodes.forEach({ self.containerNode.addSubnode($0) })
}
func animateIn() {
self.layoutIfNeeded()
self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
var duration: Double = 0.75
for node in self.nodes {
node.animateIn(duration: duration)
duration += 0.3
}
}
override func measure(_ constrainedSize: CGSize) -> CGSize {
return CGSize(width: 114.0, height: 26.0)
}
override func layout() {
super.layout()
self.containerNode.frame = self.bounds
var index = 0
let nodeSize = CGSize(width: 29.0, height: self.bounds.size.height)
for node in self.nodes {
node.frame = CGRect(origin: CGPoint(x: CGFloat(index) * nodeSize.width, y: 0.0), size: nodeSize)
index += 1
}
self.nodes.forEach({ self.containerNode.addSubnode($0) })
}
}
@@ -0,0 +1,108 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import LegacyComponents
private let emojiFont = Font.regular(28.0)
private let textFont = Font.regular(15.0)
final class CallControllerKeyPreviewNode: ASDisplayNode {
private let keyTextNode: ASTextNode
private let infoTextNode: ASTextNode
private let effectView: UIVisualEffectView
private let dismiss: () -> Void
init(keyText: String, infoText: String, dismiss: @escaping () -> Void) {
self.keyTextNode = ASTextNode()
self.keyTextNode.displaysAsynchronously = false
self.infoTextNode = ASTextNode()
self.infoTextNode.displaysAsynchronously = false
self.dismiss = dismiss
self.effectView = UIVisualEffectView()
if #available(iOS 9.0, *) {
} else {
self.effectView.effect = UIBlurEffect(style: .dark)
self.effectView.alpha = 0.0
}
super.init()
self.keyTextNode.attributedText = NSAttributedString(string: keyText, attributes: [NSAttributedString.Key.font: Font.regular(58.0), NSAttributedString.Key.kern: 11.0 as NSNumber])
self.infoTextNode.attributedText = NSAttributedString(string: infoText, font: Font.regular(14.0), textColor: UIColor.white, paragraphAlignment: .center)
self.view.addSubview(self.effectView)
self.addSubnode(self.keyTextNode)
self.addSubnode(self.infoTextNode)
}
override func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
self.effectView.frame = CGRect(origin: CGPoint(), size: size)
let keyTextSize = self.keyTextNode.measure(CGSize(width: 300.0, height: 300.0))
transition.updateFrame(node: self.keyTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - keyTextSize.width) / 2) + 6.0, y: floor((size.height - keyTextSize.height) / 2) - 50.0), size: keyTextSize))
let infoTextSize = self.infoTextNode.measure(CGSize(width: size.width - 32.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.infoTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - infoTextSize.width) / 2.0), y: floor((size.height - infoTextSize.height) / 2.0) + 30.0), size: infoTextSize))
}
func animateIn(from rect: CGRect, fromNode: ASDisplayNode) {
self.keyTextNode.layer.animatePosition(from: CGPoint(x: rect.midX, y: rect.midY), to: self.keyTextNode.layer.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
if let transitionView = fromNode.view.snapshotView(afterScreenUpdates: false) {
self.view.addSubview(transitionView)
transitionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
transitionView.layer.animatePosition(from: CGPoint(x: rect.midX, y: rect.midY), to: self.keyTextNode.layer.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak transitionView] _ in
transitionView?.removeFromSuperview()
})
transitionView.layer.animateScale(from: 1.0, to: self.keyTextNode.frame.size.width / rect.size.width, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
}
self.keyTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
self.keyTextNode.layer.animateScale(from: rect.size.width / self.keyTextNode.frame.size.width, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.infoTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
UIView.animate(withDuration: 0.3, animations: {
if #available(iOS 9.0, *) {
self.effectView.effect = UIBlurEffect(style: .dark)
} else {
self.effectView.alpha = 1.0
}
})
}
func animateOut(to rect: CGRect, toNode: ASDisplayNode, completion: @escaping () -> Void) {
self.keyTextNode.layer.animatePosition(from: self.keyTextNode.layer.position, to: CGPoint(x: rect.midX + 2.0, y: rect.midY), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
completion()
})
self.keyTextNode.layer.animateScale(from: 1.0, to: rect.size.width / (self.keyTextNode.frame.size.width - 2.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.infoTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
UIView.animate(withDuration: 0.3, animations: {
if #available(iOS 9.0, *) {
self.effectView.effect = nil
} else {
self.effectView.alpha = 0.0
}
})
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.dismiss()
}
}
}
@@ -0,0 +1,370 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramAudio
import AccountContext
import LocalizedPeerData
import PhotoResources
import CallsEmoji
import TooltipUI
import AlertUI
import PresentationDataUtils
import DeviceAccess
import ContextUI
import AppBundle
private func interpolateFrame(from fromValue: CGRect, to toValue: CGRect, t: CGFloat) -> CGRect {
return CGRect(x: floorToScreenPixels(toValue.origin.x * t + fromValue.origin.x * (1.0 - t)), y: floorToScreenPixels(toValue.origin.y * t + fromValue.origin.y * (1.0 - t)), width: floorToScreenPixels(toValue.size.width * t + fromValue.size.width * (1.0 - t)), height: floorToScreenPixels(toValue.size.height * t + fromValue.size.height * (1.0 - t)))
}
private func interpolate(from: CGFloat, to: CGFloat, value: CGFloat) -> CGFloat {
return (1.0 - value) * from + value * to
}
final class CallVideoNode: ASDisplayNode, PreviewVideoNode {
private var placeholderImageNode: ASImageNode?
private let videoTransformContainer: ASDisplayNode
private let videoView: PresentationCallVideoView
private var effectView: UIVisualEffectView?
private let videoPausedNode: ImmediateTextNode
private var isBlurred: Bool = false
private var currentCornerRadius: CGFloat = 0.0
private let isReadyUpdated: () -> Void
private(set) var isReady: Bool = false
private var isReadyTimer: SwiftSignalKit.Timer?
private let readyPromise = ValuePromise(false)
var ready: Signal<Bool, NoError> {
return self.readyPromise.get()
}
private let isFlippedUpdated: (CallVideoNode) -> Void
private(set) var currentOrientation: PresentationCallVideoView.Orientation
private(set) var currentAspect: CGFloat = 0.0
private var previousVideoHeight: CGFloat?
init(videoView: PresentationCallVideoView, displayPlaceholderUntilReady: Bool = false, disabledText: String?, assumeReadyAfterTimeout: Bool, isReadyUpdated: @escaping () -> Void, orientationUpdated: @escaping () -> Void, isFlippedUpdated: @escaping (CallVideoNode) -> Void) {
self.isReadyUpdated = isReadyUpdated
self.isFlippedUpdated = isFlippedUpdated
self.videoTransformContainer = ASDisplayNode()
self.videoView = videoView
videoView.view.clipsToBounds = true
videoView.view.backgroundColor = .black
self.currentOrientation = videoView.getOrientation()
self.currentAspect = videoView.getAspect()
self.videoPausedNode = ImmediateTextNode()
self.videoPausedNode.alpha = 0.0
self.videoPausedNode.maximumNumberOfLines = 3
super.init()
self.backgroundColor = .black
self.clipsToBounds = true
if #available(iOS 13.0, *) {
self.layer.cornerCurve = .continuous
}
self.videoTransformContainer.view.addSubview(self.videoView.view)
self.addSubnode(self.videoTransformContainer)
if displayPlaceholderUntilReady {
let placeholderImageNode = ASImageNode()
placeholderImageNode.image = UIImage(bundleImageName: "Camera/SelfiePlaceholder")
self.placeholderImageNode = placeholderImageNode
self.addSubnode(placeholderImageNode)
}
if let disabledText = disabledText {
self.videoPausedNode.attributedText = NSAttributedString(string: disabledText, font: Font.regular(17.0), textColor: .white)
self.addSubnode(self.videoPausedNode)
}
self.videoView.setOnFirstFrameReceived { [weak self] aspectRatio in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
if !strongSelf.isReady {
strongSelf.isReady = true
strongSelf.readyPromise.set(true)
strongSelf.isReadyTimer?.invalidate()
strongSelf.isReadyUpdated()
if let placeholderImageNode = strongSelf.placeholderImageNode {
strongSelf.placeholderImageNode = nil
placeholderImageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak placeholderImageNode] _ in
placeholderImageNode?.removeFromSupernode()
})
}
}
}
}
self.videoView.setOnOrientationUpdated { [weak self] orientation, aspect in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
if strongSelf.currentOrientation != orientation || strongSelf.currentAspect != aspect {
strongSelf.currentOrientation = orientation
strongSelf.currentAspect = aspect
orientationUpdated()
}
}
}
self.videoView.setOnIsMirroredUpdated { [weak self] _ in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
strongSelf.isFlippedUpdated(strongSelf)
}
}
if assumeReadyAfterTimeout {
self.isReadyTimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in
guard let strongSelf = self else {
return
}
if !strongSelf.isReady {
strongSelf.isReady = true
strongSelf.readyPromise.set(true)
strongSelf.isReadyUpdated()
}
}, queue: .mainQueue())
}
self.isReadyTimer?.start()
}
deinit {
self.isReadyTimer?.invalidate()
}
override func didLoad() {
super.didLoad()
if #available(iOS 13.0, *) {
self.layer.cornerCurve = .continuous
}
}
func animateRadialMask(from fromRect: CGRect, to toRect: CGRect) {
let maskLayer = CAShapeLayer()
maskLayer.frame = fromRect
let path = CGMutablePath()
path.addEllipse(in: CGRect(origin: CGPoint(), size: fromRect.size))
maskLayer.path = path
self.layer.mask = maskLayer
let topLeft = CGPoint(x: 0.0, y: 0.0)
let topRight = CGPoint(x: self.bounds.width, y: 0.0)
let bottomLeft = CGPoint(x: 0.0, y: self.bounds.height)
let bottomRight = CGPoint(x: self.bounds.width, y: self.bounds.height)
func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat {
let dx = v1.x - v2.x
let dy = v1.y - v2.y
return sqrt(dx * dx + dy * dy)
}
var maxRadius = distance(toRect.center, topLeft)
maxRadius = max(maxRadius, distance(toRect.center, topRight))
maxRadius = max(maxRadius, distance(toRect.center, bottomLeft))
maxRadius = max(maxRadius, distance(toRect.center, bottomRight))
maxRadius = ceil(maxRadius)
let targetFrame = CGRect(origin: CGPoint(x: toRect.center.x - maxRadius, y: toRect.center.y - maxRadius), size: CGSize(width: maxRadius * 2.0, height: maxRadius * 2.0))
let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut)
transition.updatePosition(layer: maskLayer, position: targetFrame.center)
transition.updateTransformScale(layer: maskLayer, scale: maxRadius * 2.0 / fromRect.width, completion: { [weak self] _ in
self?.layer.mask = nil
})
}
func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) {
self.updateLayout(size: size, cornerRadius: self.currentCornerRadius, isOutgoing: true, deviceOrientation: .portrait, isCompactLayout: false, transition: transition)
}
func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) {
self.currentCornerRadius = cornerRadius
if let placeholderImageNode = self.placeholderImageNode, let image = placeholderImageNode.image {
let placeholderSize = image.size.aspectFilled(size)
transition.updateFrame(node: placeholderImageNode, frame: CGRect(origin: CGPoint(x: (size.width - placeholderSize.width) * 0.5, y: (size.height - placeholderSize.height) * 0.5), size: placeholderSize))
}
var rotationAngle: CGFloat
if false && isOutgoing && isCompactLayout {
rotationAngle = CGFloat.pi / 2.0
} else {
switch self.currentOrientation {
case .rotation0:
rotationAngle = 0.0
case .rotation90:
rotationAngle = CGFloat.pi / 2.0
case .rotation180:
rotationAngle = CGFloat.pi
case .rotation270:
rotationAngle = -CGFloat.pi / 2.0
}
var additionalAngle: CGFloat = 0.0
switch deviceOrientation {
case .portrait:
additionalAngle = 0.0
case .landscapeLeft:
additionalAngle = CGFloat.pi / 2.0
case .landscapeRight:
additionalAngle = -CGFloat.pi / 2.0
case .portraitUpsideDown:
rotationAngle = CGFloat.pi
default:
additionalAngle = 0.0
}
rotationAngle += additionalAngle
if abs(rotationAngle - CGFloat.pi * 3.0 / 2.0) < 0.01 {
rotationAngle = -CGFloat.pi / 2.0
}
if abs(rotationAngle - (-CGFloat.pi)) < 0.01 {
rotationAngle = -CGFloat.pi + 0.001
}
}
let rotateFrame = abs(rotationAngle.remainder(dividingBy: CGFloat.pi)) > 1.0
let fittingSize: CGSize
if rotateFrame {
fittingSize = CGSize(width: size.height, height: size.width)
} else {
fittingSize = size
}
let unboundVideoSize = CGSize(width: self.currentAspect * 10000.0, height: 10000.0)
var fittedVideoSize = unboundVideoSize.fitted(fittingSize)
if fittedVideoSize.width < fittingSize.width || fittedVideoSize.height < fittingSize.height {
let isVideoPortrait = unboundVideoSize.width < unboundVideoSize.height
let isFittingSizePortrait = fittingSize.width < fittingSize.height
if isCompactLayout && isVideoPortrait == isFittingSizePortrait {
fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize)
} else {
let maxFittingEdgeDistance: CGFloat
if isCompactLayout {
maxFittingEdgeDistance = 200.0
} else {
maxFittingEdgeDistance = 400.0
}
if fittedVideoSize.width > fittingSize.width - maxFittingEdgeDistance && fittedVideoSize.height > fittingSize.height - maxFittingEdgeDistance {
fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize)
}
}
}
let rotatedVideoHeight: CGFloat = max(fittedVideoSize.height, fittedVideoSize.width)
let videoFrame: CGRect = CGRect(origin: CGPoint(), size: fittedVideoSize)
let videoPausedSize = self.videoPausedNode.updateLayout(CGSize(width: size.width - 16.0, height: 100.0))
transition.updateFrame(node: self.videoPausedNode, frame: CGRect(origin: CGPoint(x: floor((size.width - videoPausedSize.width) / 2.0), y: floor((size.height - videoPausedSize.height) / 2.0)), size: videoPausedSize))
self.videoTransformContainer.bounds = CGRect(origin: CGPoint(), size: videoFrame.size)
if transition.isAnimated && !videoFrame.height.isZero, let previousVideoHeight = self.previousVideoHeight, !previousVideoHeight.isZero {
let scaleDifference = previousVideoHeight / rotatedVideoHeight
if abs(scaleDifference - 1.0) > 0.001 {
transition.animateTransformScale(node: self.videoTransformContainer, from: scaleDifference, additive: true)
}
}
self.previousVideoHeight = rotatedVideoHeight
transition.updatePosition(node: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateTransformRotation(view: self.videoTransformContainer.view, angle: rotationAngle)
let localVideoFrame = CGRect(origin: CGPoint(), size: videoFrame.size)
self.videoView.view.bounds = localVideoFrame
self.videoView.view.center = localVideoFrame.center
// TODO: properly fix the issue
// On iOS 13 and later metal layer transformation is broken if the layer does not require compositing
self.videoView.view.alpha = 0.995
if let effectView = self.effectView {
transition.updateFrame(view: effectView, frame: localVideoFrame)
}
transition.updateCornerRadius(layer: self.layer, cornerRadius: self.currentCornerRadius)
}
func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) {
if self.hasScheduledUnblur {
self.hasScheduledUnblur = false
}
if self.isBlurred == isBlurred {
return
}
self.isBlurred = isBlurred
if isBlurred {
if self.effectView == nil {
let effectView = UIVisualEffectView()
self.effectView = effectView
effectView.frame = self.videoTransformContainer.bounds
self.videoTransformContainer.view.addSubview(effectView)
}
if animated {
UIView.animate(withDuration: 0.3, animations: {
self.videoPausedNode.alpha = 1.0
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
})
} else {
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
}
} else if let effectView = self.effectView {
self.effectView = nil
UIView.animate(withDuration: 0.3, animations: {
self.videoPausedNode.alpha = 0.0
effectView.effect = nil
}, completion: { [weak effectView] _ in
effectView?.removeFromSuperview()
})
}
}
private var hasScheduledUnblur = false
func flip(withBackground: Bool) {
if withBackground {
self.backgroundColor = .black
}
UIView.transition(with: withBackground ? self.videoTransformContainer.view : self.view, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: {
UIView.performWithoutAnimation {
self.updateIsBlurred(isBlurred: true, light: false, animated: false)
}
}) { finished in
self.backgroundColor = nil
self.hasScheduledUnblur = true
Queue.mainQueue().after(0.5) {
if self.hasScheduledUnblur {
self.updateIsBlurred(isBlurred: false)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,280 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
private let compactNameFont = Font.regular(28.0)
private let regularNameFont = Font.regular(36.0)
private let compactStatusFont = Font.regular(18.0)
private let regularStatusFont = Font.regular(18.0)
enum CallControllerStatusValue: Equatable {
case text(string: String, displayLogo: Bool)
case timer((String, Bool) -> String, Double)
static func ==(lhs: CallControllerStatusValue, rhs: CallControllerStatusValue) -> Bool {
switch lhs {
case let .text(text, displayLogo):
if case .text(text, displayLogo) = rhs {
return true
} else {
return false
}
case let .timer(_, referenceTime):
if case .timer(_, referenceTime) = rhs {
return true
} else {
return false
}
}
}
}
final class CallControllerStatusNode: ASDisplayNode {
private let titleNode: TextNode
private let statusContainerNode: ASDisplayNode
private let statusNode: TextNode
private let statusMeasureNode: TextNode
private let receptionNode: CallControllerReceptionNode
private let logoNode: ASImageNode
private let titleActivateAreaNode: AccessibilityAreaNode
private let statusActivateAreaNode: AccessibilityAreaNode
var title: String = ""
var subtitle: String = ""
var status: CallControllerStatusValue = .text(string: "", displayLogo: false) {
didSet {
if self.status != oldValue {
self.statusTimer?.invalidate()
if let snapshotView = self.statusContainerNode.view.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = self.statusContainerNode.frame
self.view.insertSubview(snapshotView, belowSubview: self.statusContainerNode.view)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
snapshotView.layer.animateScale(from: 1.0, to: 0.3, duration: 0.3, removeOnCompletion: false)
snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: snapshotView.frame.height / 2.0), duration: 0.3, delay: 0.0, removeOnCompletion: false, additive: true)
self.statusContainerNode.layer.animateScale(from: 0.3, to: 1.0, duration: 0.3)
self.statusContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.statusContainerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -snapshotView.frame.height / 2.0), to: CGPoint(), duration: 0.3, delay: 0.0, additive: true)
}
if case .timer = self.status {
self.statusTimer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
if let strongSelf = self, let validLayoutWidth = strongSelf.validLayoutWidth {
let _ = strongSelf.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}, queue: Queue.mainQueue())
self.statusTimer?.start()
} else {
if let validLayoutWidth = self.validLayoutWidth {
let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}
}
}
}
var reception: Int32? {
didSet {
if self.reception != oldValue {
if let reception = self.reception {
self.receptionNode.reception = reception
if oldValue == nil {
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring)
transition.updateAlpha(node: self.receptionNode, alpha: 1.0)
}
} else if self.reception == nil, oldValue != nil {
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring)
transition.updateAlpha(node: self.receptionNode, alpha: 0.0)
}
if (oldValue == nil) != (self.reception != nil) {
if let validLayoutWidth = self.validLayoutWidth {
let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}
}
}
}
private var statusTimer: SwiftSignalKit.Timer?
private var validLayoutWidth: CGFloat?
override init() {
self.titleNode = TextNode()
self.statusContainerNode = ASDisplayNode()
self.statusNode = TextNode()
self.statusNode.displaysAsynchronously = false
self.statusMeasureNode = TextNode()
self.receptionNode = CallControllerReceptionNode()
self.receptionNode.alpha = 0.0
self.logoNode = ASImageNode()
self.logoNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallTitleLogo"), color: .white)
self.logoNode.isHidden = true
self.titleActivateAreaNode = AccessibilityAreaNode()
self.titleActivateAreaNode.accessibilityTraits = .staticText
self.statusActivateAreaNode = AccessibilityAreaNode()
self.statusActivateAreaNode.accessibilityTraits = [.staticText, .updatesFrequently]
super.init()
self.isUserInteractionEnabled = false
self.addSubnode(self.titleNode)
self.addSubnode(self.statusContainerNode)
self.statusContainerNode.addSubnode(self.statusNode)
self.statusContainerNode.addSubnode(self.receptionNode)
self.statusContainerNode.addSubnode(self.logoNode)
self.addSubnode(self.titleActivateAreaNode)
self.addSubnode(self.statusActivateAreaNode)
}
deinit {
self.statusTimer?.invalidate()
}
func setVisible(_ visible: Bool, transition: ContainedViewLayoutTransition) {
let alpha: CGFloat = visible ? 1.0 : 0.0
transition.updateAlpha(node: self.titleNode, alpha: alpha)
transition.updateAlpha(node: self.statusContainerNode, alpha: alpha)
}
func updateLayout(constrainedWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayoutWidth = constrainedWidth
let nameFont: UIFont
let statusFont: UIFont
if constrainedWidth < 330.0 {
nameFont = compactNameFont
statusFont = compactStatusFont
} else {
nameFont = regularNameFont
statusFont = regularStatusFont
}
var statusOffset: CGFloat = 0.0
let statusText: String
let statusMeasureText: String
var statusDisplayLogo: Bool = false
switch self.status {
case let .text(text, displayLogo):
statusText = text
statusMeasureText = text
statusDisplayLogo = displayLogo
if displayLogo {
statusOffset += 10.0
}
case let .timer(format, referenceTime):
let duration = Int32(CFAbsoluteTimeGetCurrent() - referenceTime)
let durationString: String
let measureDurationString: String
if duration > 60 * 60 {
durationString = String(format: "%02d:%02d:%02d", arguments: [duration / 3600, (duration / 60) % 60, duration % 60])
measureDurationString = "00:00:00"
} else {
durationString = String(format: "%02d:%02d", arguments: [(duration / 60) % 60, duration % 60])
measureDurationString = "00:00"
}
statusText = format(durationString, false)
statusMeasureText = format(measureDurationString, true)
if self.reception != nil {
statusOffset += 8.0
}
}
let spacing: CGFloat = 1.0
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: self.title, font: nameFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let (statusMeasureLayout, statusMeasureApply) = TextNode.asyncLayout(self.statusMeasureNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusMeasureText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let (statusLayout, statusApply) = TextNode.asyncLayout(self.statusNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let _ = titleApply()
let _ = statusApply()
let _ = statusMeasureApply()
self.titleActivateAreaNode.accessibilityLabel = self.title
self.statusActivateAreaNode.accessibilityLabel = statusText
self.titleNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - titleLayout.size.width) / 2.0), y: 0.0), size: titleLayout.size)
self.statusContainerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: titleLayout.size.height + spacing), size: CGSize(width: constrainedWidth, height: statusLayout.size.height))
self.statusNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - statusMeasureLayout.size.width) / 2.0) + statusOffset, y: 0.0), size: statusLayout.size)
self.receptionNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX - receptionNodeSize.width, y: 9.0), size: receptionNodeSize)
self.logoNode.isHidden = !statusDisplayLogo
if let image = self.logoNode.image, let firstLineRect = statusMeasureLayout.linesRects().first {
let firstLineOffset = floor((statusMeasureLayout.size.width - firstLineRect.width) / 2.0)
self.logoNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX + firstLineOffset - image.size.width - 7.0, y: 5.0), size: image.size)
}
self.titleActivateAreaNode.frame = self.titleNode.frame
self.statusActivateAreaNode.frame = self.statusContainerNode.frame
return titleLayout.size.height + spacing + statusLayout.size.height
}
}
private final class CallControllerReceptionNodeParameters: NSObject {
let reception: Int32
init(reception: Int32) {
self.reception = reception
}
}
private let receptionNodeSize = CGSize(width: 24.0, height: 10.0)
final class CallControllerReceptionNode : ASDisplayNode {
var reception: Int32 = 4 {
didSet {
self.setNeedsDisplay()
}
}
override init() {
super.init()
self.isOpaque = false
self.isLayerBacked = true
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return CallControllerReceptionNodeParameters(reception: self.reception)
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
context.setFillColor(UIColor.white.cgColor)
if let parameters = parameters as? CallControllerReceptionNodeParameters{
let width: CGFloat = 3.0
var spacing: CGFloat = 1.5
if UIScreenScale > 2 {
spacing = 4.0 / 3.0
}
for i in 0 ..< 4 {
let height = 4.0 + 2.0 * CGFloat(i)
let rect = CGRect(x: bounds.minX + CGFloat(i) * (width + spacing), y: receptionNodeSize.height - height, width: width, height: height)
if i >= parameters.reception {
context.setAlpha(0.4)
}
let path = UIBezierPath(roundedRect: rect, cornerRadius: 0.5)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
@@ -0,0 +1,323 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
private let labelFont = Font.regular(17.0)
private let smallLabelFont = Font.regular(15.0)
private enum ToastDescription: Equatable {
enum Key: Hashable {
case camera
case microphone
case mute
case battery
}
case camera
case microphone
case mute
case battery
var key: Key {
switch self {
case .camera:
return .camera
case .microphone:
return .microphone
case .mute:
return .mute
case .battery:
return .battery
}
}
}
struct CallControllerToastContent: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let camera = CallControllerToastContent(rawValue: 1 << 0)
public static let microphone = CallControllerToastContent(rawValue: 1 << 1)
public static let mute = CallControllerToastContent(rawValue: 1 << 2)
public static let battery = CallControllerToastContent(rawValue: 1 << 3)
}
final class CallControllerToastContainerNode: ASDisplayNode {
private var toastNodes: [ToastDescription.Key: CallControllerToastItemNode] = [:]
private var visibleToastNodes: [CallControllerToastItemNode] = []
private let strings: PresentationStrings
private var validLayout: (CGFloat, CGFloat)?
private var content: CallControllerToastContent?
private var appliedContent: CallControllerToastContent?
var title: String = ""
init(strings: PresentationStrings) {
self.strings = strings
super.init()
}
private func updateToastsLayout(strings: PresentationStrings, content: CallControllerToastContent, width: CGFloat, bottomInset: CGFloat, animated: Bool) -> CGFloat {
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
self.appliedContent = content
let spacing: CGFloat = 18.0
var height: CGFloat = 0.0
var toasts: [ToastDescription] = []
if content.contains(.camera) {
toasts.append(.camera)
}
if content.contains(.microphone) {
toasts.append(.microphone)
}
if content.contains(.mute) {
toasts.append(.mute)
}
if content.contains(.battery) {
toasts.append(.battery)
}
var transitions: [ToastDescription.Key: (ContainedViewLayoutTransition, CGFloat, Bool)] = [:]
var validKeys: [ToastDescription.Key] = []
for toast in toasts {
validKeys.append(toast.key)
var toastTransition = transition
var animateIn = false
let toastNode: CallControllerToastItemNode
if let current = self.toastNodes[toast.key] {
toastNode = current
} else {
toastNode = CallControllerToastItemNode()
self.toastNodes[toast.key] = toastNode
self.addSubnode(toastNode)
self.visibleToastNodes.append(toastNode)
toastTransition = .immediate
animateIn = transition.isAnimated
}
let toastContent: CallControllerToastItemNode.Content
switch toast {
case .camera:
toastContent = CallControllerToastItemNode.Content(
key: .camera,
image: .camera,
text: strings.Call_CameraOff(self.title).string
)
case .microphone:
toastContent = CallControllerToastItemNode.Content(
key: .microphone,
image: .microphone,
text: strings.Call_MicrophoneOff(self.title).string
)
case .mute:
toastContent = CallControllerToastItemNode.Content(
key: .mute,
image: .microphone,
text: strings.Call_YourMicrophoneOff
)
case .battery:
toastContent = CallControllerToastItemNode.Content(
key: .battery,
image: .battery,
text: strings.Call_BatteryLow(self.title).string
)
}
let toastHeight = toastNode.update(width: width, content: toastContent, transition: toastTransition)
transitions[toast.key] = (toastTransition, toastHeight, animateIn)
}
var removedKeys: [ToastDescription.Key] = []
for (key, toastNode) in self.toastNodes {
if !validKeys.contains(key) {
removedKeys.append(key)
self.visibleToastNodes.removeAll { $0 === toastNode }
if animated {
toastNode.animateOut(transition: transition) { [weak toastNode] in
toastNode?.removeFromSupernode()
}
} else {
toastNode.removeFromSupernode()
}
}
}
for key in removedKeys {
self.toastNodes.removeValue(forKey: key)
}
for toastNode in self.visibleToastNodes {
if let content = toastNode.currentContent, let (transition, toastHeight, animateIn) = transitions[content.key] {
transition.updateFrame(node: toastNode, frame: CGRect(x: 0.0, y: height, width: width, height: toastHeight))
height += toastHeight + spacing
if animateIn {
toastNode.animateIn()
}
}
}
if height > 0.0 {
height -= spacing
}
return height
}
func updateLayout(strings: PresentationStrings, content: CallControllerToastContent?, constrainedWidth: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = (constrainedWidth, bottomInset)
self.content = content
if let content = self.content {
return self.updateToastsLayout(strings: strings, content: content, width: constrainedWidth, bottomInset: bottomInset, animated: transition.isAnimated)
} else {
return 0.0
}
}
}
private class CallControllerToastItemNode: ASDisplayNode {
struct Content: Equatable {
enum Image {
case camera
case microphone
case battery
}
var key: ToastDescription.Key
var image: Image
var text: String
init(key: ToastDescription.Key, image: Image, text: String) {
self.key = key
self.image = image
self.text = text
}
}
let clipNode: ASDisplayNode
let effectView: UIVisualEffectView
let iconNode: ASImageNode
let textNode: ImmediateTextNode
private(set) var currentContent: Content?
private(set) var currentWidth: CGFloat?
private(set) var currentHeight: CGFloat?
override init() {
self.clipNode = ASDisplayNode()
self.clipNode.clipsToBounds = true
self.clipNode.layer.cornerRadius = 14.0
self.effectView = UIVisualEffectView()
self.effectView.effect = UIBlurEffect(style: .light)
self.effectView.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.contentMode = .center
self.textNode = ImmediateTextNode()
self.textNode.maximumNumberOfLines = 2
self.textNode.displaysAsynchronously = false
self.textNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.clipNode)
self.clipNode.view.addSubview(self.effectView)
self.clipNode.addSubnode(self.iconNode)
self.clipNode.addSubnode(self.textNode)
}
override func didLoad() {
super.didLoad()
if #available(iOS 13.0, *) {
self.clipNode.layer.cornerCurve = .continuous
}
}
func update(width: CGFloat, content: Content, transition: ContainedViewLayoutTransition) -> CGFloat {
let inset: CGFloat = 30.0
let isNarrowScreen = width <= 320.0
let font = isNarrowScreen ? smallLabelFont : labelFont
let topInset: CGFloat = isNarrowScreen ? 5.0 : 4.0
if self.currentContent != content || self.currentWidth != width {
self.currentContent = content
self.currentWidth = width
var image: UIImage?
switch content.image {
case .camera:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastCamera"), color: .white)
case .microphone:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastMicrophone"), color: .white)
case .battery:
image = generateTintedImage(image: UIImage(bundleImageName: "Call/CallToastBattery"), color: .white)
}
if transition.isAnimated, let image = image, let previousContent = self.iconNode.image {
self.iconNode.image = image
self.iconNode.layer.animate(from: previousContent.cgImage!, to: image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2)
} else {
self.iconNode.image = image
}
self.textNode.attributedText = NSAttributedString(string: content.text, font: font, textColor: .white)
let iconSize = CGSize(width: 44.0, height: 28.0)
let iconSpacing: CGFloat = isNarrowScreen ? 0.0 : 1.0
let textSize = self.textNode.updateLayout(CGSize(width: width - inset * 2.0 - iconSize.width - iconSpacing, height: 100.0))
let backgroundSize = CGSize(width: iconSize.width + iconSpacing + textSize.width + 6.0 * 2.0, height: max(28.0, textSize.height + 4.0 * 2.0))
let backgroundFrame = CGRect(origin: CGPoint(x: floor((width - backgroundSize.width) / 2.0), y: 0.0), size: backgroundSize)
transition.updateFrame(node: self.clipNode, frame: backgroundFrame)
transition.updateFrame(view: self.effectView, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
self.iconNode.frame = CGRect(origin: CGPoint(), size: iconSize)
self.textNode.frame = CGRect(origin: CGPoint(x: iconSize.width + iconSpacing, y: topInset), size: textSize)
self.currentHeight = backgroundSize.height
}
return self.currentHeight ?? 28.0
}
func animateIn() {
let targetFrame = self.clipNode.frame
let initialFrame = CGRect(x: floor((self.frame.width - 44.0) / 2.0), y: 0.0, width: 44.0, height: 28.0)
self.clipNode.frame = initialFrame
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.3, damping: 105.0, completion: { _ in
self.clipNode.frame = targetFrame
self.clipNode.layer.animateFrame(from: initialFrame, to: targetFrame, duration: 0.35, timingFunction: kCAMediaTimingFunctionSpring)
})
}
func animateOut(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateTransformScale(node: self, scale: 0.1)
transition.updateAlpha(node: self, alpha: 0.0, completion: { _ in
completion()
})
}
}
@@ -0,0 +1,128 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
private func attributedStringForDebugInfo(_ info: String, version: String) -> NSAttributedString {
guard !info.isEmpty else {
return NSAttributedString(string: "")
}
var string = info
string = "libtgvoip v\(version)\n" + string
string = string.replacingOccurrences(of: "Remote endpoints: \n", with: "")
string = string.replacingOccurrences(of: "Jitter ", with: "\nJitter ")
string = string.replacingOccurrences(of: "Key fingerprint:\n", with: "Key fingerprint: ")
let attributedString = NSMutableAttributedString(string: string, attributes: [NSAttributedString.Key.font: Font.monospace(10), NSAttributedString.Key.foregroundColor: UIColor.white])
let titleStyle = NSMutableParagraphStyle()
titleStyle.alignment = .center
titleStyle.lineSpacing = 7.0
let style = NSMutableParagraphStyle()
style.lineHeightMultiple = 1.15
let secondaryColor = UIColor(rgb: 0xa6a9a8)
let activeColor = UIColor(rgb: 0xa0d875)
let titleAttributes = [NSAttributedString.Key.font: Font.semiboldMonospace(15), NSAttributedString.Key.paragraphStyle: titleStyle]
let nameAttributes = [NSAttributedString.Key.font: Font.semiboldMonospace(10), NSAttributedString.Key.foregroundColor: secondaryColor]
let styleAttributes = [NSAttributedString.Key.paragraphStyle: style]
let typeAttributes = [NSAttributedString.Key.foregroundColor: secondaryColor]
let activeAttributes = [NSAttributedString.Key.font: Font.semiboldMonospace(10), NSAttributedString.Key.foregroundColor: activeColor]
let range = string.startIndex ..< string.endIndex
string.enumerateSubstrings(in: range, options: NSString.EnumerationOptions.byLines) { (line, range, _, _) in
guard let line = line else {
return
}
if range.lowerBound == string.startIndex {
attributedString.addAttributes(titleAttributes, range: NSRange(range, in: string))
}
else {
if let semicolonRange = line.range(of: ":") {
if let bracketRange = line.range(of: "[") {
if let _ = line.range(of: "IN_USE") {
attributedString.addAttributes(activeAttributes, range: NSRange(range, in: string))
} else {
let offset = line.distance(from: line.startIndex, to: bracketRange.lowerBound)
let distance = line.distance(from: line.startIndex, to: line.endIndex)
attributedString.addAttributes(typeAttributes, range: NSRange(string.index(range.lowerBound, offsetBy: offset) ..< string.index(range.lowerBound, offsetBy: distance), in: string))
}
} else {
attributedString.addAttributes(styleAttributes, range: NSRange(range, in: string))
let offset = line.distance(from: line.startIndex, to: semicolonRange.upperBound)
attributedString.addAttributes(nameAttributes, range: NSRange(range.lowerBound ..< string.index(range.lowerBound, offsetBy: offset), in: string))
}
}
}
}
return attributedString
}
final class CallDebugNode: ASDisplayNode {
private let disposable = MetaDisposable()
private let dimNode: ASDisplayNode
private let textNode: ASTextNode
private let timestamp = CACurrentMediaTime()
public var dismiss: (() -> Void)?
init(signal: Signal<(String, String), NoError>) {
self.dimNode = ASDisplayNode()
self.dimNode.isLayerBacked = true
self.dimNode.backgroundColor = UIColor(rgb: 0x26282c, alpha: 0.95)
self.dimNode.isUserInteractionEnabled = false
self.textNode = ASTextNode()
self.textNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.dimNode)
self.addSubnode(self.textNode)
self.disposable.set((signal
|> deliverOnMainQueue).start(next: { [weak self] (version, info) in
self?.update(info, version: version)
}))
}
deinit {
self.disposable.dispose()
}
override func didLoad() {
super.didLoad()
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
private func update(_ info: String, version: String) {
self.textNode.attributedText = attributedStringForDebugInfo(info, version: version)
self.setNeedsLayout()
}
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if CACurrentMediaTime() - self.timestamp > 1.0 {
self.dismiss?()
}
}
override func layout() {
super.layout()
let size = self.bounds.size
self.dimNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height))
let textSize = textNode.measure(CGSize(width: size.width - 20.0, height: size.height))
self.textNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: floorToScreenPixels((size.height - textSize.height) / 2.0)), size: textSize)
}
}
@@ -0,0 +1,361 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import OverlayStatusController
import AccountContext
private enum CallFeedbackReason: Int32, CaseIterable {
case videoDistorted
case videoLowQuality
case echo
case noise
case interruption
case distortedSpeech
case silentLocal
case silentRemote
case dropped
var hashtag: String {
switch self {
case .echo:
return "echo"
case .noise:
return "noise"
case .interruption:
return "interruptions"
case .distortedSpeech:
return "distorted_speech"
case .silentLocal:
return "silent_local"
case .silentRemote:
return "silent_remote"
case .dropped:
return "dropped"
case .videoDistorted:
return "distorted_video"
case .videoLowQuality:
return "pixelated_video"
}
}
var isVideoRelated: Bool {
switch self {
case .videoDistorted, .videoLowQuality:
return true
default:
return false
}
}
static func localizedString(for reason: CallFeedbackReason, strings: PresentationStrings) -> String {
switch reason {
case .echo:
return strings.CallFeedback_ReasonEcho
case .noise:
return strings.CallFeedback_ReasonNoise
case .interruption:
return strings.CallFeedback_ReasonInterruption
case .distortedSpeech:
return strings.CallFeedback_ReasonDistortedSpeech
case .silentLocal:
return strings.CallFeedback_ReasonSilentLocal
case .silentRemote:
return strings.CallFeedback_ReasonSilentRemote
case .dropped:
return strings.CallFeedback_ReasonDropped
case .videoDistorted:
return strings.CallFeedback_VideoReasonDistorted
case .videoLowQuality:
return strings.CallFeedback_VideoReasonLowQuality
}
}
}
private final class CallFeedbackControllerArguments {
let updateComment: (String) -> Void
let scrollToComment: () -> Void
let toggleReason: (CallFeedbackReason, Bool) -> Void
let toggleIncludeLogs: (Bool) -> Void
init(updateComment: @escaping (String) -> Void, scrollToComment: @escaping () -> Void, toggleReason: @escaping (CallFeedbackReason, Bool) -> Void, toggleIncludeLogs: @escaping (Bool) -> Void) {
self.updateComment = updateComment
self.scrollToComment = scrollToComment
self.toggleReason = toggleReason
self.toggleIncludeLogs = toggleIncludeLogs
}
}
private enum CallFeedbackControllerSection: Int32 {
case reasons
case comment
case logs
}
private enum CallFeedbackControllerEntryTag: ItemListItemTag {
case comment
func isEqual(to other: ItemListItemTag) -> Bool {
if let other = other as? CallFeedbackControllerEntryTag {
return self == other
} else {
return false
}
}
}
private enum CallFeedbackControllerEntry: ItemListNodeEntry {
case reasonsHeader(PresentationTheme, String)
case reason(PresentationTheme, CallFeedbackReason, String, Bool)
case comment(PresentationTheme, String, String)
case includeLogs(PresentationTheme, String, Bool)
case includeLogsInfo(PresentationTheme, String)
var section: ItemListSectionId {
switch self {
case .reasonsHeader, .reason:
return CallFeedbackControllerSection.reasons.rawValue
case .comment:
return CallFeedbackControllerSection.comment.rawValue
case .includeLogs, .includeLogsInfo:
return CallFeedbackControllerSection.logs.rawValue
}
}
var stableId: Int32 {
switch self {
case .reasonsHeader:
return 0
case let .reason(_, reason, _, _):
return 1 + reason.rawValue
case .comment:
return 100
case .includeLogs:
return 101
case .includeLogsInfo:
return 102
}
}
static func ==(lhs: CallFeedbackControllerEntry, rhs: CallFeedbackControllerEntry) -> Bool {
switch lhs {
case let .reasonsHeader(lhsTheme, lhsText):
if case let .reasonsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
case let .reason(lhsTheme, lhsReason, lhsText, lhsValue):
if case let .reason(rhsTheme, rhsReason, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsReason == rhsReason, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .comment(lhsTheme, lhsText, lhsValue):
if case let .comment(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .includeLogs(lhsTheme, lhsText, lhsValue):
if case let .includeLogs(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue {
return true
} else {
return false
}
case let .includeLogsInfo(lhsTheme, lhsText):
if case let .includeLogsInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
return true
} else {
return false
}
}
}
static func <(lhs: CallFeedbackControllerEntry, rhs: CallFeedbackControllerEntry) -> Bool {
return lhs.stableId < rhs.stableId
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! CallFeedbackControllerArguments
switch self {
case let .reasonsHeader(_, text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case let .reason(_, reason, title, value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, value: value, maximumNumberOfLines: 2, sectionId: self.section, style: .blocks, updated: { value in
arguments.toggleReason(reason, value)
})
case let .comment(_, text, placeholder):
return ItemListMultilineInputItem(presentationData: presentationData, systemStyle: .glass, text: text, placeholder: placeholder, maxLength: nil, sectionId: self.section, style: .blocks, textUpdated: { updatedText in
arguments.updateComment(updatedText)
}, updatedFocus: { focused in
if focused {
arguments.scrollToComment()
}
}, tag: CallFeedbackControllerEntryTag.comment)
case let .includeLogs(_, title, value):
return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in
arguments.toggleIncludeLogs(value)
})
case let .includeLogsInfo(_, text):
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
}
}
}
private struct CallFeedbackState: Equatable {
let reasons: Set<CallFeedbackReason>
let comment: String
let includeLogs: Bool
init(reasons: Set<CallFeedbackReason> = Set(), comment: String = "", includeLogs: Bool = true) {
self.reasons = reasons
self.comment = comment
self.includeLogs = includeLogs
}
func withUpdatedReasons(_ reasons: Set<CallFeedbackReason>) -> CallFeedbackState {
return CallFeedbackState(reasons: reasons, comment: self.comment, includeLogs: self.includeLogs)
}
func withUpdatedComment(_ comment: String) -> CallFeedbackState {
return CallFeedbackState(reasons: self.reasons, comment: comment, includeLogs: self.includeLogs)
}
func withUpdatedIncludeLogs(_ includeLogs: Bool) -> CallFeedbackState {
return CallFeedbackState(reasons: self.reasons, comment: self.comment, includeLogs: includeLogs)
}
}
private func callFeedbackControllerEntries(theme: PresentationTheme, strings: PresentationStrings, state: CallFeedbackState, isVideo: Bool) -> [CallFeedbackControllerEntry] {
var entries: [CallFeedbackControllerEntry] = []
entries.append(.reasonsHeader(theme, strings.CallFeedback_WhatWentWrong))
if isVideo {
for reason in CallFeedbackReason.allCases {
if !reason.isVideoRelated {
continue
}
entries.append(.reason(theme, reason, CallFeedbackReason.localizedString(for: reason, strings: strings), state.reasons.contains(reason)))
}
}
for reason in CallFeedbackReason.allCases {
if reason.isVideoRelated {
continue
}
entries.append(.reason(theme, reason, CallFeedbackReason.localizedString(for: reason, strings: strings), state.reasons.contains(reason)))
}
entries.append(.comment(theme, state.comment, strings.CallFeedback_AddComment))
entries.append(.includeLogs(theme, strings.CallFeedback_IncludeLogs, state.includeLogs))
entries.append(.includeLogsInfo(theme, strings.CallFeedback_IncludeLogsInfo))
return entries
}
public func callFeedbackController(sharedContext: SharedAccountContext, account: Account, callId: CallId, rating: Int, userInitiated: Bool, isVideo: Bool) -> ViewController {
let initialState = CallFeedbackState()
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((CallFeedbackState) -> CallFeedbackState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
var presentControllerImpl: ((ViewController) -> Void)?
var dismissImpl: (() -> Void)?
var ensureItemVisibleImpl: ((CallFeedbackControllerEntryTag, Bool) -> Void)?
let arguments = CallFeedbackControllerArguments(updateComment: { value in
updateState { $0.withUpdatedComment(value) }
ensureItemVisibleImpl?(.comment, false)
}, scrollToComment: {
ensureItemVisibleImpl?(.comment, true)
}, toggleReason: { reason, value in
updateState { current in
var reasons = current.reasons
if value {
reasons.insert(reason)
} else {
reasons.remove(reason)
}
return current.withUpdatedReasons(reasons)
}
}, toggleIncludeLogs: { value in
updateState { $0.withUpdatedIncludeLogs(value) }
})
let signal = combineLatest(sharedContext.presentationData, statePromise.get())
|> deliverOnMainQueue
|> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in
let leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: {
dismissImpl?()
})
let rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.CallFeedback_Send), style: .bold, enabled: true, action: {
var comment = state.comment
var hashtags = ""
for reason in CallFeedbackReason.allCases {
if state.reasons.contains(reason) {
if !hashtags.isEmpty {
hashtags.append(" ")
}
hashtags.append("#\(reason.hashtag)")
}
}
if !comment.isEmpty && !state.reasons.isEmpty {
comment.append("\n")
}
comment.append(hashtags)
let _ = rateCallAndSendLogs(engine: TelegramEngine(account: account), callId: callId, starsCount: rating, comment: comment, userInitiated: userInitiated, includeLogs: state.includeLogs).start()
dismissImpl?()
presentControllerImpl?(OverlayStatusController(theme: presentationData.theme, type: .starSuccess(presentationData.strings.CallFeedback_Success)))
})
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.CallFeedback_Title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: callFeedbackControllerEntries(theme: presentationData.theme, strings: presentationData.strings, state: state, isVideo: isVideo), style: .blocks, animateChanges: false)
return (controllerState, (listState, arguments))
}
let controller = ItemListController(sharedContext: sharedContext, state: signal)
controller.navigationPresentation = .modal
presentControllerImpl = { [weak controller] c in
controller?.present(c, in: .window(.root))
}
dismissImpl = { [weak controller] in
controller?.view.endEditing(true)
controller?.dismiss()
}
ensureItemVisibleImpl = { [weak controller] targetTag, animated in
controller?.afterLayout({
guard let controller = controller else {
return
}
var resultItemNode: ListViewItemNode?
let _ = controller.frameForItemNode({ itemNode in
if let itemNode = itemNode as? ItemListItemNode {
if let tag = itemNode.tag, tag.isEqual(to: targetTag) {
resultItemNode = itemNode as? ListViewItemNode
return true
}
}
return false
})
if let resultItemNode = resultItemNode {
controller.ensureItemNodeVisible(resultItemNode, animated: animated)
}
})
}
return controller
}
@@ -0,0 +1,415 @@
import SGAppGroupIdentifier
import Foundation
import UIKit
import CallKit
import Intents
import AVFoundation
import TelegramCore
import SwiftSignalKit
import AppBundle
import AccountContext
import TelegramAudio
import TelegramVoip
private let sharedProviderDelegate: CallKitProviderDelegate? = {
return CallKitProviderDelegate()
}()
public final class CallKitIntegration {
public static var isAvailable: Bool {
#if targetEnvironment(simulator)
return false
#else
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
return Locale.current.regionCode?.lowercased() != "cn" && !(UserDefaults(suiteName: sgAppGroupIdentifier())?.bool(forKey: "legacyNotificationsFix") ?? false)
} else {
return false
}
#endif
}
private let audioSessionActivePromise = ValuePromise<Bool>(false, ignoreRepeated: true)
var audioSessionActive: Signal<Bool, NoError> {
return self.audioSessionActivePromise.get()
}
private let hasActiveCallsValue = ValuePromise<Bool>(false, ignoreRepeated: true)
public var hasActiveCalls: Signal<Bool, NoError> {
return self.hasActiveCallsValue.get()
}
private static let sharedInstance: CallKitIntegration? = CallKitIntegration()
public static var shared: CallKitIntegration? {
return self.sharedInstance
}
func setup(
startCall: @escaping (AccountContext, UUID, EnginePeer.Id?, String, Bool) -> Signal<Bool, NoError>,
answerCall: @escaping (UUID) -> Void,
endCall: @escaping (UUID) -> Signal<Bool, NoError>,
setCallMuted: @escaping (UUID, Bool) -> Void,
audioSessionActivationChanged: @escaping (Bool) -> Void
) {
sharedProviderDelegate?.setup(audioSessionActivePromise: self.audioSessionActivePromise, startCall: startCall, answerCall: answerCall, endCall: endCall, setCallMuted: setCallMuted, audioSessionActivationChanged: audioSessionActivationChanged, hasActiveCallsValue: hasActiveCallsValue)
}
private init?() {
if !CallKitIntegration.isAvailable {
return nil
}
}
func startCall(context: AccountContext, peerId: EnginePeer.Id, phoneNumber: String?, localContactId: String?, isVideo: Bool, displayTitle: String) {
sharedProviderDelegate?.startCall(context: context, peerId: peerId, phoneNumber: phoneNumber, isVideo: isVideo, displayTitle: displayTitle)
self.donateIntent(peerId: peerId, displayTitle: displayTitle, localContactId: localContactId)
}
func answerCall(uuid: UUID) {
#if DEBUG
print("CallKitIntegration: Answer call \(uuid)")
#endif
sharedProviderDelegate?.answerCall(uuid: uuid)
}
public func dropCall(uuid: UUID) {
#if DEBUG
print("CallKitIntegration: Drop call \(uuid)")
#endif
sharedProviderDelegate?.dropCall(uuid: uuid)
}
public func reportIncomingCall(uuid: UUID, stableId: Int64, handle: String, phoneNumber: String?, isVideo: Bool, displayTitle: String, completion: ((NSError?) -> Void)?) {
#if DEBUG
print("CallKitIntegration: Report incoming call \(uuid)")
#endif
sharedProviderDelegate?.reportIncomingCall(uuid: uuid, stableId: stableId, handle: handle, phoneNumber: phoneNumber, isVideo: isVideo, displayTitle: displayTitle, completion: completion)
}
func reportOutgoingCallConnected(uuid: UUID, at date: Date) {
sharedProviderDelegate?.reportOutgoingCallConnected(uuid: uuid, at: date)
}
private func donateIntent(peerId: EnginePeer.Id, displayTitle: String, localContactId: String?) {
let handle = INPersonHandle(value: "tg\(peerId.id._internalGetInt64Value())", type: .unknown)
let contact = INPerson(personHandle: handle, nameComponents: nil, displayName: displayTitle, image: nil, contactIdentifier: localContactId, customIdentifier: "tg\(peerId.id._internalGetInt64Value())")
let intent = INStartCallIntent(audioRoute: .unknown, destinationType: .normal, contacts: [contact], recordTypeForRedialing: .unknown, callCapability: .audioCall)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .outgoing
interaction.donate { _ in
}
}
public func applyVoiceChatOutputMode(outputMode: AudioSessionOutputMode) {
sharedProviderDelegate?.applyVoiceChatOutputMode(outputMode: outputMode)
}
public func updateCallIsConference(uuid: UUID, title: String) {
sharedProviderDelegate?.updateCallIsConference(uuid: uuid, title: title)
}
}
@available(iOSApplicationExtension 10.0, iOS 10.0, *)
class CallKitProviderDelegate: NSObject, CXProviderDelegate {
private let provider: CXProvider
private let callController = CXCallController()
private var currentStartCallAccount: (UUID, AccountContext)?
private var alreadyReportedIncomingCalls = Set<UUID>()
private var uuidToPeerIdMapping: [UUID: EnginePeer.Id] = [:]
private var startCall: ((AccountContext, UUID, EnginePeer.Id?, String, Bool) -> Signal<Bool, NoError>)?
private var answerCall: ((UUID) -> Void)?
private var endCall: ((UUID) -> Signal<Bool, NoError>)?
private var setCallMuted: ((UUID, Bool) -> Void)?
private var audioSessionActivationChanged: ((Bool) -> Void)?
private var hasActiveCallsValue: ValuePromise<Bool>?
private var isAudioSessionActive: Bool = false
private var pendingVoiceChatOutputMode: AudioSessionOutputMode?
private let disposableSet = DisposableSet()
fileprivate var audioSessionActivePromise: ValuePromise<Bool>?
private var activeCalls = Set<UUID>() {
didSet {
self.hasActiveCallsValue?.set(!self.activeCalls.isEmpty)
}
}
override init() {
self.provider = CXProvider(configuration: CallKitProviderDelegate.providerConfiguration())
super.init()
self.provider.setDelegate(self, queue: nil)
}
func setup(audioSessionActivePromise: ValuePromise<Bool>, startCall: @escaping (AccountContext, UUID, EnginePeer.Id?, String, Bool) -> Signal<Bool, NoError>, answerCall: @escaping (UUID) -> Void, endCall: @escaping (UUID) -> Signal<Bool, NoError>, setCallMuted: @escaping (UUID, Bool) -> Void, audioSessionActivationChanged: @escaping (Bool) -> Void, hasActiveCallsValue: ValuePromise<Bool>) {
self.audioSessionActivePromise = audioSessionActivePromise
self.startCall = startCall
self.answerCall = answerCall
self.endCall = endCall
self.setCallMuted = setCallMuted
self.audioSessionActivationChanged = audioSessionActivationChanged
self.hasActiveCallsValue = hasActiveCallsValue
}
private static func providerConfiguration() -> CXProviderConfiguration {
// MARK: Swiftgram
let providerConfiguration = CXProviderConfiguration(localizedName: "Swiftgram")
providerConfiguration.supportsVideo = true
providerConfiguration.maximumCallsPerCallGroup = 1
providerConfiguration.maximumCallGroups = 1
providerConfiguration.supportedHandleTypes = [.phoneNumber, .generic]
if let image = UIImage(named: "Call/CallKitLogo", in: getAppBundle(), compatibleWith: nil) {
providerConfiguration.iconTemplateImageData = image.pngData()
}
return providerConfiguration
}
private func requestTransaction(_ transaction: CXTransaction, completion: ((Bool) -> Void)? = nil) {
Logger.shared.log("CallKitIntegration", "requestTransaction \(transaction)")
self.callController.request(transaction) { error in
if let error = error {
Logger.shared.log("CallKitIntegration", "error in requestTransaction \(transaction): \(error)")
}
completion?(error == nil)
}
}
func endCall(uuid: UUID) {
Logger.shared.log("CallKitIntegration", "endCall \(uuid)")
let endCallAction = CXEndCallAction(call: uuid)
let transaction = CXTransaction(action: endCallAction)
self.requestTransaction(transaction)
self.activeCalls.remove(uuid)
}
func dropCall(uuid: UUID) {
self.alreadyReportedIncomingCalls.insert(uuid)
Logger.shared.log("CallKitIntegration", "report call ended \(uuid)")
self.provider.reportCall(with: uuid, endedAt: nil, reason: CXCallEndedReason.remoteEnded)
self.activeCalls.remove(uuid)
}
func answerCall(uuid: UUID) {
Logger.shared.log("CallKitIntegration", "answer call \(uuid)")
let answerCallAction = CXAnswerCallAction(call: uuid)
let transaction = CXTransaction(action: answerCallAction)
self.requestTransaction(transaction)
}
func startCall(context: AccountContext, peerId: EnginePeer.Id, phoneNumber: String?, isVideo: Bool, displayTitle: String) {
let uuid = UUID()
self.currentStartCallAccount = (uuid, context)
let handle: CXHandle
if let phoneNumber = phoneNumber {
handle = CXHandle(type: .phoneNumber, value: phoneNumber)
} else {
handle = CXHandle(type: .generic, value: "\(peerId.id._internalGetInt64Value())")
}
self.uuidToPeerIdMapping[uuid] = peerId
let startCallAction = CXStartCallAction(call: uuid, handle: handle)
startCallAction.contactIdentifier = displayTitle
startCallAction.isVideo = isVideo
let transaction = CXTransaction(action: startCallAction)
Logger.shared.log("CallKitIntegration", "initiate call \(uuid)")
self.requestTransaction(transaction, completion: { _ in
let update = CXCallUpdate()
update.remoteHandle = handle
update.localizedCallerName = displayTitle
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
self.provider.reportCall(with: uuid, updated: update)
self.activeCalls.insert(uuid)
})
}
func reportIncomingCall(uuid: UUID, stableId: Int64, handle: String, phoneNumber: String?, isVideo: Bool, displayTitle: String, completion: ((NSError?) -> Void)?) {
if self.alreadyReportedIncomingCalls.contains(uuid) {
completion?(nil)
return
}
self.alreadyReportedIncomingCalls.insert(uuid)
let update = CXCallUpdate()
let nativeHandle: CXHandle
if let phoneNumber = phoneNumber {
nativeHandle = CXHandle(type: .phoneNumber, value: phoneNumber)
} else {
nativeHandle = CXHandle(type: .generic, value: handle)
}
update.remoteHandle = nativeHandle
update.localizedCallerName = displayTitle
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
update.hasVideo = isVideo
Logger.shared.log("CallKitIntegration", "report incoming call \(uuid)")
OngoingCallContext.setupAudioSession()
self.provider.reportNewIncomingCall(with: uuid, update: update, completion: { error in
if error == nil {
self.activeCalls.insert(uuid)
}
completion?(error as NSError?)
})
}
func reportOutgoingCallConnecting(uuid: UUID, at date: Date) {
Logger.shared.log("CallKitIntegration", "report outgoing call connecting \(uuid)")
self.provider.reportOutgoingCall(with: uuid, startedConnectingAt: date)
}
func reportOutgoingCallConnected(uuid: UUID, at date: Date) {
Logger.shared.log("CallKitIntegration", "report call connected \(uuid)")
self.provider.reportOutgoingCall(with: uuid, connectedAt: date)
}
func updateCallIsConference(uuid: UUID, title: String) {
let update = CXCallUpdate()
let handle = CXHandle(type: .generic, value: "\(uuid)")
update.remoteHandle = handle
update.localizedCallerName = title
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
self.provider.reportCall(with: uuid, updated: update)
}
func providerDidReset(_ provider: CXProvider) {
Logger.shared.log("CallKitIntegration", "providerDidReset")
self.activeCalls.removeAll()
}
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
Logger.shared.log("CallKitIntegration", "provider perform start call action \(action)")
guard let startCall = self.startCall, let (uuid, context) = self.currentStartCallAccount, uuid == action.callUUID else {
action.fail()
return
}
self.currentStartCallAccount = nil
let disposable = MetaDisposable()
self.disposableSet.add(disposable)
let peerId = self.uuidToPeerIdMapping[action.callUUID]
disposable.set((startCall(context, action.callUUID, peerId, action.handle.value, action.isVideo)
|> deliverOnMainQueue
|> afterDisposed { [weak self, weak disposable] in
if let strongSelf = self, let disposable = disposable {
strongSelf.disposableSet.remove(disposable)
}
}).start(next: { result in
if result {
action.fulfill()
} else {
action.fail()
}
}))
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
Logger.shared.log("CallKitIntegration", "provider perform answer call action \(action)")
guard let answerCall = self.answerCall else {
action.fail()
return
}
answerCall(action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
Logger.shared.log("CallKitIntegration", "provider perform end call action \(action)")
guard let endCall = self.endCall else {
action.fail()
return
}
let disposable = MetaDisposable()
self.disposableSet.add(disposable)
disposable.set((endCall(action.callUUID)
|> deliverOnMainQueue
|> afterDisposed { [weak self, weak disposable] in
if let strongSelf = self, let disposable = disposable {
strongSelf.disposableSet.remove(disposable)
}
}).start(next: { result in
if result {
action.fulfill(withDateEnded: Date())
} else {
action.fail()
}
}))
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
Logger.shared.log("CallKitIntegration", "provider perform mute call action \(action)")
guard let setCallMuted = self.setCallMuted else {
action.fail()
return
}
setCallMuted(action.uuid, action.isMuted)
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
Logger.shared.log("CallKitIntegration", "provider didActivate audio session")
self.isAudioSessionActive = true
self.audioSessionActivationChanged?(true)
self.audioSessionActivePromise?.set(true)
if let outputMode = self.pendingVoiceChatOutputMode {
self.pendingVoiceChatOutputMode = nil
sharedManagedAudioSession?.applyVoiceChatOutputModeInCurrentAudioSession(outputMode: outputMode)
}
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
Logger.shared.log("CallKitIntegration", "provider didDeactivate audio session")
self.isAudioSessionActive = false
self.audioSessionActivationChanged?(false)
self.audioSessionActivePromise?.set(false)
}
func applyVoiceChatOutputMode(outputMode: AudioSessionOutputMode) {
if self.isAudioSessionActive {
sharedManagedAudioSession?.applyVoiceChatOutputModeInCurrentAudioSession(outputMode: outputMode)
} else {
self.pendingVoiceChatOutputMode = outputMode
}
}
}
@@ -0,0 +1,235 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import ComponentFlow
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramVoip
import AccountContext
import AlertComponent
func rateCallAndSendLogs(engine: TelegramEngine, callId: CallId, starsCount: Int, comment: String, userInitiated: Bool, includeLogs: Bool) -> Signal<Void, NoError> {
let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(4244000))
let rate = engine.calls.rateCall(callId: callId, starsCount: Int32(starsCount), comment: comment, userInitiated: userInitiated)
if includeLogs {
let id = Int64.random(in: Int64.min ... Int64.max)
let name = "\(callId.id)_\(callId.accessHash).log.json"
let path = callLogsPath(account: engine.account) + "/" + name
let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: name)], alternativeRepresentations: [])
let message = EnqueueMessage.message(text: comment, attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
return rate
|> then(enqueueMessages(account: engine.account, peerId: peerId, messages: [message])
|> mapToSignal({ _ -> Signal<Void, NoError> in
return .single(Void())
}))
} else if !comment.isEmpty {
return rate
|> then(enqueueMessages(account: engine.account, peerId: peerId, messages: [.message(text: comment, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])])
|> mapToSignal({ _ -> Signal<Void, NoError> in
return .single(Void())
}))
} else {
return rate
}
}
public func callRatingController(
sharedContext: SharedAccountContext,
account: Account,
callId: CallId,
userInitiated: Bool,
isVideo: Bool,
present: @escaping (ViewController, Any) -> Void,
push: @escaping (ViewController) -> Void
) -> ViewController {
let strings = sharedContext.currentPresentationData.with { $0 }.strings
var dismissImpl: (() -> Void)?
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(
title: strings.Calls_RatingTitle,
alignment: .center
)
)
))
content.append(AnyComponentWithIdentity(
id: "stars",
component: AnyComponent(
AlertCallRatingComponent(completion: { rating in
dismissImpl?()
if rating < 4 {
push(callFeedbackController(sharedContext: sharedContext, account: account, callId: callId, rating: rating, userInitiated: userInitiated, isVideo: isVideo))
} else {
let _ = rateCallAndSendLogs(engine: TelegramEngine(account: account), callId: callId, starsCount: rating, comment: "", userInitiated: userInitiated, includeLogs: false).start()
}
})
)
))
let alertController = AlertScreen(
sharedContext: sharedContext,
content: content,
actions: [
.init(title: strings.Common_NotNow)
]
)
dismissImpl = { [weak alertController] in
alertController?.dismiss(completion: nil)
}
return alertController
}
private final class AlertCallRatingComponent: Component {
public typealias EnvironmentType = AlertComponentEnvironment
private let completion: (Int) -> Void
public init(completion: @escaping (Int) -> Void) {
self.completion = completion
}
public static func ==(lhs: AlertCallRatingComponent, rhs: AlertCallRatingComponent) -> Bool {
return true
}
public final class View: UIView {
private var containerView: UIView
private let starButtons: [HighlightTrackingButton]
var rating: Int?
private var component: AlertCallRatingComponent?
private weak var state: EmptyComponentState?
public override init(frame: CGRect) {
self.containerView = UIView()
var starButtons: [HighlightTrackingButton] = []
for _ in 0 ..< 5 {
starButtons.append(HighlightTrackingButton())
}
self.starButtons = starButtons
super.init(frame: frame)
self.addSubview(self.containerView)
for button in self.starButtons {
button.addTarget(self, action: #selector(self.starPressed(_:)), for: .touchDown)
button.addTarget(self, action: #selector(self.starReleased(_:)), for: .touchUpInside)
self.containerView.addSubview(button)
}
self.containerView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))))
}
public required init?(coder: NSCoder) {
preconditionFailure()
}
@objc func panGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
let location = gestureRecognizer.location(in: self.containerView)
var selectedButton: HighlightTrackingButton?
for button in self.starButtons {
if button.frame.contains(location) {
selectedButton = button
break
}
}
if let selectedButton = selectedButton {
switch gestureRecognizer.state {
case .began, .changed:
self.starPressed(selectedButton)
case .ended:
self.starReleased(selectedButton)
case .cancelled:
self.resetStars()
default:
break
}
} else {
self.resetStars()
}
}
private func resetStars() {
for i in 0 ..< self.starButtons.count {
let node = self.starButtons[i]
node.isSelected = false
}
}
@objc func starPressed(_ sender: HighlightTrackingButton) {
if let index = self.starButtons.firstIndex(of: sender) {
self.rating = index + 1
for i in 0 ..< self.starButtons.count {
let node = self.starButtons[i]
node.isSelected = i <= index
}
}
}
@objc func starReleased(_ sender: HighlightTrackingButton) {
guard let component = self.component else {
return
}
if let index = self.starButtons.firstIndex(of: sender) {
self.rating = index + 1
for i in 0 ..< self.starButtons.count {
let node = self.starButtons[i]
node.isSelected = i <= index
}
if let rating = self.rating {
component.completion(rating)
}
}
}
func update(component: AlertCallRatingComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<AlertComponentEnvironment>, transition: ComponentTransition) -> CGSize {
if self.component == nil {
for i in 0 ..< self.starButtons.count {
let button = self.starButtons[i]
button.setImage(UIImage(bundleImageName: "Call/Star")?.withRenderingMode(.alwaysTemplate), for: .normal)
button.setImage(UIImage(bundleImageName: "Call/StarHighlighted")?.withRenderingMode(.alwaysTemplate), for: .selected)
button.setImage(UIImage(bundleImageName: "Call/StarHighlighted")?.withRenderingMode(.alwaysTemplate), for: [.selected, .highlighted])
}
}
self.component = component
self.state = state
let environment = environment[AlertComponentEnvironment.self]
let buttonCount = CGFloat(self.starButtons.count)
let starSize = CGSize(width: 42.0, height: 38.0)
let starsOrigin = floorToScreenPixels((availableSize.width - starSize.width * buttonCount) / 2.0)
self.containerView.frame = CGRect(origin: CGPoint(x: starsOrigin, y: 0.0), size: CGSize(width: starSize.width * buttonCount, height: starSize.height))
for i in 0 ..< self.starButtons.count {
let button = self.starButtons[i]
button.imageView?.tintColor = environment.theme.actionSheet.controlAccentColor
transition.setFrame(view: button, frame: CGRect(x: starSize.width * CGFloat(i), y: 0.0, width: starSize.width, height: starSize.height))
}
return CGSize(width: availableSize.width, height: 38.0)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<AlertComponentEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,163 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
public class CallRouteActionSheetItem: ActionSheetItem {
public let title: String
public let icon: UIImage?
public let selected: Bool
public let action: () -> Void
public init(title: String, icon: UIImage?, selected: Bool, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.selected = selected
self.action = action
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
let node = CallRouteActionSheetItemNode(theme: theme)
node.setItem(self)
return node
}
public func updateNode(_ node: ActionSheetItemNode) {
guard let node = node as? CallRouteActionSheetItemNode else {
assertionFailure()
return
}
node.setItem(self)
node.requestLayoutUpdate()
}
}
public class CallRouteActionSheetItemNode: ActionSheetItemNode {
private let theme: ActionSheetControllerTheme
private let defaultFont: UIFont
private var item: CallRouteActionSheetItem?
private let button: HighlightTrackingButton
private let label: ASTextNode
private let iconNode: ASImageNode
private let checkNode: ASImageNode
private let accessibilityArea: AccessibilityAreaNode
override public init(theme: ActionSheetControllerTheme) {
self.theme = theme
self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.button = HighlightTrackingButton()
self.button.isAccessibilityElement = false
self.label = ASTextNode()
self.label.isUserInteractionEnabled = false
self.label.maximumNumberOfLines = 1
self.label.displaysAsynchronously = false
self.label.truncationMode = .byTruncatingTail
self.label.isAccessibilityElement = false
self.iconNode = ASImageNode()
self.iconNode.isUserInteractionEnabled = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.iconNode.isAccessibilityElement = false
self.checkNode = ASImageNode()
self.checkNode.isUserInteractionEnabled = false
self.checkNode.displayWithoutProcessing = true
self.checkNode.displaysAsynchronously = false
self.checkNode.image = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(theme.controlAccentColor.cgColor)
context.setLineWidth(2.0)
context.move(to: CGPoint(x: 12.0, y: 1.0))
context.addLine(to: CGPoint(x: 4.16482734, y: 9.0))
context.addLine(to: CGPoint(x: 1.0, y: 5.81145833))
context.strokePath()
})
self.checkNode.isAccessibilityElement = false
self.accessibilityArea = AccessibilityAreaNode()
super.init(theme: theme)
self.view.addSubview(self.button)
self.label.isUserInteractionEnabled = false
self.addSubnode(self.label)
self.addSubnode(self.iconNode)
self.addSubnode(self.checkNode)
self.addSubnode(self.accessibilityArea)
self.button.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemBackgroundColor
})
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
self.accessibilityArea.activate = { [weak self] in
self?.buttonPressed()
return true
}
}
func setItem(_ item: CallRouteActionSheetItem) {
self.item = item
let defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.label.attributedText = NSAttributedString(string: item.title, font: defaultFont, textColor: self.theme.standardActionTextColor)
if let icon = item.icon {
self.iconNode.image = generateTintedImage(image: icon, color: self.theme.standardActionTextColor)
} else {
self.iconNode.isHidden = true
}
self.checkNode.isHidden = !item.selected
var accessibilityTraits: UIAccessibilityTraits = [.button]
if item.selected {
accessibilityTraits.insert(.selected)
}
self.accessibilityArea.accessibilityTraits = accessibilityTraits
self.accessibilityArea.accessibilityLabel = item.title
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 57.0)
self.button.frame = CGRect(origin: CGPoint(), size: size)
let labelSize = self.label.measure(CGSize(width: max(1.0, size.width - 10.0), height: size.height))
self.label.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - labelSize.width) / 2.0), y: floorToScreenPixels((size.height - labelSize.height) / 2.0)), size: labelSize)
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: 12.0, y: floor((size.height - image.size.height) / 2.0)), size: image.size)
}
if let image = self.checkNode.image {
self.checkNode.frame = CGRect(origin: CGPoint(x: size.width - image.size.width - 13.0, y: floor((size.height - image.size.height) / 2.0)), size: image.size)
}
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
@objc func buttonPressed() {
if let item = self.item {
item.action()
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AppBundle
import ComponentFlow
import AlertComponent
import BundleIconComponent
public func callSuggestTabController(sharedContext: SharedAccountContext) -> ViewController {
let strings = sharedContext.currentPresentationData.with { $0 }.strings
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "header",
component: AnyComponent(
AlertCallSuggestHeaderComponent()
)
))
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(title: strings.Calls_CallTabTitle)
)
))
content.append(AnyComponentWithIdentity(
id: "text",
component: AnyComponent(
AlertTextComponent(content: .plain(strings.Calls_CallTabDescription))
)
))
let alertController = AlertScreen(
sharedContext: sharedContext,
content: content,
actions: [
.init(title: strings.Common_NotNow),
.init(title: strings.Calls_AddTab, type: .default, action: {
let _ = updateCallListSettingsInteractively(accountManager: sharedContext.accountManager, {
$0.withUpdatedShowTab(true)
}).start()
})
]
)
return alertController
}
private final class AlertCallSuggestHeaderComponent: Component {
public typealias EnvironmentType = AlertComponentEnvironment
public init() {
}
public static func ==(lhs: AlertCallSuggestHeaderComponent, rhs: AlertCallSuggestHeaderComponent) -> Bool {
return true
}
public final class View: UIView {
private let image = ComponentView<Empty>()
private let accentImage = ComponentView<Empty>()
private var component: AlertCallSuggestHeaderComponent?
private weak var state: EmptyComponentState?
func update(component: AlertCallSuggestHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<AlertComponentEnvironment>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let environment = environment[AlertComponentEnvironment.self]
let imageSize = self.image.update(
transition: .immediate,
component: AnyComponent(
BundleIconComponent(name: "Call List/AlertIcon", tintColor: environment.theme.actionSheet.primaryTextColor.withMultipliedAlpha(0.2))
),
environment: {},
containerSize: availableSize
)
let imageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - imageSize.width) / 2.0), y: 0.0), size: imageSize)
if let imageView = self.image.view {
if imageView.superview == nil {
self.addSubview(imageView)
}
imageView.frame = imageFrame
}
let _ = self.accentImage.update(
transition: .immediate,
component: AnyComponent(
BundleIconComponent(name: "Call List/AlertAccentIcon", tintColor: environment.theme.actionSheet.controlAccentColor)
),
environment: {},
containerSize: availableSize
)
let accentImageFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - imageSize.width) / 2.0), y: 0.0), size: imageSize)
if let accentImageView = self.accentImage.view {
if accentImageView.superview == nil {
self.addSubview(accentImageView)
}
accentImageView.frame = accentImageFrame
}
return CGSize(width: availableSize.width, height: imageSize.height)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<AlertComponentEnvironment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,407 @@
import Foundation
import UIKit
import Display
import ComponentFlow
private let purple = UIColor(rgb: 0xdf44b8)
private let pink = UIColor(rgb: 0x3851eb)
public final class AnimatedCountView: UIView {
let countLabel = AnimatedCountLabel()
let subtitleLabel = UILabel()
private let foregroundView = UIView()
private let foregroundGradientLayer = CAGradientLayer()
private let maskingView = UIView()
private var scaleFactor: CGFloat { 0.7 }
override init(frame: CGRect = .zero) {
super.init(frame: frame)
self.foregroundGradientLayer.type = .radial
self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0]
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
self.foregroundView.mask = self.maskingView
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
self.addSubview(self.foregroundView)
self.addSubview(self.subtitleLabel)
self.maskingView.addSubview(countLabel)
countLabel.clipsToBounds = false
subtitleLabel.textAlignment = .center
self.clipsToBounds = false
subtitleLabel.textColor = .white
}
override public func layoutSubviews() {
super.layoutSubviews()
self.updateFrames()
}
func updateFrames(transition: ComponentFlow.ComponentTransition? = nil) {
let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height
let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: self.countLabel.attributedText?.length == 0 ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight)
if let transition {
transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60))
transition.setFrame(view: self.maskingView, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(view: self.countLabel, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(view: self.subtitleLabel, frame: subtitleFrame)
} else {
self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40)
self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)
self.maskingView.frame = CGRect(origin: .zero, size: bounds.size)
countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height))
subtitleLabel.frame = subtitleFrame
}
}
func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) {
self.setupGradientAnimations()
let backgroundGradientColors: [CGColor]
if gradientColors.count == 1 {
backgroundGradientColors = [gradientColors[0], gradientColors[0]]
} else {
backgroundGradientColors = gradientColors
}
self.foregroundGradientLayer.colors = backgroundGradientColors
let text: String = countString
self.countLabel.fontSize = fontSize
self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor((fontSize + 4.0) / 3.0), 12.0), weight: .semibold)])
self.subtitleLabel.isHidden = subtitle.isEmpty
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupGradientAnimations() {
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.foregroundGradientLayer.startPoint
let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45))
self.foregroundGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
self?.setupGradientAnimations()
}
self.foregroundGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
}
class AnimatedCharLayer: CATextLayer {
var text: String? {
get {
self.string as? String ?? (self.string as? NSAttributedString)?.string
}
set {
self.string = newValue
}
}
var attributedText: NSAttributedString? {
get {
self.string as? NSAttributedString
}
set {
self.string = newValue
}
}
var layer: CALayer { self }
override init() {
super.init()
self.contentsScale = UIScreen.main.scale
self.masksToBounds = false
}
override init(layer: Any) {
super.init(layer: layer)
self.contentsScale = UIScreen.main.scale
self.masksToBounds = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class AnimatedCountLabel: UILabel {
override var text: String? {
get {
chars.reduce("") { $0 + ($1.text ?? "") }
}
set {
// update(with: newValue ?? "")
}
}
override var attributedText: NSAttributedString? {
get {
let string = NSMutableAttributedString()
for char in chars {
string.append(char.attributedText ?? NSAttributedString())
}
return string
}
set {
udpateAttributed(with: newValue ?? NSAttributedString())
}
}
private var chars = [AnimatedCharLayer]()
private let containerView = UIView()
var itemWidth: CGFloat { 36 * fontSize / 60 }
var commaWidthForSpacing: CGFloat { 12 * fontSize / 60 }
var commaFrameWidth: CGFloat { 36 * fontSize / 60 }
var interItemSpacing: CGFloat { 0 * fontSize / 60 }
var didBegin = false
var fontSize: CGFloat = 60
var scaleFactor: CGFloat { 1 }
override init(frame: CGRect = .zero) {
super.init(frame: frame)
containerView.clipsToBounds = false
addSubview(containerView)
self.clipsToBounds = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat {
if let characters {
var offset = characters[0..<index].reduce(0) {
if $1.string == "," {
return $0 + commaWidthForSpacing + interItemSpacing
}
return $0 + itemWidth + interItemSpacing
}
if characters.count > index && characters[index].string == "," {
if index > 0, ["1", "7"].contains(characters[index - 1].string) {
offset -= commaWidthForSpacing * 0.5
} else {
offset -= commaWidthForSpacing / 6// 3
}
}
return offset
} else {
return offsetForChar(at: index, within: self.chars.compactMap(\.attributedText))
}
}
override func layoutSubviews() {
super.layoutSubviews()
let countWidth = offsetForChar(at: chars.count) - interItemSpacing
containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height)
chars.enumerated().forEach { (index, char) in
let offset = offsetForChar(at: index)
char.frame.origin.x = offset
char.frame.origin.y = 0
char.frame.size.height = containerView.bounds.height
}
}
func udpateAttributed(with newString: NSAttributedString) {
let interItemSpacing: CGFloat = 0
let separatedStrings = Array(newString.string).map { String($0) }
var range = NSRange(location: 0, length: 0)
var newChars = [NSAttributedString]()
for string in separatedStrings {
range.length = string.count
let attributedString = newString.attributedSubstring(from: range)
newChars.append(attributedString)
range.location += range.length
}
let currentChars = chars.map { $0.attributedText ?? .init() }
let maxAnimationDuration: TimeInterval = 1.2
var numberOfChanges = abs(newChars.count - currentChars.count)
for index in 0..<min(newChars.count, currentChars.count) {
let newCharIndex = newChars.count - 1 - index
let currCharIndex = currentChars.count - 1 - index
if newChars[newCharIndex] != currentChars[currCharIndex] {
numberOfChanges += 1
}
}
let initialDuration: TimeInterval = min(0.25, maxAnimationDuration / Double(numberOfChanges))
let interItemDelay: TimeInterval = 0.08
var changeIndex = 0
var newLayers = [AnimatedCharLayer]()
let isInitialSet = currentChars.isEmpty
for index in 0..<min(newChars.count, currentChars.count) {
let newCharIndex = newChars.count - 1 - index
let currCharIndex = currentChars.count - 1 - index
if newChars[newCharIndex] != currentChars[currCharIndex] {
let initialDuration = newChars[newCharIndex] != currentChars[currCharIndex] ? initialDuration : 0
if !isInitialSet && newChars[newCharIndex] != currentChars[currCharIndex] {
animateOut(for: chars[currCharIndex].layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
} else {
chars[currCharIndex].layer.removeFromSuperlayer()
}
let newLayer = AnimatedCharLayer()
newLayer.attributedText = newChars[newCharIndex]
let offset = offsetForChar(at: newCharIndex, within: newChars)
newLayer.frame = .init(
x: offset,
y: 0,
width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth,
height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0)
)
containerView.layer.addSublayer(newLayer)
if !isInitialSet && newChars[newCharIndex] != currentChars[currCharIndex] {
newLayer.layer.opacity = 0
animateIn(for: newLayer.layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
changeIndex += 1
}
newLayers.append(newLayer)
} else {
newLayers.append(chars[currCharIndex])
let offset = offsetForChar(at: newCharIndex, within: newChars)
chars[currCharIndex].frame = .init(
x: offset,
y: 0,
width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth,
height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0)
)
}
}
for index in min(newChars.count, currentChars.count)..<currentChars.count {
let currCharIndex = currentChars.count - 1 - index
animateOut(for: chars[currCharIndex].layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
changeIndex += 1
}
for index in min(newChars.count, currentChars.count)..<newChars.count {
let newCharIndex = newChars.count - 1 - index
let newLayer = AnimatedCharLayer()
newLayer.attributedText = newChars[newCharIndex]
let offset = offsetForChar(at: newCharIndex, within: newChars)
newLayer.frame = .init(x: offset, y: 0, width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth, height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0))
containerView.layer.addSublayer(newLayer)
if !isInitialSet {
animateIn(for: newLayer.layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
}
newLayers.append(newLayer)
changeIndex += 1
}
let prevCount = chars.count
chars = newLayers.reversed()
let countWidth = offsetForChar(at: newChars.count, within: newChars) - interItemSpacing
if didBegin && prevCount != chars.count {
UIView.animate(withDuration: Double(changeIndex) * initialDuration) { [self] in
containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height)
if countWidth * scaleFactor > self.bounds.width {
let scale = (self.bounds.width - 32) / (countWidth * scaleFactor)
containerView.transform = .init(scaleX: scale, y: scale)
} else {
containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor)
}
}
} else if countWidth > 0 {
containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height)
didBegin = true
}
self.clipsToBounds = false
}
func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) {
let beginTimeOffset: CFTimeInterval = 0
DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) {
let beginTime: CFTimeInterval = 0
let opacityInAnimation = CABasicAnimation(keyPath: "opacity")
opacityInAnimation.fromValue = 1
opacityInAnimation.toValue = 0
opacityInAnimation.fillMode = .forwards
opacityInAnimation.isRemovedOnCompletion = false
let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleOutAnimation.fromValue = 1
scaleOutAnimation.toValue = 0.0
let translate = CABasicAnimation(keyPath: "transform.translation")
translate.fromValue = CGPoint.zero
translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)
let group = CAAnimationGroup()
group.animations = [opacityInAnimation, scaleOutAnimation, translate]
group.duration = duration
group.beginTime = beginTimeOffset + beginTime
group.fillMode = .forwards
group.isRemovedOnCompletion = false
group.completion = { _ in
layer.removeFromSuperlayer()
}
layer.add(group, forKey: "out")
}
}
func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) {
let beginTimeOffset: CFTimeInterval = 0 // CACurrentMediaTime()
DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in
let beginTime: CFTimeInterval = 0
newLayer.opacity = 0
let opacityInAnimation = CABasicAnimation(keyPath: "opacity")
opacityInAnimation.fromValue = 0
opacityInAnimation.toValue = 1
opacityInAnimation.duration = duration
opacityInAnimation.beginTime = beginTimeOffset + beginTime
opacityInAnimation.fillMode = .backwards
newLayer.opacity = 1
newLayer.add(opacityInAnimation, forKey: "opacity")
let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleOutAnimation.fromValue = 0
scaleOutAnimation.toValue = 1
scaleOutAnimation.duration = duration
scaleOutAnimation.beginTime = beginTimeOffset + beginTime
newLayer.add(scaleOutAnimation, forKey: "scalein")
let animation = CAKeyframeAnimation()
animation.keyPath = "position.y"
animation.values = [20 * fontSize / 60, -6 * fontSize / 60, 0]
animation.keyTimes = [0, 0.64, 1]
animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut)
animation.duration = duration / 0.64
animation.beginTime = beginTimeOffset + beginTime
animation.isAdditive = true
newLayer.add(animation, forKey: "pos")
}
}
}
@@ -0,0 +1,279 @@
import Foundation
import UIKit
import AVFoundation
import SwiftSignalKit
import UniversalMediaPlayer
import Postbox
import TelegramCore
import AccountContext
import TelegramAudio
import Display
import TelegramVoip
import RangeSet
import ManagedFile
import FFMpegBinding
import TelegramUniversalVideoContent
final class LivestreamVideoViewV1: UIView {
private final class PartContext {
let part: DirectMediaStreamingContext.Playlist.Part
let disposable = MetaDisposable()
var resolvedTimeOffset: Double?
var data: TempBoxFile?
var info: FFMpegMediaInfo?
init(part: DirectMediaStreamingContext.Playlist.Part) {
self.part = part
}
deinit {
self.disposable.dispose()
}
}
private let context: AccountContext
private let audioSessionManager: ManagedAudioSession
private let call: PresentationGroupCall
private let chunkPlayerPartsState = Promise<ChunkMediaPlayerPartsState>(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts([])))
private var parts: [ChunkMediaPlayerPart] = [] {
didSet {
self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts(self.parts))))
}
}
private let player: ChunkMediaPlayer
private let playerNode: MediaPlayerNode
private var playerStatus: MediaPlayerStatus?
private var playerStatusDisposable: Disposable?
private var streamingContextDisposable: Disposable?
private var streamingContext: DirectMediaStreamingContext?
private var playlistDisposable: Disposable?
private var partContexts: [Int: PartContext] = [:]
private var requestedSeekTimestamp: Double?
init(
context: AccountContext,
audioSessionManager: ManagedAudioSession,
call: PresentationGroupCall
) {
self.context = context
self.audioSessionManager = audioSessionManager
self.call = call
self.playerNode = MediaPlayerNode()
var onSeeked: (() -> Void)?
self.player = ChunkMediaPlayerV2(
params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context),
audioSessionManager: audioSessionManager,
source: .externalParts(self.chunkPlayerPartsState.get()),
video: true,
enableSound: true,
baseRate: 1.0,
onSeeked: {
onSeeked?()
},
playerNode: self.playerNode
)
super.init(frame: CGRect())
self.addSubview(self.playerNode.view)
onSeeked = {
}
self.playerStatusDisposable = (self.player.status
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
guard let self else {
return
}
self.updatePlayerStatus(status: status)
})
var didProcessFramesToDisplay = false
self.playerNode.isHidden = true
self.playerNode.hasSentFramesToDisplay = { [weak self] in
guard let self, !didProcessFramesToDisplay else {
return
}
didProcessFramesToDisplay = true
self.playerNode.isHidden = false
}
if let call = call as? PresentationGroupCallImpl {
self.streamingContextDisposable = (call.externalMediaStream.get()
|> deliverOnMainQueue).startStrict(next: { [weak self] externalMediaStream in
guard let self else {
return
}
self.streamingContext = externalMediaStream
self.resetPlayback()
})
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.playerStatusDisposable?.dispose()
self.streamingContextDisposable?.dispose()
self.playlistDisposable?.dispose()
}
private func updatePlayerStatus(status: MediaPlayerStatus) {
self.playerStatus = status
self.updatePlaybackPositionIfNeeded()
}
private func resetPlayback() {
self.parts = []
self.playlistDisposable?.dispose()
self.playlistDisposable = nil
guard let streamingContext = self.streamingContext else {
return
}
self.playlistDisposable = (streamingContext.playlistData()
|> deliverOnMainQueue).startStrict(next: { [weak self] playlist in
guard let self else {
return
}
self.updatePlaylist(playlist: playlist)
})
}
private func updatePlaylist(playlist: DirectMediaStreamingContext.Playlist) {
var validPartIds: [Int] = []
for part in playlist.parts.prefix(upTo: 4) {
validPartIds.append(part.index)
if self.partContexts[part.index] == nil {
let partContext = PartContext(part: part)
self.partContexts[part.index] = partContext
if let streamingContext = self.streamingContext {
partContext.disposable.set((streamingContext.partData(index: part.index)
|> deliverOn(Queue.concurrentDefaultQueue())
|> map { data -> (file: TempBoxFile, info: FFMpegMediaInfo)? in
guard let data else {
return nil
}
let tempFile = TempBox.shared.tempFile(fileName: "part.mp4")
if let _ = try? data.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) {
if let info = extractFFMpegMediaInfo(path: tempFile.path) {
return (tempFile, info)
} else {
return nil
}
} else {
TempBox.shared.dispose(tempFile)
return nil
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self, weak partContext] fileAndInfo in
guard let self, let partContext else {
return
}
if let (file, info) = fileAndInfo {
partContext.data = file
partContext.info = info
} else {
partContext.data = nil
}
self.updatePartContexts()
}))
}
}
}
var removedPartIds: [Int] = []
for (id, _) in self.partContexts {
if !validPartIds.contains(id) {
removedPartIds.append(id)
}
}
for id in removedPartIds {
self.partContexts.removeValue(forKey: id)
}
}
private func updatePartContexts() {
var readyParts: [ChunkMediaPlayerPart] = []
let sortedContexts = self.partContexts.values.sorted(by: { $0.part.timestamp < $1.part.timestamp })
outer: for i in 0 ..< sortedContexts.count {
let partContext = sortedContexts[i]
if let data = partContext.data {
let offsetTime: Double
if i != 0 {
var foundOffset: Double?
inner: for j in 0 ..< i {
let previousContext = sortedContexts[j]
if previousContext.part.index == partContext.part.index - 1 {
if let previousInfo = previousContext.info {
if let previousResolvedOffset = previousContext.resolvedTimeOffset {
if let audio = previousInfo.audio {
foundOffset = previousResolvedOffset + audio.duration.seconds
} else {
foundOffset = partContext.part.timestamp
}
}
}
break inner
}
}
if let foundOffset {
partContext.resolvedTimeOffset = foundOffset
offsetTime = foundOffset
} else {
continue outer
}
} else {
if let resolvedOffset = partContext.resolvedTimeOffset {
offsetTime = resolvedOffset
} else {
offsetTime = partContext.part.timestamp
partContext.resolvedTimeOffset = offsetTime
}
}
readyParts.append(ChunkMediaPlayerPart(
startTime: partContext.part.timestamp,
endTime: partContext.part.timestamp + partContext.part.duration,
content: ChunkMediaPlayerPart.TempFile(file: data),
codecName: nil,
offsetTime: offsetTime
))
}
}
readyParts.sort(by: { $0.startTime < $1.startTime })
self.parts = readyParts
self.updatePlaybackPositionIfNeeded()
}
private func updatePlaybackPositionIfNeeded() {
if let part = self.parts.first {
if let playerStatus = self.playerStatus, playerStatus.timestamp < part.startTime {
if self.requestedSeekTimestamp != part.startTime {
self.requestedSeekTimestamp = part.startTime
self.player.seek(timestamp: part.startTime, play: true)
}
}
}
}
public func update(size: CGSize, transition: ContainedViewLayoutTransition) {
//transition.updateFrame(view: self.playerNode.view, frame: CGRect(origin: CGPoint(), size: size))
self.playerNode.frame = CGRect(origin: CGPoint(), size: size)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
import Foundation
import UIKit
import ComponentFlow
import AccountContext
import AVKit
import MultilineTextComponent
import Display
import ShimmerEffect
import TelegramCore
import SwiftSignalKit
import AvatarNode
import Postbox
import TelegramVoip
import ComponentDisplayAdapters
public final class MediaStreamVideoComponent: Component {
let call: PresentationGroupCallImpl
let videoEndpointId: String?
let isVisible: Bool
let isAdmin: Bool
let peerTitle: String
let enablePictureInPicture: Bool
let activatePictureInPicture: ActionSlot<Action<Void>>
let deactivatePictureInPicture: ActionSlot<Void>
let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void
let pictureInPictureClosed: () -> Void
let addInset: Bool
let isFullscreen: Bool
let onVideoSizeRetrieved: (CGSize) -> Void
let videoLoading: Bool
let callPeer: Peer?
let onVideoPlaybackLiveChange: (Bool) -> Void
public init(
call: PresentationGroupCallImpl,
videoEndpointId: String?,
isVisible: Bool,
isAdmin: Bool,
peerTitle: String,
addInset: Bool,
isFullscreen: Bool,
videoLoading: Bool,
callPeer: Peer?,
enablePictureInPicture: Bool,
activatePictureInPicture: ActionSlot<Action<Void>>,
deactivatePictureInPicture: ActionSlot<Void>,
bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void,
pictureInPictureClosed: @escaping () -> Void,
onVideoSizeRetrieved: @escaping (CGSize) -> Void,
onVideoPlaybackLiveChange: @escaping (Bool) -> Void
) {
self.call = call
self.videoEndpointId = videoEndpointId
self.isVisible = isVisible
self.isAdmin = isAdmin
self.peerTitle = peerTitle
self.videoLoading = videoLoading
self.enablePictureInPicture = enablePictureInPicture
self.activatePictureInPicture = activatePictureInPicture
self.deactivatePictureInPicture = deactivatePictureInPicture
self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation
self.pictureInPictureClosed = pictureInPictureClosed
self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange
self.callPeer = callPeer
self.addInset = addInset
self.isFullscreen = isFullscreen
self.onVideoSizeRetrieved = onVideoSizeRetrieved
}
public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool {
if lhs.call !== rhs.call {
return false
}
if lhs.videoEndpointId != rhs.videoEndpointId {
return false
}
if lhs.isVisible != rhs.isVisible {
return false
}
if lhs.isAdmin != rhs.isAdmin {
return false
}
if lhs.peerTitle != rhs.peerTitle {
return false
}
if lhs.addInset != rhs.addInset {
return false
}
if lhs.isFullscreen != rhs.isFullscreen {
return false
}
if lhs.videoLoading != rhs.videoLoading {
return false
}
if lhs.enablePictureInPicture != rhs.enablePictureInPicture {
return false
}
return true
}
public final class State: ComponentState {
override init() {
super.init()
}
}
public func makeState() -> State {
return State()
}
public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView {
public final class Tag {
}
private let videoRenderingContext = VideoRenderingContext()
private let blurTintView: UIView
private var videoBlurView: VideoRenderingView?
private var videoView: VideoRenderingView?
private var videoPlaceholderView: UIView?
private var noSignalView: ComponentHostView<Empty>?
private let loadingBlurView = CustomIntensityVisualEffectView(effect: UIBlurEffect(style: .light), intensity: 0.4)
private let shimmerOverlayView = CALayer()
private var pictureInPictureController: AVPictureInPictureController?
private var component: MediaStreamVideoComponent?
private var hadVideo: Bool = false
private var requestedExpansion: Bool = false
private var noSignalTimer: Foundation.Timer?
private var noSignalTimeout: Bool = false
private let videoBlurGradientMask = CAGradientLayer()
private let videoBlurSolidMask = CALayer()
private var wasVisible = true
private var borderShimmer = StandaloneShimmerEffect()
private let shimmerBorderLayer = CALayer()
private let placeholderView = UIImageView()
private var videoStalled = false {
didSet {
if videoStalled != oldValue {
self.updateVideoStalled(isStalled: self.videoStalled, transition: nil)
// state?.updated()
}
}
}
var onVideoPlaybackChange: ((Bool) -> Void) = { _ in }
private var frameInputDisposable: Disposable?
private var stallTimer: Foundation.Timer?
private let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
private var avatarDisposable: Disposable?
private var didBeginLoadingAvatar = false
private var timeLastFrameReceived: CFAbsoluteTime?
private var isFullscreen: Bool = false
private let videoLoadingThrottler = Throttler<Bool>(duration: 1, queue: .main)
private var wasFullscreen: Bool = false
private var isAnimating = false
private var didRequestBringBack = false
private weak var state: State?
private var lastPresentation: UIView?
private var pipTrackDisplayLink: CADisplayLink?
private var livestreamVideoView: LivestreamVideoViewV1?
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) {
fatalError("init(coder:) has not been implemented")
}
deinit {
avatarDisposable?.dispose()
frameInputDisposable?.dispose()
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
public func matches(tag: Any) -> Bool {
if let _ = tag as? Tag {
return true
}
return false
}
func expandFromPictureInPicture() {
if let pictureInPictureController = self.pictureInPictureController, pictureInPictureController.isPictureInPictureActive {
self.requestedExpansion = true
self.pictureInPictureController?.stopPictureInPicture()
}
}
private func updateVideoStalled(isStalled: Bool, transition: ComponentTransition?) {
if isStalled {
guard let component = self.component else { return }
if let peerId = component.call.peerId, let frameView = lastFrame[peerId.id.description] {
frameView.removeFromSuperview()
placeholderView.subviews.forEach { $0.removeFromSuperview() }
placeholderView.addSubview(frameView)
frameView.frame = placeholderView.bounds
}
if !hadVideo && placeholderView.superview == nil {
addSubview(placeholderView)
}
let needsFadeInAnimation = hadVideo
if loadingBlurView.superview == nil {
//addSubview(loadingBlurView)
if needsFadeInAnimation {
let anim = CABasicAnimation(keyPath: "opacity")
anim.duration = 0.5
anim.fromValue = 0
anim.toValue = 1
loadingBlurView.layer.opacity = 1
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
loadingBlurView.layer.add(anim, forKey: "opacity")
}
}
loadingBlurView.layer.zPosition = 998
self.noSignalView?.layer.zPosition = loadingBlurView.layer.zPosition + 1
if shimmerBorderLayer.superlayer == nil {
loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer)
}
loadingBlurView.clipsToBounds = true
let cornerRadius = loadingBlurView.layer.cornerRadius
shimmerBorderLayer.cornerRadius = cornerRadius
shimmerBorderLayer.masksToBounds = true
shimmerBorderLayer.compositingFilter = "softLightBlendMode"
let borderMask = CAShapeLayer()
shimmerBorderLayer.mask = borderMask
if let transition, shimmerBorderLayer.mask != nil {
let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
borderMask.path = initialPath
transition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds)
let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
transition.setShapeLayerPath(layer: borderMask, path: borderMaskPath)
} else {
shimmerBorderLayer.frame = loadingBlurView.bounds
let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
borderMask.path = borderMaskPath
}
borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor
borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor
borderMask.lineWidth = 3
borderMask.compositingFilter = "softLightBlendMode"
borderShimmer = StandaloneShimmerEffect()
borderShimmer.layer = shimmerBorderLayer
borderShimmer.updateHorizontal(background: .clear, foreground: .white)
loadingBlurView.alpha = 1
} else {
if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 {
let anim = CABasicAnimation(keyPath: "opacity")
anim.duration = 0.4
anim.fromValue = 1.0
anim.toValue = 0.0
self.loadingBlurView.layer.opacity = 0
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
isAnimating = true
anim.completion = { [weak self] _ in
guard self?.videoStalled == false else { return }
self?.loadingBlurView.removeFromSuperview()
self?.placeholderView.removeFromSuperview()
self?.isAnimating = false
}
loadingBlurView.layer.add(anim, forKey: "opacity")
}
}
}
func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: ComponentTransition) -> CGSize {
self.state = state
self.component = component
self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange
self.isFullscreen = component.isFullscreen
if let peer = component.callPeer, !didBeginLoadingAvatar {
didBeginLoadingAvatar = true
avatarDisposable = peerAvatarCompleteImage(account: component.call.account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true).start(next: { [weak self] image in
DispatchQueue.main.async {
self?.placeholderView.contentMode = .scaleAspectFill
self?.placeholderView.image = image
}
})
}
if component.videoEndpointId == nil || component.videoLoading || self.videoStalled {
updateVideoStalled(isStalled: true, transition: transition)
} else {
updateVideoStalled(isStalled: false, transition: transition)
}
if let videoEndpointId = component.videoEndpointId, self.videoView == nil {
if let input = component.call.video(endpointId: videoEndpointId) {
var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
guard let strongSelf = self else { return timer.invalidate() }
let currentTime = CFAbsoluteTimeGetCurrent()
if let lastFrameTime = strongSelf.timeLastFrameReceived,
currentTime - lastFrameTime > 0.5 {
strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in
strongSelf.videoStalled = isStalled
strongSelf.onVideoPlaybackChange(!isStalled)
}
}
} }
// TODO: use mapToThrottled (?)
frameInputDisposable = input.start(next: { [weak self] input in
guard let strongSelf = self else { return }
strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent()
strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in
strongSelf.videoStalled = isStalled
strongSelf.onVideoPlaybackChange(!isStalled)
}
})
stallTimer = _stallTimer
self.clipsToBounds = true
if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) {
self.videoView = videoView
self.addSubview(videoView)
videoView.alpha = 0
UIView.animate(withDuration: 0.3) {
videoView.alpha = 1
}
if component.enablePictureInPicture, let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView {
sampleBufferVideoView.sampleBufferLayer.masksToBounds = true
if #available(iOS 13.0, *) {
sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true
}
final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
var onTransitionFinished: (() -> Void)?
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) {
onTransitionFinished?()
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
completionHandler()
}
public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
}
var pictureInPictureController: AVPictureInPictureController? = nil
if #available(iOS 15.0, *) {
pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: {
let delegate = PlaybackDelegateImpl()
delegate.onTransitionFinished = {
}
return delegate
}()))
pictureInPictureController?.playerLayer.masksToBounds = false
pictureInPictureController?.playerLayer.cornerRadius = 10
} else if AVPictureInPictureController.isPictureInPictureSupported() {
pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))
}
pictureInPictureController?.delegate = self
if #available(iOS 14.2, *) {
pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true
}
if #available(iOS 14.0, *) {
pictureInPictureController?.requiresLinearPlayback = true
}
self.pictureInPictureController = pictureInPictureController
}
videoView.setOnOrientationUpdated { [weak state] _, _ in
state?.updated(transition: .immediate)
}
videoView.setOnFirstFrameReceived { [weak self, weak state] _ in
guard let strongSelf = self else {
return
}
strongSelf.hadVideo = true
strongSelf.noSignalTimer?.invalidate()
strongSelf.noSignalTimer = nil
strongSelf.noSignalTimeout = false
strongSelf.noSignalView?.removeFromSuperview()
strongSelf.noSignalView = nil
state?.updated(transition: .immediate)
}
}
if component.addInset, let videoView = self.videoView, let videoBlurView = self.videoRenderingContext.makeBlurView(input: input, mainView: videoView) {
self.videoBlurView = videoBlurView
self.insertSubview(videoBlurView, belowSubview: self.blurTintView)
videoBlurView.alpha = 0
UIView.animate(withDuration: 0.3) {
videoBlurView.alpha = 1
}
self.videoBlurGradientMask.type = .radial
self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5)
self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0)
self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor
self.videoBlurGradientMask.addSublayer(videoBlurSolidMask)
}
}
} else if component.isFullscreen {
if fullScreenBackgroundPlaceholder.superview == nil {
insertSubview(fullScreenBackgroundPlaceholder, at: 0)
transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 1)
}
fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5)
} else {
transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 0, completion: { didComplete in
if didComplete {
self.fullScreenBackgroundPlaceholder.removeFromSuperview()
}
})
}
fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize)
let videoInset: CGFloat
if component.addInset && !component.isFullscreen {
videoInset = 16
} else {
videoInset = 0
}
let videoSize: CGSize
let videoCornerRadius: CGFloat = (component.isFullscreen || !component.addInset) ? 0 : 10
let videoFrameUpdateTransition: ComponentTransition
if self.wasFullscreen != component.isFullscreen {
videoFrameUpdateTransition = transition
} else {
videoFrameUpdateTransition = transition.withAnimation(.none)
}
if let videoView = self.videoView {
if let peerId = component.call.peerId, videoView.bounds.size.width > 0,
videoView.alpha > 0,
self.hadVideo,
let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) {
lastFrame[peerId.id.description] = snapshot
}
var aspect = videoView.getAspect()
if component.isFullscreen && self.hadVideo {
if aspect <= 0.01 {
aspect = 16.0 / 9
}
} else if !self.hadVideo {
aspect = 16.0 / 9
}
if component.isFullscreen || !component.addInset {
videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height))
} else {
// Limiting by smallest side -- redundant if passing precalculated availableSize
let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2
let availableVideoHeight = availableVideoWidth * 9.0 / 16
videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight))
}
let blurredVideoSize = component.isFullscreen ? availableSize : videoSize.aspectFilled(availableSize)
component.onVideoSizeRetrieved(videoSize)
var isVideoVisible = component.isVisible
if !self.wasVisible && component.isVisible {
videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2)
} else if self.wasVisible && !component.isVisible {
videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2)
}
if let pictureInPictureController = self.pictureInPictureController {
if pictureInPictureController.isPictureInPictureActive {
isVideoVisible = true
}
}
videoView.updateIsEnabled(isVideoVisible)
videoView.clipsToBounds = true
videoView.layer.cornerRadius = videoCornerRadius
self.wasFullscreen = component.isFullscreen
let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil)
if let videoBlurView = self.videoBlurView {
videoBlurView.updateIsEnabled(component.isVisible)
if component.isFullscreen {
videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: CGRect(
origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)),
size: blurredVideoSize
), completion: nil)
} else {
videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -70.0 * aspect, dy: -70.0))
}
videoBlurView.layer.mask = videoBlurGradientMask
if !component.isFullscreen {
transition.setAlpha(layer: videoBlurSolidMask, alpha: 0)
} else {
transition.setAlpha(layer: videoBlurSolidMask, alpha: 1)
}
videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds)
videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds)
}
if component.call.accountContext.sharedContext.immediateExperimentalUISettings.liveStreamV2 && self.livestreamVideoView == nil {
let livestreamVideoView = LivestreamVideoViewV1(context: component.call.accountContext, audioSessionManager: component.call.accountContext.sharedContext.mediaManager.audioSession, call: component.call)
self.livestreamVideoView = livestreamVideoView
livestreamVideoView.layer.masksToBounds = true
self.addSubview(livestreamVideoView)
livestreamVideoView.frame = newVideoFrame
livestreamVideoView.layer.cornerRadius = videoCornerRadius
livestreamVideoView.update(size: newVideoFrame.size, transition: .immediate)
/*var pictureInPictureController: AVPictureInPictureController? = nil
if #available(iOS 15.0, *) {
pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(playerLayer: livePlayerView.playerLayer))
pictureInPictureController?.playerLayer.masksToBounds = false
pictureInPictureController?.playerLayer.cornerRadius = 10
} else if AVPictureInPictureController.isPictureInPictureSupported() {
pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))
}
pictureInPictureController?.delegate = self
if #available(iOS 14.2, *) {
pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true
}
if #available(iOS 14.0, *) {
pictureInPictureController?.requiresLinearPlayback = true
}
self.pictureInPictureController = pictureInPictureController*/
}
if let livestreamVideoView = self.livestreamVideoView {
videoFrameUpdateTransition.setFrame(view: livestreamVideoView, frame: newVideoFrame, completion: nil)
videoFrameUpdateTransition.setCornerRadius(layer: livestreamVideoView.layer, cornerRadius: videoCornerRadius)
livestreamVideoView.update(size: newVideoFrame.size, transition: transition.containedViewLayoutTransition)
videoView.isHidden = true
}
} else {
let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2
let availableVideoHeight = availableVideoWidth * 9.0 / 16
videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight))
}
let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
if self.loadingBlurView.frame == .zero {
self.loadingBlurView.frame = loadingBlurViewFrame
} else {
// Using ComponentTransition.setFrame on UIVisualEffectView causes instant update of sublayers
switch videoFrameUpdateTransition.animation {
case let .curve(duration, curve):
UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [weak self] in
guard let self else {
return
}
self.loadingBlurView.frame = loadingBlurViewFrame
})
default:
self.loadingBlurView.frame = loadingBlurViewFrame
}
}
videoFrameUpdateTransition.setCornerRadius(layer: self.loadingBlurView.layer, cornerRadius: videoCornerRadius)
videoFrameUpdateTransition.setFrame(view: self.placeholderView, frame: loadingBlurViewFrame)
videoFrameUpdateTransition.setCornerRadius(layer: self.placeholderView.layer, cornerRadius: videoCornerRadius)
self.placeholderView.clipsToBounds = true
self.placeholderView.subviews.forEach {
videoFrameUpdateTransition.setFrame(view: $0, frame: self.placeholderView.bounds)
}
let initialShimmerBounds = self.shimmerBorderLayer.bounds
videoFrameUpdateTransition.setFrame(layer: self.shimmerBorderLayer, frame: loadingBlurView.bounds)
let borderMask = CAShapeLayer()
let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)
borderMask.path = initialPath
videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil))
borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor
borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor
borderMask.lineWidth = 3
self.shimmerBorderLayer.mask = borderMask
self.shimmerBorderLayer.cornerRadius = videoCornerRadius
if !self.hadVideo && !component.call.accountContext.sharedContext.immediateExperimentalUISettings.liveStreamV2 {
if self.noSignalTimer == nil {
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, !"".isEmpty {
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.zPosition = loadingBlurView.layer.zPosition + 1
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: .plain(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: (availableSize.height - noSignalSize.height) / 2.0), size: noSignalSize), completion: nil)
}
}
self.component = component
component.activatePictureInPicture.connect { [weak self] completion in
guard let strongSelf = self, let pictureInPictureController = strongSelf.pictureInPictureController else {
return
}
pictureInPictureController.startPictureInPicture()
completion(Void())
}
component.deactivatePictureInPicture.connect { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.expandFromPictureInPicture()
}
return availableSize
}
public func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) {
let presentationParent = self.window ?? self
presentationParent.addSubview(presentation)
presentation.frame = presentationParent.convert(videoView.frame, from: self)
if let callId = self.component?.call.peerId?.id.description {
lastFrame[callId] = presentation
}
videoView.alpha = 0
lastPresentation?.removeFromSuperview()
lastPresentation = presentation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.lastPresentation?.removeFromSuperview()
self.lastPresentation = nil
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
}
UIView.animate(withDuration: 0.1) { [self] in
videoBlurView?.alpha = 0
}
// TODO: assure player window
UIApplication.shared.windows.first?.layer.cornerRadius = 10.0
UIApplication.shared.windows.first?.layer.masksToBounds = true
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = CADisplayLink(target: self, selector: #selector(observePiPWindow))
self.pipTrackDisplayLink?.add(to: .main, forMode: .default)
}
@objc func observePiPWindow() {
let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0
if pipViewDidBecomeVisible {
lastPresentation?.removeFromSuperview()
lastPresentation = nil
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
}
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
guard let component = self.component else {
completionHandler(false)
return
}
didRequestBringBack = true
component.bringBackControllerForPictureInPictureDeactivation {
completionHandler(true)
}
}
public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.didRequestBringBack = false
self.state?.updated(transition: .immediate)
}
public func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if self.requestedExpansion {
self.requestedExpansion = false
} else if !didRequestBringBack {
self.component?.pictureInPictureClosed()
}
didRequestBringBack = false
// TODO: extract precise animation timing or observe window changes
// Handle minimized case separatelly (can we detect minimized?)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.videoView?.alpha = 1
}
UIView.animate(withDuration: 0.3) { [self] in
self.videoBlurView?.alpha = 1
}
}
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.videoView?.alpha = 1
self.state?.updated(transition: .immediate)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: State, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, transition: transition)
}
}
// TODO: move to appropriate place
fileprivate var lastFrame: [String: UIView] = [:]
private final class CustomIntensityVisualEffectView: UIVisualEffectView {
private var animator: UIViewPropertyAnimator!
init(effect: UIVisualEffect, intensity: CGFloat) {
super.init(effect: nil)
animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [weak self] in self?.effect = effect }
animator.startAnimation()
animator.pauseAnimation()
animator.fractionComplete = intensity
animator.pausesOnCompletion = true
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
deinit {
animator.stopAnimation(true)
}
}
@@ -0,0 +1,513 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import AvatarNode
import GlassBackgroundComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import AccountContext
import TextFormat
import TelegramPresentationData
import ReactionSelectionNode
import BundleIconComponent
import LottieComponent
import Markdown
private let glassColor = UIColor(rgb: 0x25272e, alpha: 0.72)
final class MessageItemComponent: Component {
public enum Icon: Equatable {
case peer(EnginePeer)
case icon(String)
case animation(String)
}
public enum Style {
case generic
case notification
case status
}
private let context: AccountContext
private let icon: Icon
private let style: Style
private let text: String
private let entities: [MessageTextEntity]
private let availableReactions: [ReactionItem]?
private let openPeer: ((EnginePeer) -> Void)?
init(
context: AccountContext,
icon: Icon,
style: Style,
text: String,
entities: [MessageTextEntity],
availableReactions: [ReactionItem]?,
openPeer: ((EnginePeer) -> Void)?
) {
self.context = context
self.icon = icon
self.style = style
self.text = text
self.entities = entities
self.availableReactions = availableReactions
self.openPeer = openPeer
}
static func == (lhs: MessageItemComponent, rhs: MessageItemComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.style != rhs.style {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if (lhs.availableReactions ?? []).isEmpty != (rhs.availableReactions ?? []).isEmpty {
return false
}
return true
}
final class View: UIView {
private let container: UIView
private let background: GlassBackgroundView
private let avatarNode: AvatarNode
private let icon: ComponentView<Empty>
private let text: ComponentView<Empty>
weak var standaloneReactionAnimation: StandaloneReactionAnimation?
private var cachedEntities: [MessageTextEntity]?
private var entityFiles: [MediaId: TelegramMediaFile] = [:]
private var component: MessageItemComponent?
override init(frame: CGRect) {
self.container = UIView()
self.container.transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
self.background = GlassBackgroundView()
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0))
self.avatarNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -8.0)
self.icon = ComponentView()
self.text = ComponentView()
super.init(frame: frame)
self.addSubview(self.container)
self.container.addSubview(self.background)
self.container.addSubview(self.avatarNode.view)
self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.avatarTapped)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func avatarTapped() {
guard let component = self.component, case let .peer(peer) = component.icon else {
return
}
component.openPeer?(peer)
}
func animateFrom(globalFrame: CGRect, cornerRadius: CGFloat, textSnapshotView: UIView, transition: ComponentTransition) {
guard let superview = self.superview?.superview?.superview else {
return
}
let originalCenter = self.container.center
let originalTransform = self.container.transform
let superviewCenter = self.convert(self.container.center, to: superview)
self.container.center = superviewCenter
self.container.transform = .identity
superview.addSubview(self.container)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let direction: CGFloat = hasRTL ? -1.0 : 1.0
let initialSize = self.background.frame.size
self.container.addSubview(textSnapshotView)
transition.setAlpha(view: textSnapshotView, alpha: 0.0, completion: { _ in
textSnapshotView.removeFromSuperview()
})
let additionalOffset = hasRTL ? globalFrame.size.width - initialSize.width : 0.0
transition.setPosition(view: textSnapshotView, position: CGPoint(x: textSnapshotView.center.x + 71.0 * direction - additionalOffset, y: textSnapshotView.center.y))
self.background.update(size: globalFrame.size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: .immediate)
self.background.update(size: initialSize, cornerRadius: 18.0, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: transition)
let deltaX = (globalFrame.width - self.container.frame.width) / 2.0
let deltaY = (globalFrame.height - self.container.frame.height) / 2.0
let fromFrame = superview.convert(globalFrame, from: nil).offsetBy(dx: -deltaX, dy: -deltaY)
self.container.center = fromFrame.center
transition.setPosition(view: self.container, position: superviewCenter, completion: { _ in
self.container.center = originalCenter
self.container.transform = originalTransform
self.insertSubview(self.container, at: 0)
})
if let textView = self.text.view {
transition.animatePosition(view: textView, from: CGPoint(x: -71.0 * direction, y: 0.0), to: .zero, additive: true)
transition.animateAlpha(view: textView, from: 0.0, to: 1.0)
}
transition.animateAlpha(view: self.avatarNode.view, from: 0.0, to: 1.0)
transition.animateScale(view: self.avatarNode.view, from: 0.01, to: 1.0)
}
func update(component: MessageItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
var transition = transition
if isFirstTime {
transition = .immediate
}
self.component = component
let theme = defaultDarkPresentationTheme
var fontSize: CGFloat = 14.0
let minimalHeight: CGFloat
let avatarSpacing: CGFloat
let avatarInset: CGFloat
let avatarSize: CGSize
let rightInset: CGFloat
var maximumNumberOfLines: Int = 0
var hasBackground = true
var textVerticalMargin: CGFloat = 15.0
switch component.style {
case .generic:
minimalHeight = 36.0
avatarSpacing = 10.0
avatarInset = 4.0
avatarSize = CGSize(width: 28.0, height: 28.0)
rightInset = 13.0
case .notification:
minimalHeight = 50.0
avatarSpacing = 10.0
avatarInset = 10.0
avatarSize = CGSize(width: 30.0, height: 30.0)
rightInset = 15.0
case .status:
minimalHeight = 24.0
avatarSpacing = 4.0
avatarInset = 4.0
avatarSize = CGSize(width: 16.0, height: 16.0)
rightInset = 0.0
maximumNumberOfLines = 1
hasBackground = false
fontSize = 13.0
textVerticalMargin = 0.0
}
let textFont = Font.regular(fontSize)
let boldTextFont = Font.semibold(fontSize)
let italicFont = Font.italic(fontSize)
let boldItalicTextFont = Font.semiboldItalic(fontSize)
let monospaceFont = Font.monospace(fontSize)
let textColor: UIColor = .white
let linkColor: UIColor = component.style == .status ? textColor : UIColor(rgb: 0x59b6fa)
let cornerRadius = minimalHeight * 0.5
let iconSpacing: CGFloat = 10.0
var peerName = ""
if case let .peer(peer) = component.icon {
if case .notification = component.style {
} else {
peerName = peer.compactDisplayTitle
if peerName.count > 40 {
peerName = "\(peerName.prefix(40))"
}
}
}
let text = component.text
var entities = component.entities
if let cachedEntities = self.cachedEntities {
entities = cachedEntities
} else if let availableReactions = component.availableReactions, text.count == 1 {
let emoji = component.text.strippedEmoji
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(emoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if case .builtin = reactionItem?.reaction.rawValue, let item = component.context.animatedEmojiStickersValue[emoji]?.first {
self.entityFiles[item.file.fileId] = item.file._parse()
entities.insert(MessageTextEntity(range: 0 ..< (text as NSString).length, type: .CustomEmoji(stickerPack: nil, fileId: item.file.fileId.id)), at: 0)
self.cachedEntities = entities
}
} else {
entities = entities.filter { entity in
switch entity.type {
case .Bold, .Italic, .Strikethrough, .Underline, .Spoiler:
return true
case .CustomEmoji:
if case let .peer(peer) = component.icon, peer.isPremium {
return true
}
return false
default:
return false
}
}
self.cachedEntities = entities
}
let attributedText: NSAttributedString
if case .notification = component.style {
attributedText = parseMarkdownIntoAttributedString(
text,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: linkColor),
linkAttribute: { _ in return nil }
)
)
} else {
let textWithAppliedEntities = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: linkColor, baseFont: textFont, linkFont: textFont, boldFont: boldTextFont, italicFont: italicFont, boldItalicFont: boldItalicTextFont, fixedFont: monospaceFont, blockQuoteFont: textFont, message: nil, entityFiles: self.entityFiles).mutableCopy() as! NSMutableAttributedString
if !peerName.isEmpty {
textWithAppliedEntities.insert(NSAttributedString(string: "\u{2066}\(peerName)\u{2069} ", font: boldTextFont, textColor: textColor), at: 0)
}
attributedText = textWithAppliedEntities
}
let spacing: CGFloat
switch component.icon {
case .peer:
spacing = avatarSpacing
case .icon:
spacing = iconSpacing
case .animation:
spacing = iconSpacing
}
let textSize = self.text.update(
transition: transition,
component: AnyComponent(MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff, alpha: 0.3),
text: .plain(attributedText),
maximumNumberOfLines: maximumNumberOfLines,
lineSpacing: 0.1,
spoilerColor: .white,
handleSpoilers: true
)),
environment: {},
containerSize: CGSize(width: availableSize.width - avatarInset - avatarSize.width - spacing - rightInset, height: .greatestFiniteMagnitude)
)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let size = CGSize(
width: avatarInset + avatarSize.width + spacing + textSize.width + rightInset,
height: max(minimalHeight, textSize.height + textVerticalMargin)
)
switch component.icon {
case let .peer(peer):
if peer.smallProfileImage != nil {
self.avatarNode.setPeerV2(
context: component.context,
theme: theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
} else {
self.avatarNode.setPeer(
context: component.context,
theme: theme,
peer: peer,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
}
let avatarFrame = CGRect(origin: CGPoint(x: avatarInset, y: avatarInset), size: avatarSize)
if self.avatarNode.bounds.isEmpty {
self.avatarNode.frame = mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL)
} else {
transition.setFrame(view: self.avatarNode.view, frame: mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = false
case let .icon(iconName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(name: iconName, tintColor: .white)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.container.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
case let .animation(animationName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: animationName
),
placeholderColor: nil,
startingPosition: .end,
size: CGSize(width: 40.0, height: 40.0),
loop: false
)),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset - 3.0,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.container.addSubview(iconView)
iconView.playOnce()
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
}
func mappedFrame(_ frame: CGRect, containerSize: CGSize, hasRTL: Bool) -> CGRect {
return CGRect(
origin: CGPoint(x: hasRTL ? containerSize.width - frame.minX - frame.width : frame.minX, y: frame.origin.y),
size: frame.size
)
}
let textFrame = CGRect(
origin: CGPoint(
x: avatarInset + avatarSize.width + spacing,
y: floorToScreenPixels((size.height - textSize.height) / 2.0)
),
size: textSize
)
if let textView = self.text.view {
if textView.superview == nil {
self.container.addSubview(textView)
}
transition.setFrame(view: textView, frame: mappedFrame(textFrame, containerSize: size, hasRTL: hasRTL))
}
transition.setFrame(view: self.container, frame: CGRect(origin: CGPoint(), size: size))
self.background.isHidden = !hasBackground
self.background.update(size: size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: transition)
transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size))
if isFirstTime, let availableReactions = component.availableReactions, let textView = self.text.view {
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(component.text.strippedEmoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if let reactionItem {
Queue.mainQueue().justDispatch {
guard let listView = self.superview else {
return
}
let emojiTargetView = UIView(frame: CGRect(origin: CGPoint(x: textView.frame.width - 32.0, y: -17.0), size: CGSize(width: 44.0, height: 44.0)))
emojiTargetView.isUserInteractionEnabled = false
textView.addSubview(emojiTargetView)
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: nil, useDirectRendering: false)
self.container.addSubview(standaloneReactionAnimation.view)
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = listView.bounds
standaloneReactionAnimation.animateReactionSelection(
context: component.context,
theme: theme,
animationCache: component.context.animationCache,
reaction: reactionItem,
avatarPeers: [],
playHaptic: false,
isLarge: false,
hideCenterAnimation: true,
targetView: emojiTargetView,
addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = self.bounds
listView.addSubview(standaloneReactionAnimation.view)
},
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
}
}
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,277 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import AccountContext
import ReactionSelectionNode
final class MessageListComponent: Component {
struct Item: Equatable {
let id: AnyHashable
let icon: MessageItemComponent.Icon
let isNotification: Bool
let text: String
let entities: [MessageTextEntity]
}
class SendActionTransition {
public let randomId: Int64
public let textSnapshotView: UIView
public let globalFrame: CGRect
public let cornerRadius: CGFloat
init(randomId: Int64, textSnapshotView: UIView, globalFrame: CGRect, cornerRadius: CGFloat) {
self.randomId = randomId
self.textSnapshotView = textSnapshotView
self.globalFrame = globalFrame
self.cornerRadius = cornerRadius
}
}
private let context: AccountContext
private let items: [Item]
private let availableReactions: [ReactionItem]?
private let sendActionTransition: SendActionTransition?
private let openPeer: (EnginePeer) -> Void
init(
context: AccountContext,
items: [Item],
availableReactions: [ReactionItem]?,
sendActionTransition: SendActionTransition?,
openPeer: @escaping (EnginePeer) -> Void
) {
self.context = context
self.items = items
self.availableReactions = availableReactions
self.sendActionTransition = sendActionTransition
self.openPeer = openPeer
}
static func == (lhs: MessageListComponent, rhs: MessageListComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.items != rhs.items {
return false
}
if (lhs.availableReactions ?? []).isEmpty != (rhs.availableReactions ?? []).isEmpty {
return false
}
if lhs.sendActionTransition !== rhs.sendActionTransition {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollView
private var component: MessageListComponent?
private weak var state: EmptyComponentState?
private var isUpdating = false
private var nextSendActionTransition: MessageListComponent.SendActionTransition?
private var itemViews: [AnyHashable: ComponentView<Empty>] = [:]
private let topInset: CGFloat = 8.0
private let bottomInset: CGFloat = 8.0
private let itemSpacing: CGFloat = 6.0
private var ignoreScrolling: Bool = false
override init(frame: CGRect) {
self.scrollView = ScrollView()
super.init(frame: frame)
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = true
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = false
self.scrollView.transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
}
private func isAtBottom(tolerance: CGFloat = 1.0) -> Bool {
let bottomY = -self.scrollView.adjustedContentInset.top
return self.scrollView.contentOffset.y <= bottomY + tolerance
}
private func scrollToBottom(animated: Bool) {
let targetY = -self.scrollView.adjustedContentInset.top
if animated {
self.scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true)
} else {
self.scrollView.contentOffset = CGPoint(x: 0, y: targetY)
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for (_, itemView) in self.itemViews {
if let view = itemView.view, view.point(inside: self.convert(point, to: view), with: event) {
return true
}
}
return false
}
func update(component: MessageListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
if let _ = component.sendActionTransition {
self.nextSendActionTransition = component.sendActionTransition
}
let originalTransition = transition
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize))
let previousContentHeight = self.scrollView.contentSize.height
let wasAtBottom = self.isAtBottom(tolerance: 1.0)
let maxWidth: CGFloat = min(availableSize.width - 16.0, 330.0)
var measured: [(id: AnyHashable, size: CGSize, item: MessageListComponent.Item, itemTransition: ComponentTransition)] = []
measured.reserveCapacity(component.items.count)
for item in component.items {
var itemTransition = transition
let key = item.id
let container = self.itemViews[key] ?? {
itemTransition = .immediate
let v = ComponentView<Empty>()
self.itemViews[key] = v
return v
}()
let size = container.update(
transition: transition,
component: AnyComponent(MessageItemComponent(
context: component.context,
icon: item.icon,
style: item.isNotification ? .notification : .generic,
text: item.text,
entities: item.entities,
availableReactions: component.availableReactions,
openPeer: component.openPeer
)),
environment: {},
containerSize: CGSize(width: maxWidth, height: .greatestFiniteMagnitude)
)
measured.append((id: key, size: size, item: item, itemTransition: itemTransition))
}
let itemsHeight: CGFloat = measured.reduce(0) { $0 + $1.size.height } +
CGFloat(max(0, measured.count - 1)) * self.itemSpacing
let contentHeight = self.topInset + itemsHeight + self.bottomInset
var y = self.bottomInset
var validKeys = Set<AnyHashable>()
for (index, entry) in measured.enumerated() {
validKeys.insert(entry.id)
if let itemView = self.itemViews[entry.id]?.view {
var customAnimation = false
if let nextSendActionTransition = self.nextSendActionTransition, entry.id == AnyHashable(nextSendActionTransition.randomId) {
customAnimation = true
}
let itemFrame = CGRect(
origin: CGPoint(x: floor((availableSize.width - entry.size.width) / 2.0), y: y),
size: entry.size
)
if itemView.superview == nil {
if !originalTransition.animation.isImmediate && !customAnimation {
originalTransition.animateAlpha(view: itemView, from: 0.0, to: 1.0)
originalTransition.animateScale(view: itemView, from: 0.01, to: 1.0)
}
if customAnimation, let nextSendActionTransition = self.nextSendActionTransition {
self.nextSendActionTransition = nil
itemView.frame = itemFrame
if let itemView = itemView as? MessageItemComponent.View {
itemView.isHidden = true
Queue.mainQueue().justDispatch {
itemView.animateFrom(globalFrame: nextSendActionTransition.globalFrame, cornerRadius: nextSendActionTransition.cornerRadius, textSnapshotView: nextSendActionTransition.textSnapshotView, transition: originalTransition)
itemView.isHidden = false
}
}
}
self.scrollView.addSubview(itemView)
}
entry.itemTransition.setFrame(view: itemView, frame: itemFrame)
}
y += entry.size.height
if index != measured.count - 1 { y += self.itemSpacing }
}
let finalContentHeight = max(availableSize.height, contentHeight)
self.scrollView.contentSize = CGSize(width: availableSize.width, height: finalContentHeight)
let delta = self.scrollView.contentSize.height - previousContentHeight
if !wasAtBottom && abs(delta) > .ulpOfOne {
self.scrollView.contentOffset.y += delta
} else if wasAtBottom {
self.scrollToBottom(animated: false)
}
if self.itemViews.count > validKeys.count {
let toRemove = self.itemViews.keys.filter { !validKeys.contains($0) }
for key in toRemove {
if let itemView = self.itemViews[key]?.view {
if transition.animation.isImmediate {
itemView.removeFromSuperview()
} else {
transition.setAlpha(view: itemView, alpha: 0.0, completion: { _ in
itemView.removeFromSuperview()
})
transition.setScale(view: itemView, scale: 0.01)
}
}
self.itemViews.removeValue(forKey: key)
}
}
if wasAtBottom {
self.scrollToBottom(animated: false)
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,82 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import TelegramPresentationData
import TelegramStringFormatting
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xe4436c)
final class ParticipantsComponent: Component {
private let strings: PresentationStrings
private let count: Int
private let showsSubtitle: Bool
private let fontSize: CGFloat
private let gradientColors: [CGColor]
init(strings: PresentationStrings, count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) {
self.strings = strings
self.count = count
self.showsSubtitle = showsSubtitle
self.fontSize = fontSize
self.gradientColors = gradientColors
}
static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.showsSubtitle != rhs.showsSubtitle {
return false
}
if lhs.fontSize != rhs.fontSize {
return false
}
return true
}
func makeView() -> View {
View(frame: .zero)
}
func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment<ComponentFlow.Empty>, transition: ComponentFlow.ComponentTransition) -> CGSize {
view.counter.update(
countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "",
subtitle: self.showsSubtitle ? (self.count > 0 ? self.strings.LiveStream_Watching.lowercased() : self.strings.LiveStream_NoViewers.lowercased()) : "",
fontSize: self.fontSize,
gradientColors: self.gradientColors
)
switch transition.animation {
case let .curve(duration, curve):
UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: {
view.bounds.size = availableSize
view.counter.frame.size = availableSize
view.counter.updateFrames(transition: transition)
})
default:
view.bounds.size = availableSize
view.counter.frame.size = availableSize
view.counter.updateFrames()
}
return availableSize
}
final class View: UIView {
let counter = AnimatedCountView()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(counter)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}
@@ -0,0 +1,269 @@
import Foundation
import UIKit
import ComponentFlow
import ActivityIndicatorComponent
import AccountContext
import AVKit
import MultilineTextComponent
import Display
import TelegramPresentationData
final class StreamSheetComponent: CombinedComponent {
let strings: PresentationStrings
let sheetHeight: CGFloat
let topOffset: CGFloat
let backgroundColor: UIColor
let participantsCount: Int
let bottomPadding: CGFloat
let isFullyExtended: Bool
let deviceCornerRadius: CGFloat
let videoHeight: CGFloat
let isFullscreen: Bool
let fullscreenTopComponent: AnyComponent<Empty>
let fullscreenBottomComponent: AnyComponent<Empty>
init(
strings: PresentationStrings,
topOffset: CGFloat,
sheetHeight: CGFloat,
backgroundColor: UIColor,
bottomPadding: CGFloat,
participantsCount: Int,
isFullyExtended: Bool,
deviceCornerRadius: CGFloat,
videoHeight: CGFloat,
isFullscreen: Bool,
fullscreenTopComponent: AnyComponent<Empty>,
fullscreenBottomComponent: AnyComponent<Empty>
) {
self.strings = strings
self.topOffset = topOffset
self.sheetHeight = sheetHeight
self.backgroundColor = backgroundColor
self.bottomPadding = bottomPadding
self.participantsCount = participantsCount
self.isFullyExtended = isFullyExtended
self.deviceCornerRadius = deviceCornerRadius
self.videoHeight = videoHeight
self.isFullscreen = isFullscreen
self.fullscreenTopComponent = fullscreenTopComponent
self.fullscreenBottomComponent = fullscreenBottomComponent
}
static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.topOffset != rhs.topOffset {
return false
}
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.sheetHeight != rhs.sheetHeight {
return false
}
if !lhs.backgroundColor.isEqual(rhs.backgroundColor) {
return false
}
if lhs.bottomPadding != rhs.bottomPadding {
return false
}
if lhs.participantsCount != rhs.participantsCount {
return false
}
if lhs.isFullyExtended != rhs.isFullyExtended {
return false
}
if lhs.videoHeight != rhs.videoHeight {
return false
}
if lhs.isFullscreen != rhs.isFullscreen {
return false
}
if lhs.fullscreenTopComponent != rhs.fullscreenTopComponent {
return false
}
if lhs.fullscreenBottomComponent != rhs.fullscreenBottomComponent {
return false
}
return true
}
final class View: UIView {
var overlayComponentsFrames = [CGRect]()
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subframe in overlayComponentsFrames {
if subframe.contains(point) { return true }
}
return false
}
func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: ComponentTransition) -> CGSize {
return availableSize
}
override func draw(_ rect: CGRect) {
super.draw(rect)
// Debug interactive area
// guard let context = UIGraphicsGetCurrentContext() else { return }
// context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor)
// overlayComponentsFrames.forEach { frame in
// context.addRect(frame)
// context.fillPath()
// }
}
}
func makeView() -> View {
View()
}
public final class State: ComponentState {
override init() {
super.init()
}
}
public func makeState() -> State {
return State()
}
private weak var state: State?
static var body: Body {
let background = Child(SheetBackgroundComponent.self)
let viewerCounter = Child(ParticipantsComponent.self)
return { context in
let size = context.availableSize
let topOffset = context.component.topOffset
let backgroundExtraOffset: CGFloat
if #available(iOS 16.0, *) {
// In iOS 16 context.view does not inherit safeAreaInsets, quick fix:
let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0
backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0
} else {
backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0
}
let background = background.update(
component: SheetBackgroundComponent(
color: context.component.backgroundColor,
radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 10.0,
offset: backgroundExtraOffset
),
availableSize: CGSize(width: size.width, height: context.component.sheetHeight),
transition: context.transition
)
let viewerCounter = viewerCounter.update(
component: ParticipantsComponent(strings: context.component.strings, count: context.component.participantsCount, fontSize: 44.0),
availableSize: CGSize(width: context.availableSize.width, height: 70),
transition: context.transition
)
let isFullscreen = context.component.isFullscreen
context.add(background
.position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2))
)
(context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = []
context.view.backgroundColor = .clear
let videoHeight = context.component.videoHeight
let sheetHeight = context.component.sheetHeight
let animatedParticipantsVisible = !isFullscreen
context.add(viewerCounter
.position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0))
.opacity(animatedParticipantsVisible ? 1 : 0)
)
return size
}
}
}
final class SheetBackgroundComponent: Component {
private let color: UIColor
private let radius: CGFloat
private let offset: CGFloat
class View: UIView {
private let backgroundView = UIView()
func update(availableSize: CGSize, color: UIColor, cornerRadius: CGFloat, offset: CGFloat, transition: ComponentTransition) {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
let extraBottomForReleaseAnimation: CGFloat = 500
if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil {
if transition.animation.isImmediate {
UIView.animate(withDuration: 0.4) { [self] in
backgroundView.backgroundColor = color
backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))
}
let anim = CABasicAnimation(keyPath: "cornerRadius")
anim.fromValue = backgroundView.layer.cornerRadius
backgroundView.layer.cornerRadius = cornerRadius
anim.toValue = cornerRadius
anim.duration = 0.4
backgroundView.layer.add(anim, forKey: "cornerRadius")
} else {
transition.setBackgroundColor(view: backgroundView, color: color)
transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)))
transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius)
}
} else {
backgroundView.backgroundColor = color
backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))
backgroundView.layer.cornerRadius = cornerRadius
}
backgroundView.isUserInteractionEnabled = false
backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
backgroundView.clipsToBounds = true
backgroundView.layer.masksToBounds = true
}
}
func makeView() -> View {
View()
}
static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.radius != rhs.radius {
return false
}
if lhs.offset != rhs.offset {
return false
}
return true
}
public init(color: UIColor, radius: CGFloat, offset: CGFloat) {
self.color = color
self.radius = radius
self.offset = offset
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition)
return availableSize
}
}
@@ -0,0 +1,45 @@
import Foundation
import TelegramCore
public func groupCallLogsPath(account: Account) -> String {
return account.basePath + "/group-calls"
}
func cleanupGroupCallLogs(account: Account) {
let path = groupCallLogsPath(account: account)
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: path, isDirectory: nil) {
try? fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
}
var oldest: [(URL, Date)] = []
var count = 0
if let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: path), includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], errorHandler: nil) {
for url in enumerator {
if let url = url as? URL {
if let date = (try? url.resourceValues(forKeys: Set([.contentModificationDateKey])))?.contentModificationDate {
oldest.append((url, date))
count += 1
}
}
}
}
let callLogsLimit = 20
if count > callLogsLimit {
oldest.sort(by: { $0.1 > $1.1 })
while oldest.count > callLogsLimit {
try? fileManager.removeItem(atPath: oldest[oldest.count - 1].0.path)
oldest.removeLast()
}
}
}
public func allocateCallLogPath(account: Account) -> String {
let path = groupCallLogsPath(account: account)
let _ = try? FileManager.default.createDirectory(at: URL(fileURLWithPath: path), withIntermediateDirectories: true, attributes: nil)
let name = "log-\(Date())".replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
return "\(path)/\(name).log"
}
@@ -0,0 +1,179 @@
import Foundation
import TelegramCore
import TelegramVoip
import SwiftSignalKit
protocol ScreencastContext: AnyObject {
func addExternalAudioData(data: Data)
func stop(account: Account, reportCallId: CallId?)
func setRTCJoinResponse(clientParams: String)
}
protocol ScreencastIPCContext: AnyObject {
var isActive: Signal<Bool, NoError> { get }
func requestScreencast() -> Signal<(String, UInt32), NoError>?
func setJoinResponse(clientParams: String)
func disableScreencast(account: Account)
}
final class ScreencastInProcessIPCContext: ScreencastIPCContext {
private let isConference: Bool
private let e2eContext: ConferenceCallE2EContext?
private let screencastBufferServerContext: IpcGroupCallBufferAppContext
private var screencastCallContext: ScreencastContext?
private let screencastCapturer: OngoingCallVideoCapturer
private var screencastFramesDisposable: Disposable?
private var screencastAudioDataDisposable: Disposable?
var isActive: Signal<Bool, NoError> {
return self.screencastBufferServerContext.isActive
}
init(basePath: String, isConference: Bool, e2eContext: ConferenceCallE2EContext?) {
self.isConference = isConference
self.e2eContext = e2eContext
let screencastBufferServerContext = IpcGroupCallBufferAppContext(basePath: basePath + "/broadcast-coordination")
self.screencastBufferServerContext = screencastBufferServerContext
let screencastCapturer = OngoingCallVideoCapturer(isCustom: true)
self.screencastCapturer = screencastCapturer
self.screencastFramesDisposable = (screencastBufferServerContext.frames
|> deliverOnMainQueue).start(next: { [weak screencastCapturer] screencastFrame in
guard let screencastCapturer = screencastCapturer else {
return
}
guard let sampleBuffer = sampleBufferFromPixelBuffer(pixelBuffer: screencastFrame.0) else {
return
}
screencastCapturer.injectSampleBuffer(sampleBuffer, rotation: screencastFrame.1, completion: {})
})
self.screencastAudioDataDisposable = (screencastBufferServerContext.audioData
|> deliverOnMainQueue).start(next: { [weak self] data in
Queue.mainQueue().async {
guard let self else {
return
}
self.screencastCallContext?.addExternalAudioData(data: data)
}
})
}
deinit {
self.screencastFramesDisposable?.dispose()
self.screencastAudioDataDisposable?.dispose()
}
func requestScreencast() -> Signal<(String, UInt32), NoError>? {
if self.screencastCallContext == nil {
var encryptionContext: OngoingGroupCallEncryptionContext?
if let e2eContext = self.e2eContext {
encryptionContext = OngoingGroupCallEncryptionContextImpl(e2eCall: e2eContext.state, channelId: 1)
} else if self.isConference {
// Prevent non-encrypted conference calls
encryptionContext = OngoingGroupCallEncryptionContextImpl(e2eCall: Atomic(value: ConferenceCallE2EContext.ContextStateHolder()), channelId: 1)
}
let screencastCallContext = InProcessScreencastContext(
context: OngoingGroupCallContext(
audioSessionActive: .single(true),
video: self.screencastCapturer,
requestMediaChannelDescriptions: { _, _ in EmptyDisposable },
rejoinNeeded: { },
outgoingAudioBitrateKbit: nil,
videoContentType: .screencast,
enableNoiseSuppression: false,
disableAudioInput: true,
enableSystemMute: false,
prioritizeVP8: false,
logPath: "",
onMutedSpeechActivityDetected: { _ in },
isConference: self.isConference,
audioIsActiveByDefault: true,
isStream: false,
sharedAudioDevice: nil,
encryptionContext: encryptionContext
)
)
self.screencastCallContext = screencastCallContext
return screencastCallContext.joinPayload
} else {
return nil
}
}
func setJoinResponse(clientParams: String) {
if let screencastCallContext = self.screencastCallContext {
screencastCallContext.setRTCJoinResponse(clientParams: clientParams)
}
}
func disableScreencast(account: Account) {
if let screencastCallContext = self.screencastCallContext {
self.screencastCallContext = nil
screencastCallContext.stop(account: account, reportCallId: nil)
self.screencastBufferServerContext.stopScreencast()
}
}
}
final class ScreencastEmbeddedIPCContext: ScreencastIPCContext {
private let serverContext: IpcGroupCallEmbeddedAppContext
var isActive: Signal<Bool, NoError> {
return self.serverContext.isActive
}
init(basePath: String) {
self.serverContext = IpcGroupCallEmbeddedAppContext(basePath: basePath + "/embedded-broadcast-coordination")
}
func requestScreencast() -> Signal<(String, UInt32), NoError>? {
if let id = self.serverContext.startScreencast() {
return self.serverContext.joinPayload
|> filter { joinPayload -> Bool in
return joinPayload.id == id
}
|> map { joinPayload -> (String, UInt32) in
return (joinPayload.data, joinPayload.ssrc)
}
} else {
return nil
}
}
func setJoinResponse(clientParams: String) {
self.serverContext.joinResponse = IpcGroupCallEmbeddedAppContext.JoinResponse(data: clientParams)
}
func disableScreencast(account: Account) {
self.serverContext.stopScreencast()
}
}
private final class InProcessScreencastContext: ScreencastContext {
private let context: OngoingGroupCallContext
var joinPayload: Signal<(String, UInt32), NoError> {
return self.context.joinPayload
}
init(context: OngoingGroupCallContext) {
self.context = context
}
func addExternalAudioData(data: Data) {
self.context.addExternalAudioData(data: data)
}
func stop(account: Account, reportCallId: CallId?) {
self.context.stop(account: account, reportCallId: reportCallId, debugLog: Promise())
}
func setRTCJoinResponse(clientParams: String) {
self.context.setConnectionMode(.rtc, keepBroadcastConnectedIfWasEnabled: false, isUnifiedBroadcast: false)
self.context.setJoinResponse(payload: clientParams)
}
}
@@ -0,0 +1,377 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AccountContext
import ContextUI
enum VideoNodeLayoutMode {
case fillOrFitToSquare
case fillHorizontal
case fillVertical
case fit
}
final class GroupVideoNode: ASDisplayNode, PreviewVideoNode {
enum Position {
case tile
case list
case mainstage
}
let sourceContainerNode: PinchSourceContainerNode
private let containerNode: ASDisplayNode
private let videoViewContainer: UIView
private let videoView: VideoRenderingView
private let debugTextNode: ImmediateTextNode
private let backdropVideoViewContainer: UIView
private let backdropVideoView: VideoRenderingView?
private var effectView: UIVisualEffectView?
private var isBlurred: Bool = false
private var isEnabled: Bool = false
private var isBlurEnabled: Bool = false
private var validLayout: (CGSize, VideoNodeLayoutMode)?
var tapped: (() -> Void)?
private let readyPromise = ValuePromise(false)
var ready: Signal<Bool, NoError> {
return self.readyPromise.get()
}
public var isMainstageExclusive = false
init(videoView: VideoRenderingView, backdropVideoView: VideoRenderingView?) {
self.sourceContainerNode = PinchSourceContainerNode()
self.containerNode = ASDisplayNode()
self.videoViewContainer = UIView()
self.videoViewContainer.isUserInteractionEnabled = false
self.videoView = videoView
self.backdropVideoViewContainer = UIView()
self.backdropVideoViewContainer.isUserInteractionEnabled = false
self.backdropVideoView = backdropVideoView
self.debugTextNode = ImmediateTextNode()
super.init()
if let backdropVideoView = backdropVideoView {
self.backdropVideoViewContainer.addSubview(backdropVideoView)
self.view.addSubview(self.backdropVideoViewContainer)
}
self.videoViewContainer.addSubview(self.videoView)
self.addSubnode(self.sourceContainerNode)
self.containerNode.view.addSubview(self.videoViewContainer)
self.containerNode.addSubnode(self.debugTextNode)
self.sourceContainerNode.contentNode.addSubnode(self.containerNode)
self.clipsToBounds = true
videoView.setOnFirstFrameReceived({ [weak self] _ in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
strongSelf.readyPromise.set(true)
if let (size, layoutMode) = strongSelf.validLayout {
strongSelf.updateLayout(size: size, layoutMode: layoutMode, transition: .immediate)
}
}
})
videoView.setOnOrientationUpdated({ [weak self] _, _ in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
if let (size, layoutMode) = strongSelf.validLayout {
strongSelf.updateLayout(size: size, layoutMode: layoutMode, transition: .immediate)
}
}
})
self.containerNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
func updateIsEnabled(_ isEnabled: Bool) {
self.isEnabled = isEnabled
self.videoView.updateIsEnabled(isEnabled)
self.backdropVideoView?.updateIsEnabled(isEnabled && self.isBlurEnabled)
}
func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) {
if self.hasScheduledUnblur {
self.hasScheduledUnblur = false
}
if self.isBlurred == isBlurred {
return
}
self.isBlurred = isBlurred
if isBlurred {
if self.effectView == nil {
let effectView = UIVisualEffectView()
self.effectView = effectView
effectView.frame = self.bounds
self.view.addSubview(effectView)
}
if animated {
UIView.animate(withDuration: 0.3, animations: {
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
})
} else {
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
}
} else if let effectView = self.effectView {
self.effectView = nil
UIView.animate(withDuration: 0.3, animations: {
effectView.effect = nil
}, completion: { [weak effectView] _ in
effectView?.removeFromSuperview()
})
}
}
private var hasScheduledUnblur = false
func flip(withBackground: Bool) {
if withBackground {
self.backgroundColor = .black
}
var snapshotView: UIView?
if let snapshot = self.videoView.snapshotView(afterScreenUpdates: false) {
snapshotView = snapshot
snapshot.transform = self.videoView.transform
snapshot.frame = self.videoView.frame
self.videoView.superview?.insertSubview(snapshot, aboveSubview: self.videoView)
}
UIView.transition(with: withBackground ? self.videoViewContainer : self.view, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: {
UIView.performWithoutAnimation {
self.updateIsBlurred(isBlurred: true, light: false, animated: false)
}
}) { finished in
self.backgroundColor = nil
self.hasScheduledUnblur = true
if let snapshotView = snapshotView {
Queue.mainQueue().after(0.3) {
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
if self.hasScheduledUnblur {
self.updateIsBlurred(isBlurred: false)
}
}
} else {
Queue.mainQueue().after(0.4) {
if self.hasScheduledUnblur {
self.updateIsBlurred(isBlurred: false)
}
}
}
}
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.tapped?()
}
}
var aspectRatio: CGFloat {
let orientation = self.videoView.getOrientation()
var aspect = self.videoView.getAspect()
if aspect <= 0.01 {
aspect = 3.0 / 4.0
}
let rotatedAspect: CGFloat
switch orientation {
case .rotation0:
rotatedAspect = 1.0 / aspect
case .rotation90:
rotatedAspect = aspect
case .rotation180:
rotatedAspect = 1.0 / aspect
case .rotation270:
rotatedAspect = aspect
}
return rotatedAspect
}
func updateDebugInfo(text: String) {
self.debugTextNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: .white)
if let (size, layoutMode) = self.validLayout {
self.updateLayout(size: size, layoutMode: layoutMode, transition: .immediate)
}
}
func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, layoutMode)
let debugTextSize = self.debugTextNode.updateLayout(CGSize(width: 200.0, height: 200.0))
if size.height > size.width + 100.0 {
self.debugTextNode.frame = CGRect(origin: CGPoint(x: 5.0, y: 44.0), size: debugTextSize)
} else {
self.debugTextNode.frame = CGRect(origin: CGPoint(x: 5.0, y: 5.0), size: debugTextSize)
}
let bounds = CGRect(origin: CGPoint(), size: size)
self.sourceContainerNode.update(size: size, transition: .immediate)
transition.updateFrameAsPositionAndBounds(node: self.sourceContainerNode, frame: bounds)
transition.updateFrameAsPositionAndBounds(node: self.containerNode, frame: bounds)
transition.updateFrameAsPositionAndBounds(layer: self.videoViewContainer.layer, frame: bounds)
transition.updateFrameAsPositionAndBounds(layer: self.backdropVideoViewContainer.layer, frame: bounds)
let orientation = self.videoView.getOrientation()
var aspect = self.videoView.getAspect()
if aspect <= 0.01 {
aspect = 3.0 / 4.0
}
let rotatedAspect: CGFloat
let angle: CGFloat
let switchOrientation: Bool
switch orientation {
case .rotation0:
angle = 0.0
rotatedAspect = 1 / aspect
switchOrientation = false
case .rotation90:
angle = CGFloat.pi / 2.0
rotatedAspect = aspect
switchOrientation = true
case .rotation180:
angle = CGFloat.pi
rotatedAspect = 1 / aspect
switchOrientation = false
case .rotation270:
angle = CGFloat.pi * 3.0 / 2.0
rotatedAspect = aspect
switchOrientation = true
}
var rotatedVideoSize = CGSize(width: 100.0, height: rotatedAspect * 100.0)
let videoSize = rotatedVideoSize
var containerSize = size
if switchOrientation {
rotatedVideoSize = CGSize(width: rotatedVideoSize.height, height: rotatedVideoSize.width)
containerSize = CGSize(width: containerSize.height, height: containerSize.width)
}
let fittedSize = rotatedVideoSize.aspectFitted(containerSize)
let filledSize = rotatedVideoSize.aspectFilled(containerSize)
var squareSide = size.height
if !size.height.isZero && size.width / size.height < 1.2 {
squareSide = max(size.width, size.height)
}
let filledToSquareSize = rotatedVideoSize.aspectFilled(CGSize(width: squareSide, height: squareSide))
switch layoutMode {
case .fit:
rotatedVideoSize = fittedSize
case .fillOrFitToSquare:
rotatedVideoSize = filledToSquareSize
case .fillHorizontal:
if videoSize.width > videoSize.height {
rotatedVideoSize = filledSize
} else {
rotatedVideoSize = fittedSize
}
case .fillVertical:
if videoSize.width < videoSize.height {
rotatedVideoSize = filledSize
} else {
rotatedVideoSize = fittedSize
}
}
var rotatedVideoFrame = CGRect(origin: CGPoint(x: floor((size.width - rotatedVideoSize.width) / 2.0), y: floor((size.height - rotatedVideoSize.height) / 2.0)), size: rotatedVideoSize)
rotatedVideoFrame.origin.x = floor(rotatedVideoFrame.origin.x)
rotatedVideoFrame.origin.y = floor(rotatedVideoFrame.origin.y)
rotatedVideoFrame.size.width = ceil(rotatedVideoFrame.size.width)
rotatedVideoFrame.size.height = ceil(rotatedVideoFrame.size.height)
self.videoView.alpha = 0.995
let normalizedVideoSize = rotatedVideoFrame.size.aspectFilled(CGSize(width: 1080.0, height: 1080.0))
transition.updatePosition(layer: self.videoView.layer, position: rotatedVideoFrame.center)
transition.updateBounds(layer: self.videoView.layer, bounds: CGRect(origin: CGPoint(), size: normalizedVideoSize))
self.videoView.updateLayout(size: normalizedVideoSize, transition: transition)
let transformScale: CGFloat = rotatedVideoFrame.width / normalizedVideoSize.width
transition.updateTransformScale(layer: self.videoViewContainer.layer, scale: transformScale)
if let backdropVideoView = self.backdropVideoView {
backdropVideoView.alpha = 0.995
let topFrame = rotatedVideoFrame
rotatedVideoSize = filledSize
var rotatedVideoFrame = CGRect(origin: CGPoint(x: floor((size.width - rotatedVideoSize.width) / 2.0), y: floor((size.height - rotatedVideoSize.height) / 2.0)), size: rotatedVideoSize)
rotatedVideoFrame.origin.x = floor(rotatedVideoFrame.origin.x)
rotatedVideoFrame.origin.y = floor(rotatedVideoFrame.origin.y)
rotatedVideoFrame.size.width = ceil(rotatedVideoFrame.size.width)
rotatedVideoFrame.size.height = ceil(rotatedVideoFrame.size.height)
self.isBlurEnabled = !topFrame.contains(rotatedVideoFrame)
let normalizedVideoSize = rotatedVideoFrame.size.aspectFilled(CGSize(width: 1080.0, height: 1080.0))
let effectiveBlurEnabled = self.isEnabled && self.isBlurEnabled
if effectiveBlurEnabled {
self.backdropVideoView?.updateIsEnabled(true)
}
transition.updatePosition(layer: backdropVideoView.layer, position: rotatedVideoFrame.center, force: true, completion: { [weak self] value in
guard let strongSelf = self, value else {
return
}
if !(strongSelf.isEnabled && strongSelf.isBlurEnabled) {
strongSelf.backdropVideoView?.updateIsEnabled(false)
}
})
transition.updateBounds(layer: backdropVideoView.layer, bounds: CGRect(origin: CGPoint(), size: normalizedVideoSize))
backdropVideoView.updateLayout(size: normalizedVideoSize, transition: transition)
let transformScale: CGFloat = rotatedVideoFrame.width / normalizedVideoSize.width
transition.updateTransformScale(layer: self.backdropVideoViewContainer.layer, scale: transformScale)
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformRotation(view: backdropVideoView, angle: angle)
}
if let effectView = self.effectView {
if case let .animated(duration, .spring) = transition {
UIView.animate(withDuration: duration, delay: 0.0, usingSpringWithDamping: 500.0, initialSpringVelocity: 0.0, options: .layoutSubviews, animations: {
effectView.frame = bounds
})
} else {
transition.animateView {
effectView.frame = bounds
}
}
}
let transition: ContainedViewLayoutTransition = .immediate
transition.updateTransformRotation(view: self.videoView, angle: angle)
}
var snapshotView: UIView?
func storeSnapshot() {
if self.frame.size.width == 180.0 {
self.snapshotView = self.view.snapshotView(afterScreenUpdates: false)
}
}
}
@@ -0,0 +1,249 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import AppBundle
enum LegacyCallControllerButtonType {
case mute
case end
case accept
case speaker
case bluetooth
case switchCamera
}
private let buttonSize = CGSize(width: 75.0, height: 75.0)
private func generateEmptyButtonImage(icon: UIImage?, strokeColor: UIColor?, fillColor: UIColor, knockout: Bool = false, angle: CGFloat = 0.0) -> UIImage? {
return generateImage(buttonSize, contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.copy)
if let strokeColor = strokeColor {
context.setFillColor(strokeColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: 1.5, y: 1.5), size: CGSize(width: size.width - 3.0, height: size.height - 3.0)))
} else {
context.setFillColor(fillColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)))
}
if let icon = icon {
if !angle.isZero {
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.rotate(by: angle)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
}
let imageSize = icon.size
let imageRect = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floor((size.width - imageSize.height) / 2.0)), size: imageSize)
if knockout {
context.setBlendMode(.copy)
context.clip(to: imageRect, mask: icon.cgImage!)
context.setFillColor(UIColor.clear.cgColor)
context.fill(imageRect)
} else {
context.setBlendMode(.normal)
context.draw(generateTintedImage(image: icon, color: .white)!.cgImage!, in: imageRect)
}
}
})
}
private func generateFilledButtonImage(color: UIColor, icon: UIImage?, angle: CGFloat = 0.0) -> UIImage? {
return generateImage(buttonSize, contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.normal)
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
if let icon = icon {
if !angle.isZero {
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.rotate(by: angle)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
}
context.draw(icon.cgImage!, in: CGRect(origin: CGPoint(x: floor((size.width - icon.size.width) / 2.0), y: floor((size.height - icon.size.height) / 2.0)), size: icon.size))
}
})
}
private let emptyStroke = UIColor(white: 1.0, alpha: 0.8)
private let emptyHighlightedFill = UIColor(white: 1.0, alpha: 0.3)
private let invertedFill = UIColor(white: 1.0, alpha: 1.0)
private let labelFont = Font.regular(14.5)
final class LegacyCallControllerButtonNode: HighlightTrackingButtonNode {
private var type: LegacyCallControllerButtonType
private var regularImage: UIImage?
private var highlightedImage: UIImage?
private var filledImage: UIImage?
private let backgroundNode: ASImageNode
private let labelNode: ASTextNode?
init(type: LegacyCallControllerButtonType, label: String?) {
self.type = type
self.backgroundNode = ASImageNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.displayWithoutProcessing = false
self.backgroundNode.displaysAsynchronously = false
if let label = label {
let labelNode = ASTextNode()
labelNode.attributedText = NSAttributedString(string: label, font: labelFont, textColor: .white)
self.labelNode = labelNode
} else {
self.labelNode = nil
}
var regularImage: UIImage?
var highlightedImage: UIImage?
var filledImage: UIImage?
switch type {
case .mute:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .accept:
regularImage = generateFilledButtonImage(color: UIColor(rgb: 0x74db58), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"), angle: CGFloat.pi * 3.0 / 4.0)
highlightedImage = generateFilledButtonImage(color: UIColor(rgb: 0x74db58), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"), angle: CGFloat.pi * 3.0 / 4.0)
case .end:
regularImage = generateFilledButtonImage(color: UIColor(rgb: 0xd92326), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"))
highlightedImage = generateFilledButtonImage(color: UIColor(rgb: 0xd92326), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"))
case .speaker:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .bluetooth:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .switchCamera:
let patternImage = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSwitchCameraButton"), color: .white)
regularImage = generateEmptyButtonImage(icon: patternImage, strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: patternImage, strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: patternImage, strokeColor: nil, fillColor: invertedFill, knockout: true)
}
self.regularImage = regularImage
self.highlightedImage = highlightedImage
self.filledImage = filledImage
super.init()
self.addSubnode(self.backgroundNode)
if let labelNode = self.labelNode {
self.addSubnode(labelNode)
}
self.backgroundNode.image = regularImage
self.currentImage = regularImage
self.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
strongSelf.internalHighlighted = highlighted
strongSelf.updateState(highlighted: highlighted, selected: strongSelf.isSelected)
}
}
}
private var internalHighlighted = false
override var isSelected: Bool {
didSet {
self.updateState(highlighted: self.internalHighlighted, selected: self.isSelected)
}
}
private var currentImage: UIImage?
private func updateState(highlighted: Bool, selected: Bool) {
let image: UIImage?
if selected {
image = self.filledImage
} else if highlighted {
image = self.highlightedImage
} else {
image = self.regularImage
}
if self.currentImage !== image {
let currentContents = self.backgroundNode.layer.contents
self.backgroundNode.layer.removeAnimation(forKey: "contents")
if let currentContents = currentContents, let image = image {
self.backgroundNode.image = image
self.backgroundNode.layer.animate(from: currentContents as AnyObject, to: image.cgImage!, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: image === self.currentImage || image === self.filledImage ? 0.25 : 0.15)
} else {
self.backgroundNode.image = image
}
self.currentImage = image
}
}
func updateType(_ type: LegacyCallControllerButtonType) {
if self.type == type {
return
}
self.type = type
var regularImage: UIImage?
var highlightedImage: UIImage?
var filledImage: UIImage?
switch type {
case .mute:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallMuteButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .accept:
regularImage = generateFilledButtonImage(color: UIColor(rgb: 0x74db58), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"), angle: CGFloat.pi * 3.0 / 4.0)
highlightedImage = generateFilledButtonImage(color: UIColor(rgb: 0x74db58), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"), angle: CGFloat.pi * 3.0 / 4.0)
case .end:
regularImage = generateFilledButtonImage(color: UIColor(rgb: 0xd92326), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"))
highlightedImage = generateFilledButtonImage(color: UIColor(rgb: 0xd92326), icon: UIImage(bundleImageName: "Call/LegacyCallPhoneButton"))
case .speaker:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/LegacyCallSpeakerButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .bluetooth:
regularImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: UIImage(bundleImageName: "Call/CallBluetoothButton"), strokeColor: nil, fillColor: invertedFill, knockout: true)
case .switchCamera:
let patternImage = generateTintedImage(image: UIImage(bundleImageName: "Call/CallSwitchCameraButton"), color: .white)
regularImage = generateEmptyButtonImage(icon: patternImage, strokeColor: emptyStroke, fillColor: .clear)
highlightedImage = generateEmptyButtonImage(icon: patternImage, strokeColor: emptyStroke, fillColor: emptyHighlightedFill)
filledImage = generateEmptyButtonImage(icon: patternImage, strokeColor: nil, fillColor: invertedFill, knockout: true)
}
self.regularImage = regularImage
self.highlightedImage = highlightedImage
self.filledImage = filledImage
self.updateState(highlighted: self.isHighlighted, selected: self.isSelected)
}
func animateRollTransition() {
self.backgroundNode.layer.animate(from: 0.0 as NSNumber, to: (-CGFloat.pi * 5 / 4) as NSNumber, keyPath: "transform.rotation.z", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3, removeOnCompletion: false)
self.labelNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
}
override func layout() {
super.layout()
let size = self.bounds.size
self.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.width))
if let labelNode = self.labelNode {
let labelSize = labelNode.measure(CGSize(width: 200.0, height: 100.0))
labelNode.frame = CGRect(origin: CGPoint(x: floor((size.width - labelSize.width) / 2.0), y: 81.0), size: labelSize)
}
}
}
@@ -0,0 +1,241 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import MediaPlayer
import TelegramPresentationData
enum LegacyCallControllerButtonsSpeakerMode {
case none
case builtin
case speaker
case headphones
case bluetooth
}
enum LegacyCallControllerButtonsMode: Equatable {
case active(speakerMode: LegacyCallControllerButtonsSpeakerMode)
case incoming
}
final class LegacyCallControllerButtonsNode: ASDisplayNode {
private let acceptButton: LegacyCallControllerButtonNode
private let declineButton: LegacyCallControllerButtonNode
private let muteButton: LegacyCallControllerButtonNode
private let endButton: LegacyCallControllerButtonNode
private let speakerButton: LegacyCallControllerButtonNode
private let swichCameraButton: LegacyCallControllerButtonNode
private var mode: LegacyCallControllerButtonsMode?
private var validLayout: CGFloat?
var isMuted = false {
didSet {
self.muteButton.isSelected = self.isMuted
}
}
var accept: (() -> Void)?
var mute: (() -> Void)?
var end: (() -> Void)?
var speaker: (() -> Void)?
var toggleVideo: (() -> Void)?
var rotateCamera: (() -> Void)?
init(strings: PresentationStrings) {
self.acceptButton = LegacyCallControllerButtonNode(type: .accept, label: strings.Call_Accept)
self.acceptButton.alpha = 0.0
self.declineButton = LegacyCallControllerButtonNode(type: .end, label: strings.Call_Decline)
self.declineButton.alpha = 0.0
self.muteButton = LegacyCallControllerButtonNode(type: .mute, label: nil)
self.muteButton.alpha = 0.0
self.endButton = LegacyCallControllerButtonNode(type: .end, label: nil)
self.endButton.alpha = 0.0
self.speakerButton = LegacyCallControllerButtonNode(type: .speaker, label: nil)
self.speakerButton.alpha = 0.0
self.swichCameraButton = LegacyCallControllerButtonNode(type: .switchCamera, label: nil)
self.swichCameraButton.alpha = 0.0
super.init()
self.addSubnode(self.acceptButton)
self.addSubnode(self.declineButton)
self.addSubnode(self.muteButton)
self.addSubnode(self.endButton)
self.addSubnode(self.speakerButton)
self.addSubnode(self.swichCameraButton)
self.acceptButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
self.declineButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
self.muteButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
self.endButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
self.speakerButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
self.swichCameraButton.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
}
func updateLayout(constrainedWidth: CGFloat, transition: ContainedViewLayoutTransition) {
let previousLayout = self.validLayout
self.validLayout = constrainedWidth
if let mode = self.mode, previousLayout != self.validLayout {
self.updateButtonsLayout(mode: mode, width: constrainedWidth, animated: false)
}
}
func updateMode(_ mode: LegacyCallControllerButtonsMode) {
if self.mode != mode {
let previousMode = self.mode
self.mode = mode
if let validLayout = self.validLayout {
self.updateButtonsLayout(mode: mode, width: validLayout, animated: previousMode != nil)
}
}
}
private func updateButtonsLayout(mode: LegacyCallControllerButtonsMode, width: CGFloat, animated: Bool) {
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.3, curve: .spring)
} else {
transition = .immediate
}
let threeButtonSpacing: CGFloat = 28.0
let twoButtonSpacing: CGFloat = 105.0
let buttonSize = CGSize(width: 75.0, height: 75.0)
let threeButtonsWidth = 3.0 * buttonSize.width + 2.0 * threeButtonSpacing
let twoButtonsWidth = 2.0 * buttonSize.width + 1.0 * twoButtonSpacing
var origin = CGPoint(x: floor((width - threeButtonsWidth) / 2.0), y: 0.0)
for button in [self.muteButton, self.endButton, self.speakerButton] {
transition.updateFrame(node: button, frame: CGRect(origin: origin, size: buttonSize))
if button === self.speakerButton {
transition.updateFrame(node: self.swichCameraButton, frame: CGRect(origin: origin, size: buttonSize))
}
origin.x += buttonSize.width + threeButtonSpacing
}
origin = CGPoint(x: floor((width - twoButtonsWidth) / 2.0), y: 0.0)
for button in [self.declineButton, self.acceptButton] {
transition.updateFrame(node: button, frame: CGRect(origin: origin, size: buttonSize))
origin.x += buttonSize.width + twoButtonSpacing
}
switch mode {
case .incoming:
for button in [self.declineButton, self.acceptButton] {
button.alpha = 1.0
}
for button in [self.muteButton, self.endButton, self.speakerButton, self.swichCameraButton] {
button.alpha = 0.0
}
case let .active(speakerMode):
for button in [self.muteButton] {
if animated && button.alpha.isZero {
button.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
button.alpha = 1.0
}
for button in [self.swichCameraButton] {
if animated && !button.alpha.isZero {
button.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
button.alpha = 0.0
}
for button in [self.speakerButton] {
if animated && button.alpha.isZero {
button.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
button.alpha = 1.0
}
var animatingAcceptButton = false
if self.endButton.alpha.isZero {
if animated {
if !self.acceptButton.alpha.isZero {
animatingAcceptButton = true
self.endButton.layer.animatePosition(from: self.acceptButton.position, to: self.endButton.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.acceptButton.animateRollTransition()
self.endButton.layer.animate(from: (CGFloat.pi * 5 / 4) as NSNumber, to: 0.0 as NSNumber, keyPath: "transform.rotation.z", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3)
self.acceptButton.layer.animatePosition(from: self.acceptButton.position, to: self.endButton.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.acceptButton.alpha = 0.0
strongSelf.acceptButton.layer.removeAnimation(forKey: "position")
strongSelf.acceptButton.layer.removeAnimation(forKey: "transform.rotation.z")
}
})
}
self.endButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
self.endButton.alpha = 1.0
}
if !self.declineButton.alpha.isZero {
if animated {
self.declineButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
self.declineButton.alpha = 0.0
}
if self.acceptButton.alpha.isZero && !animatingAcceptButton {
self.acceptButton.alpha = 0.0
}
self.speakerButton.isSelected = speakerMode == .speaker
self.speakerButton.isHidden = speakerMode == .none
let speakerButtonType: LegacyCallControllerButtonType
switch speakerMode {
case .none, .builtin, .speaker:
speakerButtonType = .speaker
case .headphones:
speakerButtonType = .bluetooth
case .bluetooth:
speakerButtonType = .bluetooth
}
self.speakerButton.updateType(speakerButtonType)
}
}
@objc func buttonPressed(_ button: LegacyCallControllerButtonNode) {
if button === self.muteButton {
self.mute?()
} else if button === self.endButton || button === self.declineButton {
self.end?()
} else if button === self.speakerButton {
self.speaker?()
} else if button === self.acceptButton {
self.accept?()
} else if button === self.swichCameraButton {
self.rotateCamera?()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let buttons = [
self.acceptButton,
self.declineButton,
self.muteButton,
self.endButton,
self.speakerButton,
self.swichCameraButton
]
for button in buttons {
if button.isHidden || button.alpha.isZero {
continue
}
if let result = button.view.hitTest(self.view.convert(point, to: button.view), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
}
@@ -0,0 +1,567 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramAudio
import AccountContext
import LocalizedPeerData
import PhotoResources
import CallsEmoji
final class LegacyCallControllerNode: ASDisplayNode, CallControllerNodeProtocol {
private let sharedContext: SharedAccountContext
private let account: Account
private let statusBar: StatusBar
private var presentationData: PresentationData
private var peer: Peer?
private let debugInfo: Signal<(String, String), NoError>
private var forceReportRating = false
private let easyDebugAccess: Bool
private let call: PresentationCall
private let containerNode: ASDisplayNode
private let imageNode: TransformImageNode
private let dimNode: ASDisplayNode
private let backButtonArrowNode: ASImageNode
private let backButtonNode: HighlightableButtonNode
private let statusNode: LegacyCallControllerStatusNode
private let buttonsNode: LegacyCallControllerButtonsNode
private var keyPreviewNode: CallControllerKeyPreviewNode?
private var debugNode: CallDebugNode?
private var keyTextData: (Data, String)?
private let keyButtonNode: HighlightableButtonNode
private var validLayout: (ContainerViewLayout, CGFloat)?
var isMuted: Bool = false {
didSet {
self.buttonsNode.isMuted = self.isMuted
}
}
private var shouldStayHiddenUntilConnection: Bool = false
private var audioOutputState: ([AudioSessionOutput], currentOutput: AudioSessionOutput?)?
private var callState: PresentationCallState?
var toggleMute: (() -> Void)?
var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)?
var beginAudioOuputSelection: ((Bool) -> Void)?
var acceptCall: (() -> Void)?
var endCall: (() -> Void)?
var setIsVideoPaused: ((Bool) -> Void)?
var back: (() -> Void)?
var presentCallRating: ((CallId, Bool) -> Void)?
var callEnded: ((Bool) -> Void)?
var willBeDismissedInteractively: (() -> Void)?
var dismissedInteractively: (() -> Void)?
var present: ((ViewController) -> Void)?
var dismissAllTooltips: (() -> Void)?
init(sharedContext: SharedAccountContext, account: Account, presentationData: PresentationData, statusBar: StatusBar, debugInfo: Signal<(String, String), NoError>, shouldStayHiddenUntilConnection: Bool = false, easyDebugAccess: Bool, call: PresentationCall) {
self.sharedContext = sharedContext
self.account = account
self.presentationData = presentationData
self.statusBar = statusBar
self.debugInfo = debugInfo
self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection
self.easyDebugAccess = easyDebugAccess
self.call = call
self.containerNode = ASDisplayNode()
if self.shouldStayHiddenUntilConnection {
self.containerNode.alpha = 0.0
}
self.imageNode = TransformImageNode()
self.imageNode.contentAnimations = [.subsequentUpdates]
self.dimNode = ASDisplayNode()
self.dimNode.isUserInteractionEnabled = false
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.4)
self.backButtonArrowNode = ASImageNode()
self.backButtonArrowNode.displayWithoutProcessing = true
self.backButtonArrowNode.displaysAsynchronously = false
self.backButtonArrowNode.image = NavigationBarTheme.generateBackArrowImage(color: .white)
self.backButtonNode = HighlightableButtonNode()
self.statusNode = LegacyCallControllerStatusNode()
self.buttonsNode = LegacyCallControllerButtonsNode(strings: self.presentationData.strings)
self.keyButtonNode = HighlightableButtonNode()
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.containerNode.backgroundColor = .black
self.addSubnode(self.containerNode)
self.backButtonNode.setTitle(presentationData.strings.Common_Back, with: Font.regular(17.0), with: .white, for: [])
self.backButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -20.0, bottom: -8.0, right: -8.0)
self.backButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backButtonNode.layer.removeAnimation(forKey: "opacity")
strongSelf.backButtonArrowNode.layer.removeAnimation(forKey: "opacity")
strongSelf.backButtonNode.alpha = 0.4
strongSelf.backButtonArrowNode.alpha = 0.4
} else {
strongSelf.backButtonNode.alpha = 1.0
strongSelf.backButtonArrowNode.alpha = 1.0
strongSelf.backButtonNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.backButtonArrowNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.containerNode.addSubnode(self.imageNode)
self.containerNode.addSubnode(self.dimNode)
self.containerNode.addSubnode(self.statusNode)
self.containerNode.addSubnode(self.buttonsNode)
self.containerNode.addSubnode(self.keyButtonNode)
self.containerNode.addSubnode(self.backButtonArrowNode)
self.containerNode.addSubnode(self.backButtonNode)
self.buttonsNode.mute = { [weak self] in
self?.toggleMute?()
}
self.buttonsNode.speaker = { [weak self] in
self?.beginAudioOuputSelection?(false)
}
self.buttonsNode.end = { [weak self] in
self?.endCall?()
}
self.buttonsNode.accept = { [weak self] in
self?.acceptCall?()
}
self.keyButtonNode.addTarget(self, action: #selector(self.keyPressed), forControlEvents: .touchUpInside)
self.backButtonNode.addTarget(self, action: #selector(self.backPressed), forControlEvents: .touchUpInside)
}
override func didLoad() {
super.didLoad()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
self.view.addGestureRecognizer(panRecognizer)
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) {
if !arePeersEqual(self.peer, peer) {
self.peer = peer
if let peerReference = PeerReference(peer), !peer.profileImageRepresentations.isEmpty {
let representations: [ImageRepresentationWithReference] = peer.profileImageRepresentations.map({ ImageRepresentationWithReference(representation: $0, reference: .avatar(peer: peerReference, resource: $0.resource)) })
self.imageNode.setSignal(chatAvatarGalleryPhoto(account: self.account, representations: representations, immediateThumbnailData: nil, autoFetchFullSize: true))
self.dimNode.isHidden = false
} else {
self.imageNode.setSignal(callDefaultBackground())
self.dimNode.isHidden = true
}
self.statusNode.title = EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)
if hasOther {
self.statusNode.subtitle = self.presentationData.strings.Call_AnsweringWithAccount(EnginePeer(accountPeer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).string
if let callState = callState {
self.updateCallState(callState)
}
}
if let (layout, navigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
}
func updateAudioOutputs(availableOutputs: [AudioSessionOutput], currentOutput: AudioSessionOutput?) {
if self.audioOutputState?.0 != availableOutputs || self.audioOutputState?.1 != currentOutput {
self.audioOutputState = (availableOutputs, currentOutput)
self.updateButtonsMode()
}
}
func updateCallState(_ callState: PresentationCallState) {
self.callState = callState
let statusValue: LegacyCallControllerStatusValue
var statusReception: Int32?
switch callState.state {
case .waiting, .connecting:
statusValue = .text(self.presentationData.strings.Call_StatusConnecting)
case let .requesting(ringing):
if ringing {
statusValue = .text(self.presentationData.strings.Call_StatusRinging)
} else {
statusValue = .text(self.presentationData.strings.Call_StatusRequesting)
}
case .terminating:
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
case let .terminated(_, reason, _):
if let reason = reason {
switch reason {
case let .ended(type):
switch type {
case .busy:
statusValue = .text(self.presentationData.strings.Call_StatusBusy)
case .hungUp, .missed, .switchedToConference:
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
}
case .error:
statusValue = .text(self.presentationData.strings.Call_StatusFailed)
}
} else {
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
}
case .ringing:
var text = self.presentationData.strings.Call_StatusIncoming
if !self.statusNode.subtitle.isEmpty {
text += "\n\(self.statusNode.subtitle)"
}
statusValue = .text(text)
case .active(let timestamp, let reception, let keyVisualHash), .reconnecting(let timestamp, let reception, let keyVisualHash):
let strings = self.presentationData.strings
var isReconnecting = false
if case .reconnecting = callState.state {
isReconnecting = true
}
statusValue = .timer({ value in
if isReconnecting {
return strings.Call_StatusConnecting
} else {
return strings.Call_StatusOngoing(value).string
}
}, timestamp)
if self.keyTextData?.0 != keyVisualHash {
let text = stringForEmojiHashOfData(keyVisualHash, 4)!.joined(separator: "")
self.keyTextData = (keyVisualHash, text)
self.keyButtonNode.setAttributedTitle(NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: Font.regular(22.0), NSAttributedString.Key.kern: 2.5 as NSNumber]), for: [])
let keyTextSize = self.keyButtonNode.measure(CGSize(width: 200.0, height: 200.0))
self.keyButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.keyButtonNode.frame = CGRect(origin: self.keyButtonNode.frame.origin, size: keyTextSize)
if let (layout, navigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
statusReception = reception
}
switch callState.state {
case .terminated, .terminating:
if !self.statusNode.alpha.isEqual(to: 0.5) {
self.statusNode.alpha = 0.5
self.buttonsNode.alpha = 0.5
self.keyButtonNode.alpha = 0.5
self.backButtonArrowNode.alpha = 0.5
self.backButtonNode.alpha = 0.5
self.statusNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
self.buttonsNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
self.keyButtonNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
}
default:
if !self.statusNode.alpha.isEqual(to: 1.0) {
self.statusNode.alpha = 1.0
self.buttonsNode.alpha = 1.0
self.keyButtonNode.alpha = 1.0
self.backButtonArrowNode.alpha = 1.0
self.backButtonNode.alpha = 1.0
}
}
if self.shouldStayHiddenUntilConnection {
switch callState.state {
case .connecting, .active:
self.containerNode.alpha = 1.0
default:
break
}
}
self.statusNode.status = statusValue
self.statusNode.reception = statusReception
self.updateButtonsMode()
if case let .terminated(id, _, reportRating) = callState.state, let callId = id {
let presentRating = reportRating || self.forceReportRating
if presentRating {
self.presentCallRating?(callId, false)
}
self.callEnded?(presentRating)
}
}
private func updateButtonsMode() {
guard let callState = self.callState else {
return
}
switch callState.state {
case .ringing:
self.buttonsNode.updateMode(.incoming)
default:
var mode: LegacyCallControllerButtonsSpeakerMode = .none
if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput {
switch currentOutput {
case .builtin:
mode = .builtin
case .speaker:
mode = .speaker
case .headphones:
mode = .headphones
case .port:
mode = .bluetooth
}
if availableOutputs.count <= 1 {
mode = .none
}
}
self.buttonsNode.updateMode(.active(speakerMode: mode))
}
}
func animateIn() {
var bounds = self.bounds
bounds.origin = CGPoint()
self.bounds = bounds
self.layer.removeAnimation(forKey: "bounds")
self.statusBar.layer.removeAnimation(forKey: "opacity")
self.containerNode.layer.removeAnimation(forKey: "opacity")
self.containerNode.layer.removeAnimation(forKey: "scale")
self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
if !self.shouldStayHiddenUntilConnection {
self.containerNode.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3)
self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
func animateOut(completion: @escaping () -> Void) {
self.statusBar.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
if !self.shouldStayHiddenUntilConnection || self.containerNode.alpha > 0.0 {
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
self.containerNode.layer.animateScale(from: 1.0, to: 1.04, duration: 0.3, removeOnCompletion: false, completion: { _ in
completion()
})
} else {
completion()
}
}
func expandFromPipIfPossible() {
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (layout, navigationBarHeight)
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
if let keyPreviewNode = self.keyPreviewNode {
transition.updateFrame(node: keyPreviewNode, frame: CGRect(origin: CGPoint(), size: layout.size))
keyPreviewNode.updateLayout(size: layout.size, transition: .immediate)
}
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let arguments = TransformImageArguments(corners: ImageCorners(), imageSize: CGSize(width: 640.0, height: 640.0).aspectFilled(layout.size), boundingSize: layout.size, intrinsicInsets: UIEdgeInsets())
let apply = self.imageNode.asyncLayout()(arguments)
apply()
let navigationOffset: CGFloat = max(20.0, layout.safeInsets.top)
let backSize = self.backButtonNode.measure(CGSize(width: 320.0, height: 100.0))
if let image = self.backButtonArrowNode.image {
transition.updateFrame(node: self.backButtonArrowNode, frame: CGRect(origin: CGPoint(x: 10.0, y: navigationOffset + 11.0), size: image.size))
}
transition.updateFrame(node: self.backButtonNode, frame: CGRect(origin: CGPoint(x: 29.0, y: navigationOffset + 11.0), size: backSize))
var statusOffset: CGFloat
if layout.metrics.widthClass == .regular && layout.metrics.heightClass == .regular {
if layout.size.height.isEqual(to: 1366.0) {
statusOffset = 160.0
} else {
statusOffset = 120.0
}
} else {
if layout.size.height.isEqual(to: 736.0) {
statusOffset = 80.0
} else if layout.size.width.isEqual(to: 320.0) {
statusOffset = 60.0
} else {
statusOffset = 64.0
}
}
statusOffset += layout.safeInsets.top
let buttonsHeight: CGFloat = 75.0
let buttonsOffset: CGFloat
if layout.size.width.isEqual(to: 320.0) {
if layout.size.height.isEqual(to: 480.0) {
buttonsOffset = 60.0
} else {
buttonsOffset = 73.0
}
} else {
buttonsOffset = 83.0
}
let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition)
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: statusOffset), size: CGSize(width: layout.size.width, height: statusHeight)))
self.buttonsNode.updateLayout(constrainedWidth: layout.size.width, transition: transition)
let buttonsOriginY: CGFloat = layout.size.height - (buttonsOffset - 40.0) - buttonsHeight - layout.intrinsicInsets.bottom
transition.updateFrame(node: self.buttonsNode, frame: CGRect(origin: CGPoint(x: 0.0, y: buttonsOriginY), size: CGSize(width: layout.size.width, height: buttonsHeight)))
let keyTextSize = self.keyButtonNode.frame.size
transition.updateFrame(node: self.keyButtonNode, frame: CGRect(origin: CGPoint(x: layout.size.width - keyTextSize.width - 8.0, y: navigationOffset + 8.0), size: keyTextSize))
if let debugNode = self.debugNode {
transition.updateFrame(node: debugNode, frame: CGRect(origin: CGPoint(), size: layout.size))
}
}
@objc func keyPressed() {
if self.keyPreviewNode == nil, let keyText = self.keyTextData?.1, let peer = self.peer {
let keyPreviewNode = CallControllerKeyPreviewNode(keyText: keyText, infoText: self.presentationData.strings.Call_EmojiDescription(EnginePeer(peer).compactDisplayTitle).string.replacingOccurrences(of: "%%", with: "%"), dismiss: { [weak self] in
if let _ = self?.keyPreviewNode {
self?.backPressed()
}
})
self.containerNode.insertSubnode(keyPreviewNode, belowSubnode: self.statusNode)
self.keyPreviewNode = keyPreviewNode
if let (validLayout, _) = self.validLayout {
keyPreviewNode.updateLayout(size: validLayout.size, transition: .immediate)
self.keyButtonNode.isHidden = true
keyPreviewNode.animateIn(from: self.keyButtonNode.frame, fromNode: self.keyButtonNode)
}
}
}
@objc func backPressed() {
if let keyPreviewNode = self.keyPreviewNode {
self.keyPreviewNode = nil
keyPreviewNode.animateOut(to: self.keyButtonNode.frame, toNode: self.keyButtonNode, completion: { [weak self, weak keyPreviewNode] in
self?.keyButtonNode.isHidden = false
keyPreviewNode?.removeFromSupernode()
})
} else {
self.back?()
}
}
private var debugTapCounter: (Double, Int) = (0.0, 0)
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
if let _ = self.keyPreviewNode {
self.backPressed()
} else {
let point = recognizer.location(in: recognizer.view)
if self.statusNode.frame.contains(point) {
if self.easyDebugAccess {
self.presentDebugNode()
} else {
let timestamp = CACurrentMediaTime()
if self.debugTapCounter.0 < timestamp - 0.75 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 = 0
}
if self.debugTapCounter.0 >= timestamp - 0.75 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 += 1
}
if self.debugTapCounter.1 >= 10 {
self.debugTapCounter.1 = 0
self.presentDebugNode()
}
}
}
}
}
}
private func presentDebugNode() {
guard self.debugNode == nil else {
return
}
self.forceReportRating = true
let debugNode = CallDebugNode(signal: self.debugInfo)
debugNode.dismiss = { [weak self] in
if let strongSelf = self {
strongSelf.debugNode?.removeFromSupernode()
strongSelf.debugNode = nil
}
}
self.addSubnode(debugNode)
self.debugNode = debugNode
if let (layout, navigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .changed:
let offset = recognizer.translation(in: self.view).y
var bounds = self.bounds
bounds.origin.y = -offset
self.bounds = bounds
case .ended:
let velocity = recognizer.velocity(in: self.view).y
if abs(velocity) < 100.0 {
var bounds = self.bounds
let previous = bounds
bounds.origin = CGPoint()
self.bounds = bounds
self.layer.animateBounds(from: previous, to: bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
} else {
var bounds = self.bounds
let previous = bounds
bounds.origin = CGPoint(x: 0.0, y: velocity > 0.0 ? -bounds.height: bounds.height)
self.bounds = bounds
self.layer.animateBounds(from: previous, to: bounds, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { [weak self] _ in
self?.dismissedInteractively?()
})
}
case .cancelled:
var bounds = self.bounds
let previous = bounds
bounds.origin = CGPoint()
self.bounds = bounds
self.layer.animateBounds(from: previous, to: bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
default:
break
}
}
}
@@ -0,0 +1,221 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
private let compactNameFont = Font.regular(28.0)
private let regularNameFont = Font.regular(36.0)
private let compactStatusFont = Font.regular(18.0)
private let regularStatusFont = Font.regular(18.0)
enum LegacyCallControllerStatusValue: Equatable {
case text(String)
case timer((String) -> String, Double)
static func ==(lhs: LegacyCallControllerStatusValue, rhs: LegacyCallControllerStatusValue) -> Bool {
switch lhs {
case let .text(text):
if case .text(text) = rhs {
return true
} else {
return false
}
case let .timer(_, referenceTime):
if case .timer(_, referenceTime) = rhs {
return true
} else {
return false
}
}
}
}
final class LegacyCallControllerStatusNode: ASDisplayNode {
private let titleNode: TextNode
private let statusNode: TextNode
private let statusMeasureNode: TextNode
private let receptionNode: LegacyCallControllerReceptionNode
var title: String = ""
var subtitle: String = ""
var status: LegacyCallControllerStatusValue = .text("") {
didSet {
if self.status != oldValue {
self.statusTimer?.invalidate()
if case .timer = self.status {
self.statusTimer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
if let strongSelf = self, let validLayoutWidth = strongSelf.validLayoutWidth {
let _ = strongSelf.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}, queue: Queue.mainQueue())
self.statusTimer?.start()
} else {
if let validLayoutWidth = self.validLayoutWidth {
let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}
}
}
}
var reception: Int32? {
didSet {
if self.reception != oldValue {
if let reception = self.reception {
self.receptionNode.reception = reception
if oldValue == nil {
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring)
transition.updateAlpha(node: self.receptionNode, alpha: 1.0)
}
} else if self.reception == nil, oldValue != nil {
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring)
transition.updateAlpha(node: self.receptionNode, alpha: 0.0)
}
if (oldValue == nil) != (self.reception != nil) {
if let validLayoutWidth = self.validLayoutWidth {
let _ = self.updateLayout(constrainedWidth: validLayoutWidth, transition: .immediate)
}
}
}
}
}
private var statusTimer: SwiftSignalKit.Timer?
private var validLayoutWidth: CGFloat?
override init() {
self.titleNode = TextNode()
self.statusNode = TextNode()
self.statusNode.displaysAsynchronously = false
self.statusMeasureNode = TextNode()
self.receptionNode = LegacyCallControllerReceptionNode()
self.receptionNode.alpha = 0.0
super.init()
self.isUserInteractionEnabled = false
self.addSubnode(self.titleNode)
self.addSubnode(self.statusNode)
self.addSubnode(self.receptionNode)
}
deinit {
self.statusTimer?.invalidate()
}
func updateLayout(constrainedWidth: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayoutWidth = constrainedWidth
let nameFont: UIFont
let statusFont: UIFont
if constrainedWidth < 330.0 {
nameFont = compactNameFont
statusFont = compactStatusFont
} else {
nameFont = regularNameFont
statusFont = regularStatusFont
}
var statusOffset: CGFloat = 0.0
let statusText: String
let statusMeasureText: String
switch self.status {
case let .text(text):
statusText = text
statusMeasureText = text
case let .timer(format, referenceTime):
let duration = Int32(CFAbsoluteTimeGetCurrent() - referenceTime)
let durationString: String
let measureDurationString: String
if duration > 60 * 60 {
durationString = String(format: "%02d:%02d:%02d", arguments: [duration / 3600, (duration / 60) % 60, duration % 60])
measureDurationString = "00:00:00"
} else {
durationString = String(format: "%02d:%02d", arguments: [(duration / 60) % 60, duration % 60])
measureDurationString = "00:00"
}
statusText = format(durationString)
statusMeasureText = format(measureDurationString)
if self.reception != nil {
statusOffset += 8.0
}
}
let spacing: CGFloat = 4.0
let (titleLayout, titleApply) = TextNode.asyncLayout(self.titleNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: self.title, font: nameFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let (statusMeasureLayout, statusMeasureApply) = TextNode.asyncLayout(self.statusMeasureNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusMeasureText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let (statusLayout, statusApply) = TextNode.asyncLayout(self.statusNode)(TextNodeLayoutArguments(attributedString: NSAttributedString(string: statusText, font: statusFont, textColor: .white), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0)))
let _ = titleApply()
let _ = statusApply()
let _ = statusMeasureApply()
self.titleNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - titleLayout.size.width) / 2.0), y: 0.0), size: titleLayout.size)
self.statusNode.frame = CGRect(origin: CGPoint(x: floor((constrainedWidth - statusMeasureLayout.size.width) / 2.0) + statusOffset, y: titleLayout.size.height + spacing), size: statusLayout.size)
self.receptionNode.frame = CGRect(origin: CGPoint(x: self.statusNode.frame.minX - receptionNodeSize.width, y: titleLayout.size.height + spacing + 9.0), size: receptionNodeSize)
return titleLayout.size.height + spacing + statusLayout.size.height
}
}
private final class CallControllerReceptionNodeParameters: NSObject {
let reception: Int32
init(reception: Int32) {
self.reception = reception
}
}
private let receptionNodeSize = CGSize(width: 24.0, height: 10.0)
final class LegacyCallControllerReceptionNode : ASDisplayNode {
var reception: Int32 = 4 {
didSet {
self.setNeedsDisplay()
}
}
override init() {
super.init()
self.isOpaque = false
self.isLayerBacked = true
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return CallControllerReceptionNodeParameters(reception: self.reception)
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
context.setFillColor(UIColor.white.cgColor)
if let parameters = parameters as? CallControllerReceptionNodeParameters{
let width: CGFloat = 3.0
var spacing: CGFloat = 1.5
if UIScreenScale > 2 {
spacing = 4.0 / 3.0
}
for i in 0 ..< 4 {
let height = 4.0 + 2.0 * CGFloat(i)
let rect = CGRect(x: bounds.minX + CGFloat(i) * (width + spacing), y: receptionNodeSize.height - height, width: width, height: height)
if i >= parameters.reception {
context.setAlpha(0.4)
}
let path = UIBezierPath(roundedRect: rect, cornerRadius: 0.5)
context.addPath(path.cgPath)
context.fillPath()
}
}
}
}
@@ -0,0 +1,683 @@
#if targetEnvironment(simulator)
#else
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AccountContext
import TelegramVoip
import AVFoundation
import Metal
import MetalPerformanceShaders
private func alignUp(size: Int, align: Int) -> Int {
precondition(((align - 1) & align) == 0, "Align must be a power of two")
let alignmentMask = align - 1
return (size + alignmentMask) & ~alignmentMask
}
private func getCubeVertexData(
cropX: Int,
cropY: Int,
cropWidth: Int,
cropHeight: Int,
frameWidth: Int,
frameHeight: Int,
rotation: Int,
mirrorHorizontally: Bool,
mirrorVertically: Bool,
buffer: UnsafeMutablePointer<Float>
) {
var cropLeft = Float(cropX) / Float(frameWidth)
var cropRight = Float(cropX + cropWidth) / Float(frameWidth)
var cropTop = Float(cropY) / Float(frameHeight)
var cropBottom = Float(cropY + cropHeight) / Float(frameHeight)
if mirrorHorizontally {
swap(&cropLeft, &cropRight)
}
if mirrorVertically {
swap(&cropTop, &cropBottom)
}
switch rotation {
default:
var values: [Float] = [
-1.0, -1.0, cropLeft, cropBottom,
1.0, -1.0, cropRight, cropBottom,
-1.0, 1.0, cropLeft, cropTop,
1.0, 1.0, cropRight, cropTop
]
memcpy(buffer, &values, values.count * MemoryLayout.size(ofValue: values[0]));
}
}
@available(iOS 13.0, *)
private protocol FrameBufferRenderingState {
var frameSize: CGSize? { get }
var mirrorHorizontally: Bool { get }
var mirrorVertically: Bool { get }
func encode(renderingContext: MetalVideoRenderingContext, vertexBuffer: MTLBuffer, renderEncoder: MTLRenderCommandEncoder) -> Bool
}
@available(iOS 13.0, *)
private final class BlitRenderingState {
static func encode(renderingContext: MetalVideoRenderingContext, texture: MTLTexture, vertexBuffer: MTLBuffer, renderEncoder: MTLRenderCommandEncoder) -> Bool {
renderEncoder.setRenderPipelineState(renderingContext.blitPipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(texture, index: 0)
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: 1)
return true
}
}
@available(iOS 13.0, *)
private final class NV12FrameBufferRenderingState: FrameBufferRenderingState {
private var yTexture: MTLTexture?
private var uvTexture: MTLTexture?
private(set) var mirrorHorizontally: Bool = false
private(set) var mirrorVertically: Bool = false
var frameSize: CGSize? {
if let yTexture = self.yTexture {
return CGSize(width: yTexture.width, height: yTexture.height)
} else {
return nil
}
}
func updateTextureBuffers(renderingContext: MetalVideoRenderingContext, frameBuffer: OngoingGroupCallContext.VideoFrameData.NativeBuffer, mirrorHorizontally: Bool, mirrorVertically: Bool) {
let pixelBuffer = frameBuffer.pixelBuffer
var lumaTexture: MTLTexture?
var chromaTexture: MTLTexture?
var outTexture: CVMetalTexture?
let lumaWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
let lumaHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
var indexPlane = 0
var result = CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, renderingContext.textureCache, pixelBuffer, nil, .r8Unorm, lumaWidth, lumaHeight, indexPlane, &outTexture)
if result == kCVReturnSuccess, let outTexture = outTexture {
lumaTexture = CVMetalTextureGetTexture(outTexture)
}
outTexture = nil
indexPlane = 1
result = CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, renderingContext.textureCache, pixelBuffer, nil, .rg8Unorm, lumaWidth / 2, lumaHeight / 2, indexPlane, &outTexture)
if result == kCVReturnSuccess, let outTexture = outTexture {
chromaTexture = CVMetalTextureGetTexture(outTexture)
}
outTexture = nil
if let lumaTexture = lumaTexture, let chromaTexture = chromaTexture {
self.yTexture = lumaTexture
self.uvTexture = chromaTexture
} else {
self.yTexture = nil
self.uvTexture = nil
}
self.mirrorHorizontally = mirrorHorizontally
self.mirrorVertically = mirrorVertically
}
func encode(renderingContext: MetalVideoRenderingContext, vertexBuffer: MTLBuffer, renderEncoder: MTLRenderCommandEncoder) -> Bool {
guard let yTexture = self.yTexture, let uvTexture = self.uvTexture else {
return false
}
renderEncoder.setRenderPipelineState(renderingContext.nv12PipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(yTexture, index: 0)
renderEncoder.setFragmentTexture(uvTexture, index: 1)
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: 1)
return true
}
}
@available(iOS 13.0, *)
private final class I420FrameBufferRenderingState: FrameBufferRenderingState {
private var yTexture: MTLTexture?
private var uTexture: MTLTexture?
private var vTexture: MTLTexture?
private var lumaTextureDescriptorSize: CGSize?
private var lumaTextureDescriptor: MTLTextureDescriptor?
private var chromaTextureDescriptor: MTLTextureDescriptor?
private(set) var mirrorHorizontally: Bool = false
private(set) var mirrorVertically: Bool = false
var frameSize: CGSize? {
if let yTexture = self.yTexture {
return CGSize(width: yTexture.width, height: yTexture.height)
} else {
return nil
}
}
func updateTextureBuffers(renderingContext: MetalVideoRenderingContext, frameBuffer: OngoingGroupCallContext.VideoFrameData.I420Buffer) {
let lumaSize = CGSize(width: frameBuffer.width, height: frameBuffer.height)
if lumaSize != lumaTextureDescriptorSize || lumaTextureDescriptor == nil || chromaTextureDescriptor == nil {
self.lumaTextureDescriptorSize = lumaSize
let lumaTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .r8Unorm, width: frameBuffer.width, height: frameBuffer.height, mipmapped: false)
lumaTextureDescriptor.usage = .shaderRead
self.lumaTextureDescriptor = lumaTextureDescriptor
self.yTexture = renderingContext.device.makeTexture(descriptor: lumaTextureDescriptor)
let chromaTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .r8Unorm, width: frameBuffer.width / 2, height: frameBuffer.height / 2, mipmapped: false)
chromaTextureDescriptor.usage = .shaderRead
self.chromaTextureDescriptor = chromaTextureDescriptor
self.uTexture = renderingContext.device.makeTexture(descriptor: chromaTextureDescriptor)
self.vTexture = renderingContext.device.makeTexture(descriptor: chromaTextureDescriptor)
}
guard let yTexture = self.yTexture, let uTexture = self.uTexture, let vTexture = self.vTexture else {
return
}
frameBuffer.y.withUnsafeBytes { bufferPointer in
if let baseAddress = bufferPointer.baseAddress {
yTexture.replace(region: MTLRegionMake2D(0, 0, yTexture.width, yTexture.height), mipmapLevel: 0, withBytes: baseAddress, bytesPerRow: frameBuffer.strideY)
}
}
frameBuffer.u.withUnsafeBytes { bufferPointer in
if let baseAddress = bufferPointer.baseAddress {
uTexture.replace(region: MTLRegionMake2D(0, 0, uTexture.width, uTexture.height), mipmapLevel: 0, withBytes: baseAddress, bytesPerRow: frameBuffer.strideU)
}
}
frameBuffer.v.withUnsafeBytes { bufferPointer in
if let baseAddress = bufferPointer.baseAddress {
vTexture.replace(region: MTLRegionMake2D(0, 0, vTexture.width, vTexture.height), mipmapLevel: 0, withBytes: baseAddress, bytesPerRow: frameBuffer.strideV)
}
}
}
func encode(renderingContext: MetalVideoRenderingContext, vertexBuffer: MTLBuffer, renderEncoder: MTLRenderCommandEncoder) -> Bool {
guard let yTexture = self.yTexture, let uTexture = self.uTexture, let vTexture = self.vTexture else {
return false
}
renderEncoder.setRenderPipelineState(renderingContext.i420PipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder.setFragmentTexture(yTexture, index: 0)
renderEncoder.setFragmentTexture(uTexture, index: 1)
renderEncoder.setFragmentTexture(vTexture, index: 2)
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: 1)
return true
}
}
@available(iOS 13.0, *)
final class MetalVideoRenderingView: UIView, VideoRenderingView {
static override var layerClass: AnyClass {
return CAMetalLayer.self
}
private var metalLayer: CAMetalLayer {
return self.layer as! CAMetalLayer
}
private weak var renderingContext: MetalVideoRenderingContext?
private var renderingContextIndex: Int?
private let blur: Bool
private let vertexBuffer: MTLBuffer
private var frameBufferRenderingState: FrameBufferRenderingState?
private var blurInputTexture: MTLTexture?
private var blurOutputTexture: MTLTexture?
fileprivate private(set) var isEnabled: Bool = false
fileprivate var needsRedraw: Bool = false
fileprivate let numberOfUsedDrawables = Atomic<Int>(value: 0)
private var onFirstFrameReceived: ((Float) -> Void)?
private var onOrientationUpdated: ((PresentationCallVideoView.Orientation, CGFloat) -> Void)?
private var onIsMirroredUpdated: ((Bool) -> Void)?
private var didReportFirstFrame: Bool = false
private var currentOrientation: PresentationCallVideoView.Orientation = .rotation0
private var currentAspect: CGFloat = 1.0
private var disposable: Disposable?
init?(renderingContext: MetalVideoRenderingContext, input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, blur: Bool) {
self.renderingContext = renderingContext
self.blur = blur
let vertexBufferArray = Array<Float>(repeating: 0, count: 16)
guard let vertexBuffer = renderingContext.device.makeBuffer(bytes: vertexBufferArray, length: vertexBufferArray.count * MemoryLayout.size(ofValue: vertexBufferArray[0]), options: [.cpuCacheModeWriteCombined]) else {
return nil
}
self.vertexBuffer = vertexBuffer
super.init(frame: CGRect())
self.renderingContextIndex = renderingContext.add(view: self)
self.metalLayer.device = renderingContext.device
self.metalLayer.pixelFormat = .bgra8Unorm
self.metalLayer.framebufferOnly = true
self.metalLayer.allowsNextDrawableTimeout = true
self.disposable = input.start(next: { [weak self] videoFrameData in
Queue.mainQueue().async {
self?.addFrame(videoFrameData)
}
})
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable?.dispose()
if let renderingContext = self.renderingContext, let renderingContextIndex = self.renderingContextIndex {
renderingContext.remove(index: renderingContextIndex)
}
}
private func addFrame(_ videoFrameData: OngoingGroupCallContext.VideoFrameData) {
let aspect = CGFloat(videoFrameData.width) / CGFloat(videoFrameData.height)
var isAspectUpdated = false
if self.currentAspect != aspect {
self.currentAspect = aspect
isAspectUpdated = true
}
let videoFrameOrientation = PresentationCallVideoView.Orientation(videoFrameData.orientation)
var isOrientationUpdated = false
if self.currentOrientation != videoFrameOrientation {
self.currentOrientation = videoFrameOrientation
isOrientationUpdated = true
}
if isAspectUpdated || isOrientationUpdated {
self.onOrientationUpdated?(self.currentOrientation, self.currentAspect)
}
if !self.didReportFirstFrame {
self.didReportFirstFrame = true
self.onFirstFrameReceived?(Float(self.currentAspect))
}
if self.isEnabled, let renderingContext = self.renderingContext {
switch videoFrameData.buffer {
case let .native(buffer):
let renderingState: NV12FrameBufferRenderingState
if let current = self.frameBufferRenderingState as? NV12FrameBufferRenderingState {
renderingState = current
} else {
renderingState = NV12FrameBufferRenderingState()
self.frameBufferRenderingState = renderingState
}
renderingState.updateTextureBuffers(renderingContext: renderingContext, frameBuffer: buffer, mirrorHorizontally: videoFrameData.mirrorHorizontally, mirrorVertically: videoFrameData.mirrorVertically)
self.needsRedraw = true
case let .i420(buffer):
let renderingState: I420FrameBufferRenderingState
if let current = self.frameBufferRenderingState as? I420FrameBufferRenderingState {
renderingState = current
} else {
renderingState = I420FrameBufferRenderingState()
self.frameBufferRenderingState = renderingState
}
renderingState.updateTextureBuffers(renderingContext: renderingContext, frameBuffer: buffer)
self.needsRedraw = true
default:
break
}
}
}
fileprivate func encode(commandBuffer: MTLCommandBuffer) -> MTLDrawable? {
guard let renderingContext = self.renderingContext else {
return nil
}
if self.numberOfUsedDrawables.with({ $0 }) >= 2 {
return nil
}
guard let frameBufferRenderingState = self.frameBufferRenderingState else {
return nil
}
guard let frameSize = frameBufferRenderingState.frameSize else {
return nil
}
let mirrorHorizontally = frameBufferRenderingState.mirrorHorizontally
let mirrorVertically = frameBufferRenderingState.mirrorVertically
let drawableSize: CGSize
if self.blur {
drawableSize = frameSize.aspectFitted(CGSize(width: 64.0, height: 64.0))
} else {
drawableSize = frameSize
}
if self.blur {
if let current = self.blurInputTexture, current.width == Int(drawableSize.width) && current.height == Int(drawableSize.height) {
} else {
let blurTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: Int(drawableSize.width), height: Int(drawableSize.height), mipmapped: false)
blurTextureDescriptor.usage = [.shaderRead, .shaderWrite, .renderTarget]
if let texture = renderingContext.device.makeTexture(descriptor: blurTextureDescriptor) {
self.blurInputTexture = texture
}
}
if let current = self.blurOutputTexture, current.width == Int(drawableSize.width) && current.height == Int(drawableSize.height) {
} else {
let blurTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, width: Int(drawableSize.width), height: Int(drawableSize.height), mipmapped: false)
blurTextureDescriptor.usage = [.shaderRead, .shaderWrite]
if let texture = renderingContext.device.makeTexture(descriptor: blurTextureDescriptor) {
self.blurOutputTexture = texture
}
}
}
if self.metalLayer.drawableSize != drawableSize {
self.metalLayer.drawableSize = drawableSize
}
getCubeVertexData(
cropX: 0,
cropY: 0,
cropWidth: Int(drawableSize.width),
cropHeight: Int(drawableSize.height),
frameWidth: Int(drawableSize.width),
frameHeight: Int(drawableSize.height),
rotation: 0,
mirrorHorizontally: mirrorHorizontally,
mirrorVertically: mirrorVertically,
buffer: self.vertexBuffer.contents().assumingMemoryBound(to: Float.self)
)
guard let drawable = self.metalLayer.nextDrawable() else {
return nil
}
if let blurInputTexture = self.blurInputTexture, let blurOutputTexture = self.blurOutputTexture {
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = blurInputTexture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
red: 0.0,
green: 0.0,
blue: 0.0,
alpha: 1.0
)
guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
return nil
}
let _ = frameBufferRenderingState.encode(renderingContext: renderingContext, vertexBuffer: self.vertexBuffer, renderEncoder: renderEncoder)
renderEncoder.endEncoding()
renderingContext.blurKernel.encode(commandBuffer: commandBuffer, sourceTexture: blurInputTexture, destinationTexture: blurOutputTexture)
let blitPassDescriptor = MTLRenderPassDescriptor()
blitPassDescriptor.colorAttachments[0].texture = drawable.texture
blitPassDescriptor.colorAttachments[0].loadAction = .clear
blitPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
red: 0.0,
green: 0.0,
blue: 0.0,
alpha: 1.0
)
guard let blitEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: blitPassDescriptor) else {
return nil
}
let _ = BlitRenderingState.encode(renderingContext: renderingContext, texture: blurOutputTexture, vertexBuffer: self.vertexBuffer, renderEncoder: blitEncoder)
blitEncoder.endEncoding()
} else {
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = drawable.texture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
red: 0.0,
green: 0.0,
blue: 0.0,
alpha: 1.0
)
guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
return nil
}
let _ = frameBufferRenderingState.encode(renderingContext: renderingContext, vertexBuffer: self.vertexBuffer, renderEncoder: renderEncoder)
renderEncoder.endEncoding()
}
return drawable
}
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) {
self.onFirstFrameReceived = f
self.didReportFirstFrame = false
}
func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) {
self.onOrientationUpdated = f
}
func getOrientation() -> PresentationCallVideoView.Orientation {
return self.currentOrientation
}
func getAspect() -> CGFloat {
return self.currentAspect
}
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) {
self.onIsMirroredUpdated = f
}
func updateIsEnabled(_ isEnabled: Bool) {
if self.isEnabled != isEnabled {
self.isEnabled = isEnabled
if self.isEnabled {
self.needsRedraw = true
}
}
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
}
@available(iOS 13.0, *)
class MetalVideoRenderingContext {
private final class ViewReference {
weak var view: MetalVideoRenderingView?
init(view: MetalVideoRenderingView) {
self.view = view
}
}
fileprivate let device: MTLDevice
fileprivate let textureCache: CVMetalTextureCache
fileprivate let blurKernel: MPSImageGaussianBlur
fileprivate let blitPipelineState: MTLRenderPipelineState
fileprivate let nv12PipelineState: MTLRenderPipelineState
fileprivate let i420PipelineState: MTLRenderPipelineState
private let commandQueue: MTLCommandQueue
private var displayLink: ConstantDisplayLinkAnimator?
private var viewReferences = Bag<ViewReference>()
init?() {
guard let device = MTLCreateSystemDefaultDevice() else {
return nil
}
self.device = device
var textureCache: CVMetalTextureCache?
let _ = CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, self.device, nil, &textureCache)
if let textureCache = textureCache {
self.textureCache = textureCache
} else {
return nil
}
let mainBundle = Bundle(for: MetalVideoRenderingView.self)
guard let path = mainBundle.path(forResource: "TelegramCallsUIBundle", ofType: "bundle") else {
return nil
}
guard let bundle = Bundle(path: path) else {
return nil
}
guard let defaultLibrary = try? self.device.makeDefaultLibrary(bundle: bundle) else {
return nil
}
self.blurKernel = MPSImageGaussianBlur(device: self.device, sigma: 3.0)
func makePipelineState(vertexProgram: String, fragmentProgram: String) -> MTLRenderPipelineState? {
guard let loadedVertexProgram = defaultLibrary.makeFunction(name: vertexProgram) else {
return nil
}
guard let loadedFragmentProgram = defaultLibrary.makeFunction(name: fragmentProgram) else {
return nil
}
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
pipelineStateDescriptor.vertexFunction = loadedVertexProgram
pipelineStateDescriptor.fragmentFunction = loadedFragmentProgram
pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
guard let pipelineState = try? device.makeRenderPipelineState(descriptor: pipelineStateDescriptor) else {
return nil
}
return pipelineState
}
guard let blitPipelineState = makePipelineState(vertexProgram: "nv12VertexPassthrough", fragmentProgram: "blitFragmentColorConversion") else {
return nil
}
self.blitPipelineState = blitPipelineState
guard let nv12PipelineState = makePipelineState(vertexProgram: "nv12VertexPassthrough", fragmentProgram: "nv12FragmentColorConversion") else {
return nil
}
self.nv12PipelineState = nv12PipelineState
guard let i420PipelineState = makePipelineState(vertexProgram: "i420VertexPassthrough", fragmentProgram: "i420FragmentColorConversion") else {
return nil
}
self.i420PipelineState = i420PipelineState
guard let commandQueue = self.device.makeCommandQueue() else {
return nil
}
self.commandQueue = commandQueue
self.displayLink = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.redraw()
})
self.displayLink?.isPaused = false
}
func updateVisibility(isVisible: Bool) {
self.displayLink?.isPaused = !isVisible
}
fileprivate func add(view: MetalVideoRenderingView) -> Int {
return self.viewReferences.add(ViewReference(view: view))
}
fileprivate func remove(index: Int) {
self.viewReferences.remove(index)
}
private func redraw() {
guard let commandBuffer = self.commandQueue.makeCommandBuffer() else {
return
}
var drawables: [MTLDrawable] = []
var takenViewReferences: [ViewReference] = []
for viewReference in self.viewReferences.copyItems() {
guard let videoView = viewReference.view else {
continue
}
if !videoView.needsRedraw {
continue
}
videoView.needsRedraw = false
if let drawable = videoView.encode(commandBuffer: commandBuffer) {
let numberOfUsedDrawables = videoView.numberOfUsedDrawables
let _ = numberOfUsedDrawables.modify {
return $0 + 1
}
takenViewReferences.append(viewReference)
drawable.addPresentedHandler { _ in
let _ = numberOfUsedDrawables.modify {
return max(0, $0 - 1)
}
}
drawables.append(drawable)
}
}
if drawables.isEmpty {
return
}
if drawables.count > 10 {
print("Schedule \(drawables.count) drawables")
}
commandBuffer.addScheduledHandler { _ in
for drawable in drawables {
drawable.present()
}
}
commandBuffer.commit()
}
}
#endif
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
import Foundation
import AVFoundation
private func loadToneData(name: String, addSilenceDuration: Double = 0.0) -> Data? {
let outputSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM as NSNumber,
AVSampleRateKey: 48000.0 as NSNumber,
AVLinearPCMBitDepthKey: 16 as NSNumber,
AVLinearPCMIsNonInterleaved: false as NSNumber,
AVLinearPCMIsFloatKey: false as NSNumber,
AVLinearPCMIsBigEndianKey: false as NSNumber,
AVNumberOfChannelsKey: 1 as NSNumber
]
let nsName: NSString = name as NSString
let baseName: String
let nameExtension: String
let pathExtension = nsName.pathExtension
if pathExtension.isEmpty {
baseName = name
nameExtension = "caf"
} else {
baseName = nsName.substring(with: NSRange(location: 0, length: (name.count - pathExtension.count - 1)))
nameExtension = pathExtension
}
guard let url = Bundle.main.url(forResource: baseName, withExtension: nameExtension) else {
return nil
}
let asset = AVURLAsset(url: url)
guard let assetReader = try? AVAssetReader(asset: asset) else {
return nil
}
let readerOutput = AVAssetReaderAudioMixOutput(audioTracks: asset.tracks, audioSettings: outputSettings)
if !assetReader.canAdd(readerOutput) {
return nil
}
assetReader.add(readerOutput)
if !assetReader.startReading() {
return nil
}
var data = Data()
while assetReader.status == .reading {
if let nextBuffer = readerOutput.copyNextSampleBuffer() {
var abl = AudioBufferList()
var blockBuffer: CMBlockBuffer? = nil
CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(nextBuffer, bufferListSizeNeededOut: nil, bufferListOut: &abl, bufferListSize: MemoryLayout<AudioBufferList>.size, blockBufferAllocator: nil, blockBufferMemoryAllocator: nil, flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, blockBufferOut: &blockBuffer)
let size = Int(CMSampleBufferGetTotalSampleSize(nextBuffer))
if size != 0, let mData = abl.mBuffers.mData {
data.append(Data(bytes: mData, count: size))
}
} else {
break
}
}
if !addSilenceDuration.isZero {
let sampleRate = 48000
let numberOfSamples = Int(Double(sampleRate) * addSilenceDuration)
let numberOfChannels = 1
let numberOfBytes = numberOfSamples * 2 * numberOfChannels
data.append(Data(count: numberOfBytes))
}
return data
}
enum PresentationCallTone: Equatable {
case ringing
case connecting
case busy
case failed
case ended
case groupJoined
case groupLeft
case groupConnecting
case custom(name: String, loopCount: Int?)
var loopCount: Int? {
switch self {
case .busy:
return 3
case .failed:
return 1
case .ended:
return 1
case .groupJoined, .groupLeft:
return 1
case .groupConnecting:
return nil
case let .custom(_, loopCount):
return loopCount
default:
return nil
}
}
}
func presentationCallToneData(_ tone: PresentationCallTone) -> Data? {
switch tone {
case .ringing:
return loadToneData(name: "voip_ringback.mp3")
case .connecting:
return loadToneData(name: "voip_connecting.mp3")
case .busy:
return loadToneData(name: "voip_busy.mp3")
case .failed:
return loadToneData(name: "voip_fail.mp3")
case .ended:
return loadToneData(name: "voip_end.mp3")
case .groupJoined:
return loadToneData(name: "voip_group_joined.mp3")
case .groupLeft:
return loadToneData(name: "voip_group_left.mp3")
case .groupConnecting:
return loadToneData(name: "voip_group_connecting.mp3", addSilenceDuration: 2.0)
case let .custom(name, _):
return loadToneData(name: name)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AccountContext
import TelegramVoip
import AVFoundation
import LibYuvBinding
private func copyI420BufferToNV12Buffer(buffer: OngoingGroupCallContext.VideoFrameData.I420Buffer, pixelBuffer: CVPixelBuffer) -> Bool {
guard CVPixelBufferGetPixelFormatType(pixelBuffer) == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange else {
return false
}
guard CVPixelBufferGetWidthOfPlane(pixelBuffer, 0) == buffer.width else {
return false
}
guard CVPixelBufferGetHeightOfPlane(pixelBuffer, 0) == buffer.height else {
return false
}
let cvRet = CVPixelBufferLockBaseAddress(pixelBuffer, [])
if cvRet != kCVReturnSuccess {
return false
}
defer {
CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
}
guard let dstY = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else {
return false
}
let dstStrideY = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
guard let dstUV = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) else {
return false
}
let dstStrideUV = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
buffer.y.withUnsafeBytes { srcYBuffer in
guard let srcY = srcYBuffer.baseAddress else {
return
}
buffer.u.withUnsafeBytes { srcUBuffer in
guard let srcU = srcUBuffer.baseAddress else {
return
}
buffer.v.withUnsafeBytes { srcVBuffer in
guard let srcV = srcVBuffer.baseAddress else {
return
}
libyuv_I420ToNV12(
srcY.assumingMemoryBound(to: UInt8.self),
Int32(buffer.strideY),
srcU.assumingMemoryBound(to: UInt8.self),
Int32(buffer.strideU),
srcV.assumingMemoryBound(to: UInt8.self),
Int32(buffer.strideV),
dstY.assumingMemoryBound(to: UInt8.self),
Int32(dstStrideY),
dstUV.assumingMemoryBound(to: UInt8.self),
Int32(dstStrideUV),
Int32(buffer.width),
Int32(buffer.height)
)
}
}
}
return true
}
final class SampleBufferVideoRenderingView: UIView, VideoRenderingView {
static override var layerClass: AnyClass {
return AVSampleBufferDisplayLayer.self
}
var sampleBufferLayer: AVSampleBufferDisplayLayer {
return self.layer as! AVSampleBufferDisplayLayer
}
private var isEnabled: Bool = false
private var onFirstFrameReceived: ((Float) -> Void)?
private var onOrientationUpdated: ((PresentationCallVideoView.Orientation, CGFloat) -> Void)?
private var onIsMirroredUpdated: ((Bool) -> Void)?
private var didReportFirstFrame: Bool = false
private var currentOrientation: PresentationCallVideoView.Orientation = .rotation0
private var currentAspect: CGFloat = 1.0
private var disposable: Disposable?
init(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>) {
super.init(frame: CGRect())
self.disposable = input.start(next: { [weak self] videoFrameData in
Queue.mainQueue().async {
self?.addFrame(videoFrameData)
}
})
self.sampleBufferLayer.videoGravity = .resize
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable?.dispose()
}
private func addFrame(_ videoFrameData: OngoingGroupCallContext.VideoFrameData) {
let aspect = CGFloat(videoFrameData.width) / CGFloat(videoFrameData.height)
var isAspectUpdated = false
if self.currentAspect != aspect {
self.currentAspect = aspect
isAspectUpdated = true
}
let videoFrameOrientation = PresentationCallVideoView.Orientation(videoFrameData.orientation)
var isOrientationUpdated = false
if self.currentOrientation != videoFrameOrientation {
self.currentOrientation = videoFrameOrientation
isOrientationUpdated = true
}
if isAspectUpdated || isOrientationUpdated {
self.onOrientationUpdated?(self.currentOrientation, self.currentAspect)
}
if !self.didReportFirstFrame {
self.didReportFirstFrame = true
self.onFirstFrameReceived?(Float(self.currentAspect))
}
if self.isEnabled {
switch videoFrameData.buffer {
case let .native(buffer):
if let sampleBuffer = sampleBufferFromPixelBuffer(pixelBuffer: buffer.pixelBuffer) {
self.sampleBufferLayer.enqueue(sampleBuffer)
}
case let .i420(buffer):
let ioSurfaceProperties = NSMutableDictionary()
let options = NSMutableDictionary()
options.setObject(ioSurfaceProperties, forKey: kCVPixelBufferIOSurfacePropertiesKey as NSString)
var pixelBuffer: CVPixelBuffer?
CVPixelBufferCreate(
kCFAllocatorDefault,
buffer.width,
buffer.height,
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
options,
&pixelBuffer
)
if let pixelBuffer = pixelBuffer {
if copyI420BufferToNV12Buffer(buffer: buffer, pixelBuffer: pixelBuffer) {
if let sampleBuffer = sampleBufferFromPixelBuffer(pixelBuffer: pixelBuffer) {
self.sampleBufferLayer.enqueue(sampleBuffer)
}
}
}
default:
break
}
}
}
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) {
self.onFirstFrameReceived = f
self.didReportFirstFrame = false
}
func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) {
self.onOrientationUpdated = f
}
func getOrientation() -> PresentationCallVideoView.Orientation {
return self.currentOrientation
}
func getAspect() -> CGFloat {
return self.currentAspect
}
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) {
self.onIsMirroredUpdated = f
}
func updateIsEnabled(_ isEnabled: Bool) {
self.isEnabled = isEnabled
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
}
}
@@ -0,0 +1,470 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import ViewControllerComponent
import AccountContext
import SheetComponent
import ButtonComponent
import TelegramCore
import AnimatedTextComponent
import MultilineTextComponent
import BalancedTextComponent
import TelegramPresentationData
import TelegramStringFormatting
import TextFormat
import Markdown
import GlassControls
private final class ScheduleVideoChatSheetContentComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let scheduleAction: (Int32) -> Void
let dismiss: () -> Void
init(
scheduleAction: @escaping (Int32) -> Void,
dismiss: @escaping () -> Void
) {
self.scheduleAction = scheduleAction
self.dismiss = dismiss
}
static func ==(lhs: ScheduleVideoChatSheetContentComponent, rhs: ScheduleVideoChatSheetContentComponent) -> Bool {
return true
}
final class View: UIView {
private let buttons = ComponentView<Empty>()
private let actionButton = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private let mainText = ComponentView<Empty>()
private var pickerView: UIDatePicker?
private let calendar = Calendar(identifier: .gregorian)
private let dateFormatter: DateFormatter
private var component: ScheduleVideoChatSheetContentComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
self.dateFormatter = DateFormatter()
self.dateFormatter.timeStyle = .none
self.dateFormatter.dateStyle = .short
self.dateFormatter.timeZone = TimeZone.current
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
@objc private func scheduleDatePickerUpdated() {
self.state?.updated(transition: .immediate)
}
private func updateSchedulePickerLimits() {
let timeZone = TimeZone(secondsFromGMT: 0)!
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
let currentDate = Date()
var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate)
components.second = 0
let roundedDate = calendar.date(from: components)!
let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: roundedDate)
let minute = components.minute ?? 0
components.minute = 0
let roundedToHourDate = calendar.date(from: components)!
components.hour = 0
let roundedToMidnightDate = calendar.date(from: components)!
let nextTwoHourDate = calendar.date(byAdding: .hour, value: minute > 30 ? 4 : 3, to: roundedToHourDate)
let maxDate = calendar.date(byAdding: .day, value: 8, to: roundedToMidnightDate)
if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) {
self.pickerView?.maximumDate = date
}
if let next1MinDate = next1MinDate, let nextTwoHourDate = nextTwoHourDate {
self.pickerView?.minimumDate = next1MinDate
self.pickerView?.maximumDate = maxDate
self.pickerView?.date = nextTwoHourDate
}
}
func update(component: ScheduleVideoChatSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
let _ = previousComponent
self.component = component
self.state = state
let environment = environment[EnvironmentType.self].value
let sideInset: CGFloat = 16.0
var contentHeight: CGFloat = 0.0
contentHeight += 16.0
let buttonsSize = self.buttons.update(
transition: transition,
component: AnyComponent(
GlassControlPanelComponent(
theme: environment.theme,
leftItem: GlassControlPanelComponent.Item(
items: [
GlassControlGroupComponent.Item(
id: AnyHashable("close"),
content: .icon("Navigation/Close"),
action: { [weak self] in
guard let component = self?.component else {
return
}
component.dismiss()
}
)
],
background: .panel
),
centralItem: nil,
rightItem: nil,
centerAlignmentIfPossible: true,
isDark: true
)
),
environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 44.0)
)
if let buttonsView = self.buttons.view {
if buttonsView.superview == nil {
self.addSubview(buttonsView)
}
transition.setFrame(view: buttonsView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - buttonsSize.width) * 0.5), y: contentHeight), size: buttonsSize))
}
contentHeight += buttonsSize.height
let titleString = NSMutableAttributedString()
titleString.append(NSAttributedString(string: environment.strings.VideoChat_ScheduleButtonTitle, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor))
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(titleString),
maximumNumberOfLines: 1
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setFrame(view: titleView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: contentHeight - buttonsSize.height * 0.5 - titleSize.height * 0.5), size: titleSize))
}
contentHeight += 16.0
let pickerView: UIDatePicker
if let current = self.pickerView {
pickerView = current
} else {
let textColor = UIColor.white
UILabel.setDateLabel(textColor)
pickerView = UIDatePicker()
pickerView.timeZone = TimeZone(secondsFromGMT: 0)
pickerView.datePickerMode = .countDownTimer
pickerView.datePickerMode = .dateAndTime
pickerView.locale = Locale.current
pickerView.timeZone = TimeZone.current
pickerView.minuteInterval = 1
self.addSubview(pickerView)
pickerView.addTarget(self, action: #selector(self.scheduleDatePickerUpdated), for: .valueChanged)
if #available(iOS 13.4, *) {
pickerView.preferredDatePickerStyle = .wheels
}
pickerView.setValue(textColor, forKey: "textColor")
self.pickerView = pickerView
self.addSubview(pickerView)
self.updateSchedulePickerLimits()
}
let pickerFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: CGSize(width: availableSize.width - sideInset * 2.0, height: 216.0))
transition.setFrame(view: pickerView, frame: pickerFrame)
contentHeight += pickerFrame.height
contentHeight += 26.0
let date = pickerView.date
let calendar = Calendar(identifier: .gregorian)
let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let timestamp = Int32(date.timeIntervalSince1970)
let time = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: PresentationDateTimeFormat())
let buttonTitle: String
if calendar.isDateInToday(date) {
buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleToday(time).string
} else if calendar.isDateInTomorrow(date) {
buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleTomorrow(time).string
} else {
buttonTitle = environment.strings.ScheduleVoiceChat_ScheduleOn(self.dateFormatter.string(from: date), time).string
}
let delta = timestamp - currentTimestamp
let isGroup = "".isEmpty
let intervalString = scheduledTimeIntervalString(strings: environment.strings, value: max(60, delta))
let text: String = isGroup ? environment.strings.ScheduleVoiceChat_GroupText(intervalString).string : environment.strings.ScheduleLiveStream_ChannelText(intervalString).string
let mainText = NSMutableAttributedString()
mainText.append(parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(
body: MarkdownAttributeSet(
font: Font.regular(14.0),
textColor: UIColor(rgb: 0x8e8e93)
),
bold: MarkdownAttributeSet(
font: Font.semibold(14.0),
textColor: UIColor(rgb: 0x8e8e93)
),
link: MarkdownAttributeSet(
font: Font.regular(14.0),
textColor: environment.theme.list.itemAccentColor,
additionalAttributes: [:]
),
linkAttribute: { attributes in
return ("URL", "")
}
)))
let mainTextSize = self.mainText.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(mainText),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0)
)
if let mainTextView = self.mainText.view {
if mainTextView.superview == nil {
self.addSubview(mainTextView)
}
transition.setFrame(view: mainTextView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - mainTextSize.width) * 0.5), y: contentHeight), size: mainTextSize))
}
contentHeight += mainTextSize.height
contentHeight += 32.0
var buttonContents: [AnyComponentWithIdentity<Empty>] = []
buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(
Text(text: buttonTitle, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor)
)))
let buttonTransition = transition
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
let buttonSize = self.actionButton.update(
transition: buttonTransition,
component: AnyComponent(ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: UIColor(rgb: 0x3252EF),
foreground: .white,
pressedColor: UIColor(rgb: 0x3252EF).withMultipliedAlpha(0.8)
),
content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent(
HStack(buttonContents, spacing: 5.0)
)),
isEnabled: true,
tintWhenDisabled: false,
displaysProgress: false,
action: { [weak self] in
guard let self, let component = self.component, let pickerView = self.pickerView else {
return
}
component.scheduleAction(Int32(pickerView.date.timeIntervalSince1970))
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0)
)
let buttonFrame = CGRect(origin: CGPoint(x: buttonInsets.left, y: contentHeight), size: buttonSize)
if let buttonView = self.actionButton.view {
if buttonView.superview == nil {
self.addSubview(buttonView)
}
transition.setFrame(view: buttonView, frame: buttonFrame)
}
contentHeight += buttonSize.height
contentHeight += buttonInsets.bottom
return CGSize(width: availableSize.width, height: contentHeight)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
private final class ScheduleVideoChatSheetScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let scheduleAction: (Int32) -> Void
init(
context: AccountContext,
scheduleAction: @escaping (Int32) -> Void
) {
self.context = context
self.scheduleAction = scheduleAction
}
static func ==(lhs: ScheduleVideoChatSheetScreenComponent, rhs: ScheduleVideoChatSheetScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
final class View: UIView {
private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, SheetComponentEnvironment)>()
private let sheetAnimateOut = ActionSlot<Action<Void>>()
private var component: ScheduleVideoChatSheetScreenComponent?
private var environment: EnvironmentType?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: ScheduleVideoChatSheetScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
self.component = component
let environment = environment[ViewControllerComponentContainer.Environment.self].value
self.environment = environment
let sheetEnvironment = SheetComponentEnvironment(
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { [weak self] _ in
guard let self, let environment = self.environment else {
return
}
self.sheetAnimateOut.invoke(Action { _ in
if let controller = environment.controller() {
controller.dismiss(completion: nil)
}
})
}
)
let _ = self.sheet.update(
transition: transition,
component: AnyComponent(SheetComponent(
content: AnyComponent(ScheduleVideoChatSheetContentComponent(
scheduleAction: { [weak self] timestamp in
guard let self else {
return
}
self.sheetAnimateOut.invoke(Action { [weak self] _ in
guard let self, let component = self.component else {
return
}
if let controller = self.environment?.controller() {
controller.dismiss(completion: nil)
}
component.scheduleAction(timestamp)
})
},
dismiss: { [weak self] in
guard let self else {
return
}
self.sheetAnimateOut.invoke(Action { [weak self] _ in
guard let self else {
return
}
if let controller = self.environment?.controller() {
controller.dismiss(completion: nil)
}
})
}
)),
style: .glass,
backgroundColor: .color(UIColor(rgb: 0x1c1c1e)),
animateOut: self.sheetAnimateOut
)),
environment: {
environment
sheetEnvironment
},
containerSize: availableSize
)
if let sheetView = self.sheet.view {
if sheetView.superview == nil {
self.addSubview(sheetView)
}
transition.setFrame(view: sheetView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<ViewControllerComponentContainer.Environment>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public class ScheduleVideoChatSheetScreen: ViewControllerComponentContainer {
public init(context: AccountContext, scheduleAction: @escaping (Int32) -> Void) {
super.init(context: context, component: ScheduleVideoChatSheetScreenComponent(
context: context,
scheduleAction: scheduleAction
), navigationBarAppearance: .none, theme: .dark)
self.statusBar.statusBarStyle = .Ignore
self.navigationPresentation = .flatModal
self.blocksBackgroundWhenInOverlay = true
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.view.disablesInteractiveModalDismiss = true
}
}
@@ -0,0 +1,264 @@
import Foundation
import SwiftSignalKit
import TelegramVoip
import TelegramAudio
import DeviceProximity
public final class SharedCallAudioContext {
private static weak var current: SharedCallAudioContext?
let audioDevice: OngoingCallContext.AudioDevice?
let callKitIntegration: CallKitIntegration?
private let defaultToSpeaker: Bool
private var audioSessionDisposable: Disposable?
private var audioSessionShouldBeActiveDisposable: Disposable?
private var isAudioSessionActiveDisposable: Disposable?
private var audioOutputStateDisposable: Disposable?
private(set) var audioSessionControl: ManagedAudioSessionControl?
private let isAudioSessionActivePromise = Promise<Bool>(false)
private var isAudioSessionActive: Signal<Bool, NoError> {
return self.isAudioSessionActivePromise.get()
}
private let audioOutputStatePromise = Promise<([AudioSessionOutput], AudioSessionOutput?)>(([], nil))
private var audioOutputStateValue: ([AudioSessionOutput], AudioSessionOutput?) = ([], nil)
public private(set) var currentAudioOutputValue: AudioSessionOutput = .builtin
private var didSetCurrentAudioOutputValue: Bool = false
var audioOutputState: Signal<([AudioSessionOutput], AudioSessionOutput?), NoError> {
return self.audioOutputStatePromise.get()
}
private let audioSessionShouldBeActive = Promise<Bool>(true)
private var initialSetupTimer: Foundation.Timer?
private var proximityManagerIndex: Int?
static func get(audioSession: ManagedAudioSession, callKitIntegration: CallKitIntegration?, defaultToSpeaker: Bool = false, reuseCurrent: Bool = false, enableMicrophone: Bool = true) -> SharedCallAudioContext {
if let current = self.current, reuseCurrent {
return current
}
let context = SharedCallAudioContext(audioSession: audioSession, callKitIntegration: callKitIntegration, defaultToSpeaker: defaultToSpeaker, enableMicrophone: enableMicrophone)
self.current = context
return context
}
private init(audioSession: ManagedAudioSession, callKitIntegration: CallKitIntegration?, defaultToSpeaker: Bool = false, enableMicrophone: Bool = true) {
self.callKitIntegration = callKitIntegration
self.audioDevice = OngoingCallContext.AudioDevice.create(enableSystemMute: false, enableMicrophone: enableMicrophone)
var defaultToSpeaker = defaultToSpeaker
if audioSession.getIsHeadsetPluggedIn() {
defaultToSpeaker = false
}
self.defaultToSpeaker = defaultToSpeaker
if defaultToSpeaker {
self.didSetCurrentAudioOutputValue = true
self.currentAudioOutputValue = .speaker
}
var didReceiveAudioOutputs = false
self.audioSessionDisposable = audioSession.push(audioSessionType: enableMicrophone ? .voiceCall : .play(mixWithOthers: true), manualActivate: { [weak self] control in
Queue.mainQueue().async {
guard let self else {
return
}
let previousControl = self.audioSessionControl
self.audioSessionControl = control
if previousControl == nil, let audioSessionControl = self.audioSessionControl {
if let callKitIntegration = self.callKitIntegration {
if self.didSetCurrentAudioOutputValue {
callKitIntegration.applyVoiceChatOutputMode(outputMode: .custom(self.currentAudioOutputValue))
}
} else {
audioSessionControl.setOutputMode(.custom(self.currentAudioOutputValue))
audioSessionControl.setup(synchronous: true)
}
let audioSessionActive: Signal<Bool, NoError>
if let callKitIntegration = self.callKitIntegration {
audioSessionActive = callKitIntegration.audioSessionActive
} else {
audioSessionControl.activate({ _ in })
audioSessionActive = .single(true)
}
self.isAudioSessionActivePromise.set(audioSessionActive)
self.initialSetupTimer?.invalidate()
self.initialSetupTimer = Foundation.Timer(timeInterval: 0.5, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
if self.defaultToSpeaker, let audioSessionControl = self.audioSessionControl {
self.currentAudioOutputValue = .speaker
self.didSetCurrentAudioOutputValue = true
if let callKitIntegration = self.callKitIntegration {
if self.didSetCurrentAudioOutputValue {
callKitIntegration.applyVoiceChatOutputMode(outputMode: .custom(self.currentAudioOutputValue))
}
} else {
audioSessionControl.setOutputMode(.custom(self.currentAudioOutputValue))
audioSessionControl.setup(synchronous: true)
}
self.updateProximityMonitoring()
}
})
}
}
}, deactivate: { [weak self] _ in
return Signal { subscriber in
Queue.mainQueue().async {
if let self {
self.isAudioSessionActivePromise.set(.single(false))
self.audioSessionControl = nil
}
subscriber.putCompletion()
}
return EmptyDisposable
}
}, availableOutputsChanged: { [weak self] availableOutputs, currentOutput in
Queue.mainQueue().async {
guard let self else {
return
}
self.audioOutputStateValue = (availableOutputs, currentOutput)
if let currentOutput = currentOutput {
self.currentAudioOutputValue = currentOutput
self.didSetCurrentAudioOutputValue = true
self.updateProximityMonitoring()
}
var signal: Signal<([AudioSessionOutput], AudioSessionOutput?), NoError> = .single((availableOutputs, currentOutput))
if !didReceiveAudioOutputs {
didReceiveAudioOutputs = true
if currentOutput == .speaker {
signal = .single((availableOutputs, .builtin))
|> then(
signal
|> delay(1.0, queue: Queue.mainQueue())
)
}
}
self.audioOutputStatePromise.set(signal)
}
})
self.audioSessionShouldBeActive.set(.single(true))
self.audioSessionShouldBeActiveDisposable = (self.audioSessionShouldBeActive.get()
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
if value {
if let audioSessionControl = self.audioSessionControl {
let audioSessionActive: Signal<Bool, NoError>
if let callKitIntegration = self.callKitIntegration {
audioSessionActive = callKitIntegration.audioSessionActive
} else {
audioSessionControl.activate({ _ in })
audioSessionActive = .single(true)
}
self.isAudioSessionActivePromise.set(audioSessionActive)
} else {
self.isAudioSessionActivePromise.set(.single(false))
}
} else {
self.isAudioSessionActivePromise.set(.single(false))
}
})
self.isAudioSessionActiveDisposable = (self.isAudioSessionActive
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
self.audioDevice?.setIsAudioSessionActive(value)
})
self.audioOutputStateDisposable = (self.audioOutputStatePromise.get()
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
self.audioOutputStateValue = value
if let currentOutput = value.1 {
self.currentAudioOutputValue = currentOutput
self.updateProximityMonitoring()
}
})
}
deinit {
self.audioSessionDisposable?.dispose()
self.audioSessionShouldBeActiveDisposable?.dispose()
self.isAudioSessionActiveDisposable?.dispose()
self.audioOutputStateDisposable?.dispose()
self.initialSetupTimer?.invalidate()
if let proximityManagerIndex = self.proximityManagerIndex {
DeviceProximityManager.shared().remove(proximityManagerIndex)
}
}
func setCurrentAudioOutput(_ output: AudioSessionOutput) {
self.initialSetupTimer?.invalidate()
self.initialSetupTimer = nil
guard self.currentAudioOutputValue != output else {
return
}
self.currentAudioOutputValue = output
self.didSetCurrentAudioOutputValue = true
self.audioOutputStatePromise.set(.single((self.audioOutputStateValue.0, output))
|> then(
.single(self.audioOutputStateValue)
|> delay(1.0, queue: Queue.mainQueue())
))
if let audioSessionControl = self.audioSessionControl {
if let callKitIntegration = self.callKitIntegration {
callKitIntegration.applyVoiceChatOutputMode(outputMode: .custom(self.currentAudioOutputValue))
} else {
audioSessionControl.setOutputMode(.custom(output))
}
}
}
public func switchToSpeakerIfBuiltin() {
if case .builtin = self.currentAudioOutputValue {
self.setCurrentAudioOutput(.speaker)
}
}
private func updateProximityMonitoring() {
var shouldMonitorProximity = false
switch self.currentAudioOutputValue {
case .builtin:
shouldMonitorProximity = true
default:
break
}
if shouldMonitorProximity {
if self.proximityManagerIndex == nil {
self.proximityManagerIndex = DeviceProximityManager.shared().add { _ in
}
}
} else {
if let proximityManagerIndex = self.proximityManagerIndex {
self.proximityManagerIndex = nil
DeviceProximityManager.shared().remove(proximityManagerIndex)
}
}
}
}
@@ -0,0 +1,345 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import TelegramAudio
import GlassBackgroundComponent
final class VideoChatActionButtonComponent: Component {
enum Content: Equatable {
enum BluetoothType: Equatable {
case generic
case airpods
case airpodsPro
case airpodsMax
}
enum Audio: Equatable {
case none
case builtin
case speaker
case headphones
case bluetooth(BluetoothType)
}
fileprivate enum IconType: Equatable {
enum Audio: Equatable {
case speaker
case headphones
case bluetooth(BluetoothType)
}
case audio(audio: Audio)
case video
case rotateCamera
case message
case share
case leave
}
case audio(audio: Audio, isEnabled: Bool)
case video(isActive: Bool)
case rotateCamera
case message
case share
case leave
fileprivate var iconType: IconType {
switch self {
case let .audio(audio, _):
let mappedAudio: IconType.Audio
switch audio {
case .none, .builtin, .speaker:
mappedAudio = .speaker
case .headphones:
mappedAudio = .headphones
case let .bluetooth(type):
mappedAudio = .bluetooth(type)
}
return .audio(audio: mappedAudio)
case .video:
return .video
case .rotateCamera:
return .rotateCamera
case .message:
return .message
case .share:
return .share
case .leave:
return .leave
}
}
}
enum MicrophoneState: Equatable {
case connecting
case muted(forced: Bool)
case unmuted
case raiseHand
case scheduled
}
let strings: PresentationStrings
let content: Content
let microphoneState: MicrophoneState
let isCollapsed: Bool
init(
strings: PresentationStrings,
content: Content,
microphoneState: MicrophoneState,
isCollapsed: Bool
) {
self.strings = strings
self.content = content
self.microphoneState = microphoneState
self.isCollapsed = isCollapsed
}
static func ==(lhs: VideoChatActionButtonComponent, rhs: VideoChatActionButtonComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.content != rhs.content {
return false
}
if lhs.microphoneState != rhs.microphoneState {
return false
}
if lhs.isCollapsed != rhs.isCollapsed {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let icon = ComponentView<Empty>()
private let background: GlassBackgroundView
private let title = ComponentView<Empty>()
private var component: VideoChatActionButtonComponent?
private var isUpdating: Bool = false
private var contentImage: UIImage?
override init(frame: CGRect) {
self.background = GlassBackgroundView()
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: VideoChatActionButtonComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
let genericBackgroundColor = UIColor(rgb: 0x25272e, alpha: 0.72)
let blueColor = UIColor(rgb: 0x21477d)
let titleText: String
let backgroundColor: UIColor
let iconDiameter: CGFloat
var isEnabled: Bool = true
switch component.content {
case let .audio(audio, isEnabledValue):
var isActive = false
switch audio {
case .none, .builtin:
titleText = component.strings.Call_Speaker
case .speaker:
isEnabled = isEnabledValue
isActive = isEnabledValue
titleText = component.strings.Call_Speaker
case .headphones:
titleText = component.strings.Call_Audio
case .bluetooth:
titleText = component.strings.Call_Audio
}
switch component.microphoneState {
case .connecting:
backgroundColor = genericBackgroundColor
case .muted:
backgroundColor = !isActive ? genericBackgroundColor : blueColor
case .unmuted:
backgroundColor = !isActive ? genericBackgroundColor : UIColor(rgb: 0x34C659)
case .raiseHand, .scheduled:
backgroundColor = !isActive ? UIColor(rgb: 0x23306B) : UIColor(rgb: 0x3252EF)
}
iconDiameter = 60.0
case let .video(isActive):
titleText = component.strings.VoiceChat_Video
switch component.microphoneState {
case .connecting:
backgroundColor = genericBackgroundColor
case .muted:
backgroundColor = !isActive ? genericBackgroundColor : blueColor
case .unmuted:
backgroundColor = !isActive ? genericBackgroundColor : UIColor(rgb: 0x34C659)
case .raiseHand, .scheduled:
backgroundColor = UIColor(rgb: 0x3252EF)
}
iconDiameter = 58.0
case .rotateCamera:
titleText = component.strings.VoiceChat_Rotate
switch component.microphoneState {
case .connecting:
backgroundColor = genericBackgroundColor
case .muted:
backgroundColor = blueColor
case .unmuted:
backgroundColor = UIColor(rgb: 0x34C659)
case .raiseHand, .scheduled:
backgroundColor = UIColor(rgb: 0x3252EF)
}
iconDiameter = 44.0
case .message:
titleText = component.strings.VoiceChat_Message
backgroundColor = genericBackgroundColor
iconDiameter = 56.0
case .share:
titleText = component.strings.VoiceChat_ShareButton
backgroundColor = genericBackgroundColor
iconDiameter = 56.0
case .leave:
titleText = component.strings.VoiceChat_Leave
backgroundColor = UIColor(rgb: 0x330d0b)
iconDiameter = 22.0
}
if self.contentImage == nil || previousComponent?.content.iconType != component.content.iconType {
switch component.content.iconType {
case let .audio(audio):
let iconName: String
switch audio {
case .speaker:
iconName = "Call/CallSpeakerButton"
case .headphones:
iconName = "Call/CallHeadphonesButton"
case let .bluetooth(type):
switch type {
case .generic:
iconName = "Call/CallBluetoothButton"
case .airpods:
iconName = "Call/CallAirpodsButton"
case .airpodsPro:
iconName = "Call/CallAirpodsProButton"
case .airpodsMax:
iconName = "Call/CallAirpodsMaxButton"
}
}
self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate)
case .video:
self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate)
case .rotateCamera:
self.contentImage = UIImage(bundleImageName: "Call/CallSwitchCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate)
case .message:
self.contentImage = UIImage(bundleImageName: "Call/CallMessageButton")?.precomposed().withRenderingMode(.alwaysTemplate)
case .share:
self.contentImage = UIImage(bundleImageName: "Call/CallShareButton")?.precomposed().withRenderingMode(.alwaysTemplate)
case .leave:
self.contentImage = generateImage(CGSize(width: 28.0, height: 28.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setLineWidth(4.0 - UIScreenPixel)
context.setLineCap(.round)
context.setStrokeColor(UIColor.white.cgColor)
context.move(to: CGPoint(x: 2.0 + UIScreenPixel, y: 2.0 + UIScreenPixel))
context.addLine(to: CGPoint(x: 26.0 - UIScreenPixel, y: 26.0 - UIScreenPixel))
context.strokePath()
context.move(to: CGPoint(x: 26.0 - UIScreenPixel, y: 2.0 + UIScreenPixel))
context.addLine(to: CGPoint(x: 2.0 + UIScreenPixel, y: 26.0 - UIScreenPixel))
context.strokePath()
})
}
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleText, font: Font.regular(13.0), textColor: .white)),
horizontalAlignment: .center,
maximumNumberOfLines: 2
)),
environment: {},
containerSize: CGSize(width: 90.0, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: availableSize.height)
let tintTransition: ComponentTransition
if self.background.superview == nil {
tintTransition = .immediate
self.insertSubview(self.background, at: 0)
} else {
if !transition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
} else {
tintTransition = .immediate
}
}
self.background.update(size: size, cornerRadius: size.width * 0.5, isDark: true, tintColor: .init(kind: .custom(style: .default, color: backgroundColor)), transition: tintTransition)
transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size))
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) * 0.5), y: size.height + 5.0), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
alphaTransition.setAlpha(view: titleView, alpha: component.isCollapsed ? 0.0 : 1.0)
}
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(Image(
image: self.contentImage,
tintColor: .white,
size: CGSize(width: iconDiameter, height: iconDiameter)
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
transition.setPosition(view: iconView, position: iconFrame.center)
transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size))
transition.setAlpha(view: iconView, alpha: isEnabled ? 1.0 : 0.6)
transition.setScale(view: iconView, scale: availableSize.height / 56.0)
}
self.isEnabled = isEnabled
self.isUserInteractionEnabled = isEnabled
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,846 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import BalancedTextComponent
import TelegramPresentationData
import CallsEmoji
import ImageBlur
import HierarchyTrackingLayer
import GlassBackgroundComponent
private final class EmojiContainerView: UIView {
private let maskImageView: UIImageView?
let contentView: UIView
var isMaskEnabled: Bool = false {
didSet {
if self.isMaskEnabled != oldValue {
if self.isMaskEnabled {
self.mask = self.maskImageView
} else {
self.mask = nil
}
}
}
}
init(hasMask: Bool) {
if hasMask {
self.maskImageView = UIImageView()
} else {
self.maskImageView = nil
}
self.contentView = UIView()
self.contentView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.0)
self.contentView.center = CGPoint(x: 0.0, y: 0.0)
super.init(frame: CGRect())
if let maskImageView = self.maskImageView {
self.mask = maskImageView
}
self.addSubview(self.contentView)
self.clipsToBounds = hasMask
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, borderWidth: CGFloat) {
let minimalHeight = borderWidth * 2.0 + 1.0
let minimalSize = CGSize(width: 4.0, height: minimalHeight)
if let maskImageView = self.maskImageView, maskImageView.image?.size != minimalSize {
let generatedImage = generateImage(minimalSize, rotatedContext: { imageSize, context in
context.clear(CGRect(origin: CGPoint(), size: imageSize))
let height: CGFloat = borderWidth
let baseGradientAlpha: CGFloat = 1.0
let numSteps = 8
let firstStep = 0
let firstLocation = 0.0
let colors = (0 ..< numSteps).map { i -> UIColor in
if i < firstStep {
return UIColor(white: 1.0, alpha: 1.0)
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
let value: CGFloat = bezierPoint(0.42, 0.0, 0.58, 1.0, step)
return UIColor(white: 1.0, alpha: baseGradientAlpha * value)
}
}
var locations = (0 ..< numSteps).map { i -> CGFloat in
if i < firstStep {
return 0.0
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
return (firstLocation + (1.0 - firstLocation) * step)
}
}
let gradient = CGGradient(colorsSpace: DeviceGraphicsContextSettings.shared.colorSpace, colors: colors.map { $0.cgColor } as CFArray, locations: &locations)!
context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(origin: CGPoint(x: 0.0, y: height), size: CGSize(width: imageSize.width, height: 1.0)))
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: height), options: CGGradientDrawingOptions())
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: imageSize.height), end: CGPoint(x: 0.0, y: imageSize.height - height), options: CGGradientDrawingOptions())
})
let capInsets = UIEdgeInsets(top: borderWidth, left: 0, bottom: borderWidth, right: 0)
maskImageView.image = generatedImage?.resizableImage(withCapInsets: capInsets, resizingMode: .stretch)
}
if let maskImageView = self.maskImageView {
maskImageView.frame = CGRect(origin: CGPoint(), size: size)
}
self.contentView.bounds = CGRect(origin: CGPoint(), size: size)
}
}
private final class EmojiContentLayer: SimpleLayer {
private let size: CGSize
private(set) var emoji: String?
private var image: UIImage?
private var motionBlurLayer: SimpleLayer?
init(size: CGSize) {
self.size = size
super.init()
self.contentsGravity = .center
self.contentsScale = UIScreenScale
}
override init(layer: Any) {
if let layer = layer as? EmojiContentLayer {
self.size = layer.size
} else {
self.size = CGSize()
}
super.init(layer: layer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setEmoji(emoji: String, font: UIFont) {
if self.emoji == emoji {
return
}
self.emoji = emoji
let attributedText = NSAttributedString(string: emoji, attributes: [
NSAttributedString.Key.font: font,
NSAttributedString.Key.foregroundColor: UIColor.black
])
var boundingRect = attributedText.boundingRect(with: CGSize(width: 200.0, height: 200.0), options: .usesLineFragmentOrigin, context: nil)
boundingRect.size.width = ceil(boundingRect.size.width)
boundingRect.size.height = ceil(boundingRect.size.height)
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: boundingRect.size))
let image = renderer.image { context in
UIGraphicsPushContext(context.cgContext)
attributedText.draw(at: CGPoint())
UIGraphicsPopContext()
}
self.image = image
self.contents = image.cgImage
if let motionBlurLayer = self.motionBlurLayer {
motionBlurLayer.contents = verticalBlurredImage(image, radius: 6.0)?.cgImage
}
}
func setMotionBlurFactor(factor: CGFloat, transition: ComponentTransition) {
if factor != 0.0 {
let motionBlurLayer: SimpleLayer
if let current = self.motionBlurLayer {
motionBlurLayer = current
} else {
motionBlurLayer = SimpleLayer()
if let image = self.image {
motionBlurLayer.contents = verticalBlurredImage(image, radius: 6.0)?.cgImage
}
motionBlurLayer.contentsScale = self.contentsScale
self.motionBlurLayer = motionBlurLayer
self.addSublayer(motionBlurLayer)
motionBlurLayer.position = CGPoint(x: self.size.width * 0.5, y: self.size.height * 0.5)
motionBlurLayer.bounds = CGRect(origin: CGPoint(), size: self.size)
motionBlurLayer.opacity = 0.0
}
let scaleFactor = 1.0 * (1.0 - factor) + 2.0 * factor
let opacityFactor = 0.0 * (1.0 - factor) + 0.6 * factor
transition.setTransform(layer: motionBlurLayer, transform: CATransform3DMakeScale(1.0, scaleFactor, 1.0))
transition.setAlpha(layer: motionBlurLayer, alpha: opacityFactor)
} else if let motionBlurLayer = self.motionBlurLayer {
self.motionBlurLayer = nil
transition.setAlpha(layer: motionBlurLayer, alpha: 0.0, completion: { [weak motionBlurLayer] _ in
motionBlurLayer?.removeFromSuperlayer()
})
transition.setTransform(layer: motionBlurLayer, transform: CATransform3DIdentity)
}
}
}
private final class EmojiItemComponent: Component {
let emoji: String?
init(emoji: String?) {
self.emoji = emoji
}
static func ==(lhs: EmojiItemComponent, rhs: EmojiItemComponent) -> Bool {
if lhs.emoji != rhs.emoji {
return false
}
return true
}
final class View: UIView {
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private let containerView: EmojiContainerView
private let measureEmojiView = ComponentView<Empty>()
private var pendingContainerView: EmojiContainerView?
private var pendingEmojiLayers: [EmojiContentLayer] = []
private var emojiLayer: EmojiContentLayer?
private var component: EmojiItemComponent?
private weak var state: EmptyComponentState?
private var pendingEmojiValues: [String]?
override init(frame: CGRect) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
self.containerView = EmojiContainerView(hasMask: true)
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.addSubview(self.containerView)
self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in
guard let self else {
return
}
if value {
self.state?.updated(transition: .immediate)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: EmojiItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let pendingContainerInset: CGFloat = 8.0
self.component = component
self.state = state
let motionBlurTransition: ComponentTransition
if transition.animation.isImmediate {
motionBlurTransition = .immediate
} else {
motionBlurTransition = .easeInOut(duration: 0.2)
}
let size = self.measureEmojiView.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: "👍", font: Font.regular(40.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 200.0)
)
let containerFrame = CGRect(origin: CGPoint(x: -pendingContainerInset, y: -pendingContainerInset), size: CGSize(width: size.width + pendingContainerInset * 2.0, height: size.height + pendingContainerInset * 2.0))
self.containerView.frame = containerFrame
self.containerView.update(size: containerFrame.size, borderWidth: 10.0)
let borderEmoji = 2
let numEmoji = borderEmoji * 2 + 3
var previousEmojiLayer: EmojiContentLayer?
if let emoji = component.emoji {
let emojiLayer: EmojiContentLayer
var emojiLayerTransition = transition
if let current = self.emojiLayer {
emojiLayer = current
} else {
emojiLayerTransition = .immediate
emojiLayer = EmojiContentLayer(size: size)
self.emojiLayer = emojiLayer
}
emojiLayer.setEmoji(emoji: emoji, font: Font.regular(40.0))
let emojiSize = size
let emojiFrame = CGRect(origin: CGPoint(x: pendingContainerInset + floor((size.width - emojiSize.width) * 0.5), y: pendingContainerInset + floor((size.height - emojiSize.height) * 0.5)), size: emojiSize)
if emojiLayer.superlayer == nil {
self.containerView.contentView.layer.addSublayer(emojiLayer)
}
emojiLayerTransition.setFrame(layer: emojiLayer, frame: emojiFrame)
emojiLayer.setMotionBlurFactor(factor: 0.0, transition: emojiLayerTransition.animation.isImmediate ? .immediate : motionBlurTransition)
if let pendingContainerView = self.pendingContainerView {
self.pendingContainerView = nil
for pendingEmojiLayer in self.pendingEmojiLayers {
pendingEmojiLayer.setMotionBlurFactor(factor: 0.0, transition: motionBlurTransition)
}
self.pendingEmojiLayers.removeAll()
let currentPendingContainerOffset = pendingContainerView.contentView.layer.presentation()?.position.y ?? pendingContainerView.contentView.layer.position.y
pendingContainerView.contentView.layer.removeAnimation(forKey: "offsetCycle")
pendingContainerView.contentView.layer.position.y = currentPendingContainerOffset
let animateTransition: ComponentTransition = .spring(duration: 0.4)
let targetOffset: CGFloat = CGFloat(borderEmoji - 1) * size.height
animateTransition.setPosition(layer: pendingContainerView.contentView.layer, position: CGPoint(x: 0.0, y: targetOffset), completion: { [weak self, weak pendingContainerView] _ in
pendingContainerView?.removeFromSuperview()
self?.containerView.isMaskEnabled = false
})
animateTransition.animatePosition(layer: emojiLayer, from: CGPoint(x: 0.0, y: currentPendingContainerOffset - targetOffset), to: CGPoint(), additive: true)
} else {
self.containerView.isMaskEnabled = false
}
self.pendingEmojiValues = nil
} else {
if let emojiLayer = self.emojiLayer {
self.emojiLayer = nil
previousEmojiLayer = emojiLayer
}
if self.pendingEmojiValues?.count != numEmoji {
var pendingEmojiValuesValue: [String] = []
for _ in 0 ..< numEmoji - borderEmoji - 1 {
pendingEmojiValuesValue.append(randomCallsEmoji() ?? "👍")
}
for i in 0 ..< borderEmoji + 1 {
pendingEmojiValuesValue.append(pendingEmojiValuesValue[i])
}
self.pendingEmojiValues = pendingEmojiValuesValue
}
}
if let pendingEmojiValues, pendingEmojiValues.count == numEmoji {
self.containerView.isMaskEnabled = true
let pendingContainerView: EmojiContainerView
if let current = self.pendingContainerView {
pendingContainerView = current
} else {
pendingContainerView = EmojiContainerView(hasMask: false)
self.pendingContainerView = pendingContainerView
}
for i in 0 ..< numEmoji {
let pendingEmojiLayer: EmojiContentLayer
if self.pendingEmojiLayers.count > i {
pendingEmojiLayer = self.pendingEmojiLayers[i]
} else {
pendingEmojiLayer = EmojiContentLayer(size: size)
self.pendingEmojiLayers.append(pendingEmojiLayer)
}
pendingEmojiLayer.setEmoji(emoji: pendingEmojiValues[i], font: Font.regular(40.0))
let pendingEmojiViewSize = size
if pendingEmojiLayer.superlayer == nil {
pendingContainerView.contentView.layer.addSublayer(pendingEmojiLayer)
}
pendingEmojiLayer.frame = CGRect(origin: CGPoint(x: pendingContainerInset, y: pendingContainerInset + CGFloat(i) * size.height), size: pendingEmojiViewSize)
pendingEmojiLayer.setMotionBlurFactor(factor: 1.0, transition: motionBlurTransition)
}
pendingContainerView.frame = CGRect(origin: CGPoint(), size: containerFrame.size)
pendingContainerView.update(size: containerFrame.size, borderWidth: 10.0)
if pendingContainerView.superview == nil {
self.containerView.contentView.addSubview(pendingContainerView)
let startTime = CACurrentMediaTime()
var loopAnimationOffset: Double = 0.0
if let previousEmojiLayerValue = previousEmojiLayer {
previousEmojiLayer = nil
pendingContainerView.contentView.layer.addSublayer(previousEmojiLayerValue)
previousEmojiLayerValue.position = previousEmojiLayerValue.position.offsetBy(dx: 0.0, dy: CGFloat(numEmoji) * size.height)
let animation = CABasicAnimation(keyPath: "position.y")
loopAnimationOffset = 0.25
animation.duration = loopAnimationOffset
animation.fromValue = -CGFloat(numEmoji) * size.height
animation.toValue = 0.0
animation.timingFunction = CAMediaTimingFunction(name: .easeIn)
animation.autoreverses = false
animation.repeatCount = 1.0
animation.fillMode = .backwards
animation.isRemovedOnCompletion = true
animation.beginTime = pendingContainerView.contentView.layer.convertTime(startTime, from: nil)
animation.isAdditive = true
animation.completion = { [weak previousEmojiLayerValue] _ in
previousEmojiLayerValue?.removeFromSuperlayer()
}
pendingContainerView.contentView.layer.add(animation, forKey: "offsetCyclePre")
previousEmojiLayerValue.setMotionBlurFactor(factor: 1.0, transition: motionBlurTransition)
}
let animation = CABasicAnimation(keyPath: "position.y")
animation.duration = 0.2
animation.fromValue = -CGFloat(numEmoji - borderEmoji) * size.height
animation.toValue = CGFloat(borderEmoji - 3) * size.height
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.autoreverses = false
animation.repeatCount = .infinity
animation.fillMode = .forwards
animation.beginTime = pendingContainerView.contentView.layer.convertTime(startTime + loopAnimationOffset, from: nil)
pendingContainerView.contentView.layer.add(animation, forKey: "offsetCycle")
} else if pendingContainerView.contentView.layer.animation(forKey: "offsetCycle") == nil {
let animation = CABasicAnimation(keyPath: "position.y")
animation.duration = 0.2
animation.fromValue = -CGFloat(numEmoji - borderEmoji) * size.height
animation.toValue = CGFloat(borderEmoji - 3) * size.height
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.autoreverses = false
animation.repeatCount = .infinity
animation.fillMode = .forwards
pendingContainerView.contentView.layer.add(animation, forKey: "offsetCycle")
}
} else if let pendingContainerView = self.pendingContainerView {
self.pendingContainerView = nil
pendingContainerView.removeFromSuperview()
for emojiLayer in self.pendingEmojiLayers {
emojiLayer.removeFromSuperlayer()
}
self.pendingEmojiLayers.removeAll()
}
if let previousEmojiLayer = previousEmojiLayer {
previousEmojiLayer.removeFromSuperlayer()
}
return size
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class VideoChatEncryptionKeyComponent: Component {
let theme: PresentationTheme
let strings: PresentationStrings
let emoji: [String]
let isShort: Bool
let isExpanded: Bool
let tapAction: () -> Void
init(
theme: PresentationTheme,
strings: PresentationStrings,
emoji: [String],
isShort: Bool,
isExpanded: Bool,
tapAction: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.emoji = emoji
self.isShort = isShort
self.isExpanded = isExpanded
self.tapAction = tapAction
}
static func ==(lhs: VideoChatEncryptionKeyComponent, rhs: VideoChatEncryptionKeyComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.emoji != rhs.emoji {
return false
}
if lhs.isShort != rhs.isShort {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
return true
}
final class View: UIView {
private let containerView: UIView
private var emojiItems: [ComponentView<Empty>] = []
private let backgroundView = GlassBackgroundView()
private let collapsedText = ComponentView<Empty>()
private let expandedText = ComponentView<Empty>()
private let expandedSeparatorLayer = SimpleLayer()
private let expandedButtonText = ComponentView<Empty>()
private var component: VideoChatEncryptionKeyComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer?
#if DEBUG
private var mockStateTimer: Foundation.Timer?
private var mockCurrentKey: [String]?
#endif
override init(frame: CGRect) {
self.containerView = UIView()
//self.containerView.clipsToBounds = true
super.init(frame: frame)
self.addSubview(self.containerView)
self.containerView.addSubview(self.backgroundView)
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.addGestureRecognizer(tapRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
#if DEBUG
self.mockStateTimer?.invalidate()
#endif
}
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
component.tapAction()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.containerView.frame.contains(point) {
return self
} else {
return nil
}
}
func update(component: VideoChatEncryptionKeyComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
#if DEBUG && false
if self.component == nil {
self.mockStateTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
if self.mockCurrentKey == nil {
self.mockCurrentKey = (0 ..< 4).map { _ in randomCallsEmoji() ?? "👍" }
} else {
self.mockCurrentKey = nil
}
if !self.isUpdating {
self.state?.updated(transition: .spring(duration: 0.4), isLocal: true)
}
})
}
let emoji = self.mockCurrentKey ?? []
#else
let emoji = component.emoji
#endif
self.component = component
self.state = state
let alphaTransition: ComponentTransition
if transition.animation.isImmediate {
alphaTransition = .immediate
} else {
alphaTransition = .easeInOut(duration: 0.25)
}
let collapsedSideInset: CGFloat = 7.0
let collapsedVerticalInset: CGFloat = 8.0
let collapsedEmojiSpacing: CGFloat = 0.0
let collapsedEmojiTextSpacing: CGFloat = 5.0
let expandedTopInset: CGFloat = 8.0
let expandedSideInset: CGFloat = 14.0
var expandedEmojiSpacing: CGFloat = 10.0
let expandedEmojiTextSpacing: CGFloat = 10.0
let expandedTextButtonSpacing: CGFloat = 10.0
let expandedButtonTopInset: CGFloat = 12.0
let expandedButtonBottomInset: CGFloat = 13.0
let emojiItemSizes = (0 ..< 4).map { i -> CGSize in
let emojiItem: ComponentView<Empty>
if self.emojiItems.count > i {
emojiItem = self.emojiItems[i]
} else {
emojiItem = ComponentView()
self.emojiItems.append(emojiItem)
}
return emojiItem.update(
transition: transition,
component: AnyComponent(EmojiItemComponent(
emoji: i < emoji.count ? emoji[i] : nil
)),
environment: {},
containerSize: CGSize(width: 200.0, height: 200.0)
)
}
let collapsedTextSize = self.collapsedText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.isShort ? component.strings.VideoChat_EncryptionKeyLabelShort : component.strings.VideoChat_EncryptionKeyLabel, font: Font.semibold(12.0), textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: 1000.0, height: 1000.0)
)
let expandedTextSize = self.expandedText.update(
transition: .immediate,
component: AnyComponent(BalancedTextComponent(
text: .plain(NSAttributedString(string: component.strings.VideoChat_EncryptionKeyText, font: Font.regular(12.0), textColor: component.theme.list.itemPrimaryTextColor)),
maximumNumberOfLines: 0,
lineSpacing: 0.3
)),
environment: {},
containerSize: CGSize(width: availableSize.width - expandedSideInset * 2.0, height: 1000.0)
)
let expandedButtonTextSize = self.expandedButtonText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.VideoChat_EncryptionKeyDone, font: Font.regular(17.0), textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - expandedSideInset * 2.0, height: 1000.0)
)
let collapsedEmojiFactor: CGFloat = 20.0 / 40.0
let collapsedEmojiSizes = emojiItemSizes.map { CGSize(width: floor($0.width * collapsedEmojiFactor), height: floor($0.height * collapsedEmojiFactor)) }
let collapsedSize = CGSize(width: collapsedTextSize.width + collapsedSideInset * 2.0 + collapsedEmojiTextSpacing * 2.0 + collapsedEmojiSpacing + collapsedEmojiSizes.reduce(into: 0.0, { $0 += $1.width }), height: collapsedTextSize.height + collapsedVerticalInset * 2.0)
var expandedEmojiWidth = expandedEmojiSpacing * CGFloat(self.emojiItems.count - 1) + emojiItemSizes.reduce(into: 0.0, { $0 += $1.width })
var expandedSize = CGSize(width: expandedSideInset * 2.0, height: 0.0)
expandedSize.width += max(expandedTextSize.width, expandedEmojiWidth)
expandedSize.height += expandedTopInset
expandedSize.height += emojiItemSizes[0].height
expandedSize.height += expandedEmojiTextSpacing
expandedSize.height += expandedTextSize.height
expandedSize.height += expandedTextButtonSpacing
expandedSize.height += expandedButtonTopInset
expandedSize.height += expandedButtonTextSize.height
expandedSize.height += expandedButtonBottomInset
if expandedEmojiWidth < expandedSize.width - expandedSideInset * 2.0 {
expandedEmojiWidth = expandedSize.width - expandedSideInset * 2.0
let cleanEmojiWidth = emojiItemSizes.reduce(into: 0.0, { $0 += $1.width })
expandedEmojiSpacing = floorToScreenPixels((expandedEmojiWidth - cleanEmojiWidth) / CGFloat(self.emojiItems.count - 1))
expandedEmojiSpacing = min(expandedEmojiSpacing, 24.0)
expandedEmojiWidth = expandedEmojiSpacing * CGFloat(self.emojiItems.count - 1) + emojiItemSizes.reduce(into: 0.0, { $0 += $1.width })
}
let backgroundSize = component.isExpanded ? expandedSize : collapsedSize
let backgroundCornerRadius: CGFloat = component.isExpanded ? 26.0 : collapsedSize.height * 0.5
let backgroundFrame = CGRect(origin: CGPoint(), size: backgroundSize)
self.backgroundView.update(size: backgroundSize, cornerRadius: backgroundCornerRadius, isDark: true, tintColor: .init(kind: .custom(style: .default, color: UIColor(rgb: 0x161616, alpha: 0.6))), transition: transition)
transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
var collapsedEmojiLeftOffset = collapsedSideInset
var collapsedEmojiRightOffset = collapsedSize.width - collapsedSideInset
for i in 0 ..< self.emojiItems.count {
let mappedIndex: Int
if i < 2 {
mappedIndex = i
} else {
mappedIndex = self.emojiItems.count - (i - 1)
}
var collapsedItemFrame = CGRect(origin: CGPoint(x: 0.0, y: floor((collapsedSize.height - collapsedEmojiSizes[mappedIndex].height) * 0.5)), size: collapsedEmojiSizes[mappedIndex])
if i < 2 {
if mappedIndex != 0 {
collapsedEmojiLeftOffset += collapsedEmojiSpacing
}
collapsedItemFrame.origin.x = collapsedEmojiLeftOffset
collapsedEmojiLeftOffset += collapsedEmojiSizes[mappedIndex].width
} else {
if mappedIndex != 0 {
collapsedEmojiRightOffset -= collapsedEmojiSpacing
}
collapsedItemFrame.origin.x = collapsedEmojiRightOffset - collapsedEmojiSizes[mappedIndex].width
collapsedEmojiRightOffset -= collapsedEmojiSizes[mappedIndex].width
}
var expandedItemFrame = CGRect(origin: CGPoint(x: floor((backgroundFrame.width - expandedEmojiWidth) * 0.5), y: expandedTopInset), size: emojiItemSizes[mappedIndex])
expandedItemFrame.origin.x += CGFloat(mappedIndex) * (emojiItemSizes[0].width + expandedEmojiSpacing)
let itemFrame: CGRect
if component.isExpanded {
itemFrame = expandedItemFrame
} else {
itemFrame = collapsedItemFrame
}
if let itemView = self.emojiItems[mappedIndex].view {
if itemView.superview == nil {
self.containerView.addSubview(itemView)
}
transition.setPosition(view: itemView, position: itemFrame.center)
itemView.bounds = CGRect(origin: CGPoint(), size: emojiItemSizes[mappedIndex])
transition.setScale(view: itemView, scale: itemFrame.height / emojiItemSizes[mappedIndex].height)
}
}
var collapsedTextFrame = CGRect(origin: CGPoint(x: collapsedEmojiLeftOffset + collapsedEmojiTextSpacing, y: floor((collapsedSize.height - collapsedTextSize.height) * 0.5)), size: collapsedTextSize)
var expandedTextFrame = CGRect(origin: CGPoint(x: floor((backgroundSize.width - expandedTextSize.width) * 0.5), y: expandedTopInset + emojiItemSizes[0].height + expandedEmojiTextSpacing), size: expandedTextSize)
if component.isExpanded {
collapsedTextFrame.origin = expandedTextFrame.origin
} else {
expandedTextFrame.origin = collapsedTextFrame.origin
}
if let collapsedTextView = self.collapsedText.view {
if collapsedTextView.superview == nil {
collapsedTextView.layer.anchorPoint = CGPoint()
self.containerView.addSubview(collapsedTextView)
} else {
if collapsedTextView.alpha != (component.isExpanded ? 0.0 : 1.0) {
if let blurFilter = CALayer.blur() {
let maxBlur: CGFloat = 8.0
if !component.isExpanded {
blurFilter.setValue(0.0 as NSNumber, forKey: "inputRadius")
collapsedTextView.layer.filters = [blurFilter]
collapsedTextView.layer.animate(from: maxBlur as NSNumber, to: 0.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak collapsedTextView] flag in
if flag, let collapsedTextView {
collapsedTextView.layer.filters = nil
}
})
} else {
blurFilter.setValue(maxBlur as NSNumber, forKey: "inputRadius")
collapsedTextView.layer.filters = [blurFilter]
collapsedTextView.layer.animate(from: 0.0 as NSNumber, to: maxBlur as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: true)
}
}
}
}
transition.setPosition(view: collapsedTextView, position: collapsedTextFrame.origin)
collapsedTextView.bounds = CGRect(origin: CGPoint(), size: collapsedTextFrame.size)
alphaTransition.setAlpha(view: collapsedTextView, alpha: component.isExpanded ? 0.0 : 1.0)
}
if let expandedTextView = self.expandedText.view {
if expandedTextView.superview == nil {
expandedTextView.layer.anchorPoint = CGPoint()
self.containerView.addSubview(expandedTextView)
} else {
if expandedTextView.alpha != (component.isExpanded ? 1.0 : 0.0) {
if let blurFilter = CALayer.blur() {
let maxBlur: CGFloat = 8.0
if component.isExpanded {
blurFilter.setValue(0.0 as NSNumber, forKey: "inputRadius")
expandedTextView.layer.filters = [blurFilter]
expandedTextView.layer.animate(from: maxBlur as NSNumber, to: 0.0 as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak expandedTextView] flag in
if flag, let expandedTextView {
expandedTextView.layer.filters = nil
}
})
} else {
blurFilter.setValue(maxBlur as NSNumber, forKey: "inputRadius")
expandedTextView.layer.filters = [blurFilter]
expandedTextView.layer.animate(from: 0.0 as NSNumber, to: maxBlur as NSNumber, keyPath: "filters.gaussianBlur.inputRadius", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2, removeOnCompletion: true)
}
}
}
}
transition.setPosition(view: expandedTextView, position: expandedTextFrame.origin)
expandedTextView.bounds = CGRect(origin: CGPoint(), size: expandedTextFrame.size)
alphaTransition.setAlpha(view: expandedTextView, alpha: component.isExpanded ? 1.0 : 0.0)
}
let expandedButtonOffset: CGFloat = component.isExpanded ? 0.0 : (expandedButtonBottomInset + expandedButtonTextSize.height + expandedButtonTopInset)
if self.expandedSeparatorLayer.superlayer == nil {
self.containerView.layer.addSublayer(self.expandedSeparatorLayer)
}
self.expandedSeparatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor
transition.setFrame(layer: self.expandedSeparatorLayer, frame: CGRect(origin: CGPoint(x: 0.0, y: backgroundSize.height - expandedButtonBottomInset - expandedButtonTextSize.height - expandedButtonTopInset + expandedButtonOffset), size: CGSize(width: backgroundSize.width, height: UIScreenPixel)))
alphaTransition.setAlpha(layer: self.expandedSeparatorLayer, alpha: component.isExpanded ? 1.0 : 0.0)
if let expandedButtonTextView = self.expandedButtonText.view {
if expandedButtonTextView.superview == nil {
self.containerView.addSubview(expandedButtonTextView)
}
transition.setFrame(view: expandedButtonTextView, frame: CGRect(origin: CGPoint(x: floor((backgroundSize.width - expandedButtonTextSize.width) * 0.5), y: backgroundSize.height - expandedButtonBottomInset - expandedButtonTextSize.height + expandedButtonOffset), size: expandedButtonTextSize))
alphaTransition.setAlpha(view: expandedButtonTextView, alpha: component.isExpanded ? 1.0 : 0.0)
}
transition.setFrame(view: self.containerView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: backgroundSize))
return CGSize(width: backgroundSize.width, height: collapsedSize.height)
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,137 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import BackButtonComponent
final class VideoChatExpandedControlsComponent: Component {
let theme: PresentationTheme
let strings: PresentationStrings
let isPinned: Bool
let backAction: () -> Void
let pinAction: () -> Void
init(
theme: PresentationTheme,
strings: PresentationStrings,
isPinned: Bool,
backAction: @escaping () -> Void,
pinAction: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.isPinned = isPinned
self.backAction = backAction
self.pinAction = pinAction
}
static func ==(lhs: VideoChatExpandedControlsComponent, rhs: VideoChatExpandedControlsComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.isPinned != rhs.isPinned {
return false
}
return true
}
final class View: UIView {
private let backButton = ComponentView<Empty>()
private let pinStatus = ComponentView<Empty>()
private var component: VideoChatExpandedControlsComponent?
private var isUpdating: Bool = false
private var ignoreScrolling: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let backButtonView = self.backButton.view, let result = backButtonView.hitTest(self.convert(point, to: backButtonView), with: event) {
return result
}
if let pinStatusView = self.pinStatus.view, let result = pinStatusView.hitTest(self.convert(point, to: pinStatusView), with: event) {
return result
}
return nil
}
func update(component: VideoChatExpandedControlsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let backButtonSize = self.backButton.update(
transition: transition,
component: AnyComponent(BackButtonComponent(
title: component.strings.Common_Back,
color: .white,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.backAction()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width * 0.5, height: 100.0)
)
let backButtonFrame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: backButtonSize)
if let backButtonView = self.backButton.view {
if backButtonView.superview == nil {
self.addSubview(backButtonView)
}
transition.setFrame(view: backButtonView, frame: backButtonFrame)
}
let pinStatusSize = self.pinStatus.update(
transition: transition,
component: AnyComponent(VideoChatPinStatusComponent(
theme: component.theme,
strings: component.strings,
isPinned: component.isPinned,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.pinAction()
}
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let pinStatusFrame = CGRect(origin: CGPoint(x: availableSize.width - 0.0 - pinStatusSize.width, y: 0.0), size: pinStatusSize)
if let pinStatusView = self.pinStatus.view {
if pinStatusView.superview == nil {
self.addSubview(pinStatusView)
}
transition.setFrame(view: pinStatusView, frame: pinStatusFrame)
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,771 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import TelegramCore
import AccountContext
import SwiftSignalKit
import MetalEngine
import CallScreen
import AvatarNode
import ContextUI
final class VideoChatParticipantThumbnailComponent: Component {
let call: VideoChatCall
let theme: PresentationTheme
let participant: GroupCallParticipantsContext.Participant
let isPresentation: Bool
let isSelected: Bool
let isSpeaking: Bool
let displayVideo: Bool
let interfaceOrientation: UIInterfaceOrientation
let action: (() -> Void)?
let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?
init(
call: VideoChatCall,
theme: PresentationTheme,
participant: GroupCallParticipantsContext.Participant,
isPresentation: Bool,
isSelected: Bool,
isSpeaking: Bool,
displayVideo: Bool,
interfaceOrientation: UIInterfaceOrientation,
action: (() -> Void)?,
contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?
) {
self.call = call
self.theme = theme
self.participant = participant
self.isPresentation = isPresentation
self.isSelected = isSelected
self.isSpeaking = isSpeaking
self.displayVideo = displayVideo
self.interfaceOrientation = interfaceOrientation
self.action = action
self.contextAction = contextAction
}
static func ==(lhs: VideoChatParticipantThumbnailComponent, rhs: VideoChatParticipantThumbnailComponent) -> Bool {
if lhs.call != rhs.call {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.participant != rhs.participant {
return false
}
if lhs.isPresentation != rhs.isPresentation {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.displayVideo != rhs.displayVideo {
return false
}
if lhs.interfaceOrientation != rhs.interfaceOrientation {
return false
}
if (lhs.action == nil) != (rhs.action == nil) {
return false
}
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
return false
}
return true
}
private struct VideoSpec: Equatable {
var resolution: CGSize
var rotationAngle: Float
var followsDeviceOrientation: Bool
init(resolution: CGSize, rotationAngle: Float, followsDeviceOrientation: Bool) {
self.resolution = resolution
self.rotationAngle = rotationAngle
self.followsDeviceOrientation = followsDeviceOrientation
}
}
final class View: ContextControllerSourceView {
private static let selectedBorderImage: UIImage? = {
return generateStretchableFilledCircleImage(diameter: 20.0, color: nil, strokeColor: UIColor.white, strokeWidth: 2.0)?.withRenderingMode(.alwaysTemplate)
}()
private var component: VideoChatParticipantThumbnailComponent?
private weak var componentState: EmptyComponentState?
private var isUpdating: Bool = false
private let extractedContainerView: ContextExtractedContentContainingView
private let backgroundLayer: SimpleLayer
private var avatarNode: AvatarNode?
private let title = ComponentView<Empty>()
private let muteStatus = ComponentView<Empty>()
private var selectedBorderView: UIImageView?
private var videoSource: AdaptedCallVideoSource?
private var videoDisposable: Disposable?
private var videoBackgroundLayer: SimpleLayer?
private var videoLayer: PrivateCallVideoLayer?
private var videoSpec: VideoSpec?
override init(frame: CGRect) {
self.extractedContainerView = ContextExtractedContentContainingView()
self.backgroundLayer = SimpleLayer()
self.backgroundLayer.backgroundColor = UIColor(rgb: 0x1C1C1E).cgColor
super.init(frame: frame)
self.addSubview(self.extractedContainerView)
self.targetViewForActivationProgress = self.extractedContainerView.contentView
self.extractedContainerView.contentView.layer.addSublayer(self.backgroundLayer)
self.extractedContainerView.contentView.clipsToBounds = true
self.extractedContainerView.contentView.layer.cornerRadius = 10.0
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
self.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
gesture.cancel()
return
}
if let participantPeer = component.participant.peer {
component.contextAction?(participantPeer, self.extractedContainerView, gesture)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.videoDisposable?.dispose()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
guard let component = self.component, let action = component.action else {
return
}
action()
}
}
func update(component: VideoChatParticipantThumbnailComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
let wasSpeaking = previousComponent?.isSpeaking ?? false
let speakingAlphaTransition: ComponentTransition
if transition.animation.isImmediate {
speakingAlphaTransition = .immediate
} else {
if let previousComponent, previousComponent.isSelected == component.isSelected {
if !wasSpeaking {
speakingAlphaTransition = .easeInOut(duration: 0.1)
} else {
speakingAlphaTransition = .easeInOut(duration: 0.25)
}
} else {
speakingAlphaTransition = .immediate
}
}
self.component = component
self.componentState = state
transition.setFrame(layer: self.backgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setPosition(view: self.extractedContainerView, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.extractedContainerView, bounds: CGRect(origin: CGPoint(), size: availableSize))
transition.setPosition(view: self.extractedContainerView.contentView, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.extractedContainerView.contentView, bounds: CGRect(origin: CGPoint(), size: availableSize))
self.extractedContainerView.contentRect = CGRect(origin: CGPoint(), size: availableSize)
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0))
avatarNode.isUserInteractionEnabled = false
self.avatarNode = avatarNode
self.extractedContainerView.contentView.addSubview(avatarNode.view)
}
let avatarSize = CGSize(width: 50.0, height: 50.0)
let avatarFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - avatarSize.width) * 0.5), y: 7.0), size: avatarSize)
transition.setFrame(view: avatarNode.view, frame: avatarFrame)
avatarNode.updateSize(size: avatarSize)
if component.participant.peer?.smallProfileImage != nil {
avatarNode.setPeerV2(context: component.call.accountContext, theme: component.theme, peer: component.participant.peer, displayDimensions: avatarSize)
} else {
avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: component.participant.peer, displayDimensions: avatarSize)
}
let muteStatusSize = self.muteStatus.update(
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
content: component.isPresentation ? .screenshare : .mute(isFilled: true, isMuted: component.participant.muteState != nil && !component.isSpeaking)
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: availableSize.width + 5.0 - muteStatusSize.width, y: availableSize.height + 5.0 - muteStatusSize.height), size: muteStatusSize)
if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View {
if muteStatusView.superview == nil {
self.extractedContainerView.contentView.addSubview(muteStatusView)
}
transition.setPosition(view: muteStatusView, position: muteStatusFrame.center)
transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size))
transition.setScale(view: muteStatusView, scale: 0.65)
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.participant.peer?.compactDisplayTitle ?? "User \(component.participant.id)", font: Font.semibold(13.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 6.0 * 2.0 - 12.0, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: 6.0, y: availableSize.height - 6.0 - titleSize.height), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
titleView.isUserInteractionEnabled = false
self.extractedContainerView.contentView.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
if component.displayVideo, let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription {
let videoBackgroundLayer: SimpleLayer
if let current = self.videoBackgroundLayer {
videoBackgroundLayer = current
} else {
videoBackgroundLayer = SimpleLayer()
videoBackgroundLayer.backgroundColor = UIColor(white: 0.1, alpha: 1.0).cgColor
self.videoBackgroundLayer = videoBackgroundLayer
self.extractedContainerView.contentView.layer.insertSublayer(videoBackgroundLayer, above: avatarNode.layer)
videoBackgroundLayer.isHidden = true
}
let videoLayer: PrivateCallVideoLayer
if let current = self.videoLayer {
videoLayer = current
} else {
videoLayer = PrivateCallVideoLayer(enableSharpening: false)
self.videoLayer = videoLayer
self.extractedContainerView.contentView.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer)
self.extractedContainerView.contentView.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer)
videoLayer.blurredLayer.opacity = 0.25
if let input = component.call.video(endpointId: videoDescription.endpointId) {
let videoSource = AdaptedCallVideoSource(videoStreamSignal: input)
self.videoSource = videoSource
self.videoDisposable?.dispose()
self.videoDisposable = videoSource.addOnUpdated { [weak self] in
guard let self, let videoSource = self.videoSource, let videoLayer = self.videoLayer else {
return
}
let videoOutput = videoSource.currentOutput
videoLayer.video = videoOutput
if let videoOutput {
let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle, followsDeviceOrientation: videoOutput.followsDeviceOrientation)
if self.videoSpec != videoSpec {
self.videoSpec = videoSpec
if !self.isUpdating {
self.componentState?.updated(transition: .immediate, isLocal: true)
}
}
} else {
if self.videoSpec != nil {
self.videoSpec = nil
if !self.isUpdating {
self.componentState?.updated(transition: .immediate, isLocal: true)
}
}
}
}
}
}
transition.setFrame(layer: videoBackgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize))
if let videoSpec = self.videoSpec {
videoBackgroundLayer.isHidden = component.isSelected
videoLayer.blurredLayer.isHidden = component.isSelected
videoLayer.isHidden = component.isSelected
let rotationAngle = resolveCallVideoRotationAngle(angle: videoSpec.rotationAngle, followsDeviceOrientation: videoSpec.followsDeviceOrientation, interfaceOrientation: component.interfaceOrientation)
var rotatedResolution = videoSpec.resolution
var videoIsRotated = false
if abs(rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne {
videoIsRotated = true
}
if videoIsRotated {
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
}
let videoSize = rotatedResolution.aspectFilled(availableSize)
let videoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - videoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - videoSize.height) * 0.5)), size: videoSize)
let blurredVideoSize = rotatedResolution.aspectFilled(availableSize)
let blurredVideoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - blurredVideoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - blurredVideoSize.height) * 0.5)), size: blurredVideoSize)
let videoResolution = rotatedResolution.aspectFitted(CGSize(width: availableSize.width * 3.0, height: availableSize.height * 3.0))
var rotatedVideoResolution = videoResolution
var rotatedVideoFrame = videoFrame
var rotatedBlurredVideoFrame = blurredVideoFrame
var rotatedVideoBoundsSize = videoFrame.size
var rotatedBlurredVideoBoundsSize = blurredVideoFrame.size
if videoIsRotated {
rotatedVideoBoundsSize = CGSize(width: rotatedVideoBoundsSize.height, height: rotatedVideoBoundsSize.width)
rotatedVideoFrame = rotatedVideoFrame.size.centered(around: rotatedVideoFrame.center)
rotatedBlurredVideoBoundsSize = CGSize(width: rotatedBlurredVideoBoundsSize.height, height: rotatedBlurredVideoBoundsSize.width)
rotatedBlurredVideoFrame = rotatedBlurredVideoFrame.size.centered(around: rotatedBlurredVideoFrame.center)
}
rotatedVideoResolution = rotatedVideoResolution.aspectFittedOrSmaller(CGSize(width: rotatedVideoFrame.width * UIScreenScale, height: rotatedVideoFrame.height * UIScreenScale))
transition.setPosition(layer: videoLayer, position: rotatedVideoFrame.center)
transition.setBounds(layer: videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoBoundsSize))
transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0))
videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2)
transition.setPosition(layer: videoLayer.blurredLayer, position: rotatedBlurredVideoFrame.center)
transition.setBounds(layer: videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedBlurredVideoBoundsSize))
transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0))
}
} else {
if let videoBackgroundLayer = self.videoBackgroundLayer {
self.videoBackgroundLayer = nil
videoBackgroundLayer.removeFromSuperlayer()
}
if let videoLayer = self.videoLayer {
self.videoLayer = nil
videoLayer.blurredLayer.removeFromSuperlayer()
videoLayer.removeFromSuperlayer()
}
self.videoDisposable?.dispose()
self.videoDisposable = nil
self.videoSource = nil
self.videoSpec = nil
}
if component.isSelected || component.isSpeaking {
let selectedBorderView: UIImageView
if let current = self.selectedBorderView {
selectedBorderView = current
speakingAlphaTransition.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
} else {
selectedBorderView = UIImageView()
self.selectedBorderView = selectedBorderView
selectedBorderView.alpha = 0.0
self.extractedContainerView.contentView.addSubview(selectedBorderView)
selectedBorderView.image = View.selectedBorderImage
selectedBorderView.frame = CGRect(origin: CGPoint(), size: availableSize)
speakingAlphaTransition.setAlpha(view: selectedBorderView, alpha: 1.0)
ComponentTransition.immediate.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
} else if let selectedBorderView = self.selectedBorderView {
if !speakingAlphaTransition.animation.isImmediate {
if selectedBorderView.alpha != 0.0 {
speakingAlphaTransition.setAlpha(view: selectedBorderView, alpha: 0.0, completion: { [weak self, weak selectedBorderView] completed in
guard let self, let component = self.component, let selectedBorderView, self.selectedBorderView === selectedBorderView, completed else {
return
}
if !component.isSelected && !component.isSpeaking {
selectedBorderView.removeFromSuperview()
self.selectedBorderView = nil
}
})
}
} else {
self.selectedBorderView = nil
selectedBorderView.removeFromSuperview()
}
}
if let selectedBorderView = self.selectedBorderView {
transition.setFrame(view: selectedBorderView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class VideoChatExpandedParticipantThumbnailsComponent: Component {
final class Participant: Equatable {
struct Key: Hashable {
var id: GroupCallParticipantsContext.Participant.Id
var isPresentation: Bool
init(id: GroupCallParticipantsContext.Participant.Id, isPresentation: Bool) {
self.id = id
self.isPresentation = isPresentation
}
}
let participant: GroupCallParticipantsContext.Participant
let isPresentation: Bool
var key: Key {
return Key(id: self.participant.id, isPresentation: self.isPresentation)
}
init(
participant: GroupCallParticipantsContext.Participant,
isPresentation: Bool
) {
self.participant = participant
self.isPresentation = isPresentation
}
static func ==(lhs: Participant, rhs: Participant) -> Bool {
if lhs === rhs {
return true
}
if lhs.participant != rhs.participant {
return false
}
if lhs.isPresentation != rhs.isPresentation {
return false
}
return true
}
}
let call: VideoChatCall
let theme: PresentationTheme
let displayVideo: Bool
let participants: [Participant]
let selectedParticipant: Participant.Key?
let speakingParticipants: Set<EnginePeer.Id>
let interfaceOrientation: UIInterfaceOrientation
let updateSelectedParticipant: (Participant.Key) -> Void
let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?
init(
call: VideoChatCall,
theme: PresentationTheme,
displayVideo: Bool,
participants: [Participant],
selectedParticipant: Participant.Key?,
speakingParticipants: Set<EnginePeer.Id>,
interfaceOrientation: UIInterfaceOrientation,
updateSelectedParticipant: @escaping (Participant.Key) -> Void,
contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?
) {
self.call = call
self.theme = theme
self.displayVideo = displayVideo
self.participants = participants
self.selectedParticipant = selectedParticipant
self.speakingParticipants = speakingParticipants
self.interfaceOrientation = interfaceOrientation
self.updateSelectedParticipant = updateSelectedParticipant
self.contextAction = contextAction
}
static func ==(lhs: VideoChatExpandedParticipantThumbnailsComponent, rhs: VideoChatExpandedParticipantThumbnailsComponent) -> Bool {
if lhs.call != rhs.call {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.displayVideo != rhs.displayVideo {
return false
}
if lhs.participants != rhs.participants {
return false
}
if lhs.selectedParticipant != rhs.selectedParticipant {
return false
}
if lhs.speakingParticipants != rhs.speakingParticipants {
return false
}
if lhs.interfaceOrientation != rhs.interfaceOrientation {
return false
}
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private struct ItemLayout {
let containerSize: CGSize
let containerInsets: UIEdgeInsets
let itemCount: Int
let itemSize: CGSize
let itemSpacing: CGFloat
let contentSize: CGSize
init(containerSize: CGSize, containerInsets: UIEdgeInsets, itemCount: Int) {
self.containerSize = containerSize
self.containerInsets = containerInsets
self.itemCount = itemCount
self.itemSize = CGSize(width: 84.0, height: 84.0)
self.itemSpacing = 6.0
let itemsWidth: CGFloat = CGFloat(itemCount) * self.itemSize.width + CGFloat(max(itemCount - 1, 0)) * self.itemSpacing
self.contentSize = CGSize(width: self.containerInsets.left + self.containerInsets.right + itemsWidth, height: self.containerInsets.top + self.containerInsets.bottom + self.itemSize.height)
}
func frame(at index: Int) -> CGRect {
let frame = CGRect(origin: CGPoint(x: self.containerInsets.left + CGFloat(index) * (self.itemSize.width + self.itemSpacing), y: self.containerInsets.top), size: self.itemSize)
return frame
}
func visibleItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) {
if self.itemCount == 0 {
return (0, -1)
}
let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: 0.0)
var minVisibleRow = Int(floor((offsetRect.minX) / (self.itemSize.width + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxX) / (self.itemSize.width + self.itemSpacing)))
let minVisibleIndex = minVisibleRow
let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) - 1)
return (minVisibleIndex, maxVisibleIndex)
}
}
private final class VisibleItem {
let view = ComponentView<Empty>()
init() {
}
}
final class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollView
private var component: VideoChatExpandedParticipantThumbnailsComponent?
private var isUpdating: Bool = false
private var ignoreScrolling: Bool = false
private var itemLayout: ItemLayout?
private var visibleItems: [Participant.Key: VisibleItem] = [:]
override init(frame: CGRect) {
self.scrollView = ScrollView()
super.init(frame: frame)
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
self.scrollView.contentInsetAdjustmentBehavior = .never
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: ComponentTransition) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
var validListItemIds: [Participant.Key] = []
let visibleListItemRange = itemLayout.visibleItemRange(for: self.scrollView.bounds)
if visibleListItemRange.maxIndex >= visibleListItemRange.minIndex {
for i in visibleListItemRange.minIndex ... visibleListItemRange.maxIndex {
let participant = component.participants[i]
validListItemIds.append(participant.key)
var itemTransition = transition
let itemView: VisibleItem
if let current = self.visibleItems[participant.key] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = VisibleItem()
self.visibleItems[participant.key] = itemView
}
let itemFrame = itemLayout.frame(at: i)
let participantKey = participant.key
var isSpeaking = false
if let participantPeer = participant.participant.peer {
isSpeaking = component.speakingParticipants.contains(participantPeer.id)
}
let _ = itemView.view.update(
transition: itemTransition,
component: AnyComponent(VideoChatParticipantThumbnailComponent(
call: component.call,
theme: component.theme,
participant: participant.participant,
isPresentation: participant.isPresentation,
isSelected: component.selectedParticipant == participant.key,
isSpeaking: isSpeaking,
displayVideo: component.displayVideo,
interfaceOrientation: component.interfaceOrientation,
action: { [weak self] in
guard let self, let component = self.component else {
return
}
component.updateSelectedParticipant(participantKey)
},
contextAction: component.contextAction
)),
environment: {},
containerSize: itemFrame.size
)
if let itemComponentView = itemView.view.view {
if itemComponentView.superview == nil {
itemComponentView.clipsToBounds = true
self.scrollView.addSubview(itemComponentView)
if !transition.animation.isImmediate {
itemComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
transition.animateScale(view: itemComponentView, from: 0.001, to: 1.0)
}
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
}
}
var removedListItemIds: [Participant.Key] = []
for (itemId, itemView) in self.visibleItems {
if !validListItemIds.contains(itemId) {
removedListItemIds.append(itemId)
if let itemComponentView = itemView.view.view {
if !transition.animation.isImmediate {
transition.setScale(view: itemComponentView, scale: 0.001)
itemComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemComponentView] _ in
itemComponentView?.removeFromSuperview()
})
} else {
itemComponentView.removeFromSuperview()
}
}
}
}
for itemId in removedListItemIds {
self.visibleItems.removeValue(forKey: itemId)
}
}
func update(component: VideoChatExpandedParticipantThumbnailsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let itemLayout = ItemLayout(
containerSize: availableSize,
containerInsets: UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0),
itemCount: component.participants.count
)
self.itemLayout = itemLayout
let size = CGSize(width: availableSize.width, height: itemLayout.contentSize.height)
self.ignoreScrolling = true
let previousOffsetX = self.scrollView.bounds.minX
if self.scrollView.bounds.size != size {
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: size))
}
let contentSize = CGSize(width: itemLayout.contentSize.width, height: size.height)
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
if self.scrollView.isDragging {
self.scrollView.bounds.origin.x = previousOffsetX
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,185 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import AvatarNode
import TelegramPresentationData
import AccountContext
import TelegramCore
import Markdown
import TextFormat
final class VideoChatExpandedSpeakingToastComponent: Component {
let context: AccountContext
let peer: EnginePeer
let strings: PresentationStrings
let theme: PresentationTheme
let action: (EnginePeer) -> Void
init(
context: AccountContext,
peer: EnginePeer,
strings: PresentationStrings,
theme: PresentationTheme,
action: @escaping (EnginePeer) -> Void
) {
self.context = context
self.peer = peer
self.strings = strings
self.theme = theme
self.action = action
}
static func ==(lhs: VideoChatExpandedSpeakingToastComponent, rhs: VideoChatExpandedSpeakingToastComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let background = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private var avatarNode: AvatarNode?
private var component: VideoChatExpandedSpeakingToastComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
if let component = self.component {
component.action(component.peer)
}
}
func update(component: VideoChatExpandedSpeakingToastComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let avatarLeftInset: CGFloat = 3.0
let avatarVerticalInset: CGFloat = 3.0
let avatarSpacing: CGFloat = 12.0
let rightInset: CGFloat = 16.0
let avatarWidth: CGFloat = 32.0
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 })
let bodyAttributes = MarkdownAttributeSet(font: Font.regular(15.0), textColor: .white, additionalAttributes: [:])
let boldAttributes = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: .white, additionalAttributes: [:])
let titleText = addAttributesToStringWithRanges(component.strings.VoiceChat_ParticipantIsSpeaking(component.peer.displayTitle(strings: component.strings, displayOrder: presentationData.nameDisplayOrder))._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes])
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(titleText)
)),
environment: {},
containerSize: CGSize(width: availableSize.width - avatarLeftInset - avatarWidth - avatarSpacing - rightInset, height: 100.0)
)
let size = CGSize(width: avatarLeftInset + avatarWidth + avatarSpacing + titleSize.width + rightInset, height: avatarWidth + avatarVerticalInset * 2.0)
let _ = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(
color: UIColor(white: 0.0, alpha: 0.9),
cornerRadius: .value(size.height * 0.5),
smoothCorners: false
)),
environment: {},
containerSize: size
)
let backgroundFrame = CGRect(origin: CGPoint(), size: size)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
backgroundView.isUserInteractionEnabled = false
self.addSubview(backgroundView)
}
transition.setFrame(view: backgroundView, frame: backgroundFrame)
}
let titleFrame = CGRect(origin: CGPoint(x: avatarLeftInset + avatarWidth + avatarSpacing, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
titleView.layer.anchorPoint = CGPoint()
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0))
self.avatarNode = avatarNode
self.addSubview(avatarNode.view)
avatarNode.isUserInteractionEnabled = false
}
let avatarSize = CGSize(width: avatarWidth, height: avatarWidth)
let clipStyle: AvatarNodeClipStyle
if case let .channel(channel) = component.peer, channel.isForumOrMonoForum {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
if component.peer.smallProfileImage != nil {
avatarNode.setPeerV2(
context: component.context,
theme: component.theme,
peer: component.peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: false,
displayDimensions: avatarSize
)
} else {
avatarNode.setPeer(context: component.context, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize)
}
let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: avatarVerticalInset), size: avatarSize)
transition.setPosition(view: avatarNode.view, position: avatarFrame.center)
transition.setBounds(view: avatarNode.view, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
avatarNode.updateSize(size: avatarSize)
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,194 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import BundleIconComponent
final class VideoChatListInviteComponent: Component {
enum Icon {
case addUser
case link
}
let title: String
let icon: Icon
let theme: PresentationTheme
let hasNext: Bool
let action: () -> Void
init(
title: String,
icon: Icon,
theme: PresentationTheme,
hasNext: Bool,
action: @escaping () -> Void
) {
self.title = title
self.icon = icon
self.theme = theme
self.hasNext = hasNext
self.action = action
}
static func ==(lhs: VideoChatListInviteComponent, rhs: VideoChatListInviteComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.theme !== rhs.theme {
return false
}
if lhs.hasNext != rhs.hasNext {
return false
}
return true
}
final class View: HighlightTrackingButton {
private let icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private var component: VideoChatListInviteComponent?
private var isUpdating: Bool = false
private var highlightBackgroundLayer: SimpleLayer?
private var highlightBackgroundFrame: CGRect?
private let separatorLayer: SimpleLayer
override init(frame: CGRect) {
self.separatorLayer = SimpleLayer()
super.init(frame: frame)
self.highligthedChanged = { [weak self] isHighlighted in
guard let self, let component = self.component, let highlightBackgroundFrame = self.highlightBackgroundFrame else {
return
}
if isHighlighted {
self.superview?.bringSubviewToFront(self)
let highlightBackgroundLayer: SimpleLayer
if let current = self.highlightBackgroundLayer {
highlightBackgroundLayer = current
} else {
highlightBackgroundLayer = SimpleLayer()
self.highlightBackgroundLayer = highlightBackgroundLayer
self.layer.insertSublayer(highlightBackgroundLayer, at: 0)
highlightBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor
}
highlightBackgroundLayer.frame = highlightBackgroundFrame
highlightBackgroundLayer.opacity = 1.0
if component.hasNext {
highlightBackgroundLayer.maskedCorners = []
highlightBackgroundLayer.masksToBounds = false
highlightBackgroundLayer.cornerRadius = 0.0
} else {
highlightBackgroundLayer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
highlightBackgroundLayer.masksToBounds = true
highlightBackgroundLayer.cornerRadius = 10.0
}
} else {
if let highlightBackgroundLayer = self.highlightBackgroundLayer {
self.highlightBackgroundLayer = nil
highlightBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak highlightBackgroundLayer] _ in
highlightBackgroundLayer?.removeFromSuperlayer()
})
}
}
}
self.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action()
}
func update(component: VideoChatListInviteComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.list.itemAccentColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 62.0 - 8.0, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: 46.0)
let titleFrame = CGRect(origin: CGPoint(x: 62.0, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
titleView.layer.anchorPoint = CGPoint()
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
let iconName: String
switch component.icon {
case .addUser:
iconName = "Chat/Context Menu/AddUser"
case .link:
iconName = "Chat/Context Menu/Link"
}
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: iconName,
tintColor: component.theme.list.itemAccentColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((62.0 - iconSize.width) * 0.5), y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
iconView.isUserInteractionEnabled = false
self.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: iconFrame)
}
self.highlightBackgroundFrame = CGRect(origin: CGPoint(), size: size)
if self.separatorLayer.superlayer == nil {
self.layer.addSublayer(self.separatorLayer)
}
self.separatorLayer.backgroundColor = component.theme.list.itemBlocksSeparatorColor.cgColor
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: 62.0, y: size.height), size: CGSize(width: size.width - 62.0, height: UIScreenPixel)))
self.separatorLayer.isHidden = !component.hasNext
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,146 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import AppBundle
import LottieComponent
import BundleIconComponent
final class VideoChatMuteIconComponent: Component {
enum Content: Equatable {
case mute(isFilled: Bool, isMuted: Bool)
case screenshare
}
let color: UIColor
let content: Content
let shadowColor: UIColor?
let shadowBlur: CGFloat
init(
color: UIColor,
content: Content,
shadowColor: UIColor? = nil,
shadowBlur: CGFloat = 0.0
) {
self.color = color
self.content = content
self.shadowColor = shadowColor
self.shadowBlur = shadowBlur
}
static func ==(lhs: VideoChatMuteIconComponent, rhs: VideoChatMuteIconComponent) -> Bool {
if lhs.color != rhs.color {
return false
}
if lhs.content != rhs.content {
return false
}
if lhs.shadowColor != rhs.shadowColor {
return false
}
if lhs.shadowBlur != rhs.shadowBlur {
return false
}
return true
}
final class View: HighlightTrackingButton {
private var icon: VoiceChatMicrophoneNode?
private var scheenshareIcon: ComponentView<Empty>?
private var component: VideoChatMuteIconComponent?
private var isUpdating: Bool = false
private var contentImage: UIImage?
var iconView: UIView? {
return self.icon?.view
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(component: VideoChatMuteIconComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
if case let .mute(isFilled, isMuted) = component.content {
let icon: VoiceChatMicrophoneNode
if let current = self.icon {
icon = current
} else {
icon = VoiceChatMicrophoneNode()
self.icon = icon
self.addSubview(icon.view)
}
let animationSize = availableSize
let animationFrame = animationSize.centered(in: CGRect(origin: CGPoint(), size: availableSize)).insetBy(dx: -component.shadowBlur, dy: -component.shadowBlur)
transition.setFrame(view: icon.view, frame: animationFrame)
icon.update(state: VoiceChatMicrophoneNode.State(muted: isMuted, filled: isFilled, color: component.color, shadowColor: component.shadowColor, shadowBlur: component.shadowBlur), animated: !transition.animation.isImmediate)
} else {
if let icon = self.icon {
self.icon = nil
icon.view.removeFromSuperview()
}
}
if case .screenshare = component.content {
let scheenshareIcon: ComponentView<Empty>
if let current = self.scheenshareIcon {
scheenshareIcon = current
} else {
scheenshareIcon = ComponentView()
self.scheenshareIcon = scheenshareIcon
}
let scheenshareIconSize = scheenshareIcon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(
name: "Call/StatusScreen",
tintColor: component.color,
shadowColor: component.shadowColor,
shadowBlur: component.shadowBlur
)),
environment: {},
containerSize: availableSize
)
let scheenshareIconFrame = scheenshareIconSize.centered(in: CGRect(origin: CGPoint(), size: availableSize))
if let scheenshareIconView = scheenshareIcon.view {
if scheenshareIconView.superview == nil {
self.addSubview(scheenshareIconView)
}
transition.setPosition(view: scheenshareIconView, position: scheenshareIconFrame.center)
transition.setBounds(view: scheenshareIconView, bounds: CGRect(origin: CGPoint(), size: scheenshareIconFrame.size))
transition.setScale(view: scheenshareIconView, scale: 1.5)
}
} else {
if let scheenshareIcon = self.scheenshareIcon {
self.scheenshareIcon = nil
scheenshareIcon.view?.removeFromSuperview()
}
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,414 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import AccountContext
import AvatarNode
import VoiceChatActionButton
import CallScreen
import MetalEngine
import SwiftSignalKit
private final class BlobView: UIView {
let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
}
}
private(set) var isAnimating = false
public typealias BlobRange = (min: CGFloat, max: CGFloat)
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat,
mediumBlobRange: BlobRange,
bigBlobRange: BlobRange
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let self else {
return
}
if !self.isCurrentlyInHierarchy {
return
}
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1
self.updateAudioLevel()
}
updateInHierarchy = { [weak self] value in
guard let self else {
return
}
self.isCurrentlyInHierarchy = value
if value {
self.startAnimating()
} else {
self.stopAnimating()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 0.3, 1.0)) * 1.0)
let blobScale = 1.28 + additionalAvatarScale
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(additionalAvatarScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.displayLinkAnimator?.isPaused = true
}
func update(size: CGSize) {
super.layoutSubviews()
let blobsFrame = CGRect(origin: CGPoint(), size: size)
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
}
}
final class VideoChatParticipantAvatarComponent: Component {
let call: VideoChatCall
let peer: EnginePeer?
let myPeerId: EnginePeer.Id
let isSpeaking: Bool
let theme: PresentationTheme
init(
call: VideoChatCall,
peer: EnginePeer?,
myPeerId: EnginePeer.Id,
isSpeaking: Bool,
theme: PresentationTheme
) {
self.call = call
self.peer = peer
self.myPeerId = myPeerId
self.isSpeaking = isSpeaking
self.theme = theme
}
static func ==(lhs: VideoChatParticipantAvatarComponent, rhs: VideoChatParticipantAvatarComponent) -> Bool {
if lhs.call != rhs.call {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.myPeerId != rhs.myPeerId {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private var component: VideoChatParticipantAvatarComponent?
private var isUpdating: Bool = false
private var avatarNode: AvatarNode?
private var blobView: BlobView?
private var audioLevelDisposable: Disposable?
private var wasSpeaking: Bool?
private var noAudioTimer: Foundation.Timer?
private var lastAudioLevelTimestamp: Double = 0.0
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.audioLevelDisposable?.dispose()
self.noAudioTimer?.invalidate()
}
private func checkNoAudio() {
let timestamp = CFAbsoluteTimeGetCurrent()
if self.lastAudioLevelTimestamp + 1.0 < timestamp {
self.noAudioTimer?.invalidate()
self.noAudioTimer = nil
if let blobView = self.blobView {
let transition: ComponentTransition = .easeInOut(duration: 0.3)
transition.setAlpha(view: blobView, alpha: 0.0, completion: { [weak self, weak blobView] completed in
guard let self, let blobView, completed else {
return
}
if self.blobView === blobView {
self.blobView = nil
}
blobView.removeFromSuperview()
})
transition.setScale(layer: blobView.layer, scale: 0.5)
if let avatarNode = self.avatarNode {
transition.setScale(view: avatarNode.view, scale: 1.0)
}
}
}
}
func update(component: VideoChatParticipantAvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.isUserInteractionEnabled = false
let previousComponent = self.component
self.component = component
if let previousComponent, previousComponent.call != component.call {
self.audioLevelDisposable?.dispose()
self.audioLevelDisposable = nil
}
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0))
self.avatarNode = avatarNode
self.addSubview(avatarNode.view)
}
let avatarSize = availableSize
let clipStyle: AvatarNodeClipStyle
if case let .channel(channel) = component.peer, channel.isForumOrMonoForum {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
if let blobView = self.blobView {
let tintTransition: ComponentTransition
if let previousComponent, previousComponent.isSpeaking != component.isSpeaking {
if component.isSpeaking {
tintTransition = .easeInOut(duration: 0.15)
} else {
tintTransition = .easeInOut(duration: 0.25)
}
} else {
tintTransition = .immediate
}
tintTransition.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
if component.peer?.smallProfileImage != nil {
avatarNode.setPeerV2(
context: component.call.accountContext,
theme: component.theme,
peer: component.peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: false,
displayDimensions: avatarSize
)
} else {
avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize)
}
let avatarFrame = CGRect(origin: CGPoint(), size: avatarSize)
transition.setPosition(view: avatarNode.view, position: avatarFrame.center)
transition.setBounds(view: avatarNode.view, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
avatarNode.updateSize(size: avatarSize)
let blobScale: CGFloat = 2.0
if self.audioLevelDisposable == nil {
struct Level {
var value: Float
var isSpeaking: Bool
}
let peerId = component.peer?.id
let levelSignal: Signal<Level?, NoError>
if peerId == component.myPeerId {
levelSignal = component.call.myAudioLevelAndSpeaking
|> map { value, isSpeaking -> Level? in
if value == 0.0 {
return nil
} else {
return Level(value: value, isSpeaking: isSpeaking)
}
}
} else {
levelSignal = component.call.audioLevels
|> map { levels -> Level? in
for level in levels {
if level.0 == peerId {
return Level(value: level.2, isSpeaking: level.3)
}
}
return nil
}
}
self.audioLevelDisposable = (levelSignal
|> distinctUntilChanged(isEqual: { lhs, rhs in
if (lhs == nil) != (rhs == nil) {
return false
}
if lhs != nil {
return false
} else {
return true
}
})
|> deliverOnMainQueue).startStrict(next: { [weak self] level in
guard let self, let component = self.component, let avatarNode = self.avatarNode else {
return
}
if let level, level.value >= 0.1 {
self.lastAudioLevelTimestamp = CFAbsoluteTimeGetCurrent()
let blobView: BlobView
if let current = self.blobView {
blobView = current
} else {
self.wasSpeaking = nil
blobView = BlobView(
frame: avatarNode.frame,
maxLevel: 1.5,
mediumBlobRange: (0.69, 0.87),
bigBlobRange: (0.71, 1.0)
)
self.blobView = blobView
let blobSize = floor(avatarNode.bounds.width * blobScale)
blobView.center = avatarNode.frame.center
blobView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: blobSize, height: blobSize))
blobView.layer.transform = CATransform3DMakeScale(1.0 / blobScale, 1.0 / blobScale, 1.0)
blobView.update(size: blobView.bounds.size)
self.insertSubview(blobView, belowSubview: avatarNode.view)
blobView.layer.animateScale(from: 0.5, to: 1.0 / blobScale, duration: 0.2)
blobView.scaleUpdated = { [weak self] additionalScale in
guard let self, let avatarNode = self.avatarNode else {
return
}
avatarNode.layer.transform = CATransform3DMakeScale(1.0 + additionalScale, 1.0 + additionalScale, 1.0)
}
ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
if blobView.alpha == 0.0 {
let transition: ComponentTransition = .easeInOut(duration: 0.3)
transition.setAlpha(view: blobView, alpha: 1.0)
transition.setScale(view: blobView, scale: 1.0 / blobScale)
}
blobView.updateLevel(CGFloat(level.value), immediately: false)
if let noAudioTimer = self.noAudioTimer {
self.noAudioTimer = nil
noAudioTimer.invalidate()
}
} else {
if let blobView = self.blobView {
blobView.updateLevel(0.0, immediately: false)
}
}
if self.noAudioTimer == nil {
self.noAudioTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true, block: { [weak self] _ in
guard let self else {
return
}
self.checkNoAudio()
})
}
})
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,296 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import LottieComponent
import BundleIconComponent
final class VideoChatParticipantStatusComponent: Component {
let muteState: GroupCallParticipantsContext.Participant.MuteState?
let hasRaiseHand: Bool
let isSpeaking: Bool
let theme: PresentationTheme
init(
muteState: GroupCallParticipantsContext.Participant.MuteState?,
hasRaiseHand: Bool,
isSpeaking: Bool,
theme: PresentationTheme
) {
self.muteState = muteState
self.hasRaiseHand = hasRaiseHand
self.isSpeaking = isSpeaking
self.theme = theme
}
static func ==(lhs: VideoChatParticipantStatusComponent, rhs: VideoChatParticipantStatusComponent) -> Bool {
if lhs.muteState != rhs.muteState {
return false
}
if lhs.hasRaiseHand != rhs.hasRaiseHand {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private var muteStatus: ComponentView<Empty>?
private var raiseHandStatus: ComponentView<Empty>?
private var component: VideoChatParticipantStatusComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: VideoChatParticipantStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let alphaTransition: ComponentTransition
if !transition.animation.isImmediate {
alphaTransition = .easeInOut(duration: 0.2)
} else {
alphaTransition = .immediate
}
let size = CGSize(width: 44.0, height: 44.0)
let isRaiseHand: Bool
if let muteState = component.muteState {
if muteState.canUnmute {
isRaiseHand = false
} else {
isRaiseHand = component.hasRaiseHand
}
} else {
isRaiseHand = false
}
if !isRaiseHand {
let muteStatus: ComponentView<Empty>
var muteStatusTransition = transition
if let current = self.muteStatus {
muteStatus = current
} else {
muteStatusTransition = muteStatusTransition.withAnimation(.none)
muteStatus = ComponentView()
self.muteStatus = muteStatus
}
let muteStatusSize = muteStatus.update(
transition: muteStatusTransition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking)
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize)
if let muteStatusView = muteStatus.view as? VideoChatMuteIconComponent.View {
var animateIn = false
if muteStatusView.superview == nil {
animateIn = true
self.addSubview(muteStatusView)
}
muteStatusTransition.setFrame(view: muteStatusView, frame: muteStatusFrame)
let tintTransition: ComponentTransition
if !muteStatusTransition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
} else {
tintTransition = .immediate
}
if let iconView = muteStatusView.iconView {
let iconTintColor: UIColor
if component.isSpeaking {
iconTintColor = UIColor(rgb: 0x33C758)
} else {
if let muteState = component.muteState {
if muteState.canUnmute {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
} else {
iconTintColor = UIColor(rgb: 0xFF3B30)
}
} else {
iconTintColor = UIColor(white: 1.0, alpha: 0.4)
}
}
tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor)
}
if animateIn, !transition.animation.isImmediate {
transition.animateScale(view: muteStatusView, from: 0.001, to: 1.0)
alphaTransition.animateAlpha(view: muteStatusView, from: 0.0, to: 1.0)
}
}
} else if let muteStatus = self.muteStatus {
self.muteStatus = nil
if let muteStatusView = muteStatus.view {
if !transition.animation.isImmediate {
transition.setScale(view: muteStatusView, scale: 0.001)
alphaTransition.setAlpha(view: muteStatusView, alpha: 0.0, completion: { [weak muteStatusView] _ in
muteStatusView?.removeFromSuperview()
})
} else {
muteStatusView.removeFromSuperview()
}
}
}
if isRaiseHand {
let raiseHandStatus: ComponentView<Empty>
var raiseHandStatusTransition = transition
if let current = self.raiseHandStatus {
raiseHandStatus = current
} else {
raiseHandStatusTransition = raiseHandStatusTransition.withAnimation(.none)
raiseHandStatus = ComponentView()
self.raiseHandStatus = raiseHandStatus
}
let raiseHandStatusSize = raiseHandStatus.update(
transition: raiseHandStatusTransition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: "anim_hand1"
),
color: component.theme.list.itemAccentColor,
size: CGSize(width: 48.0, height: 48.0)
)),
environment: {},
containerSize: CGSize(width: 48.0, height: 48.0)
)
let raiseHandStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - raiseHandStatusSize.width) * 0.5) - 2.0, y: floor((size.height - raiseHandStatusSize.height) * 0.5)), size: raiseHandStatusSize)
if let raiseHandStatusView = raiseHandStatus.view {
var animateIn = false
if raiseHandStatusView.superview == nil {
animateIn = true
self.addSubview(raiseHandStatusView)
}
raiseHandStatusTransition.setFrame(view: raiseHandStatusView, frame: raiseHandStatusFrame)
if animateIn, !transition.animation.isImmediate {
transition.animateScale(view: raiseHandStatusView, from: 0.001, to: 1.0)
alphaTransition.animateAlpha(view: raiseHandStatusView, from: 0.0, to: 1.0)
}
}
} else if let raiseHandStatus = self.raiseHandStatus {
self.raiseHandStatus = nil
if let raiseHandStatusView = raiseHandStatus.view {
if !transition.animation.isImmediate {
transition.setScale(view: raiseHandStatusView, scale: 0.001)
alphaTransition.setAlpha(view: raiseHandStatusView, alpha: 0.0, completion: { [weak raiseHandStatusView] _ in
raiseHandStatusView?.removeFromSuperview()
})
} else {
raiseHandStatusView.removeFromSuperview()
}
}
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
final class VideoChatParticipantInvitedStatusComponent: Component {
let theme: PresentationTheme
init(theme: PresentationTheme) {
self.theme = theme
}
static func ==(lhs: VideoChatParticipantInvitedStatusComponent, rhs: VideoChatParticipantInvitedStatusComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private var icon = ComponentView<Empty>()
private var component: VideoChatParticipantInvitedStatusComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: VideoChatParticipantInvitedStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let size = CGSize(width: 44.0, height: 44.0)
let iconTintColor = UIColor(white: 1.0, alpha: 0.4)
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(
name: "Call/GroupPeerInvited",
tintColor: iconTintColor
)),
environment: {},
containerSize: size
)
let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5) - 2.0, y: floor((size.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: iconFrame)
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,763 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import BundleIconComponent
import MetalEngine
import CallScreen
import TelegramCore
import AccountContext
import SwiftSignalKit
import DirectMediaImageCache
import FastBlur
import ContextUI
import ComponentDisplayAdapters
import AvatarNode
private func blurredAvatarImage(_ dataImage: UIImage) -> UIImage? {
let imageContextSize = CGSize(width: 64.0, height: 64.0)
if let imageContext = DrawingContext(size: imageContextSize, scale: 1.0, clear: true) {
imageContext.withFlippedContext { c in
if let cgImage = dataImage.cgImage {
c.draw(cgImage, in: CGRect(origin: CGPoint(), size: imageContextSize))
}
}
telegramFastBlurMore(Int32(imageContext.size.width * imageContext.scale), Int32(imageContext.size.height * imageContext.scale), Int32(imageContext.bytesPerRow), imageContext.bytes)
return imageContext.generateImage()
} else {
return nil
}
}
private let activityBorderImage: UIImage = {
return generateStretchableFilledCircleImage(diameter: 32.0, color: nil, strokeColor: .white, strokeWidth: 2.0)!.withRenderingMode(.alwaysTemplate)
}()
final class VideoChatParticipantVideoComponent: Component {
let theme: PresentationTheme
let strings: PresentationStrings
let call: VideoChatCall
let participant: GroupCallParticipantsContext.Participant
let isMyPeer: Bool
let isPresentation: Bool
let isSpeaking: Bool
let maxVideoQuality: Int
let isExpanded: Bool
let isUIHidden: Bool
let contentInsets: UIEdgeInsets
let controlInsets: UIEdgeInsets
let interfaceOrientation: UIInterfaceOrientation
let enableVideoSharpening: Bool
let action: (() -> Void)?
let contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?
let activatePinch: ((PinchSourceContainerNode) -> Void)?
let deactivatedPinch: (() -> Void)?
init(
theme: PresentationTheme,
strings: PresentationStrings,
call: VideoChatCall,
participant: GroupCallParticipantsContext.Participant,
isMyPeer: Bool,
isPresentation: Bool,
isSpeaking: Bool,
maxVideoQuality: Int,
isExpanded: Bool,
isUIHidden: Bool,
contentInsets: UIEdgeInsets,
controlInsets: UIEdgeInsets,
interfaceOrientation: UIInterfaceOrientation,
enableVideoSharpening: Bool,
action: (() -> Void)?,
contextAction: ((EnginePeer, ContextExtractedContentContainingView, ContextGesture) -> Void)?,
activatePinch: ((PinchSourceContainerNode) -> Void)?,
deactivatedPinch: (() -> Void)?
) {
self.theme = theme
self.strings = strings
self.call = call
self.participant = participant
self.isMyPeer = isMyPeer
self.isPresentation = isPresentation
self.isSpeaking = isSpeaking
self.maxVideoQuality = maxVideoQuality
self.isExpanded = isExpanded
self.isUIHidden = isUIHidden
self.contentInsets = contentInsets
self.controlInsets = controlInsets
self.interfaceOrientation = interfaceOrientation
self.enableVideoSharpening = enableVideoSharpening
self.action = action
self.contextAction = contextAction
self.activatePinch = activatePinch
self.deactivatedPinch = deactivatedPinch
}
static func ==(lhs: VideoChatParticipantVideoComponent, rhs: VideoChatParticipantVideoComponent) -> Bool {
if lhs.call != rhs.call {
return false
}
if lhs.participant != rhs.participant {
return false
}
if lhs.isMyPeer != rhs.isMyPeer {
return false
}
if lhs.isPresentation != rhs.isPresentation {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.maxVideoQuality != rhs.maxVideoQuality {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
if lhs.isUIHidden != rhs.isUIHidden {
return false
}
if lhs.contentInsets != rhs.contentInsets {
return false
}
if lhs.controlInsets != rhs.controlInsets {
return false
}
if lhs.interfaceOrientation != rhs.interfaceOrientation {
return false
}
if lhs.enableVideoSharpening != rhs.enableVideoSharpening {
return false
}
if (lhs.action == nil) != (rhs.action == nil) {
return false
}
if (lhs.contextAction == nil) != (rhs.contextAction == nil) {
return false
}
if (lhs.activatePinch == nil) != (rhs.activatePinch == nil) {
return false
}
if (lhs.deactivatedPinch == nil) != (rhs.deactivatedPinch == nil) {
return false
}
return true
}
private struct VideoSpec: Equatable {
var resolution: CGSize
var rotationAngle: Float
var followsDeviceOrientation: Bool
init(resolution: CGSize, rotationAngle: Float, followsDeviceOrientation: Bool) {
self.resolution = resolution
self.rotationAngle = rotationAngle
self.followsDeviceOrientation = followsDeviceOrientation
}
}
private struct ReferenceLocation: Equatable {
var containerWidth: CGFloat
var positionX: CGFloat
init(containerWidth: CGFloat, positionX: CGFloat) {
self.containerWidth = containerWidth
self.positionX = positionX
}
}
private final class AnimationHint {
enum Kind {
case videoAvailabilityChanged
}
let kind: Kind
init(kind: Kind) {
self.kind = kind
}
}
final class View: ContextControllerSourceView {
private var component: VideoChatParticipantVideoComponent?
private weak var componentState: EmptyComponentState?
private var isUpdating: Bool = false
private var previousSize: CGSize?
private let backgroundGradientView: UIImageView
private let muteStatus = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private var blurredAvatarDisposable: Disposable?
private var blurredAvatarView: UIImageView?
private let pinchContainerNode: PinchSourceContainerNode
private let extractedContainerView: ContextExtractedContentContainingView
private var videoSource: AdaptedCallVideoSource?
private var videoPlaceholder: VideoSource.Output?
private var videoDisposable: Disposable?
private var videoBackgroundLayer: SimpleLayer?
private var videoLayer: PrivateCallVideoLayer?
private var videoSpec: VideoSpec?
private var awaitingFirstVideoFrameForUnpause: Bool = false
private var videoStatus: ComponentView<Empty>?
private var activityBorderView: UIImageView?
private var referenceLocation: ReferenceLocation?
private var loadingEffectView: VideoChatVideoLoadingEffectView?
override init(frame: CGRect) {
self.backgroundGradientView = UIImageView()
self.pinchContainerNode = PinchSourceContainerNode()
self.extractedContainerView = ContextExtractedContentContainingView()
super.init(frame: frame)
self.addSubview(self.extractedContainerView)
self.targetViewForActivationProgress = self.extractedContainerView.contentView
self.extractedContainerView.contentView.addSubview(self.pinchContainerNode.view)
self.pinchContainerNode.contentNode.view.addSubview(self.backgroundGradientView)
//TODO:release optimize
self.pinchContainerNode.contentNode.view.layer.cornerRadius = 16.0
self.pinchContainerNode.contentNode.view.clipsToBounds = true
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
self.pinchContainerNode.activate = { [weak self] sourceNode in
guard let self, let component = self.component else {
return
}
component.activatePinch?(sourceNode)
}
self.pinchContainerNode.animatedOut = { [weak self] in
guard let self, let component = self.component else {
return
}
component.deactivatedPinch?()
}
self.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
gesture.cancel()
return
}
if let participantPeer = component.participant.peer {
component.contextAction?(participantPeer, self.extractedContainerView, gesture)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.videoDisposable?.dispose()
self.blurredAvatarDisposable?.dispose()
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
guard let component = self.component, let action = component.action else {
return
}
action()
}
}
func updatePlaceholder(placeholder: VideoSource.Output) {
self.videoPlaceholder = placeholder
self.componentState?.updated(transition: .immediate, isLocal: true)
}
func update(component: VideoChatParticipantVideoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
self.componentState = state
self.isGestureEnabled = !component.isExpanded
self.pinchContainerNode.isPinchGestureEnabled = component.activatePinch != nil
transition.setPosition(view: self.pinchContainerNode.view, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.pinchContainerNode.view, bounds: CGRect(origin: CGPoint(), size: availableSize))
self.pinchContainerNode.update(size: availableSize, transition: transition.containedViewLayoutTransition)
transition.setPosition(view: self.extractedContainerView, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.extractedContainerView, bounds: CGRect(origin: CGPoint(), size: availableSize))
transition.setPosition(view: self.extractedContainerView.contentView, position: CGRect(origin: CGPoint(), size: availableSize).center)
transition.setBounds(view: self.extractedContainerView.contentView, bounds: CGRect(origin: CGPoint(), size: availableSize))
self.extractedContainerView.contentRect = CGRect(origin: CGPoint(), size: availableSize)
transition.setFrame(view: self.pinchContainerNode.contentNode.view, frame: CGRect(origin: CGPoint(), size: availableSize))
transition.setFrame(view: self.backgroundGradientView, frame: CGRect(origin: CGPoint(), size: availableSize))
let alphaTransition: ComponentTransition
if !transition.animation.isImmediate {
alphaTransition = .easeInOut(duration: 0.2)
} else {
alphaTransition = .immediate
}
let videoAlphaTransition: ComponentTransition
if let animationHint = transition.userData(AnimationHint.self), case .videoAvailabilityChanged = animationHint.kind {
videoAlphaTransition = .easeInOut(duration: 0.2)
} else {
videoAlphaTransition = alphaTransition
}
let controlsAlpha: CGFloat = component.isUIHidden ? 0.0 : 1.0
if previousComponent == nil {
let colors = calculateAvatarColors(context: component.call.accountContext, explicitColorIndex: nil, peerId: component.participant.peer?.id, nameColor: component.participant.peer?.nameColor, icon: .none, theme: component.theme)
self.backgroundGradientView.image = generateGradientImage(size: CGSize(width: 8.0, height: 32.0), colors: colors.reversed(), locations: [0.0, 1.0], direction: .vertical)
}
if let smallProfileImage = component.participant.peer?.smallProfileImage {
let blurredAvatarView: UIImageView
if let current = self.blurredAvatarView {
blurredAvatarView = current
transition.setFrame(view: blurredAvatarView, frame: CGRect(origin: CGPoint(), size: availableSize))
} else {
blurredAvatarView = UIImageView()
blurredAvatarView.contentMode = .scaleAspectFill
self.blurredAvatarView = blurredAvatarView
self.pinchContainerNode.contentNode.view.insertSubview(blurredAvatarView, aboveSubview: self.backgroundGradientView)
blurredAvatarView.frame = CGRect(origin: CGPoint(), size: availableSize)
}
if self.blurredAvatarDisposable == nil {
//TODO:release synchronous
if let participantPeer = component.participant.peer, let imageCache = component.call.accountContext.imageCache as? DirectMediaImageCache, let peerReference = PeerReference(participantPeer._asPeer()) {
if let result = imageCache.getAvatarImage(peer: peerReference, resource: MediaResourceReference.avatar(peer: peerReference, resource: smallProfileImage.resource), immediateThumbnail: participantPeer.profileImageRepresentations.first?.immediateThumbnailData, size: 64, synchronous: false) {
if let image = result.image {
blurredAvatarView.image = blurredAvatarImage(image)
}
if let loadSignal = result.loadSignal {
self.blurredAvatarDisposable = (loadSignal
|> deliverOnMainQueue).startStrict(next: { [weak self] image in
guard let self else {
return
}
if let image {
self.blurredAvatarView?.image = blurredAvatarImage(image)
} else {
self.blurredAvatarView?.image = nil
}
})
}
}
}
}
} else {
if let blurredAvatarView = self.blurredAvatarView {
self.blurredAvatarView = nil
blurredAvatarView.removeFromSuperview()
}
if let blurredAvatarDisposable = self.blurredAvatarDisposable {
self.blurredAvatarDisposable = nil
blurredAvatarDisposable.dispose()
}
}
let muteStatusSize = self.muteStatus.update(
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
content: component.isPresentation ? .screenshare : .mute(isFilled: true, isMuted: component.participant.muteState != nil && !component.isSpeaking),
shadowColor: UIColor(white: 0.0, alpha: 0.7),
shadowBlur: 8.0
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame: CGRect
if component.isExpanded {
muteStatusFrame = CGRect(origin: CGPoint(x: 5.0, y: availableSize.height - component.controlInsets.bottom + 1.0 - muteStatusSize.height), size: muteStatusSize)
} else {
muteStatusFrame = CGRect(origin: CGPoint(x: 1.0, y: availableSize.height - component.controlInsets.bottom + 3.0 - muteStatusSize.height), size: muteStatusSize)
}
if let muteStatusView = self.muteStatus.view {
if muteStatusView.superview == nil {
self.pinchContainerNode.contentNode.view.addSubview(muteStatusView)
muteStatusView.alpha = controlsAlpha
}
transition.setPosition(view: muteStatusView, position: muteStatusFrame.center)
transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size))
transition.setScale(view: muteStatusView, scale: component.isExpanded ? 1.0 : 0.7)
alphaTransition.setAlpha(view: muteStatusView, alpha: controlsAlpha)
}
let titleInnerInsets = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.participant.peer?.debugDisplayTitle ?? "User \(component.participant.id)", font: Font.semibold(16.0), textColor: .white)),
insets: titleInnerInsets,
textShadowColor: UIColor(white: 0.0, alpha: 0.7),
textShadowBlur: 8.0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 8.0 * 2.0 - 4.0, height: 100.0)
)
let titleFrame: CGRect
if component.isExpanded {
titleFrame = CGRect(origin: CGPoint(x: 36.0 - titleInnerInsets.left, y: availableSize.height - component.controlInsets.bottom - 8.0 - titleSize.height + titleInnerInsets.top), size: titleSize)
} else {
titleFrame = CGRect(origin: CGPoint(x: 29.0 - titleInnerInsets.left, y: availableSize.height - component.controlInsets.bottom - 4.0 - titleSize.height + titleInnerInsets.top + 1.0), size: titleSize)
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
self.pinchContainerNode.contentNode.view.addSubview(titleView)
titleView.alpha = controlsAlpha
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
transition.setScale(view: titleView, scale: component.isExpanded ? 1.0 : 0.825)
alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha)
}
var previousVideoDescription: GroupCallParticipantsContext.Participant.VideoDescription?
if let previousComponent {
if previousComponent.isMyPeer && previousComponent.isPresentation {
previousVideoDescription = nil
} else {
previousVideoDescription = previousComponent.maxVideoQuality == 0 ? nil : (previousComponent.isPresentation ? previousComponent.participant.presentationDescription : previousComponent.participant.videoDescription)
}
}
let videoDescription: GroupCallParticipantsContext.Participant.VideoDescription?
if component.isMyPeer && component.isPresentation {
videoDescription = nil
} else {
videoDescription = component.maxVideoQuality == 0 ? nil : (component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription)
}
var isEffectivelyPaused = false
if let videoDescription, videoDescription.isPaused {
isEffectivelyPaused = true
} else if let previousComponent {
let previousVideoDescription = previousComponent.isPresentation ? previousComponent.participant.presentationDescription : previousComponent.participant.videoDescription
if let previousVideoDescription, previousVideoDescription.isPaused {
self.awaitingFirstVideoFrameForUnpause = true
}
if self.awaitingFirstVideoFrameForUnpause {
isEffectivelyPaused = true
}
}
if let videoDescription {
let videoBackgroundLayer: SimpleLayer
if let current = self.videoBackgroundLayer {
videoBackgroundLayer = current
} else {
videoBackgroundLayer = SimpleLayer()
videoBackgroundLayer.backgroundColor = UIColor(white: 0.1, alpha: 1.0).cgColor
videoBackgroundLayer.opacity = 0.0
self.videoBackgroundLayer = videoBackgroundLayer
if let blurredAvatarView = self.blurredAvatarView {
self.pinchContainerNode.contentNode.view.layer.insertSublayer(videoBackgroundLayer, above: blurredAvatarView.layer)
} else {
self.pinchContainerNode.contentNode.view.layer.insertSublayer(videoBackgroundLayer, above: self.backgroundGradientView.layer)
}
videoBackgroundLayer.isHidden = true
}
let videoUpdated: () -> Void = { [weak self] in
guard let self, let videoSource = self.videoSource, let videoLayer = self.videoLayer else {
return
}
var videoOutput = videoSource.currentOutput
var isPlaceholder = false
if videoOutput == nil {
isPlaceholder = true
videoOutput = self.videoPlaceholder
} else {
self.videoPlaceholder = nil
}
videoLayer.video = videoOutput
if let videoOutput {
let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle, followsDeviceOrientation: videoOutput.followsDeviceOrientation)
if self.videoSpec != videoSpec || self.awaitingFirstVideoFrameForUnpause {
self.awaitingFirstVideoFrameForUnpause = false
self.videoSpec = videoSpec
if !self.isUpdating {
var transition: ComponentTransition = .immediate
if !isPlaceholder {
transition = transition.withUserData(AnimationHint(kind: .videoAvailabilityChanged))
}
self.componentState?.updated(transition: transition, isLocal: true)
}
}
} else {
if self.videoSpec != nil {
self.videoSpec = nil
if !self.isUpdating {
self.componentState?.updated(transition: .immediate, isLocal: true)
}
}
}
}
let videoLayer: PrivateCallVideoLayer
var resetVideoSource = false
if let current = self.videoLayer {
videoLayer = current
if let previousVideoDescription, previousVideoDescription.endpointId != videoDescription.endpointId {
resetVideoSource = true
}
} else {
videoLayer = PrivateCallVideoLayer(enableSharpening: component.enableVideoSharpening)
self.videoLayer = videoLayer
videoLayer.opacity = 0.0
self.pinchContainerNode.contentNode.view.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer)
self.pinchContainerNode.contentNode.view.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer)
videoLayer.blurredLayer.opacity = 0.0
resetVideoSource = true
}
if resetVideoSource {
if let input = component.call.video(endpointId: videoDescription.endpointId) {
let videoSource = AdaptedCallVideoSource(videoStreamSignal: input)
self.videoSource = videoSource
self.videoDisposable?.dispose()
self.videoDisposable = videoSource.addOnUpdated {
videoUpdated()
}
}
}
if let _ = self.videoPlaceholder, videoLayer.video == nil {
videoUpdated()
}
transition.setFrame(layer: videoBackgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize))
if let videoSpec = self.videoSpec {
if videoBackgroundLayer.isHidden {
videoBackgroundLayer.isHidden = false
}
videoAlphaTransition.setAlpha(layer: videoBackgroundLayer, alpha: 1.0)
if isEffectivelyPaused {
videoAlphaTransition.setAlpha(layer: videoLayer, alpha: 0.0)
videoAlphaTransition.setAlpha(layer: videoLayer.blurredLayer, alpha: 0.9)
} else {
videoAlphaTransition.setAlpha(layer: videoLayer, alpha: 1.0)
videoAlphaTransition.setAlpha(layer: videoLayer.blurredLayer, alpha: 0.25)
}
let rotationAngle = resolveCallVideoRotationAngle(angle: videoSpec.rotationAngle, followsDeviceOrientation: videoSpec.followsDeviceOrientation, interfaceOrientation: component.interfaceOrientation)
var rotatedResolution = videoSpec.resolution
var videoIsRotated = false
if abs(rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne {
videoIsRotated = true
}
if videoIsRotated {
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
}
let videoSize = rotatedResolution.aspectFitted(availableSize)
let videoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - videoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - videoSize.height) * 0.5)), size: videoSize)
let blurredVideoSize = rotatedResolution.aspectFilled(availableSize)
let blurredVideoFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - blurredVideoSize.width) * 0.5), y: floorToScreenPixels((availableSize.height - blurredVideoSize.height) * 0.5)), size: blurredVideoSize)
let videoResolution = rotatedResolution
var rotatedVideoResolution = videoResolution
var rotatedVideoFrame = videoFrame
var rotatedBlurredVideoFrame = blurredVideoFrame
var rotatedVideoBoundsSize = videoFrame.size
var rotatedBlurredVideoBoundsSize = blurredVideoFrame.size
if videoIsRotated {
rotatedVideoBoundsSize = CGSize(width: rotatedVideoBoundsSize.height, height: rotatedVideoBoundsSize.width)
rotatedVideoFrame = rotatedVideoFrame.size.centered(around: rotatedVideoFrame.center)
rotatedBlurredVideoBoundsSize = CGSize(width: rotatedBlurredVideoBoundsSize.height, height: rotatedBlurredVideoBoundsSize.width)
rotatedBlurredVideoFrame = rotatedBlurredVideoFrame.size.centered(around: rotatedBlurredVideoFrame.center)
}
rotatedVideoResolution = rotatedVideoResolution.aspectFittedOrSmaller(CGSize(width: rotatedVideoFrame.width * UIScreenScale, height: rotatedVideoFrame.height * UIScreenScale))
transition.setPosition(layer: videoLayer, position: rotatedVideoFrame.center)
transition.setBounds(layer: videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoBoundsSize))
transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0))
videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2)
transition.setPosition(layer: videoLayer.blurredLayer, position: rotatedBlurredVideoFrame.center)
transition.setBounds(layer: videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedBlurredVideoBoundsSize))
transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0))
}
} else {
if let videoBackgroundLayer = self.videoBackgroundLayer {
self.videoBackgroundLayer = nil
videoBackgroundLayer.removeFromSuperlayer()
}
if let videoLayer = self.videoLayer {
self.videoLayer = nil
videoLayer.blurredLayer.removeFromSuperlayer()
videoLayer.removeFromSuperlayer()
}
self.videoDisposable?.dispose()
self.videoDisposable = nil
self.videoSource = nil
self.videoSpec = nil
}
var statusKind: VideoChatParticipantVideoStatusComponent.Kind?
if component.isPresentation && component.isMyPeer {
statusKind = .ownScreenshare
} else if isEffectivelyPaused {
statusKind = .paused
}
if let statusKind {
let videoStatus: ComponentView<Empty>
var videoStatusTransition = transition
if let current = self.videoStatus {
videoStatus = current
} else {
videoStatusTransition = videoStatusTransition.withAnimation(.none)
videoStatus = ComponentView()
self.videoStatus = videoStatus
}
let _ = videoStatus.update(
transition: videoStatusTransition,
component: AnyComponent(VideoChatParticipantVideoStatusComponent(
strings: component.strings,
kind: statusKind,
isExpanded: component.isExpanded
)),
environment: {},
containerSize: availableSize
)
if let videoStatusView = videoStatus.view {
if videoStatusView.superview == nil {
videoStatusView.isUserInteractionEnabled = false
videoStatusView.alpha = 0.0
self.pinchContainerNode.contentNode.view.addSubview(videoStatusView)
}
videoStatusTransition.setFrame(view: videoStatusView, frame: CGRect(origin: CGPoint(), size: availableSize))
videoAlphaTransition.setAlpha(view: videoStatusView, alpha: 1.0)
}
} else if let videoStatus = self.videoStatus {
self.videoStatus = nil
if let videoStatusView = videoStatus.view {
videoAlphaTransition.setAlpha(view: videoStatusView, alpha: 0.0, completion: { [weak videoStatusView] _ in
videoStatusView?.removeFromSuperview()
})
}
}
if videoDescription != nil && self.videoSpec == nil && !isEffectivelyPaused {
if self.loadingEffectView == nil {
let loadingEffectView = VideoChatVideoLoadingEffectView(effectAlpha: 0.1, borderAlpha: 0.2, cornerRadius: 16.0, duration: 1.0)
self.loadingEffectView = loadingEffectView
loadingEffectView.alpha = 0.0
loadingEffectView.isUserInteractionEnabled = false
self.pinchContainerNode.contentNode.view.addSubview(loadingEffectView)
if let referenceLocation = self.referenceLocation {
self.updateHorizontalReferenceLocation(containerWidth: referenceLocation.containerWidth, positionX: referenceLocation.positionX, transition: .immediate)
}
videoAlphaTransition.setAlpha(view: loadingEffectView, alpha: 1.0)
}
} else if let loadingEffectView = self.loadingEffectView {
self.loadingEffectView = nil
videoAlphaTransition.setAlpha(view: loadingEffectView, alpha: 0.0, completion: { [weak loadingEffectView] _ in
loadingEffectView?.removeFromSuperview()
})
}
if component.isSpeaking && !component.isExpanded {
let activityBorderView: UIImageView
if let current = self.activityBorderView {
activityBorderView = current
} else {
activityBorderView = UIImageView()
self.activityBorderView = activityBorderView
self.pinchContainerNode.contentNode.view.addSubview(activityBorderView)
activityBorderView.image = activityBorderImage
activityBorderView.tintColor = UIColor(rgb: 0x33C758)
if let previousSize {
activityBorderView.frame = CGRect(origin: CGPoint(), size: previousSize)
}
}
} else if let activityBorderView = self.activityBorderView {
if !transition.animation.isImmediate {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
if activityBorderView.alpha != 0.0 {
alphaTransition.setAlpha(view: activityBorderView, alpha: 0.0, completion: { [weak self, weak activityBorderView] completed in
guard let self, let component = self.component, let activityBorderView, self.activityBorderView === activityBorderView, completed else {
return
}
if !component.isSpeaking {
activityBorderView.removeFromSuperview()
self.activityBorderView = nil
}
})
}
} else {
self.activityBorderView = nil
activityBorderView.removeFromSuperview()
}
}
if let activityBorderView = self.activityBorderView {
transition.setFrame(view: activityBorderView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
self.previousSize = availableSize
return availableSize
}
func updateHorizontalReferenceLocation(containerWidth: CGFloat, positionX: CGFloat, transition: ComponentTransition) {
self.referenceLocation = ReferenceLocation(containerWidth: containerWidth, positionX: positionX)
if let loadingEffectView = self.loadingEffectView, let size = self.previousSize {
transition.setFrame(view: loadingEffectView, frame: CGRect(origin: CGPoint(), size: size))
loadingEffectView.update(size: size, containerWidth: containerWidth, offsetX: positionX, gradientWidth: floor(containerWidth * 0.8), transition: transition)
}
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,140 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import BundleIconComponent
import MultilineTextComponent
final class VideoChatParticipantVideoStatusComponent: Component {
enum Kind {
case ownScreenshare
case paused
}
let strings: PresentationStrings
let kind: Kind
let isExpanded: Bool
init(
strings: PresentationStrings,
kind: Kind,
isExpanded: Bool
) {
self.strings = strings
self.kind = kind
self.isExpanded = isExpanded
}
static func ==(lhs: VideoChatParticipantVideoStatusComponent, rhs: VideoChatParticipantVideoStatusComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.kind != rhs.kind {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
return true
}
final class View: UIView {
private var icon = ComponentView<Empty>()
private let title = ComponentView<Empty>()
private var component: VideoChatParticipantVideoStatusComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: VideoChatParticipantVideoStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
var iconTransition = transition
if let previousComponent, previousComponent.kind != component.kind {
self.icon.view?.removeFromSuperview()
self.icon = ComponentView()
iconTransition = iconTransition.withAnimation(.none)
}
let iconName: String
let titleValue: String
switch component.kind {
case .ownScreenshare:
iconName = "Call/ScreenSharePhone"
titleValue = component.strings.VoiceChat_YouAreSharingScreen
case .paused:
iconName = "Call/Pause"
titleValue = component.strings.VoiceChat_VideoPaused
}
let iconSize = self.icon.update(
transition: iconTransition,
component: AnyComponent(BundleIconComponent(
name: iconName,
tintColor: .white
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: titleValue, font: Font.semibold(14.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 8.0 * 2.0, height: 100.0)
)
let scale: CGFloat = component.isExpanded ? 1.0 : 0.825
let spacing: CGFloat = 18.0
let contentHeight: CGFloat = iconSize.height + spacing + titleSize.height
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: floor((availableSize.height - contentHeight) * 0.5)), size: iconSize)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.addSubview(iconView)
}
iconTransition.setFrame(view: iconView, frame: iconFrame)
}
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
iconTransition.setFrame(view: titleView, frame: titleFrame)
}
iconTransition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(scale, scale, 1.0))
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import ComponentDisplayAdapters
final class VideoChatPinStatusComponent: Component {
let theme: PresentationTheme
let strings: PresentationStrings
let isPinned: Bool
let action: () -> Void
init(
theme: PresentationTheme,
strings: PresentationStrings,
isPinned: Bool,
action: @escaping () -> Void
) {
self.theme = theme
self.strings = strings
self.isPinned = isPinned
self.action = action
}
static func ==(lhs: VideoChatPinStatusComponent, rhs: VideoChatPinStatusComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.isPinned != rhs.isPinned {
return false
}
return true
}
final class View: UIView {
private var pinNode: VoiceChatPinButtonNode?
private var component: VideoChatPinStatusComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
@objc private func pinPressed() {
guard let component = self.component else {
return
}
component.action()
}
func update(component: VideoChatPinStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let pinNode: VoiceChatPinButtonNode
if let current = self.pinNode {
pinNode = current
} else {
pinNode = VoiceChatPinButtonNode(theme: component.theme, strings: component.strings)
self.pinNode = pinNode
self.addSubview(pinNode.view)
pinNode.addTarget(self, action: #selector(self.pinPressed), forControlEvents: .touchUpInside)
}
let pinNodeSize = pinNode.update(size: availableSize, transition: transition.containedViewLayoutTransition)
let pinNodeFrame = CGRect(origin: CGPoint(), size: pinNodeSize)
transition.setFrame(view: pinNode.view, frame: pinNodeFrame)
pinNode.update(pinned: component.isPinned, animated: !transition.animation.isImmediate)
let size = pinNodeSize
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,229 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import TelegramStringFormatting
import HierarchyTrackingLayer
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xef436c)
private let latePurple = UIColor(rgb: 0x974aa9)
private let latePink = UIColor(rgb: 0xf0436c)
private func textForTimeout(value: Int32) -> String {
if value < 3600 {
let minutes = value / 60
let seconds = value % 60
let secondsPadding = seconds < 10 ? "0" : ""
return "\(minutes):\(secondsPadding)\(seconds)"
} else {
let hours = value / 3600
let minutes = (value % 3600) / 60
let minutesPadding = minutes < 10 ? "0" : ""
let seconds = value % 60
let secondsPadding = seconds < 10 ? "0" : ""
return "\(hours):\(minutesPadding)\(minutes):\(secondsPadding)\(seconds)"
}
}
final class VideoChatScheduledInfoComponent: Component {
let timestamp: Int32
let strings: PresentationStrings
init(
timestamp: Int32,
strings: PresentationStrings
) {
self.timestamp = timestamp
self.strings = strings
}
static func ==(lhs: VideoChatScheduledInfoComponent, rhs: VideoChatScheduledInfoComponent) -> Bool {
if lhs.timestamp != rhs.timestamp {
return false
}
if lhs.strings !== rhs.strings {
return false
}
return true
}
final class View: UIView {
private let title = ComponentView<Empty>()
private let countdownText = ComponentView<Empty>()
private let dateText = ComponentView<Empty>()
private let countdownContainerView: UIView
private let countdownMaskView: UIView
private let countdownGradientLayer: SimpleGradientLayer
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private var component: VideoChatScheduledInfoComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
self.countdownContainerView = UIView()
self.countdownMaskView = UIView()
self.countdownGradientLayer = SimpleGradientLayer()
self.countdownGradientLayer.type = .radial
self.countdownGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor]
self.countdownGradientLayer.locations = [0.0, 0.85, 1.0]
self.countdownGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.countdownGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.countdownContainerView.layer.addSublayer(self.countdownGradientLayer)
self.addSubview(self.countdownContainerView)
self.countdownContainerView.mask = self.countdownMaskView
self.hierarchyTrackingLayer.isInHierarchyUpdated = { [weak self] value in
guard let self else {
return
}
if value {
self.updateAnimations()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateAnimations() {
if let _ = self.countdownGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.countdownGradientLayer.startPoint
let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45))
self.countdownGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
guard let self else {
return
}
if self.hierarchyTrackingLayer.isInHierarchy {
self.updateAnimations()
}
}
self.countdownGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
func update(component: VideoChatScheduledInfoComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.strings.VoiceChat_StartsIn, font: Font.with(size: 23.0, design: .round, weight: .semibold), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 200.0)
)
let remainingSeconds: Int32 = max(0, component.timestamp - Int32(Date().timeIntervalSince1970))
let countdownText: String
if remainingSeconds >= 86400 {
countdownText = scheduledTimeIntervalString(strings: component.strings, value: remainingSeconds)
} else {
countdownText = textForTimeout(value: abs(remainingSeconds))
/*if remainingSeconds < 0 && !self.isLate {
self.isLate = true
self.foregroundGradientLayer.colors = [latePink.cgColor, latePurple.cgColor, latePurple.cgColor]
}*/
}
let countdownTextSize = self.countdownText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: countdownText, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 400.0)
)
let dateText = humanReadableStringForTimestamp(strings: component.strings, dateTimeFormat: PresentationDateTimeFormat(), timestamp: component.timestamp, alwaysShowTime: true).string
let dateTextSize = self.dateText.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: dateText, font: Font.with(size: 23.0, design: .round, weight: .semibold), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 400.0)
)
let titleSpacing: CGFloat = 5.0
let dateSpacing: CGFloat = 5.0
let contentHeight: CGFloat = titleSize.height + titleSpacing + countdownTextSize.height + dateSpacing + dateTextSize.height
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((availableSize.height - contentHeight) * 0.5)), size: titleSize)
let countdownTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - countdownTextSize.width) * 0.5), y: titleFrame.maxY + titleSpacing), size: countdownTextSize)
let dateTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - dateTextSize.width) * 0.5), y: countdownTextFrame.maxY + dateSpacing), size: dateTextSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.center)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
if let countdownTextView = self.countdownText.view {
if countdownTextView.superview == nil {
self.countdownMaskView.addSubview(countdownTextView)
}
transition.setFrame(view: countdownTextView, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size))
}
transition.setFrame(view: self.countdownContainerView, frame: countdownTextFrame)
transition.setFrame(view: self.countdownMaskView, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size))
transition.setFrame(layer: self.countdownGradientLayer, frame: CGRect(origin: CGPoint(), size: countdownTextFrame.size))
if let dateTextView = self.dateText.view {
if dateTextView.superview == nil {
self.addSubview(dateTextView)
}
transition.setPosition(view: dateTextView, position: dateTextFrame.center)
dateTextView.bounds = CGRect(origin: CGPoint(), size: dateTextFrame.size)
}
self.updateAnimations()
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
import Foundation
import UIKit
import Display
import TelegramCore
import SwiftSignalKit
import OverlayStatusController
import PresentationDataUtils
import InviteLinksUI
import UndoUI
import TelegramPresentationData
import AccountContext
extension VideoChatScreenComponent.View {
func openInviteMembers() {
guard case let .group(groupCall) = self.currentCall else {
return
}
if groupCall.isConference {
var disablePeerIds: [EnginePeer.Id] = []
disablePeerIds.append(groupCall.accountContext.account.peerId)
if let members = self.members {
for participant in members.participants {
if let participantPeer = participant.peer, !disablePeerIds.contains(participantPeer.id) {
disablePeerIds.append(participantPeer.id)
}
}
}
let controller = CallController.openConferenceAddParticipant(context: groupCall.accountContext, disablePeerIds: disablePeerIds, shareLink: { [weak self] in
guard let self else {
return
}
guard let inviteLinks = self.inviteLinks else {
return
}
self.presentShare(inviteLinks)
}, completion: { [weak self] peerIds in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
for peerId in peerIds {
let _ = groupCall.invitePeer(peerId.id, isVideo: peerId.isVideo)
}
})
self.environment?.controller()?.push(controller)
} else {
var canInvite = true
var inviteIsLink = false
if case let .channel(peer) = self.peer {
if peer.flags.contains(.isGigagroup) {
if peer.flags.contains(.isCreator) || peer.adminRights != nil {
} else {
canInvite = false
}
}
if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) {
inviteIsLink = true
}
}
var inviteType: VideoChatParticipantsComponent.Participants.InviteType?
if canInvite {
if inviteIsLink {
inviteType = .shareLink
} else {
inviteType = .invite(isMultipleUsers: true)
}
}
guard let inviteType else {
return
}
guard let peerId = groupCall.peerId else {
return
}
switch inviteType {
case .invite:
let groupPeer = groupCall.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
let _ = (groupPeer
|> deliverOnMainQueue).start(next: { [weak self] groupPeer in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall, let groupPeer else {
return
}
let inviteLinks = self.inviteLinks
if case let .channel(groupPeer) = groupPeer {
var canInviteMembers = true
if case .broadcast = groupPeer.info, !(groupPeer.addressName?.isEmpty ?? true) {
canInviteMembers = false
}
if !canInviteMembers {
if let inviteLinks {
self.presentShare(inviteLinks)
}
return
}
}
var filters: [ChannelMembersSearchFilter] = []
if let members = self.members {
filters.append(.disable(Array(members.participants.compactMap { $0.peer?.id })))
}
if case let .channel(groupPeer) = groupPeer {
if !groupPeer.hasPermission(.inviteMembers) && inviteLinks?.listenerLink == nil {
filters.append(.excludeNonMembers)
}
} else if case let .legacyGroup(groupPeer) = groupPeer {
if groupPeer.hasBannedPermission(.banAddMembers) {
filters.append(.excludeNonMembers)
}
}
filters.append(.excludeBots)
var dismissController: (() -> Void)?
let controller = groupCall.accountContext.sharedContext.makeChannelMembersSearchController(params: ChannelMembersSearchControllerParams(context: groupCall.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
dismissController?()
return
}
guard let callState = self.callState else {
return
}
let presentationData = groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
if peer.id == callState.myPeerId {
return
}
if let participant {
dismissController?()
if groupCall.invitePeer(participant.peer.id, isVideo: false) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
self.presentToast(icon: .peer(EnginePeer(participant.peer)), text: text, duration: 3)
}
} else {
if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) {
let text = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
environment.controller()?.present(textAlertController(context: groupCall.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in
dismissController?()
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let _ = (enqueueMessages(account: groupCall.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])])
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
self.presentToast(icon: .animation("anim_savedmessages"), text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string, duration: 3)
})
})]), in: .window(.root))
} else {
let text: String
if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info {
text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
}
environment.controller()?.present(textAlertController(context: groupCall.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
if case let .channel(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = groupCall.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: groupCall.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id])
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [weak selfController] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
selfController?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = progressSignal.start()
inviteSignal = inviteSignal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
inviteDisposable.set(nil)
}
inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in
dismissController?()
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
let text: String
switch error {
case .limitExceeded:
text = environment.strings.Channel_ErrorAddTooMuch
case .tooMuchJoined:
text = environment.strings.Invite_ChannelsTooMuch
case .generic:
text = environment.strings.Login_UnknownError
case .restricted:
text = environment.strings.Channel_ErrorAddBlocked
case .notMutualContact:
if case .broadcast = groupPeer.info {
text = environment.strings.Channel_AddUserLeftError
} else {
text = environment.strings.GroupInfo_AddUserLeftError
}
case .botDoesntSupportGroups:
text = environment.strings.Channel_BotDoesntSupportGroups
case .tooMuchBots:
text = environment.strings.Channel_TooMuchBots
case .bot:
text = environment.strings.Login_UnknownError
case .kicked:
text = environment.strings.Channel_AddUserKickedError
}
environment.controller()?.present(textAlertController(context: groupCall.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}, completed: { [weak self] in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
dismissController?()
return
}
dismissController?()
if groupCall.invitePeer(peer.id, isVideo: false) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentToast(icon: .peer(peer), text: text, duration: 3)
}
}))
} else if case let .legacyGroup(groupPeer) = groupPeer {
guard let selfController = environment.controller() else {
return
}
let inviteDisposable = self.inviteDisposable
var inviteSignal = groupCall.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id)
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { [weak selfController] subscriber in
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
selfController?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = progressSignal.start()
inviteSignal = inviteSignal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
cancelImpl = {
inviteDisposable.set(nil)
}
inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in
dismissController?()
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
let context = groupCall.accountContext
switch error {
case .privacy:
let _ = (groupCall.accountContext.account.postbox.loadedPeerWithId(peer.id)
|> deliverOnMainQueue).start(next: { [weak self] peer in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
environment.controller()?.present(textAlertController(context: groupCall.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
})
case .notMutualContact:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .tooManyChannels:
environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
case .groupFull, .generic:
environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root))
}
}, completed: { [weak self] in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
dismissController?()
return
}
dismissController?()
if groupCall.invitePeer(peer.id, isVideo: false) {
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
} else {
text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string
}
self.presentToast(icon: .peer(peer), text: text, duration: 3)
}
}))
}
})]), in: .window(.root))
}
}
}))
controller.copyInviteLink = { [weak self] in
dismissController?()
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
guard let callPeerId = groupCall.peerId else {
return
}
let _ = (groupCall.accountContext.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: callPeerId),
TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: callPeerId)
)
|> map { peer, exportedInvitation -> String? in
if let link = inviteLinks?.listenerLink {
return link
} else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty {
return "https://t.me/\(addressName)"
} else if let link = exportedInvitation?.link {
return link
} else {
return nil
}
}
|> deliverOnMainQueue).start(next: { [weak self] link in
guard let self, let environment = self.environment else {
return
}
if let link {
UIPasteboard.general.string = link
self.presentToast(icon: .animation("anim_linkcopied"), text: environment.strings.VoiceChat_InviteLinkCopiedText, duration: 3)
}
})
}
dismissController = { [weak controller] in
controller?.dismiss()
}
environment.controller()?.push(controller)
})
case .shareLink:
guard let inviteLinks = self.inviteLinks else {
return
}
self.presentShare(inviteLinks)
}
}
}
}
@@ -0,0 +1,795 @@
import Foundation
import UIKit
import Display
import ContextUI
import TelegramCore
import SwiftSignalKit
import DeleteChatPeerActionSheetItem
import PeerListItemComponent
import LegacyComponents
import LegacyUI
import WebSearchUI
import MapResourceToAvatarSizes
import LegacyMediaPickerUI
import AvatarNode
import PresentationDataUtils
import AccountContext
import CallsEmoji
import AlertComponent
import TelegramPresentationData
import ComponentFlow
import MultilineTextComponent
import AVFoundation
private func resolvedEmojiKey(data: Data) -> [String] {
let resolvedKey = stringForEmojiHashOfData(data, 4) ?? []
return resolvedKey
}
private final class EmojiKeyAlertComponet: CombinedComponent {
let theme: PresentationTheme
let emojiKey: [String]
let title: String
let text: String
init(theme: PresentationTheme, emojiKey: [String], title: String, text: String) {
self.theme = theme
self.emojiKey = emojiKey
self.title = title
self.text = text
}
static func ==(lhs: EmojiKeyAlertComponet, rhs: EmojiKeyAlertComponet) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.emojiKey != rhs.emojiKey {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.text != rhs.text {
return false
}
return true
}
public static var body: Body {
//let emojiKeyItems = ChildMap(environment: MultilineTextComponent.self, keyedBy: Int.self)
let emojiKey = Child(MultilineTextComponent.self)
let title = Child(MultilineTextComponent.self)
let text = Child(MultilineTextComponent.self)
return { context in
/*let emojiKeyItems = context.component.emojiKey.map { item in
return emojiKeyItems[item].update(
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.emojiKey.joined(separator: ""), font: Font.semibold(40.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center
)),
environment: {},
availableSize: CGSize(width: 100.0, height: 100.0),
transition: .immediate
)
}*/
let emojiKey = emojiKey.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.emojiKey.joined(separator: ""), font: Font.semibold(40.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center
),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
let title = title.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.title, font: Font.semibold(16.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
let text = text.update(
component: MultilineTextComponent(
text: .plain(NSAttributedString(string: context.component.text, font: Font.regular(13.0), textColor: context.component.theme.actionSheet.primaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: context.availableSize.width, height: 10000.0),
transition: .immediate
)
var size = CGSize(width: 0.0, height: 0.0)
size.width = max(size.width, emojiKey.size.width)
size.width = max(size.width, title.size.width)
size.width = max(size.width, text.size.width)
let titleSpacing: CGFloat = 10.0
let textSpacing: CGFloat = 10.0
size.height += emojiKey.size.height
size.height += titleSpacing
size.height += title.size.height
size.height += textSpacing
size.height += text.size.height
var contentHeight: CGFloat = 0.0
let emojiKeyFrame = CGRect(origin: CGPoint(x: floor((size.width - emojiKey.size.width) * 0.5), y: contentHeight), size: emojiKey.size)
contentHeight += emojiKey.size.height + titleSpacing
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - title.size.width) * 0.5), y: contentHeight), size: title.size)
contentHeight += title.size.height + textSpacing
let textFrame = CGRect(origin: CGPoint(x: floor((size.width - text.size.width) * 0.5), y: contentHeight), size: text.size)
contentHeight += text.size.height + 5.0
context.add(emojiKey
.position(emojiKeyFrame.center)
)
context.add(title
.position(titleFrame.center)
)
context.add(text
.position(textFrame.center)
)
return size
}
}
}
extension VideoChatScreenComponent.View {
func openMoreMenu() {
guard let sourceView = self.navigationLeftButton.view else {
return
}
guard let environment = self.environment, let controller = environment.controller() else {
return
}
guard let currentCall = self.currentCall else {
return
}
guard let callState = self.callState else {
return
}
let canManageCall = callState.canManageCall
var isConference = false
if case let .group(groupCall) = currentCall {
isConference = groupCall.isConference
}
var items: [ContextMenuItem] = []
if self.peer != nil, let displayAsPeers = self.displayAsPeers, displayAsPeers.count > 1 {
for peer in displayAsPeers {
if peer.peer.id == callState.myPeerId {
let avatarSize = CGSize(width: 28.0, height: 28.0)
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: currentCall.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in
guard let self else {
return
}
c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuDisplayAsItems()))))
})))
items.append(.separator)
break
}
}
}
if let (availableOutputs, currentOutput) = self.audioOutputState, availableOutputs.count > 1 {
var currentOutputTitle = ""
for output in availableOutputs {
if output == currentOutput {
let title: String
switch output {
case .builtin:
title = UIDevice.current.model
case .speaker:
title = environment.strings.Call_AudioRouteSpeaker
case .headphones:
title = environment.strings.Call_AudioRouteHeadphones
case let .port(port):
title = port.name
}
currentOutputTitle = title
break
}
}
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] c, _ in
guard let self else {
return
}
c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuAudioItems()))))
})))
}
if canManageCall && !isConference {
let text: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_EditTitle
} else {
text = environment.strings.VoiceChat_EditTitle
}
items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openTitleEditing()
})))
if callState.canEnableMessages {
items.append(.action(ContextMenuActionItem(text: callState.messagesAreEnabled ? environment.strings.VoiceChat_ContextDisableMessages : environment.strings.VoiceChat_ContextEnableMessages, icon: { theme -> UIImage? in
return generateTintedImage(image: UIImage(bundleImageName: callState.messagesAreEnabled ? "Call/MessagesDisable" : "Call/MessagesEnable"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
guard let self, let currentCall = self.currentCall else {
return
}
let isEnabled = !callState.messagesAreEnabled
currentCall.setMessagesEnabled(isEnabled: isEnabled)
let iconName: String
let text: String
if isEnabled {
iconName = "Call/ToastMessagesEnabled"
text = environment.strings.VoiceChat_ToastMessagesEnabled
} else {
iconName = "Call/ToastMessagesDisabled"
text = environment.strings.VoiceChat_ToastMessagesDisabled
}
self.presentToast(icon: .icon(iconName), text: text, duration: 3)
})))
}
var hasPermissions = true
if let peer = self.peer, case let .channel(chatPeer) = peer {
if case .broadcast = chatPeer.info {
hasPermissions = false
} else if chatPeer.flags.contains(.isGigagroup) {
hasPermissions = false
}
}
if hasPermissions {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] c, _ in
guard let self else {
return
}
c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuPermissionItems()))))
})))
}
}
if let inviteLinks = self.inviteLinks {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_Share, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.presentShare(inviteLinks)
})))
}
//let isScheduled = strongSelf.isScheduled
let isScheduled: Bool = !"".isEmpty
let canSpeak: Bool
if let muteState = callState.muteState {
canSpeak = muteState.canUnmute
} else {
canSpeak = true
}
if !isScheduled && canSpeak {
if #available(iOS 15.0, *) {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MicrophoneModes, textColor: .primary, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
f(.dismissWithoutContent)
AVCaptureDevice.showSystemUserInterface(.microphoneModes)
})))
}
}
if let members = self.members, members.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) {
let qualityList: [(Int, String)] = [
(0, environment.strings.VideoChat_IncomingVideoQuality_AudioOnly),
(180, "180p"),
(360, "360p"),
(Int.max, "720p")
]
let videoQualityTitle = qualityList.first(where: { $0.0 == self.maxVideoQuality })?.1 ?? ""
items.append(.action(ContextMenuActionItem(text: environment.strings.VideoChat_IncomingVideoQuality_Title, textColor: .primary, textLayout: .secondLineWithValue(videoQualityTitle), icon: { _ in
return nil
}, action: { [weak self] c, _ in
guard let self else {
c?.dismiss(completion: nil)
return
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, iconPosition: .left, action: { (c, _) in
c?.popItems()
})))
items.append(.separator)
for (quality, title) in qualityList {
let isSelected = self.maxVideoQuality == quality
items.append(.action(ContextMenuActionItem(text: title, icon: { _ in
if isSelected {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white)
} else {
return UIImage()
}
}, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
if self.maxVideoQuality != quality {
self.maxVideoQuality = quality
self.state?.updated(transition: .immediate)
}
})))
}
c?.pushItems(items: .single(ContextController.Items(content: .list(items))))
})))
}
if callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) {
if currentCall.hasScreencast {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_StopScreenSharing, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
guard let self, let currentCall = self.currentCall else {
return
}
currentCall.disableScreencast()
})))
} else {
items.append(.custom(VoiceChatShareScreenContextItem(context: currentCall.accountContext, text: environment.strings.VoiceChat_ShareScreen, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor)
}, action: { _, _ in }), false))
}
}
if canManageCall && !isConference {
if let recordingStartTimestamp = callState.recordingStartTimestamp {
items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, let environment = self.environment, let currentCall = self.currentCall else {
return
}
let alertController = textAlertController(context: currentCall.accountContext, forceTheme: environment.theme, title: nil, text: environment.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_StopRecordingStop, action: { [weak self] in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
groupCall.setShouldBeRecording(false, title: nil, videoOrientation: nil)
Queue.mainQueue().after(0.88) {
HapticFeedback().success()
}
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_RecordingSaved
} else {
text = environment.strings.VideoChat_RecordingSaved
}
self.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in
if case .info = value, let self, let environment = self.environment, let currentCall = self.currentCall, let navigationController = environment.controller()?.navigationController as? NavigationController {
let context = currentCall.accountContext
environment.controller()?.dismiss(completion: { [weak navigationController] in
Queue.mainQueue().justDispatch {
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> deliverOnMainQueue).start(next: { peer in
guard let peer, let navigationController else {
return
}
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil))
})
}
})
return true
}
return false
})
})])
environment.controller()?.present(alertController, in: .window(.root))
}), false))
} else {
let text: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_StartRecording
} else {
text = environment.strings.VoiceChat_StartRecording
}
if callState.scheduleTimestamp == nil {
items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in
return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, let environment = self.environment, let currentCall = self.currentCall, let peer = self.peer else {
return
}
let controller = VoiceChatRecordingSetupController(context: currentCall.accountContext, peer: peer, completion: { [weak self] videoOrientation in
guard let self, let environment = self.environment, let currentCall = self.currentCall, let peer = self.peer else {
return
}
let title: String
let text: String
let placeholder: String
if let _ = videoOrientation {
placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholderVideo
} else {
placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholder
}
if case let .channel(channel) = peer, case .broadcast = channel.info {
title = environment.strings.LiveStream_StartRecordingTitle
if let _ = videoOrientation {
text = environment.strings.LiveStream_StartRecordingTextVideo
} else {
text = environment.strings.LiveStream_StartRecordingText
}
} else {
title = environment.strings.VoiceChat_StartRecordingTitle
if let _ = videoOrientation {
text = environment.strings.VoiceChat_StartRecordingTextVideo
} else {
text = environment.strings.VoiceChat_StartRecordingText
}
}
let controller = voiceChatTitleEditController(context: currentCall.accountContext, forceTheme: environment.theme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak self] title in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall, let peer = self.peer, let title else {
return
}
groupCall.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation)
let text: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
text = environment.strings.LiveStream_RecordingStarted
} else {
text = environment.strings.VoiceChat_RecordingStarted
}
self.presentToast(icon: .animation("anim_vcrecord"), text: text, duration: 3)
groupCall.playTone(.recordingStarted)
})
environment.controller()?.present(controller, in: .window(.root))
})
environment.controller()?.present(controller, in: .window(.root))
})))
}
}
}
if canManageCall {
let text: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream
} else {
text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.strings.VoiceChat_EndVoiceChat
}
items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, let environment = self.environment, let currentCall = self.currentCall else {
return
}
let action: () -> Void = { [weak self] in
guard let self, let currentCall = self.currentCall else {
return
}
switch currentCall {
case let .group(groupCall):
let _ = (groupCall.leave(terminateIfPossible: true)
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let self, let environment = self.environment else {
return
}
environment.controller()?.dismiss()
})
case let .conferenceSource(conferenceSource):
let _ = (conferenceSource.hangUp()
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let self, let environment = self.environment else {
return
}
environment.controller()?.dismiss()
})
}
}
let title: String
let text: String
if case let .channel(channel) = self.peer, case .broadcast = channel.info {
title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle
text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText
} else {
title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle
text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText
}
let alertController = textAlertController(context: currentCall.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: {
action()
})])
environment.controller()?.present(alertController, in: .window(.root))
})))
} else {
let leaveText: String
if case let .channel(channel) = peer, case .broadcast = channel.info {
leaveText = environment.strings.LiveStream_LeaveVoiceChat
} else {
leaveText = environment.strings.VoiceChat_LeaveVoiceChat
}
items.append(.action(ContextMenuActionItem(text: leaveText, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, let currentCall = self.currentCall else {
return
}
switch currentCall {
case let .group(groupCall):
let _ = (groupCall.leave(terminateIfPossible: false)
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let self, let environment = self.environment else {
return
}
environment.controller()?.dismiss()
})
case let .conferenceSource(conferenceSource):
let _ = (conferenceSource.hangUp()
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let self, let environment = self.environment else {
return
}
environment.controller()?.dismiss()
})
}
})))
}
let presentationData = currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let contextController = makeContextController(presentationData: presentationData, source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil)
controller.presentInGlobalOverlay(contextController)
}
private func contextMenuDisplayAsItems() -> [ContextMenuItem] {
guard let environment = self.environment else {
return []
}
guard case let .group(groupCall) = self.currentCall else {
return []
}
guard let callState = self.callState else {
return []
}
let myPeerId = callState.myPeerId
let avatarSize = CGSize(width: 28.0, height: 28.0)
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, iconPosition: .left, action: { (c, _) in
c?.popItems()
})))
items.append(.separator)
var isGroup = false
if let displayAsPeers = self.displayAsPeers {
for peer in displayAsPeers {
if peer.peer is TelegramGroup {
isGroup = true
break
} else if let peer = peer.peer as? TelegramChannel, case .group = peer.info {
isGroup = true
break
}
}
}
items.append(.custom(VoiceChatInfoContextItem(text: isGroup ? environment.strings.VoiceChat_DisplayAsInfoGroup : environment.strings.VoiceChat_DisplayAsInfo, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor)
}), true))
if let displayAsPeers = self.displayAsPeers {
for peer in displayAsPeers {
var subtitle: String?
if peer.peer.id.namespace == Namespaces.Peer.CloudUser {
subtitle = environment.strings.VoiceChat_PersonalAccount
} else if let subscribers = peer.subscribers {
if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info {
subtitle = environment.strings.Conversation_StatusSubscribers(subscribers)
} else {
subtitle = environment.strings.Conversation_StatusMembers(subscribers)
}
}
let isSelected = peer.peer.id == myPeerId
let extendedAvatarSize = CGSize(width: 35.0, height: 35.0)
let theme = environment.theme
let avatarSignal = peerAvatarCompleteImage(account: groupCall.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)
|> map { image -> UIImage? in
if isSelected, let image = image {
return generateImage(extendedAvatarSize, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0)
context.draw(image.cgImage!, in: CGRect(x: (extendedAvatarSize.width - avatarSize.width) / 2.0, y: (extendedAvatarSize.height - avatarSize.height) / 2.0, width: avatarSize.width, height: avatarSize.height))
let lineWidth = 1.0 + UIScreenPixel
context.setLineWidth(lineWidth)
context.setStrokeColor(theme.actionSheet.controlAccentColor.cgColor)
context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0))
})
} else {
return image
}
}
items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in
f(.default)
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
if peer.peer.id != myPeerId {
groupCall.reconnect(as: peer.peer.id)
}
})))
if peer.peer.id.namespace == Namespaces.Peer.CloudUser {
items.append(.separator)
}
}
}
return items
}
private func contextMenuAudioItems() -> [ContextMenuItem] {
guard let environment = self.environment else {
return []
}
guard let (availableOutputs, currentOutput) = self.audioOutputState else {
return []
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, iconPosition: .left, action: { (c, _) in
c?.popItems()
})))
items.append(.separator)
for output in availableOutputs {
let title: String
switch output {
case .builtin:
title = UIDevice.current.model
case .speaker:
title = environment.strings.Call_AudioRouteSpeaker
case .headphones:
title = environment.strings.Call_AudioRouteHeadphones
case let .port(port):
title = port.name
}
items.append(.action(ContextMenuActionItem(text: title, icon: { theme in
if output == currentOutput {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor)
} else {
return UIImage()
}
}, action: { [weak self] _, f in
f(.default)
guard let self, let currentCall = self.currentCall else {
return
}
currentCall.setCurrentAudioOutput(output)
})))
}
return items
}
private func contextMenuPermissionItems() -> [ContextMenuItem] {
guard let environment = self.environment, let callState = self.callState else {
return []
}
var items: [ContextMenuItem] = []
if callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState {
let isMuted = defaultParticipantMuteState == .muted
items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, iconPosition: .left, action: { (c, _) in
c?.popItems()
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionEveryone, icon: { theme in
if isMuted {
return UIImage()
} else {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor)
}
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
groupCall.updateDefaultParticipantsAreMuted(isMuted: false)
})))
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionAdmin, icon: { theme in
if !isMuted {
return UIImage()
} else {
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor)
}
}, action: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
groupCall.updateDefaultParticipantsAreMuted(isMuted: true)
})))
}
return items
}
}
@@ -0,0 +1,759 @@
import Foundation
import UIKit
import Display
import SSignalKit
import SwiftSignalKit
import AccountContext
import TelegramCore
import ContextUI
import DeleteChatPeerActionSheetItem
import UndoUI
import LegacyComponents
import WebSearchUI
import MapResourceToAvatarSizes
import LegacyUI
import LegacyMediaPickerUI
import AVFoundation
extension VideoChatScreenComponent.View {
func openParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) {
guard let environment = self.environment else {
return
}
guard let members = self.members, let participant = members.participants.first(where: { $0.id == .peer(id) }) else {
return
}
guard let currentCall = self.currentCall else {
return
}
let muteStatePromise = Promise<GroupCallParticipantsContext.Participant.MuteState?>(participant.muteState)
let itemsForEntry: (GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { [weak self] muteState in
guard let self, let environment = self.environment, let currentCall = self.currentCall else {
return []
}
guard let callState = self.callState else {
return []
}
guard let peer = participant.peer else {
return []
}
var items: [ContextMenuItem] = []
var hasVolumeSlider = false
if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou {
} else {
if callState.canManageCall || callState.myPeerId != id {
hasVolumeSlider = true
let minValue: CGFloat
if callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil {
minValue = 0.01
} else {
minValue = 0.0
}
items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: participant.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { [weak self] newValue, finished in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
if finished && newValue.isZero {
let updatedMuteState = groupCall.updateMuteState(peerId: peer.id, isMuted: true)
muteStatePromise.set(.single(updatedMuteState))
} else {
groupCall.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished)
}
}), true))
}
}
if callState.myPeerId == id && !hasVolumeSlider && ((participant.about?.isEmpty ?? true) || participant.peer?.smallProfileImage == nil) {
items.append(.custom(VoiceChatInfoContextItem(text: environment.strings.VoiceChat_ImproveYourProfileText, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor)
}), true))
}
if peer.id == callState.myPeerId {
if participant.hasRaiseHand {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_CancelSpeakRequest, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
groupCall.lowerHand()
f(.default)
})))
}
items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? environment.strings.VoiceChat_AddPhoto : environment.strings.VoiceChat_ChangePhoto, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
Queue.mainQueue().after(0.1) {
guard let self else {
return
}
self.openAvatarForEditing(fromGallery: false, completion: {})
}
})))
items.append(.action(ContextMenuActionItem(text: (participant.about?.isEmpty ?? true) ? environment.strings.VoiceChat_AddBio : environment.strings.VoiceChat_EditBio, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
Queue.mainQueue().after(0.1) {
guard let self, let environment = self.environment, let currentCall = self.currentCall else {
return
}
let maxBioLength: Int
if peer.id.namespace == Namespaces.Peer.CloudUser {
maxBioLength = 70
} else {
maxBioLength = 100
}
let controller = voiceChatTitleEditController(context: currentCall.accountContext, forceTheme: environment.theme, title: environment.strings.VoiceChat_EditBioTitle, text: environment.strings.VoiceChat_EditBioText, placeholder: environment.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, value: participant.about, maxLength: maxBioLength, apply: { [weak self] bio in
guard let self, let environment = self.environment, let currentCall = self.currentCall, let bio else {
return
}
if peer.id.namespace == Namespaces.Peer.CloudUser {
let _ = (currentCall.accountContext.engine.accountData.updateAbout(about: bio)
|> `catch` { _ -> Signal<Void, NoError> in
return .complete()
}).start()
} else {
let _ = (currentCall.accountContext.engine.peers.updatePeerDescription(peerId: peer.id, description: bio)
|> `catch` { _ -> Signal<Void, NoError> in
return .complete()
}).start()
}
self.presentToast(icon: .animation("anim_infotip"), text: environment.strings.VoiceChat_EditBioSuccess, duration: 4)
})
environment.controller()?.present(controller, in: .window(.root))
}
})))
if case let .user(peer) = peer {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ChangeName, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
f(.default)
Queue.mainQueue().after(0.1) {
guard let self, let environment = self.environment, let currentCall = self.currentCall else {
return
}
let controller = voiceChatUserNameController(context: currentCall.accountContext, forceTheme: environment.theme, title: environment.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: environment.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: environment.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { [weak self] firstAndLastName in
guard let self, let environment = self.environment, let currentCall = self.currentCall, let (firstName, lastName) = firstAndLastName else {
return
}
let _ = currentCall.accountContext.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).startStandalone()
self.presentToast(icon: .animation("anim_infotip"), text: environment.strings.VoiceChat_EditNameSuccess, duration: 4)
})
environment.controller()?.present(controller, in: .window(.root))
}
})))
}
} else {
if (callState.canManageCall || callState.adminIds.contains(currentCall.accountContext.account.peerId)) {
if callState.adminIds.contains(peer.id) {
if let _ = muteState {
} else {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let _ = groupCall.updateMuteState(peerId: peer.id, isMuted: true)
f(.default)
})))
}
} else {
if let muteState = muteState, !muteState.canUnmute {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmutePeer, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: participant.hasRaiseHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
let _ = groupCall.updateMuteState(peerId: peer.id, isMuted: false)
f(.default)
if let participantPeer = participant.peer {
let text = environment.strings.VoiceChat_UserCanNowSpeak(participantPeer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string
self.presentToast(icon: .animation("anim_vcspeak"), text: text, duration: 3)
}
})))
} else {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let _ = groupCall.updateMuteState(peerId: peer.id, isMuted: true)
f(.default)
})))
}
}
} else {
if let muteState = muteState, muteState.mutedByYou {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmuteForMe, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let _ = groupCall.updateMuteState(peerId: peer.id, isMuted: false)
f(.default)
})))
} else {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MuteForMe, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let _ = groupCall.updateMuteState(peerId: peer.id, isMuted: true)
f(.default)
})))
}
}
let openTitle: String
let openIcon: UIImage?
if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) {
if case let .channel(peer) = peer, case .broadcast = peer.info {
openTitle = environment.strings.VoiceChat_OpenChannel
openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels")
} else {
openTitle = environment.strings.VoiceChat_OpenGroup
openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups")
}
} else {
openTitle = environment.strings.Conversation_ContextMenuSendMessage
openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message")
}
items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in
return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] _, f in
guard let self else {
return
}
self.openPeer(peer)
f(.dismissWithoutContent)
})))
if case let .group(groupCall) = self.currentCall, (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != groupCall.peerId {
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { [weak self] c, _ in
c?.dismiss(completion: {
guard let self, case let .group(groupCall) = self.currentCall else {
return
}
let chatPeer: Signal<EnginePeer?, NoError>
if let peerId = groupCall.peerId {
chatPeer = groupCall.accountContext.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
} else {
chatPeer = .single(nil)
}
let _ = (chatPeer
|> deliverOnMainQueue).start(next: { [weak self] chatPeer in
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
let presentationData = groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
let nameDisplayOrder = presentationData.nameDisplayOrder
if let chatPeer {
items.append(DeleteChatPeerActionSheetItem(context: groupCall.accountContext, peer: peer, chatPeer: chatPeer, action: .removeFromGroup, strings: environment.strings, nameDisplayOrder: nameDisplayOrder))
} else {
items.append(ActionSheetTextItem(title: environment.strings.VoiceChat_RemoveConferencePeerConfirmation(peer.displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string, parseMarkdown: true))
}
items.append(ActionSheetButtonItem(title: environment.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self, let environment = self.environment, case let .group(groupCall) = self.currentCall else {
return
}
if groupCall.isConference {
groupCall.kickPeer(id: peer.id)
self.presentToast(icon: .animation("anim_banned"), text: environment.strings.VoiceChat_RemovedConferencePeerText(peer.displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string, duration: 3)
} else {
if let callPeerId = groupCall.peerId {
let _ = groupCall.accountContext.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: groupCall.accountContext.engine, peerId: callPeerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start()
groupCall.removedPeer(peer.id)
}
self.presentToast(icon: .animation("anim_banned"), text: environment.strings.VoiceChat_RemovedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string, duration: 3)
}
}))
actionSheet.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
environment.controller()?.present(actionSheet, in: .window(.root))
})
})
})))
}
}
return items
}
let items = muteStatePromise.get()
|> map { muteState -> [ContextMenuItem] in
return itemsForEntry(muteState)
}
let presentationData = currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let contextController = makeContextController(
presentationData: presentationData,
source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)),
items: items |> map { items in
return ContextController.Items(content: .list(items))
},
recognizer: nil,
gesture: gesture
)
environment.controller()?.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
controller.dismiss()
}
return true
})
environment.controller()?.presentInGlobalOverlay(contextController)
}
func openInvitedParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) {
guard let environment = self.environment else {
return
}
guard let currentCall = self.currentCall else {
return
}
guard case .group = self.currentCall else {
return
}
let itemsForEntry: () -> [ContextMenuItem] = { [weak self] in
guard let self, let environment = self.environment else {
return []
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { [weak self] c, _ in
c?.dismiss(result: .dismissWithoutContent, completion: nil)
guard let self else {
return
}
guard case let .group(groupCall) = self.currentCall else {
return
}
groupCall.kickPeer(id: id)
})))
return items
}
let items = itemsForEntry()
let presentationData = currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let contextController = makeContextController(
presentationData: presentationData,
source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)),
items: .single(ContextController.Items(content: .list(items))),
recognizer: nil,
gesture: gesture
)
environment.controller()?.forEachController({ controller in
if let controller = controller as? UndoOverlayController {
controller.dismiss()
}
return true
})
environment.controller()?.presentInGlobalOverlay(contextController)
}
func openPeer(_ peer: EnginePeer) {
guard let environment = self.environment, let currentCall = self.currentCall else {
return
}
guard let controller = environment.controller() as? VideoChatScreenV2Impl, let navigationController = controller.parentNavigationController else {
return
}
let context = currentCall.accountContext
controller.dismiss(completion: { [weak navigationController] in
Queue.mainQueue().after(0.1) {
guard let navigationController else {
return
}
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil))
}
})
}
private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) {
guard let currentCall = self.currentCall else {
return
}
guard let callState = self.callState else {
return
}
let peerId = callState.myPeerId
let _ = (currentCall.accountContext.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
TelegramEngine.EngineData.Item.Configuration.SearchBots()
)
|> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in
guard let self, let currentCall = self.currentCall, let environment = self.environment else {
return
}
guard let peer else {
return
}
let presentationData = currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme)
let legacyController = LegacyController(presentation: .custom, theme: environment.theme)
legacyController.statusBar.statusBarStyle = .Ignore
let emptyController = LegacyEmptyController(context: legacyController.context)!
let navigationController = makeLegacyNavigationController(rootController: emptyController)
navigationController.setNavigationBarHidden(true, animated: false)
navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0)
legacyController.bind(controller: navigationController)
self.endEditing(true)
environment.controller()?.present(legacyController, in: .window(.root))
var hasPhotos = false
if !peer.profileImageRepresentations.isEmpty {
hasPhotos = true
}
let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)!
mixin.forceDark = true
mixin.stickersContext = LegacyPaintStickersContext(context: currentCall.accountContext)
let _ = self.currentAvatarMixin.swap(mixin)
mixin.requestSearchController = { [weak self] assetsController in
guard let self, let currentCall = self.currentCall, let environment = self.environment else {
return
}
let controller = WebSearchController(context: currentCall.accountContext, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in
assetsController?.dismiss()
guard let self else {
return
}
self.updateProfilePhoto(result)
}))
controller.navigationPresentation = .modal
environment.controller()?.push(controller)
if fromGallery {
completion()
}
}
mixin.didFinishWithImage = { [weak self] image in
if let image = image {
completion()
self?.updateProfilePhoto(image)
}
}
mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in
if let image = image, let asset = asset {
completion()
self?.updateProfileVideo(image, asset: asset, adjustments: adjustments)
}
}
mixin.didFinishWithDelete = { [weak self] in
guard let self, let environment = self.environment else {
return
}
let proceed = { [weak self] in
guard let self, let currentCall = self.currentCall else {
return
}
let _ = self.currentAvatarMixin.swap(nil)
let postbox = currentCall.accountContext.account.postbox
self.updateAvatarDisposable.set((currentCall.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
|> deliverOnMainQueue).start())
}
let actionSheet = ActionSheetController(presentationData: presentationData)
let items: [ActionSheetItem] = [
ActionSheetButtonItem(title: environment.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
proceed()
})
]
actionSheet.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
environment.controller()?.present(actionSheet, in: .window(.root))
}
mixin.didDismiss = { [weak self, weak legacyController] in
guard let self else {
return
}
let _ = self.currentAvatarMixin.swap(nil)
legacyController?.dismiss()
}
let menuController = mixin.present()
if let menuController = menuController {
menuController.customRemoveFromParentViewController = { [weak legacyController] in
legacyController?.dismiss()
}
}
})
}
private func updateProfilePhoto(_ image: UIImage) {
guard let currentCall = self.currentCall else {
return
}
guard let callState = self.callState else {
return
}
guard let data = image.jpegData(compressionQuality: 0.6) else {
return
}
let peerId = callState.myPeerId
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
currentCall.accountContext.account.postbox.mediaBox.storeResourceData(resource.id, data: data)
let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)
self.currentUpdatingAvatar = (representation, 0.0)
let postbox = currentCall.accountContext.account.postbox
let signal = peerId.namespace == Namespaces.Peer.CloudUser ? currentCall.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
}) : currentCall.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: currentCall.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations)
})
self.updateAvatarDisposable.set((signal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
switch result {
case .complete:
self.currentUpdatingAvatar = nil
self.state?.updated(transition: .spring(duration: 0.4))
case let .progress(value):
self.currentUpdatingAvatar = (representation, value)
}
}))
self.state?.updated(transition: .spring(duration: 0.4))
}
private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) {
guard let currentCall = self.currentCall else {
return
}
guard let callState = self.callState else {
return
}
guard let data = image.jpegData(compressionQuality: 0.6) else {
return
}
let peerId = callState.myPeerId
let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
currentCall.accountContext.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data)
let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)
self.currentUpdatingAvatar = (representation, 0.0)
var videoStartTimestamp: Double? = nil
if let adjustments = adjustments, adjustments.videoStartValue > 0.0 {
videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue
}
let context = currentCall.accountContext
let account = context.account
let signal = Signal<TelegramMediaResource, UploadPeerPhotoError> { [weak self] subscriber in
let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in
if let paintingData = adjustments.paintingData, paintingData.hasAnimation {
return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments)
} else {
return nil
}
}
let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4")
let uploadInterface = LegacyLiveUploadInterface(context: context)
let signal: SSignal
if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer {
let durationSignal: SSignal = SSignal(generator: { subscriber in
let disposable = (entityRenderer.duration()).start(next: { duration in
subscriber.putNext(duration)
subscriber.putCompletion()
})
return SBlockDisposable(block: {
disposable.dispose()
})
})
signal = durationSignal.map(toSignal: { duration -> SSignal in
if let duration = duration as? Double {
return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)!
} else {
return SSignal.single(nil)
}
})
} else if let asset = asset as? AVAsset {
signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)!
} else {
signal = SSignal.complete()
}
let signalDisposable = signal.start(next: { next in
if let result = next as? TGMediaVideoConversionResult {
if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) {
account.postbox.mediaBox.storeResourceData(photoResource.id, data: data)
}
if let timestamp = videoStartTimestamp {
videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05))
}
var value = stat()
if stat(result.fileURL.path, &value) == 0 {
if let data = try? Data(contentsOf: result.fileURL) {
let resource: TelegramMediaResource
if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult {
resource = LocalFileMediaResource(fileId: liveUploadData.id)
} else {
resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
}
account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true)
subscriber.putNext(resource)
EngineTempBox.shared.dispose(tempFile)
}
}
subscriber.putCompletion()
} else if let progress = next as? NSNumber {
Queue.mainQueue().async { [weak self] in
guard let self else {
return
}
self.currentUpdatingAvatar = (representation, Float(truncating: progress) * 0.25)
self.state?.updated(transition: .spring(duration: 0.4))
}
}
}, error: { _ in
}, completed: nil)
let disposable = ActionDisposable {
signalDisposable?.dispose()
}
return ActionDisposable {
disposable.dispose()
}
}
self.updateAvatarDisposable.set((signal
|> mapToSignal { videoResource -> Signal<UpdatePeerPhotoStatus, UploadPeerPhotoError> in
if peerId.namespace == Namespaces.Peer.CloudUser {
return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
} else {
return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in
return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations)
})
}
}
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
switch result {
case .complete:
self.currentUpdatingAvatar = nil
self.state?.updated(transition: .spring(duration: 0.4))
case let .progress(value):
self.currentUpdatingAvatar = (representation, 0.25 + value * 0.75)
self.state?.updated(transition: .spring(duration: 0.4))
}
}))
}
}
private final class ParticipantExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = false
let blurBackground: Bool = true
private let contentView: ContextExtractedContentContainingView
init(contentView: ContextExtractedContentContainingView) {
self.contentView = contentView
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,303 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import MultilineTextComponent
import TelegramPresentationData
import HierarchyTrackingLayer
import ChatTitleActivityNode
import AnimatedTextComponent
final class VideoChatTitleComponent: Component {
let title: String
let status: [AnimatedTextComponent.Item]
let isRecording: Bool
let isLandscape: Bool
let strings: PresentationStrings
let tapAction: (() -> Void)?
let longTapAction: (() -> Void)?
init(
title: String,
status: [AnimatedTextComponent.Item],
isRecording: Bool,
isLandscape: Bool,
strings: PresentationStrings,
tapAction: (() -> Void)?,
longTapAction: (() -> Void)?
) {
self.title = title
self.status = status
self.isRecording = isRecording
self.isLandscape = isLandscape
self.strings = strings
self.tapAction = tapAction
self.longTapAction = longTapAction
}
static func ==(lhs: VideoChatTitleComponent, rhs: VideoChatTitleComponent) -> Bool {
if lhs.title != rhs.title {
return false
}
if lhs.status != rhs.status {
return false
}
if lhs.isRecording != rhs.isRecording {
return false
}
if lhs.isLandscape != rhs.isLandscape {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if (lhs.tapAction == nil) != (rhs.tapAction == nil) {
return false
}
if (lhs.longTapAction == nil) != (rhs.longTapAction == nil) {
return false
}
return true
}
final class View: UIView {
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private let title = ComponentView<Empty>()
private let status = ComponentView<Empty>()
private var recordingImageView: UIImageView?
private var activityStatusNode: ChatTitleActivityNode?
private var component: VideoChatTitleComponent?
private var isUpdating: Bool = false
private var currentActivityStatus: String?
private var currentSize: CGSize?
private var tapRecognizer: TapLongTapOrDoubleTapGestureRecognizer?
public var recordingIndicatorView: UIView? {
return self.recordingImageView
}
override init(frame: CGRect) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
super.init(frame: frame)
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.updateAnimations()
}
let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
tapRecognizer.tapActionAtPoint = { _ in
return .waitForSingleTap
}
self.addGestureRecognizer(tapRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation {
if case .tap = gesture {
component.tapAction?()
} else if case .longTap = gesture {
component.longTapAction?()
}
}
}
}
private func updateAnimations() {
if let recordingImageView = self.recordingImageView {
if recordingImageView.layer.animation(forKey: "blink") == nil {
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber]
animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber]
animation.duration = 0.7
animation.autoreverses = true
animation.repeatCount = Float.infinity
recordingImageView.layer.add(animation, forKey: "blink")
}
}
}
func updateActivityStatus(value: String?, transition: ComponentTransition) {
if self.currentActivityStatus == value {
return
}
self.currentActivityStatus = value
guard let currentSize = self.currentSize, let statusView = self.status.view else {
return
}
let alphaTransition: ComponentTransition
if transition.animation.isImmediate {
alphaTransition = .immediate
} else {
alphaTransition = .easeInOut(duration: 0.2)
}
if let value {
let activityStatusNode: ChatTitleActivityNode
if let current = self.activityStatusNode {
activityStatusNode = current
} else {
activityStatusNode = ChatTitleActivityNode()
self.activityStatusNode = activityStatusNode
}
let _ = activityStatusNode.transitionToState(.recordingVoice(NSAttributedString(string: value, font: Font.regular(13.0), textColor: UIColor(rgb: 0x34c759)), UIColor(rgb: 0x34c759)), animation: .none)
let activityStatusSize = activityStatusNode.updateLayout(CGSize(width: currentSize.width, height: 100.0), alignment: .center)
let activityStatusFrame = CGRect(origin: CGPoint(x: floor((currentSize.width - activityStatusSize.width) * 0.5), y: statusView.center.y - activityStatusSize.height * 0.5 + 7.0), size: activityStatusSize)
let activityStatusNodeView = activityStatusNode.view
activityStatusNodeView.center = activityStatusFrame.center
activityStatusNodeView.bounds = CGRect(origin: CGPoint(), size: activityStatusFrame.size)
if activityStatusNodeView.superview == nil {
self.addSubview(activityStatusNode.view)
ComponentTransition.immediate.setTransform(view: activityStatusNodeView, transform: CATransform3DMakeTranslation(0.0, -10.0, 0.0))
activityStatusNodeView.alpha = 0.0
}
transition.setTransform(view: activityStatusNodeView, transform: CATransform3DIdentity)
alphaTransition.setAlpha(view: activityStatusNodeView, alpha: 1.0)
transition.setTransform(view: statusView, transform: CATransform3DMakeTranslation(0.0, 10.0, 0.0))
alphaTransition.setAlpha(view: statusView, alpha: 0.0)
} else {
if let activityStatusNode = self.activityStatusNode {
self.activityStatusNode = nil
let activityStatusNodeView = activityStatusNode.view
transition.setTransform(view: activityStatusNodeView, transform: CATransform3DMakeTranslation(0.0, -10.0, 0.0))
alphaTransition.setAlpha(view: activityStatusNodeView, alpha: 0.0, completion: { [weak activityStatusNodeView] _ in
activityStatusNodeView?.removeFromSuperview()
})
}
transition.setTransform(view: statusView, transform: CATransform3DIdentity)
alphaTransition.setAlpha(view: statusView, alpha: 1.0)
}
}
func update(component: VideoChatTitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
self.component = component
self.tapRecognizer?.isEnabled = component.longTapAction != nil || component.tapAction != nil
let spacing: CGFloat = 0.0
var maxTitleWidth = availableSize.width
if component.isRecording {
maxTitleWidth -= 10.0
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: .white))
)),
environment: {},
containerSize: CGSize(width: maxTitleWidth, height: 100.0)
)
let statusComponent: AnyComponent<Empty>
statusComponent = AnyComponent(AnimatedTextComponent(
font: Font.regular(12.0),
color: UIColor(white: 1.0, alpha: 0.5),
items: component.status
))
let statusSize = self.status.update(
transition: transition,
component: statusComponent,
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let size = CGSize(width: availableSize.width, height: titleSize.height + spacing + statusSize.height)
var titleFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: titleSize)
if !component.isLandscape {
titleFrame.origin.x = floor((size.width - titleSize.width) * 0.5)
}
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.layer.anchorPoint = CGPoint()
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
}
var statusFrame = CGRect(origin: CGPoint(x: 0.0, y: titleFrame.maxY + spacing), size: statusSize)
if !component.isLandscape {
statusFrame.origin.x = floor((size.width - statusSize.width) * 0.5)
}
if let statusView = self.status.view {
if statusView.superview == nil {
statusView.layer.anchorPoint = CGPoint()
statusView.isUserInteractionEnabled = false
self.addSubview(statusView)
}
transition.setPosition(view: statusView, position: statusFrame.origin)
statusView.bounds = CGRect(origin: CGPoint(), size: statusFrame.size)
}
if component.isRecording {
var recordingImageTransition = transition
let recordingImageView: UIImageView
if let current = self.recordingImageView {
recordingImageView = current
} else {
recordingImageTransition = recordingImageTransition.withAnimation(.none)
recordingImageView = UIImageView()
recordingImageView.image = generateFilledCircleImage(diameter: 8.0, color: UIColor(rgb: 0xFF3B2F))
self.recordingImageView = recordingImageView
self.addSubview(recordingImageView)
transition.animateScale(view: recordingImageView, from: 0.0001, to: 1.0)
}
let recordingImageFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 5.0, y: titleFrame.minY + floor(titleFrame.height - 8.0) * 0.5 + 1.0), size: CGSize(width: 8.0, height: 8.0))
recordingImageTransition.setFrame(view: recordingImageView, frame: recordingImageFrame)
self.updateAnimations()
} else {
if let recordingImageView = self.recordingImageView {
self.recordingImageView = nil
transition.setScale(view: recordingImageView, scale: 0.0001, completion: { [weak recordingImageView] _ in
recordingImageView?.removeFromSuperview()
})
}
}
self.currentSize = size
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,24 @@
import Foundation
import SwiftSignalKit
import TelegramCore
enum VideoChatNotificationIcon {
case peer(EnginePeer)
case icon(String)
case animation(String)
}
extension VideoChatScreenComponent.View {
func presentToast(icon: VideoChatNotificationIcon, text: String, duration: Int32) {
let id = Int64.random(in: 0 ..< .max)
let expiresOn = Int32(CFAbsoluteTimeGetCurrent()) + duration
self.toastMessages.append((id: id, icon: icon, text: text, expiresOn: expiresOn))
self.state?.updated(transition: .spring(duration: 0.4))
Queue.mainQueue().after(Double(duration)) {
self.toastMessages.removeAll(where: { $0.id == id })
self.state?.updated(transition: .spring(duration: 0.4))
}
}
}
@@ -0,0 +1,6 @@
import Foundation
import SwiftSignalKit
final class VideoChatVideoContext {
}
@@ -0,0 +1,203 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import HierarchyTrackingLayer
private let shadowImage: UIImage? = {
UIImage(named: "Stories/PanelGradient")
}()
private func generateGradient(baseAlpha: CGFloat) -> UIImage? {
return generateImage(CGSize(width: 200.0, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0))
if let shadowImage {
UIGraphicsPushContext(context)
for i in 0 ..< 2 {
let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height))
context.saveGState()
context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY)
context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5)
let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width))
context.clip(to: adjustedRect, mask: shadowImage.cgImage!)
context.setFillColor(foregroundColor.cgColor)
context.fill(adjustedRect)
context.restoreGState()
}
UIGraphicsPopContext()
}
})
}
private final class AnimatedGradientView: UIView {
private struct Params: Equatable {
var size: CGSize
var containerWidth: CGFloat
var offsetX: CGFloat
var gradientWidth: CGFloat
init(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat) {
self.size = size
self.containerWidth = containerWidth
self.offsetX = offsetX
self.gradientWidth = gradientWidth
}
}
private let duration: Double
private let hierarchyTrackingLayer: HierarchyTrackingLayer
private let backgroundContainerView: UIView
private let backgroundScaleView: UIView
private let backgroundOffsetView: UIView
private let backgroundView: UIImageView
private var params: Params?
init(effectAlpha: CGFloat, duration: Double) {
self.hierarchyTrackingLayer = HierarchyTrackingLayer()
self.duration = duration
self.backgroundContainerView = UIView()
self.backgroundContainerView.layer.anchorPoint = CGPoint()
self.backgroundScaleView = UIView()
self.backgroundOffsetView = UIView()
self.backgroundView = UIImageView()
super.init(frame: CGRect())
self.layer.addSublayer(self.hierarchyTrackingLayer)
self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in
guard let self else {
return
}
self.updateAnimations()
}
self.backgroundView.image = generateGradient(baseAlpha: effectAlpha)
self.backgroundOffsetView.addSubview(self.backgroundView)
self.backgroundScaleView.addSubview(self.backgroundOffsetView)
self.backgroundContainerView.addSubview(self.backgroundScaleView)
self.addSubview(self.backgroundContainerView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateAnimations() {
if self.backgroundView.layer.animation(forKey: "shimmer") == nil {
let animation = self.backgroundView.layer.makeAnimation(from: -1.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
animation.beginTime = 1.0
self.backgroundView.layer.add(animation, forKey: "shimmer")
}
if self.backgroundScaleView.layer.animation(forKey: "shimmer") == nil {
let animation = self.backgroundScaleView.layer.makeAnimation(from: 0.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
animation.beginTime = 1.0
self.backgroundScaleView.layer.add(animation, forKey: "shimmer")
}
}
func update(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat, transition: ComponentTransition) {
let params = Params(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth)
if self.params == params {
return
}
self.params = params
let backgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0))
transition.setPosition(view: self.backgroundView, position: backgroundFrame.center)
transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.setPosition(view: self.backgroundOffsetView, position: backgroundFrame.center)
transition.setBounds(view: self.backgroundOffsetView, bounds: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.setTransform(view: self.backgroundOffsetView, transform: CATransform3DMakeScale(gradientWidth, 1.0, 1.0))
let backgroundContainerViewSubFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
transition.setPosition(view: self.backgroundContainerView, position: CGPoint())
transition.setBounds(view: self.backgroundContainerView, bounds: backgroundContainerViewSubFrame)
var containerTransform = CATransform3DIdentity
containerTransform = CATransform3DTranslate(containerTransform, -offsetX, 0.0, 0.0)
containerTransform = CATransform3DScale(containerTransform, containerWidth, size.height, 1.0)
transition.setSublayerTransform(view: self.backgroundContainerView, transform: containerTransform)
transition.setSublayerTransform(view: self.backgroundScaleView, transform: CATransform3DMakeScale(1.0 / containerWidth, 1.0, 1.0))
self.updateAnimations()
}
}
final class VideoChatVideoLoadingEffectView: UIView {
private struct Params: Equatable {
var size: CGSize
var containerWidth: CGFloat
var offsetX: CGFloat
var gradientWidth: CGFloat
init(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat) {
self.size = size
self.containerWidth = containerWidth
self.offsetX = offsetX
self.gradientWidth = gradientWidth
}
}
private let duration: Double
private let cornerRadius: CGFloat
private let backgroundView: AnimatedGradientView
private let borderMaskView: UIImageView
private let borderBackgroundView: AnimatedGradientView
private var params: Params?
init(effectAlpha: CGFloat, borderAlpha: CGFloat, cornerRadius: CGFloat = 12.0, duration: Double) {
self.duration = duration
self.cornerRadius = cornerRadius
self.backgroundView = AnimatedGradientView(effectAlpha: effectAlpha, duration: duration)
self.borderMaskView = UIImageView()
self.borderMaskView.image = generateStretchableFilledCircleImage(diameter: cornerRadius * 2.0, color: nil, strokeColor: .white, strokeWidth: 2.0)
self.borderBackgroundView = AnimatedGradientView(effectAlpha: borderAlpha, duration: duration)
super.init(frame: CGRect())
self.addSubview(self.backgroundView)
self.borderBackgroundView.mask = self.borderMaskView
self.addSubview(self.borderBackgroundView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat, transition: ComponentTransition) {
let params = Params(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth)
if self.params == params {
return
}
self.params = params
self.backgroundView.update(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth, transition: transition)
self.borderBackgroundView.update(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth, transition: transition)
transition.setFrame(view: self.borderMaskView, frame: CGRect(origin: CGPoint(), size: size))
}
}
@@ -0,0 +1,86 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AccountContext
import TelegramVoip
import AVFoundation
protocol VideoRenderingView: UIView {
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void)
func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void)
func getOrientation() -> PresentationCallVideoView.Orientation
func getAspect() -> CGFloat
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void)
func updateIsEnabled(_ isEnabled: Bool)
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
}
class VideoRenderingContext {
private var metalContextImpl: Any?
#if targetEnvironment(simulator)
#else
@available(iOS 13.0, *)
var metalContext: MetalVideoRenderingContext {
if let value = self.metalContextImpl as? MetalVideoRenderingContext {
return value
} else {
let value = MetalVideoRenderingContext()!
self.metalContextImpl = value
return value
}
}
#endif
func makeView(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, blur: Bool, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? {
#if targetEnvironment(simulator)
if blur {
#if DEBUG
return SampleBufferVideoRenderingView(input: input)
#else
return nil
#endif
}
return SampleBufferVideoRenderingView(input: input)
#else
if #available(iOS 13.0, *), !forceSampleBufferDisplayLayer {
return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: blur)
} else {
if blur {
return nil
}
return SampleBufferVideoRenderingView(input: input)
}
#endif
}
func makeBlurView(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, mainView: VideoRenderingView?, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? {
return self.makeView(input: input, blur: true, forceSampleBufferDisplayLayer: forceSampleBufferDisplayLayer)
}
func updateVisibility(isVisible: Bool) {
#if targetEnvironment(simulator)
#else
if #available(iOS 13.0, *) {
self.metalContext.updateVisibility(isVisible: isVisible)
}
#endif
}
}
extension PresentationCallVideoView.Orientation {
init(_ orientation: OngoingCallVideoOrientation) {
switch orientation {
case .rotation0:
self = .rotation0
case .rotation90:
self = .rotation90
case .rotation180:
self = .rotation180
case .rotation270:
self = .rotation270
}
}
}
@@ -0,0 +1,122 @@
import Foundation
import UIKit
import Display
import Postbox
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AnimatedStickerNode
import AppBundle
public final class VoiceChatAccountHeaderActionSheetItem: ActionSheetItem {
let title: String
let text: String
public init(title: String, text: String) {
self.title = title
self.text = text
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
return VoiceChatAccountHeaderActionSheetItemNode(theme: theme, title: self.title, text: self.text)
}
public func updateNode(_ node: ActionSheetItemNode) {
}
}
private final class VoiceChatAccountHeaderActionSheetItemNode: ActionSheetItemNode {
private let theme: ActionSheetControllerTheme
private let iconBackgroundNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: ImmediateTextNode
private let textNode: ImmediateTextNode
private let accessibilityArea: AccessibilityAreaNode
init(theme: ActionSheetControllerTheme, title: String, text: String) {
self.theme = theme
let titleFont = Font.bold(floor(theme.baseFontSize))
let textFont = Font.regular(floor(theme.baseFontSize * 13.0 / 17.0))
self.iconBackgroundNode = ASImageNode()
self.iconBackgroundNode.displaysAsynchronously = false
self.iconBackgroundNode.displayWithoutProcessing = true
self.iconBackgroundNode.image = generateImage(CGSize(width: 72.0, height: 72.0), contextGenerator: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.addPath(UIBezierPath(ovalIn: bounds).cgPath)
context.clip()
var locations: [CGFloat] = [0.0, 1.0]
let colorsArray: NSArray = [UIColor(rgb: 0x2a9ef1).cgColor, UIColor(rgb: 0x72d5fd).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colorsArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
})
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/Accounts"), color: UIColor.white)
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 0
self.titleNode.textAlignment = .center
self.titleNode.isAccessibilityElement = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.maximumNumberOfLines = 0
self.textNode.textAlignment = .center
self.textNode.isAccessibilityElement = false
self.accessibilityArea = AccessibilityAreaNode()
super.init(theme: theme)
self.addSubnode(self.iconBackgroundNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
self.addSubnode(self.accessibilityArea)
self.titleNode.attributedText = NSAttributedString(string: title, font: titleFont, textColor: theme.primaryTextColor)
self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: theme.secondaryTextColor)
self.accessibilityArea.accessibilityLabel = text
self.accessibilityArea.accessibilityTraits = .staticText
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let titleSize = self.titleNode.updateLayout(CGSize(width: constrainedSize.width - 80.0, height: .greatestFiniteMagnitude))
let textSize = self.textNode.updateLayout(CGSize(width: constrainedSize.width - 80.0, height: .greatestFiniteMagnitude))
let topInset: CGFloat = 20.0
let titleSpacing: CGFloat = 10.0
let textSpacing: CGFloat = 6.0
let bottomInset: CGFloat = 14.0
let iconSize = CGSize(width: 72.0, height: 72.0)
let iconFrame = CGRect(origin: CGPoint(x: floor((constrainedSize.width - iconSize.width) / 2.0), y: topInset), size: iconSize)
self.iconBackgroundNode.frame = iconFrame
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: iconFrame.minX + floorToScreenPixels((iconSize.width - image.size.width) / 2.0), y: iconFrame.minY + floorToScreenPixels((iconSize.height - image.size.height) / 2.0)), size: image.size)
}
self.titleNode.frame = CGRect(origin: CGPoint(x: floor((constrainedSize.width - titleSize.width) / 2.0), y: topInset + iconSize.height + titleSpacing), size: titleSize)
self.textNode.frame = CGRect(origin: CGPoint(x: floor((constrainedSize.width - textSize.width) / 2.0), y: topInset + iconSize.height + titleSpacing + titleSize.height + textSpacing), size: textSize)
let size = CGSize(width: constrainedSize.width, height: topInset + iconSize.height + titleSpacing + titleSize.height + textSpacing + textSize.height + bottomInset)
self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size)
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
}
@@ -0,0 +1,287 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
public enum VoiceChatActionItemIcon : Equatable {
case none
case generic(UIImage)
public var image: UIImage? {
switch self {
case .none:
return nil
case let .generic(image):
return image
}
}
public static func ==(lhs: VoiceChatActionItemIcon, rhs: VoiceChatActionItemIcon) -> Bool {
switch lhs {
case .none:
if case .none = rhs {
return true
} else {
return false
}
case let .generic(image):
if case .generic(image) = rhs {
return true
} else {
return false
}
}
}
}
class VoiceChatActionItem: ListViewItem {
let presentationData: ItemListPresentationData
let title: String
let icon: VoiceChatActionItemIcon
let action: () -> Void
init(presentationData: ItemListPresentationData, title: String, icon: VoiceChatActionItemIcon, action: @escaping () -> Void) {
self.presentationData = presentationData
self.title = title
self.icon = icon
self.action = action
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = VoiceChatActionItemNode()
let (layout, apply) = node.asyncLayout()(self, params, previousItem == nil || previousItem is VoiceChatTilesGridItem, nextItem == nil)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? VoiceChatActionItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, previousItem == nil || previousItem is VoiceChatTilesGridItem, nextItem == nil)
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
var selectable: Bool = true
func selected(listView: ListView){
self.action()
listView.clearHighlightAnimated(true)
}
}
class VoiceChatActionItemNode: ListViewItemNode {
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightContainerNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private let activateArea: AccessibilityAreaNode
private var item: VoiceChatActionItem?
init() {
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.highlightContainerNode = ASDisplayNode()
self.highlightContainerNode.clipsToBounds = true
self.highlightedBackgroundNode = ASDisplayNode()
self.activateArea = AccessibilityAreaNode()
super.init(layerBacked: false)
self.highlightContainerNode.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.activateArea)
self.activateArea.activate = { [weak self] in
self?.item?.action()
return true
}
}
override func didLoad() {
super.didLoad()
if #available(iOS 13.0, *) {
self.highlightContainerNode.layer.cornerCurve = .continuous
}
}
func asyncLayout() -> (_ item: VoiceChatActionItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let currentItem = self.item
return { item, params, first, last in
var updatedTheme: PresentationTheme?
var updatedContent = false
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
}
if currentItem?.title != item.title {
updatedContent = true
}
let titleFont = Font.regular(17.0)
var leftInset: CGFloat = 8.0 + params.leftInset
if case .generic = item.icon {
leftInset += 49.0
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemAccentColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - 10.0 - leftInset - params.rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let contentHeight: CGFloat = 12.0 * 2.0 + titleLayout.size.height
let contentSize = CGSize(width: params.width, height: contentHeight)
let insets = UIEdgeInsets()
let separatorHeight = UIScreenPixel
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
guard params.width > 0.0 else {
return
}
strongSelf.activateArea.accessibilityLabel = item.title
strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: layout.contentSize.width - params.leftInset - params.rightInset, height: layout.contentSize.height))
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.08)
strongSelf.bottomStripeNode.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.08)
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
strongSelf.iconNode.image = generateTintedImage(image: item.icon.image, color: item.presentationData.theme.list.itemAccentColor)
} else if updatedContent {
strongSelf.iconNode.image = generateTintedImage(image: item.icon.image, color: item.presentationData.theme.list.itemAccentColor)
}
let _ = titleApply()
let titleOffset = leftInset
let hideBottomStripe: Bool = last
if let image = item.icon.image {
let iconFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - image.size.width) / 2.0) - 1.0, y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)
strongSelf.iconNode.frame = iconFrame
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 0)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 1)
}
strongSelf.topStripeNode.isHidden = true
strongSelf.bottomStripeNode.isHidden = hideBottomStripe
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight))
strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: titleOffset + 1.0, y: floor((contentSize.height - titleLayout.size.height) / 2.0)), size: titleLayout.size)
strongSelf.highlightContainerNode.frame = CGRect(origin: CGPoint(x: params.leftInset, y: -UIScreenPixel), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel + 11.0))
strongSelf.highlightContainerNode.cornerRadius = first ? 11.0 : 0.0
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: contentSize.height + UIScreenPixel + UIScreenPixel))
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightContainerNode.alpha = 1.0
if self.highlightContainerNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightContainerNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightContainerNode)
}
}
} else {
if self.highlightContainerNode.supernode != nil {
if animated {
self.highlightContainerNode.layer.animateAlpha(from: self.highlightContainerNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightContainerNode.removeFromSupernode()
}
}
})
self.highlightContainerNode.alpha = 0.0
} else {
self.highlightContainerNode.removeFromSupernode()
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
override public func headers() -> [ListViewItemHeader]? {
return nil
}
}
@@ -0,0 +1,685 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import SolidRoundedButtonNode
import PresentationDataUtils
import UIKitRuntimeUtils
import ReplayKit
private let accentColor: UIColor = UIColor(rgb: 0x0088ff)
protocol PreviewVideoNode: ASDisplayNode {
var ready: Signal<Bool, NoError> { get }
func flip(withBackground: Bool)
func updateIsBlurred(isBlurred: Bool, light: Bool, animated: Bool)
func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition)
}
final class VoiceChatCameraPreviewController: ViewController {
private var controllerNode: VoiceChatCameraPreviewControllerNode {
return self.displayNode as! VoiceChatCameraPreviewControllerNode
}
private let sharedContext: SharedAccountContext
private var animatedIn = false
private let cameraNode: PreviewVideoNode
private let shareCamera: (ASDisplayNode, Bool) -> Void
private let switchCamera: () -> Void
private var presentationDataDisposable: Disposable?
init(sharedContext: SharedAccountContext, cameraNode: PreviewVideoNode, shareCamera: @escaping (ASDisplayNode, Bool) -> Void, switchCamera: @escaping () -> Void) {
self.sharedContext = sharedContext
self.cameraNode = cameraNode
self.shareCamera = shareCamera
self.switchCamera = switchCamera
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.blocksBackgroundWhenInOverlay = true
self.presentationDataDisposable = (sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = VoiceChatCameraPreviewControllerNode(controller: self, sharedContext: self.sharedContext, cameraNode: self.cameraNode)
self.controllerNode.shareCamera = { [weak self] unmuted in
if let strongSelf = self {
strongSelf.shareCamera(strongSelf.cameraNode, unmuted)
strongSelf.dismiss()
}
}
self.controllerNode.switchCamera = { [weak self] in
self?.switchCamera()
self?.cameraNode.flip(withBackground: false)
}
self.controllerNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.cancel = { [weak self] in
self?.dismiss()
}
}
override public func loadView() {
super.loadView()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.controllerNode.animateOut(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
private class VoiceChatCameraPreviewControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
private weak var controller: VoiceChatCameraPreviewController?
private let sharedContext: SharedAccountContext
private var presentationData: PresentationData
private let cameraNode: PreviewVideoNode
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let contentContainerNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
private let contentBackgroundNode: ASDisplayNode
private let titleNode: ASTextNode
private let previewContainerNode: ASDisplayNode
private let shimmerNode: ShimmerEffectForegroundNode
private let doneButton: SolidRoundedButtonNode
private var broadcastPickerView: UIView?
private let cancelButton: HighlightableButtonNode
private let placeholderTextNode: ImmediateTextNode
private let placeholderIconNode: ASImageNode
private var wheelNode: WheelControlNode
private var selectedTabIndex: Int = 1
private var containerLayout: (ContainerViewLayout, CGFloat)?
private var applicationStateDisposable: Disposable?
private let hapticFeedback = HapticFeedback()
private let readyDisposable = MetaDisposable()
var shareCamera: ((Bool) -> Void)?
var switchCamera: (() -> Void)?
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
init(controller: VoiceChatCameraPreviewController, sharedContext: SharedAccountContext, cameraNode: PreviewVideoNode) {
self.controller = controller
self.sharedContext = sharedContext
self.presentationData = sharedContext.currentPresentationData.with { $0 }
self.cameraNode = cameraNode
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
self.wrappingScrollNode.view.canCancelContentTouches = true
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.isOpaque = false
self.backgroundNode = ASDisplayNode()
self.backgroundNode.clipsToBounds = true
self.backgroundNode.cornerRadius = 16.0
let backgroundColor = UIColor(rgb: 0x000000)
self.contentBackgroundNode = ASDisplayNode()
self.contentBackgroundNode.backgroundColor = backgroundColor
let title = self.presentationData.strings.VoiceChat_VideoPreviewTitle
self.titleNode = ASTextNode()
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.bold(17.0), textColor: UIColor(rgb: 0xffffff))
self.doneButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0xffffff), foregroundColor: UIColor(rgb: 0x4f5352)), font: .bold, height: 48.0, cornerRadius: 24.0)
self.doneButton.title = self.presentationData.strings.VoiceChat_VideoPreviewContinue
if #available(iOS 12.0, *) {
let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0))
broadcastPickerView.alpha = 0.02
broadcastPickerView.isHidden = true
broadcastPickerView.preferredExtension = "\(self.sharedContext.applicationBindings.appBundleId).BroadcastUpload"
broadcastPickerView.showsMicrophoneButton = false
self.broadcastPickerView = broadcastPickerView
}
self.cancelButton = HighlightableButtonNode()
self.cancelButton.setAttributedTitle(NSAttributedString(string: self.presentationData.strings.Common_Cancel, font: Font.regular(17.0), textColor: UIColor(rgb: 0xffffff)), for: [])
self.previewContainerNode = ASDisplayNode()
self.previewContainerNode.clipsToBounds = true
self.previewContainerNode.cornerRadius = 11.0
self.previewContainerNode.backgroundColor = UIColor(rgb: 0x2b2b2f)
self.shimmerNode = ShimmerEffectForegroundNode(size: 200.0)
self.previewContainerNode.addSubnode(self.shimmerNode)
self.placeholderTextNode = ImmediateTextNode()
self.placeholderTextNode.alpha = 0.0
self.placeholderTextNode.maximumNumberOfLines = 3
self.placeholderTextNode.textAlignment = .center
self.placeholderIconNode = ASImageNode()
self.placeholderIconNode.alpha = 0.0
self.placeholderIconNode.contentMode = .scaleAspectFit
self.placeholderIconNode.displaysAsynchronously = false
self.wheelNode = WheelControlNode(items: [WheelControlNode.Item(title: UIDevice.current.model == "iPad" ? self.presentationData.strings.VoiceChat_VideoPreviewTabletScreen : self.presentationData.strings.VoiceChat_VideoPreviewPhoneScreen), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewFrontCamera), WheelControlNode.Item(title: self.presentationData.strings.VoiceChat_VideoPreviewBackCamera)], selectedIndex: self.selectedTabIndex)
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.addSubnode(self.dimNode)
self.wrappingScrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.wrappingScrollNode)
self.wrappingScrollNode.addSubnode(self.backgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
self.backgroundNode.addSubnode(self.contentBackgroundNode)
self.contentContainerNode.addSubnode(self.previewContainerNode)
self.contentContainerNode.addSubnode(self.titleNode)
self.contentContainerNode.addSubnode(self.doneButton)
if let broadcastPickerView = self.broadcastPickerView {
self.contentContainerNode.view.addSubview(broadcastPickerView)
}
self.contentContainerNode.addSubnode(self.cancelButton)
self.previewContainerNode.addSubnode(self.cameraNode)
self.previewContainerNode.addSubnode(self.placeholderIconNode)
self.previewContainerNode.addSubnode(self.placeholderTextNode)
self.previewContainerNode.addSubnode(self.wheelNode)
self.wheelNode.selectedIndexChanged = { [weak self] index in
if let strongSelf = self {
if (index == 1 && strongSelf.selectedTabIndex == 2) || (index == 2 && strongSelf.selectedTabIndex == 1) {
strongSelf.switchCamera?()
}
if index == 0 && [1, 2].contains(strongSelf.selectedTabIndex) {
strongSelf.broadcastPickerView?.isHidden = false
strongSelf.cameraNode.updateIsBlurred(isBlurred: true, light: false, animated: true)
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 1.0)
transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 1.0)
} else if [1, 2].contains(index) && strongSelf.selectedTabIndex == 0 {
strongSelf.broadcastPickerView?.isHidden = true
strongSelf.cameraNode.updateIsBlurred(isBlurred: false, light: false, animated: true)
let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
transition.updateAlpha(node: strongSelf.placeholderTextNode, alpha: 0.0)
transition.updateAlpha(node: strongSelf.placeholderIconNode, alpha: 0.0)
}
strongSelf.selectedTabIndex = index
}
}
self.doneButton.pressed = { [weak self] in
if let strongSelf = self {
strongSelf.shareCamera?(true)
}
}
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside)
self.readyDisposable.set(self.cameraNode.ready.start(next: { [weak self] ready in
if let strongSelf = self, ready {
Queue.mainQueue().after(0.07) {
strongSelf.shimmerNode.alpha = 0.0
strongSelf.shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
}))
}
deinit {
self.readyDisposable.dispose()
self.applicationStateDisposable?.dispose()
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
}
override func didLoad() {
super.didLoad()
let leftSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.leftSwipeGesture))
leftSwipeGestureRecognizer.direction = .left
let rightSwipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.rightSwipeGesture))
rightSwipeGestureRecognizer.direction = .right
self.view.addGestureRecognizer(leftSwipeGestureRecognizer)
self.view.addGestureRecognizer(rightSwipeGestureRecognizer)
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
}
}
@objc func leftSwipeGesture() {
if self.selectedTabIndex < 2 {
self.wheelNode.setSelectedIndex(self.selectedTabIndex + 1, animated: true)
self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex)
}
}
@objc func rightSwipeGesture() {
if self.selectedTabIndex > 0 {
self.wheelNode.setSelectedIndex(self.selectedTabIndex - 1, animated: true)
self.wheelNode.selectedIndexChanged(self.wheelNode.selectedIndex)
}
}
@objc func cancelPressed() {
self.cancel?()
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
func animateIn() {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
let targetBounds = self.bounds
self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset)
self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset)
transition.animateView({
self.bounds = targetBounds
self.dimNode.position = dimPosition
})
self.applicationStateDisposable = (self.sharedContext.applicationBindings.applicationIsActive
|> filter { !$0 }
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.controller?.dismiss()
})
}
func animateOut(completion: (() -> Void)? = nil) {
var dimCompleted = false
var offsetCompleted = false
let internalCompletion: () -> Void = { [weak self] in
if let strongSelf = self, dimCompleted && offsetCompleted {
strongSelf.dismiss?()
}
completion?()
}
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
dimCompleted = true
internalCompletion()
})
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
offsetCompleted = true
internalCompletion()
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) {
return self.dimNode.view
}
}
return super.hitTest(point, with: event)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset
let additionalTopHeight = max(0.0, -contentOffset.y)
if additionalTopHeight >= 30.0 {
self.cancel?()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
let isLandscape: Bool
if layout.size.width > layout.size.height {
isLandscape = true
} else {
isLandscape = false
}
let isTablet: Bool
if case .regular = layout.metrics.widthClass {
isTablet = true
} else {
isTablet = false
}
var insets = layout.insets(options: [.statusBar])
insets.top = max(10.0, insets.top)
let contentSize: CGSize
if isLandscape {
if isTablet {
contentSize = CGSize(width: 870.0, height: 690.0)
} else {
contentSize = CGSize(width: layout.size.width, height: layout.size.height)
}
} else {
if isTablet {
contentSize = CGSize(width: 600.0, height: 960.0)
} else {
contentSize = CGSize(width: layout.size.width, height: layout.size.height - insets.top - 8.0)
}
}
let sideInset = floor((layout.size.width - contentSize.width) / 2.0)
let contentFrame: CGRect
if isTablet {
contentFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((layout.size.height - contentSize.height) / 2.0)), size: contentSize)
} else {
contentFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentSize.height), size: contentSize)
}
var backgroundFrame = contentFrame
if !isTablet {
backgroundFrame.size.height += 2000.0
}
if backgroundFrame.minY < contentFrame.minY {
backgroundFrame.origin.y = contentFrame.minY
}
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let titleSize = self.titleNode.measure(CGSize(width: contentFrame.width, height: .greatestFiniteMagnitude))
let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 20.0), size: titleSize)
transition.updateFrame(node: self.titleNode, frame: titleFrame)
var previewSize: CGSize
var previewFrame: CGRect
let previewAspectRatio: CGFloat = 1.85
if isLandscape {
let previewHeight = contentFrame.height
previewSize = CGSize(width: min(contentFrame.width - layout.safeInsets.left - layout.safeInsets.right, ceil(previewHeight * previewAspectRatio)), height: previewHeight)
previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentFrame.width - previewSize.width) / 2.0), y: 0.0), size: previewSize)
} else {
previewSize = CGSize(width: contentFrame.width, height: min(contentFrame.height, ceil(contentFrame.width * previewAspectRatio)))
previewFrame = CGRect(origin: CGPoint(), size: previewSize)
}
transition.updateFrame(node: self.previewContainerNode, frame: previewFrame)
transition.updateFrame(node: self.shimmerNode, frame: CGRect(origin: CGPoint(), size: previewFrame.size))
self.shimmerNode.update(foregroundColor: UIColor(rgb: 0xffffff, alpha: 0.07))
self.shimmerNode.updateAbsoluteRect(previewFrame, within: layout.size)
let cancelButtonSize = self.cancelButton.measure(CGSize(width: (previewFrame.width - titleSize.width) / 2.0, height: .greatestFiniteMagnitude))
let cancelButtonFrame = CGRect(origin: CGPoint(x: previewFrame.minX + 17.0, y: 20.0), size: cancelButtonSize)
transition.updateFrame(node: self.cancelButton, frame: cancelButtonFrame)
self.cameraNode.frame = CGRect(origin: CGPoint(), size: previewSize)
self.cameraNode.updateLayout(size: previewSize, layoutMode: isLandscape ? .fillHorizontal : .fillVertical, transition: .immediate)
self.placeholderTextNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_VideoPreviewShareScreenInfo, font: Font.semibold(16.0), textColor: .white)
self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white)
let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: previewSize.width - 80.0, height: 100.0))
transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) + 10.0), size: placeholderTextSize))
if let imageSize = self.placeholderIconNode.image?.size {
transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((previewSize.width - imageSize.width) / 2.0), y: floorToScreenPixels(previewSize.height / 2.0) - imageSize.height - 8.0), size: imageSize))
}
let buttonInset: CGFloat = 16.0
let buttonMaxWidth: CGFloat = 360.0
let buttonWidth = min(buttonMaxWidth, contentFrame.width - buttonInset * 2.0)
let doneButtonHeight = self.doneButton.updateLayout(width: buttonWidth, transition: transition)
transition.updateFrame(node: self.doneButton, frame: CGRect(x: floorToScreenPixels((contentFrame.width - buttonWidth) / 2.0), y: previewFrame.maxY - doneButtonHeight - buttonInset, width: buttonWidth, height: doneButtonHeight))
self.broadcastPickerView?.frame = self.doneButton.frame
let wheelFrame = CGRect(origin: CGPoint(x: 16.0 + previewFrame.minX, y: previewFrame.maxY - doneButtonHeight - buttonInset - 36.0 - 20.0), size: CGSize(width: previewFrame.width - 32.0, height: 36.0))
self.wheelNode.updateLayout(size: wheelFrame.size, transition: transition)
transition.updateFrame(node: self.wheelNode, frame: wheelFrame)
transition.updateFrame(node: self.contentContainerNode, frame: contentFrame)
}
}
private let textFont = Font.with(size: 14.0, design: .camera, weight: .regular)
private let selectedTextFont = Font.with(size: 14.0, design: .camera, weight: .semibold)
private class WheelControlNode: ASDisplayNode, ASGestureRecognizerDelegate {
struct Item: Equatable {
public let title: String
public init(title: String) {
self.title = title
}
}
private let maskNode: ASDisplayNode
private let containerNode: ASDisplayNode
private var itemNodes: [HighlightTrackingButtonNode]
private var validLayout: CGSize?
private var _items: [Item]
private var _selectedIndex: Int = 0
public var selectedIndex: Int {
get {
return self._selectedIndex
}
set {
guard newValue != self._selectedIndex else {
return
}
self._selectedIndex = newValue
if let size = self.validLayout {
self.updateLayout(size: size, transition: .immediate)
}
}
}
public func setSelectedIndex(_ index: Int, animated: Bool) {
guard index != self._selectedIndex else {
return
}
self._selectedIndex = index
if let size = self.validLayout {
self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .easeInOut))
}
}
public var selectedIndexChanged: (Int) -> Void = { _ in }
public init(items: [Item], selectedIndex: Int) {
self._items = items
self._selectedIndex = selectedIndex
self.maskNode = ASDisplayNode()
self.maskNode.setLayerBlock({
let maskLayer = CAGradientLayer()
maskLayer.colors = [UIColor.clear.cgColor, UIColor.white.cgColor, UIColor.white.cgColor, UIColor.clear.cgColor]
maskLayer.locations = [0.0, 0.15, 0.85, 1.0]
maskLayer.startPoint = CGPoint(x: 0.0, y: 0.0)
maskLayer.endPoint = CGPoint(x: 1.0, y: 0.0)
return maskLayer
})
self.containerNode = ASDisplayNode()
self.itemNodes = items.map { item in
let itemNode = HighlightTrackingButtonNode()
itemNode.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0)
itemNode.titleNode.maximumNumberOfLines = 1
itemNode.titleNode.truncationMode = .byTruncatingTail
itemNode.accessibilityLabel = item.title
itemNode.accessibilityTraits = [.button]
itemNode.hitTestSlop = UIEdgeInsets(top: -10.0, left: -5.0, bottom: -10.0, right: -5.0)
itemNode.setTitle(item.title.uppercased(), with: textFont, with: .white, for: .normal)
itemNode.titleNode.shadowColor = UIColor.black.cgColor
itemNode.titleNode.shadowOffset = CGSize()
itemNode.titleNode.layer.shadowRadius = 2.0
itemNode.titleNode.layer.shadowOpacity = 0.3
itemNode.titleNode.layer.masksToBounds = false
itemNode.titleNode.layer.shouldRasterize = true
itemNode.titleNode.layer.rasterizationScale = UIScreen.main.scale
return itemNode
}
super.init()
self.clipsToBounds = true
self.addSubnode(self.containerNode)
self.itemNodes.forEach(self.containerNode.addSubnode(_:))
self.setupButtons()
}
override func didLoad() {
super.didLoad()
self.view.layer.mask = self.maskNode.layer
self.view.disablesInteractiveTransitionGestureRecognizer = true
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayout = size
let bounds = CGRect(origin: CGPoint(), size: size)
transition.updateFrame(node: self.maskNode, frame: bounds)
let spacing: CGFloat = 15.0
if !self.itemNodes.isEmpty {
var leftOffset: CGFloat = 0.0
var selectedItemNode: ASDisplayNode?
for i in 0 ..< self.itemNodes.count {
let itemNode = self.itemNodes[i]
let itemSize = itemNode.measure(size)
transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: leftOffset, y: (size.height - itemSize.height) / 2.0), size: itemSize))
leftOffset += itemSize.width + spacing
let isSelected = self.selectedIndex == i
if isSelected {
selectedItemNode = itemNode
}
if itemNode.isSelected != isSelected {
itemNode.isSelected = isSelected
let title = itemNode.attributedTitle(for: .normal)?.string ?? ""
itemNode.setTitle(title, with: isSelected ? selectedTextFont : textFont, with: isSelected ? UIColor(rgb: 0xffd60a) : .white, for: .normal)
if isSelected {
itemNode.accessibilityTraits.insert(.selected)
} else {
itemNode.accessibilityTraits.remove(.selected)
}
}
}
let totalWidth = leftOffset - spacing
if let selectedItemNode = selectedItemNode {
let itemCenter = selectedItemNode.frame.center
transition.updateFrame(node: self.containerNode, frame: CGRect(x: bounds.width / 2.0 - itemCenter.x, y: 0.0, width: totalWidth, height: bounds.height))
for i in 0 ..< self.itemNodes.count {
let itemNode = self.itemNodes[i]
let convertedBounds = itemNode.view.convert(itemNode.bounds, to: self.view)
let position = convertedBounds.center
let offset = position.x - bounds.width / 2.0
let angle = abs(offset / bounds.width * 0.99)
let sign: CGFloat = offset > 0 ? 1.0 : -1.0
var transform = CATransform3DMakeTranslation(-22.0 * angle * angle * sign, 0.0, 0.0)
transform = CATransform3DRotate(transform, angle, 0.0, sign, 0.0)
transition.animateView {
itemNode.transform = transform
}
}
}
}
}
private func setupButtons() {
for i in 0 ..< self.itemNodes.count {
let itemNode = self.itemNodes[i]
itemNode.addTarget(self, action: #selector(self.buttonPressed(_:)), forControlEvents: .touchUpInside)
}
}
@objc private func buttonPressed(_ button: HighlightTrackingButtonNode) {
guard let index = self.itemNodes.firstIndex(of: button) else {
return
}
self._selectedIndex = index
self.selectedIndexChanged(index)
if let size = self.validLayout {
self.updateLayout(size: size, transition: .animated(duration: 0.2, curve: .slide))
}
}
}
@@ -0,0 +1,322 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramStringFormatting
import TelegramVoip
import TelegramAudio
import AccountContext
import Postbox
import TelegramCore
import MergeLists
import ItemListUI
import AppBundle
import ContextUI
import ShareController
import DeleteChatPeerActionSheetItem
import UndoUI
import AlertUI
import PresentationDataUtils
import DirectionalPanGesture
import AvatarNode
import TooltipUI
import LegacyUI
import LegacyComponents
import LegacyMediaPickerUI
import WebSearchUI
import MapResourceToAvatarSizes
import SolidRoundedButtonNode
import AudioBlob
import DeviceAccess
import VoiceChatActionButton
let panelBackgroundColor = UIColor(rgb: 0x1c1c1e)
let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e)
let fullscreenBackgroundColor = UIColor(rgb: 0x000000)
let smallButtonSize = CGSize(width: 36.0, height: 36.0)
let sideButtonSize = CGSize(width: 56.0, height: 56.0)
let topPanelHeight: CGFloat = 63.0
let bottomAreaHeight: CGFloat = 206.0
let fullscreenBottomAreaHeight: CGFloat = 80.0
let bottomGradientHeight: CGFloat = 70.0
func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? {
if !top && !bottom {
return nil
}
return generateImage(CGSize(width: 50.0, height: 50.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor)
context.fill(bounds)
context.setBlendMode(.clear)
var corners: UIRectCorner = []
if top {
corners.insert(.topLeft)
corners.insert(.topRight)
}
if bottom {
corners.insert(.bottomLeft)
corners.insert(.bottomRight)
}
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0))
context.addPath(path.cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 25)
}
func decorationTopCornersImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor)
context.fill(bounds)
context.setBlendMode(.clear)
var corners: UIRectCorner = []
corners.insert(.topLeft)
corners.insert(.topRight)
let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 60.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0))
context.addPath(path.cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 32)
}
func decorationBottomCornersImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor)
context.fill(bounds)
context.setBlendMode(.clear)
var corners: UIRectCorner = []
corners.insert(.bottomLeft)
corners.insert(.bottomRight)
let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0))
context.addPath(path.cgPath)
context.fillPath()
})?.resizableImage(withCapInsets: UIEdgeInsets(top: 25.0, left: 25.0, bottom: 0.0, right: 25.0), resizingMode: .stretch)
}
private func decorationBottomGradientImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 24.0, height: bottomGradientHeight), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let color = dark ? fullscreenBackgroundColor : panelBackgroundColor
let colorsArray = [color.withAlphaComponent(0.0).cgColor, color.cgColor] as CFArray
var locations: [CGFloat] = [1.0, 0.0]
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
})
}
struct VoiceChatPeerEntry: Identifiable {
enum State {
case listening
case speaking
case invited
case raisedHand
}
var peer: Peer
var about: String?
var isMyPeer: Bool
var videoEndpointId: String?
var videoPaused: Bool
var presentationEndpointId: String?
var presentationPaused: Bool
var effectiveSpeakerVideoEndpointId: String?
var state: State
var muteState: GroupCallParticipantsContext.Participant.MuteState?
var canManageCall: Bool
var volume: Int32?
var raisedHand: Bool
var displayRaisedHandStatus: Bool
var active: Bool
var isLandscape: Bool
var effectiveVideoEndpointId: String? {
return self.presentationEndpointId ?? self.videoEndpointId
}
init(
peer: Peer,
about: String?,
isMyPeer: Bool,
videoEndpointId: String?,
videoPaused: Bool,
presentationEndpointId: String?,
presentationPaused: Bool,
effectiveSpeakerVideoEndpointId: String?,
state: State,
muteState: GroupCallParticipantsContext.Participant.MuteState?,
canManageCall: Bool,
volume: Int32?,
raisedHand: Bool,
displayRaisedHandStatus: Bool,
active: Bool,
isLandscape: Bool
) {
self.peer = peer
self.about = about
self.isMyPeer = isMyPeer
self.videoEndpointId = videoEndpointId
self.videoPaused = videoPaused
self.presentationEndpointId = presentationEndpointId
self.presentationPaused = presentationPaused
self.effectiveSpeakerVideoEndpointId = effectiveSpeakerVideoEndpointId
self.state = state
self.muteState = muteState
self.canManageCall = canManageCall
self.volume = volume
self.raisedHand = raisedHand
self.displayRaisedHandStatus = displayRaisedHandStatus
self.active = active
self.isLandscape = isLandscape
}
var stableId: PeerId {
return self.peer.id
}
static func ==(lhs: VoiceChatPeerEntry, rhs: VoiceChatPeerEntry) -> Bool {
if !lhs.peer.isEqual(rhs.peer) {
return false
}
if lhs.about != rhs.about {
return false
}
if lhs.isMyPeer != rhs.isMyPeer {
return false
}
if lhs.videoEndpointId != rhs.videoEndpointId {
return false
}
if lhs.videoPaused != rhs.videoPaused {
return false
}
if lhs.presentationEndpointId != rhs.presentationEndpointId {
return false
}
if lhs.presentationPaused != rhs.presentationPaused {
return false
}
if lhs.effectiveSpeakerVideoEndpointId != rhs.effectiveSpeakerVideoEndpointId {
return false
}
if lhs.state != rhs.state {
return false
}
if lhs.muteState != rhs.muteState {
return false
}
if lhs.canManageCall != rhs.canManageCall {
return false
}
if lhs.volume != rhs.volume {
return false
}
if lhs.raisedHand != rhs.raisedHand {
return false
}
if lhs.displayRaisedHandStatus != rhs.displayRaisedHandStatus {
return false
}
if lhs.active != rhs.active {
return false
}
if lhs.isLandscape != rhs.isLandscape {
return false
}
return true
}
}
public protocol VoiceChatController: ViewController {
var call: VideoChatCall { get }
var currentOverlayController: VoiceChatOverlayController? { get }
var parentNavigationController: NavigationController? { get set }
var onViewDidAppear: (() -> Void)? { get set }
var onViewDidDisappear: (() -> Void)? { get set }
func updateCall(call: VideoChatCall)
func dismiss(closing: Bool, manual: Bool)
}
private final class VoiceChatContextExtractedContentSource: ContextExtractedContentSource {
var keepInPlace: Bool
let ignoreContentTouches: Bool = false
let blurBackground: Bool
let maskView: UIView?
private var animateTransitionIn: () -> Void
private var animateTransitionOut: () -> Void
private let sourceNode: ContextExtractedContentContainingNode
var centerVertically: Bool
var shouldBeDismissed: Signal<Bool, NoError>
init(sourceNode: ContextExtractedContentContainingNode, maskView: UIView?, keepInPlace: Bool, blurBackground: Bool, centerVertically: Bool, shouldBeDismissed: Signal<Bool, NoError>, animateTransitionIn: @escaping () -> Void, animateTransitionOut: @escaping () -> Void) {
self.sourceNode = sourceNode
self.maskView = maskView
self.keepInPlace = keepInPlace
self.blurBackground = blurBackground
self.centerVertically = centerVertically
self.shouldBeDismissed = shouldBeDismissed
self.animateTransitionIn = animateTransitionIn
self.animateTransitionOut = animateTransitionOut
}
func takeView() -> ContextControllerTakeViewInfo? {
self.animateTransitionIn()
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView)
}
func putBack() -> ContextControllerPutBackViewInfo? {
self.animateTransitionOut()
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView)
}
}
final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceView: UIView
init(controller: ViewController, sourceView: UIView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
public func shouldUseV2VideoChatImpl(context: AccountContext) -> Bool {
var useV2 = true
if context.sharedContext.immediateExperimentalUISettings.disableCallV2 {
useV2 = false
}
if let data = context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_videochatui_v2"] {
useV2 = false
}
return useV2
}
public func makeVoiceChatControllerInitialData(sharedContext: SharedAccountContext, accountContext: AccountContext, call: VideoChatCall) -> Signal<Any, NoError> {
return VideoChatScreenV2Impl.initialData(call: call) |> map { $0 as Any }
}
public func makeVoiceChatController(sharedContext: SharedAccountContext, accountContext: AccountContext, call: VideoChatCall, initialData: Any, sourceCallController: CallController?) -> VoiceChatController {
return VideoChatScreenV2Impl(initialData: initialData as! VideoChatScreenV2Impl.InitialData, call: call, sourceCallController: sourceCallController)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import AccountContext
import TelegramPresentationData
import AvatarNode
import AnimationUI
func optionsBackgroundImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 28.0, height: 28.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: dark ? 0x1c1c1e : 0x2c2c2e).cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
})?.stretchableImage(withLeftCapWidth: 14, topCapHeight: 14)
}
func optionsCircleImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 28.0, height: 28.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: dark ? 0x1c1c1e : 0x2c2c2e).cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
})
}
func panelButtonImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 38.0, height: 28.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: 14.0).cgPath)
context.setFillColor(UIColor(rgb: dark ? 0x1c1c1e : 0x2c2c2e).cgColor)
context.fillPath()
context.setFillColor(UIColor.white.cgColor)
if let image = UIImage(bundleImageName: "Call/PanelIcon") {
let imageSize = image.size
let imageRect = CGRect(origin: CGPoint(), size: imageSize)
context.saveGState()
context.translateBy(x: 7.0, y: 2.0)
context.clip(to: imageRect, mask: image.cgImage!)
context.fill(imageRect)
context.restoreGState()
}
})
}
func closeButtonImage(dark: Bool) -> UIImage? {
return generateImage(CGSize(width: 28.0, height: 28.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setLineWidth(2.0)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setStrokeColor(UIColor.white.cgColor)
context.move(to: CGPoint(x: 6.0 - UIScreenPixel, y: 17.0))
context.addLine(to: CGPoint(x: 14.0, y: 8.0 + UIScreenPixel))
context.addLine(to: CGPoint(x: 22.0 + UIScreenPixel, y: 17.0))
context.strokePath()
})
}
final class VoiceChatHeaderButton: HighlightableButtonNode {
enum Content {
case image(UIImage?)
case more(UIImage?)
case avatar(Peer)
}
private let context: AccountContext
private var theme: PresentationTheme
let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
private let iconNode: ASImageNode
private var animationNode: AnimationNode?
private let avatarNode: AvatarNode
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
private let wide: Bool
init(context: AccountContext, wide: Bool = false) {
self.context = context
self.theme = context.sharedContext.currentPresentationData.with { $0 }.theme
self.wide = wide
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.contentMode = .scaleToFill
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 17.0))
self.avatarNode.isHidden = true
super.init()
self.containerNode.addSubnode(self.referenceNode)
self.referenceNode.addSubnode(self.iconNode)
self.referenceNode.addSubnode(self.avatarNode)
self.addSubnode(self.containerNode)
self.containerNode.shouldBegin = { [weak self] location in
guard let strongSelf = self, let _ = strongSelf.contextAction else {
return false
}
return true
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.contextAction?(strongSelf.containerNode, gesture)
}
self.iconNode.image = optionsCircleImage(dark: false)
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: wide ? 38.0 : 28.0, height: 28.0))
self.referenceNode.frame = self.containerNode.bounds
self.iconNode.frame = self.containerNode.bounds
self.avatarNode.frame = self.containerNode.bounds
}
private var content: Content?
func setContent(_ content: Content, animated: Bool = false) {
if case .more = content, self.animationNode == nil {
let iconColor = UIColor(rgb: 0xffffff)
let animationNode = AnimationNode(animation: "anim_profilemore", colors: ["Point 2.Group 1.Fill 1": iconColor,
"Point 3.Group 1.Fill 1": iconColor,
"Point 1.Group 1.Fill 1": iconColor], scale: 1.0)
animationNode.frame = self.containerNode.bounds
self.addSubnode(animationNode)
self.animationNode = animationNode
}
if animated {
switch content {
case let .image(image):
if let snapshotView = self.referenceNode.view.snapshotContentTree() {
snapshotView.frame = self.referenceNode.frame
self.view.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
}
self.iconNode.image = image
self.iconNode.isHidden = false
self.avatarNode.isHidden = true
case let .avatar(peer):
self.avatarNode.setPeer(context: self.context, theme: self.theme, peer: EnginePeer(peer))
self.iconNode.isHidden = true
self.avatarNode.isHidden = false
self.animationNode?.isHidden = true
case let .more(image):
self.iconNode.image = image
self.iconNode.isHidden = false
self.avatarNode.isHidden = true
self.animationNode?.isHidden = false
}
} else {
self.content = content
switch content {
case let .image(image):
self.iconNode.image = image
self.iconNode.isHidden = false
self.avatarNode.isHidden = true
case let .avatar(peer):
self.avatarNode.setPeer(context: self.context, theme: self.theme, peer: EnginePeer(peer))
self.iconNode.isHidden = true
self.avatarNode.isHidden = false
self.animationNode?.isHidden = true
case let .more(image):
self.iconNode.image = image
self.iconNode.isHidden = false
self.avatarNode.isHidden = true
self.animationNode?.isHidden = false
}
}
}
override func didLoad() {
super.didLoad()
self.view.isOpaque = false
}
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
return CGSize(width: wide ? 38.0 : 28.0, height: 28.0)
}
func onLayout() {
}
func play() {
self.animationNode?.playOnce()
}
}
@@ -0,0 +1,110 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import AppBundle
import ContextUI
import TelegramStringFormatting
public final class VoiceChatInfoContextItem: ContextMenuCustomItem {
let text: String
let icon: (PresentationTheme) -> UIImage?
public init(text: String, icon: @escaping (PresentationTheme) -> UIImage?) {
self.text = text
self.icon = icon
}
public func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return VoiceChatInfoContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected)
}
}
private final class VoiceChatInfoContextItemNode: ASDisplayNode, ContextMenuCustomNode {
private let item: VoiceChatInfoContextItem
private let presentationData: PresentationData
private let getController: () -> ContextControllerProtocol?
private let actionSelected: (ContextMenuActionResult) -> Void
private let backgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private let iconNode: ASImageNode
init(presentationData: PresentationData, item: VoiceChatInfoContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.item = item
self.presentationData = presentationData
self.getController = getController
self.actionSelected = actionSelected
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isAccessibilityElement = false
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.textNode = ImmediateTextNode()
self.textNode.isAccessibilityElement = false
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.textNode.attributedText = NSAttributedString(string: item.text, font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
self.textNode.maximumNumberOfLines = 0
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.image = item.icon(presentationData.theme)
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textNode)
self.addSubnode(self.iconNode)
}
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let sideInset: CGFloat = 18.0
let iconSideInset: CGFloat = 20.0
let verticalInset: CGFloat = 11.0
let iconSize = self.iconNode.image.flatMap({ $0.size }) ?? CGSize()
let standardIconWidth: CGFloat = 32.0
var rightTextInset: CGFloat = sideInset
if !iconSize.width.isZero {
rightTextInset = max(iconSize.width, standardIconWidth) + iconSideInset + sideInset - 12.0
}
let textSize = self.textNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude))
return (CGSize(width: textSize.width + sideInset + rightTextInset, height: verticalInset * 2.0 + textSize.height), { size, transition in
let verticalOrigin = floor((size.height - textSize.height) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: iconSideInset + 40.0, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
})
}
func updateTheme(presentationData: PresentationData) {
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
}
func canBeHighlighted() -> Bool {
return false
}
func updateIsHighlighted(isHighlighted: Bool) {
}
func performAction() {
}
}
@@ -0,0 +1,724 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import AlertUI
import PresentationDataUtils
import ShareController
import AvatarNode
import UndoUI
public final class VoiceChatJoinScreen: ViewController {
private var controllerNode: Node {
return self.displayNode as! Node
}
private var animatedIn = false
private let context: AccountContext
private let peerId: PeerId
private let invite: String?
private var join: (CachedChannelData.ActiveCall) -> Void
private var presentationData: PresentationData
private let disposable = MetaDisposable()
public init(context: AccountContext, peerId: PeerId, invite: String?, join: @escaping (CachedChannelData.ActiveCall) -> Void) {
self.context = context
self.peerId = peerId
self.invite = invite
self.join = join
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable.dispose()
}
override public func loadDisplayNode() {
self.displayNode = Node(context: self.context, requestLayout: { [weak self] transition in
self?.requestLayout(transition: transition)
}, asSpeaker: self.invite != nil)
self.controllerNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.cancel = { [weak self] in
self?.dismiss()
}
self.controllerNode.join = { [weak self] call in
self?.dismiss()
self?.join(call)
}
self.displayNodeDidLoad()
let context = self.context
let peerId = self.peerId
let invite = self.invite
let signal = context.engine.calls.updatedCurrentPeerGroupCall(peerId: peerId)
|> castError(GetCurrentGroupCallError.self)
|> mapToSignal { call -> Signal<(EnginePeer, GroupCallSummary)?, GetCurrentGroupCallError> in
if let call = call {
let peer = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|> castError(GetCurrentGroupCallError.self)
return combineLatest(peer, context.engine.calls.getCurrentGroupCall(reference: .id(id: call.id, accessHash: call.accessHash)))
|> map { peer, call -> (EnginePeer, GroupCallSummary)? in
if let peer = peer, let call = call {
return (peer, call)
} else {
return nil
}
}
} else {
return .single(nil)
}
}
let callJoinAsPeerId = context.engine.data.get(TelegramEngine.EngineData.Item.Peer.CallJoinAsPeerId(id: peerId))
|> castError(GetCurrentGroupCallError.self)
let currentGroupCall: Signal<(PresentationGroupCall, Int64, Bool)?, GetCurrentGroupCallError>
if let callManager = context.sharedContext.callManager {
currentGroupCall = callManager.currentGroupCallSignal
|> castError(GetCurrentGroupCallError.self)
|> mapToSignal { call -> Signal<(PresentationGroupCall, Int64, Bool)?, GetCurrentGroupCallError> in
if case let .group(call) = call {
return call.summaryState
|> castError(GetCurrentGroupCallError.self)
|> map { state -> (PresentationGroupCall, Int64, Bool)? in
if let state = state, let info = state.info {
return (call, info.id, state.callState.muteState?.canUnmute ?? true)
} else {
return nil
}
}
|> filter { value in
return value != nil
}
} else {
return .single(nil)
}
}
|> take(1)
} else {
currentGroupCall = .single(nil)
}
self.disposable.set(combineLatest(queue: Queue.mainQueue(), signal, context.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: peerId) |> castError(GetCurrentGroupCallError.self), callJoinAsPeerId, currentGroupCall).start(next: { [weak self] peerAndCall, availablePeers, callJoinAsPeerId, currentGroupCallIdAndCanUnmute in
if let strongSelf = self {
if let (peer, call) = peerAndCall {
if let (currentGroupCall, currentGroupCallId, canUnmute) = currentGroupCallIdAndCanUnmute, call.info.id == currentGroupCallId {
strongSelf.dismiss()
if let invite = invite, !canUnmute {
currentGroupCall.reconnect(with: invite)
}
strongSelf.context.sharedContext.navigateToCurrentCall()
return
}
let defaultJoinAsPeerId: PeerId? = callJoinAsPeerId
let activeCall = CachedChannelData.ActiveCall(id: call.info.id, accessHash: call.info.accessHash, title: call.info.title, scheduleTimestamp: call.info.scheduleTimestamp, subscribedToScheduled: call.info.subscribedToScheduled, isStream: call.info.isStream)
if availablePeers.count > 0 && defaultJoinAsPeerId == nil {
strongSelf.dismiss()
strongSelf.join(activeCall)
} else {
strongSelf.controllerNode.setPeer(call: activeCall, peer: peer._asPeer(), title: call.info.title, memberCount: call.info.participantCount, isStream: call.info.isStream)
}
} else {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .linkRevoked(text: presentationData.strings.InviteLinks_InviteLinkExpired), elevatedLayout: true, animateInAsReplacement: true, action: { _ in return false }), in: .window(.root))
strongSelf.dismiss()
}
}
}))
self.ready.set(self.controllerNode.ready.get())
}
override public func loadView() {
super.loadView()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.controllerNode.animateOut(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
class Node: ViewControllerTracingNode, ASScrollViewDelegate {
private let context: AccountContext
private var presentationData: PresentationData
private let asSpeaker: Bool
private var call: CachedChannelData.ActiveCall?
private let requestLayout: (ContainedViewLayoutTransition) -> Void
private var containerLayout: (ContainerViewLayout, CGFloat, CGFloat)?
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let cancelButtonNode: ASButtonNode
private let contentContainerNode: ASDisplayNode
private let contentBackgroundNode: ASImageNode
private var contentNode: (ASDisplayNode & ShareContentContainerNode)?
private var previousContentNode: (ASDisplayNode & ShareContentContainerNode)?
private var animateContentNodeOffsetFromBackgroundOffset: CGFloat?
private let actionsBackgroundNode: ASImageNode
private let actionButtonNode: ShareActionButtonNode
private let actionSeparatorNode: ASDisplayNode
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
var join: ((CachedChannelData.ActiveCall) -> Void)?
let ready = Promise<Bool>()
private var didSetReady = false
private var scheduledLayoutTransitionRequestId: Int = 0
private var scheduledLayoutTransitionRequest: (Int, ContainedViewLayoutTransition)?
private let disposable = MetaDisposable()
init(context: AccountContext, requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, asSpeaker: Bool) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.asSpeaker = asSpeaker
self.requestLayout = requestLayout
let roundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: self.presentationData.theme.actionSheet.opaqueItemBackgroundColor)
let highlightedRoundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: self.presentationData.theme.actionSheet.opaqueItemHighlightedBackgroundColor)
let theme = self.presentationData.theme
let halfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(theme.actionSheet.opaqueItemBackgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)))
context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0)))
})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1)
let highlightedHalfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(theme.actionSheet.opaqueItemHighlightedBackgroundColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)))
context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0)))
})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1)
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
self.wrappingScrollNode.view.canCancelContentTouches = true
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.cancelButtonNode = ASButtonNode()
self.cancelButtonNode.displaysAsynchronously = false
self.cancelButtonNode.setBackgroundImage(roundedBackground, for: .normal)
self.cancelButtonNode.setBackgroundImage(highlightedRoundedBackground, for: .highlighted)
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.isOpaque = false
self.contentContainerNode.clipsToBounds = true
self.contentBackgroundNode = ASImageNode()
self.contentBackgroundNode.displaysAsynchronously = false
self.contentBackgroundNode.displayWithoutProcessing = true
self.contentBackgroundNode.image = roundedBackground
self.actionsBackgroundNode = ASImageNode()
self.actionsBackgroundNode.isLayerBacked = true
self.actionsBackgroundNode.displayWithoutProcessing = true
self.actionsBackgroundNode.displaysAsynchronously = false
self.actionsBackgroundNode.image = halfRoundedBackground
self.actionButtonNode = ShareActionButtonNode(badgeBackgroundColor: self.presentationData.theme.actionSheet.controlAccentColor, badgeTextColor: self.presentationData.theme.actionSheet.opaqueItemBackgroundColor)
self.actionButtonNode.displaysAsynchronously = false
self.actionButtonNode.titleNode.displaysAsynchronously = false
self.actionButtonNode.setBackgroundImage(highlightedHalfRoundedBackground, for: .highlighted)
self.actionSeparatorNode = ASDisplayNode()
self.actionSeparatorNode.isLayerBacked = true
self.actionSeparatorNode.displaysAsynchronously = false
self.actionSeparatorNode.backgroundColor = self.presentationData.theme.actionSheet.opaqueItemSeparatorColor
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.addSubnode(self.dimNode)
self.wrappingScrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.wrappingScrollNode)
self.cancelButtonNode.setTitle(self.presentationData.strings.Common_Cancel, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal)
self.wrappingScrollNode.addSubnode(self.cancelButtonNode)
self.cancelButtonNode.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside)
self.actionButtonNode.addTarget(self, action: #selector(self.installActionButtonPressed), forControlEvents: .touchUpInside)
self.wrappingScrollNode.addSubnode(self.contentBackgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
self.contentContainerNode.addSubnode(self.actionSeparatorNode)
self.contentContainerNode.addSubnode(self.actionsBackgroundNode)
self.contentContainerNode.addSubnode(self.actionButtonNode)
self.transitionToContentNode(ShareLoadingContainerNode(theme: theme, forceNativeAppearance: false))
self.actionButtonNode.alpha = 0.0
self.actionSeparatorNode.alpha = 0.0
self.actionsBackgroundNode.alpha = 0.0
self.ready.set(.single(true))
self.didSetReady = true
}
deinit {
self.disposable.dispose()
}
override func didLoad() {
super.didLoad()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
}
}
func transitionToContentNode(_ contentNode: (ASDisplayNode & ShareContentContainerNode)?, fastOut: Bool = false) {
if self.contentNode !== contentNode {
let transition: ContainedViewLayoutTransition
let previous = self.contentNode
if let previous = previous {
previous.setContentOffsetUpdated(nil)
transition = .animated(duration: 0.4, curve: .spring)
self.previousContentNode = previous
previous.alpha = 0.0
previous.layer.animateAlpha(from: 1.0, to: 0.0, duration: fastOut ? 0.1 : 0.2, removeOnCompletion: true, completion: { [weak self, weak previous] _ in
if let strongSelf = self, let previous = previous {
if strongSelf.previousContentNode === previous {
strongSelf.previousContentNode = nil
}
previous.removeFromSupernode()
}
})
} else {
transition = .immediate
}
self.contentNode = contentNode
if let (layout, navigationBarHeight, bottomGridInset) = self.containerLayout {
if let contentNode = contentNode, let previous = previous {
contentNode.frame = previous.frame
contentNode.updateLayout(size: previous.bounds.size, isLandscape: layout.size.width > layout.size.height, bottomInset: bottomGridInset, transition: .immediate)
contentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in
self?.contentNodeOffsetUpdated(contentOffset, transition: transition)
})
self.contentContainerNode.insertSubnode(contentNode, at: 0)
contentNode.alpha = 1.0
let animation = contentNode.layer.makeAnimation(from: 0.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "opacity", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.35)
animation.fillMode = .both
if !fastOut {
animation.beginTime = contentNode.layer.convertTime(CACurrentMediaTime(), from: nil) + 0.1
}
contentNode.layer.add(animation, forKey: "opacity")
self.animateContentNodeOffsetFromBackgroundOffset = self.contentBackgroundNode.frame.minY
self.scheduleInteractiveTransition(transition)
contentNode.activate()
previous.deactivate()
} else {
if let contentNode = self.contentNode {
contentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in
self?.contentNodeOffsetUpdated(contentOffset, transition: transition)
})
self.contentContainerNode.insertSubnode(contentNode, at: 0)
}
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
} else if let contentNode = contentNode {
contentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in
self?.contentNodeOffsetUpdated(contentOffset, transition: transition)
})
self.contentContainerNode.insertSubnode(contentNode, at: 0)
}
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
var insets = layout.insets(options: [.statusBar, .input])
let cleanInsets = layout.insets(options: [.statusBar])
insets.top = max(10.0, insets.top)
var bottomInset: CGFloat = 10.0 + cleanInsets.bottom
if insets.bottom > 0 {
bottomInset -= 12.0
}
let buttonHeight: CGFloat = 57.0
let sectionSpacing: CGFloat = 8.0
let titleAreaHeight: CGFloat = 64.0
let maximumContentHeight = layout.size.height - insets.top - max(bottomInset + buttonHeight, insets.bottom) - sectionSpacing
let width = min(layout.size.width, layout.size.height) - 20.0
let sideInset = floor((layout.size.width - width) / 2.0)
let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: insets.top), size: CGSize(width: width, height: maximumContentHeight))
let contentFrame = contentContainerFrame.insetBy(dx: 0.0, dy: 0.0)
let bottomGridInset = buttonHeight
self.containerLayout = (layout, navigationBarHeight, bottomGridInset)
self.scheduledLayoutTransitionRequest = nil
transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.cancelButtonNode, frame: CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - bottomInset - buttonHeight), size: CGSize(width: width, height: buttonHeight)))
transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame)
transition.updateFrame(node: self.actionsBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentContainerFrame.size.height - bottomGridInset), size: CGSize(width: contentContainerFrame.size.width, height: bottomGridInset)))
transition.updateFrame(node: self.actionButtonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentContainerFrame.size.height - buttonHeight), size: CGSize(width: contentContainerFrame.size.width, height: buttonHeight)))
transition.updateFrame(node: self.actionSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentContainerFrame.size.height - bottomGridInset - UIScreenPixel), size: CGSize(width: contentContainerFrame.size.width, height: UIScreenPixel)))
let gridSize = CGSize(width: contentFrame.size.width, height: max(32.0, contentFrame.size.height - titleAreaHeight))
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: floor((contentContainerFrame.size.width - contentFrame.size.width) / 2.0), y: titleAreaHeight), size: gridSize))
contentNode.updateLayout(size: gridSize, isLandscape: layout.size.width > layout.size.height, bottomInset: bottomGridInset, transition: transition)
}
}
private func contentNodeOffsetUpdated(_ contentOffset: CGFloat, transition: ContainedViewLayoutTransition) {
if let (layout, _, _) = self.containerLayout {
var insets = layout.insets(options: [.statusBar, .input])
insets.top = max(10.0, insets.top)
let cleanInsets = layout.insets(options: [.statusBar])
var bottomInset: CGFloat = 10.0 + cleanInsets.bottom
if insets.bottom > 0 {
bottomInset -= 12.0
}
let buttonHeight: CGFloat = 57.0
let sectionSpacing: CGFloat = 8.0
let width = min(layout.size.width, layout.size.height) - 20.0
let sideInset = floor((layout.size.width - width) / 2.0)
let maximumContentHeight = layout.size.height - insets.top - max(bottomInset + buttonHeight, insets.bottom) - sectionSpacing
let contentFrame = CGRect(origin: CGPoint(x: sideInset, y: insets.top), size: CGSize(width: width, height: maximumContentHeight))
var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY - contentOffset), size: contentFrame.size)
if backgroundFrame.minY < contentFrame.minY {
backgroundFrame.origin.y = contentFrame.minY
}
if backgroundFrame.maxY > contentFrame.maxY {
backgroundFrame.size.height += contentFrame.maxY - backgroundFrame.maxY
}
if backgroundFrame.size.height < buttonHeight + 32.0 {
backgroundFrame.origin.y -= buttonHeight + 32.0 - backgroundFrame.size.height
backgroundFrame.size.height = buttonHeight + 32.0
}
transition.updateFrame(node: self.contentBackgroundNode, frame: backgroundFrame)
if let animateContentNodeOffsetFromBackgroundOffset = self.animateContentNodeOffsetFromBackgroundOffset {
self.animateContentNodeOffsetFromBackgroundOffset = nil
let offset = backgroundFrame.minY - animateContentNodeOffsetFromBackgroundOffset
if let contentNode = self.contentNode {
transition.animatePositionAdditive(node: contentNode, offset: CGPoint(x: 0.0, y: -offset))
}
if let previousContentNode = self.previousContentNode {
transition.updatePosition(node: previousContentNode, position: previousContentNode.position.offsetBy(dx: 0.0, dy: offset))
}
}
}
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancelButtonPressed()
}
}
@objc func cancelButtonPressed() {
self.cancel?()
}
@objc func installActionButtonPressed() {
if let call = self.call {
self.join?(call)
}
}
func animateIn() {
if self.contentNode != nil {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
let targetBounds = self.bounds
self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset)
self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset)
transition.animateView({
self.bounds = targetBounds
self.dimNode.position = dimPosition
})
}
}
private var animatingOut = false
func animateOut(completion: (() -> Void)? = nil) {
guard !self.animatingOut else {
return
}
self.animatingOut = true
if self.contentNode != nil {
var dimCompleted = false
var offsetCompleted = false
let internalCompletion: () -> Void = { [weak self] in
if let strongSelf = self, dimCompleted && offsetCompleted {
strongSelf.animatingOut = false
strongSelf.dismiss?()
}
completion?()
}
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
dimCompleted = true
internalCompletion()
})
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
offsetCompleted = true
internalCompletion()
})
} else {
self.dismiss?()
completion?()
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let result = self.actionButtonNode.hitTest(self.actionButtonNode.convert(point, from: self), with: event) {
return result
}
if self.bounds.contains(point) {
if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) && !self.cancelButtonNode.bounds.contains(self.convert(point, to: self.cancelButtonNode)) {
return self.dimNode.view
}
}
return super.hitTest(point, with: event)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset
let additionalTopHeight = max(0.0, -contentOffset.y)
if additionalTopHeight >= 30.0 {
self.cancelButtonPressed()
}
}
private func scheduleInteractiveTransition(_ transition: ContainedViewLayoutTransition) {
if let scheduledLayoutTransitionRequest = self.scheduledLayoutTransitionRequest {
switch scheduledLayoutTransitionRequest.1 {
case .immediate:
self.scheduleLayoutTransitionRequest(transition)
default:
break
}
} else {
self.scheduleLayoutTransitionRequest(transition)
}
}
private func scheduleLayoutTransitionRequest(_ transition: ContainedViewLayoutTransition) {
let requestId = self.scheduledLayoutTransitionRequestId
self.scheduledLayoutTransitionRequestId += 1
self.scheduledLayoutTransitionRequest = (requestId, transition)
(self.view as? UITracingLayerView)?.schedule(layout: { [weak self] in
if let strongSelf = self {
if let (currentRequestId, currentRequestTransition) = strongSelf.scheduledLayoutTransitionRequest, currentRequestId == requestId {
strongSelf.scheduledLayoutTransitionRequest = nil
strongSelf.requestLayout(currentRequestTransition)
}
}
})
self.setNeedsLayout()
}
func transitionToProgress(signal: Signal<Void, NoError>) {
let transition = ContainedViewLayoutTransition.animated(duration: 0.12, curve: .easeInOut)
transition.updateAlpha(node: self.actionButtonNode, alpha: 0.0)
transition.updateAlpha(node: self.actionSeparatorNode, alpha: 0.0)
transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 0.0)
self.transitionToContentNode(ShareLoadingContainerNode(theme: self.presentationData.theme, forceNativeAppearance: false), fastOut: true)
let timestamp = CACurrentMediaTime()
self.disposable.set(signal.start(completed: { [weak self] in
let minDelay = 0.6
let delay = max(0.0, (timestamp + minDelay) - CACurrentMediaTime())
Queue.mainQueue().after(delay, {
if let strongSelf = self {
strongSelf.cancel?()
}
})
}))
}
func setPeer(call: CachedChannelData.ActiveCall, peer: Peer, title: String?, memberCount: Int, isStream: Bool) {
self.call = call
let transition = ContainedViewLayoutTransition.animated(duration: 0.22, curve: .easeInOut)
transition.updateAlpha(node: self.actionButtonNode, alpha: 1.0)
transition.updateAlpha(node: self.actionSeparatorNode, alpha: 1.0)
transition.updateAlpha(node: self.actionsBackgroundNode, alpha: 1.0)
self.actionButtonNode.isEnabled = true
self.actionButtonNode.setTitle(self.asSpeaker ? self.presentationData.strings.Invitation_JoinVoiceChatAsSpeaker : self.presentationData.strings.Invitation_JoinVoiceChatAsListener, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal)
self.transitionToContentNode(VoiceChatPreviewContentNode(context: self.context, peer: peer, title: title, memberCount: memberCount, isStream: isStream, theme: self.presentationData.theme, strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder))
}
}
}
private let avatarFont = avatarPlaceholderFont(size: 26.0)
final class VoiceChatPreviewContentNode: ASDisplayNode, ShareContentContainerNode {
private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)?
private let avatarNode: AvatarNode
private let titleNode: ImmediateTextNode
private let countNode: ImmediateTextNode
init(context: AccountContext, peer: Peer, title: String?, memberCount: Int, isStream: Bool, theme: PresentationTheme, strings: PresentationStrings, displayOrder: PresentationPersonNameOrder) {
self.avatarNode = AvatarNode(font: avatarFont)
self.titleNode = ImmediateTextNode()
self.titleNode.maximumNumberOfLines = 4
self.titleNode.textAlignment = .center
self.countNode = ImmediateTextNode()
self.countNode.textAlignment = .center
super.init()
self.addSubnode(self.avatarNode)
self.avatarNode.setPeer(context: context, theme: theme, peer: EnginePeer(peer), emptyColor: theme.list.mediaPlaceholderColor)
self.addSubnode(self.titleNode)
self.titleNode.attributedText = NSAttributedString(string: title ?? EnginePeer(peer).displayTitle(strings: strings, displayOrder: displayOrder), font: Font.semibold(16.0), textColor: theme.actionSheet.primaryTextColor)
self.addSubnode(self.countNode)
self.countNode.isHidden = memberCount == 0
let text: String
if isStream {
text = memberCount == 0 ? "" : strings.LiveStream_ViewerCount(Int32(memberCount))
} else {
text = memberCount == 0 ? "" : strings.VoiceChat_Panel_Members(Int32(memberCount))
}
self.countNode.attributedText = NSAttributedString(string: text, font: Font.regular(16.0), textColor: theme.actionSheet.secondaryTextColor)
}
func activate() {
}
func deactivate() {
}
func setEnsurePeerVisibleOnLayout(_ peerId: PeerId?) {
}
func setDidBeginDragging(_ f: (() -> Void)?) {
}
func setContentOffsetUpdated(_ f: ((CGFloat, ContainedViewLayoutTransition) -> Void)?) {
self.contentOffsetUpdated = f
}
func updateTheme(_ theme: PresentationTheme) {
self.titleNode.attributedText = NSAttributedString(string: self.titleNode.attributedText?.string ?? "", font: Font.semibold(16.0), textColor: theme.actionSheet.primaryTextColor)
self.countNode.attributedText = NSAttributedString(string: self.countNode.attributedText?.string ?? "", font: Font.regular(16.0), textColor: theme.actionSheet.secondaryTextColor)
}
func updateLayout(size: CGSize, isLandscape: Bool, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
let sideInset: CGFloat = 16.0
let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: size.height))
let countSize = self.countNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: size.height))
var nodeHeight: CGFloat = 185.0 + titleSize.height
if !self.countNode.isHidden {
nodeHeight += 20.0
}
let verticalOrigin = size.height - nodeHeight
let avatarSize: CGFloat = 75.0
transition.updateFrame(node: self.avatarNode, frame: CGRect(origin: CGPoint(x: floor((size.width - avatarSize) / 2.0), y: verticalOrigin + 22.0), size: CGSize(width: avatarSize, height: avatarSize)))
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: verticalOrigin + 22.0 + avatarSize + 15.0), size: titleSize))
transition.updateFrame(node: self.countNode, frame: CGRect(origin: CGPoint(x: floor((size.width - countSize.width) / 2.0), y: verticalOrigin + 22.0 + avatarSize + 15.0 + titleSize.height + 1.0), size: countSize))
self.contentOffsetUpdated?(-size.height + nodeHeight - 64.0, transition)
}
func updateSelectedPeers(animated: Bool) {
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private final class VoiceChatMicrophoneNodeDrawingState: NSObject {
let color: UIColor
let shadowColor: UIColor?
let shadowBlur: CGFloat
let filled: Bool
let transition: CGFloat
let reverse: Bool
init(color: UIColor, shadowColor: UIColor?, shadowBlur: CGFloat, filled: Bool, transition: CGFloat, reverse: Bool) {
self.color = color
self.shadowColor = shadowColor
self.shadowBlur = shadowBlur
self.filled = filled
self.transition = transition
self.reverse = reverse
super.init()
}
}
public final class VoiceChatMicrophoneNode: ASDisplayNode {
public class State: Equatable {
public let muted: Bool
public let color: UIColor
public let filled: Bool
public let shadowColor: UIColor?
public let shadowBlur: CGFloat
public init(muted: Bool, filled: Bool, color: UIColor, shadowColor: UIColor? = nil, shadowBlur: CGFloat = 0.0) {
self.muted = muted
self.filled = filled
self.color = color
self.shadowColor = shadowColor
self.shadowBlur = shadowBlur
}
public static func ==(lhs: State, rhs: State) -> Bool {
if lhs.muted != rhs.muted {
return false
}
if lhs.color.argb != rhs.color.argb {
return false
}
if lhs.filled != rhs.filled {
return false
}
if lhs.shadowColor != rhs.shadowColor {
return false
}
if lhs.shadowBlur != rhs.shadowBlur {
return false
}
return true
}
}
private class TransitionContext {
let startTime: Double
let duration: Double
let previousState: State
init(startTime: Double, duration: Double, previousState: State) {
self.startTime = startTime
self.duration = duration
self.previousState = previousState
}
}
private var animator: ConstantDisplayLinkAnimator?
private var hasState = false
private var state: State = State(muted: false, filled: false, color: .black)
private var transitionContext: TransitionContext?
override public init() {
super.init()
self.isOpaque = false
}
public func update(state: State, animated: Bool) {
var animated = animated
if !self.hasState {
self.hasState = true
animated = false
}
if self.state != state {
let previousState = self.state
self.state = state
if animated {
self.transitionContext = TransitionContext(startTime: CACurrentMediaTime(), duration: 0.18, previousState: previousState)
}
self.updateAnimations()
self.setNeedsDisplay()
}
}
private func updateAnimations() {
var animate = false
let timestamp = CACurrentMediaTime()
if let transitionContext = self.transitionContext {
if transitionContext.startTime + transitionContext.duration < timestamp {
self.transitionContext = nil
} else {
animate = true
}
}
if animate {
let animator: ConstantDisplayLinkAnimator
if let current = self.animator {
animator = current
} else {
animator = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.updateAnimations()
})
self.animator = animator
}
animator.isPaused = false
} else {
self.animator?.isPaused = true
}
self.setNeedsDisplay()
}
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
var transitionFraction: CGFloat = self.state.muted ? 1.0 : 0.0
var color = self.state.color
var shadowColor = self.state.shadowColor
var shadowBlur = self.state.shadowBlur
var reverse = false
if let transitionContext = self.transitionContext {
let timestamp = CACurrentMediaTime()
var t = CGFloat((timestamp - transitionContext.startTime) / transitionContext.duration)
t = min(1.0, max(0.0, t))
if transitionContext.previousState.muted != self.state.muted {
transitionFraction = self.state.muted ? t : 1.0 - t
reverse = transitionContext.previousState.muted
}
if transitionContext.previousState.color.rgb != color.rgb {
color = transitionContext.previousState.color.interpolateTo(color, fraction: t)!
}
if let previousShadowColor = transitionContext.previousState.shadowColor, let shadowColorValue = shadowColor, previousShadowColor.rgb != shadowColorValue.rgb {
shadowColor = previousShadowColor.interpolateTo(shadowColorValue, fraction: t)!
}
if transitionContext.previousState.shadowBlur != shadowBlur {
shadowBlur = transitionContext.previousState.shadowBlur * (1.0 - t) + shadowBlur * t
}
}
return VoiceChatMicrophoneNodeDrawingState(color: color, shadowColor: shadowColor, shadowBlur: shadowBlur, filled: self.state.filled, transition: transitionFraction, reverse: reverse)
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? VoiceChatMicrophoneNodeDrawingState else {
return
}
var bounds = bounds
bounds = bounds.insetBy(dx: parameters.shadowBlur, dy: parameters.shadowBlur)
context.translateBy(x: bounds.minX, y: bounds.minY)
context.setFillColor(parameters.color.cgColor)
if let shadowColor = parameters.shadowColor, parameters.shadowBlur != 0.0 {
context.setShadow(offset: CGSize(), blur: parameters.shadowBlur, color: shadowColor.cgColor)
}
var clearLineWidth: CGFloat = 2.0
var lineWidth: CGFloat = 1.0 + UIScreenPixel
if bounds.size.width > 36.0 {
context.scaleBy(x: 2.0, y: 2.0)
} else if bounds.size.width < 30.0 {
clearLineWidth = 2.0
lineWidth = 1.0
}
if bounds.width < 30.0 {
context.translateBy(x: 4.0, y: 3.0)
let _ = try? drawSvgPath(context, path: "M14,8.335 C14.36727,8.335 14.665,8.632731 14.665,9 C14.665,11.903515 12.48064,14.296846 9.665603,14.626311 L9.665,16 C9.665,16.367269 9.367269,16.665 9,16.665 C8.666119,16.665 8.389708,16.418942 8.34221,16.098269 L8.335,16 L8.3354,14.626428 C5.519879,14.297415 3.335,11.90386 3.335,9 C3.335,8.632731 3.632731,8.335 4,8.335 C4.367269,8.335 4.665,8.632731 4.665,9 C4.665,11.394154 6.605846,13.335 9,13.335 C11.39415,13.335 13.335,11.394154 13.335,9 C13.335,8.632731 13.63273,8.335 14,8.335 Z ")
} else {
context.translateBy(x: 17.0, y: 18.0)
let _ = try? drawSvgPath(context, path: "M-0.004000000189989805,-9.86400032043457 C2.2960000038146973,-9.86400032043457 4.165999889373779,-8.053999900817871 4.25600004196167,-5.77400016784668 C4.25600004196167,-5.77400016784668 4.265999794006348,-5.604000091552734 4.265999794006348,-5.604000091552734 C4.265999794006348,-5.604000091552734 4.265999794006348,-0.8040000200271606 4.265999794006348,-0.8040000200271606 C4.265999794006348,1.555999994277954 2.3559999465942383,3.4660000801086426 -0.004000000189989805,3.4660000801086426 C-2.2939999103546143,3.4660000801086426 -4.164000034332275,1.6460000276565552 -4.263999938964844,-0.6240000128746033 C-4.263999938964844,-0.6240000128746033 -4.263999938964844,-0.8040000200271606 -4.263999938964844,-0.8040000200271606 C-4.263999938964844,-0.8040000200271606 -4.263999938964844,-5.604000091552734 -4.263999938964844,-5.604000091552734 C-4.263999938964844,-7.953999996185303 -2.3540000915527344,-9.86400032043457 -0.004000000189989805,-9.86400032043457 Z ")
}
if bounds.width > 30.0 && !parameters.filled {
context.setBlendMode(.clear)
let _ = try? drawSvgPath(context, path: "M0.004000000189989805,-8.53600025177002 C-1.565999984741211,-8.53600025177002 -2.8459999561309814,-7.306000232696533 -2.936000108718872,-5.75600004196167 C-2.936000108718872,-5.75600004196167 -2.936000108718872,-5.5960001945495605 -2.936000108718872,-5.5960001945495605 C-2.936000108718872,-5.5960001945495605 -2.936000108718872,-0.7960000038146973 -2.936000108718872,-0.7960000038146973 C-2.936000108718872,0.8240000009536743 -1.6260000467300415,2.134000062942505 0.004000000189989805,2.134000062942505 C1.5740000009536743,2.134000062942505 2.8540000915527344,0.9039999842643738 2.934000015258789,-0.6460000276565552 C2.934000015258789,-0.6460000276565552 2.934000015258789,-0.7960000038146973 2.934000015258789,-0.7960000038146973 C2.934000015258789,-0.7960000038146973 2.934000015258789,-5.5960001945495605 2.934000015258789,-5.5960001945495605 C2.934000015258789,-7.22599983215332 1.6239999532699585,-8.53600025177002 0.004000000189989805,-8.53600025177002 Z ")
context.setBlendMode(.normal)
}
if bounds.width < 30.0 {
let _ = try? drawSvgPath(context, path: "M9,2.5 C10.38071,2.5 11.5,3.61929 11.5,5 L11.5,9 C11.5,10.380712 10.38071,11.5 9,11.5 C7.619288,11.5 6.5,10.380712 6.5,9 L6.5,5 C6.5,3.61929 7.619288,2.5 9,2.5 Z ")
context.translateBy(x: -4.0, y: -3.0)
} else {
let _ = try? drawSvgPath(context, path: "M6.796000003814697,-1.4639999866485596 C7.165999889373779,-1.4639999866485596 7.466000080108643,-1.1640000343322754 7.466000080108643,-0.8040000200271606 C7.466000080108643,3.0959999561309814 4.47599983215332,6.296000003814697 0.6660000085830688,6.636000156402588 C0.6660000085830688,6.636000156402588 0.6660000085830688,9.196000099182129 0.6660000085830688,9.196000099182129 C0.6660000085830688,9.565999984741211 0.3659999966621399,9.866000175476074 -0.004000000189989805,9.866000175476074 C-0.33399999141693115,9.866000175476074 -0.6140000224113464,9.605999946594238 -0.6539999842643738,9.28600025177002 C-0.6539999842643738,9.28600025177002 -0.6639999747276306,9.196000099182129 -0.6639999747276306,9.196000099182129 C-0.6639999747276306,9.196000099182129 -0.6639999747276306,6.636000156402588 -0.6639999747276306,6.636000156402588 C-4.473999977111816,6.296000003814697 -7.464000225067139,3.0959999561309814 -7.464000225067139,-0.8040000200271606 C-7.464000225067139,-1.1640000343322754 -7.164000034332275,-1.4639999866485596 -6.803999900817871,-1.4639999866485596 C-6.434000015258789,-1.4639999866485596 -6.133999824523926,-1.1640000343322754 -6.133999824523926,-0.8040000200271606 C-6.133999824523926,2.5859999656677246 -3.384000062942505,5.335999965667725 -0.004000000189989805,5.335999965667725 C3.385999917984009,5.335999965667725 6.136000156402588,2.5859999656677246 6.136000156402588,-0.8040000200271606 C6.136000156402588,-1.1640000343322754 6.435999870300293,-1.4639999866485596 6.796000003814697,-1.4639999866485596 Z ")
context.translateBy(x: -18.0, y: -18.0)
}
if parameters.transition > 0.0 {
let startPoint: CGPoint
let endPoint: CGPoint
let origin: CGPoint
let length: CGFloat
if bounds.width > 30.0 {
origin = CGPoint(x: 9.0, y: 10.0 - UIScreenPixel)
length = 17.0
} else {
origin = CGPoint(x: 5.0 + UIScreenPixel, y: 4.0 + UIScreenPixel)
length = 15.0
}
if parameters.reverse {
startPoint = CGPoint(x: origin.x + length * (1.0 - parameters.transition), y: origin.y + length * (1.0 - parameters.transition)).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
endPoint = CGPoint(x: origin.x + length, y: origin.y + length).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
} else {
startPoint = origin.offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
endPoint = CGPoint(x: origin.x + length * parameters.transition, y: origin.y + length * parameters.transition).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
}
context.setBlendMode(.clear)
context.setLineWidth(clearLineWidth)
context.move(to: startPoint.offsetBy(dx: 0.0, dy: 1.0 + UIScreenPixel))
context.addLine(to: endPoint.offsetBy(dx: 0.0, dy: 1.0 + UIScreenPixel))
context.strokePath()
context.setBlendMode(.normal)
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: startPoint)
context.addLine(to: endPoint)
context.strokePath()
}
}
}
@@ -0,0 +1,471 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramVoip
import TelegramAudio
import AccountContext
import TelegramCore
import AppBundle
import ContextUI
import PresentationDataUtils
import TooltipUI
import VoiceChatActionButton
private let slideOffset: CGFloat = 80.0 + 44.0
public final class VoiceChatOverlayController: ViewController {
private final class Node: ViewControllerTracingNode, ASGestureRecognizerDelegate {
private weak var controller: VoiceChatOverlayController?
private var validLayout: ContainerViewLayout?
init(controller: VoiceChatOverlayController) {
self.controller = controller
super.init()
self.clipsToBounds = true
}
private var isButtonHidden = false
private var isSlidOffscreen = false
func update(hidden: Bool, slide: Bool, animated: Bool) {
guard let actionButton = self.controller?.actionButton else {
return
}
if self.isButtonHidden == hidden {
return
}
self.isButtonHidden = hidden
var slide = slide
if self.isSlidOffscreen && !hidden {
slide = true
}
self.isSlidOffscreen = hidden && slide
guard actionButton.supernode === self else {
return
}
if animated {
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
if hidden {
if slide {
actionButton.isHidden = false
transition.updateSublayerTransformOffset(layer: actionButton.layer, offset: CGPoint(x: slideOffset, y: 0.0))
} else {
actionButton.layer.removeAllAnimations()
actionButton.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak actionButton] finished in
if finished {
actionButton?.isHidden = true
}
})
}
} else {
actionButton.isHidden = false
actionButton.layer.removeAllAnimations()
if slide {
transition.updateSublayerTransformOffset(layer: actionButton.layer, offset: CGPoint())
} else {
actionButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4)
}
}
} else {
actionButton.isHidden = hidden
actionButton.layer.removeAllAnimations()
if hidden {
if slide {
actionButton.layer.sublayerTransform = CATransform3DMakeTranslation(slideOffset, 0.0, 0.0)
}
} else {
if slide {
actionButton.layer.sublayerTransform = CATransform3DIdentity
}
}
}
}
private var initialLeftButtonPosition: CGPoint?
private var initialRightButtonPosition: CGPoint?
func animateIn(from: CGRect) {
guard let controller = self.controller, let actionButton = controller.actionButton, let audioOutputNode = controller.audioOutputNode, let cameraNode = controller.cameraNode, let rightButton = controller.leaveNode else {
return
}
let leftButton: CallControllerButtonItemNode
if audioOutputNode.alpha.isZero {
leftButton = cameraNode
} else {
leftButton = audioOutputNode
}
self.initialLeftButtonPosition = leftButton.position
self.initialRightButtonPosition = rightButton.position
actionButton.update(snap: true, animated: !self.isSlidOffscreen && !self.isButtonHidden)
if self.isSlidOffscreen {
leftButton.isHidden = true
rightButton.isHidden = true
actionButton.layer.sublayerTransform = CATransform3DMakeTranslation(slideOffset, 0.0, 0.0)
return
} else if self.isButtonHidden {
leftButton.isHidden = true
rightButton.isHidden = true
actionButton.isHidden = true
return
}
let center = CGPoint(x: actionButton.frame.width / 2.0, y: actionButton.frame.height / 2.0)
leftButton.layer.animatePosition(from: leftButton.position, to: center, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak leftButton] _ in
leftButton?.isHidden = true
leftButton?.textNode.layer.removeAllAnimations()
leftButton?.layer.removeAllAnimations()
})
leftButton.layer.animateScale(from: 1.0, to: 0.5, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
rightButton.layer.animatePosition(from: rightButton.position, to: center, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak rightButton] _ in
rightButton?.isHidden = true
rightButton?.textNode.layer.removeAllAnimations()
rightButton?.layer.removeAllAnimations()
})
rightButton.layer.animateScale(from: 1.0, to: 0.5, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
leftButton.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false)
rightButton.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false)
let targetPosition = actionButton.position
let sourcePoint = CGPoint(x: from.midX, y: from.midY)
let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y + 90.0)
let x1 = sourcePoint.x
let y1 = sourcePoint.y
let x2 = midPoint.x
let y2 = midPoint.y
let x3 = targetPosition.x
let y3 = targetPosition.y
let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3))
var keyframes: [AnyObject] = []
for i in 0 ..< 10 {
let k = CGFloat(i) / CGFloat(10 - 1)
let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k
let y = a * x * x + b * x + c
keyframes.append(NSValue(cgPoint: CGPoint(x: x, y: y)))
}
actionButton.layer.animateKeyframes(values: keyframes, duration: 0.2, keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { _ in
})
}
private var animating = false
private var dismissed = false
func animateOut(reclaim: Bool, targetPosition: CGPoint, completion: @escaping (Bool) -> Void) {
guard let controller = self.controller, let actionButton = controller.actionButton, let audioOutputNode = controller.audioOutputNode, let cameraNode = controller.cameraNode, let rightButton = controller.leaveNode else {
return
}
let leftButton: CallControllerButtonItemNode
if audioOutputNode.alpha.isZero {
leftButton = cameraNode
} else {
leftButton = audioOutputNode
}
if reclaim {
self.dismissed = true
if self.isSlidOffscreen {
self.isSlidOffscreen = false
self.isButtonHidden = true
actionButton.layer.sublayerTransform = CATransform3DIdentity
actionButton.update(snap: false, animated: false)
actionButton.position = CGPoint(x: targetPosition.x, y: bottomAreaHeight / 2.0)
leftButton.isHidden = false
rightButton.isHidden = false
if let leftButtonPosition = self.initialLeftButtonPosition {
leftButton.position = CGPoint(x: actionButton.position.x + leftButtonPosition.x, y: actionButton.position.y)
}
if let rightButtonPosition = self.initialRightButtonPosition {
rightButton.position = CGPoint(x: actionButton.position.x + rightButtonPosition.x, y: actionButton.position.y)
}
completion(true)
} else if self.isButtonHidden {
actionButton.isHidden = false
actionButton.layer.removeAllAnimations()
actionButton.layer.sublayerTransform = CATransform3DIdentity
actionButton.update(snap: false, animated: false)
actionButton.position = CGPoint(x: targetPosition.x, y: bottomAreaHeight / 2.0)
leftButton.isHidden = false
rightButton.isHidden = false
if let leftButtonPosition = self.initialLeftButtonPosition {
leftButton.position = CGPoint(x: actionButton.position.x + leftButtonPosition.x, y: actionButton.position.y)
}
if let rightButtonPosition = self.initialRightButtonPosition {
rightButton.position = CGPoint(x: actionButton.position.x + rightButtonPosition.x, y: actionButton.position.y)
}
completion(true)
} else {
self.animating = true
let sourcePoint = actionButton.position
let transitionNode = ASDisplayNode()
transitionNode.position = sourcePoint
transitionNode.addSubnode(actionButton)
actionButton.position = CGPoint()
self.addSubnode(transitionNode)
if let leftButtonPosition = self.initialLeftButtonPosition, let rightButtonPosition = self.initialRightButtonPosition {
let center = CGPoint(x: actionButton.frame.width / 2.0, y: actionButton.frame.height / 2.0)
leftButton.isHidden = false
rightButton.isHidden = false
leftButton.layer.animatePosition(from: center, to: leftButtonPosition, duration: 0.26, delay: 0.07, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false)
rightButton.layer.animatePosition(from: center, to: rightButtonPosition, duration: 0.26, delay: 0.07, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false)
leftButton.layer.animateScale(from: 0.55, to: 1.0, duration: 0.26, delay: 0.06, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
rightButton.layer.animateScale(from: 0.55, to: 1.0, duration: 0.26, delay: 0.06, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue)
leftButton.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.05)
rightButton.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.05)
}
actionButton.update(snap: false, animated: true)
actionButton.position = CGPoint(x: targetPosition.x - sourcePoint.x, y: 80.0)
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
transition.animateView {
transitionNode.position = CGPoint(x: transitionNode.position.x, y: targetPosition.y - 80.0)
}
actionButton.layer.animatePosition(from: CGPoint(), to: actionButton.position, duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { _ in
self.animating = false
completion(false)
})
}
} else {
actionButton.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self, weak actionButton] _ in
actionButton?.removeFromSupernode()
self?.controller?.dismiss()
})
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let actionButton = self.controller?.actionButton, actionButton.supernode === self && !self.isButtonHidden {
let actionButtonSize = CGSize(width: 84.0, height: 84.0)
let actionButtonFrame = CGRect(origin: CGPoint(x: actionButton.position.x - actionButtonSize.width / 2.0, y: actionButton.position.y - actionButtonSize.height / 2.0), size: actionButtonSize)
if actionButtonFrame.contains(point) {
return actionButton.hitTest(self.view.convert(point, to: actionButton.view), with: event)
}
}
return nil
}
private var didAnimateIn = false
func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
if let controller = self.controller, let actionButton = controller.actionButton, let audioOutputNode = controller.audioOutputNode, let cameraNode = controller.cameraNode, let rightButton = controller.leaveNode, !self.animating && !self.dismissed {
let leftButton: CallControllerButtonItemNode
if audioOutputNode.alpha.isZero {
leftButton = cameraNode
} else {
leftButton = audioOutputNode
}
let convertedRect = actionButton.view.convert(actionButton.bounds, to: self.view)
let insets = layout.insets(options: [.input])
if !self.didAnimateIn {
let leftButtonFrame = leftButton.view.convert(leftButton.bounds, to: actionButton.bottomNode.view)
actionButton.bottomNode.addSubnode(leftButton)
leftButton.frame = leftButtonFrame
let rightButtonFrame = rightButton.view.convert(rightButton.bounds, to: actionButton.bottomNode.view)
actionButton.bottomNode.addSubnode(rightButton)
rightButton.frame = rightButtonFrame
}
transition.updatePosition(node: actionButton, position: CGPoint(x: layout.size.width - layout.safeInsets.right - 21.0, y: layout.size.height - insets.bottom - 22.0))
if actionButton.supernode !== self && !self.didAnimateIn {
self.didAnimateIn = true
actionButton.ignoreHierarchyChanges = true
self.addSubnode(actionButton)
var hidden = false
if let initiallyHidden = self.controller?.initiallyHidden, initiallyHidden {
hidden = initiallyHidden
}
if hidden {
self.update(hidden: true, slide: true, animated: false)
}
self.animateIn(from: convertedRect)
if hidden {
self.controller?.setupVisibilityUpdates()
}
actionButton.ignoreHierarchyChanges = false
}
}
}
}
private weak var actionButton: VoiceChatActionButton?
private weak var cameraNode: CallControllerButtonItemNode?
private weak var audioOutputNode: CallControllerButtonItemNode?
private weak var leaveNode: CallControllerButtonItemNode?
private var controllerNode: Node {
return self.displayNode as! Node
}
private var disposable: Disposable?
private weak var parentNavigationController: NavigationController?
private var currentParams: ([UIViewController], [UIViewController], VoiceChatActionButton.State)?
fileprivate var initiallyHidden: Bool
init(actionButton: VoiceChatActionButton, audioOutputNode: CallControllerButtonItemNode, cameraNode: CallControllerButtonItemNode, leaveNode: CallControllerButtonItemNode, navigationController: NavigationController?, initiallyHidden: Bool) {
self.actionButton = actionButton
self.audioOutputNode = audioOutputNode
self.cameraNode = cameraNode
self.leaveNode = leaveNode
self.parentNavigationController = navigationController
self.initiallyHidden = initiallyHidden
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
if case .active(.cantSpeak) = actionButton.stateValue {
} else if !initiallyHidden {
self.additionalSideInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 75.0)
}
if !self.initiallyHidden {
self.setupVisibilityUpdates()
}
}
deinit {
self.disposable?.dispose()
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
self.displayNode = Node(controller: self)
self.displayNodeDidLoad()
}
private func setupVisibilityUpdates() {
if let navigationController = self.parentNavigationController, let actionButton = self.actionButton {
let controllers: Signal<[UIViewController], NoError> = .single([])
|> then(navigationController.viewControllersSignal)
let overlayControllers: Signal<[UIViewController], NoError> = .single([])
|> then(navigationController.overlayControllersSignal)
self.disposable = (combineLatest(queue: Queue.mainQueue(), controllers, overlayControllers, actionButton.state)).start(next: { [weak self] controllers, overlayControllers, state in
if let strongSelf = self {
strongSelf.currentParams = (controllers, overlayControllers, state)
strongSelf.updateVisibility()
}
})
}
}
public override func dismiss(completion: (() -> Void)? = nil) {
super.dismiss(completion: completion)
self.presentingViewController?.dismiss(animated: false, completion: nil)
completion?()
}
func animateOut(reclaim: Bool, targetPosition: CGPoint, completion: @escaping (Bool) -> Void) {
self.controllerNode.animateOut(reclaim: reclaim, targetPosition: targetPosition, completion: completion)
}
public func updateVisibility() {
guard let (controllers, overlayControllers, state) = self.currentParams else {
return
}
var hasVoiceChatController = false
var overlayControllersCount = 0
for controller in controllers {
if controller is VoiceChatController {
hasVoiceChatController = true
}
}
for controller in overlayControllers {
if controller is TooltipController || controller is TooltipScreen || controller is AlertController {
} else {
overlayControllersCount += 1
}
}
var slide = true
var hidden = true
var animated = true
if controllers.count == 1 || controllers.last is ChatController {
if let chatController = controllers.last as? ChatController {
slide = false
if !chatController.isSendButtonVisible {
hidden = false
}
} else {
hidden = false
}
}
if let tabBarController = controllers.last as? TabBarController {
if let chatListController = tabBarController.controllers[tabBarController.selectedIndex] as? ChatListController, chatListController.isSearchActive {
hidden = true
}
}
if overlayControllersCount > 0 {
hidden = true
}
switch state {
case .active(.cantSpeak), .button, .scheduled:
hidden = true
default:
break
}
if hasVoiceChatController {
hidden = false
animated = self.initiallyHidden
self.initiallyHidden = false
}
self.controllerNode.update(hidden: hidden, slide: slide, animated: animated)
let previousInsets = self.additionalSideInsets
self.additionalSideInsets = hidden ? UIEdgeInsets() : UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 75.0)
if previousInsets != self.additionalSideInsets {
self.parentNavigationController?.requestLayout(transition: .animated(duration: 0.3, curve: .easeInOut))
}
}
private let hiddenPromise = ValuePromise<Bool>()
public func update(hidden: Bool, slide: Bool, animated: Bool) {
self.hiddenPromise.set(hidden)
self.controllerNode.update(hidden: hidden, slide: slide, animated: animated)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, transition: transition)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import AvatarNode
import AccountContext
public class VoiceChatPeerActionSheetItem: ActionSheetItem {
public let context: AccountContext
public let peer: EnginePeer
public let title: String
public let subtitle: String
public let action: () -> Void
public init(context: AccountContext, peer: EnginePeer, title: String, subtitle: String, action: @escaping () -> Void) {
self.context = context
self.peer = peer
self.title = title
self.subtitle = subtitle
self.action = action
}
public func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode {
let node = VoiceChatPeerActionSheetItemNode(theme: theme)
node.setItem(self)
return node
}
public func updateNode(_ node: ActionSheetItemNode) {
guard let node = node as? VoiceChatPeerActionSheetItemNode else {
assertionFailure()
return
}
node.setItem(self)
node.requestLayoutUpdate()
}
}
private let avatarFont = avatarPlaceholderFont(size: 15.0)
public class VoiceChatPeerActionSheetItemNode: ActionSheetItemNode {
private let theme: ActionSheetControllerTheme
private let defaultFont: UIFont
private var item: VoiceChatPeerActionSheetItem?
private let button: HighlightTrackingButton
private let avatarNode: AvatarNode
private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private let accessibilityArea: AccessibilityAreaNode
override public init(theme: ActionSheetControllerTheme) {
self.theme = theme
self.defaultFont = Font.regular(floor(theme.baseFontSize * 20.0 / 17.0))
self.button = HighlightTrackingButton()
self.button.isAccessibilityElement = false
self.avatarNode = AvatarNode(font: avatarFont)
self.avatarNode.isLayerBacked = !smartInvertColorsEnabled()
self.avatarNode.isAccessibilityElement = false
self.titleNode = ImmediateTextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.isAccessibilityElement = false
self.subtitleNode = ImmediateTextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.subtitleNode.displaysAsynchronously = false
self.subtitleNode.maximumNumberOfLines = 1
self.subtitleNode.isAccessibilityElement = false
self.accessibilityArea = AccessibilityAreaNode()
super.init(theme: theme)
self.view.addSubview(self.button)
self.addSubnode(self.avatarNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
self.addSubnode(self.accessibilityArea)
self.button.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemHighlightedBackgroundColor
} else {
UIView.animate(withDuration: 0.3, animations: {
strongSelf.backgroundNode.backgroundColor = strongSelf.theme.itemBackgroundColor
})
}
}
}
self.button.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
self.accessibilityArea.activate = { [weak self] in
self?.buttonPressed()
return true
}
}
func setItem(_ item: VoiceChatPeerActionSheetItem) {
self.item = item
let titleFont = Font.regular(floor(self.theme.baseFontSize ))
self.titleNode.attributedText = NSAttributedString(string: item.title, font: titleFont, textColor: self.theme.primaryTextColor)
let subtitleFont = Font.regular(floor(self.theme.baseFontSize * 13.0 / 17.0))
self.subtitleNode.attributedText = NSAttributedString(string: item.subtitle, font: subtitleFont, textColor: self.theme.secondaryTextColor)
let theme = item.context.sharedContext.currentPresentationData.with { $0 }.theme
self.avatarNode.setPeer(context: item.context, theme: theme, peer: item.peer)
self.accessibilityArea.accessibilityTraits = [.button]
self.accessibilityArea.accessibilityLabel = item.title
self.accessibilityArea.accessibilityValue = item.subtitle
}
public override func updateLayout(constrainedSize: CGSize, transition: ContainedViewLayoutTransition) -> CGSize {
let size = CGSize(width: constrainedSize.width, height: 57.0)
self.button.frame = CGRect(origin: CGPoint(), size: size)
let avatarInset: CGFloat = 42.0
let avatarSize: CGFloat = 36.0
self.avatarNode.frame = CGRect(origin: CGPoint(x: size.width - avatarSize - 14.0, y: floor((size.height - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))
let titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, size.width - avatarInset - 16.0 - 16.0 - 30.0), height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: 16.0, y: 9.0), size: titleSize)
let subtitleSize = self.subtitleNode.updateLayout(CGSize(width: max(1.0, size.width - avatarInset - 16.0 - 16.0 - 30.0), height: size.height))
self.subtitleNode.frame = CGRect(origin: CGPoint(x: 16.0, y: 32.0), size: subtitleSize)
self.accessibilityArea.frame = CGRect(origin: CGPoint(), size: size)
self.updateInternalLayout(size, constrainedSize: constrainedSize)
return size
}
@objc private func buttonPressed() {
if let item = self.item {
item.action()
}
}
}
@@ -0,0 +1,501 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import PresentationDataUtils
import AvatarNode
import TelegramStringFormatting
import ContextUI
import AccountContext
import LegacyComponents
import PeerInfoAvatarListNode
private let backgroundCornerRadius: CGFloat = 14.0
final class VoiceChatPeerProfileNode: ASDisplayNode {
private let context: AccountContext
private let size: CGSize
private var peer: EnginePeer
private var text: VoiceChatParticipantItem.ParticipantText
private let customNode: ASDisplayNode?
private let additionalEntry: Signal<(TelegramMediaImageRepresentation, Float)?, NoError>
private let backgroundImageNode: ASImageNode
private let avatarListContainerNode: ASDisplayNode
let avatarListWrapperNode: PinchSourceContainerNode
let avatarListNode: PeerInfoAvatarListContainerNode
private var videoFadeNode: ASImageNode
private let infoNode: ASDisplayNode
private let titleNode: ImmediateTextNode
private let statusNode: VoiceChatParticipantStatusNode
private var appeared = false
init(context: AccountContext, size: CGSize, sourceSize: CGSize, peer: EnginePeer, text: VoiceChatParticipantItem.ParticipantText, customNode: ASDisplayNode? = nil, additionalEntry: Signal<(TelegramMediaImageRepresentation, Float)?, NoError>, requestDismiss: (() -> Void)?) {
self.context = context
self.size = size
self.peer = peer
self.text = text
self.customNode = customNode
self.additionalEntry = additionalEntry
self.backgroundImageNode = ASImageNode()
self.backgroundImageNode.clipsToBounds = true
self.backgroundImageNode.displaysAsynchronously = false
self.backgroundImageNode.displayWithoutProcessing = true
self.videoFadeNode = ASImageNode()
self.videoFadeNode.displaysAsynchronously = false
self.videoFadeNode.contentMode = .scaleToFill
self.avatarListContainerNode = ASDisplayNode()
self.avatarListContainerNode.clipsToBounds = true
self.avatarListWrapperNode = PinchSourceContainerNode()
self.avatarListWrapperNode.clipsToBounds = true
self.avatarListWrapperNode.cornerRadius = backgroundCornerRadius
self.avatarListNode = PeerInfoAvatarListContainerNode(context: context)
self.avatarListNode.backgroundColor = .clear
self.avatarListNode.peer = peer
self.avatarListNode.firstFullSizeOnly = true
self.avatarListNode.offsetLocation = true
self.avatarListNode.customCenterTapAction = {
requestDismiss?()
}
self.infoNode = ASDisplayNode()
self.infoNode.clipsToBounds = true
self.titleNode = ImmediateTextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.statusNode = VoiceChatParticipantStatusNode()
self.statusNode.isUserInteractionEnabled = false
super.init()
self.clipsToBounds = true
self.addSubnode(self.backgroundImageNode)
self.addSubnode(self.infoNode)
self.addSubnode(self.videoFadeNode)
self.addSubnode(self.avatarListWrapperNode)
self.infoNode.addSubnode(self.titleNode)
self.infoNode.addSubnode(self.statusNode)
self.avatarListContainerNode.addSubnode(self.avatarListNode)
self.avatarListContainerNode.addSubnode(self.avatarListNode.controlsClippingOffsetNode)
self.avatarListWrapperNode.contentNode.addSubnode(self.avatarListContainerNode)
self.avatarListWrapperNode.activate = { [weak self] sourceNode in
guard let strongSelf = self else {
return
}
strongSelf.avatarListNode.controlsContainerNode.alpha = 0.0
let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: {
return UIScreen.main.bounds
})
context.sharedContext.mainWindow?.presentInGlobalOverlay(pinchController)
}
self.avatarListWrapperNode.deactivated = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.avatarListWrapperNode.contentNode.layer.animate(from: 0.0 as NSNumber, to: backgroundCornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.3, completion: { _ in
})
}
self.avatarListWrapperNode.animatedOut = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.avatarListNode.controlsContainerNode.alpha = 1.0
strongSelf.avatarListNode.controlsContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
self.updateInfo(size: size, sourceSize: sourceSize, animate: false)
}
func updateInfo(size: CGSize, sourceSize: CGSize, animate: Bool) {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let titleFont = Font.regular(17.0)
let titleColor = UIColor.white
var titleAttributedString: NSAttributedString?
if case let .user(user) = self.peer {
if let firstName = user.firstName, let lastName = user.lastName, !firstName.isEmpty, !lastName.isEmpty {
let string = NSMutableAttributedString()
switch presentationData.nameDisplayOrder {
case .firstLast:
string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor))
case .lastFirst:
string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor))
}
titleAttributedString = string
} else if let firstName = user.firstName, !firstName.isEmpty {
titleAttributedString = NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)
} else if let lastName = user.lastName, !lastName.isEmpty {
titleAttributedString = NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)
} else {
titleAttributedString = NSAttributedString(string: presentationData.strings.User_DeletedAccount, font: titleFont, textColor: titleColor)
}
} else if case let .legacyGroup(group) = peer {
titleAttributedString = NSAttributedString(string: group.title, font: titleFont, textColor: titleColor)
} else if case let .channel(channel) = peer {
titleAttributedString = NSAttributedString(string: channel.title, font: titleFont, textColor: titleColor)
}
self.titleNode.attributedText = titleAttributedString
let titleSize = self.titleNode.updateLayout(CGSize(width: self.size.width - 24.0, height: size.height))
let makeStatusLayout = self.statusNode.asyncLayout()
let (statusLayout, statusApply) = makeStatusLayout(CGSize(width: self.size.width - 24.0, height: CGFloat.greatestFiniteMagnitude), self.text, true)
let _ = statusApply()
self.titleNode.frame = CGRect(origin: CGPoint(x: 14.0, y: 0.0), size: titleSize)
self.statusNode.frame = CGRect(origin: CGPoint(x: 14.0, y: titleSize.height + 3.0), size: statusLayout)
let totalHeight = titleSize.height + statusLayout.height + 3.0 + 8.0
let infoFrame = CGRect(x: 0.0, y: size.height - totalHeight, width: sourceSize.width, height: totalHeight)
if animate {
let springDuration: Double = !self.appeared ? 0.42 : 0.3
let springDamping: CGFloat = !self.appeared ? 124.0 : 1000.0
let initialInfoPosition = self.infoNode.position
self.infoNode.layer.position = infoFrame.center
let initialInfoBounds = self.infoNode.bounds
self.infoNode.layer.bounds = CGRect(origin: CGPoint(), size: infoFrame.size)
self.infoNode.layer.animateSpring(from: NSValue(cgPoint: initialInfoPosition), to: NSValue(cgPoint: self.infoNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping)
self.infoNode.layer.animateSpring(from: NSValue(cgRect: initialInfoBounds), to: NSValue(cgRect: self.infoNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
} else {
self.infoNode.frame = infoFrame
}
}
func animateIn(from sourceNode: ASDisplayNode, targetRect: CGRect, transition: ContainedViewLayoutTransition) {
let radiusTransition = ContainedViewLayoutTransition.animated(duration: 0.15, curve: .easeInOut)
let springDuration: Double = 0.42
let springDamping: CGFloat = 124.0
if let sourceNode = sourceNode as? VoiceChatTileItemNode {
let sourceRect = sourceNode.bounds
self.backgroundImageNode.frame = sourceNode.bounds
self.updateInfo(size: sourceNode.bounds.size, sourceSize: sourceNode.bounds.size, animate: false)
self.updateInfo(size: targetRect.size, sourceSize: targetRect.size, animate: true)
self.backgroundImageNode.image = generateImage(CGSize(width: backgroundCornerRadius * 2.0, height: backgroundCornerRadius * 2.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setFillColor(UIColor(rgb: 0x1c1c1e).cgColor)
context.fillEllipse(in: bounds)
context.fill(CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height / 2.0))
})?.stretchableImage(withLeftCapWidth: Int(backgroundCornerRadius), topCapHeight: Int(backgroundCornerRadius))
self.backgroundImageNode.cornerRadius = backgroundCornerRadius
transition.updateCornerRadius(node: self.backgroundImageNode, cornerRadius: 0.0)
let initialRect = sourceRect
let initialScale: CGFloat = sourceRect.width / targetRect.width
let targetSize = CGSize(width: targetRect.size.width, height: targetRect.size.width)
self.avatarListWrapperNode.update(size: targetSize, transition: .immediate)
self.avatarListWrapperNode.frame = CGRect(x: targetRect.minX, y: targetRect.minY, width: targetRect.width, height: targetRect.width + backgroundCornerRadius)
self.avatarListContainerNode.frame = CGRect(origin: CGPoint(), size: targetSize)
self.avatarListContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.avatarListContainerNode.cornerRadius = targetRect.width / 2.0
var appearanceTransition = transition
if transition.isAnimated {
appearanceTransition = .animated(duration: springDuration, curve: .customSpring(damping: springDamping, initialVelocity: 0.0))
}
if let videoNode = sourceNode.videoNode {
videoNode.updateLayout(size: targetSize, layoutMode: .fillOrFitToSquare, transition: appearanceTransition)
appearanceTransition.updateFrame(node: videoNode, frame: CGRect(origin: CGPoint(), size: targetSize))
appearanceTransition.updateFrame(node: sourceNode.videoContainerNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: targetSize.width, height: targetSize.height + backgroundCornerRadius)))
sourceNode.videoContainerNode.cornerRadius = backgroundCornerRadius
}
self.insertSubnode(sourceNode.videoContainerNode, belowSubnode: self.avatarListWrapperNode)
if let snapshotView = sourceNode.infoNode.view.snapshotView(afterScreenUpdates: false) {
self.videoFadeNode.image = tileFadeImage
self.videoFadeNode.transform = CATransform3DMakeScale(1.0, -1.0, 1.0)
self.videoFadeNode.frame = CGRect(x: 0.0, y: sourceRect.height - sourceNode.fadeNode.frame.height, width: sourceRect.width, height: sourceNode.fadeNode.frame.height)
self.insertSubnode(self.videoFadeNode, aboveSubnode: sourceNode.videoContainerNode)
self.view.insertSubview(snapshotView, aboveSubview: sourceNode.videoContainerNode.view)
snapshotView.frame = sourceRect
appearanceTransition.updateFrame(view: snapshotView, frame: CGRect(origin: CGPoint(x: 0.0, y: targetSize.height - snapshotView.frame.size.height), size: snapshotView.frame.size))
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
snapshotView.removeFromSuperview()
})
appearanceTransition.updateFrame(node: self.videoFadeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: targetSize.height - self.videoFadeNode.frame.size.height), size: CGSize(width: targetSize.width, height: self.videoFadeNode.frame.height)))
self.videoFadeNode.alpha = 0.0
self.videoFadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
self.avatarListWrapperNode.layer.animateSpring(from: initialScale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
self.avatarListWrapperNode.layer.animateSpring(from: NSValue(cgPoint: initialRect.center), to: NSValue(cgPoint: self.avatarListWrapperNode.position), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.avatarListNode.updateCustomItemsOnlySynchronously = false
strongSelf.avatarListNode.currentItemNode?.addSubnode(sourceNode.videoContainerNode)
}
})
radiusTransition.updateCornerRadius(node: self.avatarListContainerNode, cornerRadius: 0.0)
self.avatarListWrapperNode.contentNode.clipsToBounds = true
self.avatarListNode.frame = CGRect(x: targetRect.width / 2.0, y: targetRect.width / 2.0, width: targetRect.width, height: targetRect.width)
self.avatarListNode.controlsClippingNode.frame = CGRect(x: -targetRect.width / 2.0, y: -targetRect.width / 2.0, width: targetRect.width, height: targetRect.width)
self.avatarListNode.controlsClippingOffsetNode.frame = CGRect(origin: CGPoint(x: targetRect.width / 2.0, y: targetRect.width / 2.0), size: CGSize())
self.avatarListNode.stripContainerNode.frame = CGRect(x: 0.0, y: 13.0, width: targetRect.width, height: 2.0)
self.avatarListNode.topShadowNode.frame = CGRect(x: 0.0, y: 0.0, width: targetRect.width, height: 44.0)
self.avatarListNode.updateCustomItemsOnlySynchronously = true
self.avatarListNode.update(size: targetSize, peer: self.peer, customNode: self.customNode, additionalEntry: self.additionalEntry, isExpanded: true, transition: .immediate)
let backgroundTargetRect = CGRect(x: 0.0, y: targetSize.height - backgroundCornerRadius * 2.0, width: targetRect.width, height: targetRect.height - targetSize.height + backgroundCornerRadius * 2.0)
let initialBackgroundPosition = self.backgroundImageNode.position
self.backgroundImageNode.layer.position = backgroundTargetRect.center
let initialBackgroundBounds = self.backgroundImageNode.bounds
self.backgroundImageNode.layer.bounds = CGRect(origin: CGPoint(), size: backgroundTargetRect.size)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: self.backgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: self.backgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
} else if let sourceNode = sourceNode as? VoiceChatFullscreenParticipantItemNode {
let sourceRect = sourceNode.bounds
self.backgroundImageNode.frame = sourceNode.bounds
self.updateInfo(size: sourceNode.bounds.size, sourceSize: sourceNode.bounds.size, animate: false)
self.updateInfo(size: targetRect.size, sourceSize: targetRect.size, animate: true)
self.backgroundImageNode.image = generateImage(CGSize(width: backgroundCornerRadius * 2.0, height: backgroundCornerRadius * 2.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setFillColor(UIColor(rgb: 0x1c1c1e).cgColor)
context.fillEllipse(in: bounds)
context.fill(CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height / 2.0))
})?.stretchableImage(withLeftCapWidth: Int(backgroundCornerRadius), topCapHeight: Int(backgroundCornerRadius))
self.backgroundImageNode.cornerRadius = backgroundCornerRadius
transition.updateCornerRadius(node: self.backgroundImageNode, cornerRadius: 0.0)
let initialRect: CGRect
let hasVideo: Bool
if let videoNode = sourceNode.videoNode, videoNode.supernode == sourceNode.videoContainerNode, !videoNode.alpha.isZero {
initialRect = sourceRect
hasVideo = true
} else {
initialRect = sourceNode.avatarNode.frame
hasVideo = false
}
let initialScale = initialRect.width / targetRect.width
let targetSize = CGSize(width: targetRect.size.width, height: targetRect.size.width)
self.avatarListWrapperNode.update(size: targetSize, transition: .immediate)
self.avatarListWrapperNode.frame = CGRect(x: targetRect.minX, y: targetRect.minY, width: targetRect.width, height: targetRect.width + backgroundCornerRadius)
self.avatarListContainerNode.frame = CGRect(origin: CGPoint(), size: targetSize)
self.avatarListContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.avatarListContainerNode.cornerRadius = targetRect.width / 2.0
var appearanceTransition = transition
if transition.isAnimated {
appearanceTransition = .animated(duration: springDuration, curve: .customSpring(damping: springDamping, initialVelocity: 0.0))
}
if let videoNode = sourceNode.videoNode, hasVideo {
videoNode.updateLayout(size: targetSize, layoutMode: .fillOrFitToSquare, transition: appearanceTransition)
appearanceTransition.updateFrame(node: videoNode, frame: CGRect(origin: CGPoint(), size: targetSize))
appearanceTransition.updateFrame(node: sourceNode.videoFadeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: targetSize.height - fadeHeight), size: CGSize(width: targetSize.width, height: fadeHeight)))
appearanceTransition.updateTransformScale(node: sourceNode.videoContainerNode, scale: 1.0)
appearanceTransition.updateFrame(node: sourceNode.videoContainerNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: targetSize.width, height: targetSize.height + backgroundCornerRadius)))
sourceNode.videoContainerNode.cornerRadius = backgroundCornerRadius
appearanceTransition.updateAlpha(node: sourceNode.videoFadeNode, alpha: 0.0)
} else {
let transitionNode = ASImageNode()
transitionNode.clipsToBounds = true
transitionNode.displaysAsynchronously = false
transitionNode.displayWithoutProcessing = true
transitionNode.image = sourceNode.avatarNode.unroundedImage
transitionNode.frame = CGRect(origin: CGPoint(), size: targetSize)
transitionNode.cornerRadius = targetRect.width / 2.0
radiusTransition.updateCornerRadius(node: transitionNode, cornerRadius: 0.0)
sourceNode.avatarNode.isHidden = true
self.avatarListWrapperNode.contentNode.insertSubnode(transitionNode, at: 0)
}
self.insertSubnode(sourceNode.videoContainerNode, belowSubnode: self.avatarListWrapperNode)
self.avatarListWrapperNode.layer.animateSpring(from: initialScale as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
self.avatarListWrapperNode.layer.animateSpring(from: NSValue(cgPoint: initialRect.center), to: NSValue(cgPoint: self.avatarListWrapperNode.position), keyPath: "position", duration: springDuration, initialVelocity: 0.0, damping: springDamping, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.avatarListNode.updateCustomItemsOnlySynchronously = false
strongSelf.avatarListNode.currentItemNode?.addSubnode(sourceNode.videoContainerNode)
}
})
radiusTransition.updateCornerRadius(node: self.avatarListContainerNode, cornerRadius: 0.0)
self.avatarListWrapperNode.contentNode.clipsToBounds = true
self.avatarListNode.frame = CGRect(x: targetRect.width / 2.0, y: targetRect.width / 2.0, width: targetRect.width, height: targetRect.width)
self.avatarListNode.controlsClippingNode.frame = CGRect(x: -targetRect.width / 2.0, y: -targetRect.width / 2.0, width: targetRect.width, height: targetRect.width)
self.avatarListNode.controlsClippingOffsetNode.frame = CGRect(origin: CGPoint(x: targetRect.width / 2.0, y: targetRect.width / 2.0), size: CGSize())
self.avatarListNode.stripContainerNode.frame = CGRect(x: 0.0, y: 13.0, width: targetRect.width, height: 2.0)
self.avatarListNode.topShadowNode.frame = CGRect(x: 0.0, y: 0.0, width: targetRect.width, height: 44.0)
self.avatarListNode.updateCustomItemsOnlySynchronously = true
self.avatarListNode.update(size: targetSize, peer: self.peer, customNode: self.customNode, additionalEntry: self.additionalEntry, isExpanded: true, transition: .immediate)
let backgroundTargetRect = CGRect(x: 0.0, y: targetSize.height - backgroundCornerRadius * 2.0, width: targetRect.width, height: targetRect.height - targetSize.height + backgroundCornerRadius * 2.0)
let initialBackgroundPosition = self.backgroundImageNode.position
self.backgroundImageNode.layer.position = backgroundTargetRect.center
let initialBackgroundBounds = self.backgroundImageNode.bounds
self.backgroundImageNode.layer.bounds = CGRect(origin: CGPoint(), size: backgroundTargetRect.size)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: self.backgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: self.backgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
}
self.appeared = true
}
func animateOut(to targetNode: ASDisplayNode, targetRect: CGRect, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void = {}) {
let radiusTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
let springDuration: Double = 0.3
let springDamping: CGFloat = 1000.0
if let targetNode = targetNode as? VoiceChatTileItemNode {
let initialSize = self.bounds
self.updateInfo(size: targetRect.size, sourceSize: targetRect.size, animate: true)
transition.updateCornerRadius(node: self.backgroundImageNode, cornerRadius: backgroundCornerRadius)
let targetScale = targetRect.width / avatarListContainerNode.frame.width
self.insertSubnode(targetNode.videoContainerNode, belowSubnode: self.avatarListWrapperNode)
self.insertSubnode(self.videoFadeNode, aboveSubnode: targetNode.videoContainerNode)
self.avatarListWrapperNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
self.avatarListWrapperNode.layer.animate(from: 1.0 as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false)
self.avatarListWrapperNode.layer.animate(from: NSValue(cgPoint: self.avatarListWrapperNode.position), to: NSValue(cgPoint: targetRect.center), keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak self, weak targetNode] _ in
if let targetNode = targetNode {
targetNode.contentNode.insertSubnode(targetNode.videoContainerNode, aboveSubnode: targetNode.backgroundNode)
}
completion()
self?.removeFromSupernode()
})
radiusTransition.updateCornerRadius(node: self.avatarListContainerNode, cornerRadius: backgroundCornerRadius)
if let snapshotView = targetNode.infoNode.view.snapshotView(afterScreenUpdates: true) {
self.view.insertSubview(snapshotView, aboveSubview: targetNode.videoContainerNode.view)
let snapshotFrame = snapshotView.frame
snapshotView.frame = CGRect(origin: CGPoint(x: 0.0, y: initialSize.width - snapshotView.frame.size.height), size: snapshotView.frame.size)
transition.updateFrame(view: snapshotView, frame: snapshotFrame)
snapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
transition.updateFrame(node: self.videoFadeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: targetRect.height - self.videoFadeNode.frame.size.height), size: CGSize(width: targetRect.width, height: self.videoFadeNode.frame.height)))
self.videoFadeNode.alpha = 1.0
self.videoFadeNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if let videoNode = targetNode.videoNode {
videoNode.updateLayout(size: targetRect.size, layoutMode: .fillOrFitToSquare, transition: transition)
transition.updateFrame(node: videoNode, frame: targetRect)
transition.updateFrame(node: targetNode.videoContainerNode, frame: targetRect)
}
let backgroundTargetRect = targetRect
let initialBackgroundPosition = self.backgroundImageNode.position
self.backgroundImageNode.layer.position = backgroundTargetRect.center
let initialBackgroundBounds = self.backgroundImageNode.bounds
self.backgroundImageNode.layer.bounds = CGRect(origin: CGPoint(), size: backgroundTargetRect.size)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: self.backgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: self.backgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
self.avatarListNode.stripContainerNode.alpha = 0.0
self.avatarListNode.stripContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.avatarListNode.topShadowNode.alpha = 0.0
self.avatarListNode.topShadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.infoNode.alpha = 0.0
self.infoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
} else if let targetNode = targetNode as? VoiceChatFullscreenParticipantItemNode {
let backgroundTargetRect = targetRect
self.updateInfo(size: targetRect.size, sourceSize: targetRect.size, animate: true)
targetNode.avatarNode.isHidden = false
transition.updateCornerRadius(node: self.backgroundImageNode, cornerRadius: backgroundCornerRadius)
var targetRect = targetRect
let hasVideo: Bool
if let videoNode = targetNode.videoNode, !videoNode.alpha.isZero {
hasVideo = true
} else {
targetRect = targetNode.avatarNode.frame
hasVideo = false
}
let targetScale = targetRect.width / self.avatarListContainerNode.frame.width
self.insertSubnode(targetNode.videoContainerNode, belowSubnode: self.avatarListWrapperNode)
self.insertSubnode(self.videoFadeNode, aboveSubnode: targetNode.videoContainerNode)
self.avatarListWrapperNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
self.avatarListWrapperNode.layer.animate(from: 1.0 as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false)
self.avatarListWrapperNode.layer.animate(from: NSValue(cgPoint: self.avatarListWrapperNode.position), to: NSValue(cgPoint: targetRect.center), keyPath: "position", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2, removeOnCompletion: false, completion: { [weak self, weak targetNode] _ in
if let targetNode = targetNode {
targetNode.offsetContainerNode.insertSubnode(targetNode.videoContainerNode, at: 0)
}
completion()
self?.removeFromSupernode()
})
radiusTransition.updateCornerRadius(node: self.avatarListContainerNode, cornerRadius: backgroundCornerRadius)
if hasVideo, let videoNode = targetNode.videoNode {
videoNode.updateLayout(size: CGSize(width: 180.0, height: 180.0), layoutMode: .fillOrFitToSquare, transition: transition)
transition.updateFrame(node: videoNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: 180.0, height: 180.0)))
transition.updateTransformScale(node: targetNode.videoContainerNode, scale: 84.0 / 180.0)
transition.updateFrameAsPositionAndBounds(node: targetNode.videoContainerNode, frame: CGRect(x: 0.0, y: 0.0, width: 180.0, height: 180.0))
transition.updatePosition(node: targetNode.videoContainerNode, position: CGPoint(x: 42.0, y: 42.0))
transition.updateFrame(node: targetNode.videoFadeNode, frame: CGRect(x: 0.0, y: 180.0 - fadeHeight, width: 180.0, height: fadeHeight))
transition.updateAlpha(node: targetNode.videoFadeNode, alpha: 1.0)
}
let initialBackgroundPosition = self.backgroundImageNode.position
self.backgroundImageNode.layer.position = backgroundTargetRect.center
let initialBackgroundBounds = self.backgroundImageNode.bounds
self.backgroundImageNode.layer.bounds = CGRect(origin: CGPoint(), size: backgroundTargetRect.size)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgPoint: initialBackgroundPosition), to: NSValue(cgPoint: self.backgroundImageNode.position), keyPath: "position", duration: springDuration, delay: 0.0, initialVelocity: 0.0, damping: springDamping)
self.backgroundImageNode.layer.animateSpring(from: NSValue(cgRect: initialBackgroundBounds), to: NSValue(cgRect: self.backgroundImageNode.bounds), keyPath: "bounds", duration: springDuration, initialVelocity: 0.0, damping: springDamping)
self.avatarListNode.stripContainerNode.alpha = 0.0
self.avatarListNode.stripContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.avatarListNode.topShadowNode.alpha = 0.0
self.avatarListNode.topShadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.infoNode.alpha = 0.0
self.infoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
}
@@ -0,0 +1,201 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private let iconImage = generateTintedImage(image: UIImage(bundleImageName: "Call/Pin"), color: .white)
private final class VoiceChatPinNodeDrawingState: NSObject {
let color: UIColor
let transition: CGFloat
let reverse: Bool
init(color: UIColor, transition: CGFloat, reverse: Bool) {
self.color = color
self.transition = transition
self.reverse = reverse
super.init()
}
}
final class VoiceChatPinNode: ASDisplayNode {
class State: Equatable {
let pinned: Bool
let color: UIColor
init(pinned: Bool, color: UIColor) {
self.pinned = pinned
self.color = color
}
static func ==(lhs: State, rhs: State) -> Bool {
if lhs.pinned != rhs.pinned {
return false
}
if lhs.color.argb != rhs.color.argb {
return false
}
return true
}
}
private class TransitionContext {
let startTime: Double
let duration: Double
let previousState: State
init(startTime: Double, duration: Double, previousState: State) {
self.startTime = startTime
self.duration = duration
self.previousState = previousState
}
}
private var animator: ConstantDisplayLinkAnimator?
private var hasState = false
private var state: State = State(pinned: false, color: .black)
private var transitionContext: TransitionContext?
override init() {
super.init()
self.isOpaque = false
}
func update(state: State, animated: Bool) {
var animated = animated
if !self.hasState {
self.hasState = true
animated = false
}
if self.state != state {
let previousState = self.state
self.state = state
if animated {
self.transitionContext = TransitionContext(startTime: CACurrentMediaTime(), duration: 0.18, previousState: previousState)
}
self.updateAnimations()
self.setNeedsDisplay()
}
}
private func updateAnimations() {
var animate = false
let timestamp = CACurrentMediaTime()
if let transitionContext = self.transitionContext {
if transitionContext.startTime + transitionContext.duration < timestamp {
self.transitionContext = nil
} else {
animate = true
}
}
if animate {
let animator: ConstantDisplayLinkAnimator
if let current = self.animator {
animator = current
} else {
animator = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.updateAnimations()
})
self.animator = animator
}
animator.isPaused = false
} else {
self.animator?.isPaused = true
}
self.setNeedsDisplay()
}
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
var transitionFraction: CGFloat = self.state.pinned ? 1.0 : 0.0
var color = self.state.color
var reverse = false
if let transitionContext = self.transitionContext {
let timestamp = CACurrentMediaTime()
var t = CGFloat((timestamp - transitionContext.startTime) / transitionContext.duration)
t = min(1.0, max(0.0, t))
if transitionContext.previousState.pinned != self.state.pinned {
transitionFraction = self.state.pinned ? t : 1.0 - t
reverse = transitionContext.previousState.pinned
}
if transitionContext.previousState.color.rgb != color.rgb {
color = transitionContext.previousState.color.interpolateTo(color, fraction: t)!
}
}
return VoiceChatPinNodeDrawingState(color: color, transition: transitionFraction, reverse: reverse)
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? VoiceChatPinNodeDrawingState else {
return
}
context.setFillColor(parameters.color.cgColor)
let clearLineWidth: CGFloat = 2.0
let lineWidth: CGFloat = 1.0 + UIScreenPixel
if let iconImage = iconImage?.cgImage {
context.saveGState()
context.translateBy(x: bounds.midX, y: bounds.midY)
context.scaleBy(x: 1.0, y: -1.0)
context.translateBy(x: -bounds.midX, y: -bounds.midY)
context.draw(iconImage, in: CGRect(origin: CGPoint(), size: CGSize(width: 48.0, height: 48.0)))
context.restoreGState()
}
if parameters.transition > 0.0 {
let startPoint: CGPoint
let endPoint: CGPoint
let origin = CGPoint(x: 14.0, y: 16.0 - UIScreenPixel)
let length: CGFloat = 17.0
if parameters.reverse {
startPoint = CGPoint(x: origin.x + length * (1.0 - parameters.transition), y: origin.y + length * (1.0 - parameters.transition)).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
endPoint = CGPoint(x: origin.x + length, y: origin.y + length).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
} else {
startPoint = origin.offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
endPoint = CGPoint(x: origin.x + length * parameters.transition, y: origin.y + length * parameters.transition).offsetBy(dx: UIScreenPixel, dy: -UIScreenPixel)
}
context.setBlendMode(.clear)
context.setLineWidth(clearLineWidth)
context.move(to: startPoint.offsetBy(dx: 0.0, dy: 1.0 + UIScreenPixel))
context.addLine(to: endPoint.offsetBy(dx: 0.0, dy: 1.0 + UIScreenPixel))
context.strokePath()
context.setBlendMode(.normal)
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: startPoint)
context.addLine(to: endPoint)
context.strokePath()
}
}
}
@@ -0,0 +1,264 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import AppBundle
import ContextUI
import TelegramStringFormatting
func generateStartRecordingIcon(color: UIColor) -> UIImage? {
return generateImage(CGSize(width: 18.0, height: 18.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setLineWidth(1.0 + UIScreenPixel)
context.setStrokeColor(color.cgColor)
context.strokeEllipse(in: bounds.insetBy(dx: 1.0, dy: 1.0))
context.setFillColor(color.cgColor)
context.fillEllipse(in: bounds.insetBy(dx: 5.0, dy: 5.0))
})
}
final class VoiceChatRecordingContextItem: ContextMenuCustomItem {
fileprivate let timestamp: Int32
fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void
init(timestamp: Int32, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) {
self.timestamp = timestamp
self.action = action
}
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return VoiceChatRecordingContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected)
}
}
private let textFont = Font.regular(17.0)
class VoiceChatRecordingIconNode: ASDisplayNode {
private let backgroundNode: ASImageNode
private let dotNode: ASImageNode
init(hasBackground: Bool) {
let iconSize = 16.0 + (1.0 + UIScreenPixel) * 2.0
self.backgroundNode = ASImageNode()
self.backgroundNode.displaysAsynchronously = false
self.backgroundNode.displayWithoutProcessing = true
self.backgroundNode.image = generateCircleImage(diameter: iconSize, lineWidth: 1.0 + UIScreenPixel, color: UIColor.white, backgroundColor: nil)
self.backgroundNode.isLayerBacked = true
self.dotNode = ASImageNode()
self.dotNode.displaysAsynchronously = false
self.dotNode.displayWithoutProcessing = true
self.dotNode.image = generateFilledCircleImage(diameter: 8.0, color: UIColor(rgb: 0xff3b30))
self.dotNode.isLayerBacked = true
super.init()
self.isLayerBacked = true
if hasBackground {
self.addSubnode(self.backgroundNode)
}
self.addSubnode(self.dotNode)
}
override func didEnterHierarchy() {
self.setupAnimation()
}
override func didExitHierarchy() {
self.dotNode.layer.removeAllAnimations()
}
private func setupAnimation() {
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber]
animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber]
animation.duration = 0.7
animation.autoreverses = true
animation.repeatCount = Float.infinity
self.dotNode.layer.add(animation, forKey: "recording")
}
override func layout() {
super.layout()
self.backgroundNode.frame = self.bounds
let dotSize = CGSize(width: 8.0, height: 8.0)
self.dotNode.frame = CGRect(origin: CGPoint(x: (self.bounds.width - dotSize.width) / 2.0, y: (self.bounds.height - dotSize.height) / 2.0), size: dotSize)
}
}
private final class VoiceChatRecordingContextItemNode: ASDisplayNode, ContextMenuCustomNode {
private let item: VoiceChatRecordingContextItem
private let presentationData: PresentationData
private let getController: () -> ContextControllerProtocol?
private let actionSelected: (ContextMenuActionResult) -> Void
private let backgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private let statusNode: ImmediateTextNode
private let iconNode: VoiceChatRecordingIconNode
private let buttonNode: HighlightTrackingButtonNode
private var timer: SwiftSignalKit.Timer?
private var pointerInteraction: PointerInteraction?
init(presentationData: PresentationData, item: VoiceChatRecordingContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.item = item
self.presentationData = presentationData
self.getController = getController
self.actionSelected = actionSelected
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
let subtextFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isAccessibilityElement = false
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.textNode = ImmediateTextNode()
self.textNode.isAccessibilityElement = false
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.textNode.attributedText = NSAttributedString(string: presentationData.strings.VoiceChat_StopRecording, font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
self.textNode.maximumNumberOfLines = 1
let statusNode = ImmediateTextNode()
statusNode.isAccessibilityElement = false
statusNode.isUserInteractionEnabled = false
statusNode.displaysAsynchronously = false
statusNode.attributedText = NSAttributedString(string: "0:00", font: subtextFont, textColor: presentationData.theme.contextMenu.secondaryColor)
statusNode.maximumNumberOfLines = 1
self.statusNode = statusNode
self.buttonNode = HighlightTrackingButtonNode()
self.buttonNode.isAccessibilityElement = true
self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording
self.iconNode = VoiceChatRecordingIconNode(hasBackground: true)
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textNode)
self.addSubnode(self.statusNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.buttonNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
deinit {
self.timer?.invalidate()
}
override func didLoad() {
super.didLoad()
self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: {
}, willExit: {
})
let timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
self?.updateTime(transition: .immediate)
}, queue: Queue.mainQueue())
self.timer = timer
timer.start()
}
private var validLayout: CGSize?
func updateTime(transition: ContainedViewLayoutTransition) {
guard let size = self.validLayout else {
return
}
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
let duration = max(0, timestamp - item.timestamp)
let subtextFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
self.statusNode.attributedText = NSAttributedString(string: stringForDuration(Int32(duration)), font: subtextFont, textColor: presentationData.theme.contextMenu.secondaryColor)
let sideInset: CGFloat = 18.0
let iconSideInset: CGFloat = 22.0
let statusSize = self.statusNode.updateLayout(CGSize(width: size.width - sideInset - 32.0, height: .greatestFiniteMagnitude))
transition.updateFrameAdditive(node: self.statusNode, frame: CGRect(origin: CGPoint(x: iconSideInset + 38.0, y: self.statusNode.frame.minY), size: statusSize))
}
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let sideInset: CGFloat = 18.0
let iconSideInset: CGFloat = 22.0
let verticalInset: CGFloat = 11.0
let iconSide = 16.0 + (1.0 + UIScreenPixel) * 2.0
let iconSize: CGSize = CGSize(width: iconSide, height: iconSide)
let standardIconWidth: CGFloat = 32.0
var rightTextInset: CGFloat = sideInset
if !iconSize.width.isZero {
rightTextInset = max(iconSize.width, standardIconWidth) + iconSideInset + sideInset
}
let textSize = self.textNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude))
let statusSize = self.statusNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude))
let verticalSpacing: CGFloat = 2.0
let combinedTextHeight = textSize.height + verticalSpacing + statusSize.height
return (CGSize(width: max(textSize.width, statusSize.width) + sideInset + rightTextInset, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in
let hadLayout = self.validLayout != nil
self.validLayout = size
if !hadLayout {
self.updateTime(transition: .immediate)
}
let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: iconSideInset + 38.0, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
transition.updateFrameAdditive(node: self.statusNode, frame: CGRect(origin: CGPoint(x: iconSideInset + 38.0, y: verticalOrigin + verticalSpacing + textSize.height), size: textSize))
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
})
}
func updateTheme(presentationData: PresentationData) {
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
let subtextFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)
self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
self.statusNode.attributedText = NSAttributedString(string: self.statusNode.attributedText?.string ?? "", font: subtextFont, textColor: presentationData.theme.contextMenu.secondaryColor)
}
@objc private func buttonPressed() {
self.performAction()
}
func canBeHighlighted() -> Bool {
return true
}
func updateIsHighlighted(isHighlighted: Bool) {
self.setIsHighlighted(isHighlighted)
}
func performAction() {
guard let controller = self.getController() else {
return
}
self.item.action(controller, { [weak self] result in
self?.actionSelected(result)
})
}
func setIsHighlighted(_ value: Bool) {
}
}
@@ -0,0 +1,630 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import SolidRoundedButtonNode
import PresentationDataUtils
import VoiceChatActionButton
private let accentColor: UIColor = UIColor(rgb: 0x0088ff)
final class VoiceChatRecordingSetupController: ViewController {
private var controllerNode: VoiceChatRecordingSetupControllerNode {
return self.displayNode as! VoiceChatRecordingSetupControllerNode
}
private let context: AccountContext
private let peer: EnginePeer
private let completion: (Bool?) -> Void
private var animatedIn = false
private var presentationDataDisposable: Disposable?
init(context: AccountContext, peer: EnginePeer, completion: @escaping (Bool?) -> Void) {
self.context = context
self.peer = peer
self.completion = completion
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.blocksBackgroundWhenInOverlay = true
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
strongSelf.controllerNode.updatePresentationData(presentationData)
}
})
self.statusBar.statusBarStyle = .Ignore
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = VoiceChatRecordingSetupControllerNode(controller: self, context: self.context, peer: self.peer)
self.controllerNode.completion = { [weak self] videoOrientation in
self?.completion(videoOrientation)
}
self.controllerNode.dismiss = { [weak self] in
self?.presentingViewController?.dismiss(animated: false, completion: nil)
}
self.controllerNode.cancel = { [weak self] in
self?.dismiss()
}
}
override public func loadView() {
super.loadView()
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !self.animatedIn {
self.animatedIn = true
self.controllerNode.animateIn()
}
}
override public func dismiss(completion: (() -> Void)? = nil) {
self.controllerNode.animateOut(completion: completion)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
}
private class VoiceChatRecordingSetupControllerNode: ViewControllerTracingNode, ASScrollViewDelegate {
enum MediaMode {
case videoAndAudio
case audioOnly
}
enum VideoMode {
case portrait
case landscape
}
private weak var controller: VoiceChatRecordingSetupController?
private let context: AccountContext
private var presentationData: PresentationData
private let dimNode: ASDisplayNode
private let wrappingScrollNode: ASScrollNode
private let contentContainerNode: ASDisplayNode
private let effectNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
private let contentBackgroundNode: ASDisplayNode
private let titleNode: ASTextNode
private let doneButton: VoiceChatActionButton
private let cancelButton: SolidRoundedButtonNode
private let modeContainerNode: ASDisplayNode
private let modeSeparatorNode: ASDisplayNode
private let videoAudioButton: HighlightTrackingButtonNode
private let videoAudioTitleNode: ImmediateTextNode
private let videoAudioCheckNode: ASImageNode
private let audioButton: HighlightTrackingButtonNode
private let audioTitleNode: ImmediateTextNode
private let audioCheckNode: ASImageNode
private let portraitButton: HighlightTrackingButtonNode
private let portraitIconNode: PreviewIconNode
private let portraitTitleNode: ImmediateTextNode
private let landscapeButton: HighlightTrackingButtonNode
private let landscapeIconNode: PreviewIconNode
private let landscapeTitleNode: ImmediateTextNode
private let selectionNode: ASImageNode
private var containerLayout: (ContainerViewLayout, CGFloat)?
private let hapticFeedback = HapticFeedback()
private let readyDisposable = MetaDisposable()
private var mediaMode: MediaMode = .videoAndAudio
private var videoMode: VideoMode = .portrait
var completion: ((Bool?) -> Void)?
var dismiss: (() -> Void)?
var cancel: (() -> Void)?
init(controller: VoiceChatRecordingSetupController, context: AccountContext, peer: EnginePeer) {
self.controller = controller
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.wrappingScrollNode = ASScrollNode()
self.wrappingScrollNode.view.alwaysBounceVertical = true
self.wrappingScrollNode.view.delaysContentTouches = false
self.wrappingScrollNode.view.canCancelContentTouches = true
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
self.contentContainerNode = ASDisplayNode()
self.contentContainerNode.isOpaque = false
self.backgroundNode = ASDisplayNode()
self.backgroundNode.clipsToBounds = true
self.backgroundNode.cornerRadius = 16.0
let backgroundColor = UIColor(rgb: 0x1c1c1e)
let textColor: UIColor = .white
let buttonColor: UIColor = UIColor(rgb: 0x2b2b2f)
let buttonTextColor: UIColor = .white
let blurStyle: UIBlurEffect.Style = .dark
self.effectNode = ASDisplayNode(viewBlock: {
return UIVisualEffectView(effect: UIBlurEffect(style: blurStyle))
})
self.contentBackgroundNode = ASDisplayNode()
self.contentBackgroundNode.backgroundColor = backgroundColor
let isLivestream: Bool
if case let .channel(channel) = peer, case .broadcast = channel.info {
isLivestream = true
} else {
isLivestream = false
}
let title = isLivestream ? self.presentationData.strings.LiveStream_RecordTitle : self.presentationData.strings.VoiceChat_RecordTitle
self.titleNode = ASTextNode()
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.bold(17.0), textColor: textColor)
self.doneButton = VoiceChatActionButton()
self.cancelButton = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: buttonColor, foregroundColor: buttonTextColor), font: .regular, height: 52.0, cornerRadius: 11.0)
self.cancelButton.title = self.presentationData.strings.Common_Cancel
self.modeContainerNode = ASDisplayNode()
self.modeContainerNode.clipsToBounds = true
self.modeContainerNode.cornerRadius = 11.0
self.modeContainerNode.backgroundColor = UIColor(rgb: 0x303032)
self.modeSeparatorNode = ASDisplayNode()
self.modeSeparatorNode.backgroundColor = UIColor(rgb: 0x404041)
self.videoAudioButton = HighlightTrackingButtonNode()
self.videoAudioTitleNode = ImmediateTextNode()
self.videoAudioTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordVideoAndAudio, font: Font.regular(17.0), textColor: .white, paragraphAlignment: .left)
self.videoAudioCheckNode = ASImageNode()
self.videoAudioCheckNode.displaysAsynchronously = false
self.videoAudioCheckNode.image = UIImage(bundleImageName: "Call/Check")
self.audioButton = HighlightTrackingButtonNode()
self.audioTitleNode = ImmediateTextNode()
self.audioTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordOnlyAudio, font: Font.regular(17.0), textColor: .white, paragraphAlignment: .left)
self.audioCheckNode = ASImageNode()
self.audioCheckNode.displaysAsynchronously = false
self.audioCheckNode.image = UIImage(bundleImageName: "Call/Check")
self.portraitButton = HighlightTrackingButtonNode()
self.portraitButton.backgroundColor = UIColor(rgb: 0x303032)
self.portraitButton.cornerRadius = 11.0
self.portraitIconNode = PreviewIconNode()
self.portraitTitleNode = ImmediateTextNode()
self.portraitTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordPortrait, font: Font.semibold(15.0), textColor: UIColor(rgb: 0x8e8e93), paragraphAlignment: .left)
self.landscapeButton = HighlightTrackingButtonNode()
self.landscapeButton.backgroundColor = UIColor(rgb: 0x303032)
self.landscapeButton.cornerRadius = 11.0
self.landscapeIconNode = PreviewIconNode()
self.landscapeTitleNode = ImmediateTextNode()
self.landscapeTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordLandscape, font: Font.semibold(15.0), textColor: UIColor(rgb: 0x8e8e93), paragraphAlignment: .left)
self.selectionNode = ASImageNode()
self.selectionNode.displaysAsynchronously = false
self.selectionNode.image = generateImage(CGSize(width: 174.0, height: 140.0), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let lineWidth: CGFloat = 2.0
let path = UIBezierPath(roundedRect: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0), cornerRadius: 11.0)
let cgPath = path.cgPath.copy(strokingWithWidth: lineWidth, lineCap: .round, lineJoin: .round, miterLimit: 10.0)
context.addPath(cgPath)
context.clip()
let colors: [CGColor] = [UIColor(rgb: 0x5064fd).cgColor, UIColor(rgb: 0xe76598).cgColor]
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
})
self.selectionNode.isUserInteractionEnabled = false
super.init()
self.backgroundColor = nil
self.isOpaque = false
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
self.addSubnode(self.dimNode)
self.wrappingScrollNode.view.delegate = self.wrappedScrollViewDelegate
self.addSubnode(self.wrappingScrollNode)
self.wrappingScrollNode.addSubnode(self.backgroundNode)
self.wrappingScrollNode.addSubnode(self.contentContainerNode)
self.backgroundNode.addSubnode(self.effectNode)
self.backgroundNode.addSubnode(self.contentBackgroundNode)
self.contentContainerNode.addSubnode(self.titleNode)
self.contentContainerNode.addSubnode(self.doneButton)
self.contentContainerNode.addSubnode(self.cancelButton)
self.contentContainerNode.addSubnode(self.modeContainerNode)
self.contentContainerNode.addSubnode(self.videoAudioTitleNode)
self.contentContainerNode.addSubnode(self.videoAudioCheckNode)
self.contentContainerNode.addSubnode(self.videoAudioButton)
self.contentContainerNode.addSubnode(self.modeSeparatorNode)
self.contentContainerNode.addSubnode(self.audioTitleNode)
self.contentContainerNode.addSubnode(self.audioCheckNode)
self.contentContainerNode.addSubnode(self.audioButton)
self.contentContainerNode.addSubnode(self.portraitButton)
self.contentContainerNode.addSubnode(self.portraitIconNode)
self.contentContainerNode.addSubnode(self.portraitTitleNode)
self.contentContainerNode.addSubnode(self.landscapeButton)
self.contentContainerNode.addSubnode(self.landscapeIconNode)
self.contentContainerNode.addSubnode(self.landscapeTitleNode)
self.contentContainerNode.addSubnode(self.selectionNode)
self.videoAudioButton.addTarget(self, action: #selector(self.videoAudioPressed), forControlEvents: .touchUpInside)
self.audioButton.addTarget(self, action: #selector(self.audioPressed), forControlEvents: .touchUpInside)
self.portraitButton.addTarget(self, action: #selector(self.portraitPressed), forControlEvents: .touchUpInside)
self.landscapeButton.addTarget(self, action: #selector(self.landscapePressed), forControlEvents: .touchUpInside)
self.doneButton.addTarget(self, action: #selector(self.donePressed), forControlEvents: .touchUpInside)
self.cancelButton.pressed = { [weak self] in
if let strongSelf = self {
strongSelf.cancel?()
}
}
}
@objc private func donePressed() {
let videoOrientation: Bool?
switch self.mediaMode {
case .audioOnly:
videoOrientation = nil
case .videoAndAudio:
switch self.videoMode {
case .portrait:
videoOrientation = true
case .landscape:
videoOrientation = false
}
}
self.completion?(videoOrientation)
self.dismiss?()
}
@objc private func videoAudioPressed() {
self.mediaMode = .videoAndAudio
if let (layout, navigationHeight) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.2, curve: .easeInOut))
}
}
@objc private func audioPressed() {
self.mediaMode = .audioOnly
if let (layout, navigationHeight) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.2, curve: .easeInOut))
}
}
@objc private func portraitPressed() {
self.mediaMode = .videoAndAudio
self.videoMode = .portrait
if let (layout, navigationHeight) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.2, curve: .easeInOut))
}
}
@objc private func landscapePressed() {
self.mediaMode = .videoAndAudio
self.videoMode = .landscape
if let (layout, navigationHeight) = self.containerLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .animated(duration: 0.2, curve: .easeInOut))
}
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
}
override func didLoad() {
super.didLoad()
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.wrappingScrollNode.view.contentInsetAdjustmentBehavior = .never
}
}
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
func animateIn() {
self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
let targetBounds = self.bounds
self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset)
self.dimNode.position = CGPoint(x: dimPosition.x, y: dimPosition.y - offset)
transition.animateView({
self.bounds = targetBounds
self.dimNode.position = dimPosition
})
}
func animateOut(completion: (() -> Void)? = nil) {
var dimCompleted = false
var offsetCompleted = false
let internalCompletion: () -> Void = { [weak self] in
if let strongSelf = self, dimCompleted && offsetCompleted {
strongSelf.dismiss?()
}
completion?()
}
self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in
dimCompleted = true
internalCompletion()
})
let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY
let dimPosition = self.dimNode.layer.position
self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
offsetCompleted = true
internalCompletion()
})
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if self.bounds.contains(point) {
if !self.contentBackgroundNode.bounds.contains(self.convert(point, to: self.contentBackgroundNode)) {
return self.dimNode.view
}
}
return super.hitTest(point, with: event)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset
let additionalTopHeight = max(0.0, -contentOffset.y)
if additionalTopHeight >= 30.0 {
self.cancel?()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
let isLandscape: Bool
if layout.size.width > layout.size.height, case .compact = layout.metrics.widthClass {
isLandscape = true
} else {
isLandscape = false
}
var insets = layout.insets(options: [.statusBar, .input])
let cleanInsets = layout.insets(options: [.statusBar])
insets.top = max(10.0, insets.top)
let buttonOffset: CGFloat = 60.0
let bottomInset: CGFloat = 10.0 + cleanInsets.bottom
let titleHeight: CGFloat = 54.0
var contentHeight = titleHeight + bottomInset + 52.0 + 17.0
let innerContentHeight: CGFloat = 287.0
var width = horizontalContainerFillingSizeForLayout(layout: layout, sideInset: 0.0)
if isLandscape {
contentHeight = layout.size.height
width = layout.size.width
} else {
contentHeight = titleHeight + bottomInset + 52.0 + 17.0 + innerContentHeight + buttonOffset
}
let inset: CGFloat = 16.0
let sideInset = floor((layout.size.width - width) / 2.0)
let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - contentHeight), size: CGSize(width: width, height: contentHeight))
let contentFrame = contentContainerFrame
var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY), size: CGSize(width: contentFrame.width, height: contentFrame.height + 2000.0))
if backgroundFrame.minY < contentFrame.minY {
backgroundFrame.origin.y = contentFrame.minY
}
transition.updateAlpha(node: self.titleNode, alpha: isLandscape ? 0.0 : 1.0)
transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame)
transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size))
transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let titleSize = self.titleNode.measure(CGSize(width: width, height: titleHeight))
let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 18.0), size: titleSize)
transition.updateFrame(node: self.titleNode, frame: titleFrame)
let itemHeight: CGFloat = 44.0
transition.updateFrame(node: self.modeContainerNode, frame: CGRect(x: inset, y: 56.0, width: contentFrame.width - inset * 2.0, height: itemHeight * 2.0))
transition.updateFrame(node: self.videoAudioButton, frame: CGRect(x: inset, y: 56.0, width: contentFrame.width - inset * 2.0, height: itemHeight))
transition.updateFrame(node: self.videoAudioCheckNode, frame: CGRect(x: contentFrame.width - inset - 16.0 - 20.0, y: 56.0 + floorToScreenPixels((itemHeight - 16.0) / 2.0), width: 16.0, height: 16.0))
self.videoAudioCheckNode.isHidden = self.mediaMode != .videoAndAudio
let videoAudioSize = self.videoAudioTitleNode.updateLayout(CGSize(width: contentFrame.width - inset * 2.0, height: itemHeight))
transition.updateFrame(node: self.videoAudioTitleNode, frame: CGRect(x: inset + 16.0, y: 56.0 + floorToScreenPixels((itemHeight - videoAudioSize.height) / 2.0), width: videoAudioSize.width, height: videoAudioSize.height))
transition.updateFrame(node: self.audioButton, frame: CGRect(x: inset, y: 56.0 + itemHeight, width: contentFrame.width - inset * 2.0, height: itemHeight))
transition.updateFrame(node: self.audioCheckNode, frame: CGRect(x: contentFrame.width - inset - 16.0 - 20.0, y: 56.0 + itemHeight + floorToScreenPixels((itemHeight - 16.0) / 2.0), width: 16.0, height: 16.0))
self.audioCheckNode.isHidden = self.mediaMode != .audioOnly
let audioSize = self.audioTitleNode.updateLayout(CGSize(width: contentFrame.width - inset * 2.0, height: itemHeight))
transition.updateFrame(node: self.audioTitleNode, frame: CGRect(x: inset + 16.0, y: 56.0 + itemHeight + floorToScreenPixels((itemHeight - audioSize.height) / 2.0), width: audioSize.width, height: audioSize.height))
transition.updateFrame(node: self.modeSeparatorNode, frame: CGRect(x: inset + 16.0, y: 56.0 + itemHeight, width: contentFrame.width - inset * 2.0 - 16.0, height: UIScreenPixel))
var buttonsAlpha: CGFloat = 1.0
if case .audioOnly = self.mediaMode {
buttonsAlpha = 0.3
}
transition.updateAlpha(node: self.portraitButton, alpha: buttonsAlpha)
transition.updateAlpha(node: self.portraitIconNode, alpha: buttonsAlpha)
transition.updateAlpha(node: self.portraitTitleNode, alpha: buttonsAlpha)
transition.updateAlpha(node: self.landscapeButton, alpha: buttonsAlpha)
transition.updateAlpha(node: self.landscapeIconNode, alpha: buttonsAlpha)
transition.updateAlpha(node: self.landscapeTitleNode, alpha: buttonsAlpha)
transition.updateAlpha(node: self.selectionNode, alpha: buttonsAlpha)
self.portraitTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordPortrait, font: Font.semibold(15.0), textColor: self.videoMode == .portrait ? UIColor(rgb: 0xb56df4) : UIColor(rgb: 0x8e8e93), paragraphAlignment: .left)
self.landscapeTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.VoiceChat_RecordLandscape, font: Font.semibold(15.0), textColor: self.videoMode == .landscape ? UIColor(rgb: 0xb56df4) : UIColor(rgb: 0x8e8e93), paragraphAlignment: .left)
let buttonWidth = floorToScreenPixels((contentFrame.width - inset * 2.0 - 11.0) / 2.0)
let portraitButtonFrame = CGRect(x: inset, y: 56.0 + itemHeight * 2.0 + 25.0, width: buttonWidth, height: 140.0)
transition.updateFrame(node: self.portraitButton, frame: portraitButtonFrame)
transition.updateFrame(node: self.portraitIconNode, frame: CGRect(x: portraitButtonFrame.minX + floorToScreenPixels((portraitButtonFrame.width - 72.0) / 2.0), y: portraitButtonFrame.minY + floorToScreenPixels((portraitButtonFrame.height - 122.0) / 2.0), width: 76.0, height: 122.0))
self.portraitIconNode.updateLayout(landscape: false)
let portraitSize = self.portraitTitleNode.updateLayout(CGSize(width: buttonWidth, height: 30.0))
transition.updateFrame(node: self.portraitTitleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(portraitButtonFrame.center.x - portraitSize.width / 2.0), y: portraitButtonFrame.maxY + 7.0), size: portraitSize))
let landscapeButtonFrame = CGRect(x: portraitButtonFrame.maxX + 11.0, y: portraitButtonFrame.minY, width: portraitButtonFrame.width, height: portraitButtonFrame.height)
transition.updateFrame(node: self.landscapeButton, frame: landscapeButtonFrame)
transition.updateFrame(node: self.landscapeIconNode, frame: CGRect(x: landscapeButtonFrame.minX + floorToScreenPixels((landscapeButtonFrame.width - 122.0) / 2.0), y: landscapeButtonFrame.minY + floorToScreenPixels((landscapeButtonFrame.height - 76.0) / 2.0), width: 122.0, height: 76.0))
self.landscapeIconNode.updateLayout(landscape: true)
let landscapeSize = self.landscapeTitleNode.updateLayout(CGSize(width: buttonWidth, height: 30.0))
transition.updateFrame(node: self.landscapeTitleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(landscapeButtonFrame.center.x - landscapeSize.width / 2.0), y: landscapeButtonFrame.maxY + 7.0), size: landscapeSize))
let centralButtonSide = min(contentFrame.width, layout.size.height) - 32.0
let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide)
let buttonInset: CGFloat = 16.0
let doneButtonPreFrame = CGRect(x: buttonInset, y: contentHeight - 50.0 - insets.bottom - 16.0 - buttonOffset, width: contentFrame.width - buttonInset * 2.0, height: 50.0)
let doneButtonFrame = CGRect(origin: CGPoint(x: floor(doneButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(doneButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize)
transition.updateFrame(node: self.doneButton, frame: doneButtonFrame)
if self.videoMode == .portrait {
self.selectionNode.frame = portraitButtonFrame.insetBy(dx: -1.0, dy: -1.0)
} else {
self.selectionNode.frame = landscapeButtonFrame.insetBy(dx: -1.0, dy: -1.0)
}
self.doneButton.update(size: centralButtonSize, buttonSize: CGSize(width: 112.0, height: 112.0), state: .button(text: self.presentationData.strings.VoiceChat_RecordStartRecording), title: "", subtitle: "", dark: false, small: false)
let cancelButtonHeight = self.cancelButton.updateLayout(width: contentFrame.width - buttonInset * 2.0, transition: transition)
transition.updateFrame(node: self.cancelButton, frame: CGRect(x: buttonInset, y: contentHeight - cancelButtonHeight - insets.bottom - 16.0, width: contentFrame.width, height: cancelButtonHeight))
transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame)
}
}
private class PreviewIconNode: ASDisplayNode {
private let avatar1Node: ASImageNode
private let avatar2Node: ASImageNode
private let avatar3Node: ASImageNode
private let avatar4Node: ASImageNode
override init() {
self.avatar1Node = ASImageNode()
self.avatar1Node.cornerRadius = 4.0
self.avatar1Node.clipsToBounds = true
self.avatar1Node.displaysAsynchronously = false
self.avatar1Node.backgroundColor = UIColor(rgb: 0x834fff)
self.avatar1Node.image = UIImage(bundleImageName: "Call/Avatar1")
self.avatar1Node.contentMode = .bottom
self.avatar2Node = ASImageNode()
self.avatar2Node.cornerRadius = 4.0
self.avatar2Node.clipsToBounds = true
self.avatar2Node.displaysAsynchronously = false
self.avatar2Node.backgroundColor = UIColor(rgb: 0x63d5c9)
self.avatar2Node.image = UIImage(bundleImageName: "Call/Avatar2")
self.avatar2Node.contentMode = .scaleAspectFit
self.avatar3Node = ASImageNode()
self.avatar3Node.cornerRadius = 4.0
self.avatar3Node.clipsToBounds = true
self.avatar3Node.displaysAsynchronously = false
self.avatar3Node.backgroundColor = UIColor(rgb: 0xccff60)
self.avatar3Node.image = UIImage(bundleImageName: "Call/Avatar3")
self.avatar3Node.contentMode = .scaleAspectFit
self.avatar4Node = ASImageNode()
self.avatar4Node.cornerRadius = 4.0
self.avatar4Node.clipsToBounds = true
self.avatar4Node.displaysAsynchronously = false
self.avatar4Node.backgroundColor = UIColor(rgb: 0xf5512a)
self.avatar4Node.image = UIImage(bundleImageName: "Call/Avatar4")
self.avatar4Node.contentMode = .scaleAspectFit
super.init()
self.isUserInteractionEnabled = false
self.addSubnode(self.avatar1Node)
self.addSubnode(self.avatar2Node)
self.addSubnode(self.avatar3Node)
self.addSubnode(self.avatar4Node)
}
func updateLayout(landscape: Bool) {
if landscape {
self.avatar1Node.frame = CGRect(x: 0.0, y: 0.0, width: 96.0, height: 76.0)
self.avatar2Node.frame = CGRect(x: 98.0, y: 0.0, width: 24.0, height: 24.0)
self.avatar3Node.frame = CGRect(x: 98.0, y: 26.0, width: 24.0, height: 24.0)
self.avatar4Node.frame = CGRect(x: 98.0, y: 52.0, width: 24.0, height: 24.0)
} else {
self.avatar1Node.frame = CGRect(x: 0.0, y: 0.0, width: 76.0, height: 96.0)
self.avatar2Node.frame = CGRect(x: 0.0, y: 98.0, width: 24.0, height: 24.0)
self.avatar3Node.frame = CGRect(x: 26.0, y: 98.0, width: 24.0, height: 24.0)
self.avatar4Node.frame = CGRect(x: 52.0, y: 98.0, width: 24.0, height: 24.0)
}
}
}
@@ -0,0 +1,182 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import AppBundle
import ContextUI
import TelegramStringFormatting
import ReplayKit
import AccountContext
final class VoiceChatShareScreenContextItem: ContextMenuCustomItem {
fileprivate let context: AccountContext
fileprivate let text: String
fileprivate let icon: (PresentationTheme) -> UIImage?
fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void
init(context: AccountContext, text: String, icon: @escaping (PresentationTheme) -> UIImage?, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void) {
self.context = context
self.text = text
self.icon = icon
self.action = action
}
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return VoiceChatShareScreenContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected)
}
}
private let textFont = Font.regular(17.0)
private final class VoiceChatShareScreenContextItemNode: ASDisplayNode, ContextMenuCustomNode {
private let item: VoiceChatShareScreenContextItem
private let presentationData: PresentationData
private let getController: () -> ContextControllerProtocol?
private let actionSelected: (ContextMenuActionResult) -> Void
private let backgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private let iconNode: ASImageNode
private var timer: SwiftSignalKit.Timer?
private var pointerInteraction: PointerInteraction?
private var broadcastPickerView: UIView?
private var applicationStateDisposable: Disposable?
init(presentationData: PresentationData, item: VoiceChatShareScreenContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.item = item
self.presentationData = presentationData
self.getController = getController
self.actionSelected = actionSelected
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isAccessibilityElement = false
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.textNode = ImmediateTextNode()
self.textNode.isAccessibilityElement = false
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.textNode.attributedText = NSAttributedString(string: item.text, font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
self.textNode.maximumNumberOfLines = 1
if #available(iOS 12.0, *) {
let broadcastPickerView = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 50, height: 52.0))
broadcastPickerView.alpha = 0.02
broadcastPickerView.preferredExtension = "\(item.context.sharedContext.applicationBindings.appBundleId).BroadcastUpload"
broadcastPickerView.showsMicrophoneButton = false
self.broadcastPickerView = broadcastPickerView
}
self.iconNode = ASImageNode()
self.iconNode.isAccessibilityElement = false
self.iconNode.displaysAsynchronously = false
self.iconNode.displayWithoutProcessing = true
self.iconNode.isUserInteractionEnabled = false
self.iconNode.image = item.icon(presentationData.theme)
super.init()
self.addSubnode(self.backgroundNode)
if let broadcastPickerView = self.broadcastPickerView {
self.view.addSubview(broadcastPickerView)
}
self.addSubnode(self.textNode)
self.addSubnode(self.iconNode)
}
deinit {
self.timer?.invalidate()
self.applicationStateDisposable?.dispose()
}
override func didLoad() {
super.didLoad()
self.applicationStateDisposable = (self.item.context.sharedContext.applicationBindings.applicationIsActive
|> filter { !$0 }
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.getController()?.dismiss(completion: nil)
})
}
private var validLayout: CGSize?
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let sideInset: CGFloat = 18.0
let iconSideInset: CGFloat = 20.0
let verticalInset: CGFloat = 11.0
let iconSize = self.iconNode.image.flatMap({ $0.size }) ?? CGSize()
let standardIconWidth: CGFloat = 32.0
var rightTextInset: CGFloat = sideInset
if !iconSize.width.isZero {
rightTextInset = max(iconSize.width, standardIconWidth) + iconSideInset + sideInset
}
let textSize = self.textNode.updateLayout(CGSize(width: constrainedWidth - sideInset - rightTextInset, height: .greatestFiniteMagnitude))
let verticalSpacing: CGFloat = 2.0
let combinedTextHeight = textSize.height + verticalSpacing
return (CGSize(width: textSize.width + sideInset + rightTextInset, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in
self.validLayout = size
let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: iconSideInset + 40.0, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
if let broadcastPickerView = self.broadcastPickerView {
broadcastPickerView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))
}
})
}
func updateTheme(presentationData: PresentationData) {
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
}
@objc private func buttonPressed() {
self.performAction()
}
func canBeHighlighted() -> Bool {
return false
}
func updateIsHighlighted(isHighlighted: Bool) {
self.setIsHighlighted(isHighlighted)
}
func performAction() {
guard let controller = self.getController() else {
return
}
self.item.action(controller, { [weak self] result in
self?.actionSelected(result)
})
}
func setIsHighlighted(_ value: Bool) {
}
}
@@ -0,0 +1,382 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private final class VoiceChatSpeakerNodeDrawingState: NSObject {
let color: UIColor
let transition: CGFloat
let reverse: Bool
init(color: UIColor, transition: CGFloat, reverse: Bool) {
self.color = color
self.transition = transition
self.reverse = reverse
super.init()
}
}
private func generateWaveImage(color: UIColor, num: Int) -> UIImage? {
return generateImage(CGSize(width: 36.0, height: 36.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(color.cgColor)
context.setLineWidth(1.0 + UIScreenPixel)
context.setLineCap(.round)
context.translateBy(x: 6.0, y: 6.0)
switch num {
case 1:
let _ = try? drawSvgPath(context, path: "M15,9 C15.6666667,9.95023099 16,10.9487504 16,11.9955581 C16,13.0423659 15.6666667,14.0438465 15,15 S ")
case 2:
let _ = try? drawSvgPath(context, path: "M17.5,6.5 C18.8724771,8.24209014 19.5587156,10.072709 19.5587156,11.9918565 C19.5587156,13.9110041 18.8724771,15.7470519 17.5,17.5 S ")
case 3:
let _ = try? drawSvgPath(context, path: "M20,3.5 C22,6.19232113 23,9.02145934 23,11.9874146 C23,14.9533699 22,17.7908984 20,20.5 S ")
default:
break
}
})
}
final class VoiceChatSpeakerNode: ASDisplayNode {
class State: Equatable {
enum Value: Equatable {
case muted
case low
case medium
case high
}
let value: Value
let color: UIColor
init(value: Value, color: UIColor) {
self.value = value
self.color = color
}
static func ==(lhs: State, rhs: State) -> Bool {
if lhs.value != rhs.value {
return false
}
if lhs.color.argb != rhs.color.argb {
return false
}
return true
}
}
private var hasState = false
private var state: State = State(value: .medium, color: .black)
private let iconNode: IconNode
private let waveNode1: ASImageNode
private let waveNode2: ASImageNode
private let waveNode3: ASImageNode
override init() {
self.iconNode = IconNode()
self.waveNode1 = ASImageNode()
self.waveNode1.displaysAsynchronously = false
self.waveNode1.displayWithoutProcessing = true
self.waveNode2 = ASImageNode()
self.waveNode2.displaysAsynchronously = false
self.waveNode2.displayWithoutProcessing = true
self.waveNode3 = ASImageNode()
self.waveNode3.displaysAsynchronously = false
self.waveNode3.displayWithoutProcessing = true
super.init()
self.addSubnode(self.iconNode)
self.addSubnode(self.waveNode1)
self.addSubnode(self.waveNode2)
self.addSubnode(self.waveNode3)
}
private var animating = false
func update(state: State, animated: Bool, force: Bool = false) {
var animated = animated
if !self.hasState {
self.hasState = true
animated = false
}
if self.state != state || force {
let previousState = self.state
self.state = state
if animated && self.animating {
return
}
if previousState.color != state.color {
self.waveNode1.image = generateWaveImage(color: state.color, num: 1)
self.waveNode2.image = generateWaveImage(color: state.color, num: 2)
self.waveNode3.image = generateWaveImage(color: state.color, num: 3)
}
self.update(transition: animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate, completion: {
if self.state != state {
self.update(state: self.state, animated: animated, force: true)
}
})
}
}
private func update(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void = {}) {
self.animating = transition.isAnimated
self.iconNode.update(state: IconNode.State(muted: self.state.value == .muted, color: self.state.color), animated: transition.isAnimated)
let bounds = self.bounds
let center = CGPoint(x: bounds.width / 2.0, y: bounds.height / 2.0)
self.iconNode.bounds = CGRect(origin: CGPoint(), size: bounds.size)
self.waveNode1.bounds = CGRect(origin: CGPoint(), size: bounds.size)
self.waveNode2.bounds = CGRect(origin: CGPoint(), size: bounds.size)
self.waveNode3.bounds = CGRect(origin: CGPoint(), size: bounds.size)
let iconPosition: CGPoint
let wave1Position: CGPoint
var wave1Alpha: CGFloat = 1.0
let wave2Position: CGPoint
var wave2Alpha: CGFloat = 1.0
let wave3Position: CGPoint
var wave3Alpha: CGFloat = 1.0
switch self.state.value {
case .muted:
iconPosition = CGPoint(x: center.x, y: center.y)
wave1Position = CGPoint(x: center.x + 4.0, y: center.y)
wave2Position = CGPoint(x: center.x + 4.0, y: center.y)
wave3Position = CGPoint(x: center.x + 4.0, y: center.y)
wave1Alpha = 0.0
wave2Alpha = 0.0
wave3Alpha = 0.0
case .low:
iconPosition = CGPoint(x: center.x - 1.0, y: center.y)
wave1Position = CGPoint(x: center.x + 3.0, y: center.y)
wave2Position = CGPoint(x: center.x + 3.0, y: center.y)
wave3Position = CGPoint(x: center.x + 3.0, y: center.y)
wave2Alpha = 0.0
wave3Alpha = 0.0
case .medium:
iconPosition = CGPoint(x: center.x - 3.0, y: center.y)
wave1Position = CGPoint(x: center.x + 1.0, y: center.y)
wave2Position = CGPoint(x: center.x + 1.0, y: center.y)
wave3Position = CGPoint(x: center.x + 1.0, y: center.y)
wave3Alpha = 0.0
case .high:
iconPosition = CGPoint(x: center.x - 4.0, y: center.y)
wave1Position = CGPoint(x: center.x, y: center.y)
wave2Position = CGPoint(x: center.x, y: center.y)
wave3Position = CGPoint(x: center.x, y: center.y)
}
transition.updatePosition(node: self.iconNode, position: iconPosition) { _ in
self.animating = false
completion()
}
transition.updatePosition(node: self.waveNode1, position: wave1Position)
transition.updatePosition(node: self.waveNode2, position: wave2Position)
transition.updatePosition(node: self.waveNode3, position: wave3Position)
transition.updateAlpha(node: self.waveNode1, alpha: wave1Alpha)
transition.updateAlpha(node: self.waveNode2, alpha: wave2Alpha)
transition.updateAlpha(node: self.waveNode3, alpha: wave3Alpha)
}
}
private class IconNode: ASDisplayNode {
class State: Equatable {
let muted: Bool
let color: UIColor
init(muted: Bool, color: UIColor) {
self.muted = muted
self.color = color
}
static func ==(lhs: State, rhs: State) -> Bool {
if lhs.muted != rhs.muted {
return false
}
if lhs.color.argb != rhs.color.argb {
return false
}
return true
}
}
private class TransitionContext {
let startTime: Double
let duration: Double
let previousState: State
init(startTime: Double, duration: Double, previousState: State) {
self.startTime = startTime
self.duration = duration
self.previousState = previousState
}
}
private var animator: ConstantDisplayLinkAnimator?
private var hasState = false
private var state: State = State(muted: false, color: .black)
private var transitionContext: TransitionContext?
override init() {
super.init()
self.isOpaque = false
}
func update(state: State, animated: Bool) {
var animated = animated
if !self.hasState {
self.hasState = true
animated = false
}
if self.state != state {
let previousState = self.state
self.state = state
if animated {
self.transitionContext = TransitionContext(startTime: CACurrentMediaTime(), duration: 0.18, previousState: previousState)
}
self.updateAnimations()
self.setNeedsDisplay()
}
}
private func updateAnimations() {
var animate = false
let timestamp = CACurrentMediaTime()
if let transitionContext = self.transitionContext {
if transitionContext.startTime + transitionContext.duration < timestamp {
self.transitionContext = nil
} else {
animate = true
}
}
if animate {
let animator: ConstantDisplayLinkAnimator
if let current = self.animator {
animator = current
} else {
animator = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.updateAnimations()
})
self.animator = animator
}
animator.isPaused = false
} else {
self.animator?.isPaused = true
}
self.setNeedsDisplay()
}
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
var transitionFraction: CGFloat = self.state.muted ? 1.0 : 0.0
var color = self.state.color
var reverse = false
if let transitionContext = self.transitionContext {
let timestamp = CACurrentMediaTime()
var t = CGFloat((timestamp - transitionContext.startTime) / transitionContext.duration)
t = min(1.0, max(0.0, t))
if transitionContext.previousState.muted != self.state.muted {
transitionFraction = self.state.muted ? t : 1.0 - t
reverse = transitionContext.previousState.muted
}
if transitionContext.previousState.color.rgb != color.rgb {
color = transitionContext.previousState.color.interpolateTo(color, fraction: t)!
}
}
return VoiceChatSpeakerNodeDrawingState(color: color, transition: transitionFraction, reverse: reverse)
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? VoiceChatSpeakerNodeDrawingState else {
return
}
let clearLineWidth: CGFloat = 4.0
let lineWidth: CGFloat = 1.0 + UIScreenPixel
context.setFillColor(parameters.color.cgColor)
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(lineWidth)
context.translateBy(x: 7.0, y: 6.0)
let _ = try? drawSvgPath(context, path: "M7,9 L10,9 L13.6080479,5.03114726 C13.9052535,4.70422117 14.4112121,4.6801279 14.7381382,4.97733344 C14.9049178,5.12895118 15,5.34388952 15,5.5692855 L15,18.4307145 C15,18.8725423 14.6418278,19.2307145 14.2,19.2307145 C13.974604,19.2307145 13.7596657,19.1356323 13.6080479,18.9688527 L10,15 L7,15 C6.44771525,15 6,14.5522847 6,14 L6,10 C6,9.44771525 6.44771525,9 7,9 S ")
context.translateBy(x: -7.0, y: -6.0)
if parameters.transition > 0.0 {
let startPoint: CGPoint
let endPoint: CGPoint
let origin: CGPoint
let length: CGFloat
if bounds.width > 30.0 {
origin = CGPoint(x: 9.0, y: 10.0 - UIScreenPixel)
length = 17.0
} else {
origin = CGPoint(x: 5.0 + UIScreenPixel, y: 4.0 + UIScreenPixel)
length = 15.0
}
if parameters.reverse {
startPoint = CGPoint(x: origin.x + length * (1.0 - parameters.transition), y: origin.y + length * (1.0 - parameters.transition))
endPoint = CGPoint(x: origin.x + length, y: origin.y + length)
} else {
startPoint = origin
endPoint = CGPoint(x: origin.x + length * parameters.transition, y: origin.y + length * parameters.transition)
}
context.setBlendMode(.clear)
context.setLineWidth(clearLineWidth)
context.move(to: startPoint)
context.addLine(to: endPoint)
context.strokePath()
context.setBlendMode(.normal)
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: startPoint)
context.addLine(to: endPoint)
context.strokePath()
}
}
}
@@ -0,0 +1,366 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import AccountContext
private let tileSpacing: CGFloat = 4.0
let tileHeight: CGFloat = 180.0
enum VoiceChatTileLayoutMode {
case pairs
case rows
case grid
}
final class VoiceChatTileGridNode: ASDisplayNode {
private let context: AccountContext
private var items: [VoiceChatTileItem] = []
fileprivate var itemNodes: [String: VoiceChatTileItemNode] = [:]
private var isFirstTime = true
private var absoluteLocation: (CGRect, CGSize)?
var tileNodes: [VoiceChatTileItemNode] {
return Array(self.itemNodes.values)
}
init(context: AccountContext) {
self.context = context
super.init()
self.clipsToBounds = true
}
var visibility = true {
didSet {
for (_, tileNode) in self.itemNodes {
tileNode.visibility = self.visibility
}
}
}
func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
self.absoluteLocation = (rect, containerSize)
for itemNode in self.itemNodes.values {
var localRect = rect
localRect.origin = localRect.origin.offsetBy(dx: itemNode.frame.minX, dy: itemNode.frame.minY)
localRect.size = itemNode.frame.size
itemNode.updateAbsoluteRect(localRect, within: containerSize)
}
}
func update(size: CGSize, layoutMode: VoiceChatTileLayoutMode, items: [VoiceChatTileItem], transition: ContainedViewLayoutTransition, completion: @escaping () -> Void = {}) -> CGSize {
let wasEmpty = self.items.isEmpty
self.items = items
var validIds: [String] = []
let colsCount: CGFloat
if case .grid = layoutMode {
if items.count < 3 {
colsCount = 1
} else if items.count < 5 {
colsCount = 2
} else {
colsCount = 3
}
} else {
colsCount = 2
}
let rowsCount = ceil(CGFloat(items.count) / colsCount)
let genericItemWidth = floorToScreenPixels((size.width - tileSpacing * (colsCount - 1)) / colsCount)
let lastRowItemsAreWide: Bool
let lastRowItemWidth: CGFloat
if case .grid = layoutMode {
lastRowItemsAreWide = [1, 2].contains(items.count) || items.count % Int(colsCount) != 0
var lastRowItemsCount = CGFloat(items.count % Int(colsCount))
if lastRowItemsCount.isZero {
lastRowItemsCount = colsCount
}
lastRowItemWidth = floorToScreenPixels((size.width - tileSpacing * (lastRowItemsCount - 1)) / lastRowItemsCount)
} else {
lastRowItemsAreWide = items.count == 1 || items.count % Int(colsCount) != 0
lastRowItemWidth = size.width
}
let isFirstTime = self.isFirstTime
if isFirstTime {
self.isFirstTime = false
}
var availableWidth = min(size.width, size.height)
var itemHeight = tileHeight
if case .grid = layoutMode {
itemHeight = size.height / rowsCount - (tileSpacing * (rowsCount - 1))
}
for i in 0 ..< self.items.count {
let item = self.items[i]
let col = CGFloat(i % Int(colsCount))
let row = floor(CGFloat(i) / colsCount)
let isLastRow = row == (rowsCount - 1)
let rowItemWidth = isLastRow && lastRowItemsAreWide ? lastRowItemWidth : genericItemWidth
let itemSize = CGSize(
width: rowItemWidth,
height: itemHeight
)
if case .grid = layoutMode {
availableWidth = rowItemWidth
}
let itemFrame = CGRect(origin: CGPoint(x: col * (rowItemWidth + tileSpacing), y: row * (itemHeight + tileSpacing)), size: itemSize)
validIds.append(item.id)
var itemNode: VoiceChatTileItemNode?
var wasAdded = false
if let current = self.itemNodes[item.id] {
itemNode = current
current.update(size: itemSize, availableWidth: availableWidth, item: item, transition: transition)
} else {
wasAdded = true
let addedItemNode = VoiceChatTileItemNode(context: self.context)
itemNode = addedItemNode
addedItemNode.update(size: itemSize, availableWidth: availableWidth, item: item, transition: .immediate)
self.itemNodes[self.items[i].id] = addedItemNode
self.addSubnode(addedItemNode)
}
if let itemNode = itemNode {
itemNode.visibility = self.visibility
let itemTransition: ContainedViewLayoutTransition = wasAdded ? .immediate : transition
itemTransition.updateFrameAsPositionAndBounds(node: itemNode, frame: itemFrame)
if wasAdded && !isFirstTime {
itemNode.layer.animateScale(from: 0.0, to: 1.0, duration: wasEmpty ? 0.4 : 0.3)
itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if let (rect, containerSize) = self.absoluteLocation {
var localRect = rect
localRect.origin = localRect.origin.offsetBy(dx: itemFrame.minX, dy: itemFrame.minY)
localRect.size = itemFrame.size
itemNode.updateAbsoluteRect(localRect, within: containerSize)
}
}
}
var removeIds: [String] = []
for (id, _) in self.itemNodes {
if !validIds.contains(id) {
removeIds.append(id)
}
}
for id in removeIds {
if let itemNode = self.itemNodes.removeValue(forKey: id) {
itemNode.layer.animateScale(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, additive: true)
itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemNode] _ in
itemNode?.removeFromSupernode()
})
}
}
if case let .animated(duration, _) = transition {
Queue.mainQueue().after(duration) {
completion()
}
} else {
completion()
}
let rowCount = ceil(CGFloat(self.items.count) / 2.0)
return CGSize(width: size.width, height: rowCount * (itemHeight + tileSpacing))
}
}
final class VoiceChatTilesGridItem: ListViewItem {
let context: AccountContext
let tiles: [VoiceChatTileItem]
let layoutMode: VoiceChatTileLayoutMode
let videoLimit: Int32
let reachedLimit: Bool
let getIsExpanded: () -> Bool
init(context: AccountContext, tiles: [VoiceChatTileItem], layoutMode: VoiceChatTileLayoutMode, videoLimit: Int32, reachedLimit: Bool, getIsExpanded: @escaping () -> Bool) {
self.context = context
self.tiles = tiles
self.layoutMode = layoutMode
self.videoLimit = videoLimit
self.reachedLimit = reachedLimit
self.getIsExpanded = getIsExpanded
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = VoiceChatTilesGridItemNode()
let (layout, apply) = node.asyncLayout()(self, params)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? VoiceChatTilesGridItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params)
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
}
final class VoiceChatTilesGridItemNode: ListViewItemNode {
private var item: VoiceChatTilesGridItem?
private var tileGridNode: VoiceChatTileGridNode?
let backgroundNode: ASDisplayNode
let cornersNode: ASImageNode
let limitLabel: TextNode
private var absoluteLocation: (CGRect, CGSize)?
var tileNodes: [VoiceChatTileItemNode] {
if let values = self.tileGridNode?.itemNodes.values {
return Array(values)
} else {
return []
}
}
init() {
self.backgroundNode = ASDisplayNode()
self.cornersNode = ASImageNode()
self.cornersNode.displaysAsynchronously = false
self.cornersNode.image = decorationCornersImage(top: true, bottom: false, dark: false)
self.limitLabel = TextNode()
self.limitLabel.alpha = 0.0
super.init(layerBacked: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.cornersNode)
self.addSubnode(self.limitLabel)
}
override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) {
super.animateFrameTransition(progress, currentValue)
if let tileGridNode = self.tileGridNode {
var gridFrame = tileGridNode.frame
gridFrame.size.height = currentValue
tileGridNode.frame = gridFrame
}
var backgroundFrame = self.backgroundNode.frame
backgroundFrame.size.height = currentValue
self.backgroundNode.frame = backgroundFrame
var cornersFrame = self.cornersNode.frame
cornersFrame.origin.y = currentValue
self.cornersNode.frame = cornersFrame
}
func asyncLayout() -> (_ item: VoiceChatTilesGridItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> Void) {
let currentItem = self.item
let makeLabelLayout = TextNode.asyncLayout(self.limitLabel)
return { item, params in
let presentationData = item.context.sharedContext.currentPresentationData.with { $0 }
let (textLayout, textApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: presentationData.strings.VoiceChat_VideoParticipantsLimitExceededExtended(String(item.videoLimit)).string, font: Font.regular(13.0), textColor: UIColor(rgb: 0x8e8e93), paragraphAlignment: .center), maximumNumberOfLines: 3, truncationType: .end, constrainedSize: CGSize(width: params.width - 32.0, height: CGFloat.greatestFiniteMagnitude), lineSpacing: 0.25))
let rowCount = ceil(CGFloat(item.tiles.count) / 2.0)
let gridSize = CGSize(width: params.width, height: rowCount * (tileHeight + tileSpacing))
var contentSize = gridSize
if item.reachedLimit {
contentSize.height += 10.0 + textLayout.size.height + 10.0
}
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: UIEdgeInsets())
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
let tileGridNode: VoiceChatTileGridNode
if let current = strongSelf.tileGridNode {
tileGridNode = current
} else {
strongSelf.backgroundNode.backgroundColor = item.getIsExpanded() ? fullscreenBackgroundColor : panelBackgroundColor
strongSelf.cornersNode.image = decorationCornersImage(top: true, bottom: false, dark: item.getIsExpanded())
tileGridNode = VoiceChatTileGridNode(context: item.context)
tileGridNode.visibility = strongSelf.gridVisibility
strongSelf.addSubnode(tileGridNode)
strongSelf.tileGridNode = tileGridNode
}
if let (rect, size) = strongSelf.absoluteLocation {
tileGridNode.updateAbsoluteRect(rect, within: size)
}
let transition: ContainedViewLayoutTransition = currentItem == nil ? .immediate : .animated(duration: 0.3, curve: .easeInOut)
let tileGridSize = tileGridNode.update(size: CGSize(width: params.width - params.leftInset - params.rightInset, height: params.availableHeight), layoutMode: item.layoutMode, items: item.tiles, transition: transition)
var backgroundSize = tileGridSize
if item.reachedLimit {
backgroundSize.height += 10.0 + textLayout.size.height + 10.0
}
if currentItem == nil {
tileGridNode.frame = CGRect(x: params.leftInset, y: 0.0, width: tileGridSize.width, height: tileGridSize.height)
strongSelf.backgroundNode.frame = CGRect(origin: tileGridNode.frame.origin, size: backgroundSize)
strongSelf.cornersNode.frame = CGRect(x: params.leftInset, y: layout.size.height, width: tileGridSize.width, height: 50.0)
} else {
transition.updateFrame(node: tileGridNode, frame: CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: tileGridSize))
transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: tileGridNode.frame.origin, size: backgroundSize))
strongSelf.cornersNode.frame = CGRect(x: params.leftInset, y: layout.size.height, width: tileGridSize.width, height: 50.0)
}
let _ = textApply()
if !transition.isAnimated && currentItem?.reachedLimit != item.reachedLimit {
strongSelf.backgroundNode.layer.removeAllAnimations()
strongSelf.limitLabel.layer.removeAllAnimations()
}
transition.updateFrame(node: strongSelf.limitLabel, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((params.width - textLayout.size.width) / 2.0), y: gridSize.height + 10.0), size: textLayout.size))
transition.updateAlpha(node: strongSelf.limitLabel, alpha: item.reachedLimit ? 1.0 : 0.0)
}
})
}
}
override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
self.absoluteLocation = (rect, containerSize)
self.tileGridNode?.updateAbsoluteRect(rect, within: containerSize)
}
var gridVisibility: Bool = true {
didSet {
self.tileGridNode?.visibility = self.gridVisibility
}
}
func snapshotForDismissal() {
if let snapshotView = self.tileGridNode?.view.snapshotView(afterScreenUpdates: false) {
self.tileGridNode?.view.addSubview(snapshotView)
}
}
}
@@ -0,0 +1,995 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import AccountContext
import TelegramUIPreferences
import TelegramPresentationData
import AvatarNode
private let backgroundCornerRadius: CGFloat = 11.0
private let borderLineWidth: CGFloat = 2.0
private let destructiveColor: UIColor = UIColor(rgb: 0xff3b30)
final class VoiceChatTileItem: Equatable {
enum Icon: Equatable {
case none
case microphone(Bool)
case presentation
}
let account: Account
let peer: EnginePeer
let videoEndpointId: String
let videoReady: Bool
let videoTimeouted: Bool
let isVideoLimit: Bool
let videoLimit: Int32
let isPaused: Bool
let isOwnScreencast: Bool
let strings: PresentationStrings
let nameDisplayOrder: PresentationPersonNameOrder
let icon: Icon
let text: VoiceChatParticipantItem.ParticipantText
let additionalText: VoiceChatParticipantItem.ParticipantText?
let speaking: Bool
let secondary: Bool
let isTablet: Bool
let action: () -> Void
let contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
let getVideo: (GroupVideoNode.Position) -> GroupVideoNode?
let getAudioLevel: (() -> Signal<Float, NoError>)?
var id: String {
return self.videoEndpointId
}
init(account: Account, peer: EnginePeer, videoEndpointId: String, videoReady: Bool, videoTimeouted: Bool, isVideoLimit: Bool, videoLimit: Int32, isPaused: Bool, isOwnScreencast: Bool, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, speaking: Bool, secondary: Bool, isTablet: Bool, icon: Icon, text: VoiceChatParticipantItem.ParticipantText, additionalText: VoiceChatParticipantItem.ParticipantText?, action: @escaping () -> Void, contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?, getVideo: @escaping (GroupVideoNode.Position) -> GroupVideoNode?, getAudioLevel: (() -> Signal<Float, NoError>)?) {
self.account = account
self.peer = peer
self.videoEndpointId = videoEndpointId
self.videoReady = videoReady
self.videoTimeouted = videoTimeouted
self.isVideoLimit = isVideoLimit
self.videoLimit = videoLimit
self.isPaused = isPaused
self.isOwnScreencast = isOwnScreencast
self.strings = strings
self.nameDisplayOrder = nameDisplayOrder
self.icon = icon
self.text = text
self.additionalText = additionalText
self.speaking = speaking
self.secondary = secondary
self.isTablet = isTablet
self.action = action
self.contextAction = contextAction
self.getVideo = getVideo
self.getAudioLevel = getAudioLevel
}
static func == (lhs: VoiceChatTileItem, rhs: VoiceChatTileItem) -> Bool {
if lhs.peer != rhs.peer {
return false
}
if lhs.videoEndpointId != rhs.videoEndpointId {
return false
}
if lhs.videoReady != rhs.videoReady {
return false
}
if lhs.videoTimeouted != rhs.videoTimeouted {
return false
}
if lhs.isPaused != rhs.isPaused {
return false
}
if lhs.isOwnScreencast != rhs.isOwnScreencast {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.additionalText != rhs.additionalText {
return false
}
if lhs.speaking != rhs.speaking {
return false
}
if lhs.secondary != rhs.secondary {
return false
}
if lhs.icon != rhs.icon {
return false
}
return true
}
}
private let fadeColor = UIColor(rgb: 0x000000, alpha: 0.5)
var tileFadeImage: UIImage? = {
return generateImage(CGSize(width: fadeHeight, height: fadeHeight), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let colorsArray = [fadeColor.withAlphaComponent(0.0).cgColor, fadeColor.cgColor] as CFArray
var locations: [CGFloat] = [1.0, 0.0]
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions())
})
}()
final class VoiceChatTileItemNode: ASDisplayNode {
private let context: AccountContext
let contextSourceNode: ContextExtractedContentContainingNode
private let containerNode: ContextControllerSourceNode
let contentNode: ASDisplayNode
let backgroundNode: ASDisplayNode
var videoContainerNode: ASDisplayNode
var videoNode: GroupVideoNode?
let infoNode: ASDisplayNode
let fadeNode: ASDisplayNode
private var shimmerNode: VoiceChatTileShimmeringNode?
private let titleNode: ImmediateTextNode
private var iconNode: ASImageNode?
private var animationNode: VoiceChatMicrophoneNode?
var highlightNode: VoiceChatTileHighlightNode
private let statusNode: VoiceChatParticipantStatusNode
let placeholderTextNode: ImmediateTextNode
let placeholderIconNode: ASImageNode
private var profileNode: VoiceChatPeerProfileNode?
private var extractedRect: CGRect?
private var nonExtractedRect: CGRect?
private var validLayout: (CGSize, CGFloat)?
var item: VoiceChatTileItem?
private var isExtracted = false
private let audioLevelDisposable = MetaDisposable()
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = false
init(context: AccountContext) {
self.context = context
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ContextControllerSourceNode()
self.contentNode = ASDisplayNode()
self.contentNode.clipsToBounds = true
self.contentNode.cornerRadius = backgroundCornerRadius
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = panelBackgroundColor
self.videoContainerNode = ASDisplayNode()
self.videoContainerNode.clipsToBounds = true
self.infoNode = ASDisplayNode()
self.fadeNode = ASDisplayNode()
self.fadeNode.displaysAsynchronously = false
if let image = tileFadeImage {
self.fadeNode.backgroundColor = UIColor(patternImage: image)
}
self.titleNode = ImmediateTextNode()
self.titleNode.displaysAsynchronously = false
self.statusNode = VoiceChatParticipantStatusNode()
self.highlightNode = VoiceChatTileHighlightNode()
self.highlightNode.alpha = 0.0
self.highlightNode.updateGlowAndGradientAnimations(type: .speaking)
self.placeholderTextNode = ImmediateTextNode()
self.placeholderTextNode.alpha = 0.0
self.placeholderTextNode.maximumNumberOfLines = 2
self.placeholderTextNode.textAlignment = .center
self.placeholderIconNode = ASImageNode()
self.placeholderIconNode.alpha = 0.0
self.placeholderIconNode.contentMode = .scaleAspectFit
self.placeholderIconNode.displaysAsynchronously = false
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
super.init()
self.addSubnode(self.hierarchyTrackingNode)
self.containerNode.addSubnode(self.contextSourceNode)
self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode
self.addSubnode(self.containerNode)
self.contextSourceNode.contentNode.addSubnode(self.contentNode)
self.contentNode.addSubnode(self.backgroundNode)
self.contentNode.addSubnode(self.videoContainerNode)
self.contentNode.addSubnode(self.fadeNode)
self.contentNode.addSubnode(self.infoNode)
self.infoNode.addSubnode(self.titleNode)
self.contentNode.addSubnode(self.placeholderTextNode)
self.contentNode.addSubnode(self.placeholderIconNode)
self.contentNode.addSubnode(self.highlightNode)
self.containerNode.shouldBegin = { [weak self] location in
guard let strongSelf = self, let item = strongSelf.item, item.videoReady && !item.isVideoLimit else {
return false
}
return true
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self, let item = strongSelf.item, let contextAction = item.contextAction, !item.isVideoLimit else {
gesture.cancel()
return
}
contextAction(strongSelf.contextSourceNode, gesture)
}
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in
guard let strongSelf = self, let _ = strongSelf.item else {
return
}
strongSelf.updateIsExtracted(isExtracted, transition: transition)
}
updateInHierarchy = { [weak self] value in
if let strongSelf = self {
strongSelf.isCurrentlyInHierarchy = value
strongSelf.highlightNode.isCurrentlyInHierarchy = value
}
}
}
deinit {
self.audioLevelDisposable.dispose()
}
override func didLoad() {
super.didLoad()
if #available(iOS 13.0, *) {
self.contentNode.layer.cornerCurve = .continuous
}
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap)))
}
@objc private func tap() {
if let item = self.item {
item.action()
}
}
private func updateIsExtracted(_ isExtracted: Bool, transition: ContainedViewLayoutTransition) {
guard self.isExtracted != isExtracted, let extractedRect = self.extractedRect, let nonExtractedRect = self.nonExtractedRect, let item = self.item else {
return
}
self.isExtracted = isExtracted
let springDuration: Double = 0.42
let springDamping: CGFloat = 124.0
if isExtracted {
let profileNode = VoiceChatPeerProfileNode(context: self.context, size: extractedRect.size, sourceSize: nonExtractedRect.size, peer: item.peer, text: item.text, customNode: self.videoContainerNode, additionalEntry: .single(nil), requestDismiss: { [weak self] in
self?.contextSourceNode.requestDismiss?()
})
profileNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size)
self.profileNode = profileNode
self.contextSourceNode.contentNode.addSubnode(profileNode)
profileNode.animateIn(from: self, targetRect: extractedRect, transition: transition)
var appearenceTransition = transition
if transition.isAnimated {
appearenceTransition = .animated(duration: springDuration, curve: .customSpring(damping: springDamping, initialVelocity: 0.0))
}
appearenceTransition.updateFrame(node: profileNode, frame: extractedRect)
self.contextSourceNode.contentNode.customHitTest = { [weak self] point in
if let strongSelf = self, let profileNode = strongSelf.profileNode {
if profileNode.avatarListWrapperNode.frame.contains(point) {
return profileNode.avatarListNode.view
}
}
return nil
}
self.backgroundNode.isHidden = true
self.fadeNode.isHidden = true
self.infoNode.isHidden = true
self.highlightNode.isHidden = true
} else if let profileNode = self.profileNode {
self.profileNode = nil
self.infoNode.isHidden = false
profileNode.animateOut(to: self, targetRect: nonExtractedRect, transition: transition, completion: { [weak self] in
if let strongSelf = self {
strongSelf.backgroundNode.isHidden = false
strongSelf.fadeNode.isHidden = false
strongSelf.highlightNode.isHidden = false
}
})
var appearenceTransition = transition
if transition.isAnimated {
appearenceTransition = .animated(duration: 0.2, curve: .easeInOut)
}
appearenceTransition.updateFrame(node: profileNode, frame: nonExtractedRect)
self.contextSourceNode.contentNode.customHitTest = nil
}
}
private var absoluteLocation: (CGRect, CGSize)?
func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
self.absoluteLocation = (rect, containerSize)
if let shimmerNode = self.shimmerNode {
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
}
self.updateIsEnabled()
}
var visibility = true {
didSet {
self.updateIsEnabled()
}
}
func updateIsEnabled() {
guard let (rect, containerSize) = self.absoluteLocation else {
return
}
let isVisibleInContainer = rect.maxY >= 0.0 && rect.minY <= containerSize.height
if let videoNode = self.videoNode, videoNode.supernode === self.videoContainerNode {
videoNode.updateIsEnabled(self.visibility && isVisibleInContainer)
}
}
func update(size: CGSize, availableWidth: CGFloat, item: VoiceChatTileItem, transition: ContainedViewLayoutTransition) {
guard self.validLayout?.0 != size || self.validLayout?.1 != availableWidth || self.item != item else {
return
}
self.validLayout = (size, availableWidth)
if !item.videoReady || item.isOwnScreencast {
let shimmerNode: VoiceChatTileShimmeringNode
let shimmerTransition: ContainedViewLayoutTransition
if let current = self.shimmerNode {
shimmerNode = current
shimmerTransition = transition
} else {
shimmerNode = VoiceChatTileShimmeringNode(account: item.account, peer: item.peer)
self.contentNode.insertSubnode(shimmerNode, aboveSubnode: self.fadeNode)
self.shimmerNode = shimmerNode
if let (rect, containerSize) = self.absoluteLocation {
shimmerNode.updateAbsoluteRect(rect, within: containerSize)
}
shimmerTransition = .immediate
}
shimmerTransition.updateFrame(node: shimmerNode, frame: CGRect(origin: CGPoint(), size: size))
shimmerNode.update(shimmeringColor: UIColor.white, shimmering: !item.isOwnScreencast && !item.videoTimeouted && !item.isPaused, size: size, transition: shimmerTransition)
} else if let shimmerNode = self.shimmerNode {
self.shimmerNode = nil
shimmerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak shimmerNode] _ in
shimmerNode?.removeFromSupernode()
})
}
var nodeToAnimateIn: ASDisplayNode?
var placeholderAppeared = false
var itemTransition = transition
if self.item != item {
let previousItem = self.item
self.item = item
if let getAudioLevel = item.getAudioLevel {
self.audioLevelDisposable.set((getAudioLevel()
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let strongSelf = self else {
return
}
strongSelf.highlightNode.updateLevel(CGFloat(value))
}))
}
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .easeInOut)
transition.updateAlpha(node: self.highlightNode, alpha: item.speaking ? 1.0 : 0.0)
if previousItem?.videoEndpointId != item.videoEndpointId || self.videoNode == nil {
if let current = self.videoNode {
self.videoNode = nil
current.removeFromSupernode()
}
if let videoNode = item.getVideo(item.secondary ? .list : .tile) {
itemTransition = .immediate
self.videoNode = videoNode
self.videoContainerNode.addSubnode(videoNode)
self.updateIsEnabled()
}
}
self.videoNode?.updateIsBlurred(isBlurred: item.isPaused, light: true)
var showPlaceholder = false
if item.isVideoLimit {
self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoParticipantsLimitExceeded(String(item.videoLimit)).string, font: Font.semibold(13.0), textColor: .white)
self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/VideoUnavailable"), color: .white)
showPlaceholder = true
} else if item.isOwnScreencast {
self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_YouAreSharingScreen, font: Font.semibold(13.0), textColor: .white)
self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: item.isTablet ? "Call/ScreenShareTablet" : "Call/ScreenSharePhone"), color: .white)
showPlaceholder = true
} else if item.isPaused {
self.placeholderTextNode.attributedText = NSAttributedString(string: item.strings.VoiceChat_VideoPaused, font: Font.semibold(13.0), textColor: .white)
self.placeholderIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/Pause"), color: .white)
showPlaceholder = true
}
placeholderAppeared = self.placeholderTextNode.alpha.isZero && showPlaceholder
transition.updateAlpha(node: self.placeholderTextNode, alpha: showPlaceholder ? 1.0 : 0.0)
transition.updateAlpha(node: self.placeholderIconNode, alpha: showPlaceholder ? 1.0 : 0.0)
let titleFont = Font.semibold(13.0)
let titleColor = UIColor.white
var titleAttributedString: NSAttributedString?
if item.isVideoLimit {
titleAttributedString = nil
} else if case let .user(user) = item.peer {
if let firstName = user.firstName, let lastName = user.lastName, !firstName.isEmpty, !lastName.isEmpty {
let string = NSMutableAttributedString()
switch item.nameDisplayOrder {
case .firstLast:
string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor))
case .lastFirst:
string.append(NSAttributedString(string: lastName, font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: " ", font: titleFont, textColor: titleColor))
string.append(NSAttributedString(string: firstName, font: titleFont, textColor: titleColor))
}
titleAttributedString = string
} else if let firstName = user.firstName, !firstName.isEmpty {
titleAttributedString = NSAttributedString(string: firstName, font: titleFont, textColor: titleColor)
} else if let lastName = user.lastName, !lastName.isEmpty {
titleAttributedString = NSAttributedString(string: lastName, font: titleFont, textColor: titleColor)
} else {
titleAttributedString = NSAttributedString(string: item.strings.User_DeletedAccount, font: titleFont, textColor: titleColor)
}
} else if case let .legacyGroup(group) = item.peer {
titleAttributedString = NSAttributedString(string: group.title, font: titleFont, textColor: titleColor)
} else if case let .channel(channel) = item.peer {
titleAttributedString = NSAttributedString(string: channel.title, font: titleFont, textColor: titleColor)
}
var microphoneColor = UIColor.white
if let additionalText = item.additionalText, case let .text(_, _, color) = additionalText {
if case .destructive = color {
microphoneColor = destructiveColor
}
}
self.titleNode.attributedText = titleAttributedString
var hadMicrophoneNode = false
var hadIconNode = false
if case let .microphone(muted) = item.icon {
let animationNode: VoiceChatMicrophoneNode
if let current = self.animationNode {
animationNode = current
} else {
animationNode = VoiceChatMicrophoneNode()
self.animationNode = animationNode
self.infoNode.addSubnode(animationNode)
nodeToAnimateIn = animationNode
}
animationNode.alpha = 1.0
animationNode.update(state: VoiceChatMicrophoneNode.State(muted: muted, filled: true, color: microphoneColor), animated: true)
} else if let animationNode = self.animationNode {
hadMicrophoneNode = true
self.animationNode = nil
animationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
animationNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak animationNode] _ in
animationNode?.removeFromSupernode()
})
}
if case .presentation = item.icon {
let iconNode: ASImageNode
if let current = self.iconNode {
iconNode = current
} else {
iconNode = ASImageNode()
iconNode.displaysAsynchronously = false
iconNode.contentMode = .center
self.iconNode = iconNode
self.infoNode.addSubnode(iconNode)
nodeToAnimateIn = iconNode
}
iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Call/StatusScreen"), color: .white)
} else if let iconNode = self.iconNode {
hadIconNode = true
self.iconNode = nil
iconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
iconNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false, completion: { [weak iconNode] _ in
iconNode?.removeFromSupernode()
})
}
if let node = nodeToAnimateIn, hadMicrophoneNode || hadIconNode {
node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
node.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
}
}
let bounds = CGRect(origin: CGPoint(), size: size)
self.containerNode.frame = bounds
self.contextSourceNode.frame = bounds
self.contextSourceNode.contentNode.frame = bounds
transition.updateFrame(node: self.contentNode, frame: bounds)
let extractedWidth = availableWidth
let makeStatusLayout = self.statusNode.asyncLayout()
let (statusLayout, _) = makeStatusLayout(CGSize(width: availableWidth - 30.0, height: CGFloat.greatestFiniteMagnitude), item.text, true)
let extractedRect = CGRect(x: 0.0, y: 0.0, width: extractedWidth, height: extractedWidth + statusLayout.height + 39.0)
let nonExtractedRect = bounds
self.extractedRect = extractedRect
self.nonExtractedRect = nonExtractedRect
self.contextSourceNode.contentRect = extractedRect
if self.videoContainerNode.supernode === self.contentNode {
if let videoNode = self.videoNode {
itemTransition.updateFrame(node: videoNode, frame: bounds)
if videoNode.supernode === self.videoContainerNode {
videoNode.updateLayout(size: size, layoutMode: .fillOrFitToSquare, transition: itemTransition)
}
}
transition.updateFrame(node: self.videoContainerNode, frame: bounds)
}
transition.updateFrame(node: self.backgroundNode, frame: bounds)
transition.updateFrame(node: self.highlightNode, frame: bounds)
self.highlightNode.updateLayout(size: bounds.size, transition: transition)
transition.updateFrame(node: self.infoNode, frame: bounds)
transition.updateFrame(node: self.fadeNode, frame: CGRect(x: 0.0, y: size.height - fadeHeight, width: size.width, height: fadeHeight))
let titleSize = self.titleNode.updateLayout(CGSize(width: size.width - 50.0, height: size.height))
self.titleNode.frame = CGRect(origin: CGPoint(x: 30.0, y: size.height - titleSize.height - 8.0), size: titleSize)
var transition = transition
if nodeToAnimateIn != nil || placeholderAppeared {
transition = .immediate
}
if let iconNode = self.iconNode, let image = iconNode.image {
transition.updateFrame(node: iconNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels(16.0 - image.size.width / 2.0), y: floorToScreenPixels(size.height - 15.0 - image.size.height / 2.0)), size: image.size))
}
if let animationNode = self.animationNode {
let animationSize = CGSize(width: 36.0, height: 36.0)
animationNode.bounds = CGRect(origin: CGPoint(), size: animationSize)
animationNode.transform = CATransform3DMakeScale(0.66667, 0.66667, 1.0)
transition.updatePosition(node: animationNode, position: CGPoint(x: 16.0, y: size.height - 15.0))
}
let placeholderTextSize = self.placeholderTextNode.updateLayout(CGSize(width: size.width - 30.0, height: 100.0))
transition.updateFrame(node: self.placeholderTextNode, frame: CGRect(origin: CGPoint(x: floor((size.width - placeholderTextSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) + 10.0), size: placeholderTextSize))
if let image = self.placeholderIconNode.image {
let imageScale: CGFloat = item.isVideoLimit ? 1.0 : 0.5
let imageSize = CGSize(width: image.size.width * imageScale, height: image.size.height * imageScale)
transition.updateFrame(node: self.placeholderIconNode, frame: CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) / 2.0), y: floorToScreenPixels(size.height / 2.0) - imageSize.height - 4.0), size: imageSize))
}
}
func transitionIn(from sourceNode: ASDisplayNode?) {
guard let item = self.item else {
return
}
var videoNode: GroupVideoNode?
if let sourceNode = sourceNode as? VoiceChatFullscreenParticipantItemNode, let _ = sourceNode.item {
if let sourceVideoNode = sourceNode.videoNode {
sourceNode.videoNode = nil
videoNode = sourceVideoNode
}
}
if videoNode == nil {
videoNode = item.getVideo(item.secondary ? .list : .tile)
}
if let videoNode = videoNode {
videoNode.alpha = 1.0
self.videoNode = videoNode
self.videoContainerNode.addSubnode(videoNode)
videoNode.updateLayout(size: self.bounds.size, layoutMode: .fillOrFitToSquare, transition: .immediate)
videoNode.frame = self.bounds
self.updateIsEnabled()
}
}
}
private let blue = UIColor(rgb: 0x007fff)
private let lightBlue = UIColor(rgb: 0x00affe)
private let green = UIColor(rgb: 0x33c659)
private let activeBlue = UIColor(rgb: 0x00a0b9)
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xef436c)
class VoiceChatTileHighlightNode: ASDisplayNode {
enum Gradient {
case speaking
case active
case mutedForYou
case muted
}
private var maskView: UIView?
private let maskLayer = CALayer()
private let foregroundGradientLayer = CAGradientLayer()
var isCurrentlyInHierarchy = false {
didSet {
if self.isCurrentlyInHierarchy != oldValue && self.isCurrentlyInHierarchy {
self.updateAnimations()
}
}
}
private var audioLevel: CGFloat = 0.0
private var presentationAudioLevel: CGFloat = 0.0
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
override init() {
self.foregroundGradientLayer.type = .radial
self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0]
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
super.init()
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let strongSelf = self else { return }
strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1
}
}
override func didLoad() {
super.didLoad()
self.layer.addSublayer(self.foregroundGradientLayer)
let maskView = UIView()
maskView.layer.addSublayer(self.maskLayer)
self.maskView = maskView
self.maskLayer.masksToBounds = true
self.maskLayer.cornerRadius = backgroundCornerRadius - UIScreenPixel
self.maskLayer.borderColor = UIColor.white.cgColor
self.maskLayer.borderWidth = borderLineWidth
self.view.mask = self.maskView
}
func updateAnimations() {
if !self.isCurrentlyInHierarchy {
self.foregroundGradientLayer.removeAllAnimations()
return
}
self.setupGradientAnimations()
}
func updateLevel(_ level: CGFloat) {
self.audioLevel = level
}
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
let bounds = CGRect(origin: CGPoint(), size: size)
if let maskView = self.maskView {
transition.updateFrame(view: maskView, frame: bounds)
}
transition.updateFrame(layer: self.maskLayer, frame: bounds)
transition.updateFrame(layer: self.foregroundGradientLayer, frame: bounds)
}
private func setupGradientAnimations() {
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.foregroundGradientLayer.startPoint
let newValue: CGPoint
if self.presentationAudioLevel > 0.22 {
newValue = CGPoint(x: CGFloat.random(in: 0.9 ..< 1.0), y: CGFloat.random(in: 0.15 ..< 0.35))
} else if self.presentationAudioLevel > 0.01 {
newValue = CGPoint(x: CGFloat.random(in: 0.57 ..< 0.85), y: CGFloat.random(in: 0.15 ..< 0.45))
} else {
newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.75), y: CGFloat.random(in: 0.25 ..< 0.45))
}
self.foregroundGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
guard let strongSelf = self else {
return
}
if strongSelf.isCurrentlyInHierarchy {
strongSelf.setupGradientAnimations()
}
}
self.foregroundGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
private var gradient: Gradient?
func updateGlowAndGradientAnimations(type: Gradient, animated: Bool = true) {
guard self.gradient != type else {
return
}
self.gradient = type
let initialColors = self.foregroundGradientLayer.colors
let targetColors: [CGColor]
switch type {
case .speaking:
targetColors = [activeBlue.cgColor, green.cgColor, green.cgColor]
case .active:
targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
case .mutedForYou:
targetColors = [pink.cgColor, destructiveColor.cgColor, destructiveColor.cgColor]
case .muted:
targetColors = [pink.cgColor, purple.cgColor, purple.cgColor]
}
self.foregroundGradientLayer.colors = targetColors
if animated {
self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
}
self.updateAnimations()
}
}
final class ShimmerEffectForegroundNode: ASDisplayNode {
private var currentForegroundColor: UIColor?
private let imageNodeContainer: ASDisplayNode
private let imageNode: ASDisplayNode
private var absoluteLocation: (CGRect, CGSize)?
private var isCurrentlyInHierarchy = false
private var shouldBeAnimating = false
private let size: CGFloat
init(size: CGFloat) {
self.size = size
self.imageNodeContainer = ASDisplayNode()
self.imageNodeContainer.isLayerBacked = true
self.imageNode = ASDisplayNode()
self.imageNode.isLayerBacked = true
self.imageNode.displaysAsynchronously = false
super.init()
self.isLayerBacked = true
self.clipsToBounds = true
self.imageNodeContainer.addSubnode(self.imageNode)
self.addSubnode(self.imageNodeContainer)
}
override func didEnterHierarchy() {
super.didEnterHierarchy()
self.isCurrentlyInHierarchy = true
self.updateAnimation()
}
override func didExitHierarchy() {
super.didExitHierarchy()
self.isCurrentlyInHierarchy = false
self.updateAnimation()
}
func update(foregroundColor: UIColor) {
if let currentForegroundColor = self.currentForegroundColor, currentForegroundColor.isEqual(foregroundColor) {
return
}
self.currentForegroundColor = foregroundColor
let image = generateImage(CGSize(width: self.size, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.clip(to: CGRect(origin: CGPoint(), size: size))
let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor
let peakColor = foregroundColor.cgColor
var locations: [CGFloat] = [0.0, 0.5, 1.0]
let colors: [CGColor] = [transparentColor, peakColor, transparentColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
})
if let image = image {
self.imageNode.backgroundColor = UIColor(patternImage: image)
}
}
func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
if let absoluteLocation = self.absoluteLocation, absoluteLocation.0 == rect && absoluteLocation.1 == containerSize {
return
}
let sizeUpdated = self.absoluteLocation?.1 != containerSize
let frameUpdated = self.absoluteLocation?.0 != rect
self.absoluteLocation = (rect, containerSize)
if sizeUpdated {
if self.shouldBeAnimating {
self.imageNode.layer.removeAnimation(forKey: "shimmer")
self.addImageAnimation()
} else {
self.updateAnimation()
}
}
if frameUpdated {
self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: -rect.minX, y: -rect.minY), size: containerSize)
}
}
private func updateAnimation() {
let shouldBeAnimating = self.isCurrentlyInHierarchy && self.absoluteLocation != nil
if shouldBeAnimating != self.shouldBeAnimating {
self.shouldBeAnimating = shouldBeAnimating
if shouldBeAnimating {
self.addImageAnimation()
} else {
self.imageNode.layer.removeAnimation(forKey: "shimmer")
}
}
}
private func addImageAnimation() {
guard let containerSize = self.absoluteLocation?.1 else {
return
}
let gradientHeight: CGFloat = self.size
self.imageNode.frame = CGRect(origin: CGPoint(x: -gradientHeight, y: 0.0), size: CGSize(width: gradientHeight, height: containerSize.height))
let animation = self.imageNode.layer.makeAnimation(from: 0.0 as NSNumber, to: (containerSize.width + gradientHeight) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 1.3 * 1.0, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
animation.beginTime = 1.0
self.imageNode.layer.add(animation, forKey: "shimmer")
}
}
private class VoiceChatTileShimmeringNode: ASDisplayNode {
private let backgroundNode: ImageNode
private let effectNode: ShimmerEffectForegroundNode
private let borderNode: ASDisplayNode
private var borderMaskView: UIView?
private let borderEffectNode: ShimmerEffectForegroundNode
private var currentShimmeringColor: UIColor?
private var currentShimmering: Bool?
private var currentSize: CGSize?
public init(account: Account, peer: EnginePeer) {
self.backgroundNode = ImageNode(enableHasImage: false, enableEmpty: false, enableAnimatedTransition: true)
self.backgroundNode.displaysAsynchronously = false
self.backgroundNode.contentMode = .scaleAspectFill
self.effectNode = ShimmerEffectForegroundNode(size: 240.0)
self.borderNode = ASDisplayNode()
self.borderEffectNode = ShimmerEffectForegroundNode(size: 320.0)
super.init()
self.clipsToBounds = true
self.cornerRadius = backgroundCornerRadius
self.addSubnode(self.backgroundNode)
self.addSubnode(self.effectNode)
self.addSubnode(self.borderNode)
self.borderNode.addSubnode(self.borderEffectNode)
self.backgroundNode.setSignal(peerAvatarCompleteImage(account: account, peer: peer, size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true))
}
public override func didLoad() {
super.didLoad()
if self.effectNode.supernode != nil {
self.effectNode.layer.compositingFilter = "screenBlendMode"
self.borderEffectNode.layer.compositingFilter = "screenBlendMode"
let borderMaskView = UIView()
borderMaskView.layer.borderWidth = 1.0
borderMaskView.layer.borderColor = UIColor.white.cgColor
borderMaskView.layer.cornerRadius = backgroundCornerRadius
self.borderMaskView = borderMaskView
if let size = self.currentSize {
borderMaskView.frame = CGRect(origin: CGPoint(), size: size)
}
self.borderNode.view.mask = borderMaskView
if #available(iOS 13.0, *) {
borderMaskView.layer.cornerCurve = .continuous
}
}
if #available(iOS 13.0, *) {
self.layer.cornerCurve = .continuous
}
}
public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) {
self.effectNode.updateAbsoluteRect(rect, within: containerSize)
self.borderEffectNode.updateAbsoluteRect(rect, within: containerSize)
}
public func update(shimmeringColor: UIColor, shimmering: Bool, size: CGSize, transition: ContainedViewLayoutTransition) {
if let currentShimmeringColor = self.currentShimmeringColor, currentShimmeringColor.isEqual(shimmeringColor) && self.currentSize == size && self.currentShimmering == shimmering {
return
}
let firstTime = self.currentShimmering == nil
self.currentShimmeringColor = shimmeringColor
self.currentShimmering = shimmering
self.currentSize = size
let transition: ContainedViewLayoutTransition = firstTime ? .immediate : (transition.isAnimated ? transition : .animated(duration: 0.45, curve: .easeInOut))
transition.updateAlpha(node: self.effectNode, alpha: shimmering ? 1.0 : 0.0)
transition.updateAlpha(node: self.borderNode, alpha: shimmering ? 1.0 : 0.0)
let bounds = CGRect(origin: CGPoint(), size: size)
self.effectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.3))
transition.updateFrame(node: self.effectNode, frame: bounds)
self.borderEffectNode.update(foregroundColor: shimmeringColor.withAlphaComponent(0.45))
transition.updateFrame(node: self.borderEffectNode, frame: bounds)
transition.updateFrame(node: self.backgroundNode, frame: bounds)
transition.updateFrame(node: self.borderNode, frame: bounds)
if let borderMaskView = self.borderMaskView {
transition.updateFrame(view: borderMaskView, frame: bounds)
}
}
}
@@ -0,0 +1,232 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import TelegramStringFormatting
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xef436c)
private let latePurple = UIColor(rgb: 0x974aa9)
private let latePink = UIColor(rgb: 0xf0436c)
private func textForTimeout(value: Int32) -> String {
if value < 3600 {
let minutes = value / 60
let seconds = value % 60
let secondsPadding = seconds < 10 ? "0" : ""
return "\(minutes):\(secondsPadding)\(seconds)"
} else {
let hours = value / 3600
let minutes = (value % 3600) / 60
let minutesPadding = minutes < 10 ? "0" : ""
let seconds = value % 60
let secondsPadding = seconds < 10 ? "0" : ""
return "\(hours):\(minutesPadding)\(minutes):\(secondsPadding)\(seconds)"
}
}
final class VoiceChatTimerNode: ASDisplayNode {
private let strings: PresentationStrings
private let dateTimeFormat: PresentationDateTimeFormat
private let titleNode: ImmediateTextNode
private let subtitleNode: ImmediateTextNode
private let timerNode: ImmediateTextNode
private let foregroundView = UIView()
private let foregroundGradientLayer = CAGradientLayer()
private let maskView = UIView()
private var validLayout: CGSize?
private var updateTimer: SwiftSignalKit.Timer?
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = false
private var isLate = false
init(strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat) {
self.strings = strings
self.dateTimeFormat = dateTimeFormat
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.titleNode = ImmediateTextNode()
self.subtitleNode = ImmediateTextNode()
self.timerNode = ImmediateTextNode()
super.init()
self.addSubnode(self.hierarchyTrackingNode)
self.allowsGroupOpacity = true
self.isUserInteractionEnabled = false
self.foregroundGradientLayer.type = .radial
self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor]
self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0]
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
self.foregroundView.mask = self.maskView
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
self.view.addSubview(self.foregroundView)
self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
self.maskView.addSubnode(self.timerNode)
updateInHierarchy = { [weak self] value in
if let strongSelf = self {
strongSelf.isCurrentlyInHierarchy = value
strongSelf.updateAnimations()
}
}
}
deinit {
self.updateTimer?.invalidate()
}
func animateIn() {
self.foregroundView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0)
}
private func updateAnimations() {
if self.isInHierarchy {
self.setupGradientAnimations()
} else {
self.foregroundGradientLayer.removeAllAnimations()
}
}
private func setupGradientAnimations() {
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.foregroundGradientLayer.startPoint
let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45))
self.foregroundGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy {
self?.setupGradientAnimations()
}
}
self.foregroundGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
func update(size: CGSize, participants: Int32, groupingSeparator: String, transition: ContainedViewLayoutTransition) {
if self.validLayout == nil {
self.updateAnimations()
}
self.validLayout = size
self.foregroundView.frame = CGRect(origin: CGPoint(), size: size)
self.foregroundGradientLayer.frame = self.foregroundView.bounds
self.maskView.frame = self.foregroundView.bounds
let text: String = presentationStringsFormattedNumber(participants, groupingSeparator)
let subtitle = "listening"
self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white)
let titleSize = self.titleNode.updateLayout(size)
self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height)
self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height))
if timerSize.width > size.width - 32.0 {
self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height))
}
self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height)
self.subtitleNode.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 21.0, design: .round, weight: .semibold, traits: []), textColor: .white)
let subtitleSize = self.subtitleNode.updateLayout(size)
self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height)
self.foregroundView.frame = CGRect(origin: CGPoint(), size: size)
}
func update(size: CGSize, scheduleTime: Int32?, transition: ContainedViewLayoutTransition) {
if self.validLayout == nil {
self.updateAnimations()
}
self.validLayout = size
guard let scheduleTime = scheduleTime else {
return
}
self.foregroundView.frame = CGRect(origin: CGPoint(), size: size)
self.foregroundGradientLayer.frame = self.foregroundView.bounds
self.maskView.frame = self.foregroundView.bounds
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let elapsedTime = scheduleTime - currentTime
let timerText: String
if elapsedTime >= 86400 {
timerText = scheduledTimeIntervalString(strings: self.strings, value: elapsedTime)
} else {
timerText = textForTimeout(value: abs(elapsedTime))
if elapsedTime < 0 && !self.isLate {
self.isLate = true
self.foregroundGradientLayer.colors = [latePink.cgColor, latePurple.cgColor, latePurple.cgColor]
}
}
if self.updateTimer == nil {
let timer = SwiftSignalKit.Timer(timeout: 0.5, repeat: true, completion: { [weak self] in
if let strongSelf = self, let size = strongSelf.validLayout {
strongSelf.update(size: size, scheduleTime: scheduleTime, transition: .immediate)
}
}, queue: Queue.mainQueue())
self.updateTimer = timer
timer.start()
}
let subtitle = humanReadableStringForTimestamp(strings: self.strings, dateTimeFormat: self.dateTimeFormat, timestamp: scheduleTime, alwaysShowTime: true).string
self.titleNode.attributedText = NSAttributedString(string: elapsedTime < 0 ? self.strings.VoiceChat_LateBy : self.strings.VoiceChat_StartsIn, font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white)
let titleSize = self.titleNode.updateLayout(size)
self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height)
self.timerNode.attributedText = NSAttributedString(string: timerText, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height))
if timerSize.width > size.width - 32.0 {
self.timerNode.attributedText = NSAttributedString(string: timerText, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height))
}
self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height)
self.subtitleNode.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 21.0, design: .round, weight: .semibold, traits: []), textColor: .white)
let subtitleSize = self.subtitleNode.updateLayout(size)
self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height)
self.foregroundView.frame = CGRect(origin: CGPoint(), size: size)
}
}
@@ -0,0 +1,185 @@
import Foundation
import UIKit
import SwiftSignalKit
import AsyncDisplayKit
import Display
import TelegramCore
import TelegramPresentationData
import AccountContext
import UrlEscaping
import ComponentFlow
import AlertComponent
import AlertInputFieldComponent
func voiceChatTitleEditController(
context: AccountContext,
forceTheme: PresentationTheme?,
title: String,
text: String,
placeholder: String,
doneButtonTitle: String? = nil,
value: String?,
maxLength: Int,
apply: @escaping (String?) -> Void
) -> ViewController {
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
if let forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
let strings = presentationData.strings
let inputState = AlertInputFieldComponent.ExternalState()
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(title: title)
)
))
content.append(AnyComponentWithIdentity(
id: "text",
component: AnyComponent(
AlertTextComponent(content: .plain(text))
)
))
var applyImpl: (() -> Void)?
content.append(AnyComponentWithIdentity(
id: "input",
component: AnyComponent(
AlertInputFieldComponent(
context: context,
initialValue: value,
placeholder: placeholder,
characterLimit: maxLength,
hasClearButton: true,
isInitiallyFocused: true,
externalState: inputState,
returnKeyAction: {
applyImpl?()
}
)
)
))
let alertController = AlertScreen(
configuration: AlertScreen.Configuration(allowInputInset: true),
content: content,
actions: [
.init(title: strings.Common_Cancel),
.init(title: doneButtonTitle ?? strings.Common_Done, type: .default, action: {
applyImpl?()
})
],
updatedPresentationData: (presentationData, .single(presentationData))
)
applyImpl = {
let previousValue = value ?? ""
let newValue = inputState.value.trimmingCharacters(in: .whitespacesAndNewlines)
apply(previousValue != newValue || value == nil ? newValue : nil)
}
return alertController
}
func voiceChatUserNameController(
context: AccountContext,
forceTheme: PresentationTheme?,
title: String,
firstNamePlaceholder: String,
lastNamePlaceholder: String,
doneButtonTitle: String? = nil,
firstName: String?,
lastName: String?,
maxLength: Int,
apply: @escaping ((String, String)?) -> Void
) -> ViewController {
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
if let forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
let strings = presentationData.strings
let firstNameState = AlertInputFieldComponent.ExternalState()
let lastNameState = AlertInputFieldComponent.ExternalState()
var content: [AnyComponentWithIdentity<AlertComponentEnvironment>] = []
content.append(AnyComponentWithIdentity(
id: "title",
component: AnyComponent(
AlertTitleComponent(title: title)
)
))
var nextImpl: (() -> Void)?
var applyImpl: (() -> Void)?
content.append(AnyComponentWithIdentity(
id: "firstName",
component: AnyComponent(
AlertInputFieldComponent(
context: context,
initialValue: firstName,
placeholder: firstNamePlaceholder,
characterLimit: maxLength,
hasClearButton: true,
returnKeyType: .next,
isInitiallyFocused: true,
externalState: firstNameState,
returnKeyAction: {
nextImpl?()
}
)
)
))
content.append(AnyComponentWithIdentity(
id: "lastName",
component: AnyComponent(
AlertInputFieldComponent(
context: context,
initialValue: lastName,
placeholder: lastNamePlaceholder,
characterLimit: maxLength,
hasClearButton: true,
isInitiallyFocused: false,
externalState: lastNameState,
returnKeyAction: {
applyImpl?()
}
)
)
))
let alertController = AlertScreen(
configuration: AlertScreen.Configuration(allowInputInset: true),
content: content,
actions: [
.init(title: strings.Common_Cancel),
.init(title: doneButtonTitle ?? strings.Common_Done, type: .default, action: {
applyImpl?()
})
],
updatedPresentationData: (presentationData, .single(presentationData))
)
nextImpl = {
lastNameState.activateInput()
}
applyImpl = {
let previousFirstName = firstName ?? ""
let previousLastName = lastName ?? ""
let newFirstName = firstNameState.value.trimmingCharacters(in: .whitespacesAndNewlines)
let newLastName = lastNameState.value.trimmingCharacters(in: .whitespacesAndNewlines)
if newFirstName.isEmpty {
firstNameState.animateError()
return
}
if previousFirstName != newFirstName || previousLastName != newLastName {
apply((newFirstName, newLastName))
} else {
apply(nil)
}
}
return alertController
}
@@ -0,0 +1,120 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import ChatTitleActivityNode
private let constructiveColor: UIColor = UIColor(rgb: 0x34c759)
final class VoiceChatTitleNode: ASDisplayNode {
private var theme: PresentationTheme
private let titleNode: ASTextNode
private let infoNode: ChatTitleActivityNode
let recordingIconNode: VoiceChatRecordingIconNode
public var isRecording: Bool = false {
didSet {
self.recordingIconNode.isHidden = !self.isRecording
}
}
var tapped: (() -> Void)?
init(theme: PresentationTheme) {
self.theme = theme
self.titleNode = ASTextNode()
self.titleNode.displaysAsynchronously = false
self.titleNode.maximumNumberOfLines = 1
self.titleNode.truncationMode = .byTruncatingTail
self.titleNode.isOpaque = false
self.infoNode = ChatTitleActivityNode()
self.recordingIconNode = VoiceChatRecordingIconNode(hasBackground: false)
super.init()
self.addSubnode(self.titleNode)
self.addSubnode(self.infoNode)
self.addSubnode(self.recordingIconNode)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didLoad() {
super.didLoad()
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap)))
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if point.y > 0.0 && point.y < self.frame.size.height && point.x > min(self.titleNode.frame.minX, self.infoNode.frame.minX) && point.x < max(self.recordingIconNode.frame.maxX, self.infoNode.frame.maxX) {
return true
} else {
return false
}
}
@objc private func tap() {
self.tapped?()
}
func update(size: CGSize, title: String, subtitle: String, speaking: Bool, slide: Bool, transition: ContainedViewLayoutTransition) {
guard !size.width.isZero else {
return
}
var titleUpdated = false
if let previousTitle = self.titleNode.attributedText?.string {
titleUpdated = previousTitle != title
}
if titleUpdated, let snapshotView = self.titleNode.view.snapshotContentTree() {
snapshotView.frame = self.titleNode.frame
self.view.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
self.titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
if slide {
self.infoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
let offset: CGFloat = 16.0
snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -offset), duration: 0.2, removeOnCompletion: false, additive: true)
self.titleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.2, additive: true)
self.infoNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.2, additive: true)
}
}
self.titleNode.attributedText = NSAttributedString(string: title, font: Font.medium(17.0), textColor: UIColor(rgb: 0xffffff))
var state = ChatTitleActivityNodeState.none
if speaking {
state = .recordingVoice(NSAttributedString(string: subtitle, font: Font.regular(13.0), textColor: constructiveColor), constructiveColor)
} else {
state = .info(NSAttributedString(string: subtitle, font: Font.regular(13.0), textColor: UIColor(rgb: 0xffffff, alpha: 0.5)), .generic)
}
let _ = self.infoNode.transitionToState(state, animation: .slide)
let constrainedSize = CGSize(width: size.width - 140.0, height: size.height)
let titleSize = self.titleNode.measure(constrainedSize)
let infoSize = self.infoNode.updateLayout(constrainedSize, offset: 1.0, alignment: .center)
let titleInfoSpacing: CGFloat = 0.0
let combinedHeight = titleSize.height + infoSize.height + titleInfoSpacing
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize)
self.titleNode.frame = titleFrame
self.infoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - infoSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: infoSize)
let iconSide = 16.0 + (1.0 + UIScreenPixel) * 2.0
let iconSize: CGSize = CGSize(width: iconSide, height: iconSide)
self.recordingIconNode.frame = CGRect(origin: CGPoint(x: titleFrame.maxX + 1.0, y: titleFrame.minY + 1.0), size: iconSize)
}
}
@@ -0,0 +1,209 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramPresentationData
import AppBundle
import ContextUI
final class VoiceChatVolumeContextItem: ContextMenuCustomItem {
private let minValue: CGFloat
private let value: CGFloat
private let valueChanged: (CGFloat, Bool) -> Void
init(minValue: CGFloat, value: CGFloat, valueChanged: @escaping (CGFloat, Bool) -> Void) {
self.minValue = minValue
self.value = value
self.valueChanged = valueChanged
}
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return VoiceChatVolumeContextItemNode(presentationData: presentationData, getController: getController, minValue: self.minValue, value: self.value, valueChanged: self.valueChanged)
}
}
private let textFont = Font.regular(17.0)
private final class VoiceChatVolumeContextItemNode: ASDisplayNode, ContextMenuCustomNode {
private var presentationData: PresentationData
private let backgroundIconNode: VoiceChatSpeakerNode
private let backgroundTextNode: ImmediateTextNode
private let foregroundNode: ASDisplayNode
private let foregroundIconNode: VoiceChatSpeakerNode
private let foregroundTextNode: ImmediateTextNode
let minValue: CGFloat
var value: CGFloat = 1.0 {
didSet {
self.updateValue()
}
}
let needsPadding: Bool = false
private let valueChanged: (CGFloat, Bool) -> Void
private let hapticFeedback = HapticFeedback()
init(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, minValue: CGFloat, value: CGFloat, valueChanged: @escaping (CGFloat, Bool) -> Void) {
self.presentationData = presentationData
self.minValue = minValue
self.value = value
self.valueChanged = valueChanged
self.backgroundIconNode = VoiceChatSpeakerNode()
self.backgroundTextNode = ImmediateTextNode()
self.backgroundTextNode.isAccessibilityElement = false
self.backgroundTextNode.isUserInteractionEnabled = false
self.backgroundTextNode.displaysAsynchronously = false
self.backgroundTextNode.textAlignment = .left
self.foregroundNode = ASDisplayNode()
self.foregroundNode.clipsToBounds = true
self.foregroundNode.isAccessibilityElement = false
self.foregroundNode.backgroundColor = UIColor(rgb: 0xffffff)
self.foregroundNode.isUserInteractionEnabled = false
self.foregroundIconNode = VoiceChatSpeakerNode()
self.foregroundTextNode = ImmediateTextNode()
self.foregroundTextNode.isAccessibilityElement = false
self.foregroundTextNode.isUserInteractionEnabled = false
self.foregroundTextNode.displaysAsynchronously = false
self.foregroundTextNode.textAlignment = .left
super.init()
self.isUserInteractionEnabled = true
self.addSubnode(self.backgroundIconNode)
self.addSubnode(self.backgroundTextNode)
self.addSubnode(self.foregroundNode)
self.foregroundNode.addSubnode(self.foregroundIconNode)
self.foregroundNode.addSubnode(self.foregroundTextNode)
}
override func didLoad() {
super.didLoad()
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
self.view.addGestureRecognizer(panGestureRecognizer)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapGestureRecognizer)
}
func updateTheme(presentationData: PresentationData) {
self.presentationData = presentationData
self.updateValue()
}
private func updateValue(transition: ContainedViewLayoutTransition = .immediate) {
let width = self.frame.width
let value = self.value / 2.0
transition.updateFrameAdditive(node: self.foregroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: value * width, height: self.frame.height)))
self.backgroundTextNode.attributedText = NSAttributedString(string: "\(Int(self.value * 100.0))%", font: textFont, textColor: UIColor(rgb: 0xffffff))
self.foregroundTextNode.attributedText = NSAttributedString(string: "\(Int(self.value * 100.0))%", font: textFont, textColor: UIColor(rgb: 0x000000))
let iconValue: VoiceChatSpeakerNode.State.Value
if value == 0.0 {
iconValue = .muted
} else if value < 0.33 {
iconValue = .low
} else if value < 0.66 {
iconValue = .medium
} else {
iconValue = .high
}
self.backgroundIconNode.update(state: VoiceChatSpeakerNode.State(value: iconValue, color: UIColor(rgb: 0xffffff)), animated: true)
self.foregroundIconNode.update(state: VoiceChatSpeakerNode.State(value: iconValue, color: UIColor(rgb: 0x000000)), animated: true)
let _ = self.backgroundTextNode.updateLayout(CGSize(width: 70.0, height: .greatestFiniteMagnitude))
let _ = self.foregroundTextNode.updateLayout(CGSize(width: 70.0, height: .greatestFiniteMagnitude))
}
func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let valueWidth: CGFloat = 70.0
let height: CGFloat = 45.0
var textSize = self.backgroundTextNode.updateLayout(CGSize(width: valueWidth, height: .greatestFiniteMagnitude))
textSize.width = valueWidth
return (CGSize(width: height * 3.0, height: height), { size, transition in
let leftInset: CGFloat = 25.0
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize)
transition.updateFrameAdditive(node: self.backgroundTextNode, frame: textFrame)
transition.updateFrameAdditive(node: self.foregroundTextNode, frame: textFrame)
let iconSize = CGSize(width: 36.0, height: 36.0)
let iconFrame = CGRect(origin: CGPoint(x: size.width - iconSize.width - 10.0, y: floor((size.height - iconSize.height) / 2.0)), size: iconSize)
self.backgroundIconNode.frame = iconFrame
self.foregroundIconNode.frame = iconFrame
self.updateValue(transition: transition)
})
}
@objc private func panGesture(_ gestureRecognizer: UIPanGestureRecognizer) {
switch gestureRecognizer.state {
case .began:
break
case .changed:
let previousValue = self.value
let translation: CGFloat = gestureRecognizer.translation(in: gestureRecognizer.view).x
let delta = translation / self.bounds.width * 2.0
self.value = max(self.minValue, min(2.0, self.value + delta))
gestureRecognizer.setTranslation(CGPoint(), in: gestureRecognizer.view)
if self.value == 2.0 && previousValue != 2.0 {
self.backgroundIconNode.layer.animateScale(from: 1.0, to: 1.1, duration: 0.16, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.backgroundIconNode.layer.animateScale(from: 1.1, to: 1.0, duration: 0.16)
}
})
self.foregroundIconNode.layer.animateScale(from: 1.0, to: 1.1, duration: 0.16, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.foregroundIconNode.layer.animateScale(from: 1.1, to: 1.0, duration: 0.16)
}
})
self.hapticFeedback.impact(.soft)
} else if self.value == 0.0 && previousValue != 0.0 {
self.hapticFeedback.impact(.soft)
}
if abs(previousValue - self.value) >= 0.01 {
self.valueChanged(self.value, false)
}
case .ended:
let translation: CGFloat = gestureRecognizer.translation(in: gestureRecognizer.view).x
let delta = translation / self.bounds.width * 2.0
self.value = max(self.minValue, min(2.0, self.value + delta))
self.valueChanged(self.value, true)
default:
break
}
}
@objc private func tapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
let location = gestureRecognizer.location(in: gestureRecognizer.view)
self.value = max(self.minValue, min(2.0, location.x / self.bounds.width * 2.0))
self.valueChanged(self.value, true)
}
func canBeHighlighted() -> Bool {
return false
}
func updateIsHighlighted(isHighlighted: Bool) {
}
func performAction() {
}
}