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? 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 } let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) private let infoThrottler = Throttler.init(duration: 5, queue: .main) init(call: PresentationGroupCallImpl) { self.call = call if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { self.isPictureInPictureSupported = true } else { self.isPictureInPictureSupported = 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 in guard let source else { return .single(nil) } let time = engine.calls.requestStreamState(dataSource: source, callId: info.id, accessHash: info.accessHash) |> map { state -> Int64? in guard let state else { return nil } return state.channels.first?.latestTimestamp } return time |> mapToSignal { latestTimestamp -> Signal in guard let latestTimestamp else { return .single(nil) } let durationMilliseconds: Int64 = 32000 let bufferOffset: Int64 = 1 * durationMilliseconds let timestampId = latestTimestamp - bufferOffset return engine.calls.getVideoBroadcastPart(dataSource: source, callId: info.id, accessHash: info.accessHash, timestampIdMilliseconds: timestampId, durationMilliseconds: durationMilliseconds, channelId: 2, quality: 0) |> mapToSignal { result -> Signal in switch result.status { case let .data(data): return .single(data) case .notReady, .resyncNeeded, .rejoinNeeded: return .single(nil) } } } } |> deliverOnMainQueue).start(next: { [weak self] data in guard let self, let data else { return } let _ = self let _ = data })*/ }) let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) 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 } strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(members.totalCount, 1) : members.totalCount /*Int.random(in: 0..<10000000)*/) { [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()) }) } } }) } 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.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 { 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 = 40 + environment.safeInsets.bottom let requiredSheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) 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 ) 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] = [] // let videoIsPlayable = context.state.videoIsPlayable 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: { activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { return } controller.dismiss(closing: false, manual: true) }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) } var topLeftButton: AnyComponent? 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)) // TODO: спросить про dismissWithoutContent и default 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() credentialsPromise.set(call.accountContext.engine.calls.getGroupCallStreamCredentials(peerId: call.peerId, revokePreviousCredentials: false) |> `catch` { _ -> Signal 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: 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] 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? 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 { activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { return } 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.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.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.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 ? .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)) ) let videoPos: CGFloat if isFullscreen { videoPos = context.availableSize.height / 2 + dragOffset } else { videoPos = sheetPosition - requiredSheetHeight / 2 + videoHeight / 2 + 50 + 12 } context.add(video .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) ) // // // 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 ) context.add(topItem .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 32))) .opacity((!isFullscreen || state.displayUI) ? 1 : 0) // .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) )) } 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(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.3, 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 override init(frame: CGRect = .zero) { super.init(frame: frame) addSubview(label) label.text = "LIVE" label.font = .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 let titleFadeLayer = CALayer() private let trackingLayer: HierarchyTrackingLayer private func updateTitleFadeLayer(textFrame: CGRect) { // titleLabel.backgroundColor = .red guard let string = titleLabel.attributedText, string.boundingRect(with: .init(width: .max, height: .max), context: nil).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.black.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 solidPartLayer.frame = CGRect( origin: CGPoint(x: max(containerWidth - availableWidth, gradientRadius), y: 0), size: CGSize(width: safeSolidWidth, height: textFrame.height)) titleLabel.frame = CGRect(x: textFrame.minX - extraSpace, y: textFrame.minY, width: textFrame.width + extraSpace, height: textFrame.height) } else { 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)) } self.titleLabel.textAlignment = .natural titleFadeLayer.addSublayer(solidPartLayer) let gradientLayer = CAGradientLayer() gradientLayer.colors = [UIColor.black.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.mask = titleFadeLayer } 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 = 40 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 - 22 / 2), size: .init(width: 40, height: 22)) 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, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } private final class NavigationBarComponent: CombinedComponent { let topInset: CGFloat let sideInset: CGFloat let leftItem: AnyComponent? let rightItems: [AnyComponentWithIdentity] let centerItem: AnyComponent? let backgroundVisible: Bool init( topInset: CGFloat, sideInset: CGFloat, backgroundVisible: Bool, leftItem: AnyComponent?, rightItems: [AnyComponentWithIdentity], centerItem: AnyComponent? ) { self.topInset = 0 // topInset self.sideInset = sideInset self.backgroundVisible = backgroundVisible self.leftItem = leftItem self.rightItems = rightItems self.centerItem = centerItem } static func ==(lhs: NavigationBarComponent, rhs: NavigationBarComponent) -> Bool { if lhs.topInset != rhs.topInset { return false } if lhs.sideInset != rhs.sideInset { return false } if lhs.leftItem != rhs.leftItem { return false } if lhs.rightItems != rhs.rightItems { return false } if lhs.centerItem != rhs.centerItem { return false } return true } static var body: Body { let background = Child(Rectangle.self) let leftItem = Child(environment: Empty.self) let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) let centerItem = Child(environment: Empty.self) return { context in var availableWidth = context.availableSize.width let sideInset: CGFloat = 16.0 + context.component.sideInset let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) let background = background.update( component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5/*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 ), availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), transition: context.transition ) var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height) 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) / 2.0)) ) 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? let rightItem: AnyComponent? let centerItem: AnyComponent? init( bottomInset: CGFloat, sideInset: CGFloat, leftItem: AnyComponent?, rightItem: AnyComponent?, centerItem: AnyComponent? ) { self.bottomInset = bottomInset self.sideInset = sideInset self.leftItem = leftItem self.rightItem = rightItem self.centerItem = centerItem } static func ==(lhs: ToolbarComponent, rhs: ToolbarComponent) -> Bool { if lhs.bottomInset != rhs.bottomInset { return false } if lhs.sideInset != rhs.sideInset { return false } if lhs.leftItem != rhs.leftItem { return false } if lhs.rightItem != rhs.rightItem { return false } if lhs.centerItem != rhs.centerItem { return false } return true } static var body: Body { let background = Child(Rectangle.self) let leftItem = Child(environment: Empty.self) let rightItem = Child(environment: Empty.self) let centerItem = Child(environment: Empty.self) return { context in var availableWidth = context.availableSize.width let sideInset: CGFloat = 16.0 + context.component.sideInset let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( component: leftItemComponent, availableSize: CGSize(width: availableWidth, height: contentHeight), transition: context.transition ) } if let leftItem = leftItem { availableWidth -= leftItem.size.width } let rightItem = context.component.rightItem.flatMap { rightItemComponent in return rightItem.update( component: rightItemComponent, availableSize: CGSize(width: availableWidth, height: contentHeight), transition: context.transition ) } if let rightItem = rightItem { availableWidth -= rightItem.size.width } let temporaryOffsetForSmallerSubtitle: CGFloat = 12 let centerItem = context.component.centerItem.flatMap { centerItemComponent in return centerItem.update( component: centerItemComponent, availableSize: CGSize(width: availableWidth, height: contentHeight - temporaryOffsetForSmallerSubtitle / 2), transition: context.transition ) } if let centerItem = centerItem { availableWidth -= centerItem.size.width } context.add(background .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) ) var centerLeftInset = sideInset if let leftItem = leftItem { context.add(leftItem .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) ) centerLeftInset += leftItem.size.width + 4.0 } var centerRightInset = sideInset if let rightItem = rightItem { context.add(rightItem .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) ) centerRightInset += rightItem.size.width + 4.0 } let maxCenterInset = max(centerLeftInset, centerRightInset) if let centerItem = centerItem { context.add(centerItem .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0 - temporaryOffsetForSmallerSubtitle)) ) } return size } } } private final class ButtonsRowComponent: CombinedComponent { let bottomInset: CGFloat let sideInset: CGFloat let leftItem: AnyComponent? let rightItem: AnyComponent? let centerItem: AnyComponent? init( bottomInset: CGFloat, sideInset: CGFloat, leftItem: AnyComponent?, rightItem: AnyComponent?, centerItem: AnyComponent? ) { self.bottomInset = bottomInset self.sideInset = sideInset self.leftItem = leftItem self.rightItem = rightItem self.centerItem = centerItem } static func ==(lhs: ButtonsRowComponent, rhs: ButtonsRowComponent) -> Bool { if lhs.bottomInset != rhs.bottomInset { return false } if lhs.sideInset != rhs.sideInset { return false } if lhs.leftItem != rhs.leftItem { return false } if lhs.rightItem != rhs.rightItem { return false } if lhs.centerItem != rhs.centerItem { return false } return true } static var body: Body { let leftItem = Child(environment: Empty.self) let rightItem = Child(environment: Empty.self) let centerItem = Child(environment: Empty.self) return { context in var availableWidth = context.availableSize.width let sideInset: CGFloat = 40 + 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(titleLabel.intrinsicContentSize.height, 12) - 6 iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon) gradientLayer.masksToBounds = true gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2 gradientLayer.frame = iconView.frame titleLabel.frame = .init(x: 0, y: bounds.height - titleLabel.intrinsicContentSize.height, width: bounds.width, height: titleLabel.intrinsicContentSize.height) } } func makeView() -> View { View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) } view.gradientLayer.colors = gradientColors view.titleLabel.text = title view.setNeedsLayout() return availableSize } } public final class Throttler { public var duration: TimeInterval = 0.25 public var queue: DispatchQueue = .main public var isEnabled: Bool { duration > 0 } private var isThrottling: Bool = false private var lastValue: T? private var accumulator = Set() private var lastCompletedValue: T? public init(duration: TimeInterval = 0.25, queue: DispatchQueue = .main) { self.duration = duration self.queue = queue } public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) { queue.async { [self] in accumulator.insert(value) if !isThrottling { isThrottling = true lastValue = nil completion?(value) self.lastCompletedValue = value } else { lastValue = value } if lastValue == nil { queue.asyncAfter(deadline: .now() + duration) { [self] in accumulator.removeAll() // TODO: quick fix, replace with timer queue.asyncAfter(deadline: .now() + duration) { [self] in isThrottling = false } guard let lastValue = lastValue, lastCompletedValue != lastValue || includingLatest else { return } accumulator.insert(lastValue) self.lastValue = nil completion?(lastValue) lastCompletedValue = lastValue } } } } public func cancelCurrent() { lastValue = nil isThrottling = false accumulator.removeAll() } public func canEmit(_ value: T) -> Bool { !accumulator.contains(value) } } public extension Throttler where T == Bool { func throttle(includingLatest: Bool = false, _ completion: ((T) -> Void)?) { publish(true, includingLatest: includingLatest, using: completion) } }