Swiftgram/submodules/TelegramUI/Sources/OverlayMediaControllerNode.swift
2024-04-02 19:16:00 +04:00

435 lines
20 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import AccountContext
private final class OverlayMediaControllerNodeView: UITracingLayerView {
var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)?
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
return self.hitTestImpl?(point, event)
}
}
private final class OverlayMediaVideoNodeData {
var node: OverlayMediaItemNode
var location: CGPoint
var isMinimized: Bool
var currentSize: CGSize
init(node: OverlayMediaItemNode, location: CGPoint, isMinimized: Bool, currentSize: CGSize) {
self.node = node
self.location = location
self.isMinimized = isMinimized
self.currentSize = currentSize
}
}
final class OverlayMediaControllerNode: ASDisplayNode, ASGestureRecognizerDelegate {
private let updatePossibleEmbeddingItem: (OverlayMediaControllerEmbeddingItem?) -> Void
private let embedPossibleEmbeddingItem: (OverlayMediaControllerEmbeddingItem) -> Bool
private var videoNodes: [OverlayMediaVideoNodeData] = []
private var validLayout: ContainerViewLayout?
private var locationByGroup: [OverlayMediaItemNodeGroup: CGPoint] = [:]
private weak var draggingNode: OverlayMediaItemNode?
private var draggingStartPosition = CGPoint()
private var pinchingNode: OverlayMediaItemNode?
private var pinchingNodeInitialSize: CGSize?
init(updatePossibleEmbeddingItem: @escaping (OverlayMediaControllerEmbeddingItem?) -> Void, embedPossibleEmbeddingItem: @escaping (OverlayMediaControllerEmbeddingItem) -> Bool) {
self.updatePossibleEmbeddingItem = updatePossibleEmbeddingItem
self.embedPossibleEmbeddingItem = embedPossibleEmbeddingItem
super.init()
self.setViewBlock({
return OverlayMediaControllerNodeView()
})
(self.view as! OverlayMediaControllerNodeView).hitTestImpl = { [weak self] point, event in
return self?.hitTest(point, with: event)
}
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
panRecognizer.cancelsTouchesInView = false
panRecognizer.delegate = self.wrappedGestureRecognizerDelegate
self.view.addGestureRecognizer(panRecognizer)
let pinchRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.pinchGesture(_:)))
pinchRecognizer.cancelsTouchesInView = false
pinchRecognizer.delegate = self.wrappedGestureRecognizerDelegate
self.view.addGestureRecognizer(pinchRecognizer)
}
deinit {
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPinchGestureRecognizer {
return false
}
return true
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for item in self.videoNodes {
if item.node.frame.contains(point) {
if let result = item.node.hitTest(point.offsetBy(dx: -item.node.frame.origin.x, dy: -item.node.frame.origin.y), with: event) {
return result
} else {
return item.node.view
}
}
}
return nil
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
for item in self.videoNodes {
let nodeSize = item.currentSize
transition.updateFrame(node: item.node, frame: CGRect(origin: self.nodePosition(layout: layout, size: nodeSize, location: item.location, hidden: !item.node.customTransition && !item.node.hasAttachedContext, isMinimized: item.isMinimized, tempExtendedTopInset: item.node.tempExtendedTopInset), size: nodeSize))
item.node.updateLayout(nodeSize)
}
}
private func nodePosition(layout: ContainerViewLayout, size: CGSize, location: CGPoint, hidden: Bool, isMinimized: Bool, tempExtendedTopInset: Bool) -> CGPoint {
var layoutInsets = layout.insets(options: [.input])
layoutInsets.bottom += 48.0
if tempExtendedTopInset {
layoutInsets.top += 38.0
}
let inset: CGFloat = 4.0 + layout.safeInsets.left
var result = CGPoint()
if location.x.isZero {
if isMinimized {
result.x = inset - size.width + 40.0
} else if hidden {
result.x = -size.width - inset
} else {
result.x = inset
}
} else {
if isMinimized {
result.x = layout.size.width - inset - 40.0
} else if hidden {
result.x = layout.size.width + inset
} else {
result.x = layout.size.width - inset - size.width
}
}
if location.y.isZero {
result.y = layoutInsets.top + inset
} else {
result.y = layout.size.height - layoutInsets.bottom - inset - size.height
}
return result
}
private func nodeLocationForPosition(layout: ContainerViewLayout, position: CGPoint, velocity: CGPoint, size: CGSize, tempExtendedTopInset: Bool) -> (CGPoint, Bool) {
var layoutInsets = layout.insets(options: [.input])
layoutInsets.bottom += 48.0
if tempExtendedTopInset {
layoutInsets.top += 38.0
}
var result = CGPoint()
if position.x < layout.size.width / 2.0 {
result.x = 0.0
} else {
result.x = 1.0
}
if position.y < layoutInsets.top + (layout.size.height - layoutInsets.bottom - layoutInsets.top) / 2.0 {
result.y = 0.0
} else {
result.y = 1.0
}
let currentPosition = result
let angleEpsilon: CGFloat = 30.0
var shouldHide = false
if (velocity.x * velocity.x + velocity.y * velocity.y) >= 500.0 * 500.0 {
let x = velocity.x
let y = velocity.y
var angle = atan2(y, x) * 180.0 / CGFloat.pi * -1.0
if angle < 0.0 {
angle += 360.0
}
if currentPosition.x.isZero && currentPosition.y.isZero {
if ((angle > 0 && angle < 90 - angleEpsilon) || angle > 360 - angleEpsilon) {
result.x = 1.0
result.y = 0.0
} else if (angle > 180 + angleEpsilon && angle < 270 + angleEpsilon) {
result.x = 0.0
result.y = 1.0
} else if (angle > 270 + angleEpsilon && angle < 360 - angleEpsilon) {
result.x = 1.0
result.y = 1.0
} else {
shouldHide = true
}
} else if !currentPosition.x.isZero && currentPosition.y.isZero {
if (angle > 90 + angleEpsilon && angle < 180 + angleEpsilon) {
result.x = 0.0
result.y = 0.0
}
else if (angle > 270 - angleEpsilon && angle < 360 - angleEpsilon) {
result.x = 1.0
result.y = 1.0
}
else if (angle > 180 + angleEpsilon && angle < 270 - angleEpsilon) {
result.x = 0.0
result.y = 1.0
}
else {
shouldHide = true
}
} else if currentPosition.x.isZero && !currentPosition.y.isZero {
if (angle > 90 - angleEpsilon && angle < 180 - angleEpsilon) {
result.x = 0.0
result.y = 0.0
}
else if (angle < angleEpsilon || angle > 270 + angleEpsilon) {
result.x = 1.0
result.y = 1.0
}
else if (angle > angleEpsilon && angle < 90 - angleEpsilon) {
result.x = 1.0
result.y = 0.0
}
else if (!shouldHide) {
shouldHide = true
}
} else if !currentPosition.x.isZero && !currentPosition.y.isZero {
if (angle > angleEpsilon && angle < 90 + angleEpsilon) {
result.x = 1.0
result.y = 0.0
}
else if (angle > 180 - angleEpsilon && angle < 270 - angleEpsilon) {
result.x = 0.0
result.y = 1.0
}
else if (angle > 90 + angleEpsilon && angle < 180 - angleEpsilon) {
result.x = 0.0
result.y = 0.0
}
else if (!shouldHide) {
shouldHide = true
}
}
}
return (result, shouldHide)
}
var hasNodes: Bool {
return !self.videoNodes.isEmpty
}
func addNode(_ node: OverlayMediaItemNode, customTransition: Bool) {
var location = CGPoint(x: 1.0, y: 0.0)
node.customTransition = customTransition
if let group = node.group {
if let groupLocation = self.locationByGroup[group] {
location = groupLocation
}
}
let nodeData = OverlayMediaVideoNodeData(node: node, location: location, isMinimized: false, currentSize: node.preferredSizeForOverlayDisplay(boundingSize: self.frame.size))
self.videoNodes.append(nodeData)
self.addSubnode(node)
if let validLayout = self.validLayout {
let nodeSize = nodeData.currentSize
if self.draggingNode !== node {
if customTransition {
node.frame = CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: location, hidden: false, isMinimized: false, tempExtendedTopInset: node.tempExtendedTopInset), size: nodeSize)
} else {
node.frame = CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: location, hidden: true, isMinimized: false, tempExtendedTopInset: node.tempExtendedTopInset), size: nodeSize)
}
}
node.updateLayout(nodeSize)
self.containerLayoutUpdated(validLayout, transition: .immediate)
if !customTransition {
let positionX = CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: location, hidden: true, isMinimized: false, tempExtendedTopInset: node.tempExtendedTopInset), size: nodeSize).center.x
node.layer.animatePosition(from: CGPoint(x: positionX - node.layer.position.x, y: 0.0), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
}
}
node.hasAttachedContextUpdated = { [weak self] _ in
if let strongSelf = self, let validLayout = strongSelf.validLayout, !customTransition {
strongSelf.containerLayoutUpdated(validLayout, transition: .animated(duration: 0.3, curve: .spring))
}
}
node.unminimize = { [weak self, weak node] in
if let strongSelf = self, let node = node {
if let index = strongSelf.videoNodes.firstIndex(where: { $0.node === node }), let validLayout = strongSelf.validLayout, node !== strongSelf.draggingNode, strongSelf.videoNodes[index].isMinimized {
strongSelf.videoNodes[index].isMinimized = false
node.updateMinimizedEdge(nil, adjusting: true)
strongSelf.containerLayoutUpdated(validLayout, transition: .animated(duration: 0.3, curve: .spring))
}
}
}
node.setShouldAcquireContext(true)
}
func removeNode(_ node: OverlayMediaItemNode, customTransition: Bool) {
if node.supernode === self {
node.hasAttachedContextUpdated = nil
node.setShouldAcquireContext(false)
if let index = self.videoNodes.firstIndex(where: { $0.node === node }), let validLayout = self.validLayout {
if customTransition {
node.removeFromSupernode()
} else {
if !node.isRemoved {
let nodeSize = self.videoNodes[index].currentSize
node.layer.animateFrame(from: node.layer.frame, to: CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: self.videoNodes[index].location, hidden: true, isMinimized: self.videoNodes[index].isMinimized, tempExtendedTopInset: node.tempExtendedTopInset), size: nodeSize), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak node] _ in
node?.removeFromSupernode()
})
}
}
} else {
node.removeFromSupernode()
}
if let index = self.videoNodes.firstIndex(where: { $0.node === node }) {
self.videoNodes.remove(at: index)
}
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
if let draggingNode = self.draggingNode, let validLayout = self.validLayout, let index = self.videoNodes.firstIndex(where: { $0.node === draggingNode }){
let nodeSize = self.videoNodes[index].currentSize
let previousFrame = draggingNode.frame
draggingNode.frame = CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: self.videoNodes[index].location, hidden: !draggingNode.customTransition && !draggingNode.hasAttachedContext, isMinimized: self.videoNodes[index].isMinimized, tempExtendedTopInset: draggingNode.tempExtendedTopInset), size: nodeSize)
draggingNode.layer.animateFrame(from: previousFrame, to: draggingNode.frame, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
self.draggingNode = nil
}
loop: for item in self.videoNodes {
if item.node.frame.contains(recognizer.location(in: self.view)) {
self.draggingNode = item.node
self.draggingStartPosition = item.node.frame.origin
break loop
}
}
case .changed:
if let draggingNode = self.draggingNode, let validLayout = self.validLayout {
let translation = recognizer.translation(in: self.view)
var nodeFrame = draggingNode.frame
nodeFrame.origin = self.draggingStartPosition.offsetBy(dx: translation.x, dy: translation.y)
if nodeFrame.midX < 0.0 {
draggingNode.updateMinimizedEdge(.left, adjusting: true)
} else if nodeFrame.midX > validLayout.size.width {
draggingNode.updateMinimizedEdge(.right, adjusting: true)
} else {
draggingNode.updateMinimizedEdge(nil, adjusting: true)
}
draggingNode.frame = nodeFrame
self.updatePossibleEmbeddingItem(OverlayMediaControllerEmbeddingItem(
position: nodeFrame.center,
itemNode: draggingNode
))
}
case .ended, .cancelled:
if let draggingNode = self.draggingNode, let validLayout = self.validLayout, let index = self.videoNodes.firstIndex(where: { $0.node === draggingNode }){
let nodeSize = self.videoNodes[index].currentSize
let previousFrame = draggingNode.frame
if self.embedPossibleEmbeddingItem(OverlayMediaControllerEmbeddingItem(
position: previousFrame.center,
itemNode: draggingNode
)) {
self.draggingNode = nil
} else {
let (updatedLocation, shouldDismiss) = self.nodeLocationForPosition(layout: validLayout, position: CGPoint(x: previousFrame.midX, y: previousFrame.midY), velocity: recognizer.velocity(in: self.view), size: nodeSize, tempExtendedTopInset: draggingNode.tempExtendedTopInset)
if shouldDismiss && draggingNode.isMinimizeable {
draggingNode.updateMinimizedEdge(updatedLocation.x.isZero ? .left : .right, adjusting: false)
self.videoNodes[index].isMinimized = true
} else {
draggingNode.updateMinimizedEdge(nil, adjusting: true)
self.videoNodes[index].isMinimized = false
}
if let group = draggingNode.group {
self.locationByGroup[group] = updatedLocation
}
self.videoNodes[index].location = updatedLocation
draggingNode.frame = CGRect(origin: self.nodePosition(layout: validLayout, size: nodeSize, location: updatedLocation, hidden: !draggingNode.hasAttachedContext || shouldDismiss, isMinimized: self.videoNodes[index].isMinimized, tempExtendedTopInset: draggingNode.tempExtendedTopInset), size: nodeSize)
draggingNode.layer.animateFrame(from: previousFrame, to: draggingNode.frame, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak draggingNode] _ in
if draggingNode?.isRemoved == true {
draggingNode?.removeFromSupernode()
}
})
self.draggingNode = nil
if shouldDismiss && !draggingNode.isMinimizeable {
draggingNode.isRemoved = true
draggingNode.dismiss()
}
}
self.updatePossibleEmbeddingItem(nil)
}
default:
break
}
}
@objc func pinchGesture(_ recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
let location = recognizer.location(in: self.view)
loop: for videoNode in self.videoNodes {
if videoNode.node.frame.contains(location) {
if videoNode.node.isMinimizeable {
self.pinchingNode = videoNode.node
self.pinchingNodeInitialSize = videoNode.currentSize
}
break loop
}
}
case .changed:
if let validLayout = self.validLayout, let pinchingNode = self.pinchingNode, let initialSize = self.pinchingNodeInitialSize {
let minSize = CGSize(width: 180.0, height: 90.0)
let maxSize = CGSize(width: validLayout.size.width - validLayout.safeInsets.left - validLayout.safeInsets.right - 14.0, height: 500.0)
let scale = recognizer.scale
var updatedSize = CGSize(width: floor(initialSize.width * scale), height: floor(initialSize.height * scale))
updatedSize = updatedSize.fitted(maxSize)
if updatedSize.width < minSize.width {
updatedSize = updatedSize.aspectFitted(CGSize(width: minSize.width, height: 1000.0))
}
loop: for videoNode in self.videoNodes {
if videoNode.node === pinchingNode {
videoNode.currentSize = updatedSize
break loop
}
}
self.containerLayoutUpdated(validLayout, transition: .immediate)
}
case .ended, .cancelled:
self.pinchingNode = nil
self.pinchingNodeInitialSize = nil
default:
break
}
}
}