import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import UniversalMediaPlayer import LegacyComponents import UIKitRuntimeUtils private struct ContentParticle { var position: CGPoint var direction: CGPoint var velocity: CGFloat var alpha: CGFloat var lifetime: Double var beginTime: Double init(position: CGPoint, direction: CGPoint, velocity: CGFloat, alpha: CGFloat, lifetime: Double, beginTime: Double) { self.position = position self.direction = direction self.velocity = velocity self.alpha = alpha self.lifetime = lifetime self.beginTime = beginTime } } private final class InstantVideoRadialStatusNodeParameters: NSObject { let color: UIColor let progress: CGFloat let dimProgress: CGFloat let playProgress: CGFloat let blinkProgress: CGFloat let hasSeek: Bool let sparks: Bool let particles: [ContentParticle] init(color: UIColor, progress: CGFloat, dimProgress: CGFloat, playProgress: CGFloat, blinkProgress: CGFloat, hasSeek: Bool, sparks: Bool, particles: [ContentParticle]) { self.color = color self.progress = progress self.dimProgress = dimProgress self.playProgress = playProgress self.blinkProgress = blinkProgress self.hasSeek = hasSeek self.sparks = sparks self.particles = particles } } private extension CGFloat { var degrees: CGFloat { return self * CGFloat(180) / .pi } } private extension CGPoint { func angle(to otherPoint: CGPoint) -> CGFloat { let originX = otherPoint.x - x let originY = otherPoint.y - y let bearingRadians = atan2f(Float(originY), Float(originX)) return CGFloat(bearingRadians) } } public final class InstantVideoRadialStatusNode: ASDisplayNode, UIGestureRecognizerDelegate { private let color: UIColor private let hasSeek: Bool private let sparks: Bool private let hapticFeedback = HapticFeedback() private var particles: [ContentParticle] = [] private var effectiveProgress: CGFloat = 0.0 { didSet { self.setNeedsDisplay() } } private var seeking = false private var seekingProgress: CGFloat? private var dimmed = false private var effectiveDimProgress: CGFloat = 0.0 { didSet { self.setNeedsDisplay() } } private var effectivePlayProgress: CGFloat = 0.0 { didSet { self.setNeedsDisplay() } } private var effectiveBlinkProgress: CGFloat = 0.0 { didSet { self.setNeedsDisplay() } } private var _statusValue: MediaPlayerStatus? private var statusValue: MediaPlayerStatus? { get { return self._statusValue } set(value) { if value != self._statusValue { self._statusValue = value self.updateProgress() } } } private var animator: ConstantDisplayLinkAnimator? private var statusDisposable: Disposable? private var statusValuePromise = Promise() public var duration: Double? { if let statusValue = self.statusValue { return statusValue.duration } else { return nil } } public var status: Signal? { didSet { if let status = self.status { self.statusValuePromise.set(status |> map { $0 }) } else { self.statusValuePromise.set(.single(nil)) } } } public var tapGestureRecognizer: UITapGestureRecognizer? public var panGestureRecognizer: UIPanGestureRecognizer? public var seekTo: ((Double, Bool) -> Void)? public init(color: UIColor, hasSeek: Bool, sparks: Bool = false) { self.color = color self.hasSeek = hasSeek self.sparks = sparks super.init() self.isOpaque = false self.statusDisposable = (self.statusValuePromise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] status in if let strongSelf = self { strongSelf.statusValue = status } }) self.view.disablesInteractiveTransitionGestureRecognizer = true if sparks { self.animator = ConstantDisplayLinkAnimator(update: { [weak self] in self?.updateSparks() }) self.animator?.isPaused = false } } deinit { self.statusDisposable?.dispose() } override public func didLoad() { super.didLoad() guard self.hasSeek else { return } let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) tapGestureRecognizer.delegate = self self.view.addGestureRecognizer(tapGestureRecognizer) let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panGestureRecognizer.delegate = self self.view.addGestureRecognizer(panGestureRecognizer) } override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === self.tapGestureRecognizer || gestureRecognizer === self.panGestureRecognizer { let center = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0) let location = gestureRecognizer.location(in: self.view) let distanceFromCenter = sqrt(pow(location.x - center.x, 2.0) + pow(location.y - center.y, 2.0)) if distanceFromCenter < self.bounds.width * 0.2 { return false } return true } else { return true } } private func updateSparks() { // let bounds = self.bounds // let lineWidth: CGFloat = 4.0 // let center = CGPoint(x: bounds.midX, y: bounds.midY) // let radius: CGFloat = (bounds.size.width - lineWidth - 4.0 * 2.0) * 0.5 let endAngle: CGFloat = -CGFloat.pi / 2.0 + 2.0 * CGFloat.pi * self.effectiveProgress let v = CGPoint(x: sin(endAngle), y: -cos(endAngle)) // let c = CGPoint(x: -v.y * radius + center.x, y: v.x * radius + center.y) let timestamp = CACurrentMediaTime() let dt: CGFloat = 1.0 / 60.0 var removeIndices: [Int] = [] for i in 0 ..< self.particles.count { let currentTime = timestamp - self.particles[i].beginTime if currentTime > self.particles[i].lifetime { removeIndices.append(i) } else { let input: CGFloat = CGFloat(currentTime / self.particles[i].lifetime) let decelerated: CGFloat = (1.0 - (1.0 - input) * (1.0 - input)) self.particles[i].alpha = 1.0 - decelerated var p = self.particles[i].position let d = self.particles[i].direction let v = self.particles[i].velocity p = CGPoint(x: p.x + d.x * v * dt, y: p.y + d.y * v * dt) self.particles[i].position = p } } for i in removeIndices.reversed() { self.particles.remove(at: i) } let newParticleCount = 1 for _ in 0 ..< newParticleCount { let degrees: CGFloat = CGFloat(arc4random_uniform(140)) - 70.0 let angle: CGFloat = degrees * CGFloat.pi / 180.0 let direction = CGPoint(x: v.x * cos(angle) - v.y * sin(angle), y: v.x * sin(angle) + v.y * cos(angle)) let velocity = (25.0 + (CGFloat(arc4random()) / CGFloat(UINT32_MAX)) * 4.0) * 0.5 let lifetime = Double(0.25 + CGFloat(arc4random_uniform(100)) * 0.01) let particle = ContentParticle(position: .zero, direction: direction, velocity: velocity, alpha: 0.8, lifetime: lifetime, beginTime: timestamp) self.particles.append(particle) } self.setNeedsDisplay() } @objc private func tapGesture(_ gestureRecognizer: UITapGestureRecognizer) { let center = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0) let location = gestureRecognizer.location(in: self.view) var angle = center.angle(to: location) + CGFloat.pi / 2.0 if angle < 0.0 { angle = CGFloat.pi * 2.0 + angle } let fraction = max(0.0, min(1.0, Double(angle / (2.0 * CGFloat.pi)))) self.seekTo?(min(0.99, fraction), true) } @objc private func panGesture(_ gestureRecognizer: UIPanGestureRecognizer) { let center = CGPoint(x: self.bounds.width / 2.0, y: self.bounds.height / 2.0) let location = gestureRecognizer.location(in: self.view) var angle = center.angle(to: location) + CGFloat.pi / 2.0 if angle < 0.0 { angle = CGFloat.pi * 2.0 + angle } let fraction = max(0.0, min(1.0, Double(angle / (2.0 * CGFloat.pi)))) switch gestureRecognizer.state { case .began: self.seeking = true let playAnimation = POPSpringAnimation() playAnimation.property = POPAnimatableProperty.property(withName: "playProgress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectivePlayProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectivePlayProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty playAnimation.fromValue = self.effectivePlayProgress as NSNumber playAnimation.toValue = 0.0 as NSNumber playAnimation.springSpeed = 20 playAnimation.springBounciness = 8 self.pop_add(playAnimation, forKey: "playProgress") case .changed: if let seekingProgress = self.seekingProgress { if seekingProgress > 0.98 && fraction > 0.0 && fraction < 0.05 { self.hapticFeedback.impact(.light) let blinkAnimation = POPBasicAnimation() blinkAnimation.property = POPAnimatableProperty.property(withName: "blinkProgress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectiveBlinkProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectiveBlinkProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty blinkAnimation.fromValue = 1.0 as NSNumber blinkAnimation.toValue = 0.0 as NSNumber blinkAnimation.duration = 0.5 self.pop_add(blinkAnimation, forKey: "blinkProgress") } else if seekingProgress > 0.0 && seekingProgress < 0.05 && fraction > 0.98 { self.hapticFeedback.impact(.light) let blinkAnimation = POPBasicAnimation() blinkAnimation.property = POPAnimatableProperty.property(withName: "blinkProgress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectiveBlinkProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectiveBlinkProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty blinkAnimation.fromValue = -1.0 as NSNumber blinkAnimation.toValue = 0.0 as NSNumber blinkAnimation.duration = 0.5 self.pop_add(blinkAnimation, forKey: "blinkProgress") } } let newProgress = min(0.99, fraction) if let seekingProgress = self.seekingProgress, abs(seekingProgress - CGFloat(newProgress)) < 0.005 { } else { self.seekTo?(newProgress, false) self.seekingProgress = CGFloat(fraction) } case .ended, .cancelled: self.seeking = false self.seekTo?(min(0.99, fraction), true) self.seekingProgress = nil default: break } } override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { return InstantVideoRadialStatusNodeParameters(color: self.color, progress: self.effectiveProgress, dimProgress: self.effectiveDimProgress, playProgress: self.effectivePlayProgress, blinkProgress: self.effectiveBlinkProgress, hasSeek: self.hasSeek, sparks: self.sparks, particles: self.particles) } @objc public override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) context.fill(bounds) } if let parameters = parameters as? InstantVideoRadialStatusNodeParameters { context.setStrokeColor(parameters.color.cgColor) context.addEllipse(in: bounds) context.clip() if !parameters.dimProgress.isZero { if parameters.playProgress == 1.0 { context.setFillColor(UIColor(rgb: 0x000000, alpha: 0.35 * min(1.0, parameters.dimProgress)).cgColor) context.fillEllipse(in: bounds) } else { var locations: [CGFloat] = [0.0, 0.8, 1.0] let alpha: CGFloat = 0.2 + 0.15 * parameters.playProgress let colors: [CGColor] = [UIColor(rgb: 0x000000, alpha: alpha * min(1.0, parameters.dimProgress * parameters.playProgress)).cgColor, UIColor(rgb: 0x000000, alpha: alpha * min(1.0, parameters.dimProgress * parameters.playProgress)).cgColor, UIColor(rgb: 0x000000, alpha: alpha * min(1.0, parameters.dimProgress)).cgColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! let center = CGPoint(x: bounds.midX, y: bounds.midY) context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: bounds.width / 2.0, options: .drawsAfterEndLocation) } } context.setBlendMode(.normal) var progress = parameters.progress let startAngle: CGFloat let endAngle: CGFloat if parameters.sparks { endAngle = -CGFloat.pi / 2.0 startAngle = CGFloat(progress) * 2.0 * CGFloat.pi + endAngle } else { startAngle = -CGFloat.pi / 2.0 endAngle = CGFloat(progress) * 2.0 * CGFloat.pi + startAngle } progress = min(1.0, progress) var lineWidth: CGFloat = 4.0 if parameters.hasSeek { lineWidth += 1.0 * parameters.dimProgress } var pathDiameter = bounds.size.width - lineWidth - 8.0 if parameters.hasSeek { pathDiameter -= (18.0 * 2.0) * parameters.dimProgress } if !parameters.dimProgress.isZero { context.setLineWidth(lineWidth) if parameters.blinkProgress > 0.0 { context.setStrokeColor(parameters.color.withAlphaComponent(0.2 * parameters.blinkProgress).cgColor) context.strokeEllipse(in: CGRect(x: (bounds.size.width - pathDiameter) / 2.0 , y: (bounds.size.height - pathDiameter) / 2.0, width: pathDiameter, height: pathDiameter)) } if parameters.hasSeek { var progress = parameters.dimProgress if parameters.blinkProgress < 0.0 { progress = parameters.dimProgress + parameters.blinkProgress } context.setStrokeColor(parameters.color.withAlphaComponent(0.2 * progress).cgColor) context.strokeEllipse(in: CGRect(x: (bounds.size.width - pathDiameter) / 2.0 , y: (bounds.size.height - pathDiameter) / 2.0, width: pathDiameter, height: pathDiameter)) } if !parameters.playProgress.isZero { context.saveGState() context.translateBy(x: bounds.width / 2.0, y: bounds.height / 2.0) if parameters.hasSeek { context.scaleBy(x: 1.0 + 1.4 * parameters.playProgress, y: 1.0 + 1.4 * parameters.playProgress) } else { context.scaleBy(x: 1.0 + 0.7 * parameters.playProgress, y: 1.0 + 0.7 * parameters.playProgress) } context.translateBy(x: -bounds.width / 2.0, y: -bounds.height / 2.0) let iconSize = CGSize(width: 15.0, height: 18.0) context.translateBy(x: (bounds.width - iconSize.width) / 2.0 + 2.0, y: (bounds.height - iconSize.height) / 2.0) context.setFillColor(UIColor(rgb: 0xffffff).withAlphaComponent(min(1.0, parameters.playProgress)).cgColor) let _ = try? drawSvgPath(context, path: "M1.71891969,0.209353049 C0.769586558,-0.350676705 0,0.0908839327 0,1.18800046 L0,16.8564753 C0,17.9569971 0.750549162,18.357187 1.67393713,17.7519379 L14.1073836,9.60224049 C15.0318735,8.99626906 15.0094718,8.04970371 14.062401,7.49100858 L1.71891969,0.209353049 ") context.fillPath() context.restoreGState() } } context.setStrokeColor(parameters.color.cgColor) let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: pathDiameter / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise:true) path.lineWidth = lineWidth path.lineCapStyle = .round path.stroke() if parameters.hasSeek { let handleSide = 16.0 * min(1.0, (parameters.dimProgress * 2.0)) let handleSize = CGSize(width: handleSide, height: handleSide) let handlePosition = CGPoint(x: 0.5 * pathDiameter * cos(endAngle), y: 0.5 * pathDiameter * sin(endAngle)).offsetBy(dx: bounds.size.width / 2.0, dy: bounds.size.height / 2.0) let handleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(handlePosition.x - handleSize.width / 2.0), y: floorToScreenPixels(handlePosition.y - handleSize.height / 2.0)), size: handleSize) context.setFillColor(UIColor.white.cgColor) context.fillEllipse(in: handleFrame) } let v = CGPoint(x: sin(startAngle), y: -cos(startAngle)) let c = CGPoint(x: -v.y * pathDiameter * 0.5 + bounds.midX, y: v.x * pathDiameter * 0.5 + bounds.midY) context.setFillColor(parameters.color.cgColor) for particle in parameters.particles { let size: CGFloat = 1.3 context.setAlpha(particle.alpha) context.fillEllipse(in: CGRect(origin: CGPoint(x: c.x + particle.position.x - size / 2.0, y: c.y + particle.position.y - size / 2.0), size: CGSize(width: size, height: size))) } } } private func updateProgress() { let timestampAndDuration: (timestamp: Double, duration: Double, baseRate: Double)? if let statusValue = self.statusValue, Double(0.0).isLess(than: statusValue.duration) { timestampAndDuration = (statusValue.timestamp, statusValue.duration, statusValue.baseRate) } else { timestampAndDuration = nil } var dimmed = false if let statusValue = self.statusValue { dimmed = statusValue.status == .paused } if self.seeking { dimmed = true } if dimmed != self.dimmed { self.dimmed = dimmed let animation = POPSpringAnimation() animation.property = POPAnimatableProperty.property(withName: "dimProgress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectiveDimProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectiveDimProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty animation.fromValue = self.effectiveDimProgress as NSNumber animation.toValue = (dimmed ? 1.0 : 0.0) as NSNumber animation.springSpeed = 20 animation.springBounciness = 8 self.pop_add(animation, forKey: "dimProgress") let playAnimation = POPSpringAnimation() playAnimation.property = POPAnimatableProperty.property(withName: "playProgress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectivePlayProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectivePlayProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty playAnimation.fromValue = self.effectivePlayProgress as NSNumber playAnimation.toValue = (dimmed ? 1.0 : 0.0) as NSNumber playAnimation.springSpeed = 20 playAnimation.springBounciness = 8 self.pop_add(playAnimation, forKey: "playProgress") } if self.seeking, let progress = self.seekingProgress { self.pop_removeAnimation(forKey: "progress") self.effectiveProgress = progress } else if let (timestamp, duration, baseRate) = timestampAndDuration, let statusValue = self.statusValue { let progress = CGFloat(timestamp / duration) if progress.isNaN || !progress.isFinite { self.pop_removeAnimation(forKey: "progress") self.effectiveProgress = 0.0 } else if statusValue.status != .playing || statusValue.generationTimestamp.isZero { self.pop_removeAnimation(forKey: "progress") self.effectiveProgress = progress } else { self.pop_removeAnimation(forKey: "progress") let animation = POPBasicAnimation() animation.property = POPAnimatableProperty.property(withName: "progress", initializer: { property in property?.readBlock = { node, values in values?.pointee = (node as! InstantVideoRadialStatusNode).effectiveProgress } property?.writeBlock = { node, values in (node as! InstantVideoRadialStatusNode).effectiveProgress = values!.pointee } property?.threshold = 0.01 }) as? POPAnimatableProperty animation.fromValue = progress as NSNumber animation.toValue = 1.0 as NSNumber animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) animation.duration = max(0.0, duration - timestamp) / baseRate animation.beginTime = statusValue.generationTimestamp self.pop_add(animation, forKey: "progress") } } else { self.pop_removeAnimation(forKey: "dimProgress") self.effectiveDimProgress = 0.0 self.pop_removeAnimation(forKey: "progress") self.effectiveProgress = 0.0 } } }