mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
[WIP] Private Call UI
This commit is contained in:
parent
04705d4d09
commit
e26df40077
@ -43,7 +43,7 @@ swift_library(
|
||||
deps = [
|
||||
"//submodules/Display",
|
||||
"//submodules/MetalEngine",
|
||||
"//submodules/TelegramUI/Components/DustEffect",
|
||||
"//submodules/TelegramUI/Components/Calls/CallScreen",
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -2,10 +2,16 @@ import Foundation
|
||||
import UIKit
|
||||
import MetalEngine
|
||||
import Display
|
||||
import DustEffect
|
||||
import CallScreen
|
||||
import ComponentFlow
|
||||
|
||||
public final class ViewController: UIViewController {
|
||||
private var dustLayer: DustEffectLayer?
|
||||
private var callScreenView: PrivateCallScreen?
|
||||
private var callState: PrivateCallScreen.State = PrivateCallScreen.State(
|
||||
lifecycleState: .connecting,
|
||||
name: "Emma Walters",
|
||||
avatarImage: UIImage(named: "test")
|
||||
)
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@ -13,35 +19,35 @@ public final class ViewController: UIViewController {
|
||||
self.view.layer.addSublayer(MetalEngine.shared.rootLayer)
|
||||
MetalEngine.shared.rootLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -101.0), size: CGSize(width: 100.0, height: 100.0))
|
||||
|
||||
self.reset()
|
||||
|
||||
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageTap(_:))))
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
self.view.backgroundColor = .black
|
||||
|
||||
SharedDisplayLinkDriver.shared.updateForegroundState(true)
|
||||
|
||||
let callScreenView = PrivateCallScreen(frame: self.view.bounds)
|
||||
self.callScreenView = callScreenView
|
||||
self.view.addSubview(callScreenView)
|
||||
|
||||
self.update(size: self.view.bounds.size, transition: .immediate)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.dustLayer?.removeFromSuperlayer()
|
||||
|
||||
let dustLayer = DustEffectLayer()
|
||||
self.dustLayer = dustLayer
|
||||
dustLayer.frame = self.view.bounds
|
||||
|
||||
self.view.layer.addSublayer(dustLayer)
|
||||
}
|
||||
|
||||
@objc private func imageTap(_ recognizer: UITapGestureRecognizer) {
|
||||
if case .ended = recognizer.state {
|
||||
guard let dustLayer else {
|
||||
return
|
||||
}
|
||||
|
||||
let image = UIImage(named: "test")!
|
||||
let itemSize = CGSize(width: 200.0, height: 200.0)
|
||||
let itemFrame = CGRect(origin: CGPoint(x: floor((self.view.bounds.width - itemSize.width) * 0.5), y: floor((self.view.bounds.height - itemSize.height) * 0.5)), size: itemSize)
|
||||
dustLayer.addItem(frame: itemFrame, image: image)
|
||||
private func update(size: CGSize, transition: Transition) {
|
||||
guard let callScreenView = self.callScreenView else {
|
||||
return
|
||||
}
|
||||
|
||||
transition.setFrame(view: callScreenView, frame: CGRect(origin: CGPoint(), size: size))
|
||||
let insets: UIEdgeInsets
|
||||
if size.width < size.height {
|
||||
insets = UIEdgeInsets(top: 44.0, left: 0.0, bottom: 0.0, right: 0.0)
|
||||
} else {
|
||||
insets = UIEdgeInsets(top: 0.0, left: 44.0, bottom: 0.0, right: 44.0)
|
||||
}
|
||||
callScreenView.update(size: size, insets: insets, screenCornerRadius: 55.0, state: self.callState, transition: transition)
|
||||
}
|
||||
|
||||
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
|
||||
self.update(size: size, transition: .easeInOut(duration: 0.3))
|
||||
}
|
||||
}
|
||||
|
@ -476,6 +476,10 @@ public struct Transition {
|
||||
self.setScale(layer: view.layer, scale: scale, delay: delay, completion: completion)
|
||||
}
|
||||
|
||||
public func setScaleWithSpring(view: UIView, scale: CGFloat, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) {
|
||||
self.setScaleWithSpring(layer: view.layer, scale: scale, delay: delay, completion: completion)
|
||||
}
|
||||
|
||||
public func setScale(layer: CALayer, scale: CGFloat, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) {
|
||||
let t = layer.presentation()?.transform ?? layer.transform
|
||||
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
|
||||
@ -511,6 +515,40 @@ public struct Transition {
|
||||
}
|
||||
}
|
||||
|
||||
public func setScaleWithSpring(layer: CALayer, scale: CGFloat, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) {
|
||||
let t = layer.presentation()?.transform ?? layer.transform
|
||||
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
|
||||
if currentScale == scale {
|
||||
if let animation = layer.animation(forKey: "transform.scale") as? CABasicAnimation, let toValue = animation.toValue as? NSNumber {
|
||||
if toValue.doubleValue == scale {
|
||||
completion?(true)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
completion?(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
switch self.animation {
|
||||
case .none:
|
||||
layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
|
||||
completion?(true)
|
||||
case let .curve(duration, _):
|
||||
let previousScale = currentScale
|
||||
layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
|
||||
layer.animateSpring(
|
||||
from: previousScale as NSNumber,
|
||||
to: scale as NSNumber,
|
||||
keyPath: "transform.scale",
|
||||
duration: duration,
|
||||
delay: delay,
|
||||
removeOnCompletion: true,
|
||||
additive: false,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func setTransform(view: UIView, transform: CATransform3D, completion: ((Bool) -> Void)? = nil) {
|
||||
self.setTransform(layer: view.layer, transform: transform, completion: completion)
|
||||
}
|
||||
|
@ -121,6 +121,10 @@ open class MetalEngineSubjectLayer: SimpleLayer {
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
||||
deinit {
|
||||
MetalEngine.shared.impl.removeLayerSurfaceAllocation(layer: self)
|
||||
}
|
||||
|
||||
override public init(layer: Any) {
|
||||
super.init(layer: layer)
|
||||
}
|
||||
@ -485,6 +489,10 @@ public final class MetalEngine {
|
||||
let texture: MTLTexture
|
||||
let packContext: ShelfPackContext
|
||||
|
||||
var isEmpty: Bool {
|
||||
return self.packContext.isEmpty
|
||||
}
|
||||
|
||||
init?(id: Int, device: MTLDevice, width: Int, height: Int) {
|
||||
self.id = id
|
||||
self.width = width
|
||||
@ -692,13 +700,11 @@ public final class MetalEngine {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func addSurface(minSize: CGSize) -> Surface? {
|
||||
private func addSurface(width: Int, height: Int) -> Surface? {
|
||||
let surfaceId = self.nextSurfaceId
|
||||
self.nextSurfaceId += 1
|
||||
|
||||
let surfaceWidth = max(1024, alignUp(Int(minSize.width), alignment: 64))
|
||||
let surfaceHeight = max(512, alignUp(Int(minSize.height), alignment: 64))
|
||||
let surface = Surface(id: surfaceId, device: self.device, width: surfaceWidth, height: surfaceHeight)
|
||||
let surface = Surface(id: surfaceId, device: self.device, width: width, height: height)
|
||||
self.surfaces[surfaceId] = surface
|
||||
|
||||
return surface
|
||||
@ -727,16 +733,32 @@ public final class MetalEngine {
|
||||
updatedSurfaceId = updatedAllocation.surfaceId
|
||||
layer.contentsRect = updatedAllocation.effectivePhase.contentsRect
|
||||
} else {
|
||||
for (_, surface) in self.surfaces {
|
||||
if let allocation = surface.allocateIfPossible(renderingParameters: renderingParameters) {
|
||||
layer.surfaceAllocation = allocation
|
||||
layer.contentsRect = allocation.effectivePhase.contentsRect
|
||||
updatedSurfaceId = allocation.surfaceId
|
||||
break
|
||||
if renderingParameters.allocationWidth >= 1024 || renderingParameters.allocationHeight >= 1024 {
|
||||
let surfaceWidth = max(1024, alignUp(renderingParameters.allocationWidth * 2, alignment: 64))
|
||||
let surfaceHeight = max(512, alignUp(renderingParameters.allocationHeight, alignment: 64))
|
||||
|
||||
if let surface = self.addSurface(width: surfaceWidth, height: surfaceHeight) {
|
||||
if let allocation = surface.allocateIfPossible(renderingParameters: renderingParameters) {
|
||||
layer.surfaceAllocation = allocation
|
||||
layer.contentsRect = allocation.effectivePhase.contentsRect
|
||||
updatedSurfaceId = allocation.surfaceId
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (_, surface) in self.surfaces {
|
||||
if let allocation = surface.allocateIfPossible(renderingParameters: renderingParameters) {
|
||||
layer.surfaceAllocation = allocation
|
||||
layer.contentsRect = allocation.effectivePhase.contentsRect
|
||||
updatedSurfaceId = allocation.surfaceId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if updatedSurfaceId == nil {
|
||||
if let surface = self.addSurface(minSize: CGSize(width: CGFloat(renderingParameters.allocationWidth) * 2.0, height: CGFloat(renderingParameters.allocationHeight))) {
|
||||
let surfaceWidth = alignUp(2048, alignment: 64)
|
||||
let surfaceHeight = alignUp(2048, alignment: 64)
|
||||
|
||||
if let surface = self.addSurface(width: surfaceWidth, height: surfaceHeight) {
|
||||
if let allocation = surface.allocateIfPossible(renderingParameters: renderingParameters) {
|
||||
layer.surfaceAllocation = allocation
|
||||
layer.contentsRect = allocation.effectivePhase.contentsRect
|
||||
@ -755,12 +777,30 @@ public final class MetalEngine {
|
||||
if previousSurfaceId != updatedSurfaceId {
|
||||
if let updatedSurfaceId {
|
||||
layer.contents = self.surfaces[updatedSurfaceId]?.ioSurface
|
||||
|
||||
if previousSurfaceId != nil {
|
||||
#if DEBUG
|
||||
print("Changing surface for layer \(layer) (\(renderSpec.allocationWidth)x\(renderSpec.allocationHeight)")
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
layer.contents = nil
|
||||
|
||||
if layer.internalId != -1 {
|
||||
#if DEBUG
|
||||
print("Unable to allocate rendering surface for layer \(layer) (\(renderSpec.allocationWidth)x\(renderSpec.allocationHeight)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeLayerSurfaceAllocation(layer: MetalEngineSubjectLayer) {
|
||||
if let allocation = layer.surfaceAllocation {
|
||||
self.scheduledClearAllocations.append(allocation)
|
||||
}
|
||||
}
|
||||
|
||||
func addSubjectNeedsUpdate(subject: MetalEngineSubject) {
|
||||
let internalData: MetalEngineSubjectInternalData
|
||||
if let current = subject.internalData {
|
||||
@ -933,7 +973,7 @@ public final class MetalEngine {
|
||||
}
|
||||
|
||||
let subRect = surfaceAllocation.effectivePhase.subRect
|
||||
renderEncoder.setScissorRect(MTLScissorRect(x: Int(subRect.minX), y: Int(subRect.minY), width: Int(subRect.width), height: Int(subRect.height)))
|
||||
renderEncoder.setScissorRect(MTLScissorRect(x: Int(subRect.minX), y: surface.height - Int(subRect.maxY), width: Int(subRect.width), height: Int(subRect.height)))
|
||||
renderToLayerOperation.commands(renderEncoder, RenderLayerPlacement(effectiveRect: surfaceAllocation.effectivePhase.renderingRect))
|
||||
}
|
||||
}
|
||||
@ -954,6 +994,16 @@ public final class MetalEngine {
|
||||
}
|
||||
}
|
||||
|
||||
var removeSurfaceIds: [Int] = []
|
||||
for (id, surface) in self.surfaces {
|
||||
if surface.isEmpty {
|
||||
removeSurfaceIds.append(id)
|
||||
}
|
||||
}
|
||||
for id in removeSurfaceIds {
|
||||
self.surfaces.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if targetEnvironment(simulator)
|
||||
if #available(iOS 13.0, *) {
|
||||
|
@ -32,6 +32,8 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP
|
||||
var dismissedInteractively: (() -> Void)?
|
||||
var dismissAllTooltips: (() -> Void)?
|
||||
|
||||
private var validLayout: (layout: ContainerViewLayout, navigationBarHeight: CGFloat)?
|
||||
|
||||
init(
|
||||
sharedContext: SharedAccountContext,
|
||||
account: Account,
|
||||
@ -60,6 +62,8 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP
|
||||
}
|
||||
|
||||
func updateCallState(_ callState: PresentationCallState) {
|
||||
|
||||
|
||||
if case let .terminated(id, _, reportRating) = callState.state, let callId = id {
|
||||
if reportRating {
|
||||
self.presentCallRating?(callId, self.call.isVideo)
|
||||
@ -84,6 +88,13 @@ final class CallControllerNodeV2: ViewControllerTracingNode, CallControllerNodeP
|
||||
|
||||
}
|
||||
|
||||
private func update(transition: ContainedViewLayoutTransition) {
|
||||
guard let (layout, navigationBarHeight) = self.validLayout else {
|
||||
return
|
||||
}
|
||||
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
|
||||
}
|
||||
|
||||
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
transition.updateFrame(view: self.callScreen, frame: CGRect(origin: CGPoint(), size: layout.size))
|
||||
self.callScreen.update(size: layout.size, insets: layout.insets(options: [.statusBar]))
|
||||
|
@ -64,6 +64,7 @@ swift_library(
|
||||
"//submodules/Display",
|
||||
"//submodules/MetalEngine",
|
||||
"//submodules/ComponentFlow",
|
||||
"//submodules/SSignalKit/SwiftSignalKit",
|
||||
"//submodules/TelegramUI/Components/AnimatedTextComponent",
|
||||
],
|
||||
visibility = [
|
||||
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "smoothGradient 0.4.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
@ -1,11 +1,31 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
|
||||
final class AvatarLayer: SimpleLayer {
|
||||
struct Params: Equatable {
|
||||
var size: CGSize
|
||||
var cornerRadius: CGFloat
|
||||
var isExpanded: Bool
|
||||
|
||||
init(size: CGSize, cornerRadius: CGFloat, isExpanded: Bool) {
|
||||
self.size = size
|
||||
self.cornerRadius = cornerRadius
|
||||
self.isExpanded = isExpanded
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var params: Params?
|
||||
private var rasterizedImage: UIImage?
|
||||
private var isAnimating: Bool = false
|
||||
|
||||
var image: UIImage? {
|
||||
didSet {
|
||||
if let image = self.image {
|
||||
if self.image !== image {
|
||||
self.updateImage()
|
||||
}
|
||||
/*if let image = self.image {
|
||||
let imageSize = CGSize(width: 136.0, height: 136.0)
|
||||
let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: imageSize), format: .preferred())
|
||||
let image = renderer.image { context in
|
||||
@ -20,12 +40,15 @@ final class AvatarLayer: SimpleLayer {
|
||||
self.contents = image.cgImage
|
||||
} else {
|
||||
self.contents = nil
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
self.contentsGravity = .resizeAspectFill
|
||||
self.masksToBounds = true
|
||||
}
|
||||
|
||||
override init(layer: Any) {
|
||||
@ -36,6 +59,61 @@ final class AvatarLayer: SimpleLayer {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(size: CGSize) {
|
||||
private func updateImage() {
|
||||
guard let params else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.isAnimating || params.isExpanded {
|
||||
self.contents = self.image?.cgImage
|
||||
} else {
|
||||
self.contents = self.image.flatMap({ image -> UIImage? in
|
||||
let imageSize = CGSize(width: min(params.size.width, params.size.height), height: min(params.size.width, params.size.height))
|
||||
|
||||
return generateImage(imageSize, contextGenerator: { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
if params.cornerRadius == size.width * 0.5 {
|
||||
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
|
||||
} else {
|
||||
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: params.cornerRadius).cgPath)
|
||||
}
|
||||
context.clip()
|
||||
|
||||
if let cgImage = image.cgImage {
|
||||
context.draw(cgImage, in: CGRect(origin: CGPoint(), size: size))
|
||||
}
|
||||
})
|
||||
})?.cgImage
|
||||
}
|
||||
}
|
||||
|
||||
func update(size: CGSize, isExpanded: Bool, cornerRadius: CGFloat, transition: Transition) {
|
||||
let params = Params(size: size, cornerRadius: cornerRadius, isExpanded: isExpanded)
|
||||
if self.params == params {
|
||||
return
|
||||
}
|
||||
let previousCornerRadius = self.params?.cornerRadius
|
||||
self.params = params
|
||||
|
||||
if previousCornerRadius != params.cornerRadius {
|
||||
self.masksToBounds = true
|
||||
self.isAnimating = true
|
||||
self.updateImage()
|
||||
|
||||
if let previousCornerRadius, self.animation(forKey: "cornerRadius") == nil {
|
||||
self.cornerRadius = previousCornerRadius
|
||||
}
|
||||
transition.setCornerRadius(layer: self, cornerRadius: cornerRadius, completion: { [weak self] completed in
|
||||
guard let self, completed else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isAnimating = false
|
||||
self.masksToBounds = false
|
||||
self.cornerRadius = 0.0
|
||||
|
||||
self.updateImage()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,50 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
|
||||
final class ButtonGroupView: UIView, ContentOverlayView {
|
||||
enum Key: Hashable {
|
||||
case audio
|
||||
case video
|
||||
case mic
|
||||
case close
|
||||
final class Button {
|
||||
enum Content: Equatable {
|
||||
enum Key: Hashable {
|
||||
case speaker
|
||||
case video
|
||||
case microphone
|
||||
case end
|
||||
}
|
||||
|
||||
case speaker(isActive: Bool)
|
||||
case video(isActive: Bool)
|
||||
case microphone(isMuted: Bool)
|
||||
case end
|
||||
|
||||
var key: Key {
|
||||
switch self {
|
||||
case .speaker:
|
||||
return .speaker
|
||||
case .video:
|
||||
return .video
|
||||
case .microphone:
|
||||
return .microphone
|
||||
case .end:
|
||||
return .end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content: Content
|
||||
let action: () -> Void
|
||||
|
||||
init(content: Content, action: @escaping () -> Void) {
|
||||
self.content = content
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
let overlayMaskLayer: CALayer
|
||||
private var buttons: [Key: ContentOverlayButton] = [:]
|
||||
|
||||
var audioPressed: (() -> Void)?
|
||||
var toggleVideo: (() -> Void)?
|
||||
private var buttons: [Button]?
|
||||
private var buttonViews: [Button.Content.Key: ContentOverlayButton] = [:]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.overlayMaskLayer = SimpleLayer()
|
||||
@ -66,69 +96,74 @@ final class ButtonGroupView: UIView, ContentOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
func update(size: CGSize) {
|
||||
var keys: [Key] = []
|
||||
keys.append(.audio)
|
||||
keys.append(.video)
|
||||
keys.append(.mic)
|
||||
keys.append(.close)
|
||||
func update(size: CGSize, buttons: [Button], transition: Transition) {
|
||||
self.buttons = buttons
|
||||
|
||||
let buttonSize: CGFloat = 56.0
|
||||
let buttonSpacing: CGFloat = 36.0
|
||||
|
||||
let buttonY: CGFloat = size.height - 86.0 - buttonSize
|
||||
var buttonX: CGFloat = floor((size.width - buttonSize * CGFloat(keys.count) - buttonSpacing * CGFloat(keys.count - 1)) * 0.5)
|
||||
var buttonX: CGFloat = floor((size.width - buttonSize * CGFloat(buttons.count) - buttonSpacing * CGFloat(buttons.count - 1)) * 0.5)
|
||||
|
||||
for key in keys {
|
||||
let button: ContentOverlayButton
|
||||
if let current = self.buttons[key] {
|
||||
button = current
|
||||
} else {
|
||||
button = ContentOverlayButton(frame: CGRect())
|
||||
self.addSubview(button)
|
||||
self.buttons[key] = button
|
||||
|
||||
let image: UIImage?
|
||||
switch key {
|
||||
case .audio:
|
||||
image = UIImage(named: "Call/Speaker")
|
||||
button.action = { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.audioPressed?()
|
||||
}
|
||||
case .video:
|
||||
image = UIImage(named: "Call/Video")
|
||||
button.action = { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.toggleVideo?()
|
||||
}
|
||||
case .mic:
|
||||
image = UIImage(named: "Call/Mute")
|
||||
case .close:
|
||||
image = UIImage(named: "Call/End")
|
||||
}
|
||||
|
||||
button.setImage(image?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
button.imageView?.tintColor = .white
|
||||
for button in buttons {
|
||||
let title: String
|
||||
let image: UIImage?
|
||||
let isActive: Bool
|
||||
var isDestructive: Bool = false
|
||||
switch button.content {
|
||||
case let .speaker(isActiveValue):
|
||||
title = "speaker"
|
||||
image = UIImage(named: "Call/Speaker")
|
||||
isActive = isActiveValue
|
||||
case let .video(isActiveValue):
|
||||
title = "video"
|
||||
image = UIImage(named: "Call/Video")
|
||||
isActive = isActiveValue
|
||||
case let .microphone(isActiveValue):
|
||||
title = "mute"
|
||||
image = UIImage(named: "Call/Mute")
|
||||
isActive = isActiveValue
|
||||
case .end:
|
||||
title = "end"
|
||||
image = UIImage(named: "Call/End")
|
||||
isActive = false
|
||||
isDestructive = true
|
||||
}
|
||||
|
||||
button.frame = CGRect(origin: CGPoint(x: buttonX, y: buttonY), size: CGSize(width: buttonSize, height: buttonSize))
|
||||
let buttonView: ContentOverlayButton
|
||||
if let current = self.buttonViews[button.content.key] {
|
||||
buttonView = current
|
||||
} else {
|
||||
buttonView = ContentOverlayButton(frame: CGRect())
|
||||
self.addSubview(buttonView)
|
||||
self.buttonViews[button.content.key] = buttonView
|
||||
|
||||
let key = button.content.key
|
||||
buttonView.action = { [weak self] in
|
||||
guard let self, let button = self.buttons?.first(where: { $0.content.key == key }) else {
|
||||
return
|
||||
}
|
||||
button.action()
|
||||
}
|
||||
}
|
||||
|
||||
buttonView.frame = CGRect(origin: CGPoint(x: buttonX, y: buttonY), size: CGSize(width: buttonSize, height: buttonSize))
|
||||
buttonView.update(size: CGSize(width: buttonSize, height: buttonSize), image: image, isSelected: isActive, isDestructive: isDestructive, title: title, transition: transition)
|
||||
buttonX += buttonSize + buttonSpacing
|
||||
}
|
||||
|
||||
var removeKeys: [Key] = []
|
||||
for (key, button) in self.buttons {
|
||||
if !keys.contains(key) {
|
||||
var removeKeys: [Button.Content.Key] = []
|
||||
for (key, buttonView) in self.buttonViews {
|
||||
if !buttons.contains(where: { $0.content.key == key }) {
|
||||
removeKeys.append(key)
|
||||
button.removeFromSuperview()
|
||||
transition.setScale(view: buttonView, scale: 0.001)
|
||||
transition.setAlpha(view: buttonView, alpha: 0.0, completion: { [weak buttonView] _ in
|
||||
buttonView?.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
}
|
||||
for key in removeKeys {
|
||||
self.buttons.removeValue(forKey: key)
|
||||
self.buttonViews.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import Foundation
|
||||
import MetalKit
|
||||
import UIKit
|
||||
import MetalEngine
|
||||
import ComponentFlow
|
||||
|
||||
private func shiftArray(array: [SIMD2<Float>], offset: Int) -> [SIMD2<Float>] {
|
||||
var newArray = array
|
||||
@ -160,15 +161,16 @@ final class CallBackgroundLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(stateIndex: Int, animated: Bool) {
|
||||
func update(stateIndex: Int, transition: Transition) {
|
||||
if self.stateIndex != stateIndex {
|
||||
self.stateIndex = stateIndex
|
||||
if animated {
|
||||
if !transition.animation.isImmediate {
|
||||
self.phaseAcceleration.animate(from: 1.0, to: 0.0, duration: 2.0, curve: .easeInOut)
|
||||
self.colorTransition.animate(to: self.colorSets[stateIndex % self.colorSets.count], duration: 0.3, curve: .easeInOut)
|
||||
} else {
|
||||
self.colorTransition.set(to: self.colorSets[stateIndex % self.colorSets.count])
|
||||
}
|
||||
self.setNeedsUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,23 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
|
||||
final class ContentOverlayButton: UIButton, ContentOverlayView {
|
||||
final class ContentOverlayButton: HighlightTrackingButton, ContentOverlayView {
|
||||
private struct ContentParams: Equatable {
|
||||
var size: CGSize
|
||||
var image: UIImage?
|
||||
var isSelected: Bool
|
||||
var isDestructive: Bool
|
||||
|
||||
init(size: CGSize, image: UIImage?, isSelected: Bool, isDestructive: Bool) {
|
||||
self.size = size
|
||||
self.image = image
|
||||
self.isSelected = isSelected
|
||||
self.isDestructive = isDestructive
|
||||
}
|
||||
}
|
||||
|
||||
var overlayMaskLayer: CALayer {
|
||||
return self.overlayBackgroundLayer
|
||||
}
|
||||
@ -11,19 +26,21 @@ final class ContentOverlayButton: UIButton, ContentOverlayView {
|
||||
return MirroringLayer.self
|
||||
}
|
||||
|
||||
private let overlayBackgroundLayer: SimpleLayer
|
||||
private let backgroundLayer: SimpleLayer
|
||||
|
||||
private var internalHighlighted = false
|
||||
|
||||
private var internalHighligthedChanged: (Bool) -> Void = { _ in }
|
||||
var highligthedChanged: (Bool) -> Void = { _ in }
|
||||
|
||||
var action: (() -> Void)?
|
||||
|
||||
private let overlayBackgroundLayer: SimpleLayer
|
||||
|
||||
private let contentView: UIImageView
|
||||
private var currentContentViewIsSelected: Bool?
|
||||
|
||||
private let textView: TextView
|
||||
|
||||
private var contentParams: ContentParams?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.overlayBackgroundLayer = SimpleLayer()
|
||||
self.backgroundLayer = SimpleLayer()
|
||||
self.contentView = UIImageView()
|
||||
self.textView = TextView()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
@ -38,26 +55,36 @@ final class ContentOverlayButton: UIButton, ContentOverlayView {
|
||||
UIGraphicsPopContext()
|
||||
}.cgImage
|
||||
|
||||
self.backgroundLayer.contents = renderer.image { context in
|
||||
UIGraphicsPushContext(context.cgContext)
|
||||
context.cgContext.setFillColor(UIColor.white.withAlphaComponent(0.2).cgColor)
|
||||
context.cgContext.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size, height: size)))
|
||||
UIGraphicsPopContext()
|
||||
}.cgImage
|
||||
self.backgroundLayer.frame = CGRect(origin: CGPoint(), size: CGSize(width: size, height: size))
|
||||
|
||||
(self.layer as? MirroringLayer)?.targetLayer = self.overlayBackgroundLayer
|
||||
|
||||
self.layer.addSublayer(self.backgroundLayer)
|
||||
self.addSubview(self.contentView)
|
||||
self.addSubview(self.textView)
|
||||
|
||||
self.internalHighligthedChanged = { [weak self] highlighted in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if highlighted {
|
||||
self.alpha = 0.5
|
||||
} else {
|
||||
self.alpha = 1.0
|
||||
if let self, self.bounds.width > 0.0 {
|
||||
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
|
||||
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
|
||||
|
||||
if highlighted {
|
||||
self.layer.removeAnimation(forKey: "opacity")
|
||||
self.layer.removeAnimation(forKey: "sublayerTransform")
|
||||
let transition = Transition(animation: .curve(duration: 0.15, curve: .easeInOut))
|
||||
transition.setScale(layer: self.layer, scale: topScale)
|
||||
} else {
|
||||
let t = self.layer.presentation()?.transform ?? layer.transform
|
||||
let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13))
|
||||
|
||||
let transition = Transition(animation: .none)
|
||||
transition.setScale(layer: self.layer, scale: 1.0)
|
||||
|
||||
self.layer.animateScale(from: currentScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] completed in
|
||||
guard let self, completed else {
|
||||
return
|
||||
}
|
||||
|
||||
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,43 +97,114 @@ final class ContentOverlayButton: UIButton, ContentOverlayView {
|
||||
self.action?()
|
||||
}
|
||||
|
||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
if !self.internalHighlighted {
|
||||
self.internalHighlighted = true
|
||||
self.highligthedChanged(true)
|
||||
self.internalHighligthedChanged(true)
|
||||
func update(size: CGSize, image: UIImage?, isSelected: Bool, isDestructive: Bool, title: String, transition: Transition) {
|
||||
let contentParams = ContentParams(size: size, image: image, isSelected: isSelected, isDestructive: isDestructive)
|
||||
if self.contentParams != contentParams {
|
||||
self.contentParams = contentParams
|
||||
self.updateContent(contentParams: contentParams, transition: transition)
|
||||
}
|
||||
|
||||
return super.beginTracking(touch, with: event)
|
||||
transition.setFrame(view: self.contentView, frame: CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
let textSize = self.textView.update(string: title, fontSize: 13.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: .immediate)
|
||||
self.textView.frame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) * 0.5), y: size.height + 4.0), size: textSize)
|
||||
}
|
||||
|
||||
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
||||
if self.internalHighlighted {
|
||||
self.internalHighlighted = false
|
||||
self.highligthedChanged(false)
|
||||
self.internalHighligthedChanged(false)
|
||||
private func updateContent(contentParams: ContentParams, transition: Transition) {
|
||||
let image = generateImage(contentParams.size, rotatedContext: { size, context in
|
||||
context.clear(CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
if contentParams.isDestructive {
|
||||
context.setFillColor(UIColor(rgb: 0xFF3B30).cgColor)
|
||||
} else {
|
||||
context.setFillColor(UIColor(white: 1.0, alpha: contentParams.isSelected ? 1.0 : 0.2).cgColor)
|
||||
}
|
||||
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
if let image = contentParams.image, let cgImage = image.cgImage {
|
||||
let imageSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
|
||||
let imageFrame = CGRect(origin: CGPoint(x: floor((size.width - imageSize.width) * 0.5), y: floor((size.height - imageSize.height) * 0.5)), size: imageSize)
|
||||
|
||||
context.saveGState()
|
||||
context.translateBy(x: imageFrame.midX, y: imageFrame.midY)
|
||||
context.scaleBy(x: 1.0, y: -1.0)
|
||||
context.translateBy(x: -imageFrame.midX, y: -imageFrame.midY)
|
||||
|
||||
context.clip(to: imageFrame, mask: cgImage)
|
||||
context.setBlendMode(contentParams.isSelected ? .copy : .normal)
|
||||
context.setFillColor(contentParams.isSelected ? UIColor.clear.cgColor : UIColor(white: 1.0, alpha: 1.0).cgColor)
|
||||
context.fill(imageFrame)
|
||||
|
||||
context.resetClip()
|
||||
context.restoreGState()
|
||||
}
|
||||
})
|
||||
|
||||
if !transition.animation.isImmediate, let currentContentViewIsSelected = self.currentContentViewIsSelected, currentContentViewIsSelected != contentParams.isSelected, let previousImage = self.contentView.image, let image {
|
||||
self.contentView.layer.mask = nil
|
||||
let _ = previousImage
|
||||
let _ = image
|
||||
let _ = currentContentViewIsSelected
|
||||
|
||||
let previousContentView = UIImageView(image: previousImage)
|
||||
previousContentView.frame = self.contentView.frame
|
||||
self.addSubview(previousContentView)
|
||||
|
||||
let animationDuration = 0.16
|
||||
let animationTimingFunction: String = CAMediaTimingFunctionName.linear.rawValue
|
||||
|
||||
if contentParams.isSelected {
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = self.contentView.bounds
|
||||
maskLayer.path = UIBezierPath(ovalIn: self.contentView.bounds).cgPath
|
||||
maskLayer.strokeColor = UIColor.black.cgColor
|
||||
maskLayer.fillColor = nil
|
||||
maskLayer.lineWidth = 1.0
|
||||
self.contentView.layer.mask = maskLayer
|
||||
maskLayer.animate(from: 0.0 as NSNumber, to: contentParams.size.width as NSNumber, keyPath: "lineWidth", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak self, weak maskLayer] _ in
|
||||
guard let self, let maskLayer, self.contentView.layer.mask === maskLayer else {
|
||||
return
|
||||
}
|
||||
self.contentView.layer.mask = nil
|
||||
})
|
||||
|
||||
let previousMaskLayer = CAShapeLayer()
|
||||
previousMaskLayer.frame = previousContentView.bounds
|
||||
previousMaskLayer.path = UIBezierPath(ovalIn: previousContentView.bounds).cgPath
|
||||
previousMaskLayer.strokeColor = nil
|
||||
previousMaskLayer.fillColor = UIColor.black.cgColor
|
||||
previousContentView.layer.mask = previousMaskLayer
|
||||
previousMaskLayer.animate(from: 1.0 as NSNumber, to: 0.0001 as NSNumber, keyPath: "transform.scale", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak previousContentView] _ in
|
||||
previousContentView?.removeFromSuperview()
|
||||
})
|
||||
} else {
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.frame = self.contentView.bounds
|
||||
maskLayer.path = UIBezierPath(ovalIn: self.contentView.bounds).cgPath
|
||||
maskLayer.strokeColor = nil
|
||||
maskLayer.fillColor = UIColor.black.cgColor
|
||||
self.contentView.layer.mask = maskLayer
|
||||
maskLayer.animate(from: 0.0001 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak self, weak maskLayer] _ in
|
||||
guard let self, let maskLayer, self.contentView.layer.mask === maskLayer else {
|
||||
return
|
||||
}
|
||||
self.contentView.layer.mask = nil
|
||||
})
|
||||
|
||||
let previousMaskLayer = CAShapeLayer()
|
||||
previousMaskLayer.frame = previousContentView.bounds
|
||||
previousMaskLayer.path = UIBezierPath(ovalIn: previousContentView.bounds).cgPath
|
||||
previousMaskLayer.strokeColor = UIColor.black.cgColor
|
||||
previousMaskLayer.fillColor = nil
|
||||
previousMaskLayer.lineWidth = 1.0
|
||||
previousContentView.layer.mask = previousMaskLayer
|
||||
previousMaskLayer.animate(from: contentParams.size.width as NSNumber, to: 0.0 as NSNumber, keyPath: "lineWidth", timingFunction: animationTimingFunction, duration: animationDuration, removeOnCompletion: false, completion: { [weak previousContentView] _ in
|
||||
previousContentView?.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
super.endTracking(touch, with: event)
|
||||
}
|
||||
|
||||
override func cancelTracking(with event: UIEvent?) {
|
||||
if self.internalHighlighted {
|
||||
self.internalHighlighted = false
|
||||
self.highligthedChanged(false)
|
||||
self.internalHighligthedChanged(false)
|
||||
}
|
||||
|
||||
super.cancelTracking(with: event)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if self.internalHighlighted {
|
||||
self.internalHighlighted = false
|
||||
self.highligthedChanged(false)
|
||||
self.internalHighligthedChanged(false)
|
||||
}
|
||||
|
||||
super.touchesCancelled(touches, with: event)
|
||||
self.contentView.image = image
|
||||
self.currentContentViewIsSelected = contentParams.isSelected
|
||||
}
|
||||
}
|
||||
|
@ -2,40 +2,61 @@ import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import MetalEngine
|
||||
import ComponentFlow
|
||||
import SwiftSignalKit
|
||||
|
||||
final class ContentView: UIView {
|
||||
private struct Params: Equatable {
|
||||
var size: CGSize
|
||||
var insets: UIEdgeInsets
|
||||
var screenCornerRadius: CGFloat
|
||||
var state: PrivateCallScreen.State
|
||||
var remoteVideo: VideoSource?
|
||||
|
||||
init(size: CGSize, insets: UIEdgeInsets, state: PrivateCallScreen.State) {
|
||||
init(size: CGSize, insets: UIEdgeInsets, screenCornerRadius: CGFloat, state: PrivateCallScreen.State, remoteVideo: VideoSource?) {
|
||||
self.size = size
|
||||
self.insets = insets
|
||||
self.screenCornerRadius = screenCornerRadius
|
||||
self.state = state
|
||||
self.remoteVideo = remoteVideo
|
||||
}
|
||||
|
||||
static func ==(lhs: Params, rhs: Params) -> Bool {
|
||||
if lhs.size != rhs.size {
|
||||
return false
|
||||
}
|
||||
if lhs.insets != rhs.insets {
|
||||
return false
|
||||
}
|
||||
if lhs.screenCornerRadius != rhs.screenCornerRadius {
|
||||
return false
|
||||
}
|
||||
if lhs.state != rhs.state {
|
||||
return false
|
||||
}
|
||||
if lhs.remoteVideo !== rhs.remoteVideo {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private let blobLayer: CallBlobsLayer
|
||||
private let avatarLayer: AvatarLayer
|
||||
private let titleView: TextView
|
||||
private let statusView: StatusView
|
||||
|
||||
private var statusView: StatusView
|
||||
|
||||
private var emojiView: KeyEmojiView?
|
||||
|
||||
let blurContentsLayer: SimpleLayer
|
||||
|
||||
private let videoLayer: MainVideoLayer
|
||||
private var videoLayerMask: SimpleShapeLayer?
|
||||
private var blurredVideoLayerMask: SimpleShapeLayer?
|
||||
private var videoContainerView: VideoContainerView?
|
||||
|
||||
private var params: Params?
|
||||
|
||||
private var isDisplayingVideo: Bool = false
|
||||
private var videoDisplayFraction = AnimatedProperty<CGFloat>(0.0)
|
||||
|
||||
private var videoInput: VideoInput?
|
||||
|
||||
private let managedAnimations: ManagedAnimations
|
||||
private var activeRemoteVideoSource: VideoSource?
|
||||
private var waitingForFirstVideoFrameDisposable: Disposable?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.blobLayer = CallBlobsLayer()
|
||||
@ -46,30 +67,16 @@ final class ContentView: UIView {
|
||||
|
||||
self.blurContentsLayer = SimpleLayer()
|
||||
|
||||
self.videoLayer = MainVideoLayer()
|
||||
|
||||
self.managedAnimations = ManagedAnimations()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.layer.addSublayer(self.blobLayer)
|
||||
self.layer.addSublayer(self.avatarLayer)
|
||||
self.layer.addSublayer(self.videoLayer)
|
||||
self.blurContentsLayer.addSublayer(self.videoLayer.blurredLayer)
|
||||
|
||||
self.addSubview(self.titleView)
|
||||
|
||||
self.addSubview(self.statusView)
|
||||
|
||||
self.avatarLayer.image = UIImage(named: "test")
|
||||
|
||||
self.managedAnimations.add(property: self.videoDisplayFraction)
|
||||
self.managedAnimations.updated = { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if let params = self.params {
|
||||
self.updateInternal(params: params)
|
||||
}
|
||||
self.statusView.requestLayout = { [weak self] in
|
||||
self?.update(transition: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,45 +84,192 @@ final class ContentView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(size: CGSize, insets: UIEdgeInsets, state: PrivateCallScreen.State) {
|
||||
let params = Params(size: size, insets: insets, state: state)
|
||||
deinit {
|
||||
self.waitingForFirstVideoFrameDisposable?.dispose()
|
||||
}
|
||||
|
||||
func update(
|
||||
size: CGSize,
|
||||
insets: UIEdgeInsets,
|
||||
screenCornerRadius: CGFloat,
|
||||
state: PrivateCallScreen.State,
|
||||
remoteVideo: VideoSource?,
|
||||
transition: Transition
|
||||
) {
|
||||
let params = Params(size: size, insets: insets, screenCornerRadius: screenCornerRadius, state: state, remoteVideo: remoteVideo)
|
||||
if self.params == params {
|
||||
return
|
||||
}
|
||||
self.params = params
|
||||
self.updateInternal(params: params)
|
||||
}
|
||||
|
||||
private func updateInternal(params: Params) {
|
||||
if self.emojiView == nil {
|
||||
let emojiView = KeyEmojiView(emoji: ["🐱", "🚂", "❄️", "🎨"])
|
||||
self.emojiView = emojiView
|
||||
self.addSubview(emojiView)
|
||||
}
|
||||
if let emojiView = self.emojiView {
|
||||
emojiView.frame = CGRect(origin: CGPoint(x: params.size.width - 12.0 - emojiView.size.width, y: params.insets.top + 27.0), size: emojiView.size)
|
||||
|
||||
if self.params?.remoteVideo !== params.remoteVideo {
|
||||
self.waitingForFirstVideoFrameDisposable?.dispose()
|
||||
|
||||
if let remoteVideo = params.remoteVideo {
|
||||
if remoteVideo.currentOutput != nil {
|
||||
self.activeRemoteVideoSource = remoteVideo
|
||||
} else {
|
||||
let firstVideoFrameSignal = Signal<Never, NoError> { subscriber in
|
||||
remoteVideo.updated = { [weak remoteVideo] in
|
||||
guard let remoteVideo else {
|
||||
subscriber.putCompletion()
|
||||
return
|
||||
}
|
||||
if remoteVideo.currentOutput != nil {
|
||||
subscriber.putCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
return EmptyDisposable
|
||||
}
|
||||
var shouldUpdate = false
|
||||
self.waitingForFirstVideoFrameDisposable = (firstVideoFrameSignal
|
||||
|> timeout(1.0, queue: .mainQueue(), alternate: .complete())
|
||||
|> deliverOnMainQueue).startStrict(completed: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.activeRemoteVideoSource = remoteVideo
|
||||
if shouldUpdate {
|
||||
self.update(transition: .spring(duration: 0.3))
|
||||
}
|
||||
})
|
||||
shouldUpdate = true
|
||||
}
|
||||
} else {
|
||||
self.activeRemoteVideoSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
if self.videoInput == nil, let url = Bundle.main.url(forResource: "test2", withExtension: "mp4") {
|
||||
self.videoInput = VideoInput(device: MetalEngine.shared.device, url: url)
|
||||
self.videoLayer.video = self.videoInput
|
||||
self.params = params
|
||||
self.updateInternal(params: params, transition: transition)
|
||||
}
|
||||
|
||||
private func update(transition: Transition) {
|
||||
guard let params = self.params else {
|
||||
return
|
||||
}
|
||||
self.updateInternal(params: params, transition: transition)
|
||||
}
|
||||
|
||||
private func updateInternal(params: Params, transition: Transition) {
|
||||
if case let .active(activeState) = params.state.lifecycleState {
|
||||
let emojiView: KeyEmojiView
|
||||
var emojiTransition = transition
|
||||
if let current = self.emojiView {
|
||||
emojiView = current
|
||||
} else {
|
||||
emojiTransition = transition.withAnimation(.none)
|
||||
emojiView = KeyEmojiView(emoji: activeState.emojiKey)
|
||||
self.emojiView = emojiView
|
||||
}
|
||||
if emojiView.superview == nil {
|
||||
self.addSubview(emojiView)
|
||||
if !transition.animation.isImmediate {
|
||||
emojiView.animateIn()
|
||||
}
|
||||
}
|
||||
emojiTransition.setFrame(view: emojiView, frame: CGRect(origin: CGPoint(x: params.size.width - params.insets.right - 12.0 - emojiView.size.width, y: params.insets.top + 27.0), size: emojiView.size))
|
||||
} else {
|
||||
if let emojiView = self.emojiView {
|
||||
self.emojiView = nil
|
||||
emojiView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
//self.phase += 3.0 / 60.0
|
||||
//self.phase = self.phase.truncatingRemainder(dividingBy: 1.0)
|
||||
var avatarScale: CGFloat = 0.05 * sin(CGFloat(0.0) * CGFloat.pi)
|
||||
avatarScale *= 1.0 - self.videoDisplayFraction.value
|
||||
//var avatarScale: CGFloat = 0.05 * sin(CGFloat(0.0) * CGFloat.pi)
|
||||
//avatarScale *= 1.0 - self.videoDisplayFraction.value
|
||||
|
||||
let avatarSize: CGFloat = 136.0
|
||||
let blobSize: CGFloat = 176.0
|
||||
let collapsedAvatarSize: CGFloat = 136.0
|
||||
let blobSize: CGFloat = collapsedAvatarSize + 40.0
|
||||
|
||||
let expandedVideoRadius: CGFloat = sqrt(pow(params.size.width * 0.5, 2.0) + pow(params.size.height * 0.5, 2.0))
|
||||
let collapsedAvatarFrame = CGRect(origin: CGPoint(x: floor((params.size.width - collapsedAvatarSize) * 0.5), y: 222.0), size: CGSize(width: collapsedAvatarSize, height: collapsedAvatarSize))
|
||||
let expandedAvatarFrame = CGRect(origin: CGPoint(), size: params.size)
|
||||
let avatarFrame = self.activeRemoteVideoSource != nil ? expandedAvatarFrame : collapsedAvatarFrame
|
||||
let avatarCornerRadius = self.activeRemoteVideoSource != nil ? params.screenCornerRadius : collapsedAvatarSize * 0.5
|
||||
|
||||
let avatarFrame = CGRect(origin: CGPoint(x: floor((params.size.width - avatarSize) * 0.5), y: CGFloat.animationInterpolator.interpolate(from: 222.0, to: floor((params.size.height - avatarSize) * 0.5), fraction: self.videoDisplayFraction.value)), size: CGSize(width: avatarSize, height: avatarSize))
|
||||
if let activeRemoteVideoSource = self.activeRemoteVideoSource {
|
||||
let videoContainerView: VideoContainerView
|
||||
if let current = self.videoContainerView {
|
||||
videoContainerView = current
|
||||
} else {
|
||||
videoContainerView = VideoContainerView(frame: CGRect())
|
||||
self.videoContainerView = videoContainerView
|
||||
self.insertSubview(videoContainerView, belowSubview: self.titleView)
|
||||
self.blurContentsLayer.addSublayer(videoContainerView.blurredContainerLayer)
|
||||
|
||||
videoContainerView.layer.position = self.avatarLayer.position
|
||||
videoContainerView.layer.bounds = self.avatarLayer.bounds
|
||||
videoContainerView.alpha = 0.0
|
||||
videoContainerView.blurredContainerLayer.position = self.avatarLayer.position
|
||||
videoContainerView.blurredContainerLayer.bounds = self.avatarLayer.bounds
|
||||
videoContainerView.blurredContainerLayer.opacity = 0.0
|
||||
videoContainerView.update(size: self.avatarLayer.bounds.size, cornerRadius: self.avatarLayer.params?.cornerRadius ?? 0.0, isExpanded: false, transition: .immediate)
|
||||
}
|
||||
|
||||
if videoContainerView.video !== activeRemoteVideoSource {
|
||||
videoContainerView.video = activeRemoteVideoSource
|
||||
}
|
||||
|
||||
transition.setPosition(view: videoContainerView, position: avatarFrame.center)
|
||||
transition.setBounds(view: videoContainerView, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
||||
transition.setAlpha(view: videoContainerView, alpha: 1.0)
|
||||
transition.setPosition(layer: videoContainerView.blurredContainerLayer, position: avatarFrame.center)
|
||||
transition.setBounds(layer: videoContainerView.blurredContainerLayer, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
||||
transition.setAlpha(layer: videoContainerView.blurredContainerLayer, alpha: 1.0)
|
||||
videoContainerView.update(size: avatarFrame.size, cornerRadius: avatarCornerRadius, isExpanded: self.activeRemoteVideoSource != nil, transition: transition)
|
||||
} else {
|
||||
if let videoContainerView = self.videoContainerView {
|
||||
videoContainerView.update(size: avatarFrame.size, cornerRadius: avatarCornerRadius, isExpanded: self.activeRemoteVideoSource != nil, transition: transition)
|
||||
transition.setPosition(layer: videoContainerView.blurredContainerLayer, position: avatarFrame.center)
|
||||
transition.setBounds(layer: videoContainerView.blurredContainerLayer, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
||||
transition.setAlpha(layer: videoContainerView.blurredContainerLayer, alpha: 0.0)
|
||||
transition.setPosition(view: videoContainerView, position: avatarFrame.center)
|
||||
transition.setBounds(view: videoContainerView, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
||||
if videoContainerView.alpha != 0.0 {
|
||||
transition.setAlpha(view: videoContainerView, alpha: 0.0, completion: { [weak self, weak videoContainerView] completed in
|
||||
guard let self, let videoContainerView, completed else {
|
||||
return
|
||||
}
|
||||
videoContainerView.removeFromSuperview()
|
||||
videoContainerView.blurredContainerLayer.removeFromSuperlayer()
|
||||
if self.videoContainerView === videoContainerView {
|
||||
self.videoContainerView = nil
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let titleSize = self.titleView.update(string: "Emma Walters", fontSize: CGFloat.animationInterpolator.interpolate(from: 28.0, to: 17.0, fraction: self.videoDisplayFraction.value), fontWeight: CGFloat.animationInterpolator.interpolate(from: 0.0, to: 0.25, fraction: self.videoDisplayFraction.value), constrainedWidth: params.size.width - 16.0 * 2.0)
|
||||
let titleFrame = CGRect(origin: CGPoint(x: (params.size.width - titleSize.width) * 0.5, y: CGFloat.animationInterpolator.interpolate(from: avatarFrame.maxY + 39.0, to: params.insets.top + 17.0, fraction: self.videoDisplayFraction.value)), size: titleSize)
|
||||
self.titleView.frame = titleFrame
|
||||
if self.avatarLayer.image !== params.state.avatarImage {
|
||||
self.avatarLayer.image = params.state.avatarImage
|
||||
}
|
||||
transition.setPosition(layer: self.avatarLayer, position: avatarFrame.center)
|
||||
transition.setBounds(layer: self.avatarLayer, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size))
|
||||
self.avatarLayer.update(size: collapsedAvatarFrame.size, isExpanded: self.activeRemoteVideoSource != nil, cornerRadius: avatarCornerRadius, transition: transition)
|
||||
|
||||
let blobFrame = CGRect(origin: CGPoint(x: floor(avatarFrame.midX - blobSize * 0.5), y: floor(avatarFrame.midY - blobSize * 0.5)), size: CGSize(width: blobSize, height: blobSize))
|
||||
transition.setPosition(layer: self.blobLayer, position: CGPoint(x: blobFrame.midX, y: blobFrame.midY))
|
||||
transition.setBounds(layer: self.blobLayer, bounds: CGRect(origin: CGPoint(), size: blobFrame.size))
|
||||
//self.blobLayer.transform = CATransform3DMakeScale(1.0 + avatarScale * 2.0, 1.0 + avatarScale * 2.0, 1.0)
|
||||
|
||||
let titleSize = self.titleView.update(
|
||||
string: params.state.name,
|
||||
fontSize: self.activeRemoteVideoSource == nil ? 28.0 : 17.0,
|
||||
fontWeight: self.activeRemoteVideoSource == nil ? 0.0 : 0.25,
|
||||
color: .white,
|
||||
constrainedWidth: params.size.width - 16.0 * 2.0,
|
||||
transition: transition
|
||||
)
|
||||
let titleFrame = CGRect(
|
||||
origin: CGPoint(
|
||||
x: (params.size.width - titleSize.width) * 0.5,
|
||||
y: self.activeRemoteVideoSource == nil ? collapsedAvatarFrame.maxY + 39.0 : params.insets.top + 17.0
|
||||
),
|
||||
size: titleSize
|
||||
)
|
||||
transition.setFrame(view: self.titleView, frame: titleFrame)
|
||||
|
||||
let statusState: StatusView.State
|
||||
switch params.state.lifecycleState {
|
||||
@ -125,83 +279,47 @@ final class ContentView: UIView {
|
||||
statusState = .waiting(.ringing)
|
||||
case .exchangingKeys:
|
||||
statusState = .waiting(.generatingKeys)
|
||||
case let .active(_, signalInfo):
|
||||
statusState = .active(StatusView.ActiveState(signalStrength: signalInfo.quality))
|
||||
case let .active(activeState):
|
||||
statusState = .active(StatusView.ActiveState(startTimestamp: activeState.startTime, signalStrength: activeState.signalInfo.quality))
|
||||
}
|
||||
let statusSize = self.statusView.update(state: statusState)
|
||||
self.statusView.frame = CGRect(origin: CGPoint(x: (params.size.width - statusSize.width) * 0.5, y: titleFrame.maxY + CGFloat.animationInterpolator.interpolate(from: 4.0, to: 0.0, fraction: self.videoDisplayFraction.value)), size: statusSize)
|
||||
|
||||
let blobFrame = CGRect(origin: CGPoint(x: floor(avatarFrame.midX - blobSize * 0.5), y: floor(avatarFrame.midY - blobSize * 0.5)), size: CGSize(width: blobSize, height: blobSize))
|
||||
|
||||
self.avatarLayer.position = CGPoint(x: avatarFrame.midX, y: avatarFrame.midY)
|
||||
self.avatarLayer.bounds = CGRect(origin: CGPoint(), size: avatarFrame.size)
|
||||
|
||||
let visibleAvatarScale = CGFloat.animationInterpolator.interpolate(from: 1.0 + avatarScale, to: expandedVideoRadius * 2.0 / avatarSize, fraction: self.videoDisplayFraction.value)
|
||||
self.avatarLayer.transform = CATransform3DMakeScale(visibleAvatarScale, visibleAvatarScale, 1.0)
|
||||
self.avatarLayer.opacity = Float(1.0 - self.videoDisplayFraction.value)
|
||||
|
||||
self.blobLayer.position = CGPoint(x: blobFrame.midX, y: blobFrame.midY)
|
||||
self.blobLayer.bounds = CGRect(origin: CGPoint(), size: blobFrame.size)
|
||||
self.blobLayer.transform = CATransform3DMakeScale(1.0 + avatarScale * 2.0, 1.0 + avatarScale * 2.0, 1.0)
|
||||
|
||||
let videoResolution = CGSize(width: 400.0, height: 400.0)
|
||||
let videoFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: params.size)
|
||||
|
||||
let videoRenderingSize = CGSize(width: videoResolution.width * 2.0, height: videoResolution.height * 2.0)
|
||||
|
||||
self.videoLayer.frame = videoFrame
|
||||
self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(videoRenderingSize.width), height: Int(videoRenderingSize.height)))
|
||||
|
||||
self.videoLayer.blurredLayer.frame = videoFrame
|
||||
|
||||
let videoDisplayFraction = self.videoDisplayFraction.value
|
||||
|
||||
self.videoLayer.isHidden = videoDisplayFraction == 0.0
|
||||
self.videoLayer.opacity = Float(videoDisplayFraction)
|
||||
|
||||
self.videoLayer.blurredLayer.isHidden = videoDisplayFraction == 0.0
|
||||
|
||||
if videoDisplayFraction != 0.0 && videoDisplayFraction != 1.0 {
|
||||
let videoLayerMask: SimpleShapeLayer
|
||||
if let current = self.videoLayerMask {
|
||||
videoLayerMask = current
|
||||
if let previousState = self.statusView.state, previousState.key != statusState.key {
|
||||
let previousStatusView = self.statusView
|
||||
if !transition.animation.isImmediate {
|
||||
transition.setPosition(view: previousStatusView, position: CGPoint(x: previousStatusView.center.x, y: previousStatusView.center.y - 5.0))
|
||||
transition.setScale(view: previousStatusView, scale: 0.5)
|
||||
Transition.easeInOut(duration: 0.1).setAlpha(view: previousStatusView, alpha: 0.0, completion: { [weak previousStatusView] _ in
|
||||
previousStatusView?.removeFromSuperview()
|
||||
})
|
||||
} else {
|
||||
videoLayerMask = SimpleShapeLayer()
|
||||
self.videoLayerMask = videoLayerMask
|
||||
self.videoLayer.mask = videoLayerMask
|
||||
previousStatusView.removeFromSuperview()
|
||||
}
|
||||
|
||||
let blurredVideoLayerMask: SimpleShapeLayer
|
||||
if let current = self.blurredVideoLayerMask {
|
||||
blurredVideoLayerMask = current
|
||||
} else {
|
||||
blurredVideoLayerMask = SimpleShapeLayer()
|
||||
self.blurredVideoLayerMask = blurredVideoLayerMask
|
||||
self.videoLayer.blurredLayer.mask = blurredVideoLayerMask
|
||||
|
||||
self.statusView = StatusView()
|
||||
self.insertSubview(self.statusView, aboveSubview: previousStatusView)
|
||||
self.statusView.requestLayout = { [weak self] in
|
||||
self?.update(transition: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
let statusSize = self.statusView.update(state: statusState, transition: .immediate)
|
||||
let statusFrame = CGRect(
|
||||
origin: CGPoint(
|
||||
x: (params.size.width - statusSize.width) * 0.5,
|
||||
y: titleFrame.maxY + (self.activeRemoteVideoSource != nil ? 0.0 : 4.0)
|
||||
),
|
||||
size: statusSize
|
||||
)
|
||||
if self.statusView.bounds.isEmpty {
|
||||
self.statusView.frame = statusFrame
|
||||
|
||||
let fromRadius: CGFloat = avatarSize * 0.5
|
||||
let toRadius = expandedVideoRadius
|
||||
|
||||
let maskPosition = CGPoint(x: avatarFrame.midX, y: avatarFrame.midY)
|
||||
let maskRadius = CGFloat.animationInterpolator.interpolate(from: fromRadius, to: toRadius, fraction: videoDisplayFraction)
|
||||
|
||||
videoLayerMask.path = UIBezierPath(ovalIn: CGRect(origin: CGPoint(x: maskPosition.x - maskRadius, y: maskPosition.y - maskRadius), size: CGSize(width: maskRadius * 2.0, height: maskRadius * 2.0))).cgPath
|
||||
blurredVideoLayerMask.path = UIBezierPath(ovalIn: CGRect(origin: CGPoint(x: maskPosition.x - maskRadius, y: maskPosition.y - maskRadius), size: CGSize(width: maskRadius * 2.0, height: maskRadius * 2.0))).cgPath
|
||||
if !transition.animation.isImmediate {
|
||||
transition.animatePosition(view: self.statusView, from: CGPoint(x: 0.0, y: 5.0), to: CGPoint(), additive: true)
|
||||
transition.animateScale(view: self.statusView, from: 0.5, to: 1.0)
|
||||
Transition.easeInOut(duration: 0.15).animateAlpha(view: self.statusView, from: 0.0, to: 1.0)
|
||||
}
|
||||
} else {
|
||||
if let videoLayerMask = self.videoLayerMask {
|
||||
self.videoLayerMask = nil
|
||||
videoLayerMask.removeFromSuperlayer()
|
||||
}
|
||||
if let blurredVideoLayerMask = self.blurredVideoLayerMask {
|
||||
self.blurredVideoLayerMask = nil
|
||||
blurredVideoLayerMask.removeFromSuperlayer()
|
||||
}
|
||||
transition.setFrame(view: self.statusView, frame: statusFrame)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleDisplayVideo() {
|
||||
self.isDisplayingVideo = !self.isDisplayingVideo
|
||||
self.videoDisplayFraction.animate(to: self.isDisplayingVideo ? 1.0 : 0.0, duration: 0.4, curve: .spring)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
|
||||
final class KeyEmojiView: UIView {
|
||||
private let emojiViews: [TextView]
|
||||
@ -20,7 +21,7 @@ final class KeyEmojiView: UIView {
|
||||
nextX += itemSpacing
|
||||
}
|
||||
let emojiView = self.emojiViews[i]
|
||||
let itemSize = emojiView.update(string: emoji[i], fontSize: 16.0, fontWeight: 0.0, constrainedWidth: 100.0)
|
||||
let itemSize = emojiView.update(string: emoji[i], fontSize: 16.0, fontWeight: 0.0, color: .white, constrainedWidth: 100.0, transition: .immediate)
|
||||
if height == 0.0 {
|
||||
height = itemSize.height
|
||||
}
|
||||
@ -40,4 +41,12 @@ final class KeyEmojiView: UIView {
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func animateIn() {
|
||||
for i in 0 ..< self.emojiViews.count {
|
||||
let emojiView = self.emojiViews[i]
|
||||
emojiView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
||||
emojiView.layer.animatePosition(from: CGPoint(x: -CGFloat(self.emojiViews.count - 1 - i) * 30.0, y: 0.0), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ func imageToCVPixelBuffer(image: UIImage) -> CVPixelBuffer? {
|
||||
return pixelBuffer
|
||||
}
|
||||
|
||||
final class MainVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
final class PrivateCallVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
var internalData: MetalEngineSubjectInternalData?
|
||||
|
||||
let blurredLayer: MetalEngineSubjectLayer
|
||||
@ -99,11 +99,8 @@ final class MainVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
}
|
||||
}
|
||||
|
||||
var video: VideoInput? {
|
||||
var video: VideoSource.Output? {
|
||||
didSet {
|
||||
self.video?.updated = { [weak self] in
|
||||
self?.setNeedsUpdate()
|
||||
}
|
||||
self.setNeedsUpdate()
|
||||
}
|
||||
}
|
||||
@ -138,7 +135,7 @@ final class MainVideoLayer: MetalEngineSubjectLayer, MetalEngineSubject {
|
||||
guard let renderSpec = self.renderSpec else {
|
||||
return
|
||||
}
|
||||
guard let videoTextures = self.video?.currentOutput else {
|
||||
guard let videoTextures = self.video else {
|
||||
return
|
||||
}
|
||||
|
@ -1,6 +1,36 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
|
||||
private func addRoundedRectPath(context: CGContext, rect: CGRect, radius: CGFloat) {
|
||||
context.saveGState()
|
||||
context.translateBy(x: rect.minX, y: rect.minY)
|
||||
context.scaleBy(x: radius, y: radius)
|
||||
let fw = rect.width / radius
|
||||
let fh = rect.height / radius
|
||||
context.move(to: CGPoint(x: fw, y: fh / 2.0))
|
||||
context.addArc(tangent1End: CGPoint(x: fw, y: fh), tangent2End: CGPoint(x: fw/2, y: fh), radius: 1.0)
|
||||
context.addArc(tangent1End: CGPoint(x: 0, y: fh), tangent2End: CGPoint(x: 0, y: fh/2), radius: 1)
|
||||
context.addArc(tangent1End: CGPoint(x: 0, y: 0), tangent2End: CGPoint(x: fw/2, y: 0), radius: 1)
|
||||
context.addArc(tangent1End: CGPoint(x: fw, y: 0), tangent2End: CGPoint(x: fw, y: fh/2), radius: 1)
|
||||
context.closePath()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
private func stringForDuration(_ duration: Int) -> String {
|
||||
let hours = duration / 3600
|
||||
let minutes = duration / 60 % 60
|
||||
let seconds = duration % 60
|
||||
let durationString: String
|
||||
if hours > 0 {
|
||||
durationString = String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
durationString = String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
return durationString
|
||||
}
|
||||
|
||||
|
||||
private final class AnimatedDotsLayer: SimpleLayer {
|
||||
private let dotLayers: [SimpleLayer]
|
||||
@ -71,28 +101,116 @@ private final class AnimatedDotsLayer: SimpleLayer {
|
||||
}
|
||||
}
|
||||
|
||||
private final class SignalStrengthView: UIView {
|
||||
let barViews: [UIImageView]
|
||||
|
||||
let size: CGSize
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.barViews = (0 ..< 4).map { _ in
|
||||
return UIImageView()
|
||||
}
|
||||
|
||||
let itemWidth: CGFloat = 3.0
|
||||
let itemHeight: CGFloat = 12.0
|
||||
let itemSpacing: CGFloat = 2.0
|
||||
|
||||
self.size = CGSize(width: CGFloat(self.barViews.count) * itemWidth + CGFloat(self.barViews.count - 1) * itemSpacing, height: itemHeight)
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
let itemImage = UIGraphicsImageRenderer(size: CGSize(width: itemWidth, height: itemWidth)).image(actions: { context in
|
||||
context.cgContext.setFillColor(UIColor.white.cgColor)
|
||||
addRoundedRectPath(context: context.cgContext, rect: CGRect(origin: CGPoint(), size: CGSize(width: itemWidth, height: itemWidth)), radius: 1.0)
|
||||
context.cgContext.fillPath()
|
||||
}).stretchableImage(withLeftCapWidth: Int(itemWidth * 0.5), topCapHeight: Int(itemWidth * 0.5))
|
||||
|
||||
var nextX: CGFloat = 0.0
|
||||
|
||||
for i in 0 ..< self.barViews.count {
|
||||
let barView = self.barViews[i]
|
||||
barView.image = itemImage
|
||||
let barHeight = floor(CGFloat(i + 1) * itemHeight / CGFloat(self.barViews.count))
|
||||
barView.frame = CGRect(origin: CGPoint(x: nextX, y: itemHeight - barHeight), size: CGSize(width: itemWidth, height: barHeight))
|
||||
nextX += itemSpacing + itemWidth
|
||||
self.addSubview(barView)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(value: Double) {
|
||||
for i in 0 ..< self.barViews.count {
|
||||
if value >= Double(i + 1) / Double(self.barViews.count) {
|
||||
self.barViews[i].alpha = 1.0
|
||||
} else {
|
||||
self.barViews[i].alpha = 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class StatusView: UIView {
|
||||
private struct LayoutState: Equatable {
|
||||
var state: State
|
||||
var size: CGSize
|
||||
|
||||
init(state: State, size: CGSize) {
|
||||
self.state = state
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
enum WaitingState {
|
||||
case requesting
|
||||
case ringing
|
||||
case generatingKeys
|
||||
}
|
||||
|
||||
struct ActiveState {
|
||||
struct ActiveState: Equatable {
|
||||
var startTimestamp: Double
|
||||
var signalStrength: Double
|
||||
|
||||
init(signalStrength: Double) {
|
||||
init(startTimestamp: Double, signalStrength: Double) {
|
||||
self.startTimestamp = startTimestamp
|
||||
self.signalStrength = signalStrength
|
||||
}
|
||||
}
|
||||
|
||||
enum State {
|
||||
enum State: Equatable {
|
||||
enum Key: Equatable {
|
||||
case waiting(WaitingState)
|
||||
case active
|
||||
}
|
||||
|
||||
case waiting(WaitingState)
|
||||
case active(ActiveState)
|
||||
|
||||
var key: Key {
|
||||
switch self {
|
||||
case let .waiting(waitingState):
|
||||
return .waiting(waitingState)
|
||||
case .active:
|
||||
return .active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textView: TextView
|
||||
private let textView: TextView
|
||||
|
||||
private var dotsLayer: AnimatedDotsLayer?
|
||||
private var signalStrengthView: SignalStrengthView?
|
||||
|
||||
private var activeDurationTimer: Foundation.Timer?
|
||||
|
||||
private var layoutState: LayoutState?
|
||||
var state: State? {
|
||||
return self.layoutState?.state
|
||||
}
|
||||
|
||||
var requestLayout: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.textView = TextView()
|
||||
@ -106,9 +224,60 @@ final class StatusView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(state: State) -> CGSize {
|
||||
deinit {
|
||||
self.activeDurationTimer?.invalidate()
|
||||
}
|
||||
|
||||
func update(state: State, transition: Transition) -> CGSize {
|
||||
if let layoutState = self.layoutState, layoutState.state == state {
|
||||
return layoutState.size
|
||||
}
|
||||
let size = self.updateInternal(state: state, transition: transition)
|
||||
self.layoutState = LayoutState(state: state, size: size)
|
||||
|
||||
self.updateActiveDurationTimer()
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
private func updateActiveDurationTimer() {
|
||||
if let layoutState = self.layoutState, case let .active(activeState) = layoutState.state {
|
||||
if self.activeDurationTimer == nil {
|
||||
let timestamp = Date().timeIntervalSince1970
|
||||
let duration = timestamp - activeState.startTimestamp
|
||||
let nextTickDelay = ceil(duration) - duration + 0.05
|
||||
|
||||
self.activeDurationTimer = Foundation.Timer.scheduledTimer(withTimeInterval: nextTickDelay, repeats: false, block: { [weak self] _ in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.activeDurationTimer?.invalidate()
|
||||
self.activeDurationTimer = nil
|
||||
|
||||
if let layoutState = self.layoutState {
|
||||
let size = self.updateInternal(state: layoutState.state, transition: .immediate)
|
||||
if layoutState.size != size {
|
||||
self.layoutState = nil
|
||||
self.requestLayout?()
|
||||
}
|
||||
}
|
||||
|
||||
self.updateActiveDurationTimer()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if let activeDurationTimer = self.activeDurationTimer {
|
||||
self.activeDurationTimer = nil
|
||||
activeDurationTimer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateInternal(state: State, transition: Transition) -> CGSize {
|
||||
let textString: String
|
||||
var needsDots = false
|
||||
var monospacedDigits = false
|
||||
var signalStrength: Double?
|
||||
switch state {
|
||||
case let .waiting(waitingState):
|
||||
needsDots = true
|
||||
@ -122,15 +291,49 @@ final class StatusView: UIView {
|
||||
textString = "Exchanging encryption keys"
|
||||
}
|
||||
case let .active(activeState):
|
||||
textString = "0:00"
|
||||
let _ = activeState
|
||||
monospacedDigits = true
|
||||
|
||||
let timestamp = Date().timeIntervalSince1970
|
||||
let duration = timestamp - activeState.startTimestamp
|
||||
textString = stringForDuration(Int(duration))
|
||||
signalStrength = activeState.signalStrength
|
||||
}
|
||||
let textSize = self.textView.update(string: textString, fontSize: 16.0, fontWeight: 0.0, constrainedWidth: 200.0)
|
||||
self.textView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: textSize)
|
||||
|
||||
var contentSize = textSize
|
||||
var contentSize = CGSize()
|
||||
|
||||
let dotsSpacing: CGFloat = 6.0
|
||||
if let signalStrength {
|
||||
let signalStrengthView: SignalStrengthView
|
||||
if let current = self.signalStrengthView {
|
||||
signalStrengthView = current
|
||||
} else {
|
||||
signalStrengthView = SignalStrengthView(frame: CGRect())
|
||||
self.signalStrengthView = signalStrengthView
|
||||
self.addSubview(signalStrengthView)
|
||||
}
|
||||
signalStrengthView.update(value: signalStrength)
|
||||
contentSize.width += signalStrengthView.size.width + 7.0
|
||||
} else {
|
||||
if let signalStrengthView = self.signalStrengthView {
|
||||
self.signalStrengthView = nil
|
||||
signalStrengthView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
let textSize = self.textView.update(string: textString, fontSize: 16.0, fontWeight: 0.0, monospacedDigits: monospacedDigits, color: .white, constrainedWidth: 250.0, transition: .immediate)
|
||||
let textFrame = CGRect(origin: CGPoint(x: contentSize.width, y: 0.0), size: textSize)
|
||||
if self.textView.bounds.isEmpty {
|
||||
self.textView.frame = textFrame
|
||||
} else {
|
||||
transition.setPosition(view: self.textView, position: textFrame.center)
|
||||
transition.setBounds(view: self.textView, bounds: CGRect(origin: CGPoint(), size: textFrame.size))
|
||||
}
|
||||
|
||||
contentSize.width += textSize.width
|
||||
contentSize.height = textSize.height
|
||||
|
||||
if let signalStrengthView = self.signalStrengthView {
|
||||
transition.setFrame(view: signalStrengthView, frame: CGRect(origin: CGPoint(x: 0.0, y: floor((textSize.height - signalStrengthView.size.height) * 0.5)), size: signalStrengthView.size))
|
||||
}
|
||||
|
||||
if needsDots {
|
||||
let dotsLayer: AnimatedDotsLayer
|
||||
@ -140,13 +343,19 @@ final class StatusView: UIView {
|
||||
dotsLayer = AnimatedDotsLayer()
|
||||
self.dotsLayer = dotsLayer
|
||||
self.layer.addSublayer(dotsLayer)
|
||||
transition.animateAlpha(layer: dotsLayer, from: 0.0, to: 1.0)
|
||||
}
|
||||
|
||||
dotsLayer.frame = CGRect(origin: CGPoint(x: textSize.width + dotsSpacing, y: 1.0 + floor((textSize.height - dotsLayer.size.height) * 0.5)), size: dotsLayer.size)
|
||||
contentSize.width += dotsSpacing + dotsLayer.size.width
|
||||
let dotsSpacing: CGFloat = 6.0
|
||||
|
||||
let dotsFrame = CGRect(origin: CGPoint(x: textSize.width + dotsSpacing, y: 1.0 + floor((textSize.height - dotsLayer.size.height) * 0.5)), size: dotsLayer.size)
|
||||
transition.setFrame(layer: dotsLayer, frame: dotsFrame)
|
||||
contentSize.width += dotsSpacing + dotsFrame.width
|
||||
} else if let dotsLayer = self.dotsLayer {
|
||||
self.dotsLayer = nil
|
||||
dotsLayer.removeFromSuperlayer()
|
||||
transition.setAlpha(layer: dotsLayer, alpha: 0.0, completion: { [weak dotsLayer] _ in
|
||||
dotsLayer?.removeFromSuperlayer()
|
||||
})
|
||||
}
|
||||
|
||||
return contentSize
|
||||
|
@ -1,11 +1,13 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import ComponentFlow
|
||||
|
||||
final class TextView: UIView {
|
||||
private struct Params: Equatable {
|
||||
var string: String
|
||||
var fontSize: CGFloat
|
||||
var fontWeight: CGFloat
|
||||
var monospacedDigits: Bool
|
||||
var constrainedWidth: CGFloat
|
||||
}
|
||||
|
||||
@ -16,6 +18,7 @@ final class TextView: UIView {
|
||||
}
|
||||
|
||||
private var layoutState: LayoutState?
|
||||
private var animateContentsTransition: Bool = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: CGRect())
|
||||
@ -28,17 +31,33 @@ final class TextView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(string: String, fontSize: CGFloat, fontWeight: CGFloat, constrainedWidth: CGFloat) -> CGSize {
|
||||
let params = Params(string: string, fontSize: fontSize, fontWeight: fontWeight, constrainedWidth: constrainedWidth)
|
||||
override func action(for layer: CALayer, forKey event: String) -> CAAction? {
|
||||
if self.animateContentsTransition && event == "contents" {
|
||||
self.animateContentsTransition = false
|
||||
let animation = CABasicAnimation(keyPath: "contents")
|
||||
animation.duration = 0.15 * UIView.animationDurationFactor()
|
||||
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||
return animation
|
||||
}
|
||||
return super.action(for: layer, forKey: event)
|
||||
}
|
||||
|
||||
func update(string: String, fontSize: CGFloat, fontWeight: CGFloat, monospacedDigits: Bool = false, color: UIColor, constrainedWidth: CGFloat, transition: Transition) -> CGSize {
|
||||
let params = Params(string: string, fontSize: fontSize, fontWeight: fontWeight, monospacedDigits: monospacedDigits, constrainedWidth: constrainedWidth)
|
||||
if let layoutState = self.layoutState, layoutState.params == params {
|
||||
return layoutState.size
|
||||
}
|
||||
|
||||
let font = UIFont.systemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight))
|
||||
let font: UIFont
|
||||
if monospacedDigits {
|
||||
font = UIFont.monospacedDigitSystemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight))
|
||||
} else {
|
||||
font = UIFont.systemFont(ofSize: fontSize, weight: UIFont.Weight(fontWeight))
|
||||
}
|
||||
|
||||
let attributedString = NSAttributedString(string: string, attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: UIColor.white
|
||||
.foregroundColor: color,
|
||||
])
|
||||
let stringBounds = attributedString.boundingRect(with: CGSize(width: constrainedWidth, height: 200.0), options: .usesLineFragmentOrigin, context: nil)
|
||||
let stringSize = CGSize(width: ceil(stringBounds.width), height: ceil(stringBounds.height))
|
||||
@ -47,6 +66,7 @@ final class TextView: UIView {
|
||||
let layoutState = LayoutState(params: params, size: size, attributedString: attributedString)
|
||||
if self.layoutState != layoutState {
|
||||
self.layoutState = layoutState
|
||||
self.animateContentsTransition = !transition.animation.isImmediate
|
||||
self.setNeedsDisplay()
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import ComponentFlow
|
||||
import MetalEngine
|
||||
|
||||
private let shadowImage: UIImage? = {
|
||||
UIImage(named: "Call/VideoGradient")?.precomposed()
|
||||
}()
|
||||
|
||||
final class VideoContainerView: UIView {
|
||||
private struct Params: Equatable {
|
||||
var size: CGSize
|
||||
var cornerRadius: CGFloat
|
||||
var isExpanded: Bool
|
||||
|
||||
init(size: CGSize, cornerRadius: CGFloat, isExpanded: Bool) {
|
||||
self.size = size
|
||||
self.cornerRadius = cornerRadius
|
||||
self.isExpanded = isExpanded
|
||||
}
|
||||
}
|
||||
|
||||
private struct VideoMetrics: Equatable {
|
||||
var resolution: CGSize
|
||||
var rotationAngle: Float
|
||||
|
||||
init(resolution: CGSize, rotationAngle: Float) {
|
||||
self.resolution = resolution
|
||||
self.rotationAngle = rotationAngle
|
||||
}
|
||||
}
|
||||
|
||||
private let videoLayer: PrivateCallVideoLayer
|
||||
let blurredContainerLayer: SimpleLayer
|
||||
|
||||
private let topShadowView: UIImageView
|
||||
private let bottomShadowView: UIImageView
|
||||
|
||||
private var params: Params?
|
||||
private var videoMetrics: VideoMetrics?
|
||||
private var appliedVideoMetrics: VideoMetrics?
|
||||
|
||||
var video: VideoSource? {
|
||||
didSet {
|
||||
self.video?.updated = { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
var videoMetrics: VideoMetrics?
|
||||
if let currentOutput = self.video?.currentOutput {
|
||||
self.videoLayer.video = currentOutput
|
||||
videoMetrics = VideoMetrics(resolution: CGSize(width: CGFloat(currentOutput.y.width), height: CGFloat(currentOutput.y.height)), rotationAngle: currentOutput.rotationAngle)
|
||||
} else {
|
||||
self.videoLayer.video = nil
|
||||
}
|
||||
self.videoLayer.setNeedsUpdate()
|
||||
|
||||
if self.videoMetrics != videoMetrics {
|
||||
self.videoMetrics = videoMetrics
|
||||
self.update(transition: .easeInOut(duration: 0.2))
|
||||
}
|
||||
}
|
||||
var videoMetrics: VideoMetrics?
|
||||
if let currentOutput = self.video?.currentOutput {
|
||||
self.videoLayer.video = currentOutput
|
||||
videoMetrics = VideoMetrics(resolution: CGSize(width: CGFloat(currentOutput.y.width), height: CGFloat(currentOutput.y.height)), rotationAngle: currentOutput.rotationAngle)
|
||||
} else {
|
||||
self.videoLayer.video = nil
|
||||
}
|
||||
self.videoLayer.setNeedsUpdate()
|
||||
|
||||
if self.videoMetrics != videoMetrics {
|
||||
self.videoMetrics = videoMetrics
|
||||
self.update(transition: .easeInOut(duration: 0.2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.videoLayer = PrivateCallVideoLayer()
|
||||
self.blurredContainerLayer = SimpleLayer()
|
||||
|
||||
self.topShadowView = UIImageView()
|
||||
self.topShadowView.transform = CGAffineTransformMakeScale(1.0, -1.0)
|
||||
self.bottomShadowView = UIImageView()
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.backgroundColor = UIColor.black
|
||||
self.blurredContainerLayer.backgroundColor = UIColor.black.cgColor
|
||||
|
||||
self.layer.addSublayer(self.videoLayer)
|
||||
self.blurredContainerLayer.addSublayer(self.videoLayer.blurredLayer)
|
||||
|
||||
self.topShadowView.image = shadowImage
|
||||
self.bottomShadowView.image = shadowImage
|
||||
self.addSubview(self.topShadowView)
|
||||
self.addSubview(self.bottomShadowView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func update(transition: Transition) {
|
||||
guard let params = self.params else {
|
||||
return
|
||||
}
|
||||
self.update(params: params, transition: transition)
|
||||
}
|
||||
|
||||
func update(size: CGSize, cornerRadius: CGFloat, isExpanded: Bool, transition: Transition) {
|
||||
let params = Params(size: size, cornerRadius: cornerRadius, isExpanded: isExpanded)
|
||||
if self.params == params {
|
||||
return
|
||||
}
|
||||
|
||||
self.layer.masksToBounds = true
|
||||
if self.layer.animation(forKey: "cornerRadius") == nil {
|
||||
self.layer.cornerRadius = self.params?.cornerRadius ?? 0.0
|
||||
}
|
||||
|
||||
self.params = params
|
||||
|
||||
transition.setCornerRadius(layer: self.layer, cornerRadius: params.cornerRadius, completion: { [weak self] completed in
|
||||
guard let self, let params = self.params, completed else {
|
||||
return
|
||||
}
|
||||
if params.isExpanded {
|
||||
self.layer.masksToBounds = false
|
||||
self.layer.cornerRadius = 0.0
|
||||
}
|
||||
})
|
||||
|
||||
self.update(params: params, transition: transition)
|
||||
}
|
||||
|
||||
private func update(params: Params, transition: Transition) {
|
||||
guard let videoMetrics = self.videoMetrics else {
|
||||
return
|
||||
}
|
||||
var transition = transition
|
||||
if self.appliedVideoMetrics == nil {
|
||||
transition = .immediate
|
||||
}
|
||||
self.appliedVideoMetrics = videoMetrics
|
||||
|
||||
var rotatedResolution = videoMetrics.resolution
|
||||
var videoIsRotated = false
|
||||
if videoMetrics.rotationAngle == Float.pi * 0.5 || videoMetrics.rotationAngle == Float.pi * 3.0 / 2.0 {
|
||||
rotatedResolution = CGSize(width: rotatedResolution.height, height: rotatedResolution.width)
|
||||
videoIsRotated = true
|
||||
}
|
||||
|
||||
var videoSize = rotatedResolution.aspectFitted(params.size)
|
||||
let boundingAspectRatio = params.size.width / params.size.height
|
||||
let videoAspectRatio = videoSize.width / videoSize.height
|
||||
if abs(boundingAspectRatio - videoAspectRatio) < 0.15 {
|
||||
videoSize = rotatedResolution.aspectFilled(params.size)
|
||||
}
|
||||
|
||||
let videoResolution = rotatedResolution.aspectFittedOrSmaller(CGSize(width: 1280, height: 1280)).aspectFittedOrSmaller(CGSize(width: videoSize.width * 3.0, height: videoSize.height * 3.0))
|
||||
let rotatedVideoResolution = videoIsRotated ? CGSize(width: videoResolution.height, height: videoResolution.width) : videoResolution
|
||||
|
||||
let rotatedVideoSize = videoIsRotated ? CGSize(width: videoSize.height, height: videoSize.width) : videoSize
|
||||
let rotatedBoundingSize = params.size
|
||||
let rotatedVideoFrame = CGRect(origin: CGPoint(x: floor((rotatedBoundingSize.width - rotatedVideoSize.width) * 0.5), y: floor((rotatedBoundingSize.height - rotatedVideoSize.height) * 0.5)), size: rotatedVideoSize)
|
||||
|
||||
transition.setPosition(layer: self.videoLayer, position: rotatedVideoFrame.center)
|
||||
transition.setBounds(layer: self.videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoFrame.size))
|
||||
transition.setPosition(layer: self.videoLayer.blurredLayer, position: rotatedVideoFrame.center)
|
||||
transition.setBounds(layer: self.videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoFrame.size))
|
||||
|
||||
transition.setTransform(layer: self.videoLayer, transform: CATransform3DMakeRotation(CGFloat(videoMetrics.rotationAngle), 0.0, 0.0, 1.0))
|
||||
transition.setTransform(layer: self.videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(videoMetrics.rotationAngle), 0.0, 0.0, 1.0))
|
||||
|
||||
if params.isExpanded {
|
||||
self.videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)))
|
||||
}
|
||||
|
||||
let topShadowHeight: CGFloat = 200.0
|
||||
let topShadowFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: params.size.width, height: topShadowHeight))
|
||||
transition.setPosition(view: self.topShadowView, position: topShadowFrame.center)
|
||||
transition.setBounds(view: self.topShadowView, bounds: CGRect(origin: CGPoint(), size: topShadowFrame.size))
|
||||
transition.setAlpha(view: self.topShadowView, alpha: params.isExpanded ? 1.0 : 0.0)
|
||||
|
||||
let bottomShadowHeight: CGFloat = 200.0
|
||||
transition.setFrame(view: self.bottomShadowView, frame: CGRect(origin: CGPoint(x: 0.0, y: params.size.height - bottomShadowHeight), size: CGSize(width: params.size.width, height: bottomShadowHeight)))
|
||||
transition.setAlpha(view: self.bottomShadowView, alpha: params.isExpanded ? 1.0 : 0.0)
|
||||
}
|
||||
}
|
@ -2,17 +2,26 @@ import AVFoundation
|
||||
import Metal
|
||||
import CoreVideo
|
||||
|
||||
class VideoInput {
|
||||
final class Output {
|
||||
let y: MTLTexture
|
||||
let uv: MTLTexture
|
||||
|
||||
init(y: MTLTexture, uv: MTLTexture) {
|
||||
self.y = y
|
||||
self.uv = uv
|
||||
}
|
||||
}
|
||||
public final class VideoSourceOutput {
|
||||
let y: MTLTexture
|
||||
let uv: MTLTexture
|
||||
let rotationAngle: Float
|
||||
|
||||
init(y: MTLTexture, uv: MTLTexture, rotationAngle: Float) {
|
||||
self.y = y
|
||||
self.uv = uv
|
||||
self.rotationAngle = rotationAngle
|
||||
}
|
||||
}
|
||||
|
||||
public protocol VideoSource: AnyObject {
|
||||
typealias Output = VideoSourceOutput
|
||||
|
||||
var updated: (() -> Void)? { get set }
|
||||
var currentOutput: Output? { get }
|
||||
}
|
||||
|
||||
public final class FileVideoSource: VideoSource {
|
||||
private let playerLooper: AVPlayerLooper
|
||||
private let queuePlayer: AVQueuePlayer
|
||||
|
||||
@ -22,12 +31,12 @@ class VideoInput {
|
||||
|
||||
private var targetItem: AVPlayerItem?
|
||||
|
||||
private(set) var currentOutput: Output?
|
||||
var updated: (() -> Void)?
|
||||
public private(set) var currentOutput: Output?
|
||||
public var updated: (() -> Void)?
|
||||
|
||||
private var displayLink: SharedDisplayLink.Subscription?
|
||||
|
||||
init?(device: MTLDevice, url: URL) {
|
||||
public init?(device: MTLDevice, url: URL) {
|
||||
self.device = device
|
||||
CVMetalTextureCacheCreate(nil, nil, device, nil, &self.textureCache)
|
||||
|
||||
@ -71,6 +80,17 @@ class VideoInput {
|
||||
return false
|
||||
}
|
||||
|
||||
var rotationAngle: Float = 0.0
|
||||
if currentTime.seconds <= currentItem.duration.seconds * 0.25 {
|
||||
rotationAngle = 0.0
|
||||
} else if currentTime.seconds <= currentItem.duration.seconds * 0.5 {
|
||||
rotationAngle = Float.pi * 0.5
|
||||
} else if currentTime.seconds <= currentItem.duration.seconds * 0.75 {
|
||||
rotationAngle = Float.pi
|
||||
} else {
|
||||
rotationAngle = Float.pi * 3.0 / 2.0
|
||||
}
|
||||
|
||||
var pixelBuffer: CVPixelBuffer?
|
||||
pixelBuffer = self.videoOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil)
|
||||
|
||||
@ -92,27 +112,9 @@ class VideoInput {
|
||||
return false
|
||||
}
|
||||
|
||||
self.currentOutput = Output(y: yTexture, uv: uvTexture)
|
||||
rotationAngle = Float.pi * 0.5
|
||||
|
||||
self.currentOutput = Output(y: yTexture, uv: uvTexture, rotationAngle: rotationAngle)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ControlVideoInput {
|
||||
private let playerLooper: AVPlayerLooper
|
||||
private let queuePlayer: AVQueuePlayer
|
||||
|
||||
private let playerLayer: AVPlayerLayer
|
||||
|
||||
private var targetItem: AVPlayerItem?
|
||||
|
||||
init(url: URL, playerLayer: AVPlayerLayer) {
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
self.queuePlayer = AVQueuePlayer(playerItem: playerItem)
|
||||
self.playerLooper = AVPlayerLooper(player: self.queuePlayer, templateItem: playerItem)
|
||||
|
||||
self.playerLayer = playerLayer
|
||||
playerLayer.player = self.queuePlayer
|
||||
|
||||
self.queuePlayer.play()
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import MetalEngine
|
||||
import ComponentFlow
|
||||
|
||||
public final class PrivateCallScreen: UIView {
|
||||
public struct State: Equatable {
|
||||
@ -13,27 +14,51 @@ public final class PrivateCallScreen: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ActiveState: Equatable {
|
||||
public var startTime: Double
|
||||
public var signalInfo: SignalInfo
|
||||
public var emojiKey: [String]
|
||||
|
||||
public init(startTime: Double, signalInfo: SignalInfo, emojiKey: [String]) {
|
||||
self.startTime = startTime
|
||||
self.signalInfo = signalInfo
|
||||
self.emojiKey = emojiKey
|
||||
}
|
||||
}
|
||||
|
||||
public enum LifecycleState: Equatable {
|
||||
case connecting
|
||||
case ringing
|
||||
case exchangingKeys
|
||||
case active(startTime: Double, signalInfo: SignalInfo)
|
||||
case active(ActiveState)
|
||||
}
|
||||
|
||||
public var lifecycleState: LifecycleState
|
||||
public var name: String
|
||||
public var avatarImage: UIImage?
|
||||
|
||||
public init(lifecycleState: LifecycleState) {
|
||||
public init(
|
||||
lifecycleState: LifecycleState,
|
||||
name: String,
|
||||
avatarImage: UIImage?
|
||||
) {
|
||||
self.lifecycleState = lifecycleState
|
||||
self.name = name
|
||||
self.avatarImage = avatarImage
|
||||
}
|
||||
}
|
||||
|
||||
private struct Params: Equatable {
|
||||
var size: CGSize
|
||||
var insets: UIEdgeInsets
|
||||
var screenCornerRadius: CGFloat
|
||||
var state: State
|
||||
|
||||
init(size: CGSize, insets: UIEdgeInsets) {
|
||||
init(size: CGSize, insets: UIEdgeInsets, screenCornerRadius: CGFloat, state: State) {
|
||||
self.size = size
|
||||
self.insets = insets
|
||||
self.screenCornerRadius = screenCornerRadius
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,18 +73,14 @@ public final class PrivateCallScreen: UIView {
|
||||
|
||||
private let buttonGroupView: ButtonGroupView
|
||||
|
||||
public var state: State = State(lifecycleState: .connecting) {
|
||||
didSet {
|
||||
if self.state != oldValue {
|
||||
if let params = self.params {
|
||||
self.updateInternal(params: params, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var params: Params?
|
||||
|
||||
private var remoteVideo: VideoSource?
|
||||
|
||||
private var isSpeakerOn: Bool = false
|
||||
private var isMicrophoneMuted: Bool = false
|
||||
private var isVideoOn: Bool = false
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
self.blurContentsLayer = SimpleLayer()
|
||||
|
||||
@ -91,32 +112,53 @@ public final class PrivateCallScreen: UIView {
|
||||
|
||||
self.contentOverlayContainer.addSubview(self.buttonGroupView)
|
||||
|
||||
self.buttonGroupView.audioPressed = { [weak self] in
|
||||
guard let self else {
|
||||
/*self.buttonGroupView.audioPressed = { [weak self] in
|
||||
guard let self, var params = self.params else {
|
||||
return
|
||||
}
|
||||
|
||||
var state = self.state
|
||||
switch state.lifecycleState {
|
||||
case .connecting, .ringing, .exchangingKeys:
|
||||
state.lifecycleState = .active(startTime: CFAbsoluteTimeGetCurrent(), signalInfo: State.SignalInfo(quality: 1.0))
|
||||
case let .active(startTime, signalInfo):
|
||||
if signalInfo.quality == 1.0 {
|
||||
state.lifecycleState = .active(startTime: startTime, signalInfo: State.SignalInfo(quality: 0.2))
|
||||
} else if signalInfo.quality == 0.2 {
|
||||
state.lifecycleState = .connecting
|
||||
self.isSpeakerOn = !self.isSpeakerOn
|
||||
|
||||
switch params.state.lifecycleState {
|
||||
case .connecting:
|
||||
params.state.lifecycleState = .ringing
|
||||
case .ringing:
|
||||
params.state.lifecycleState = .exchangingKeys
|
||||
case .exchangingKeys:
|
||||
params.state.lifecycleState = .active(State.ActiveState(
|
||||
startTime: Date().timeIntervalSince1970,
|
||||
signalInfo: State.SignalInfo(quality: 1.0),
|
||||
emojiKey: ["🐱", "🚂", "❄️", "🎨"]
|
||||
))
|
||||
case var .active(activeState):
|
||||
if activeState.signalInfo.quality == 1.0 {
|
||||
activeState.signalInfo.quality = 0.1
|
||||
} else {
|
||||
activeState.signalInfo.quality = 1.0
|
||||
}
|
||||
|
||||
params.state.lifecycleState = .active(activeState)
|
||||
}
|
||||
self.state = state
|
||||
|
||||
self.params = params
|
||||
self.update(transition: .spring(duration: 0.3))
|
||||
}
|
||||
|
||||
self.buttonGroupView.toggleVideo = { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.contentView.toggleDisplayVideo()
|
||||
}
|
||||
if self.remoteVideo == nil {
|
||||
if let url = Bundle.main.url(forResource: "test2", withExtension: "mp4") {
|
||||
self.remoteVideo = FileVideoSource(device: MetalEngine.shared.device, url: url)
|
||||
}
|
||||
} else {
|
||||
self.remoteVideo = nil
|
||||
}
|
||||
|
||||
self.isVideoOn = !self.isVideoOn
|
||||
|
||||
self.update(transition: .spring(duration: 0.3))
|
||||
}*/
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
@ -131,16 +173,23 @@ public final class PrivateCallScreen: UIView {
|
||||
return result
|
||||
}
|
||||
|
||||
public func update(size: CGSize, insets: UIEdgeInsets) {
|
||||
let params = Params(size: size, insets: insets)
|
||||
public func update(size: CGSize, insets: UIEdgeInsets, screenCornerRadius: CGFloat, state: State, transition: Transition) {
|
||||
let params = Params(size: size, insets: insets, screenCornerRadius: screenCornerRadius, state: state)
|
||||
if self.params == params {
|
||||
return
|
||||
}
|
||||
self.params = params
|
||||
self.updateInternal(params: params, animated: false)
|
||||
self.updateInternal(params: params, transition: transition)
|
||||
}
|
||||
|
||||
private func updateInternal(params: Params, animated: Bool) {
|
||||
|
||||
private func update(transition: Transition) {
|
||||
guard let params = self.params else {
|
||||
return
|
||||
}
|
||||
self.updateInternal(params: params, transition: transition)
|
||||
}
|
||||
|
||||
private func updateInternal(params: Params, transition: Transition) {
|
||||
let backgroundFrame = CGRect(origin: CGPoint(), size: params.size)
|
||||
|
||||
let aspect: CGFloat = params.size.width / params.size.height
|
||||
@ -151,24 +200,24 @@ public final class PrivateCallScreen: UIView {
|
||||
let visualBackgroundFrame = backgroundFrame.insetBy(dx: -CGFloat(edgeSize) / renderingSize.width * backgroundFrame.width, dy: -CGFloat(edgeSize) / renderingSize.height * backgroundFrame.height)
|
||||
|
||||
self.backgroundLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(renderingSize.width) + edgeSize * 2, height: Int(renderingSize.height) + edgeSize * 2))
|
||||
self.backgroundLayer.frame = visualBackgroundFrame
|
||||
transition.setFrame(layer: self.backgroundLayer, frame: visualBackgroundFrame)
|
||||
|
||||
let backgroundStateIndex: Int
|
||||
switch self.state.lifecycleState {
|
||||
switch params.state.lifecycleState {
|
||||
case .connecting:
|
||||
backgroundStateIndex = 0
|
||||
case .ringing:
|
||||
backgroundStateIndex = 0
|
||||
case .exchangingKeys:
|
||||
backgroundStateIndex = 0
|
||||
case let .active(_, signalInfo):
|
||||
if signalInfo.quality <= 0.2 {
|
||||
case let .active(activeState):
|
||||
if activeState.signalInfo.quality <= 0.2 {
|
||||
backgroundStateIndex = 2
|
||||
} else {
|
||||
backgroundStateIndex = 1
|
||||
}
|
||||
}
|
||||
self.backgroundLayer.update(stateIndex: backgroundStateIndex, animated: animated)
|
||||
self.backgroundLayer.update(stateIndex: backgroundStateIndex, transition: transition)
|
||||
|
||||
self.contentOverlayLayer.frame = CGRect(origin: CGPoint(), size: params.size)
|
||||
self.contentOverlayLayer.update(size: params.size, contentInsets: UIEdgeInsets())
|
||||
@ -176,13 +225,74 @@ public final class PrivateCallScreen: UIView {
|
||||
self.contentOverlayContainer.frame = CGRect(origin: CGPoint(), size: params.size)
|
||||
|
||||
self.blurBackgroundLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(renderingSize.width) + edgeSize * 2, height: Int(renderingSize.height) + edgeSize * 2))
|
||||
self.blurBackgroundLayer.frame = visualBackgroundFrame
|
||||
self.blurBackgroundLayer.update(stateIndex: backgroundStateIndex, animated: animated)
|
||||
self.blurBackgroundLayer.update(stateIndex: backgroundStateIndex, transition: transition)
|
||||
transition.setFrame(layer: self.blurBackgroundLayer, frame: visualBackgroundFrame)
|
||||
|
||||
self.buttonGroupView.frame = CGRect(origin: CGPoint(), size: params.size)
|
||||
self.buttonGroupView.update(size: params.size)
|
||||
|
||||
let buttons: [ButtonGroupView.Button] = [
|
||||
ButtonGroupView.Button(content: .speaker(isActive: self.isSpeakerOn), action: { [weak self] in
|
||||
guard let self, var params = self.params else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isSpeakerOn = !self.isSpeakerOn
|
||||
|
||||
switch params.state.lifecycleState {
|
||||
case .connecting:
|
||||
params.state.lifecycleState = .ringing
|
||||
case .ringing:
|
||||
params.state.lifecycleState = .exchangingKeys
|
||||
case .exchangingKeys:
|
||||
params.state.lifecycleState = .active(State.ActiveState(
|
||||
startTime: Date().timeIntervalSince1970,
|
||||
signalInfo: State.SignalInfo(quality: 1.0),
|
||||
emojiKey: ["🐱", "🚂", "❄️", "🎨"]
|
||||
))
|
||||
case var .active(activeState):
|
||||
if activeState.signalInfo.quality == 1.0 {
|
||||
activeState.signalInfo.quality = 0.1
|
||||
} else {
|
||||
activeState.signalInfo.quality = 1.0
|
||||
}
|
||||
params.state.lifecycleState = .active(activeState)
|
||||
}
|
||||
|
||||
self.params = params
|
||||
self.update(transition: .spring(duration: 0.3))
|
||||
}),
|
||||
ButtonGroupView.Button(content: .video(isActive: self.isVideoOn), action: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.remoteVideo == nil {
|
||||
if let url = Bundle.main.url(forResource: "test2", withExtension: "mp4") {
|
||||
self.remoteVideo = FileVideoSource(device: MetalEngine.shared.device, url: url)
|
||||
}
|
||||
} else {
|
||||
self.remoteVideo = nil
|
||||
}
|
||||
|
||||
self.isVideoOn = !self.isVideoOn
|
||||
|
||||
self.update(transition: .spring(duration: 0.3))
|
||||
}),
|
||||
ButtonGroupView.Button(content: .microphone(isMuted: self.isMicrophoneMuted), action: {
|
||||
|
||||
}),
|
||||
ButtonGroupView.Button(content: .end, action: {
|
||||
})
|
||||
]
|
||||
self.buttonGroupView.update(size: params.size, buttons: buttons, transition: transition)
|
||||
|
||||
self.contentView.frame = CGRect(origin: CGPoint(), size: params.size)
|
||||
self.contentView.update(size: params.size, insets: params.insets, state: self.state)
|
||||
self.contentView.update(
|
||||
size: params.size,
|
||||
insets: params.insets,
|
||||
screenCornerRadius: params.screenCornerRadius,
|
||||
state: params.state,
|
||||
remoteVideo: remoteVideo,
|
||||
transition: transition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ public final class PlainButtonComponent: Component {
|
||||
transition.setScale(layer: self.contentContainer.layer, scale: topScale)
|
||||
} else {
|
||||
self.contentContainer.alpha = 1.0
|
||||
self.contentContainer.layer.animateAlpha(from: 7, to: 1.0, duration: 0.2)
|
||||
self.contentContainer.layer.animateAlpha(from: 0.7, to: 1.0, duration: 0.2)
|
||||
|
||||
let transition = Transition(animation: .none)
|
||||
transition.setScale(layer: self.contentContainer.layer, scale: 1.0)
|
||||
|
@ -17,6 +17,8 @@ typedef struct {
|
||||
|
||||
@interface ShelfPackContext : NSObject
|
||||
|
||||
@property (nonatomic, readonly) bool isEmpty;
|
||||
|
||||
- (instancetype _Nonnull)initWithWidth:(int32_t)width height:(int32_t)height;
|
||||
|
||||
- (ShelfPackItem)addItemWithWidth:(int32_t)width height:(int32_t)height;
|
||||
|
@ -6,6 +6,7 @@
|
||||
@interface ShelfPackContext () {
|
||||
std::unique_ptr<mapbox::ShelfPack> _pack;
|
||||
int32_t _nextItemId;
|
||||
int _count;
|
||||
}
|
||||
|
||||
@end
|
||||
@ -20,6 +21,10 @@
|
||||
return self;
|
||||
}
|
||||
|
||||
- (bool)isEmpty {
|
||||
return _count == 0;
|
||||
}
|
||||
|
||||
- (ShelfPackItem)addItemWithWidth:(int32_t)width height:(int32_t)height {
|
||||
ShelfPackItem item = {
|
||||
.itemId = -1,
|
||||
@ -37,6 +42,7 @@
|
||||
item.y = bin->y;
|
||||
item.width = bin->w;
|
||||
item.height = bin->h;
|
||||
_count += 1;
|
||||
}
|
||||
|
||||
return item;
|
||||
@ -45,6 +51,7 @@
|
||||
- (void)removeItem:(int32_t)itemId {
|
||||
if (const auto bin = _pack->getBin(itemId)) {
|
||||
_pack->unref(*bin);
|
||||
_count -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user