mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Voice chat optimizations (video and mic blob)
This commit is contained in:
parent
0e668a5fa2
commit
b90e3563a3
@ -110,6 +110,7 @@ swift_library(
|
||||
"//submodules/TinyThumbnail",
|
||||
"//submodules/ImageBlur",
|
||||
"//submodules/MetalEngine",
|
||||
"//submodules/TelegramUI/Components/Calls/VoiceChatActionButton",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
@ -784,7 +784,7 @@ private func copyI420BufferToNV12Buffer(buffer: OngoingGroupCallContext.VideoFra
|
||||
return true
|
||||
}
|
||||
|
||||
private final class AdaptedCallVideoSource: VideoSource {
|
||||
final class AdaptedCallVideoSource: VideoSource {
|
||||
final class I420DataBuffer: Output.DataBuffer {
|
||||
private let buffer: OngoingGroupCallContext.VideoFrameData.I420Buffer
|
||||
|
||||
|
@ -9,6 +9,7 @@ import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
import AccountContext
|
||||
import AnimatedCountLabelNode
|
||||
import VoiceChatActionButton
|
||||
|
||||
private let blue = UIColor(rgb: 0x007fff)
|
||||
private let lightBlue = UIColor(rgb: 0x00affe)
|
||||
|
@ -332,24 +332,8 @@ final class MediaStreamVideoComponent: Component {
|
||||
})
|
||||
stallTimer = _stallTimer
|
||||
self.clipsToBounds = component.isFullscreen // or just true
|
||||
if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) {
|
||||
self.videoBlurView = videoBlurView
|
||||
self.insertSubview(videoBlurView, belowSubview: self.blurTintView)
|
||||
videoBlurView.alpha = 0
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
videoBlurView.alpha = 1
|
||||
}
|
||||
self.videoBlurGradientMask.type = .radial
|
||||
self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
|
||||
self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5)
|
||||
self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0)
|
||||
|
||||
self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor
|
||||
self.videoBlurGradientMask.addSublayer(videoBlurSolidMask)
|
||||
|
||||
}
|
||||
|
||||
if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) {
|
||||
|
||||
if let videoView = self.videoRenderingContext.makeView(input: input, forceSampleBufferDisplayLayer: true) {
|
||||
self.videoView = videoView
|
||||
self.addSubview(videoView)
|
||||
videoView.alpha = 0
|
||||
@ -432,6 +416,23 @@ final class MediaStreamVideoComponent: Component {
|
||||
state?.updated(transition: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
if let videoView = self.videoView, let videoBlurView = self.videoRenderingContext.makeBlurView(input: input, mainView: videoView) {
|
||||
self.videoBlurView = videoBlurView
|
||||
self.insertSubview(videoBlurView, belowSubview: self.blurTintView)
|
||||
videoBlurView.alpha = 0
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
videoBlurView.alpha = 1
|
||||
}
|
||||
self.videoBlurGradientMask.type = .radial
|
||||
self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
|
||||
self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5)
|
||||
self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0)
|
||||
|
||||
self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor
|
||||
self.videoBlurGradientMask.addSublayer(videoBlurSolidMask)
|
||||
|
||||
}
|
||||
}
|
||||
} else if component.isFullscreen {
|
||||
if fullScreenBackgroundPlaceholder.superview == nil {
|
||||
|
@ -305,6 +305,8 @@ final class GroupVideoNode: ASDisplayNode, PreviewVideoNode {
|
||||
transition.updatePosition(layer: self.videoView.layer, position: rotatedVideoFrame.center)
|
||||
transition.updateBounds(layer: self.videoView.layer, bounds: CGRect(origin: CGPoint(), size: normalizedVideoSize))
|
||||
|
||||
self.videoView.updateLayout(size: normalizedVideoSize, transition: transition)
|
||||
|
||||
let transformScale: CGFloat = rotatedVideoFrame.width / normalizedVideoSize.width
|
||||
transition.updateTransformScale(layer: self.videoViewContainer.layer, scale: transformScale)
|
||||
|
||||
@ -340,6 +342,7 @@ final class GroupVideoNode: ASDisplayNode, PreviewVideoNode {
|
||||
})
|
||||
|
||||
transition.updateBounds(layer: backdropVideoView.layer, bounds: CGRect(origin: CGPoint(), size: normalizedVideoSize))
|
||||
backdropVideoView.updateLayout(size: normalizedVideoSize, transition: transition)
|
||||
|
||||
let transformScale: CGFloat = rotatedVideoFrame.width / normalizedVideoSize.width
|
||||
|
||||
|
@ -514,6 +514,9 @@ final class MetalVideoRenderingView: UIView, VideoRenderingView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
|
@ -226,4 +226,7 @@ final class SampleBufferVideoRenderingView: UIView, VideoRenderingView {
|
||||
func updateIsEnabled(_ isEnabled: Bool) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import SwiftSignalKit
|
||||
import AccountContext
|
||||
import TelegramVoip
|
||||
import AVFoundation
|
||||
import CallScreen
|
||||
import MetalEngine
|
||||
|
||||
protocol VideoRenderingView: UIView {
|
||||
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void)
|
||||
@ -14,6 +16,7 @@ protocol VideoRenderingView: UIView {
|
||||
func getAspect() -> CGFloat
|
||||
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void)
|
||||
func updateIsEnabled(_ isEnabled: Bool)
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
|
||||
}
|
||||
|
||||
class VideoRenderingContext {
|
||||
@ -33,27 +36,41 @@ class VideoRenderingContext {
|
||||
}
|
||||
#endif
|
||||
|
||||
func makeView(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, blur: Bool, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? {
|
||||
#if targetEnvironment(simulator)
|
||||
if blur {
|
||||
#if DEBUG
|
||||
return SampleBufferVideoRenderingView(input: input)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
func makeView(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? {
|
||||
if !forceSampleBufferDisplayLayer {
|
||||
return CallScreenVideoView(input: input)
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
return SampleBufferVideoRenderingView(input: input)
|
||||
#else
|
||||
if #available(iOS 13.0, *), !forceSampleBufferDisplayLayer {
|
||||
return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: blur)
|
||||
} else {
|
||||
if blur {
|
||||
return nil
|
||||
}
|
||||
return SampleBufferVideoRenderingView(input: input)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func makeBlurView(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>, mainView: VideoRenderingView?, forceSampleBufferDisplayLayer: Bool = false) -> VideoRenderingView? {
|
||||
if let mainView = mainView as? CallScreenVideoView {
|
||||
return CallScreenVideoBlurView(mainView: mainView)
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
#if DEBUG
|
||||
return SampleBufferVideoRenderingView(input: input)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
#else
|
||||
if #available(iOS 13.0, *), !forceSampleBufferDisplayLayer {
|
||||
return MetalVideoRenderingView(renderingContext: self.metalContext, input: input, blur: true)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func updateVisibility(isVisible: Bool) {
|
||||
#if targetEnvironment(simulator)
|
||||
@ -79,3 +96,189 @@ extension PresentationCallVideoView.Orientation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class CallScreenVideoView: UIView, VideoRenderingView {
|
||||
private var isEnabled: Bool = false
|
||||
|
||||
private var onFirstFrameReceived: ((Float) -> Void)?
|
||||
private var onOrientationUpdated: ((PresentationCallVideoView.Orientation, CGFloat) -> Void)?
|
||||
private var onIsMirroredUpdated: ((Bool) -> Void)?
|
||||
|
||||
private var didReportFirstFrame: Bool = false
|
||||
private var currentIsMirrored: Bool = false
|
||||
private var currentOrientation: PresentationCallVideoView.Orientation = .rotation0
|
||||
private var currentAspect: CGFloat = 1.0
|
||||
|
||||
fileprivate let videoSource: AdaptedCallVideoSource
|
||||
private var disposable: Disposable?
|
||||
|
||||
fileprivate let videoLayer: PrivateCallVideoLayer
|
||||
|
||||
init(input: Signal<OngoingGroupCallContext.VideoFrameData, NoError>) {
|
||||
self.videoLayer = PrivateCallVideoLayer()
|
||||
self.videoLayer.masksToBounds = true
|
||||
|
||||
self.videoSource = AdaptedCallVideoSource(videoStreamSignal: input)
|
||||
|
||||
super.init(frame: CGRect())
|
||||
|
||||
self.layer.addSublayer(self.videoLayer)
|
||||
|
||||
self.disposable = self.videoSource.addOnUpdated { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.videoLayer.video = self.videoSource.currentOutput
|
||||
|
||||
var notifyOrientationUpdated = false
|
||||
var notifyIsMirroredUpdated = false
|
||||
|
||||
if !self.didReportFirstFrame {
|
||||
notifyOrientationUpdated = true
|
||||
notifyIsMirroredUpdated = true
|
||||
}
|
||||
|
||||
if let currentOutput = self.videoSource.currentOutput {
|
||||
let currentAspect: CGFloat
|
||||
if currentOutput.resolution.height > 0.0 {
|
||||
currentAspect = currentOutput.resolution.width / currentOutput.resolution.height
|
||||
} else {
|
||||
currentAspect = 1.0
|
||||
}
|
||||
if self.currentAspect != currentAspect {
|
||||
self.currentAspect = currentAspect
|
||||
notifyOrientationUpdated = true
|
||||
}
|
||||
|
||||
let currentOrientation: PresentationCallVideoView.Orientation
|
||||
if abs(currentOutput.rotationAngle - 0.0) < .ulpOfOne {
|
||||
currentOrientation = .rotation0
|
||||
} else if abs(currentOutput.rotationAngle - Float.pi * 0.5) < .ulpOfOne {
|
||||
currentOrientation = .rotation90
|
||||
} else if abs(currentOutput.rotationAngle - Float.pi) < .ulpOfOne {
|
||||
currentOrientation = .rotation180
|
||||
} else if abs(currentOutput.rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne {
|
||||
currentOrientation = .rotation270
|
||||
} else {
|
||||
currentOrientation = .rotation0
|
||||
}
|
||||
if self.currentOrientation != currentOrientation {
|
||||
self.currentOrientation = currentOrientation
|
||||
notifyOrientationUpdated = true
|
||||
}
|
||||
|
||||
let currentIsMirrored = !currentOutput.mirrorDirection.isEmpty
|
||||
if self.currentIsMirrored != currentIsMirrored {
|
||||
self.currentIsMirrored = currentIsMirrored
|
||||
notifyIsMirroredUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if !self.didReportFirstFrame {
|
||||
self.didReportFirstFrame = true
|
||||
self.onFirstFrameReceived?(Float(self.currentAspect))
|
||||
}
|
||||
|
||||
if notifyOrientationUpdated {
|
||||
self.onOrientationUpdated?(self.currentOrientation, self.currentAspect)
|
||||
}
|
||||
|
||||
if notifyIsMirroredUpdated {
|
||||
self.onIsMirroredUpdated?(self.currentIsMirrored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.disposable?.dispose()
|
||||
}
|
||||
|
||||
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) {
|
||||
self.onFirstFrameReceived = f
|
||||
self.didReportFirstFrame = false
|
||||
}
|
||||
|
||||
func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) {
|
||||
self.onOrientationUpdated = f
|
||||
}
|
||||
|
||||
func getOrientation() -> PresentationCallVideoView.Orientation {
|
||||
return self.currentOrientation
|
||||
}
|
||||
|
||||
func getAspect() -> CGFloat {
|
||||
return self.currentAspect
|
||||
}
|
||||
|
||||
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) {
|
||||
self.onIsMirroredUpdated = f
|
||||
}
|
||||
|
||||
func updateIsEnabled(_ isEnabled: Bool) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
if let currentOutput = self.videoSource.currentOutput {
|
||||
let rotatedResolution = currentOutput.resolution
|
||||
let videoSize = size
|
||||
|
||||
let videoResolution = rotatedResolution.aspectFittedOrSmaller(CGSize(width: 1280, height: 1280)).aspectFittedOrSmaller(CGSize(width: videoSize.width * 3.0, height: videoSize.height * 3.0))
|
||||
let rotatedVideoResolution = videoResolution
|
||||
|
||||
transition.updateFrame(layer: self.videoLayer, frame: CGRect(origin: CGPoint(), size: size))
|
||||
self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class CallScreenVideoBlurView: UIView, VideoRenderingView {
|
||||
private weak var mainView: CallScreenVideoView?
|
||||
|
||||
private let blurredLayer: MetalEngineSubjectLayer
|
||||
|
||||
init(mainView: CallScreenVideoView) {
|
||||
self.mainView = mainView
|
||||
self.blurredLayer = mainView.videoLayer.blurredLayer
|
||||
|
||||
super.init(frame: CGRect())
|
||||
|
||||
self.layer.addSublayer(self.blurredLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
}
|
||||
|
||||
func setOnFirstFrameReceived(_ f: @escaping (Float) -> Void) {
|
||||
}
|
||||
|
||||
func setOnOrientationUpdated(_ f: @escaping (PresentationCallVideoView.Orientation, CGFloat) -> Void) {
|
||||
}
|
||||
|
||||
func getOrientation() -> PresentationCallVideoView.Orientation {
|
||||
return .rotation0
|
||||
}
|
||||
|
||||
func getAspect() -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func setOnIsMirroredUpdated(_ f: @escaping (Bool) -> Void) {
|
||||
}
|
||||
|
||||
func updateIsEnabled(_ isEnabled: Bool) {
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updateFrame(layer: self.blurredLayer, frame: CGRect(origin: CGPoint(), size: size))
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -32,6 +32,7 @@ import MapResourceToAvatarSizes
|
||||
import SolidRoundedButtonNode
|
||||
import AudioBlob
|
||||
import DeviceAccess
|
||||
import VoiceChatActionButton
|
||||
|
||||
let panelBackgroundColor = UIColor(rgb: 0x1c1c1e)
|
||||
let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e)
|
||||
@ -2415,18 +2416,10 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController
|
||||
}
|
||||
} else {
|
||||
if let input = (strongSelf.call as! PresentationGroupCallImpl).video(endpointId: endpointId) {
|
||||
if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) {
|
||||
completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeView(input: input, blur: true)))
|
||||
if let videoView = strongSelf.videoRenderingContext.makeView(input: input) {
|
||||
completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeBlurView(input: input, mainView: videoView)))
|
||||
}
|
||||
}
|
||||
|
||||
/*strongSelf.call.makeIncomingVideoView(endpointId: endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { videoView, backdropVideoView in
|
||||
if let videoView = videoView {
|
||||
completion(GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView))
|
||||
} else {
|
||||
completion(nil)
|
||||
}
|
||||
})*/
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3717,7 +3710,7 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController
|
||||
var isFrontCamera = true
|
||||
let videoCapturer = OngoingCallVideoCapturer()
|
||||
let input = videoCapturer.video()
|
||||
if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) {
|
||||
if let videoView = strongSelf.videoRenderingContext.makeView(input: input) {
|
||||
videoView.updateIsEnabled(true)
|
||||
|
||||
let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil)
|
||||
@ -5488,8 +5481,8 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController
|
||||
self.requestedVideoSources.insert(channel.endpointId)
|
||||
|
||||
let input = (self.call as! PresentationGroupCallImpl).video(endpointId: channel.endpointId)
|
||||
if let input = input, let videoView = self.videoRenderingContext.makeView(input: input, blur: false) {
|
||||
let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeView(input: input, blur: true))
|
||||
if let input = input, let videoView = self.videoRenderingContext.makeView(input: input) {
|
||||
let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeBlurView(input: input, mainView: videoView))
|
||||
|
||||
self.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue())))
|
||||
|> deliverOnMainQueue
|
||||
@ -5541,65 +5534,6 @@ public final class VoiceChatControllerImpl: ViewController, VoiceChatController
|
||||
self.updateMembers()
|
||||
}
|
||||
}
|
||||
|
||||
/*self.call.makeIncomingVideoView(endpointId: channel.endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { [weak self] videoView, backdropVideoView in
|
||||
Queue.mainQueue().async {
|
||||
guard let strongSelf = self, let videoView = videoView else {
|
||||
return
|
||||
}
|
||||
let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView)
|
||||
|
||||
strongSelf.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue())))
|
||||
|> deliverOnMainQueue
|
||||
).start(next: { [weak self, weak videoNode] ready, timeouted in
|
||||
if let strongSelf = self, let videoNode = videoNode {
|
||||
Queue.mainQueue().after(0.1) {
|
||||
if timeouted && !ready {
|
||||
strongSelf.timeoutedEndpointIds.insert(channel.endpointId)
|
||||
strongSelf.readyVideoEndpointIds.remove(channel.endpointId)
|
||||
strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds)
|
||||
strongSelf.wideVideoNodes.remove(channel.endpointId)
|
||||
|
||||
strongSelf.updateMembers()
|
||||
} else if ready {
|
||||
strongSelf.readyVideoEndpointIds.insert(channel.endpointId)
|
||||
strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds)
|
||||
strongSelf.timeoutedEndpointIds.remove(channel.endpointId)
|
||||
if videoNode.aspectRatio <= 0.77 {
|
||||
strongSelf.wideVideoNodes.insert(channel.endpointId)
|
||||
} else {
|
||||
strongSelf.wideVideoNodes.remove(channel.endpointId)
|
||||
}
|
||||
strongSelf.updateMembers()
|
||||
|
||||
if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass {
|
||||
if let interaction = strongSelf.itemInteraction {
|
||||
loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count {
|
||||
let entry = strongSelf.currentFullscreenEntries[i]
|
||||
switch entry {
|
||||
case let .peer(peerEntry, _):
|
||||
if peerEntry.effectiveVideoEndpointId == channel.endpointId {
|
||||
let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme)
|
||||
strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil)
|
||||
break loop
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}), forKey: channel.endpointId)
|
||||
strongSelf.videoNodes[channel.endpointId] = videoNode
|
||||
|
||||
if let _ = strongSelf.validLayout {
|
||||
strongSelf.updateMembers()
|
||||
}
|
||||
}
|
||||
})*/
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,7 @@ import AccountContext
|
||||
import LegacyComponents
|
||||
import AudioBlob
|
||||
import PeerInfoAvatarListNode
|
||||
import VoiceChatActionButton
|
||||
|
||||
private let avatarFont = avatarPlaceholderFont(size: floor(50.0 * 16.0 / 37.0))
|
||||
private let tileSize = CGSize(width: 84.0, height: 84.0)
|
||||
|
@ -13,6 +13,7 @@ import AppBundle
|
||||
import ContextUI
|
||||
import PresentationDataUtils
|
||||
import TooltipUI
|
||||
import VoiceChatActionButton
|
||||
|
||||
private let slideOffset: CGFloat = 80.0 + 44.0
|
||||
|
||||
|
@ -17,6 +17,7 @@ import AudioBlob
|
||||
import PeerInfoAvatarListNode
|
||||
import ComponentFlow
|
||||
import EmojiStatusComponent
|
||||
import VoiceChatActionButton
|
||||
|
||||
final class VoiceChatParticipantItem: ListViewItem {
|
||||
enum ParticipantText: Equatable {
|
||||
|
@ -8,6 +8,7 @@ import AccountContext
|
||||
import TelegramPresentationData
|
||||
import SolidRoundedButtonNode
|
||||
import PresentationDataUtils
|
||||
import VoiceChatActionButton
|
||||
|
||||
private let accentColor: UIColor = UIColor(rgb: 0x007aff)
|
||||
|
||||
|
@ -3,10 +3,10 @@ import MetalKit
|
||||
import MetalEngine
|
||||
import Display
|
||||
|
||||
final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
var internalData: MetalEngineSubjectInternalData?
|
||||
public final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
public var internalData: MetalEngineSubjectInternalData?
|
||||
|
||||
struct Blob {
|
||||
private struct Blob {
|
||||
var points: [Float]
|
||||
var nextPoints: [Float]
|
||||
|
||||
@ -35,7 +35,7 @@ final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
}
|
||||
}
|
||||
|
||||
final class RenderState: RenderToLayerState {
|
||||
private final class RenderState: RenderToLayerState {
|
||||
let pipelineState: MTLRenderPipelineState
|
||||
|
||||
required init?(device: MTLDevice) {
|
||||
@ -71,7 +71,7 @@ final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
|
||||
private var displayLinkSubscription: SharedDisplayLinkDriver.Link?
|
||||
|
||||
override init() {
|
||||
override public init() {
|
||||
super.init()
|
||||
|
||||
self.didEnterHierarchy = { [weak self] in
|
||||
@ -105,15 +105,15 @@ final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
}
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
override public init(layer: Any) {
|
||||
super.init(layer: layer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
required public init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(context: MetalEngineSubjectContext) {
|
||||
public func update(context: MetalEngineSubjectContext) {
|
||||
if self.bounds.isEmpty {
|
||||
return
|
||||
}
|
||||
|
@ -5,10 +5,10 @@ import MetalPerformanceShaders
|
||||
import Accelerate
|
||||
import MetalEngine
|
||||
|
||||
final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
var internalData: MetalEngineSubjectInternalData?
|
||||
public final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
public var internalData: MetalEngineSubjectInternalData?
|
||||
|
||||
let blurredLayer: MetalEngineSubjectLayer
|
||||
public let blurredLayer: MetalEngineSubjectLayer
|
||||
|
||||
final class BlurState: ComputeState {
|
||||
let computePipelineStateYUVBiPlanarToRGBA: MTLComputePipelineState
|
||||
@ -77,36 +77,36 @@ final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
}
|
||||
}
|
||||
|
||||
var video: VideoSource.Output? {
|
||||
public var video: VideoSource.Output? {
|
||||
didSet {
|
||||
self.setNeedsUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
var renderSpec: RenderLayerSpec?
|
||||
public var renderSpec: RenderLayerSpec?
|
||||
|
||||
private var rgbaTexture: PooledTexture?
|
||||
private var downscaledTexture: PooledTexture?
|
||||
private var blurredHorizontalTexture: PooledTexture?
|
||||
private var blurredVerticalTexture: PooledTexture?
|
||||
|
||||
override init() {
|
||||
override public init() {
|
||||
self.blurredLayer = MetalEngineSubjectLayer()
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
override public init(layer: Any) {
|
||||
self.blurredLayer = MetalEngineSubjectLayer()
|
||||
|
||||
super.init(layer: layer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
required public init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(context: MetalEngineSubjectContext) {
|
||||
public func update(context: MetalEngineSubjectContext) {
|
||||
if self.isHidden {
|
||||
return
|
||||
}
|
||||
|
@ -0,0 +1,77 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
load(
|
||||
"@build_bazel_rules_apple//apple:resources.bzl",
|
||||
"apple_resource_bundle",
|
||||
"apple_resource_group",
|
||||
)
|
||||
load("//build-system/bazel-utils:plist_fragment.bzl",
|
||||
"plist_fragment",
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "VoiceChatActionButtonMetalSources",
|
||||
srcs = glob([
|
||||
"Metal/**/*.metal",
|
||||
]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
plist_fragment(
|
||||
name = "VoiceChatActionButtonMetalSourcesBundleInfoPlist",
|
||||
extension = "plist",
|
||||
template =
|
||||
"""
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.telegram.VoiceChatActionButtonMetalSources</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceChatActionButton</string>
|
||||
"""
|
||||
)
|
||||
|
||||
apple_resource_bundle(
|
||||
name = "VoiceChatActionButtonMetalSourcesBundle",
|
||||
infoplists = [
|
||||
":VoiceChatActionButtonMetalSourcesBundleInfoPlist",
|
||||
],
|
||||
resources = [
|
||||
":VoiceChatActionButtonMetalSources",
|
||||
],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "Assets",
|
||||
srcs = glob(["VoiceChatActionButtonAssets.xcassets/**"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
swift_library(
|
||||
name = "VoiceChatActionButton",
|
||||
module_name = "VoiceChatActionButton",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
data = [
|
||||
":VoiceChatActionButtonMetalSourcesBundle",
|
||||
":Assets",
|
||||
],
|
||||
deps = [
|
||||
"//submodules/Display",
|
||||
"//submodules/MetalEngine",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/UIKitRuntimeUtils",
|
||||
"//submodules/SSignalKit/SwiftSignalKit",
|
||||
"//submodules/AppBundle",
|
||||
"//submodules/ManagedAnimationNode",
|
||||
"//submodules/AnimationUI",
|
||||
"//submodules/TelegramUI/Components/Calls/CallScreen",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -0,0 +1,22 @@
|
||||
#include <metal_stdlib>
|
||||
|
||||
using namespace metal;
|
||||
|
||||
struct Rectangle {
|
||||
float2 origin;
|
||||
float2 size;
|
||||
};
|
||||
|
||||
constant static float2 quadVertices[6] = {
|
||||
float2(0.0, 0.0),
|
||||
float2(1.0, 0.0),
|
||||
float2(0.0, 1.0),
|
||||
float2(1.0, 0.0),
|
||||
float2(0.0, 1.0),
|
||||
float2(1.0, 1.0)
|
||||
};
|
||||
|
||||
struct QuadVertexOut {
|
||||
float4 position [[position]];
|
||||
float2 uv;
|
||||
};
|
@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
|
||||
final class BlobView: UIView {
|
||||
let pointsCount: Int
|
||||
let smoothness: CGFloat
|
||||
|
||||
let minRandomness: CGFloat
|
||||
let maxRandomness: CGFloat
|
||||
|
||||
let minSpeed: CGFloat
|
||||
let maxSpeed: CGFloat
|
||||
|
||||
let minScale: CGFloat
|
||||
let maxScale: CGFloat
|
||||
|
||||
var scaleUpdated: ((CGFloat) -> Void)?
|
||||
|
||||
var level: CGFloat = 0 {
|
||||
didSet {
|
||||
if abs(self.level - oldValue) > 0.01 {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
let lv = self.minScale + (self.maxScale - self.minScale) * self.level
|
||||
self.shapeLayer.transform = CATransform3DMakeScale(lv, lv, 1)
|
||||
self.scaleUpdated?(self.level)
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var speedLevel: CGFloat = 0
|
||||
private var lastSpeedLevel: CGFloat = 0
|
||||
|
||||
private let shapeLayer: CAShapeLayer = {
|
||||
let layer = CAShapeLayer()
|
||||
layer.strokeColor = nil
|
||||
return layer
|
||||
}()
|
||||
|
||||
init(
|
||||
pointsCount: Int,
|
||||
minRandomness: CGFloat,
|
||||
maxRandomness: CGFloat,
|
||||
minSpeed: CGFloat,
|
||||
maxSpeed: CGFloat,
|
||||
minScale: CGFloat,
|
||||
maxScale: CGFloat
|
||||
) {
|
||||
self.pointsCount = pointsCount
|
||||
self.minRandomness = minRandomness
|
||||
self.maxRandomness = maxRandomness
|
||||
self.minSpeed = minSpeed
|
||||
self.maxSpeed = maxSpeed
|
||||
self.minScale = minScale
|
||||
self.maxScale = maxScale
|
||||
|
||||
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
|
||||
self.smoothness = ((4 / 3) * tan(angle / 4)) / sin(angle / 2) / 2
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.layer.addSublayer(self.shapeLayer)
|
||||
|
||||
self.shapeLayer.transform = CATransform3DMakeScale(minScale, minScale, 1)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setColor(_ color: UIColor) {
|
||||
self.shapeLayer.fillColor = color.cgColor
|
||||
}
|
||||
|
||||
func updateSpeedLevel(to newSpeedLevel: CGFloat) {
|
||||
self.speedLevel = max(self.speedLevel, newSpeedLevel)
|
||||
}
|
||||
|
||||
func startAnimating() {
|
||||
self.animateToNewShape()
|
||||
}
|
||||
|
||||
func stopAnimating() {
|
||||
self.shapeLayer.removeAnimation(forKey: "path")
|
||||
}
|
||||
|
||||
private func animateToNewShape() {
|
||||
if self.shapeLayer.path == nil {
|
||||
let points = generateNextBlob(for: self.bounds.size)
|
||||
self.shapeLayer.path = UIBezierPath.smoothCurve(through: points, length: bounds.width, smoothness: smoothness).cgPath
|
||||
}
|
||||
|
||||
let nextPoints = generateNextBlob(for: self.bounds.size)
|
||||
let nextPath = UIBezierPath.smoothCurve(through: nextPoints, length: bounds.width, smoothness: smoothness).cgPath
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "path")
|
||||
let previousPath = self.shapeLayer.path
|
||||
self.shapeLayer.path = nextPath
|
||||
animation.duration = CFTimeInterval(1.0 / (minSpeed + (maxSpeed - minSpeed) * speedLevel))
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
animation.fromValue = previousPath
|
||||
animation.toValue = nextPath
|
||||
animation.isRemovedOnCompletion = false
|
||||
animation.fillMode = .forwards
|
||||
animation.completion = { [weak self] finished in
|
||||
if finished {
|
||||
self?.animateToNewShape()
|
||||
}
|
||||
}
|
||||
|
||||
self.shapeLayer.add(animation, forKey: "path")
|
||||
|
||||
self.lastSpeedLevel = self.speedLevel
|
||||
self.speedLevel = 0
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func generateNextBlob(for size: CGSize) -> [CGPoint] {
|
||||
let randomness = minRandomness + (maxRandomness - minRandomness) * speedLevel
|
||||
return blob(pointsCount: pointsCount, randomness: randomness)
|
||||
.map {
|
||||
return CGPoint(
|
||||
x: $0.x * CGFloat(size.width),
|
||||
y: $0.y * CGFloat(size.height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func blob(pointsCount: Int, randomness: CGFloat) -> [CGPoint] {
|
||||
let angle = (CGFloat.pi * 2) / CGFloat(pointsCount)
|
||||
|
||||
let rgen = { () -> CGFloat in
|
||||
let accuracy: UInt32 = 1000
|
||||
let random = arc4random_uniform(accuracy)
|
||||
return CGFloat(random) / CGFloat(accuracy)
|
||||
}
|
||||
let rangeStart: CGFloat = 1 / (1 + randomness / 10)
|
||||
|
||||
let startAngle = angle * CGFloat(arc4random_uniform(100)) / CGFloat(100)
|
||||
|
||||
let points = (0 ..< pointsCount).map { i -> CGPoint in
|
||||
let randPointOffset = (rangeStart + CGFloat(rgen()) * (1 - rangeStart)) / 2
|
||||
let angleRandomness: CGFloat = angle * 0.1
|
||||
let randAngle = angle + angle * ((angleRandomness * CGFloat(arc4random_uniform(100)) / CGFloat(100)) - angleRandomness * 0.5)
|
||||
let pointX = sin(startAngle + CGFloat(i) * randAngle)
|
||||
let pointY = cos(startAngle + CGFloat(i) * randAngle)
|
||||
return CGPoint(
|
||||
x: pointX * randPointOffset,
|
||||
y: pointY * randPointOffset
|
||||
)
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.shapeLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import CallScreen
|
||||
import MetalEngine
|
||||
|
||||
final class VoiceBlobView: UIView {
|
||||
//private let mediumBlob: BlobView
|
||||
//private let bigBlob: BlobView
|
||||
|
||||
private let blobsLayer: CallBlobsLayer
|
||||
|
||||
private let maxLevel: CGFloat
|
||||
|
||||
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
|
||||
|
||||
private var audioLevel: CGFloat = 0.0
|
||||
var presentationAudioLevel: CGFloat = 0.0
|
||||
|
||||
var scaleUpdated: ((CGFloat) -> Void)? {
|
||||
didSet {
|
||||
//self.bigBlob.scaleUpdated = self.scaleUpdated
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var isAnimating = false
|
||||
|
||||
public typealias BlobRange = (min: CGFloat, max: CGFloat)
|
||||
|
||||
private let hierarchyTrackingNode: HierarchyTrackingNode
|
||||
private var isCurrentlyInHierarchy = true
|
||||
|
||||
init(
|
||||
frame: CGRect,
|
||||
maxLevel: CGFloat,
|
||||
mediumBlobRange: BlobRange,
|
||||
bigBlobRange: BlobRange
|
||||
) {
|
||||
var updateInHierarchy: ((Bool) -> Void)?
|
||||
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
|
||||
updateInHierarchy?(value)
|
||||
})
|
||||
|
||||
self.maxLevel = maxLevel
|
||||
|
||||
/*self.mediumBlob = BlobView(
|
||||
pointsCount: 8,
|
||||
minRandomness: 1,
|
||||
maxRandomness: 1,
|
||||
minSpeed: 0.9,
|
||||
maxSpeed: 4.0,
|
||||
minScale: mediumBlobRange.min,
|
||||
maxScale: mediumBlobRange.max
|
||||
)
|
||||
self.bigBlob = BlobView(
|
||||
pointsCount: 8,
|
||||
minRandomness: 1,
|
||||
maxRandomness: 1,
|
||||
minSpeed: 1.0,
|
||||
maxSpeed: 4.4,
|
||||
minScale: bigBlobRange.min,
|
||||
maxScale: bigBlobRange.max
|
||||
)*/
|
||||
|
||||
self.blobsLayer = CallBlobsLayer()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubnode(self.hierarchyTrackingNode)
|
||||
|
||||
//self.addSubview(self.bigBlob)
|
||||
//self.addSubview(self.mediumBlob)
|
||||
self.layer.addSublayer(self.blobsLayer)
|
||||
|
||||
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
if !strongSelf.isCurrentlyInHierarchy {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.presentationAudioLevel = strongSelf.presentationAudioLevel * 0.9 + strongSelf.audioLevel * 0.1
|
||||
strongSelf.updateAudioLevel()
|
||||
|
||||
//strongSelf.mediumBlob.level = strongSelf.presentationAudioLevel
|
||||
//strongSelf.bigBlob.level = strongSelf.presentationAudioLevel
|
||||
}
|
||||
|
||||
updateInHierarchy = { [weak self] value in
|
||||
if let strongSelf = self {
|
||||
strongSelf.isCurrentlyInHierarchy = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public func setColor(_ color: UIColor) {
|
||||
//self.mediumBlob.setColor(color.withAlphaComponent(0.5))
|
||||
//self.bigBlob.setColor(color.withAlphaComponent(0.21))
|
||||
}
|
||||
|
||||
public func updateLevel(_ level: CGFloat, immediately: Bool) {
|
||||
let normalizedLevel = min(1, max(level / maxLevel, 0))
|
||||
|
||||
//self.mediumBlob.updateSpeedLevel(to: normalizedLevel)
|
||||
//self.bigBlob.updateSpeedLevel(to: normalizedLevel)
|
||||
|
||||
self.audioLevel = normalizedLevel
|
||||
if immediately {
|
||||
self.presentationAudioLevel = normalizedLevel
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAudioLevel() {
|
||||
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
|
||||
let blobAmplificationFactor: CGFloat = 2.0
|
||||
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
|
||||
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
|
||||
|
||||
self.scaleUpdated?(blobScale)
|
||||
}
|
||||
|
||||
public func startAnimating() {
|
||||
guard !self.isAnimating else { return }
|
||||
self.isAnimating = true
|
||||
|
||||
self.updateBlobsState()
|
||||
|
||||
self.displayLinkAnimator?.isPaused = false
|
||||
}
|
||||
|
||||
public func stopAnimating() {
|
||||
self.stopAnimating(duration: 0.15)
|
||||
}
|
||||
|
||||
public func stopAnimating(duration: Double) {
|
||||
guard isAnimating else { return }
|
||||
self.isAnimating = false
|
||||
|
||||
self.updateBlobsState()
|
||||
|
||||
self.displayLinkAnimator?.isPaused = true
|
||||
}
|
||||
|
||||
private func updateBlobsState() {
|
||||
/*if self.isAnimating {
|
||||
if self.mediumBlob.frame.size != .zero {
|
||||
self.mediumBlob.startAnimating()
|
||||
self.bigBlob.startAnimating()
|
||||
}
|
||||
} else {
|
||||
self.mediumBlob.stopAnimating()
|
||||
self.bigBlob.stopAnimating()
|
||||
}*/
|
||||
}
|
||||
|
||||
override public func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
//self.mediumBlob.frame = bounds
|
||||
//self.bigBlob.frame = bounds
|
||||
|
||||
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
|
||||
self.blobsLayer.position = blobsFrame.center
|
||||
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
|
||||
|
||||
self.updateBlobsState()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,521 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import SwiftSignalKit
|
||||
import AnimationUI
|
||||
import AppBundle
|
||||
import ManagedAnimationNode
|
||||
import ComponentFlow
|
||||
|
||||
private let titleFont = Font.regular(15.0)
|
||||
private let subtitleFont = Font.regular(13.0)
|
||||
|
||||
private let smallScale: CGFloat = 0.48
|
||||
private let smallIconScale: CGFloat = 0.69
|
||||
|
||||
public final class VoiceChatActionButton: HighlightTrackingButtonNode {
|
||||
static let buttonHeight: CGFloat = 52.0
|
||||
|
||||
public enum State: Equatable {
|
||||
public enum ActiveState: Equatable {
|
||||
case cantSpeak
|
||||
case muted
|
||||
case on
|
||||
}
|
||||
|
||||
public enum ScheduledState: Equatable {
|
||||
case start
|
||||
case subscribe
|
||||
case unsubscribe
|
||||
}
|
||||
|
||||
case button(text: String)
|
||||
case scheduled(state: ScheduledState)
|
||||
case connecting
|
||||
case active(state: ActiveState)
|
||||
}
|
||||
|
||||
public var stateValue: State {
|
||||
return self.currentParams?.state ?? .connecting
|
||||
}
|
||||
public var statePromise = ValuePromise<State>()
|
||||
public var state: Signal<State, NoError> {
|
||||
return self.statePromise.get()
|
||||
}
|
||||
|
||||
public let bottomNode: ASDisplayNode
|
||||
private let containerNode: ASDisplayNode
|
||||
private let backgroundNode: VoiceChatActionButtonBackgroundNode
|
||||
private let iconNode: VoiceChatActionButtonIconNode
|
||||
private let labelContainerNode: ASDisplayNode
|
||||
public let titleLabel: ImmediateTextNode
|
||||
private let subtitleLabel: ImmediateTextNode
|
||||
private let buttonTitleLabel: ImmediateTextNode
|
||||
|
||||
private var currentParams: (size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, dark: Bool, small: Bool, title: String, subtitle: String, snap: Bool)?
|
||||
|
||||
private var activePromise = ValuePromise<Bool>(false)
|
||||
private var outerColorPromise = Promise<(UIColor?, UIColor?)>((nil, nil))
|
||||
public var outerColor: Signal<(UIColor?, UIColor?), NoError> {
|
||||
return self.outerColorPromise.get()
|
||||
}
|
||||
|
||||
public var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) {
|
||||
didSet {
|
||||
self.backgroundNode.connectingColor = self.connectingColor
|
||||
}
|
||||
}
|
||||
|
||||
public var activeDisposable = MetaDisposable()
|
||||
|
||||
public var isDisabled: Bool = false
|
||||
|
||||
public var ignoreHierarchyChanges: Bool {
|
||||
get {
|
||||
return self.backgroundNode.ignoreHierarchyChanges
|
||||
} set {
|
||||
self.backgroundNode.ignoreHierarchyChanges = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var wasActiveWhenPressed = false
|
||||
public var pressing: Bool = false {
|
||||
didSet {
|
||||
guard let (_, _, state, _, small, _, _, snap) = self.currentParams, !self.isDisabled else {
|
||||
return
|
||||
}
|
||||
if self.pressing {
|
||||
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
||||
if small {
|
||||
transition.updateTransformScale(node: self.backgroundNode, scale: smallScale * 0.9)
|
||||
transition.updateTransformScale(node: self.iconNode, scale: smallIconScale * 0.9)
|
||||
} else {
|
||||
transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 0.9)
|
||||
}
|
||||
|
||||
switch state {
|
||||
case let .active(state):
|
||||
switch state {
|
||||
case .on:
|
||||
self.wasActiveWhenPressed = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .connecting, .button, .scheduled:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
||||
if small {
|
||||
transition.updateTransformScale(node: self.backgroundNode, scale: smallScale)
|
||||
transition.updateTransformScale(node: self.iconNode, scale: smallIconScale)
|
||||
} else {
|
||||
transition.updateTransformScale(node: self.iconNode, scale: snap ? 0.5 : 1.0)
|
||||
}
|
||||
self.wasActiveWhenPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var animationsEnabled: Bool = true {
|
||||
didSet {
|
||||
self.backgroundNode.animationsEnabled = self.animationsEnabled
|
||||
}
|
||||
}
|
||||
|
||||
public init() {
|
||||
self.bottomNode = ASDisplayNode()
|
||||
self.bottomNode.isUserInteractionEnabled = false
|
||||
self.containerNode = ASDisplayNode()
|
||||
self.containerNode.isUserInteractionEnabled = false
|
||||
self.backgroundNode = VoiceChatActionButtonBackgroundNode()
|
||||
self.iconNode = VoiceChatActionButtonIconNode(isColored: false)
|
||||
|
||||
self.labelContainerNode = ASDisplayNode()
|
||||
self.titleLabel = ImmediateTextNode()
|
||||
self.subtitleLabel = ImmediateTextNode()
|
||||
self.buttonTitleLabel = ImmediateTextNode()
|
||||
self.buttonTitleLabel.isUserInteractionEnabled = false
|
||||
self.buttonTitleLabel.alpha = 0.0
|
||||
|
||||
super.init()
|
||||
|
||||
self.addSubnode(self.bottomNode)
|
||||
self.labelContainerNode.addSubnode(self.titleLabel)
|
||||
self.labelContainerNode.addSubnode(self.subtitleLabel)
|
||||
self.addSubnode(self.labelContainerNode)
|
||||
|
||||
self.addSubnode(self.containerNode)
|
||||
self.containerNode.addSubnode(self.backgroundNode)
|
||||
self.containerNode.addSubnode(self.iconNode)
|
||||
|
||||
self.containerNode.addSubnode(self.buttonTitleLabel)
|
||||
|
||||
self.highligthedChanged = { [weak self] pressing in
|
||||
if let strongSelf = self {
|
||||
guard let (_, _, state, _, small, _, _, snap) = strongSelf.currentParams else {
|
||||
return
|
||||
}
|
||||
if pressing {
|
||||
if case .button = state {
|
||||
strongSelf.containerNode.layer.removeAnimation(forKey: "opacity")
|
||||
strongSelf.containerNode.alpha = 0.4
|
||||
} else {
|
||||
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
||||
if small {
|
||||
transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale * 0.9)
|
||||
transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale * 0.9)
|
||||
} else {
|
||||
transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 0.9)
|
||||
}
|
||||
}
|
||||
} else if !strongSelf.pressing {
|
||||
if case .button = state {
|
||||
strongSelf.containerNode.alpha = 1.0
|
||||
strongSelf.containerNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
|
||||
} else {
|
||||
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
||||
if small {
|
||||
transition.updateTransformScale(node: strongSelf.backgroundNode, scale: smallScale)
|
||||
transition.updateTransformScale(node: strongSelf.iconNode, scale: smallIconScale)
|
||||
} else {
|
||||
transition.updateTransformScale(node: strongSelf.iconNode, scale: snap ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.backgroundNode.updatedActive = { [weak self] active in
|
||||
self?.activePromise.set(active)
|
||||
}
|
||||
|
||||
self.backgroundNode.updatedColors = { [weak self] outerColor, activeColor in
|
||||
self?.outerColorPromise.set(.single((outerColor, activeColor)))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.activeDisposable.dispose()
|
||||
}
|
||||
|
||||
public func updateLevel(_ level: CGFloat, immediately: Bool = false) {
|
||||
self.backgroundNode.audioLevel = level
|
||||
}
|
||||
|
||||
private func applyParams(animated: Bool) {
|
||||
guard let (size, _, state, _, small, title, subtitle, snap) = self.currentParams else {
|
||||
return
|
||||
}
|
||||
|
||||
let updatedTitle = self.titleLabel.attributedText?.string != title
|
||||
let updatedSubtitle = self.subtitleLabel.attributedText?.string != subtitle
|
||||
|
||||
self.titleLabel.attributedText = NSAttributedString(string: title, font: titleFont, textColor: .white)
|
||||
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: subtitleFont, textColor: .white)
|
||||
|
||||
if animated && self.titleLabel.alpha > 0.0 {
|
||||
if let snapshotView = self.titleLabel.view.snapshotContentTree(), updatedTitle {
|
||||
self.titleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.titleLabel.view)
|
||||
snapshotView.frame = self.titleLabel.frame
|
||||
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
||||
snapshotView?.removeFromSuperview()
|
||||
})
|
||||
self.titleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
||||
}
|
||||
if let snapshotView = self.subtitleLabel.view.snapshotContentTree(), updatedSubtitle {
|
||||
self.subtitleLabel.view.superview?.insertSubview(snapshotView, belowSubview: self.subtitleLabel.view)
|
||||
snapshotView.frame = self.subtitleLabel.frame
|
||||
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
||||
snapshotView?.removeFromSuperview()
|
||||
})
|
||||
self.subtitleLabel.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
let titleSize = self.titleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
|
||||
let subtitleSize = self.subtitleLabel.updateLayout(CGSize(width: size.width, height: .greatestFiniteMagnitude))
|
||||
let totalHeight = titleSize.height + subtitleSize.height + 1.0
|
||||
|
||||
self.labelContainerNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
|
||||
let titleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - totalHeight) / 2.0) + 84.0), size: titleSize)
|
||||
let subtitleLabelFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: titleLabelFrame.maxY + 1.0), size: subtitleSize)
|
||||
|
||||
self.titleLabel.bounds = CGRect(origin: CGPoint(), size: titleLabelFrame.size)
|
||||
self.titleLabel.position = titleLabelFrame.center
|
||||
self.subtitleLabel.bounds = CGRect(origin: CGPoint(), size: subtitleLabelFrame.size)
|
||||
self.subtitleLabel.position = subtitleLabelFrame.center
|
||||
|
||||
self.bottomNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
self.containerNode.frame = CGRect(origin: CGPoint(), size: size)
|
||||
|
||||
self.backgroundNode.bounds = CGRect(origin: CGPoint(), size: size)
|
||||
self.backgroundNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
||||
|
||||
var active = false
|
||||
switch state {
|
||||
case let .active(state):
|
||||
switch state {
|
||||
case .on:
|
||||
active = self.pressing && !self.wasActiveWhenPressed
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .connecting, .button, .scheduled:
|
||||
break
|
||||
}
|
||||
|
||||
if snap {
|
||||
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate
|
||||
transition.updateTransformScale(node: self.backgroundNode, scale: active ? 0.9 : 0.625)
|
||||
transition.updateTransformScale(node: self.iconNode, scale: 0.625)
|
||||
transition.updateAlpha(node: self.titleLabel, alpha: 0.0)
|
||||
transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0)
|
||||
transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 0.0)
|
||||
} else {
|
||||
let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate
|
||||
if small {
|
||||
transition.updateTransformScale(node: self.backgroundNode, scale: self.pressing ? smallScale * 0.9 : smallScale, delay: 0.0)
|
||||
transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? smallIconScale * 0.9 : smallIconScale, delay: 0.0)
|
||||
transition.updateAlpha(node: self.titleLabel, alpha: 0.0)
|
||||
transition.updateAlpha(node: self.subtitleLabel, alpha: 0.0)
|
||||
transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint(x: 0.0, y: -43.0))
|
||||
transition.updateTransformScale(node: self.titleLabel, scale: 0.8)
|
||||
transition.updateTransformScale(node: self.subtitleLabel, scale: 0.8)
|
||||
} else {
|
||||
transition.updateTransformScale(node: self.backgroundNode, scale: 1.0, delay: 0.0)
|
||||
transition.updateTransformScale(node: self.iconNode, scale: self.pressing ? 0.9 : 1.0, delay: 0.0)
|
||||
transition.updateAlpha(node: self.titleLabel, alpha: 1.0, delay: 0.05)
|
||||
transition.updateAlpha(node: self.subtitleLabel, alpha: 1.0, delay: 0.05)
|
||||
transition.updateSublayerTransformOffset(layer: self.labelContainerNode.layer, offset: CGPoint())
|
||||
transition.updateTransformScale(node: self.titleLabel, scale: 1.0)
|
||||
transition.updateTransformScale(node: self.subtitleLabel, scale: 1.0)
|
||||
}
|
||||
transition.updateAlpha(layer: self.backgroundNode.maskProgressLayer, alpha: 1.0)
|
||||
}
|
||||
|
||||
let iconSize = CGSize(width: 100.0, height: 100.0)
|
||||
self.iconNode.bounds = CGRect(origin: CGPoint(), size: iconSize)
|
||||
self.iconNode.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
|
||||
}
|
||||
|
||||
private var previousIcon: VoiceChatActionButtonIconAnimationState?
|
||||
private func applyIconParams() {
|
||||
guard let (_, _, state, _, _, _, _, _) = self.currentParams else {
|
||||
return
|
||||
}
|
||||
|
||||
let icon: VoiceChatActionButtonIconAnimationState
|
||||
switch state {
|
||||
case .button:
|
||||
icon = .empty
|
||||
case let .scheduled(state):
|
||||
switch state {
|
||||
case .start:
|
||||
icon = .start
|
||||
case .subscribe:
|
||||
icon = .subscribe
|
||||
case .unsubscribe:
|
||||
icon = .unsubscribe
|
||||
}
|
||||
case let .active(state):
|
||||
switch state {
|
||||
case .on:
|
||||
icon = .unmute
|
||||
case .muted:
|
||||
icon = .mute
|
||||
case .cantSpeak:
|
||||
icon = .hand
|
||||
}
|
||||
case .connecting:
|
||||
if let previousIcon = previousIcon {
|
||||
icon = previousIcon
|
||||
} else {
|
||||
icon = .mute
|
||||
}
|
||||
}
|
||||
self.previousIcon = icon
|
||||
|
||||
self.iconNode.enqueueState(icon)
|
||||
}
|
||||
|
||||
public func update(snap: Bool, animated: Bool) {
|
||||
if let previous = self.currentParams {
|
||||
self.currentParams = (previous.size, previous.buttonSize, previous.state, previous.dark, previous.small, previous.title, previous.subtitle, snap)
|
||||
|
||||
self.backgroundNode.isSnap = snap
|
||||
self.backgroundNode.glowHidden = snap || previous.small
|
||||
self.backgroundNode.updateColors()
|
||||
self.applyParams(animated: animated)
|
||||
self.applyIconParams()
|
||||
}
|
||||
}
|
||||
|
||||
public func update(size: CGSize, buttonSize: CGSize, state: VoiceChatActionButton.State, title: String, subtitle: String, dark: Bool, small: Bool, animated: Bool = false) {
|
||||
let previous = self.currentParams
|
||||
let previousState = previous?.state
|
||||
self.currentParams = (size, buttonSize, state, dark, small, title, subtitle, previous?.snap ?? false)
|
||||
|
||||
self.statePromise.set(state)
|
||||
|
||||
if let previousState = previousState, case .button = previousState, case .scheduled = state {
|
||||
self.buttonTitleLabel.alpha = 0.0
|
||||
self.buttonTitleLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
|
||||
self.buttonTitleLabel.layer.animateScale(from: 1.0, to: 0.001, duration: 0.24)
|
||||
|
||||
self.iconNode.alpha = 1.0
|
||||
self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
||||
self.iconNode.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.42, damping: 104.0)
|
||||
}
|
||||
|
||||
var backgroundState: VoiceChatActionButtonBackgroundNode.State
|
||||
var animated = true
|
||||
switch state {
|
||||
case let .button(text):
|
||||
backgroundState = .button
|
||||
self.buttonTitleLabel.alpha = 1.0
|
||||
self.buttonTitleLabel.attributedText = NSAttributedString(string: text, font: Font.semibold(17.0), textColor: .white)
|
||||
let titleSize = self.buttonTitleLabel.updateLayout(CGSize(width: size.width, height: 100.0))
|
||||
self.buttonTitleLabel.frame = CGRect(origin: CGPoint(x: floor((self.bounds.width - titleSize.width) / 2.0), y: floor((self.bounds.height - titleSize.height) / 2.0)), size: titleSize)
|
||||
case .scheduled:
|
||||
backgroundState = .disabled
|
||||
if previousState == .connecting {
|
||||
animated = false
|
||||
}
|
||||
case let .active(state):
|
||||
switch state {
|
||||
case .on:
|
||||
backgroundState = .blob(true)
|
||||
case .muted:
|
||||
backgroundState = .blob(false)
|
||||
case .cantSpeak:
|
||||
backgroundState = .disabled
|
||||
}
|
||||
case .connecting:
|
||||
backgroundState = .connecting
|
||||
}
|
||||
self.applyIconParams()
|
||||
|
||||
self.backgroundNode.glowHidden = (self.currentParams?.snap ?? false) || small
|
||||
self.backgroundNode.isDark = dark
|
||||
self.backgroundNode.update(state: backgroundState, animated: animated)
|
||||
|
||||
if case .active = state, let previousState = previousState, case .connecting = previousState, animated {
|
||||
self.activeDisposable.set((self.activePromise.get()
|
||||
|> deliverOnMainQueue).start(next: { [weak self] active in
|
||||
if active {
|
||||
self?.activeDisposable.set(nil)
|
||||
self?.applyParams(animated: true)
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
self.applyParams(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
var hitRect = self.bounds
|
||||
if let (_, buttonSize, state, _, _, _, _, _) = self.currentParams {
|
||||
if case .button = state {
|
||||
hitRect = CGRect(x: 0.0, y: floor((self.bounds.height - VoiceChatActionButton.buttonHeight) / 2.0), width: self.bounds.width, height: VoiceChatActionButton.buttonHeight)
|
||||
} else {
|
||||
hitRect = self.bounds.insetBy(dx: (self.bounds.width - buttonSize.width) / 2.0, dy: (self.bounds.height - buttonSize.height) / 2.0)
|
||||
}
|
||||
}
|
||||
let result = super.hitTest(point, with: event)
|
||||
if !hitRect.contains(point) {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func playAnimation() {
|
||||
self.iconNode.playRandomAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
public extension UIBezierPath {
|
||||
static func smoothCurve(through points: [CGPoint], length: CGFloat, smoothness: CGFloat, curve: Bool = false) -> UIBezierPath {
|
||||
var smoothPoints = [SmoothPoint]()
|
||||
for index in (0 ..< points.count) {
|
||||
let prevIndex = index - 1
|
||||
let prev = points[prevIndex >= 0 ? prevIndex : points.count + prevIndex]
|
||||
let curr = points[index]
|
||||
let next = points[(index + 1) % points.count]
|
||||
|
||||
let angle: CGFloat = {
|
||||
let dx = next.x - prev.x
|
||||
let dy = -next.y + prev.y
|
||||
let angle = atan2(dy, dx)
|
||||
if angle < 0 {
|
||||
return abs(angle)
|
||||
} else {
|
||||
return 2 * .pi - angle
|
||||
}
|
||||
}()
|
||||
|
||||
smoothPoints.append(
|
||||
SmoothPoint(
|
||||
point: curr,
|
||||
inAngle: angle + .pi,
|
||||
inLength: smoothness * distance(from: curr, to: prev),
|
||||
outAngle: angle,
|
||||
outLength: smoothness * distance(from: curr, to: next)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let resultPath = UIBezierPath()
|
||||
if curve {
|
||||
resultPath.move(to: CGPoint())
|
||||
resultPath.addLine(to: smoothPoints[0].point)
|
||||
} else {
|
||||
resultPath.move(to: smoothPoints[0].point)
|
||||
}
|
||||
|
||||
let smoothCount = curve ? smoothPoints.count - 1 : smoothPoints.count
|
||||
for index in (0 ..< smoothCount) {
|
||||
let curr = smoothPoints[index]
|
||||
let next = smoothPoints[(index + 1) % points.count]
|
||||
let currSmoothOut = curr.smoothOut()
|
||||
let nextSmoothIn = next.smoothIn()
|
||||
resultPath.addCurve(to: next.point, controlPoint1: currSmoothOut, controlPoint2: nextSmoothIn)
|
||||
}
|
||||
if curve {
|
||||
resultPath.addLine(to: CGPoint(x: length, y: 0.0))
|
||||
}
|
||||
resultPath.close()
|
||||
return resultPath
|
||||
}
|
||||
|
||||
static private func distance(from fromPoint: CGPoint, to toPoint: CGPoint) -> CGFloat {
|
||||
return sqrt((fromPoint.x - toPoint.x) * (fromPoint.x - toPoint.x) + (fromPoint.y - toPoint.y) * (fromPoint.y - toPoint.y))
|
||||
}
|
||||
|
||||
struct SmoothPoint {
|
||||
let point: CGPoint
|
||||
|
||||
let inAngle: CGFloat
|
||||
let inLength: CGFloat
|
||||
|
||||
let outAngle: CGFloat
|
||||
let outLength: CGFloat
|
||||
|
||||
func smoothIn() -> CGPoint {
|
||||
return smooth(angle: inAngle, length: inLength)
|
||||
}
|
||||
|
||||
func smoothOut() -> CGPoint {
|
||||
return smooth(angle: outAngle, length: outLength)
|
||||
}
|
||||
|
||||
private func smooth(angle: CGFloat, length: CGFloat) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: point.x + length * cos(angle),
|
||||
y: point.y + length * sin(angle)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,744 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
|
||||
private let progressLineWidth: CGFloat = 3.0 + UIScreenPixel
|
||||
private let buttonSize = CGSize(width: 112.0, height: 112.0)
|
||||
private let radius = buttonSize.width / 2.0
|
||||
|
||||
private let areaSize = CGSize(width: 300.0, height: 300.0)
|
||||
private let blobSize = CGSize(width: 190.0, height: 190.0)
|
||||
|
||||
private let secondaryGreyColor = UIColor(rgb: 0x1c1c1e)
|
||||
private let whiteColor = UIColor(rgb: 0xffffff)
|
||||
private let greyColor = UIColor(rgb: 0x2c2c2e)
|
||||
private let blue = UIColor(rgb: 0x007fff)
|
||||
private let lightBlue = UIColor(rgb: 0x00affe)
|
||||
private let green = UIColor(rgb: 0x33c659)
|
||||
private let activeBlue = UIColor(rgb: 0x00a0b9)
|
||||
private let purple = UIColor(rgb: 0x3252ef)
|
||||
private let pink = UIColor(rgb: 0xef436c)
|
||||
|
||||
final class VoiceChatActionButtonBackgroundNode: ASDisplayNode {
|
||||
enum State: Equatable {
|
||||
case connecting
|
||||
case disabled
|
||||
case button
|
||||
case blob(Bool)
|
||||
}
|
||||
|
||||
private var state: State
|
||||
private var hasState = false
|
||||
|
||||
private var transition: State?
|
||||
|
||||
var audioLevel: CGFloat = 0.0 {
|
||||
didSet {
|
||||
self.maskBlobView.updateLevel(self.audioLevel, immediately: false)
|
||||
}
|
||||
}
|
||||
|
||||
var updatedActive: ((Bool) -> Void)?
|
||||
var updatedColors: ((UIColor?, UIColor?) -> Void)?
|
||||
|
||||
private let backgroundCircleLayer = CAShapeLayer()
|
||||
private let foregroundCircleLayer = CAShapeLayer()
|
||||
private let growingForegroundCircleLayer = CAShapeLayer()
|
||||
|
||||
private let foregroundView = UIView()
|
||||
private let foregroundGradientLayer = CAGradientLayer()
|
||||
|
||||
private let maskView = UIView()
|
||||
private let maskGradientLayer = CAGradientLayer()
|
||||
private let maskBlobView: VoiceBlobView
|
||||
private let maskCircleLayer = CAShapeLayer()
|
||||
|
||||
let maskProgressLayer = CAShapeLayer()
|
||||
|
||||
private let maskMediumBlobLayer = CAShapeLayer()
|
||||
private let maskBigBlobLayer = CAShapeLayer()
|
||||
|
||||
private let hierarchyTrackingNode: HierarchyTrackingNode
|
||||
private var isCurrentlyInHierarchy = false
|
||||
var ignoreHierarchyChanges = false
|
||||
|
||||
override init() {
|
||||
self.state = .connecting
|
||||
|
||||
self.maskBlobView = VoiceBlobView(frame: CGRect(origin: CGPoint(x: (areaSize.width - blobSize.width) / 2.0, y: (areaSize.height - blobSize.height) / 2.0), size: blobSize), maxLevel: 1.5, mediumBlobRange: (0.69, 0.87), bigBlobRange: (0.71, 1.0))
|
||||
self.maskBlobView.setColor(whiteColor)
|
||||
self.maskBlobView.isHidden = true
|
||||
|
||||
var updateInHierarchy: ((Bool) -> Void)?
|
||||
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
|
||||
updateInHierarchy?(value)
|
||||
})
|
||||
|
||||
super.init()
|
||||
|
||||
self.addSubnode(self.hierarchyTrackingNode)
|
||||
|
||||
let circlePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: buttonSize)).cgPath
|
||||
self.backgroundCircleLayer.fillColor = greyColor.cgColor
|
||||
self.backgroundCircleLayer.path = circlePath
|
||||
|
||||
let smallerCirclePath = UIBezierPath(ovalIn: CGRect(origin: CGPoint(), size: CGSize(width: buttonSize.width - progressLineWidth, height: buttonSize.height - progressLineWidth))).cgPath
|
||||
self.foregroundCircleLayer.fillColor = greyColor.cgColor
|
||||
self.foregroundCircleLayer.path = smallerCirclePath
|
||||
self.foregroundCircleLayer.transform = CATransform3DMakeScale(0.0, 0.0, 1)
|
||||
self.foregroundCircleLayer.isHidden = true
|
||||
|
||||
self.growingForegroundCircleLayer.fillColor = greyColor.cgColor
|
||||
self.growingForegroundCircleLayer.path = smallerCirclePath
|
||||
self.growingForegroundCircleLayer.transform = CATransform3DMakeScale(1.0, 1.0, 1)
|
||||
self.growingForegroundCircleLayer.isHidden = true
|
||||
|
||||
self.foregroundGradientLayer.type = .radial
|
||||
self.foregroundGradientLayer.colors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
|
||||
self.foregroundGradientLayer.locations = [0.0, 0.55, 1.0]
|
||||
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
|
||||
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
|
||||
|
||||
self.maskView.backgroundColor = .clear
|
||||
|
||||
self.maskGradientLayer.type = .radial
|
||||
self.maskGradientLayer.colors = [UIColor(rgb: 0xffffff, alpha: 0.4).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
|
||||
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
||||
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
|
||||
self.maskGradientLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1.0)
|
||||
self.maskGradientLayer.isHidden = true
|
||||
|
||||
let path = CGMutablePath()
|
||||
path.addArc(center: CGPoint(x: (buttonSize.width + 6.0) / 2.0, y: (buttonSize.height + 6.0) / 2.0), radius: radius, startAngle: 0.0, endAngle: CGFloat.pi * 2.0, clockwise: true)
|
||||
|
||||
self.maskProgressLayer.strokeColor = whiteColor.cgColor
|
||||
self.maskProgressLayer.fillColor = UIColor.clear.cgColor
|
||||
self.maskProgressLayer.lineWidth = progressLineWidth
|
||||
self.maskProgressLayer.lineCap = .round
|
||||
self.maskProgressLayer.path = path
|
||||
|
||||
let circleFrame = CGRect(origin: CGPoint(x: (areaSize.width - buttonSize.width) / 2.0, y: (areaSize.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
|
||||
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
|
||||
|
||||
self.maskCircleLayer.path = largerCirclePath
|
||||
self.maskCircleLayer.fillColor = whiteColor.cgColor
|
||||
self.maskCircleLayer.isHidden = true
|
||||
|
||||
updateInHierarchy = { [weak self] value in
|
||||
if let strongSelf = self, !strongSelf.ignoreHierarchyChanges {
|
||||
strongSelf.isCurrentlyInHierarchy = value
|
||||
strongSelf.updateAnimations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func didLoad() {
|
||||
super.didLoad()
|
||||
|
||||
self.layer.addSublayer(self.backgroundCircleLayer)
|
||||
|
||||
self.view.addSubview(self.foregroundView)
|
||||
self.layer.addSublayer(self.foregroundCircleLayer)
|
||||
self.layer.addSublayer(self.growingForegroundCircleLayer)
|
||||
|
||||
self.foregroundView.mask = self.maskView
|
||||
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
|
||||
|
||||
self.maskView.layer.addSublayer(self.maskGradientLayer)
|
||||
self.maskView.layer.addSublayer(self.maskProgressLayer)
|
||||
self.maskView.addSubview(self.maskBlobView)
|
||||
self.maskView.layer.addSublayer(self.maskCircleLayer)
|
||||
|
||||
self.maskBlobView.scaleUpdated = { [weak self] scale in
|
||||
if let strongSelf = self {
|
||||
strongSelf.updateGlowScale(strongSelf.isActive ? scale : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupGradientAnimations() {
|
||||
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
|
||||
} else {
|
||||
let previousValue = self.foregroundGradientLayer.startPoint
|
||||
let newValue: CGPoint
|
||||
if self.maskBlobView.presentationAudioLevel > 0.22 {
|
||||
newValue = CGPoint(x: CGFloat.random(in: 0.9 ..< 1.0), y: CGFloat.random(in: 0.15 ..< 0.35))
|
||||
} else if self.maskBlobView.presentationAudioLevel > 0.01 {
|
||||
newValue = CGPoint(x: CGFloat.random(in: 0.57 ..< 0.85), y: CGFloat.random(in: 0.15 ..< 0.45))
|
||||
} else {
|
||||
newValue = CGPoint(x: CGFloat.random(in: 0.6 ..< 0.75), y: CGFloat.random(in: 0.25 ..< 0.45))
|
||||
}
|
||||
self.foregroundGradientLayer.startPoint = newValue
|
||||
|
||||
CATransaction.begin()
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "startPoint")
|
||||
animation.duration = Double.random(in: 0.8 ..< 1.4)
|
||||
animation.fromValue = previousValue
|
||||
animation.toValue = newValue
|
||||
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy {
|
||||
self?.setupGradientAnimations()
|
||||
}
|
||||
}
|
||||
|
||||
self.foregroundGradientLayer.add(animation, forKey: "movement")
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupProgressAnimations() {
|
||||
if let _ = self.maskProgressLayer.animation(forKey: "progressRotation") {
|
||||
} else {
|
||||
self.maskProgressLayer.isHidden = false
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
|
||||
animation.duration = 1.0
|
||||
animation.fromValue = NSNumber(value: Float(0.0))
|
||||
animation.toValue = NSNumber(value: Float.pi * 2.0)
|
||||
animation.repeatCount = Float.infinity
|
||||
animation.beginTime = 0.0
|
||||
self.maskProgressLayer.add(animation, forKey: "progressRotation")
|
||||
|
||||
let shrinkAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
||||
shrinkAnimation.fromValue = 1.0
|
||||
shrinkAnimation.toValue = 0.0
|
||||
shrinkAnimation.duration = 1.0
|
||||
shrinkAnimation.beginTime = 0.0
|
||||
|
||||
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
||||
growthAnimation.fromValue = 0.0
|
||||
growthAnimation.toValue = 1.0
|
||||
growthAnimation.duration = 1.0
|
||||
growthAnimation.beginTime = 1.0
|
||||
|
||||
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
rotateAnimation.fromValue = 0.0
|
||||
rotateAnimation.toValue = CGFloat.pi * 2
|
||||
rotateAnimation.isAdditive = true
|
||||
rotateAnimation.duration = 1.0
|
||||
rotateAnimation.beginTime = 1.0
|
||||
|
||||
let groupAnimation = CAAnimationGroup()
|
||||
groupAnimation.repeatCount = Float.infinity
|
||||
groupAnimation.animations = [shrinkAnimation, growthAnimation, rotateAnimation]
|
||||
groupAnimation.duration = 2.0
|
||||
|
||||
self.maskProgressLayer.add(groupAnimation, forKey: "progressGrowth")
|
||||
}
|
||||
}
|
||||
|
||||
var glowHidden: Bool = false {
|
||||
didSet {
|
||||
if self.glowHidden != oldValue {
|
||||
let initialAlpha = CGFloat(self.maskProgressLayer.opacity)
|
||||
let targetAlpha: CGFloat = self.glowHidden ? 0.0 : 1.0
|
||||
self.maskGradientLayer.opacity = Float(targetAlpha)
|
||||
self.maskGradientLayer.animateAlpha(from: initialAlpha, to: targetAlpha, duration: 0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var disableGlowAnimations = false
|
||||
func updateGlowScale(_ scale: CGFloat?) {
|
||||
if self.disableGlowAnimations {
|
||||
return
|
||||
}
|
||||
if let scale = scale {
|
||||
self.maskGradientLayer.transform = CATransform3DMakeScale(0.89 + 0.11 * scale, 0.89 + 0.11 * scale, 1.0)
|
||||
} else {
|
||||
let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (0.89))
|
||||
let targetScale: CGFloat = self.isActive ? 0.89 : 0.85
|
||||
if abs(targetScale - initialScale) > 0.03 {
|
||||
self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
|
||||
self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Gradient {
|
||||
case speaking
|
||||
case active
|
||||
case connecting
|
||||
case muted
|
||||
}
|
||||
|
||||
func updateGlowAndGradientAnimations(type: Gradient, previousType: Gradient? = nil, animated: Bool = true) {
|
||||
let effectivePreviousTyoe = previousType ?? .active
|
||||
|
||||
let scale: CGFloat
|
||||
if case .speaking = effectivePreviousTyoe {
|
||||
scale = 0.95
|
||||
} else {
|
||||
scale = 0.8
|
||||
}
|
||||
|
||||
let initialScale: CGFloat = ((self.maskGradientLayer.value(forKeyPath: "presentationLayer.transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? (((self.maskGradientLayer.value(forKeyPath: "transform.scale.x") as? NSNumber)?.floatValue).flatMap({ CGFloat($0) }) ?? scale)
|
||||
let initialColors = self.foregroundGradientLayer.colors
|
||||
|
||||
let outerColor: UIColor?
|
||||
let activeColor: UIColor?
|
||||
let targetColors: [CGColor]
|
||||
let targetScale: CGFloat
|
||||
switch type {
|
||||
case .speaking:
|
||||
targetColors = [activeBlue.cgColor, green.cgColor, green.cgColor]
|
||||
targetScale = 0.89
|
||||
outerColor = UIColor(rgb: 0x134b22)
|
||||
activeColor = green
|
||||
case .active:
|
||||
targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
|
||||
targetScale = 0.85
|
||||
outerColor = UIColor(rgb: 0x002e5d)
|
||||
activeColor = blue
|
||||
case .connecting:
|
||||
targetColors = [lightBlue.cgColor, blue.cgColor, blue.cgColor]
|
||||
targetScale = 0.3
|
||||
outerColor = nil
|
||||
activeColor = blue
|
||||
case .muted:
|
||||
targetColors = [pink.cgColor, purple.cgColor, purple.cgColor]
|
||||
targetScale = 0.85
|
||||
outerColor = UIColor(rgb: 0x24306b)
|
||||
activeColor = purple
|
||||
}
|
||||
self.updatedColors?(outerColor, activeColor)
|
||||
|
||||
self.maskGradientLayer.transform = CATransform3DMakeScale(targetScale, targetScale, 1.0)
|
||||
if let _ = previousType {
|
||||
self.maskGradientLayer.animateScale(from: initialScale, to: targetScale, duration: 0.3)
|
||||
} else if animated {
|
||||
self.maskGradientLayer.animateSpring(from: initialScale as NSNumber, to: targetScale as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
||||
}
|
||||
|
||||
self.foregroundGradientLayer.colors = targetColors
|
||||
if animated {
|
||||
self.foregroundGradientLayer.animate(from: initialColors as AnyObject, to: targetColors as AnyObject, keyPath: "colors", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
private func playMuteAnimation() {
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
if strongSelf.state != .connecting {
|
||||
return
|
||||
}
|
||||
strongSelf.maskBlobView.isHidden = true
|
||||
strongSelf.maskBlobView.stopAnimating()
|
||||
strongSelf.maskBlobView.layer.removeAllAnimations()
|
||||
})
|
||||
}
|
||||
|
||||
var animatingDisappearance = false
|
||||
private func playDeactivationAnimation() {
|
||||
if self.animatingDisappearance {
|
||||
return
|
||||
}
|
||||
self.animatingDisappearance = true
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.growingForegroundCircleLayer.isHidden = false
|
||||
CATransaction.commit()
|
||||
|
||||
self.disableGlowAnimations = true
|
||||
self.maskGradientLayer.removeAllAnimations()
|
||||
self.updateGlowAndGradientAnimations(type: .connecting, previousType: nil)
|
||||
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak self] _ in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
if strongSelf.state != .connecting {
|
||||
return
|
||||
}
|
||||
strongSelf.maskBlobView.isHidden = true
|
||||
strongSelf.maskBlobView.stopAnimating()
|
||||
strongSelf.maskBlobView.layer.removeAllAnimations()
|
||||
})
|
||||
|
||||
CATransaction.begin()
|
||||
let growthAnimation = CABasicAnimation(keyPath: "transform.scale")
|
||||
growthAnimation.fromValue = 0.0
|
||||
growthAnimation.toValue = 1.0
|
||||
growthAnimation.duration = 0.15
|
||||
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeOut)
|
||||
growthAnimation.isRemovedOnCompletion = false
|
||||
growthAnimation.fillMode = .forwards
|
||||
|
||||
CATransaction.setCompletionBlock {
|
||||
self.animatingDisappearance = false
|
||||
self.growingForegroundCircleLayer.isHidden = true
|
||||
self.disableGlowAnimations = false
|
||||
if self.state != .connecting {
|
||||
return
|
||||
}
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.maskGradientLayer.isHidden = true
|
||||
self.maskCircleLayer.isHidden = true
|
||||
self.growingForegroundCircleLayer.removeAllAnimations()
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
self.growingForegroundCircleLayer.add(growthAnimation, forKey: "insideGrowth")
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func playActivationAnimation(active: Bool) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.maskCircleLayer.isHidden = false
|
||||
self.maskProgressLayer.isHidden = true
|
||||
self.maskGradientLayer.isHidden = false
|
||||
CATransaction.commit()
|
||||
|
||||
self.maskGradientLayer.removeAllAnimations()
|
||||
self.updateGlowAndGradientAnimations(type: active ? .speaking : .active, previousType: nil)
|
||||
|
||||
self.maskBlobView.isHidden = false
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
||||
}
|
||||
|
||||
private func playConnectionAnimation(type: Gradient, completion: @escaping () -> Void) {
|
||||
CATransaction.begin()
|
||||
let initialRotation: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.transform.rotation.z") as? NSNumber)?.floatValue ?? 0.0)
|
||||
let initialStrokeEnd: CGFloat = CGFloat((self.maskProgressLayer.value(forKeyPath: "presentationLayer.strokeEnd") as? NSNumber)?.floatValue ?? 1.0)
|
||||
|
||||
self.maskProgressLayer.removeAnimation(forKey: "progressGrowth")
|
||||
self.maskProgressLayer.removeAnimation(forKey: "progressRotation")
|
||||
|
||||
let duration: Double = (1.0 - Double(initialStrokeEnd)) * 0.3
|
||||
|
||||
let growthAnimation = CABasicAnimation(keyPath: "strokeEnd")
|
||||
growthAnimation.fromValue = initialStrokeEnd
|
||||
growthAnimation.toValue = 1.0
|
||||
growthAnimation.duration = duration
|
||||
growthAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
||||
|
||||
let rotateAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
rotateAnimation.fromValue = initialRotation
|
||||
rotateAnimation.toValue = initialRotation + CGFloat.pi * 2
|
||||
rotateAnimation.isAdditive = true
|
||||
rotateAnimation.duration = duration
|
||||
rotateAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
||||
|
||||
let groupAnimation = CAAnimationGroup()
|
||||
groupAnimation.animations = [growthAnimation, rotateAnimation]
|
||||
groupAnimation.duration = duration
|
||||
|
||||
CATransaction.setCompletionBlock {
|
||||
var active = true
|
||||
if case .connecting = self.state {
|
||||
active = false
|
||||
}
|
||||
if active {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.foregroundCircleLayer.isHidden = false
|
||||
self.foregroundCircleLayer.transform = CATransform3DMakeScale(1.0, 1.0, 1.0)
|
||||
self.maskCircleLayer.isHidden = false
|
||||
self.maskProgressLayer.isHidden = true
|
||||
self.maskGradientLayer.isHidden = false
|
||||
CATransaction.commit()
|
||||
|
||||
completion()
|
||||
|
||||
self.updateGlowAndGradientAnimations(type: type, previousType: nil)
|
||||
|
||||
if case .connecting = self.state {
|
||||
} else {
|
||||
self.maskBlobView.isHidden = false
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
||||
}
|
||||
|
||||
self.updatedActive?(true)
|
||||
|
||||
CATransaction.begin()
|
||||
let shrinkAnimation = CABasicAnimation(keyPath: "transform.scale")
|
||||
shrinkAnimation.fromValue = 1.0
|
||||
shrinkAnimation.toValue = 0.00001
|
||||
shrinkAnimation.duration = 0.15
|
||||
shrinkAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeIn)
|
||||
shrinkAnimation.isRemovedOnCompletion = false
|
||||
shrinkAnimation.fillMode = .forwards
|
||||
|
||||
CATransaction.setCompletionBlock {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.foregroundCircleLayer.isHidden = true
|
||||
self.foregroundCircleLayer.transform = CATransform3DMakeScale(0.0, 0.0, 1.0)
|
||||
self.foregroundCircleLayer.removeAllAnimations()
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
self.foregroundCircleLayer.add(shrinkAnimation, forKey: "insideShrink")
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
self.maskProgressLayer.add(groupAnimation, forKey: "progressCompletion")
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private var maskIsCircle = true
|
||||
private func setupButtonAnimation() {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.backgroundCircleLayer.isHidden = true
|
||||
self.foregroundCircleLayer.isHidden = true
|
||||
self.maskCircleLayer.isHidden = false
|
||||
self.maskProgressLayer.isHidden = true
|
||||
self.maskGradientLayer.isHidden = true
|
||||
|
||||
let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: floor((self.bounds.height - VoiceChatActionButton.buttonHeight) / 2.0), width: self.bounds.width, height: VoiceChatActionButton.buttonHeight), cornerRadius: 10.0).cgPath
|
||||
self.maskCircleLayer.path = path
|
||||
self.maskIsCircle = false
|
||||
|
||||
CATransaction.commit()
|
||||
|
||||
self.updateGlowAndGradientAnimations(type: .muted, previousType: nil)
|
||||
|
||||
self.updatedActive?(true)
|
||||
}
|
||||
|
||||
private func playScheduledAnimation() {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
self.maskGradientLayer.isHidden = false
|
||||
CATransaction.commit()
|
||||
|
||||
let circleFrame = CGRect(origin: CGPoint(x: (self.bounds.width - buttonSize.width) / 2.0, y: (self.bounds.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
|
||||
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
|
||||
|
||||
let previousPath = self.maskCircleLayer.path
|
||||
self.maskCircleLayer.path = largerCirclePath
|
||||
self.maskIsCircle = true
|
||||
|
||||
self.maskCircleLayer.animateSpring(from: previousPath as AnyObject, to: largerCirclePath as AnyObject, keyPath: "path", duration: 0.6, initialVelocity: 0.0, damping: 100.0)
|
||||
|
||||
self.maskBlobView.isHidden = false
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0)
|
||||
|
||||
self.disableGlowAnimations = true
|
||||
self.maskGradientLayer.removeAllAnimations()
|
||||
self.maskGradientLayer.animateSpring(from: 0.3 as NSNumber, to: 0.85 as NSNumber, keyPath: "transform.scale", duration: 0.45, completion: { [weak self] _ in
|
||||
self?.disableGlowAnimations = false
|
||||
})
|
||||
}
|
||||
|
||||
var animationsEnabled: Bool = true {
|
||||
didSet {
|
||||
self.updateAnimations()
|
||||
}
|
||||
}
|
||||
|
||||
var isActive = false
|
||||
func updateAnimations() {
|
||||
if !self.isCurrentlyInHierarchy {
|
||||
self.foregroundGradientLayer.removeAllAnimations()
|
||||
self.growingForegroundCircleLayer.removeAllAnimations()
|
||||
self.maskGradientLayer.removeAllAnimations()
|
||||
self.maskProgressLayer.removeAllAnimations()
|
||||
self.maskBlobView.stopAnimating()
|
||||
return
|
||||
}
|
||||
|
||||
if !self.animationsEnabled {
|
||||
self.foregroundGradientLayer.removeAllAnimations()
|
||||
self.maskBlobView.stopAnimating()
|
||||
} else {
|
||||
self.setupGradientAnimations()
|
||||
}
|
||||
|
||||
switch self.state {
|
||||
case .connecting:
|
||||
self.updatedActive?(false)
|
||||
if let transition = self.transition {
|
||||
self.updateGlowScale(nil)
|
||||
if case .blob = transition {
|
||||
self.playDeactivationAnimation()
|
||||
} else if case .disabled = transition {
|
||||
self.playDeactivationAnimation()
|
||||
}
|
||||
self.transition = nil
|
||||
}
|
||||
self.setupProgressAnimations()
|
||||
self.isActive = false
|
||||
case let .blob(newActive):
|
||||
if let transition = self.transition {
|
||||
let type: Gradient = newActive ? .speaking : .active
|
||||
if transition == .connecting {
|
||||
self.playConnectionAnimation(type: type) { [weak self] in
|
||||
self?.isActive = newActive
|
||||
}
|
||||
} else if transition == .disabled {
|
||||
self.playActivationAnimation(active: newActive)
|
||||
self.transition = nil
|
||||
self.isActive = newActive
|
||||
self.updatedActive?(true)
|
||||
} else if case let .blob(previousActive) = transition {
|
||||
self.updateGlowAndGradientAnimations(type: type, previousType: previousActive ? .speaking : .active)
|
||||
self.transition = nil
|
||||
self.isActive = newActive
|
||||
}
|
||||
self.transition = nil
|
||||
} else {
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
}
|
||||
case .disabled:
|
||||
self.updatedActive?(true)
|
||||
self.isActive = false
|
||||
|
||||
if let transition = self.transition {
|
||||
if case .button = transition {
|
||||
self.playScheduledAnimation()
|
||||
} else if case .connecting = transition {
|
||||
self.playConnectionAnimation(type: .muted) { [weak self] in
|
||||
self?.isActive = false
|
||||
}
|
||||
} else if case let .blob(previousActive) = transition {
|
||||
self.updateGlowAndGradientAnimations(type: .muted, previousType: previousActive ? .speaking : .active)
|
||||
self.playMuteAnimation()
|
||||
}
|
||||
self.transition = nil
|
||||
} else {
|
||||
if self.maskBlobView.isHidden {
|
||||
self.updateGlowAndGradientAnimations(type: .muted, previousType: nil, animated: false)
|
||||
self.maskCircleLayer.isHidden = false
|
||||
self.maskProgressLayer.isHidden = true
|
||||
self.maskGradientLayer.isHidden = false
|
||||
self.maskBlobView.isHidden = false
|
||||
if self.animationsEnabled {
|
||||
self.maskBlobView.startAnimating()
|
||||
}
|
||||
self.maskBlobView.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.45)
|
||||
}
|
||||
}
|
||||
case .button:
|
||||
self.updatedActive?(true)
|
||||
self.isActive = false
|
||||
self.setupButtonAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
var isDark: Bool = false {
|
||||
didSet {
|
||||
if self.isDark != oldValue {
|
||||
self.updateColors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isSnap: Bool = false {
|
||||
didSet {
|
||||
if self.isSnap != oldValue {
|
||||
self.updateColors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var connectingColor: UIColor = UIColor(rgb: 0xb6b6bb) {
|
||||
didSet {
|
||||
if self.connectingColor.rgb != oldValue.rgb {
|
||||
self.updateColors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateColors() {
|
||||
let previousColor: CGColor = self.backgroundCircleLayer.fillColor ?? greyColor.cgColor
|
||||
let targetColor: CGColor
|
||||
if self.isSnap {
|
||||
targetColor = self.connectingColor.cgColor
|
||||
} else if self.isDark {
|
||||
targetColor = secondaryGreyColor.cgColor
|
||||
} else {
|
||||
targetColor = greyColor.cgColor
|
||||
}
|
||||
self.backgroundCircleLayer.fillColor = targetColor
|
||||
self.foregroundCircleLayer.fillColor = targetColor
|
||||
self.growingForegroundCircleLayer.fillColor = targetColor
|
||||
self.backgroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
||||
self.foregroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
||||
self.growingForegroundCircleLayer.animate(from: previousColor, to: targetColor, keyPath: "fillColor", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.3)
|
||||
}
|
||||
|
||||
func update(state: State, animated: Bool) {
|
||||
var animated = animated
|
||||
var hadState = true
|
||||
if !self.hasState {
|
||||
hadState = false
|
||||
self.hasState = true
|
||||
animated = false
|
||||
}
|
||||
|
||||
if state != self.state || !hadState {
|
||||
if animated {
|
||||
self.transition = self.state
|
||||
}
|
||||
self.state = state
|
||||
}
|
||||
|
||||
self.updateAnimations()
|
||||
}
|
||||
|
||||
var previousSize: CGSize?
|
||||
override func layout() {
|
||||
super.layout()
|
||||
|
||||
let sizeUpdated = self.previousSize != self.bounds.size
|
||||
self.previousSize = self.bounds.size
|
||||
|
||||
let bounds = CGRect(x: (self.bounds.width - areaSize.width) / 2.0, y: (self.bounds.height - areaSize.height) / 2.0, width: areaSize.width, height: areaSize.height)
|
||||
let center = bounds.center
|
||||
|
||||
self.maskBlobView.frame = CGRect(origin: CGPoint(x: bounds.minX + (bounds.width - blobSize.width) / 2.0, y: bounds.minY + (bounds.height - blobSize.height) / 2.0), size: blobSize)
|
||||
|
||||
let circleFrame = CGRect(origin: CGPoint(x: bounds.minX + (bounds.width - buttonSize.width) / 2.0, y: bounds.minY + (bounds.height - buttonSize.height) / 2.0), size: buttonSize)
|
||||
self.backgroundCircleLayer.frame = circleFrame
|
||||
self.foregroundCircleLayer.position = center
|
||||
self.foregroundCircleLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: circleFrame.width - progressLineWidth, height: circleFrame.height - progressLineWidth))
|
||||
self.growingForegroundCircleLayer.position = center
|
||||
self.growingForegroundCircleLayer.bounds = self.foregroundCircleLayer.bounds
|
||||
self.maskCircleLayer.frame = self.bounds
|
||||
|
||||
if sizeUpdated && self.maskIsCircle {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
let circleFrame = CGRect(origin: CGPoint(x: (self.bounds.width - buttonSize.width) / 2.0, y: (self.bounds.height - buttonSize.height) / 2.0), size: buttonSize).insetBy(dx: -progressLineWidth / 2.0, dy: -progressLineWidth / 2.0)
|
||||
let largerCirclePath = UIBezierPath(roundedRect: CGRect(x: circleFrame.minX, y: circleFrame.minY, width: circleFrame.width, height: circleFrame.height), cornerRadius: circleFrame.width / 2.0).cgPath
|
||||
|
||||
self.maskCircleLayer.path = largerCirclePath
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
self.maskProgressLayer.frame = circleFrame.insetBy(dx: -3.0, dy: -3.0)
|
||||
self.foregroundView.frame = self.bounds
|
||||
self.foregroundGradientLayer.frame = self.bounds
|
||||
self.maskGradientLayer.position = center
|
||||
self.maskGradientLayer.bounds = bounds
|
||||
self.maskView.frame = self.bounds
|
||||
}
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import ManagedAnimationNode
|
||||
|
||||
public enum VoiceChatActionButtonIconAnimationState: Equatable {
|
||||
case empty
|
||||
case start
|
||||
case subscribe
|
||||
case unsubscribe
|
||||
case unmute
|
||||
case mute
|
||||
case hand
|
||||
}
|
||||
|
||||
public final class VoiceChatActionButtonIconNode: ManagedAnimationNode {
|
||||
private let isColored: Bool
|
||||
private var iconState: VoiceChatActionButtonIconAnimationState = .mute
|
||||
|
||||
public init(isColored: Bool) {
|
||||
self.isColored = isColored
|
||||
super.init(size: CGSize(width: 100.0, height: 100.0))
|
||||
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmute"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.1))
|
||||
}
|
||||
|
||||
public func enqueueState(_ state: VoiceChatActionButtonIconAnimationState) {
|
||||
guard self.iconState != state else {
|
||||
return
|
||||
}
|
||||
|
||||
let previousState = self.iconState
|
||||
self.iconState = state
|
||||
|
||||
if state != .empty {
|
||||
self.alpha = 1.0
|
||||
}
|
||||
switch previousState {
|
||||
case .empty:
|
||||
switch state {
|
||||
case .start:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .subscribe:
|
||||
switch state {
|
||||
case .unsubscribe:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminder")))
|
||||
case .mute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToMute")))
|
||||
case .hand:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToRaiseHand")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .unsubscribe:
|
||||
switch state {
|
||||
case .subscribe:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminder")))
|
||||
case .mute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToMute")))
|
||||
case .hand:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToRaiseHand")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .start:
|
||||
switch state {
|
||||
case .mute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .unmute:
|
||||
switch state {
|
||||
case .mute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceMute")))
|
||||
case .hand:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmuteToRaiseHand")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .mute:
|
||||
switch state {
|
||||
case .start:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceStart"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
|
||||
case .unmute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceUnmute")))
|
||||
case .hand:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceMuteToRaiseHand")))
|
||||
case .subscribe:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceSetReminderToRaiseHand"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
|
||||
case .unsubscribe:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceCancelReminderToRaiseHand"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.001))
|
||||
case .empty:
|
||||
self.alpha = 0.0
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .hand:
|
||||
switch state {
|
||||
case .mute, .unmute:
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local("VoiceRaiseHandToMute")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func playRandomAnimation() {
|
||||
if case .hand = self.iconState {
|
||||
if let next = self.trackStack.first, case let .local(name) = next.source, name.hasPrefix("VoiceHand_") {
|
||||
return
|
||||
}
|
||||
|
||||
var useTiredAnimation = false
|
||||
var useAngryAnimation = false
|
||||
let val = Float.random(in: 0.0..<1.0)
|
||||
if val <= 0.01 {
|
||||
useTiredAnimation = true
|
||||
} else if val <= 0.05 {
|
||||
useAngryAnimation = true
|
||||
}
|
||||
|
||||
let normalAnimations = ["VoiceHand_1", "VoiceHand_2", "VoiceHand_3", "VoiceHand_4", "VoiceHand_7", "VoiceHand_8"]
|
||||
let tiredAnimations = ["VoiceHand_5", "VoiceHand_6"]
|
||||
let angryAnimations = ["VoiceHand_9", "VoiceHand_10"]
|
||||
let animations: [String]
|
||||
if useTiredAnimation {
|
||||
animations = tiredAnimations
|
||||
} else if useAngryAnimation {
|
||||
animations = angryAnimations
|
||||
} else {
|
||||
animations = normalAnimations
|
||||
}
|
||||
if let animationName = animations.randomElement() {
|
||||
self.trackTo(item: ManagedAnimationItem(source: .local(animationName)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import AnimationUI
|
||||
import AppBundle
|
||||
|
||||
public final class VoiceChatRaiseHandNode: ASDisplayNode {
|
||||
private let animationNode: AnimationNode
|
||||
private let color: UIColor?
|
||||
private var playedOnce = false
|
||||
|
||||
public init(color: UIColor?) {
|
||||
self.color = color
|
||||
if let color = color, let url = getAppBundle().url(forResource: "anim_hand1", withExtension: "json"), let data = try? Data(contentsOf: url) {
|
||||
self.animationNode = AnimationNode(animationData: transformedWithColors(data: data, colors: [(UIColor(rgb: 0xffffff), color)]))
|
||||
} else {
|
||||
self.animationNode = AnimationNode(animation: "anim_hand1", colors: nil, scale: 0.5)
|
||||
}
|
||||
super.init()
|
||||
self.addSubnode(self.animationNode)
|
||||
}
|
||||
|
||||
public func playRandomAnimation() {
|
||||
guard self.playedOnce else {
|
||||
self.playedOnce = true
|
||||
self.animationNode.play()
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.animationNode.isPlaying else {
|
||||
self.animationNode.completion = { [weak self] in
|
||||
self?.playRandomAnimation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.animationNode.completion = nil
|
||||
if let animationName = ["anim_hand1", "anim_hand2", "anim_hand3", "anim_hand4"].randomElement() {
|
||||
if let color = color, let url = getAppBundle().url(forResource: animationName, withExtension: "json"), let data = try? Data(contentsOf: url) {
|
||||
self.animationNode.setAnimation(data: transformedWithColors(data: data, colors: [(UIColor(rgb: 0xffffff), color)]))
|
||||
} else {
|
||||
self.animationNode.setAnimation(name: animationName)
|
||||
}
|
||||
self.animationNode.play()
|
||||
}
|
||||
}
|
||||
|
||||
override public func layout() {
|
||||
super.layout()
|
||||
self.animationNode.frame = self.bounds
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"provides-namespace" : true
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user