Signal-iOS/SignalMessaging/Views/LoopingVideoView.swift
2021-04-29 16:58:44 -07:00

237 lines
7.8 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import YYImage
import AVKit
import PromiseKit
/// Model object for a looping video asset
/// Any LoopingVideoViews playing this instance will all be kept in sync
@objc
public class LoopingVideo: NSObject {
fileprivate let playerItemPromise: Guarantee<AVPlayerItem?>
fileprivate var playerItem: AVPlayerItem? { playerItemPromise.value.flatMap { $0 } }
fileprivate var asset: AVAsset? { playerItem?.asset }
@objc
public init?(url: URL) {
guard OWSMediaUtils.isVideoOfValidContentTypeAndSize(path: url.path) else {
return nil
}
playerItemPromise = firstly(on: .global(qos: .userInitiated)) {
let asset = AVAsset(url: url)
let item = AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["tracks"])
return OWSMediaUtils.isValidVideo(asset: asset) ? item : nil
}
super.init()
}
deinit {
playerItem?.cancelPendingSeeks()
asset?.cancelLoading()
}
}
// TODO: Multicast for syncing up two views?
private class LoopingVideoPlayer: AVPlayer {
override init() {
super.init()
sharedInit()
}
override init(url: URL) {
super.init(url: url)
sharedInit()
}
override init(playerItem item: AVPlayerItem?) {
super.init(playerItem: item)
sharedInit()
}
private func sharedInit() {
if let item = currentItem {
NotificationCenter.default.addObserver(
self,
selector: #selector(self.playerItemDidPlayToCompletion(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: item)
}
isMuted = true
allowsExternalPlayback = true
if #available(iOS 12, *) {
preventsDisplaySleepDuringVideoPlayback = false
}
}
override func replaceCurrentItem(with newItem: AVPlayerItem?) {
readyStatusObserver = nil
if let oldItem = currentItem {
NotificationCenter.default.removeObserver(
self,
name: .AVPlayerItemDidPlayToEndTime,
object: oldItem)
oldItem.cancelPendingSeeks()
}
super.replaceCurrentItem(with: newItem)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.playerItemDidPlayToCompletion(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: newItem)
}
@objc private func playerItemDidPlayToCompletion(_ notification: NSNotification) {
guard (notification.object as AnyObject) === currentItem else { return }
seek(to: .zero)
play()
}
private var readyStatusObserver: NSKeyValueObservation?
override public func play() {
// Don't bother if we're already playing, or we don't have an item
guard let item = currentItem, rate == 0 else { return }
if item.status == .readyToPlay {
readyStatusObserver = nil
super.play()
} else if readyStatusObserver == nil {
// We're not ready to play, set up an observer to play when ready
readyStatusObserver = item.observe(\.status) { [weak self] _, _ in
guard let self = self, item === self.currentItem else { return }
if item.status == .readyToPlay {
self.play()
}
}
}
}
}
@objc
public class LoopingVideoView: UIView {
private let player = LoopingVideoPlayer()
@objc
public var video: LoopingVideo? {
didSet {
guard video !== oldValue else { return }
player.replaceCurrentItem(with: nil)
if let itemPromise = video?.playerItemPromise {
itemPromise.done(on: .global(qos: .userInitiated)) { item in
guard item === self.video?.playerItem else { return }
if let item = item {
self.player.replaceCurrentItem(with: item)
self.player.play()
}
}
}
displayReadyObserver = nil
invalidateIntrinsicContentSize()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
playerLayer.player = player
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public static var layerClass: AnyClass { AVPlayerLayer.self }
private var playerLayer: AVPlayerLayer {
layer as? AVPlayerLayer ?? {
owsFailDebug("Unexpected player type")
return AVPlayerLayer()
}()
}
override public var contentMode: UIView.ContentMode {
didSet {
switch contentMode {
case .scaleAspectFill: playerLayer.videoGravity = .resizeAspectFill
case .scaleToFill: playerLayer.videoGravity = .resize
case .scaleAspectFit: playerLayer.videoGravity = .resizeAspect
default: playerLayer.videoGravity = .resizeAspect
}
}
}
override public var intrinsicContentSize: CGSize {
guard let asset = video?.asset else {
// If we have an outstanding promise, invalidate the size once it's complete
// If there isn't, -noIntrinsicMetric is valid
if video?.playerItemPromise.isPending == true {
video?.playerItemPromise.done { _ in self.invalidateIntrinsicContentSize() }
}
return CGSize(square: UIView.noIntrinsicMetric)
}
// Tracks will always be loaded by LoopingVideo
return asset.tracks(withMediaType: .video)
.map { $0.naturalSize }
.reduce(.zero) {
CGSize(width: max($0.width, $1.width),
height: max($0.height, $1.height))
}
}
// MARK: - Placeholder Images
/// AVKit may not have the video ready in time for display. If a closure is provided here, LoopingAnimationView will invoke the closure to
/// fetch a placeholder image to present in the meantime while the video is prepared.
/// This image will be removed once the video is ready to play.
public var placeholderProvider: (() -> UIImage?)?
private var placeholderView: UIImageView?
private var displayReadyObserver: NSKeyValueObservation?
override public func draw(_ rect: CGRect) {
defer { super.draw(rect) }
guard video != nil else { return }
let isDisplayingPlaceholder = (placeholderView != nil)
// If we aren't ready for display, add an imageView to present the placeholder. Start listening
// for any changes so we can clean this up when the video layer is ready.
if !playerLayer.isReadyForDisplay,
!isDisplayingPlaceholder,
let placeholderProvider = placeholderProvider {
// First, set up our observer so we are notified once we can drop the placeholder
displayReadyObserver = playerLayer.observe(
\.isReadyForDisplay,
options: .new
) { [weak self] (_, change) in
if change.newValue == true {
self?.setNeedsDisplay()
}
}
// Then, add the placeholder image
let imageView = UIImageView()
imageView.contentMode = contentMode
imageView.image = placeholderProvider()
addSubview(imageView)
imageView.autoPinEdgesToSuperviewEdges()
placeholderView = imageView
} else if playerLayer.isReadyForDisplay {
// Cleanup. The video is ready to go.
displayReadyObserver = nil
placeholderView?.removeFromSuperview()
placeholderView = nil
}
}
}