import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext private let handleWidth: CGFloat = 14.0 private let scrubberHeight: CGFloat = 39.0 private let borderHeight: CGFloat = 1.0 + UIScreenPixel private let frameWidth: CGFloat = 24.0 private let minumumDuration: CGFloat = 1.0 private class VideoFrameLayer: SimpleShapeLayer { private let stripeLayer = SimpleShapeLayer() override func layoutSublayers() { super.layoutSublayers() if self.stripeLayer.superlayer == nil { self.stripeLayer.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.3).cgColor self.addSublayer(self.stripeLayer) } self.stripeLayer.frame = CGRect(x: self.bounds.width - UIScreenPixel, y: 0.0, width: UIScreenPixel, height: self.bounds.height) } } private final class HandleView: UIImageView { var hitTestSlop = UIEdgeInsets() override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { return self.bounds.inset(by: self.hitTestSlop).contains(point) } } final class VideoScrubberComponent: Component { typealias EnvironmentType = Empty let context: AccountContext let generationTimestamp: Double let duration: Double let startPosition: Double let endPosition: Double let position: Double let maxDuration: Double let isPlaying: Bool let frames: [UIImage] let framesUpdateTimestamp: Double let trimUpdated: (Double, Double, Bool, Bool) -> Void let positionUpdated: (Double, Bool) -> Void init( context: AccountContext, generationTimestamp: Double, duration: Double, startPosition: Double, endPosition: Double, position: Double, maxDuration: Double, isPlaying: Bool, frames: [UIImage], framesUpdateTimestamp: Double, trimUpdated: @escaping (Double, Double, Bool, Bool) -> Void, positionUpdated: @escaping (Double, Bool) -> Void ) { self.context = context self.generationTimestamp = generationTimestamp self.duration = duration self.startPosition = startPosition self.endPosition = endPosition self.position = position self.maxDuration = maxDuration self.isPlaying = isPlaying self.frames = frames self.framesUpdateTimestamp = framesUpdateTimestamp self.trimUpdated = trimUpdated self.positionUpdated = positionUpdated } static func ==(lhs: VideoScrubberComponent, rhs: VideoScrubberComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.generationTimestamp != rhs.generationTimestamp { return false } if lhs.duration != rhs.duration { return false } if lhs.startPosition != rhs.startPosition { return false } if lhs.endPosition != rhs.endPosition { return false } if lhs.position != rhs.position { return false } if lhs.maxDuration != rhs.maxDuration { return false } if lhs.isPlaying != rhs.isPlaying { return false } if lhs.framesUpdateTimestamp != rhs.framesUpdateTimestamp { return false } return true } final class View: UIView, UITextFieldDelegate { private let leftHandleView = HandleView() private let rightHandleView = HandleView() private let borderView = UIImageView() private let cursorView = HandleView() private let transparentFramesContainer = UIView() private let opaqueFramesContainer = UIView() private var transparentFrameLayers: [VideoFrameLayer] = [] private var opaqueFrameLayers: [VideoFrameLayer] = [] private var component: VideoScrubberComponent? private weak var state: EmptyComponentState? private var scrubberSize: CGSize? private var isPanningTrimHandle = false private var isPanningPositionHandle = false private var displayLink: SharedDisplayLinkDriver.Link? private var positionAnimation: (start: Double, from: Double, to: Double)? override init(frame: CGRect) { super.init(frame: frame) self.disablesInteractiveModalDismiss = true self.disablesInteractiveKeyboardGestureRecognizer = true let handleImage = generateImage(CGSize(width: handleWidth, height: scrubberHeight), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: CGSize(width: size.width * 2.0, height: size.height)), cornerRadius: 9.0) context.addPath(path.cgPath) context.fillPath() context.setBlendMode(.clear) let innerPath = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: handleWidth - 3.0, y: borderHeight), size: CGSize(width: handleWidth, height: size.height - borderHeight * 2.0)), cornerRadius: 2.0) context.addPath(innerPath.cgPath) context.fillPath() context.setBlendMode(.clear) let holeSize = CGSize(width: 2.0, height: 11.0) let holePath = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: 5.0 - UIScreenPixel, y: (size.height - holeSize.height) / 2.0), size: holeSize), cornerRadius: holeSize.width / 2.0) context.addPath(holePath.cgPath) context.fillPath() })?.withRenderingMode(.alwaysTemplate) let positionImage = generateImage(CGSize(width: handleWidth, height: 50.0), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) context.setShadow(offset: .zero, blur: 2.0, color: UIColor(rgb: 0x000000, alpha: 0.55).cgColor) let path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: 6.0, y: 4.0), size: CGSize(width: 2.0, height: 42.0)), cornerRadius: 1.0) context.addPath(path.cgPath) context.fillPath() }) self.leftHandleView.image = handleImage self.leftHandleView.isUserInteractionEnabled = true self.leftHandleView.tintColor = .white self.leftHandleView.hitTestSlop = UIEdgeInsets(top: -8.0, left: -9.0, bottom: -8.0, right: -9.0) self.rightHandleView.image = handleImage self.rightHandleView.transform = CGAffineTransform(scaleX: -1.0, y: 1.0) self.rightHandleView.isUserInteractionEnabled = true self.rightHandleView.tintColor = .white self.rightHandleView.hitTestSlop = UIEdgeInsets(top: -8.0, left: -9.0, bottom: -8.0, right: -9.0) self.cursorView.image = positionImage self.cursorView.isUserInteractionEnabled = true self.cursorView.hitTestSlop = UIEdgeInsets(top: -8.0, left: -9.0, bottom: -8.0, right: -9.0) self.borderView.image = generateImage(CGSize(width: 1.0, height: scrubberHeight), rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) context.fill(CGRect(origin: .zero, size: CGSize(width: size.width, height: borderHeight))) context.fill(CGRect(origin: CGPoint(x: 0.0, y: size.height - borderHeight), size: CGSize(width: size.width, height: scrubberHeight))) })?.withRenderingMode(.alwaysTemplate) self.borderView.tintColor = .white self.transparentFramesContainer.alpha = 0.5 self.transparentFramesContainer.clipsToBounds = true self.transparentFramesContainer.layer.cornerRadius = 9.0 self.opaqueFramesContainer.clipsToBounds = true self.opaqueFramesContainer.layer.cornerRadius = 9.0 self.addSubview(self.transparentFramesContainer) self.addSubview(self.opaqueFramesContainer) self.addSubview(self.leftHandleView) self.addSubview(self.rightHandleView) self.addSubview(self.borderView) self.addSubview(self.cursorView) self.leftHandleView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.handleLeftHandlePan(_:)))) self.rightHandleView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.handleRightHandlePan(_:)))) self.cursorView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.handlePositionHandlePan(_:)))) self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] in self?.updateCursorPosition() } self.displayLink?.isPaused = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.displayLink?.invalidate() } @objc private func handleLeftHandlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let component = self.component else { return } let location = gestureRecognizer.location(in: self) let start = handleWidth / 2.0 let end = self.frame.width - handleWidth / 2.0 let length = end - start let fraction = (location.x - start) / length var startValue = max(0.0, component.duration * fraction) if startValue > component.endPosition - minumumDuration { startValue = max(0.0, component.endPosition - minumumDuration) } var endValue = component.endPosition if endValue - startValue > component.maxDuration { let delta = (endValue - startValue) - component.maxDuration endValue -= delta } var transition: Transition = .immediate switch gestureRecognizer.state { case .began, .changed: self.isPanningTrimHandle = true component.trimUpdated(startValue, endValue, false, false) if case .began = gestureRecognizer.state { transition = .easeInOut(duration: 0.25) } case .ended, .cancelled: self.isPanningTrimHandle = false component.trimUpdated(startValue, endValue, false, true) transition = .easeInOut(duration: 0.25) default: break } self.state?.updated(transition: transition) } @objc private func handleRightHandlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let component = self.component else { return } let location = gestureRecognizer.location(in: self) let start = handleWidth / 2.0 let end = self.frame.width - handleWidth / 2.0 let length = end - start let fraction = (location.x - start) / length var endValue = min(component.duration, component.duration * fraction) if endValue < component.startPosition + minumumDuration { endValue = min(component.duration, component.startPosition + minumumDuration) } var startValue = component.startPosition if endValue - startValue > component.maxDuration { let delta = (endValue - startValue) - component.maxDuration startValue += delta } var transition: Transition = .immediate switch gestureRecognizer.state { case .began, .changed: self.isPanningTrimHandle = true component.trimUpdated(startValue, endValue, true, false) if case .began = gestureRecognizer.state { transition = .easeInOut(duration: 0.25) } case .ended, .cancelled: self.isPanningTrimHandle = false component.trimUpdated(startValue, endValue, true, true) transition = .easeInOut(duration: 0.25) default: break } self.state?.updated(transition: transition) } @objc private func handlePositionHandlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let component = self.component else { return } let location = gestureRecognizer.location(in: self) let start = handleWidth let end = self.frame.width - handleWidth let length = end - start let fraction = (location.x - start) / length let position = max(component.startPosition, min(component.endPosition, component.duration * fraction)) let transition: Transition = .immediate switch gestureRecognizer.state { case .began, .changed: self.isPanningPositionHandle = true component.positionUpdated(position, false) case .ended, .cancelled: self.isPanningPositionHandle = false component.positionUpdated(position, true) default: break } self.state?.updated(transition: transition) } private func cursorFrame(size: CGSize, position: Double, duration : Double) -> CGRect { let cursorPadding: CGFloat = 8.0 let cursorPositionFraction = duration > 0.0 ? position / duration : 0.0 let cursorPosition = floorToScreenPixels(handleWidth + handleWidth / 2.0 - cursorPadding + (size.width - handleWidth * 3.0 + cursorPadding * 2.0) * cursorPositionFraction) var cursorFrame = CGRect(origin: CGPoint(x: cursorPosition - handleWidth / 2.0, y: -5.0 - UIScreenPixel), size: CGSize(width: handleWidth, height: 50.0)) cursorFrame.origin.x = max(self.leftHandleView.frame.maxX - cursorPadding, cursorFrame.origin.x) cursorFrame.origin.x = min(self.rightHandleView.frame.minX + cursorPadding, cursorFrame.origin.x) return cursorFrame } private func updateCursorPosition() { guard let component = self.component, let scrubberSize = self.scrubberSize else { return } let timestamp = CACurrentMediaTime() let updatedPosition: Double if let (start, from, to) = self.positionAnimation { let duration = to - from let fraction = duration > 0.0 ? (timestamp - start) / duration : 0.0 updatedPosition = max(component.startPosition, min(component.endPosition, from + (to - from) * fraction)) if fraction >= 1.0 { self.positionAnimation = (timestamp, component.startPosition, component.endPosition) } } else { let advance = component.isPlaying ? timestamp - component.generationTimestamp : 0.0 updatedPosition = max(component.startPosition, min(component.endPosition, component.position + advance)) } self.cursorView.frame = cursorFrame(size: scrubberSize, position: updatedPosition, duration: component.duration) } func update(component: VideoScrubberComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let previousFramesUpdateTimestamp = self.component?.framesUpdateTimestamp self.component = component self.state = state let scrubberSize = CGSize(width: availableSize.width, height: scrubberHeight) self.scrubberSize = scrubberSize let bounds = CGRect(origin: .zero, size: scrubberSize) if component.framesUpdateTimestamp != previousFramesUpdateTimestamp { for i in 0 ..< component.frames.count { let transparentFrameLayer: VideoFrameLayer let opaqueFrameLayer: VideoFrameLayer if i >= self.transparentFrameLayers.count { transparentFrameLayer = VideoFrameLayer() transparentFrameLayer.masksToBounds = true transparentFrameLayer.contentsGravity = .resizeAspectFill self.transparentFramesContainer.layer.addSublayer(transparentFrameLayer) self.transparentFrameLayers.append(transparentFrameLayer) opaqueFrameLayer = VideoFrameLayer() opaqueFrameLayer.masksToBounds = true opaqueFrameLayer.contentsGravity = .resizeAspectFill self.opaqueFramesContainer.layer.addSublayer(opaqueFrameLayer) self.opaqueFrameLayers.append(opaqueFrameLayer) } else { transparentFrameLayer = self.transparentFrameLayers[i] opaqueFrameLayer = self.opaqueFrameLayers[i] } transparentFrameLayer.contents = component.frames[i].cgImage if let contents = opaqueFrameLayer.contents, (contents as! CGImage) !== component.frames[i].cgImage, opaqueFrameLayer.animation(forKey: "contents") == nil { opaqueFrameLayer.contents = component.frames[i].cgImage opaqueFrameLayer.animate(from: contents as AnyObject, to: component.frames[i].cgImage! as AnyObject, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.2) } else { opaqueFrameLayer.contents = component.frames[i].cgImage } } } let trimColor = self.isPanningTrimHandle ? UIColor(rgb: 0xf8d74a) : .white transition.setTintColor(view: self.leftHandleView, color: trimColor) transition.setTintColor(view: self.rightHandleView, color: trimColor) transition.setTintColor(view: self.borderView, color: trimColor) let totalWidth = scrubberSize.width - handleWidth let leftHandlePositionFraction = component.duration > 0.0 ? component.startPosition / component.duration : 0.0 let leftHandlePosition = floorToScreenPixels(handleWidth / 2.0 + totalWidth * leftHandlePositionFraction) let leftHandleFrame = CGRect(origin: CGPoint(x: leftHandlePosition - handleWidth / 2.0, y: 0.0), size: CGSize(width: handleWidth, height: scrubberSize.height)) transition.setFrame(view: self.leftHandleView, frame: leftHandleFrame) let rightHandlePositionFraction = component.duration > 0.0 ? component.endPosition / component.duration : 1.0 let rightHandlePosition = floorToScreenPixels(handleWidth / 2.0 + totalWidth * rightHandlePositionFraction) let rightHandleFrame = CGRect(origin: CGPoint(x: max(leftHandleFrame.maxX, rightHandlePosition - handleWidth / 2.0), y: 0.0), size: CGSize(width: handleWidth, height: scrubberSize.height)) transition.setFrame(view: self.rightHandleView, frame: rightHandleFrame) if self.isPanningPositionHandle || !component.isPlaying { self.positionAnimation = nil self.displayLink?.isPaused = true transition.setFrame(view: self.cursorView, frame: cursorFrame(size: scrubberSize, position: component.position, duration: component.duration)) } else { if self.positionAnimation == nil { self.positionAnimation = (CACurrentMediaTime(), component.position, component.endPosition) } self.displayLink?.isPaused = false self.updateCursorPosition() } transition.setAlpha(view: self.cursorView, alpha: self.isPanningTrimHandle ? 0.0 : 1.0) let borderFrame = CGRect(origin: CGPoint(x: leftHandleFrame.maxX, y: 0.0), size: CGSize(width: rightHandleFrame.minX - leftHandleFrame.maxX, height: scrubberSize.height)) transition.setFrame(view: self.borderView, frame: borderFrame) let handleInset: CGFloat = 7.0 transition.setFrame(view: self.transparentFramesContainer, frame: bounds) transition.setFrame(view: self.opaqueFramesContainer, frame: CGRect(origin: CGPoint(x: leftHandleFrame.maxX - handleInset, y: 0.0), size: CGSize(width: rightHandleFrame.minX - leftHandleFrame.maxX + handleInset * 2.0, height: bounds.height))) transition.setBounds(view: self.opaqueFramesContainer, bounds: CGRect(origin: CGPoint(x: leftHandleFrame.maxX - handleInset, y: 0.0), size: CGSize(width: rightHandleFrame.minX - leftHandleFrame.maxX + handleInset * 2.0, height: bounds.height))) var frameAspectRatio = 0.66 if let image = component.frames.first, image.size.height > 0.0 { frameAspectRatio = max(0.66, image.size.width / image.size.height) } let frameSize = CGSize(width: 39.0 * frameAspectRatio, height: 39.0) var frameOffset: CGFloat = 0.0 for i in 0 ..< component.frames.count { if i < self.transparentFrameLayers.count { let transparentFrameLayer = self.transparentFrameLayers[i] let opaqueFrameLayer = self.opaqueFrameLayers[i] let frame = CGRect(origin: CGPoint(x: frameOffset, y: 0.0), size: frameSize) transparentFrameLayer.frame = frame opaqueFrameLayer.frame = frame } frameOffset += frameSize.width } return scrubberSize } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let hitTestSlop = UIEdgeInsets(top: -8.0, left: -9.0, bottom: -8.0, right: -9.0) return self.bounds.inset(by: hitTestSlop).contains(point) } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }