[WIP] Private Call UI

This commit is contained in:
Ali 2023-11-13 21:51:22 +04:00
parent 04705d4d09
commit e26df40077
23 changed files with 1382 additions and 385 deletions

View File

@ -43,7 +43,7 @@ swift_library(
deps = [
"//submodules/Display",
"//submodules/MetalEngine",
"//submodules/TelegramUI/Components/DustEffect",
"//submodules/TelegramUI/Components/Calls/CallScreen",
],
)

View File

@ -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))
}
}

View File

@ -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)
}

View File

@ -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, *) {

View File

@ -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]))

View File

@ -64,6 +64,7 @@ swift_library(
"//submodules/Display",
"//submodules/MetalEngine",
"//submodules/ComponentFlow",
"//submodules/SSignalKit/SwiftSignalKit",
"//submodules/TelegramUI/Components/AnimatedTextComponent",
],
visibility = [

View File

@ -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

View File

@ -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()
})
}
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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
)
}
}

View File

@ -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)

View File

@ -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;

View File

@ -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;
}
}