2023-01-08 14:51:16 +04:00

2320 lines
111 KiB
Swift

import Foundation
import UIKit
import ComponentFlow
import Display
import AccountContext
import SwiftSignalKit
import AVKit
import TelegramCore
import Postbox
import ShareController
import UndoUI
import TelegramPresentationData
import PresentationDataUtils
import LottieAnimationComponent
import ContextUI
import ViewControllerComponent
import BundleIconComponent
import CreateExternalMediaStreamScreen
import HierarchyTrackingLayer
import UndoPanelComponent
import AvatarNode
public final class MediaStreamComponent: CombinedComponent {
struct OriginInfo: Equatable {
var title: String
var memberCount: Int
}
public typealias EnvironmentType = ViewControllerComponentContainer.Environment
public let call: PresentationGroupCallImpl
public init(call: PresentationGroupCallImpl) {
self.call = call
}
public static func ==(lhs: MediaStreamComponent, rhs: MediaStreamComponent) -> Bool {
if lhs.call !== rhs.call {
return false
}
return true
}
public final class State: ComponentState {
private let call: PresentationGroupCallImpl
private(set) var hasVideo: Bool = false
private var stateDisposable: Disposable?
private var infoDisposable: Disposable?
private(set) var originInfo: OriginInfo?
private(set) var displayUI: Bool = true
var dismissOffset: CGFloat = 0.0
var initialOffset: CGFloat = 0.0
var storedIsFullscreen: Bool?
var isFullscreen: Bool = false
var videoSize: CGSize?
var prevFullscreenOrientation: UIDeviceOrientation?
var didAutoDismissForPiP: Bool = false
private(set) var canManageCall: Bool = false
// TODO: also handle pictureInPicturePossible
let isPictureInPictureSupported: Bool
private(set) var callTitle: String?
private(set) var recordingStartTimestamp: Int32?
private(set) var peerTitle: String = ""
private(set) var chatPeer: Peer?
private(set) var isVisibleInHierarchy: Bool = false
private var isVisibleInHierarchyDisposable: Disposable?
private var scheduledDismissUITimer: SwiftSignalKit.Timer?
var videoStalled: Bool = true
var videoIsPlayable: Bool {
!videoStalled && hasVideo
}
var wantsPiP: Bool = false
let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self)
private let infoThrottler = Throttler<Int>.init(duration: 5, queue: .main)
init(call: PresentationGroupCallImpl) {
self.call = call
if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() {
self.isPictureInPictureSupported = true
} else {
self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported()
}
super.init()
self.stateDisposable = (call.state
|> map { state -> Bool in
switch state.networkState {
case .connected:
return true
default:
return false
}
}
|> filter { $0 }
|> take(1)).start(next: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.hasVideo = true
strongSelf.updated(transition: .immediate)
/*let engine = strongSelf.call.accountContext.engine
guard let info = strongSelf.call.initialCall else {
return
}
let _ = (engine.calls.getAudioBroadcastDataSource(callId: info.id, accessHash: info.accessHash)
|> mapToSignal { source -> Signal<Data?, NoError> in
guard let source else {
return .single(nil)
}
let time = engine.calls.requestStreamState(dataSource: source, callId: info.id, accessHash: info.accessHash)
|> map { state -> Int64? in
guard let state else {
return nil
}
return state.channels.first?.latestTimestamp
}
return time
|> mapToSignal { latestTimestamp -> Signal<Data?, NoError> in
guard let latestTimestamp else {
return .single(nil)
}
let durationMilliseconds: Int64 = 32000
let bufferOffset: Int64 = 1 * durationMilliseconds
let timestampId = latestTimestamp - bufferOffset
return engine.calls.getVideoBroadcastPart(dataSource: source, callId: info.id, accessHash: info.accessHash, timestampIdMilliseconds: timestampId, durationMilliseconds: durationMilliseconds, channelId: 2, quality: 0)
|> mapToSignal { result -> Signal<Data?, NoError> in
switch result.status {
case let .data(data):
return .single(data)
case .notReady, .resyncNeeded, .rejoinNeeded:
return .single(nil)
}
}
}
}
|> deliverOnMainQueue).start(next: { [weak self] data in
guard let self, let data else {
return
}
let _ = self
let _ = data
})*/
})
let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId))
self.infoDisposable = (combineLatest(queue: .mainQueue(), call.state, call.members, callPeer)
|> deliverOnMainQueue).start(next: { [weak self] state, members, callPeer in
guard let strongSelf = self, let members = members, let callPeer = callPeer else {
return
}
var updated = false
// TODO: remove debug timer
// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
var shouldReplaceNoViewersWithOne: Bool { true }
let membersCount = members.totalCount // Int.random(in: 0..<10000000) //
strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(membersCount, 1) : membersCount) { [weak strongSelf] latestCount in
let _ = members.totalCount
guard let strongSelf = strongSelf else { return }
var updated = false
let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount)
if strongSelf.originInfo != originInfo {
strongSelf.originInfo = originInfo
updated = true
}
if updated {
strongSelf.updated(transition: .immediate)
}
}
// }.fire()
if state.canManageCall != strongSelf.canManageCall {
strongSelf.canManageCall = state.canManageCall
updated = true
}
if strongSelf.peerTitle != callPeer.debugDisplayTitle {
strongSelf.peerTitle = callPeer.debugDisplayTitle
updated = true
}
strongSelf.chatPeer = callPeer._asPeer()
if strongSelf.callTitle != state.title {
strongSelf.callTitle = state.title
updated = true
}
if strongSelf.recordingStartTimestamp != state.recordingStartTimestamp {
strongSelf.recordingStartTimestamp = state.recordingStartTimestamp
updated = true
}
if updated {
strongSelf.updated(transition: .immediate)
}
})
self.isVisibleInHierarchyDisposable = (call.accountContext.sharedContext.applicationBindings.applicationInForeground
|> deliverOnMainQueue).start(next: { [weak self] inForeground in
guard let strongSelf = self else {
return
}
if strongSelf.isVisibleInHierarchy != inForeground {
strongSelf.isVisibleInHierarchy = inForeground
strongSelf.updated(transition: .immediate)
if inForeground {
Queue.mainQueue().after(0.5, {
guard let strongSelf = self, strongSelf.isVisibleInHierarchy else {
return
}
strongSelf.deactivatePictureInPictureIfVisible.invoke(Void())
})
} else {
// MARK: TODO: fullscreen ui toggle
}
}
})
}
deinit {
self.stateDisposable?.dispose()
self.infoDisposable?.dispose()
self.isVisibleInHierarchyDisposable?.dispose()
}
func toggleDisplayUI() {
self.displayUI = !self.displayUI
self.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .easeInOut)))
}
func cancelScheduledDismissUI() {
self.scheduledDismissUITimer?.invalidate()
self.scheduledDismissUITimer = nil
}
func scheduleDismissUI() {
if self.scheduledDismissUITimer == nil {
self.scheduledDismissUITimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.scheduledDismissUITimer = nil
if strongSelf.displayUI {
strongSelf.toggleDisplayUI()
}
}, queue: .mainQueue())
self.scheduledDismissUITimer?.start()
}
}
func updateDismissOffset(value: CGFloat, interactive: Bool) {
self.dismissOffset = value
if interactive {
self.updated(transition: .immediate)
} else {
self.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut)))
}
}
}
public func makeState() -> State {
return State(call: self.call)
}
public static var body: Body {
let background = Child(Rectangle.self)
let dismissTapComponent = Child(Rectangle.self)
let video = Child(MediaStreamVideoComponent.self)
let sheet = Child(StreamSheetComponent.self)
// let fullscreenOverlay = Child(StreamSheetComponent.self)
let topItem = Child(environment: Empty.self)
// let viewerCounter = Child(ParticipantsComponent.self)
let fullscreenBottomItem = Child(environment: Empty.self)
let buttonsRow = Child(environment: Empty.self)
let activatePictureInPicture = StoredActionSlot(Action<Void>.self)
let deactivatePictureInPicture = StoredActionSlot(Void.self)
let moreButtonTag = GenericComponentViewTag()
let moreAnimationTag = GenericComponentViewTag()
return { context in
let canEnforceOrientation = UIDevice.current.model != "iPad"
var forceFullScreenInLandscape: Bool { canEnforceOrientation && true }
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
if environment.isVisible {
} else {
context.state.dismissOffset = 0.0
}
let background = background.update(
component: Rectangle(color: .black.withAlphaComponent(0.0)),
availableSize: context.availableSize,
transition: context.transition
)
let call = context.component.call
let state = context.state
let controller = environment.controller
context.state.deactivatePictureInPictureIfVisible.connect {
guard let controller = controller() else {
return
}
if controller.view.window == nil {
if state.didAutoDismissForPiP {
state.updated(transition: .easeInOut(duration: 3))
deactivatePictureInPicture.invoke(Void())
// call.accountContext.sharedContext.mainWindow?.inCallNavigate?()
}
return
}
state.updated(transition: .easeInOut(duration: 3))
deactivatePictureInPicture.invoke(Void())
}
let isFullscreen: Bool
let isLandscape = context.availableSize.width > context.availableSize.height
// Always fullscreen in landscape
// TODO: support landscape sheet (wrap in scrollview, video size same as portrait)
if forceFullScreenInLandscape && isLandscape && !state.isFullscreen {
state.isFullscreen = true
isFullscreen = true
} else if !isLandscape && state.isFullscreen && canEnforceOrientation {
state.prevFullscreenOrientation = nil
state.isFullscreen = false
isFullscreen = false
} else {
isFullscreen = state.isFullscreen
}
let videoInset: CGFloat
if !isFullscreen {
videoInset = 16
} else {
videoInset = 0
}
let videoHeight: CGFloat = forceFullScreenInLandscape
? (context.availableSize.width - videoInset * 2) / 16 * 9
: context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9
let bottomPadding = 32.0 + environment.safeInsets.bottom
let requiredSheetHeight: CGFloat = isFullscreen
? context.availableSize.height
: (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding + 8)
let safeAreaTopInView: CGFloat
if #available(iOS 16.0, *) {
safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0
} else {
safeAreaTopInView = context.view.safeAreaInsets.top
}
let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30
var dragOffset = context.state.dismissOffset
if isFullyDragged {
dragOffset = max(context.state.dismissOffset, requiredSheetHeight - context.availableSize.height + safeAreaTopInView)
}
let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - requiredSheetHeight + dragOffset)
let dismissTapComponent = dismissTapComponent.update(
component: Rectangle(color: .red.withAlphaComponent(0)),
availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight),
transition: context.transition
)
// (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen
// (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden()
let video = video.update(
component: MediaStreamVideoComponent(
call: context.component.call,
hasVideo: context.state.hasVideo,
isVisible: environment.isVisible && context.state.isVisibleInHierarchy,
isAdmin: context.state.canManageCall,
peerTitle: context.state.peerTitle,
isFullscreen: isFullscreen,
videoLoading: context.state.videoStalled,
callPeer: context.state.chatPeer,
activatePictureInPicture: activatePictureInPicture,
deactivatePictureInPicture: deactivatePictureInPicture,
bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in
guard let call = call else {
completed()
return
}
call.accountContext.sharedContext.mainWindow?.inCallNavigate?()
completed()
},
pictureInPictureClosed: { [weak call] in
let _ = call?.leave(terminateIfPossible: false)
},
onVideoSizeRetrieved: { [weak state] size in
state?.videoSize = size
},
onVideoPlaybackLiveChange: { [weak state] isLive in
guard let state else { return }
let wasLive = !state.videoStalled
if isLive != wasLive {
state.videoStalled = !isLive
state.updated()
}
}
),
availableSize: context.availableSize,
transition: context.transition
)
var navigationRightItems: [AnyComponentWithIdentity<Empty>] = []
// let videoIsPlayable = context.state.videoIsPlayable
// if state.wantsPiP && state.hasVideo {
// state.wantsPiP = false
// DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
// activatePictureInPicture.invoke(Action {
// guard let controller = controller() as? MediaStreamComponentController else {
// return
// }
// controller.dismiss(closing: false, manual: true)
// })
// }
// }
if context.state.isPictureInPictureSupported /*, context.state.videoIsPlayable*/ {
navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: .white.withAlphaComponent(0.08),
size: CGSize(width: 32.0, height: 32.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent(
name: "Call/pip",
tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6)
)))
]
)),
action: { [weak state] in
guard let state, state.hasVideo else {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
state?.wantsPiP = true
controller.dismiss(closing: false, manual: true)
return
}
activatePictureInPicture.invoke(Action {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.dismiss(closing: false, manual: true)
if state.displayUI {
state.toggleDisplayUI()
}
})
}
).minSize(CGSize(width: 44.0, height: 44.0)))))
}
var topLeftButton: AnyComponent<Empty>?
if context.state.canManageCall {
let whiteColor = UIColor(white: 1.0, alpha: 1.0)
topLeftButton = AnyComponent(Button(
content: AnyComponent(ZStack([
AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle(
fillColor: .white.withAlphaComponent(0.08),
size: CGSize(width: 32.0, height: 32.0)
))),
AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent(
animation: LottieAnimationComponent.AnimationItem(
name: "anim_profilemore",
mode: .still(position: .begin)
),
colors: [
"Point 2.Group 1.Fill 1": whiteColor,
"Point 3.Group 1.Fill 1": whiteColor,
"Point 1.Group 1.Fill 1": whiteColor
],
size: CGSize(width: 32.0, height: 32.0)
).tagged(moreAnimationTag))),
])),
action: { [weak call, weak state] in
guard let call = call, let state = state else {
return
}
guard let controller = controller() as? MediaStreamComponentController else {
return
}
guard let anchorView = controller.node.hostView.findTaggedView(tag: moreButtonTag) else {
return
}
if let animationView = controller.node.hostView.findTaggedView(tag: moreAnimationTag) as? LottieAnimationComponent.View {
animationView.playOnce()
}
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak call, weak controller, weak state] _, dismissWithResult in
guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else {
return
}
let initialTitle = state.callTitle ?? ""
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
let title: String = presentationData.strings.LiveStream_EditTitle
let text: String = presentationData.strings.LiveStream_EditTitleText
let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in
guard let call = call else {
return
}
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
if let title = title, title != initialTitle {
call.updateTitle(title)
let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string
let _ = text
//strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false })
}
})
controller.present(editController, in: .window(.root))
dismissWithResult(.default)
})))
if let recordingStartTimestamp = state.recordingStartTimestamp {
items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, dismissWithResult in
guard let call = call, let controller = controller else {
return
}
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
let alertController = textAlertController(context: call.accountContext, forceTheme: defaultDarkPresentationTheme, title: nil, text: presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_StopRecordingStop, action: { [weak call, weak controller] in
guard let call = call, let controller = controller else {
return
}
call.setShouldBeRecording(false, title: nil, videoOrientation: nil)
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
let text = presentationData.strings.LiveStream_RecordingSaved
let _ = text
let _ = controller
/*strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in
if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController {
let context = strongSelf.context
strongSelf.controller?.dismiss(completion: {
Queue.mainQueue().justDispatch {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(context.account.peerId), keepStack: .always, purposefulAction: {}, peekData: nil))
}
})
return true
}
return false
})*/
})])
controller.present(alertController, in: .window(.root))
dismissWithResult(.dismissWithoutContent)
}), false))
} else {
let text = presentationData.strings.LiveStream_StartRecording
items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in
return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor)
}, action: { [weak call, weak state, weak controller] _, f in
f(.dismissWithoutContent)
guard let call = call, let state = state, let _ = state.chatPeer, let controller = controller else {
return
}
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
let title: String
let text: String
let placeholder: String = presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo
title = presentationData.strings.LiveStream_StartRecordingTitle
text = presentationData.strings.LiveStream_StartRecordingTextVideo
let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak call, weak controller] title in
guard let call = call, let controller = controller else {
return
}
let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 }
if let title = title {
call.setShouldBeRecording(true, title: title, videoOrientation: false)
let text = presentationData.strings.LiveStream_RecordingStarted
let _ = text
let _ = controller
call.playTone(.recordingStarted)
}
})
controller.present(editController, in: .window(.root))
})))
}
let credentialsPromise = Promise<GroupCallStreamCredentials>()
credentialsPromise.set(call.accountContext.engine.calls.getGroupCallStreamCredentials(peerId: call.peerId, revokePreviousCredentials: false) |> `catch` { _ -> Signal<GroupCallStreamCredentials, NoError> in return .never() })
items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_ViewCredentials, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor, backgroundColor: nil)
}, action: { [weak call, weak controller] _, a in
guard let call = call, let controller = controller else {
return
}
controller.push(CreateExternalMediaStreamScreen(context: call.accountContext, peerId: call.peerId, credentialsPromise: credentialsPromise, mode: .view))
a(.default)
})))
items.append(.action(ContextMenuActionItem(id: nil, text: /*presentationData.strings.VoiceChat_StopRecordingStop*/"Stop Live Stream", textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil)
}, action: { [weak call] _, a in
guard let call = call else {
return
}
let alertController = textAlertController(
context: call.accountContext,
forceTheme: defaultDarkPresentationTheme,
title: presentationData.strings.LiveStream_EndConfirmationTitle,
text: presentationData.strings.LiveStream_EndConfirmationText,
actions: [
TextAlertAction(
type: .genericAction,
title: presentationData.strings.Common_Cancel,
action: {}
),
TextAlertAction(
type: .destructiveAction,
title: presentationData.strings.VoiceChat_EndConfirmationEnd,
action: { [weak call] in
guard let call = call else {
return
}
let _ = call.leave(terminateIfPossible: true).start()
})
])
controller.present(alertController, in: .window(.root))
a(.default)
})))
final class ReferenceContentSource: ContextReferenceContentSource {
private let sourceView: UIView
init(sourceView: UIView) {
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
let contextController = ContextController(account: call.accountContext.account, presentationData: presentationData.withUpdated(theme: defaultDarkPresentationTheme), source: .reference(ReferenceContentSource(sourceView: anchorView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil)
/*contextController.passthroughTouchEvent = { sourceView, point in
guard let strongSelf = self else {
return .ignore
}
let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil)
guard let localResult = strongSelf.hitTest(localPoint, with: nil) else {
return .dismiss(consume: true, result: nil)
}
var testView: UIView? = localResult
while true {
if let testViewValue = testView {
if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton {
node.isUserInteractionEnabled = false
DispatchQueue.main.async {
node.isUserInteractionEnabled = true
}
return .dismiss(consume: false, result: nil)
} else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode {
node.brieflyDisableTouchActions()
return .dismiss(consume: false, result: nil)
} else {
testView = testViewValue.superview
}
} else {
break
}
}
return .dismiss(consume: true, result: nil)
}*/
controller.presentInGlobalOverlay(contextController)
}
).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag))//)//)
}
let navigationComponent = NavigationBarComponent(
topInset: environment.statusBarHeight,
sideInset: environment.safeInsets.left,
backgroundVisible: isFullscreen,
leftItem: topLeftButton,
rightItems: navigationRightItems,
centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable))
)
if context.state.storedIsFullscreen != isFullscreen {
context.state.storedIsFullscreen = isFullscreen
if isFullscreen {
context.state.scheduleDismissUI()
} else {
context.state.cancelScheduledDismissUI()
}
}
var infoItem: AnyComponent<Empty>?
if let originInfo = context.state.originInfo {
infoItem = AnyComponent(OriginInfoComponent(
memberCount: originInfo.memberCount
))
}
let availableSize = context.availableSize
let safeAreaTop = safeAreaTopInView
let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in
guard let state = state else {
return
}
switch panState {
case .began:
state.initialOffset = state.dismissOffset
case let .updated(offset):
state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true)
case let .ended(velocity):
if velocity.y > 200.0 {
if state.isFullscreen {
state.isFullscreen = false
state.prevFullscreenOrientation = UIDevice.current.orientation
state.dismissOffset = 0.0// updateDismissOffset(value: 0.0, interactive: false)
if canEnforceOrientation, let controller = controller() as? MediaStreamComponentController {
controller.updateOrientation(orientation: .portrait)
} else {
state.updated(transition: .easeInOut(duration: 0.25))
}
} else {
if isFullyDragged || state.initialOffset != 0 {
state.updateDismissOffset(value: 0.0, interactive: false)
// state.updateDismissOffset(value: 0.0, interactive: false)
} else {
if state.isPictureInPictureSupported {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
if state.hasVideo {
activatePictureInPicture.invoke(Action {
controller.dismiss(closing: false, manual: true)
if state.displayUI {
state.toggleDisplayUI()
}
})
} else {
state.wantsPiP = true
controller.dismiss(closing: false, manual: true)
}
} else {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.dismiss(closing: false, manual: true)
}
// let _ = call.leave(terminateIfPossible: false)
}
}
} else {
if isFullyDragged {
state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false)
} else {
if velocity.y < -200 {
// Expand
state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false)
} else {
state.updateDismissOffset(value: 0.0, interactive: false)
}
}
}
}
}
context.add(background
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
.gesture(.tap { [weak state] in
guard let state = state, state.isFullscreen else {
return
}
state.toggleDisplayUI()
})
.gesture(.pan { panState in
onPanGesture(panState)
})
)
context.add(dismissTapComponent
.position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2))
.gesture(.tap {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.dismiss(closing: false, manual: true)
// _ = call.leave(terminateIfPossible: false)
})
.gesture(.pan(onPanGesture))
)
if !isFullscreen || state.isFullscreen {
let imageRenderScale = UIScreen.main.scale
let bottomComponent = AnyComponent(ButtonsRowComponent(
bottomInset: environment.safeInsets.bottom,
sideInset: environment.safeInsets.left,
leftItem: AnyComponent(Button(
content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent(
gradientColors: [
UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor
// UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor,
// UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor
],
image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white),
// TODO: localize:
title: "share")),
action: {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.presentShare()
}
).minSize(CGSize(width: 65, height: 80))),
rightItem: AnyComponent(Button(
content: AnyComponent(RoundGradientButtonComponent(
gradientColors: [
UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor
// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor,
// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor
],
image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in
context.translateBy(x: size.width / 2, y: size.height / 2)
context.scaleBy(x: 0.4, y: 0.4)
context.translateBy(x: -size.width / 2, y: -size.height / 2)
let imageColor = UIColor.white
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let lineWidth: CGFloat = size.width / 7
context.setLineWidth(lineWidth - UIScreenPixel)
context.setLineCap(.round)
context.setStrokeColor(imageColor.cgColor)
context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel))
context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel))
context.strokePath()
context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel))
context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel))
context.strokePath()
}),
title: "leave"
)),
action: { [weak call] in
let _ = call?.leave(terminateIfPossible: false)
}
).minSize(CGSize(width: 44.0, height: 44.0))),
centerItem: AnyComponent(Button(
content: AnyComponent(RoundGradientButtonComponent(
gradientColors: [
UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor
// UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor,
// UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor
],
image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in
let imageColor = UIColor.white
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel)
context.setLineCap(.round)
context.setStrokeColor(imageColor.cgColor)
let lineSide = size.width / 5
let centerOffset = size.width / 20
context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2))
context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide))
context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide))
context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide))
context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset))
context.strokePath()
context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2))
context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide))
context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide))
context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide))
context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset))
context.strokePath()
}),
title: "expand"
)),
action: { [weak state] in
guard let state = state else { return }
// guard state.videoIsPlayable else {
// state.isFullscreen = false
// return
// }
if let controller = controller() as? MediaStreamComponentController {
// guard let _ = state.videoSize else { return }
state.isFullscreen.toggle()
if state.isFullscreen {
state.dismissOffset = 0.0
// if size.width > size.height {
let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation
switch currentOrientation {
case .landscapeLeft:
controller.updateOrientation(orientation: .landscapeRight)
case .landscapeRight:
controller.updateOrientation(orientation: .landscapeLeft)
default:
controller.updateOrientation(orientation: .landscapeRight)
}
// } else {
// controller.updateOrientation(orientation: .portrait)
// }
} else {
state.prevFullscreenOrientation = UIDevice.current.orientation
// TODO: Check and mind current device orientation
controller.updateOrientation(orientation: .portrait)
}
if !canEnforceOrientation {
state.updated(transition: .easeInOut(duration: 0.25))
}
}
}
).minSize(CGSize(width: 44.0, height: 44.0)))
))
let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight)
let topOffset: CGFloat = isFullscreen
? max(context.state.dismissOffset, 0)
: (context.availableSize.height - requiredSheetHeight + dragOffset)
let sheet = sheet.update(
component: StreamSheetComponent(
topComponent: AnyComponent(navigationComponent),
bottomButtonsRow: bottomComponent,
topOffset: topOffset,
sheetHeight: sheetHeight,
backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor),
bottomPadding: bottomPadding,
participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()!
isFullyExtended: isFullyDragged,
deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1,
videoHeight: videoHeight,
isFullscreen: isFullscreen,
fullscreenTopComponent: AnyComponent(navigationComponent),
fullscreenBottomComponent: bottomComponent
),
availableSize: context.availableSize,
transition: context.transition
)
// let sheetOffset: CGFloat = context.availableSize.height - requiredSheetHeight + dragOffset
// let sheetPosition = sheetOffset + requiredSheetHeight / 2
// Sheet underneath the video when in modal sheet
context.add(sheet
.position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2))
)
//
//
//
var availableWidth: CGFloat { context.availableSize.width }
var contentHeight: CGFloat { 44.0 }
// print(topItem)
// let size = context.availableSize
let topItem = topItem.update(
component: AnyComponent(navigationComponent),
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
let fullScreenToolbarComponent = AnyComponent(ToolbarComponent(
bottomInset: environment.safeInsets.bottom,
sideInset: environment.safeInsets.left,
leftItem: AnyComponent(Button(
content: AnyComponent(BundleIconComponent(
name: "Chat/Input/Accessory Panels/MessageSelectionForward",
tintColor: .white
)),
action: {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.presentShare()
}
).minSize(CGSize(width: 64.0, height: 80))),
rightItem: /*state.hasVideo ?*/ AnyComponent(Button(
content: AnyComponent(BundleIconComponent(
name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen",
tintColor: .white
)),
action: {
state.isFullscreen = false
state.prevFullscreenOrientation = UIDevice.current.orientation
if let controller = controller() as? MediaStreamComponentController {
if canEnforceOrientation {
controller.updateOrientation(orientation: .portrait)
} else {
state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3))
}
}
}
).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/,
centerItem: infoItem
))
let buttonsRow = buttonsRow.update(
component: bottomComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
let fullscreenBottomItem = fullscreenBottomItem.update(
component: fullScreenToolbarComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
let videoPos: CGFloat
if isFullscreen {
videoPos = context.availableSize.height / 2 + dragOffset
} else {
videoPos = /*sheetPosition - requiredSheetHeight / 2*/topOffset + 28.0 + 28.0 + videoHeight / 2 // + 50 + 12
}
context.add(video
.position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos))
)
context.add(topItem
.position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0)))
.opacity((!isFullscreen || state.displayUI) ? 1 : 0)
.gesture(.pan { panState in
onPanGesture(panState)
})
// .animation(key: "position")
)
context.add(buttonsRow
.opacity(isFullscreen ? 0 : 1)
// .animation(key: "opacity")
.position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50 / 2 + topOffset - bottomPadding))
)
context.add(fullscreenBottomItem
.opacity((isFullscreen && state.displayUI) ? 1 : 0)
// .animation(key: "opacity")
.position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0))
)
//
//
//
} else {
/*let fullScreenToolbarComponent = AnyComponent(ToolbarComponent(
bottomInset: environment.safeInsets.bottom,
sideInset: environment.safeInsets.left,
leftItem: AnyComponent(Button(
content: AnyComponent(BundleIconComponent(
name: "Chat/Input/Accessory Panels/MessageSelectionForward",
tintColor: .white
)),
action: {
guard let controller = controller() as? MediaStreamComponentController else {
return
}
controller.presentShare()
}
).minSize(CGSize(width: 64.0, height: 80))),
rightItem: /*state.hasVideo ?*/ AnyComponent(Button(
content: AnyComponent(BundleIconComponent(
name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen",
tintColor: .white
)),
action: {
state.isFullscreen = false
state.prevFullscreenOrientation = UIDevice.current.orientation
if let controller = controller() as? MediaStreamComponentController {
if canEnforceOrientation {
controller.updateOrientation(orientation: .portrait)
} else {
state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3))
}
}
}
).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/,
centerItem: infoItem
))
let fullScreenOverlayComponent = sheet.update(
component: StreamSheetComponent(
topComponent: AnyComponent(navigationComponent),
bottomButtonsRow: fullScreenToolbarComponent,
topOffset: /*context.availableSize.height - sheetHeight +*/ max(context.state.dismissOffset, 0),
sheetHeight: context.availableSize.height,// max(sheetHeight - context.state.dismissOffset, sheetHeight),
backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor),
bottomPadding: 0,
participantsCount: -1,
isFullyExtended: isFullyDragged,
deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1,
videoHeight: videoHeight,
isFullscreen: isFullscreen,
fullscreenTopComponent: AnyComponent(navigationComponent),
fullscreenBottomComponent: fullScreenToolbarComponent
),
availableSize: context.availableSize,
transition: context.transition
)
context.add(fullScreenOverlayComponent
.position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2))
.opacity(state.displayUI ? 1 : 0)
)
context.add(video
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset)
))*/
}
// TODO: add variable isPictureInPictureActive
// let isPictureInPictureActive = state.isPictureInPictureSupported && state.videoIsPlayable && state.hasVideo
// if !state.isVisibleInHierarchy && isPictureInPictureActive && state.isFullscreen {
// if !state.didAutoDismissForPiP {
// state.didAutoDismissForPiP = true
// (controller() as? MediaStreamComponentController)?.dismiss(closing: false, manual: true)
// }
// } else {
// state.didAutoDismissForPiP = false
// }
return context.availableSize
}
}
}
public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController {
private let context: AccountContext
public let call: PresentationGroupCall
public private(set) var currentOverlayController: VoiceChatOverlayController? = nil
public var parentNavigationController: NavigationController?
public var onViewDidAppear: (() -> Void)?
public var onViewDidDisappear: (() -> Void)?
private var initialOrientation: UIInterfaceOrientation?
private let inviteLinksPromise = Promise<GroupCallInviteLinks?>(nil)
public init(call: PresentationGroupCall) {
self.context = call.accountContext
self.call = call
super.init(context: call.accountContext, component: MediaStreamComponent(call: call as! PresentationGroupCallImpl), navigationBarAppearance: .none)
self.statusBar.statusBarStyle = .White
self.view.disablesInteractiveModalDismiss = true
self.inviteLinksPromise.set(.single(nil)
|> then(call.inviteLinks))
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.async {
self.onViewDidAppear?()
}
if let view = self.node.hostView.findTaggedView(tag: MediaStreamVideoComponent.View.Tag()) as? MediaStreamVideoComponent.View {
view.expandFromPictureInPicture()
}
self.view.clipsToBounds = true
self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in
})
self.view.layer.allowsGroupOpacity = true
self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.view.layer.allowsGroupOpacity = false
})
self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3)
if backgroundDimView.superview == nil {
guard let superview = view.superview else { return }
superview.insertSubview(backgroundDimView, belowSubview: view)
}
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
DispatchQueue.main.async {
self.onViewDidDisappear?()
}
}
override public func viewDidLoad() {
super.viewDidLoad()
// TODO: replace with actual color
backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3)
self.view.clipsToBounds = false
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
override public func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let dimViewSide: CGFloat = max(view.bounds.width, view.bounds.height)
backgroundDimView.frame = .init(x: view.bounds.midX - dimViewSide / 2, y: -view.bounds.height * 3, width: dimViewSide, height: view.bounds.height * 4)
}
public func dismiss(closing: Bool, manual: Bool) {
self.dismiss(completion: nil)
}
let backgroundDimView = UIView()
override public func dismiss(completion: (() -> Void)? = nil) {
self.view.layer.allowsGroupOpacity = true
// self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in
//
// })
self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false)
self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in
guard let strongSelf = self else {
completion?()
return
}
strongSelf.view.layer.allowsGroupOpacity = false
strongSelf.dismissImpl(completion: completion)
})
}
private func dismissImpl(completion: (() -> Void)? = nil) {
super.dismiss(completion: completion)
}
func updateOrientation(orientation: UIInterfaceOrientation) {
if self.initialOrientation == nil {
self.initialOrientation = orientation == .portrait ? .landscapeRight : .portrait
} else if self.initialOrientation == orientation {
self.initialOrientation = nil
}
self.call.accountContext.sharedContext.applicationBindings.forceOrientation(orientation)
}
func presentShare() {
let _ = (self.inviteLinksPromise.get()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] inviteLinks in
guard let strongSelf = self else {
return
}
let _ = (strongSelf.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId),
TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId)
)
|> map { peer, exportedInvitation -> GroupCallInviteLinks? in
if let inviteLinks = inviteLinks {
return inviteLinks
} else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty {
return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil)
} else if let link = exportedInvitation?.link {
return GroupCallInviteLinks(listenerLink: link, speakerLink: nil)
}
return nil
}
|> deliverOnMainQueue).start(next: { links in
guard let strongSelf = self else {
return
}
if let links = links {
strongSelf.presentShare(links: links)
}
})
})
}
func presentShare(links inviteLinks: GroupCallInviteLinks) {
let formatSendTitle: (String) -> String = { string in
var string = string
if string.contains("[") && string.contains("]") {
if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") {
string.removeSubrange(startIndex ... endIndex)
}
} else {
string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,."))
}
return string
}
let _ = formatSendTitle
let _ = (combineLatest(queue: .mainQueue(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.call.state |> take(1))
|> deliverOnMainQueue).start(next: { [weak self] peer, callState in
if let strongSelf = self {
var inviteLinks = inviteLinks
if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let defaultParticipantMuteState = callState.defaultParticipantMuteState {
let isMuted = defaultParticipantMuteState == .muted
if !isMuted {
inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil)
}
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
var segmentedValues: [ShareControllerSegmentedValue]?
segmentedValues = nil
let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: defaultDarkPresentationTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink)
shareController.completed = { [weak self] peerIds in
if let strongSelf = self {
let _ = (strongSelf.context.engine.data.get(
EngineDataList(
peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init)
)
)
|> deliverOnMainQueue).start(next: { [weak self] peerList in
if let strongSelf = self {
let peers = peerList.compactMap { $0 }
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let text: String
var isSavedMessages = false
if peers.count == 1, let peer = peers.first {
isSavedMessages = peer.id == strongSelf.context.account.peerId
let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string
} else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last {
let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string
} else if let peer = peers.first {
let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)
text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string
} else {
text = ""
}
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current)
}
})
}
}
shareController.actionCompleted = {
if let strongSelf = self {
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
}
}
strongSelf.present(shareController, in: .window(.root))
}
})
}
}
// MARK: - Subcomponents
final class StreamTitleComponent: Component {
let text: String
let isRecording: Bool
let isActive: Bool
init(text: String, isRecording: Bool, isActive: Bool) {
self.text = text
self.isRecording = isRecording
self.isActive = isActive
}
static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool {
if lhs.text != rhs.text {
return false
}
if lhs.isRecording != rhs.isRecording {
return false
}
if lhs.isActive != rhs.isActive {
return false
}
return false
}
final class LiveIndicatorView: UIView {
private let label = UILabel()
private let stalledAnimatedGradient = CAGradientLayer()
private var wasLive = false
var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 }
override init(frame: CGRect = .zero) {
super.init(frame: frame)
addSubview(label)
let liveString = NSAttributedString(
string: "LIVE",
attributes: [
.font: Font.with(size: 11.0, design: .round, weight: .bold),
.paragraphStyle: {
let style = NSMutableParagraphStyle()
style.alignment = .center
return style
}(),
.foregroundColor: UIColor.white,
.kern: -0.6
]
)
label.attributedText = liveString
// label.text = "LIVE"
// label.font = Font.with(size: 11.0, design: .round, weight: .bold)// .systemFont(ofSize: 12, weight: .semibold)
// label.textAlignment = .center
// label.textColor = .white
layer.addSublayer(stalledAnimatedGradient)
self.clipsToBounds = true
// if #available(iOS 13.0, *) {
// self.layer.cornerCurve = .continuous
// }
toggle(isLive: false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
label.frame = bounds
stalledAnimatedGradient.frame = bounds
self.layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
func toggle(isLive: Bool) {
if isLive {
if !wasLive {
wasLive = true
let anim = CAKeyframeAnimation(keyPath: "transform.scale")
anim.values = [1.0, 1.12, 0.9, 1.0]
anim.keyTimes = [0, 0.5, 0.8, 1]
anim.duration = 0.4
self.layer.add(anim, forKey: "transform")
UIView.animate(withDuration: 0.15, animations: {
self.toggle(isLive: true) })
return
}
self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1)
stalledAnimatedGradient.opacity = 0
stalledAnimatedGradient.removeAllAnimations()
} else {
if wasLive {
wasLive = false
UIView.animate(withDuration: 0.3) {
self.toggle(isLive: false)
}
return
}
self.backgroundColor = UIColor(white: 0.36, alpha: 1)
stalledAnimatedGradient.opacity = 1
}
wasLive = isLive
}
}
public final class View: UIView {
private var indicatorView: UIImageView?
let liveIndicatorView = LiveIndicatorView()
let titleLabel = UILabel()
private var titleFadeLayer = CALayer()
private let trackingLayer: HierarchyTrackingLayer
private func updateTitleFadeLayer(textFrame: CGRect) {
// titleLabel.backgroundColor = .red
guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }),
textBounds.width > textFrame.width
else {
titleLabel.layer.mask = nil
titleLabel.frame = textFrame
self.titleLabel.textAlignment = .center
return
}
var isRTL: Bool = false
if let string = titleLabel.attributedText {
let coreTextLine = CTLineCreateWithAttributedString(string)
let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray
if glyphRuns.count > 0 {
let run = glyphRuns[0] as! CTRun
if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
}
}
let gradientInset: CGFloat = 0
let gradientRadius: CGFloat = 50
let solidPartLayer = CALayer()
solidPartLayer.backgroundColor = UIColor.blue.cgColor
// let containerWidth: CGFloat = textFrame.width
let availableWidth: CGFloat = textFrame.width - gradientRadius
let extraSpace: CGFloat = 100
if isRTL {
// let adjustForRTL: CGFloat = 12
// let safeSolidWidth: CGFloat = containerWidth + adjustForRTL
// let widthDiff = min(textFrame.width - containerWidth)
solidPartLayer.frame = CGRect(
origin: CGPoint(x: textFrame.width + extraSpace - availableWidth, y: 0),
size: CGSize(width: availableWidth, height: textFrame.height))
self.titleLabel.textAlignment = .right
titleLabel.frame = CGRect(x: textFrame.minX - extraSpace, y: textFrame.minY, width: textFrame.width + extraSpace, height: textFrame.height)
} else {
self.titleLabel.textAlignment = .left
solidPartLayer.frame = CGRect(
origin: .zero,
size: CGSize(width: availableWidth, height: textFrame.height))
titleLabel.frame = CGRect(origin: textFrame.origin, size: CGSize(width: textFrame.width + extraSpace, height: textFrame.height))
}
titleFadeLayer.removeFromSuperlayer()
titleFadeLayer = CALayer()
titleFadeLayer.addSublayer(solidPartLayer)
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor]
if isRTL {
gradientLayer.startPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: textFrame.height)
} else {
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: textFrame.height)
}
titleFadeLayer.addSublayer(gradientLayer)
titleFadeLayer.masksToBounds = false
titleFadeLayer.frame = titleLabel.bounds
// titleLabel.layer.addSublayer(titleFadeLayer)
// titleFadeLayer.opacity = 0.4
titleLabel.layer.mask = titleFadeLayer
// titleLabel.backgroundColor = .green
}
override init(frame: CGRect) {
self.trackingLayer = HierarchyTrackingLayer()
super.init(frame: frame)
self.addSubview(self.titleLabel)
self.addSubview(self.liveIndicatorView)
self.trackingLayer.didEnterHierarchy = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.updateIndicatorAnimation()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateIndicatorAnimation() {
guard let indicatorView = self.indicatorView else {
return
}
if indicatorView.layer.animation(forKey: "blink") == nil {
let animation = CAKeyframeAnimation(keyPath: "opacity")
animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber]
animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber]
animation.duration = 0.7
animation.autoreverses = true
animation.repeatCount = Float.infinity
indicatorView.layer.add(animation, forKey: "recording")
}
}
func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize {
let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth
let liveIndicatorHeight: CGFloat = 20.0
let currentText = self.titleLabel.text
if currentText != component.text {
if currentText?.isEmpty == false {
UIView.transition(with: self.titleLabel, duration: 0.2) {
self.titleLabel.text = component.text
self.titleLabel.invalidateIntrinsicContentSize()
}
} else {
self.titleLabel.text = component.text
self.titleLabel.invalidateIntrinsicContentSize()
}
}
self.titleLabel.font = Font.semibold(17.0)
self.titleLabel.textColor = .white
self.titleLabel.numberOfLines = 1
let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height)
// let textSize = self.textView.update(
// transition: .immediate,
// component: AnyComponent(Text(
// text: component.text,
// font: Font.semibold(17.0),
// color: .white
// )),
// environment: {},
// containerSize: CGSize(width: availableSize.width - 4 - liveIndicatorWidth, height: availableSize.height)
// )
if component.isRecording {
if self.indicatorView == nil {
let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil))
self.addSubview(indicatorView)
self.indicatorView = indicatorView
self.updateIndicatorAnimation()
}
} else {
if let indicatorView = self.indicatorView {
self.indicatorView = nil
indicatorView.removeFromSuperview()
}
}
let sideInset: CGFloat = 20.0
let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height)
let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize)
// self.textView.frame = textFrame
if currentText?.isEmpty == false {
UIView.transition(with: self.titleLabel, duration: 0.2) {
self.updateTitleFadeLayer(textFrame: textFrame)
}
} else {
self.updateTitleFadeLayer(textFrame: textFrame)
}
liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight))
self.liveIndicatorView.toggle(isLive: component.isActive)
if let indicatorView = self.indicatorView, let image = indicatorView.image {
indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size)
}
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
private final class NavigationBarComponent: CombinedComponent {
let topInset: CGFloat
let sideInset: CGFloat
let leftItem: AnyComponent<Empty>?
let rightItems: [AnyComponentWithIdentity<Empty>]
let centerItem: AnyComponent<Empty>?
let backgroundVisible: Bool
init(
topInset: CGFloat,
sideInset: CGFloat,
backgroundVisible: Bool,
leftItem: AnyComponent<Empty>?,
rightItems: [AnyComponentWithIdentity<Empty>],
centerItem: AnyComponent<Empty>?
) {
self.topInset = 0 // topInset
self.sideInset = sideInset
self.backgroundVisible = backgroundVisible
self.leftItem = leftItem
self.rightItems = rightItems
self.centerItem = centerItem
}
static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool {
if lhs.topInset != rhs.topInset {
return false
}
if lhs.sideInset != rhs.sideInset {
return false
}
if lhs.leftItem != rhs.leftItem {
return false
}
if lhs.rightItems != rhs.rightItems {
return false
}
if lhs.centerItem != rhs.centerItem {
return false
}
return true
}
static var body: Body {
let background = Child(Rectangle.self)
let leftItem = Child(environment: Empty.self)
let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self)
let centerItem = Child(environment: Empty.self)
return { context in
var availableWidth = context.availableSize.width
let sideInset: CGFloat = 16.0 + context.component.sideInset
let contentHeight: CGFloat = 44.0
let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight)
let background = background.update(
component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5/*context.component.backgroundVisible ? 0.5 : 0*/)),
availableSize: CGSize(width: size.width, height: size.height),
transition: context.transition
)
let leftItem = context.component.leftItem.flatMap { leftItemComponent in
return leftItem.update(
component: leftItemComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
}
if let leftItem = leftItem {
availableWidth -= leftItem.size.width
}
var rightItemList: [_UpdatedChildComponent] = []
for item in context.component.rightItems {
let item = rightItems[item.id].update(
component: item.component,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
rightItemList.append(item)
availableWidth -= item.size.width
}
let centerItem = context.component.centerItem.flatMap { centerItemComponent in
return centerItem.update(
component: centerItemComponent,
availableSize: CGSize(width: availableWidth - 44 - 44, height: contentHeight),
transition: context.transition
)
}
if let centerItem = centerItem {
availableWidth -= centerItem.size.width
}
context.add(background
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0))
.opacity(context.component.backgroundVisible ? 1 : 0)
.animation(key: "opacity")
)
var centerLeftInset = sideInset
if let leftItem = leftItem {
context.add(leftItem
.position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0))
)
centerLeftInset += leftItem.size.width + 4.0
}
var rightItemX = context.availableSize.width - sideInset
for item in rightItemList.reversed() {
context.add(item
.position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0))
)
rightItemX -= item.size.width + 8.0
}
let someUndesiredOffset: CGFloat = 16
if let centerItem = centerItem {
context.add(centerItem
.position(CGPoint(x: context.availableSize.width / 2 - someUndesiredOffset, y: context.component.topInset + contentHeight / 2.0))
)
}
return size
}
}
}
private final class OriginInfoComponent: CombinedComponent {
let participantsCount: Int
private static var usingAnimatedCounter: Bool { true }
init(
memberCount: Int
) {
self.participantsCount = memberCount
}
static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool {
if lhs.participantsCount != rhs.participantsCount {
return false
}
return true
}
static var body: Body {
if usingAnimatedCounter {
let viewerCounter = Child(ParticipantsComponent.self)
return { context in
// let spacing: CGFloat = 0.0
let viewerCounter = viewerCounter.update(
component: ParticipantsComponent(
count: context.component.participantsCount,
showsSubtitle: true,
fontSize: 18.0,
gradientColors: [UIColor.white.cgColor]
),
availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height),
transition: context.transition
)
let heightReduction: CGFloat = 16.0
var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction)
size.width = min(size.width, context.availableSize.width)
size.height = min(size.height, context.availableSize.height)
context.add(viewerCounter
.position(CGPoint(x: size.width / 2.0, y: /*(context.availableSize.height - viewerCounter.size.height)*/context.availableSize.height / 2.0 + 16.0 - heightReduction / 2))
)
return size
}
} else {
let subtitle = Child(Text.self)
return { context in
// let spacing: CGFloat = 0.0
let memberCount = context.component.participantsCount
let memberCountString: String
if memberCount == 0 {
memberCountString = "no viewers"
} else {
memberCountString = memberCount > 0 ? presentationStringsFormattedNumber(Int32(memberCount), ",") : ""
}
let subtitle = subtitle.update(
component: Text(
text: memberCountString, font: Font.regular(14.0), color: .white),
availableSize: context.availableSize,
transition: context.transition
)
var size = CGSize(width: subtitle.size.width, height: subtitle.size.height)
size.width = min(size.width, context.availableSize.width)
size.height = min(size.height, context.availableSize.height)
context.add(subtitle
.position(CGPoint(x: size.width / 2.0, y: subtitle.size.height / 2.0))
)
return size
}
}
}
}
private final class ToolbarComponent: CombinedComponent {
let bottomInset: CGFloat
let sideInset: CGFloat
let leftItem: AnyComponent<Empty>?
let rightItem: AnyComponent<Empty>?
let centerItem: AnyComponent<Empty>?
init(
bottomInset: CGFloat,
sideInset: CGFloat,
leftItem: AnyComponent<Empty>?,
rightItem: AnyComponent<Empty>?,
centerItem: AnyComponent<Empty>?
) {
self.bottomInset = bottomInset
self.sideInset = sideInset
self.leftItem = leftItem
self.rightItem = rightItem
self.centerItem = centerItem
}
static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool {
if lhs.bottomInset != rhs.bottomInset {
return false
}
if lhs.sideInset != rhs.sideInset {
return false
}
if lhs.leftItem != rhs.leftItem {
return false
}
if lhs.rightItem != rhs.rightItem {
return false
}
if lhs.centerItem != rhs.centerItem {
return false
}
return true
}
static var body: Body {
let background = Child(Rectangle.self)
let leftItem = Child(environment: Empty.self)
let rightItem = Child(environment: Empty.self)
let centerItem = Child(environment: Empty.self)
return { context in
var availableWidth = context.availableSize.width
let sideInset: CGFloat = 16.0 + context.component.sideInset
let contentHeight: CGFloat = 44.0
let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset)
let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition)
let leftItem = context.component.leftItem.flatMap { leftItemComponent in
return leftItem.update(
component: leftItemComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
}
if let leftItem = leftItem {
availableWidth -= leftItem.size.width
}
let rightItem = context.component.rightItem.flatMap { rightItemComponent in
return rightItem.update(
component: rightItemComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight),
transition: context.transition
)
}
if let rightItem = rightItem {
availableWidth -= rightItem.size.width
}
let temporaryOffsetForSmallerSubtitle: CGFloat = 12
let centerItem = context.component.centerItem.flatMap { centerItemComponent in
return centerItem.update(
component: centerItemComponent,
availableSize: CGSize(width: availableWidth, height: contentHeight - temporaryOffsetForSmallerSubtitle / 2),
transition: context.transition
)
}
if let centerItem = centerItem {
availableWidth -= centerItem.size.width
}
context.add(background
.position(CGPoint(x: size.width / 2.0, y: size.height / 2.0))
)
var centerLeftInset = sideInset
if let leftItem = leftItem {
context.add(leftItem
.position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0))
)
centerLeftInset += leftItem.size.width + 4.0
}
var centerRightInset = sideInset
if let rightItem = rightItem {
context.add(rightItem
.position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0))
)
centerRightInset += rightItem.size.width + 4.0
}
let maxCenterInset = max(centerLeftInset, centerRightInset)
if let centerItem = centerItem {
context.add(centerItem
.position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0 - temporaryOffsetForSmallerSubtitle))
)
}
return size
}
}
}
private final class ButtonsRowComponent: CombinedComponent {
let bottomInset: CGFloat
let sideInset: CGFloat
let leftItem: AnyComponent<Empty>?
let rightItem: AnyComponent<Empty>?
let centerItem: AnyComponent<Empty>?
init(
bottomInset: CGFloat,
sideInset: CGFloat,
leftItem: AnyComponent<Empty>?,
rightItem: AnyComponent<Empty>?,
centerItem: AnyComponent<Empty>?
) {
self.bottomInset = bottomInset
self.sideInset = sideInset
self.leftItem = leftItem
self.rightItem = rightItem
self.centerItem = centerItem
}
static func ==(lhs: ButtonsRowComponent, rhs: ButtonsRowComponent) -> Bool {
if lhs.bottomInset != rhs.bottomInset {
return false
}
if lhs.sideInset != rhs.sideInset {
return false
}
if lhs.leftItem != rhs.leftItem {
return false
}
if lhs.rightItem != rhs.rightItem {
return false
}
if lhs.centerItem != rhs.centerItem {
return false
}
return true
}
static var body: Body {
let leftItem = Child(environment: Empty.self)
let rightItem = Child(environment: Empty.self)
let centerItem = Child(environment: Empty.self)
return { context in
var availableWidth = context.availableSize.width
let sideInset: CGFloat = 48.0 + context.component.sideInset
let contentHeight: CGFloat = 80 // 44
let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset)
let leftItem = context.component.leftItem.flatMap { leftItemComponent in
return leftItem.update(
component: leftItemComponent,
availableSize: CGSize(width: 50, height: contentHeight),
transition: context.transition
)
}
if let leftItem = leftItem {
availableWidth -= leftItem.size.width
}
let rightItem = context.component.rightItem.flatMap { rightItemComponent in
return rightItem.update(
component: rightItemComponent,
availableSize: CGSize(width: 50, height: contentHeight),
transition: context.transition
)
}
if let rightItem = rightItem {
availableWidth -= rightItem.size.width
}
let centerItem = context.component.centerItem.flatMap { centerItemComponent in
return centerItem.update(
component: centerItemComponent,
availableSize: CGSize(width: 50, height: contentHeight),
transition: context.transition
)
}
if let centerItem = centerItem {
availableWidth -= centerItem.size.width
}
var centerLeftInset = sideInset
if let leftItem = leftItem {
context.add(leftItem
.position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0))
)
centerLeftInset += leftItem.size.width + 4.0
}
var centerRightInset = sideInset
if let rightItem = rightItem {
context.add(rightItem
.position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0))
)
centerRightInset += rightItem.size.width + 4.0
}
let maxCenterInset = max(centerLeftInset, centerRightInset)
if let centerItem = centerItem {
context.add(centerItem
.position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0))
)
}
return size
}
}
}
final class RoundGradientButtonComponent: Component {
init(gradientColors: [CGColor], icon: String? = nil, image: UIImage? = nil, title: String) {
self.gradientColors = gradientColors
self.icon = icon
self.image = image
self.title = title
}
static func == (lhs: RoundGradientButtonComponent, rhs: RoundGradientButtonComponent) -> Bool {
if lhs.icon != rhs.icon {
return false
}
if lhs.gradientColors != rhs.gradientColors {
return false
}
return true
}
let gradientColors: [CGColor]
let icon: String?
let image: UIImage?
let title: String
final class View: UIView {
let gradientLayer = CAGradientLayer()
let iconView = UIImageView()
let titleLabel = UILabel()
override init(frame: CGRect = .zero) {
super.init(frame: frame)
gradientLayer.type = .radial
gradientLayer.startPoint = .init(x: 1, y: 1)
gradientLayer.endPoint = .init(x: 0, y: 0)
self.layer.addSublayer(gradientLayer)
self.addSubview(iconView)
self.clipsToBounds = false
self.addSubview(titleLabel)
titleLabel.textAlignment = .center
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = .white
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
titleLabel.invalidateIntrinsicContentSize()
let heightForIcon = bounds.height - max(round(titleLabel.intrinsicContentSize.height), 12) - 8.0
iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon)
gradientLayer.masksToBounds = true
gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2
gradientLayer.frame = iconView.frame
titleLabel.frame = .init(x: 0, y: bounds.height - titleLabel.intrinsicContentSize.height, width: bounds.width, height: titleLabel.intrinsicContentSize.height)
}
}
func makeView() -> View {
View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) }
let gradientColors: [CGColor]
if self.gradientColors.count == 1 {
gradientColors = [self.gradientColors[0], self.gradientColors[0]]
} else {
gradientColors = self.gradientColors
}
view.gradientLayer.colors = gradientColors
view.titleLabel.text = title
view.setNeedsLayout()
return availableSize
}
}
public final class Throttler<T: Hashable> {
public var duration: TimeInterval = 0.25
public var queue: DispatchQueue = .main
public var isEnabled: Bool { duration > 0 }
private var isThrottling: Bool = false
private var lastValue: T?
private var accumulator = Set<T>()
private var lastCompletedValue: T?
public init(duration: TimeInterval = 0.25, queue: DispatchQueue = .main) {
self.duration = duration
self.queue = queue
}
public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) {
queue.async { [self] in
accumulator.insert(value)
if !isThrottling {
isThrottling = true
lastValue = nil
completion?(value)
self.lastCompletedValue = value
} else {
lastValue = value
}
if lastValue == nil {
queue.asyncAfter(deadline: .now() + duration) { [self] in
accumulator.removeAll()
// TODO: quick fix, replace with timer
queue.asyncAfter(deadline: .now() + duration) { [self] in
isThrottling = false
}
guard
let lastValue = lastValue,
lastCompletedValue != lastValue || includingLatest
else { return }
accumulator.insert(lastValue)
self.lastValue = nil
completion?(lastValue)
lastCompletedValue = lastValue
}
}
}
}
public func cancelCurrent() {
lastValue = nil
isThrottling = false
accumulator.removeAll()
}
public func canEmit(_ value: T) -> Bool {
!accumulator.contains(value)
}
}
public extension Throttler where T == Bool {
func throttle(includingLatest: Bool = false, _ completion: ((T) -> Void)?) {
publish(true, includingLatest: includingLatest, using: completion)
}
}