Telegram-iOS/submodules/TelegramCallsUI/Sources/CallControllerNode.swift
Isaac 7b2b74e79b Postbox -> TelegramEngine wave 6: unused import Postbox batch sweep
First build-verified unused-import sweep: speculatively dropped
import Postbox from 782 consumer files (plain ^import Postbox$ lines,
excluding TelegramCore/Postbox/TelegramApi paths), iterated 18 full
project builds with --continueOnError, restored the import on every
file that failed to compile. 183 drops survived; 189 consumer modules
newly Postbox-free.

Bundled: spec + plan + C1 atomic batch drop + C2 CLAUDE.md outcome and
permanent methodology guidance under Wave-selection. The methodology
subsection captures the reusable playbook (--continueOnError is
essential, dependency graphs are deep so expect many iterations,
pattern-based preemptive restores accelerate convergence, and
CLAUDE.md's engine typealias cheat sheet arrows are migration targets
rather than typealiases in TelegramCore).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:46:13 +02:00

369 lines
15 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramAudio
import AccountContext
import LocalizedPeerData
import PhotoResources
import CallsEmoji
import TooltipUI
import AlertUI
import PresentationDataUtils
import DeviceAccess
import ContextUI
import AppBundle
private func interpolateFrame(from fromValue: CGRect, to toValue: CGRect, t: CGFloat) -> CGRect {
return CGRect(x: floorToScreenPixels(toValue.origin.x * t + fromValue.origin.x * (1.0 - t)), y: floorToScreenPixels(toValue.origin.y * t + fromValue.origin.y * (1.0 - t)), width: floorToScreenPixels(toValue.size.width * t + fromValue.size.width * (1.0 - t)), height: floorToScreenPixels(toValue.size.height * t + fromValue.size.height * (1.0 - t)))
}
private func interpolate(from: CGFloat, to: CGFloat, value: CGFloat) -> CGFloat {
return (1.0 - value) * from + value * to
}
final class CallVideoNode: ASDisplayNode, PreviewVideoNode {
private var placeholderImageNode: ASImageNode?
private let videoTransformContainer: ASDisplayNode
private let videoView: PresentationCallVideoView
private var effectView: UIVisualEffectView?
private let videoPausedNode: ImmediateTextNode
private var isBlurred: Bool = false
private var currentCornerRadius: CGFloat = 0.0
private let isReadyUpdated: () -> Void
private(set) var isReady: Bool = false
private var isReadyTimer: SwiftSignalKit.Timer?
private let readyPromise = ValuePromise(false)
var ready: Signal<Bool, NoError> {
return self.readyPromise.get()
}
private let isFlippedUpdated: (CallVideoNode) -> Void
private(set) var currentOrientation: PresentationCallVideoView.Orientation
private(set) var currentAspect: CGFloat = 0.0
private var previousVideoHeight: CGFloat?
init(videoView: PresentationCallVideoView, displayPlaceholderUntilReady: Bool = false, disabledText: String?, assumeReadyAfterTimeout: Bool, isReadyUpdated: @escaping () -> Void, orientationUpdated: @escaping () -> Void, isFlippedUpdated: @escaping (CallVideoNode) -> Void) {
self.isReadyUpdated = isReadyUpdated
self.isFlippedUpdated = isFlippedUpdated
self.videoTransformContainer = ASDisplayNode()
self.videoView = videoView
videoView.view.clipsToBounds = true
videoView.view.backgroundColor = .black
self.currentOrientation = videoView.getOrientation()
self.currentAspect = videoView.getAspect()
self.videoPausedNode = ImmediateTextNode()
self.videoPausedNode.alpha = 0.0
self.videoPausedNode.maximumNumberOfLines = 3
super.init()
self.backgroundColor = .black
self.clipsToBounds = true
if #available(iOS 13.0, *) {
self.layer.cornerCurve = .continuous
}
self.videoTransformContainer.view.addSubview(self.videoView.view)
self.addSubnode(self.videoTransformContainer)
if displayPlaceholderUntilReady {
let placeholderImageNode = ASImageNode()
placeholderImageNode.image = UIImage(bundleImageName: "Camera/SelfiePlaceholder")
self.placeholderImageNode = placeholderImageNode
self.addSubnode(placeholderImageNode)
}
if let disabledText = disabledText {
self.videoPausedNode.attributedText = NSAttributedString(string: disabledText, font: Font.regular(17.0), textColor: .white)
self.addSubnode(self.videoPausedNode)
}
self.videoView.setOnFirstFrameReceived { [weak self] aspectRatio in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
if !strongSelf.isReady {
strongSelf.isReady = true
strongSelf.readyPromise.set(true)
strongSelf.isReadyTimer?.invalidate()
strongSelf.isReadyUpdated()
if let placeholderImageNode = strongSelf.placeholderImageNode {
strongSelf.placeholderImageNode = nil
placeholderImageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak placeholderImageNode] _ in
placeholderImageNode?.removeFromSupernode()
})
}
}
}
}
self.videoView.setOnOrientationUpdated { [weak self] orientation, aspect in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
if strongSelf.currentOrientation != orientation || strongSelf.currentAspect != aspect {
strongSelf.currentOrientation = orientation
strongSelf.currentAspect = aspect
orientationUpdated()
}
}
}
self.videoView.setOnIsMirroredUpdated { [weak self] _ in
Queue.mainQueue().async {
guard let strongSelf = self else {
return
}
strongSelf.isFlippedUpdated(strongSelf)
}
}
if assumeReadyAfterTimeout {
self.isReadyTimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in
guard let strongSelf = self else {
return
}
if !strongSelf.isReady {
strongSelf.isReady = true
strongSelf.readyPromise.set(true)
strongSelf.isReadyUpdated()
}
}, queue: .mainQueue())
}
self.isReadyTimer?.start()
}
deinit {
self.isReadyTimer?.invalidate()
}
override func didLoad() {
super.didLoad()
if #available(iOS 13.0, *) {
self.layer.cornerCurve = .continuous
}
}
func animateRadialMask(from fromRect: CGRect, to toRect: CGRect) {
let maskLayer = CAShapeLayer()
maskLayer.frame = fromRect
let path = CGMutablePath()
path.addEllipse(in: CGRect(origin: CGPoint(), size: fromRect.size))
maskLayer.path = path
self.layer.mask = maskLayer
let topLeft = CGPoint(x: 0.0, y: 0.0)
let topRight = CGPoint(x: self.bounds.width, y: 0.0)
let bottomLeft = CGPoint(x: 0.0, y: self.bounds.height)
let bottomRight = CGPoint(x: self.bounds.width, y: self.bounds.height)
func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat {
let dx = v1.x - v2.x
let dy = v1.y - v2.y
return sqrt(dx * dx + dy * dy)
}
var maxRadius = distance(toRect.center, topLeft)
maxRadius = max(maxRadius, distance(toRect.center, topRight))
maxRadius = max(maxRadius, distance(toRect.center, bottomLeft))
maxRadius = max(maxRadius, distance(toRect.center, bottomRight))
maxRadius = ceil(maxRadius)
let targetFrame = CGRect(origin: CGPoint(x: toRect.center.x - maxRadius, y: toRect.center.y - maxRadius), size: CGSize(width: maxRadius * 2.0, height: maxRadius * 2.0))
let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut)
transition.updatePosition(layer: maskLayer, position: targetFrame.center)
transition.updateTransformScale(layer: maskLayer, scale: maxRadius * 2.0 / fromRect.width, completion: { [weak self] _ in
self?.layer.mask = nil
})
}
func updateLayout(size: CGSize, layoutMode: VideoNodeLayoutMode, transition: ContainedViewLayoutTransition) {
self.updateLayout(size: size, cornerRadius: self.currentCornerRadius, isOutgoing: true, deviceOrientation: .portrait, isCompactLayout: false, transition: transition)
}
func updateLayout(size: CGSize, cornerRadius: CGFloat, isOutgoing: Bool, deviceOrientation: UIDeviceOrientation, isCompactLayout: Bool, transition: ContainedViewLayoutTransition) {
self.currentCornerRadius = cornerRadius
if let placeholderImageNode = self.placeholderImageNode, let image = placeholderImageNode.image {
let placeholderSize = image.size.aspectFilled(size)
transition.updateFrame(node: placeholderImageNode, frame: CGRect(origin: CGPoint(x: (size.width - placeholderSize.width) * 0.5, y: (size.height - placeholderSize.height) * 0.5), size: placeholderSize))
}
var rotationAngle: CGFloat
if false && isOutgoing && isCompactLayout {
rotationAngle = CGFloat.pi / 2.0
} else {
switch self.currentOrientation {
case .rotation0:
rotationAngle = 0.0
case .rotation90:
rotationAngle = CGFloat.pi / 2.0
case .rotation180:
rotationAngle = CGFloat.pi
case .rotation270:
rotationAngle = -CGFloat.pi / 2.0
}
var additionalAngle: CGFloat = 0.0
switch deviceOrientation {
case .portrait:
additionalAngle = 0.0
case .landscapeLeft:
additionalAngle = CGFloat.pi / 2.0
case .landscapeRight:
additionalAngle = -CGFloat.pi / 2.0
case .portraitUpsideDown:
rotationAngle = CGFloat.pi
default:
additionalAngle = 0.0
}
rotationAngle += additionalAngle
if abs(rotationAngle - CGFloat.pi * 3.0 / 2.0) < 0.01 {
rotationAngle = -CGFloat.pi / 2.0
}
if abs(rotationAngle - (-CGFloat.pi)) < 0.01 {
rotationAngle = -CGFloat.pi + 0.001
}
}
let rotateFrame = abs(rotationAngle.remainder(dividingBy: CGFloat.pi)) > 1.0
let fittingSize: CGSize
if rotateFrame {
fittingSize = CGSize(width: size.height, height: size.width)
} else {
fittingSize = size
}
let unboundVideoSize = CGSize(width: self.currentAspect * 10000.0, height: 10000.0)
var fittedVideoSize = unboundVideoSize.fitted(fittingSize)
if fittedVideoSize.width < fittingSize.width || fittedVideoSize.height < fittingSize.height {
let isVideoPortrait = unboundVideoSize.width < unboundVideoSize.height
let isFittingSizePortrait = fittingSize.width < fittingSize.height
if isCompactLayout && isVideoPortrait == isFittingSizePortrait {
fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize)
} else {
let maxFittingEdgeDistance: CGFloat
if isCompactLayout {
maxFittingEdgeDistance = 200.0
} else {
maxFittingEdgeDistance = 400.0
}
if fittedVideoSize.width > fittingSize.width - maxFittingEdgeDistance && fittedVideoSize.height > fittingSize.height - maxFittingEdgeDistance {
fittedVideoSize = unboundVideoSize.aspectFilled(fittingSize)
}
}
}
let rotatedVideoHeight: CGFloat = max(fittedVideoSize.height, fittedVideoSize.width)
let videoFrame: CGRect = CGRect(origin: CGPoint(), size: fittedVideoSize)
let videoPausedSize = self.videoPausedNode.updateLayout(CGSize(width: size.width - 16.0, height: 100.0))
transition.updateFrame(node: self.videoPausedNode, frame: CGRect(origin: CGPoint(x: floor((size.width - videoPausedSize.width) / 2.0), y: floor((size.height - videoPausedSize.height) / 2.0)), size: videoPausedSize))
self.videoTransformContainer.bounds = CGRect(origin: CGPoint(), size: videoFrame.size)
if transition.isAnimated && !videoFrame.height.isZero, let previousVideoHeight = self.previousVideoHeight, !previousVideoHeight.isZero {
let scaleDifference = previousVideoHeight / rotatedVideoHeight
if abs(scaleDifference - 1.0) > 0.001 {
transition.animateTransformScale(node: self.videoTransformContainer, from: scaleDifference, additive: true)
}
}
self.previousVideoHeight = rotatedVideoHeight
transition.updatePosition(node: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
transition.updateTransformRotation(view: self.videoTransformContainer.view, angle: rotationAngle)
let localVideoFrame = CGRect(origin: CGPoint(), size: videoFrame.size)
self.videoView.view.bounds = localVideoFrame
self.videoView.view.center = localVideoFrame.center
// TODO: properly fix the issue
// On iOS 13 and later metal layer transformation is broken if the layer does not require compositing
self.videoView.view.alpha = 0.995
if let effectView = self.effectView {
transition.updateFrame(view: effectView, frame: localVideoFrame)
}
transition.updateCornerRadius(layer: self.layer, cornerRadius: self.currentCornerRadius)
}
func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) {
if self.hasScheduledUnblur {
self.hasScheduledUnblur = false
}
if self.isBlurred == isBlurred {
return
}
self.isBlurred = isBlurred
if isBlurred {
if self.effectView == nil {
let effectView = UIVisualEffectView()
self.effectView = effectView
effectView.frame = self.videoTransformContainer.bounds
self.videoTransformContainer.view.addSubview(effectView)
}
if animated {
UIView.animate(withDuration: 0.3, animations: {
self.videoPausedNode.alpha = 1.0
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
})
} else {
self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark)
}
} else if let effectView = self.effectView {
self.effectView = nil
UIView.animate(withDuration: 0.3, animations: {
self.videoPausedNode.alpha = 0.0
effectView.effect = nil
}, completion: { [weak effectView] _ in
effectView?.removeFromSuperview()
})
}
}
private var hasScheduledUnblur = false
func flip(withBackground: Bool) {
if withBackground {
self.backgroundColor = .black
}
UIView.transition(with: withBackground ? self.videoTransformContainer.view : self.view, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: {
UIView.performWithoutAnimation {
self.updateIsBlurred(isBlurred: true, light: false, animated: false)
}
}) { finished in
self.backgroundColor = nil
self.hasScheduledUnblur = true
Queue.mainQueue().after(0.5) {
if self.hasScheduledUnblur {
self.updateIsBlurred(isBlurred: false)
}
}
}
}
}