486 lines
15 KiB
Swift
486 lines
15 KiB
Swift
//
|
|
// RegularPlayer.swift
|
|
// Pods
|
|
//
|
|
// Created by King, Gavin on 3/7/17.
|
|
//
|
|
//
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
import AVKit
|
|
|
|
extension AVMediaSelectionOption: TextTrackMetadata {
|
|
public var isSDHTrack: Bool {
|
|
return self.hasMediaCharacteristic(.describesMusicAndSoundForAccessibility) && self.hasMediaCharacteristic(.transcribesSpokenDialogForAccessibility)
|
|
}
|
|
}
|
|
|
|
/// A RegularPlayer is used to play regular videos.
|
|
@objc open class RegularPlayer: NSObject, Player, ProvidesView {
|
|
public struct Constants {
|
|
public static let TimeUpdateInterval: TimeInterval = 0.1
|
|
}
|
|
|
|
// MARK: - Private Properties
|
|
|
|
fileprivate var player = AVPlayer()
|
|
|
|
private var regularPlayerView: RegularPlayerView
|
|
|
|
private var playerLayer: AVPlayerLayer {
|
|
return self.regularPlayerView.playerLayer
|
|
}
|
|
|
|
private var seekTolerance: CMTime?
|
|
|
|
private var seekTarget: CMTime = CMTime.invalid
|
|
private var isSeekInProgress: Bool = false
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Sets an AVAsset on the player.
|
|
///
|
|
/// - Parameter asset: The AVAsset
|
|
@objc open func set(_ asset: AVAsset) {
|
|
let playerItem = AVPlayerItem(asset: asset)
|
|
self.set(playerItem: playerItem)
|
|
}
|
|
|
|
@objc open func set(playerItem: AVPlayerItem) {
|
|
// Prepare the old item for removal
|
|
if let currentItem = self.player.currentItem {
|
|
self.removePlayerItemObservers(fromPlayerItem: currentItem)
|
|
}
|
|
|
|
// Replace it with the new item
|
|
self.addPlayerItemObservers(toPlayerItem: playerItem)
|
|
self.player.replaceCurrentItem(with: playerItem)
|
|
}
|
|
|
|
// MARK: - ProvidesView
|
|
|
|
private class RegularPlayerView: PlayerView {
|
|
var playerLayer: AVPlayerLayer {
|
|
return self.layer as! AVPlayerLayer
|
|
}
|
|
|
|
#if canImport(UIKit)
|
|
override class var layerClass: AnyClass {
|
|
return AVPlayerLayer.self
|
|
}
|
|
#elseif canImport(AppKit)
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
self.layer = AVPlayerLayer()
|
|
}
|
|
|
|
required init?(coder decoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
#endif
|
|
|
|
func configureForPlayer(player: AVPlayer) {
|
|
(self.layer as! AVPlayerLayer).player = player
|
|
}
|
|
}
|
|
|
|
open var view: UIView {
|
|
return self.regularPlayerView
|
|
}
|
|
|
|
// MARK: - Player
|
|
|
|
weak public var delegate: PlayerDelegate?
|
|
|
|
public private(set) var state: PlayerState = .ready {
|
|
didSet {
|
|
self.delegate?.playerDidUpdateState(player: self, previousState: oldValue)
|
|
}
|
|
}
|
|
|
|
public var duration: TimeInterval {
|
|
return self.player.currentItem?.duration.timeInterval ?? 0
|
|
}
|
|
|
|
public private(set) var time: TimeInterval = 0 {
|
|
didSet {
|
|
self.delegate?.playerDidUpdateTime(player: self)
|
|
}
|
|
}
|
|
|
|
public private(set) var bufferedTime: TimeInterval = 0 {
|
|
didSet {
|
|
self.delegate?.playerDidUpdateBufferedTime(player: self)
|
|
}
|
|
}
|
|
|
|
public var playing: Bool {
|
|
return self.player.rate > 0
|
|
}
|
|
|
|
public var ended: Bool {
|
|
return self.time >= self.duration
|
|
}
|
|
|
|
public var error: NSError? {
|
|
return self.player.errorForPlayerOrItem
|
|
}
|
|
|
|
open func seek(to time: TimeInterval) {
|
|
let cmTime = CMTimeMakeWithSeconds(time, preferredTimescale: Int32(NSEC_PER_SEC))
|
|
self.smoothSeek(to: cmTime)
|
|
}
|
|
|
|
open func play() {
|
|
self.player.play()
|
|
}
|
|
|
|
open func pause() {
|
|
self.player.pause()
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
override public convenience init() {
|
|
self.init(seekTolerance: nil)
|
|
}
|
|
|
|
public init(seekTolerance: TimeInterval?) {
|
|
self.regularPlayerView = RegularPlayerView(frame: .zero)
|
|
self.seekTolerance = seekTolerance.map {
|
|
CMTimeMakeWithSeconds($0, preferredTimescale: Int32(NSEC_PER_SEC))
|
|
}
|
|
|
|
super.init()
|
|
|
|
self.addPlayerObservers()
|
|
self.regularPlayerView.configureForPlayer(player: self.player)
|
|
self.setupAirplay()
|
|
}
|
|
|
|
deinit {
|
|
if let playerItem = self.player.currentItem {
|
|
self.removePlayerItemObservers(fromPlayerItem: playerItem)
|
|
}
|
|
|
|
self.removePlayerObservers()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
@available(iOS 10.0, tvOS 10.0, macOS 10.12, *)
|
|
public var automaticallyWaitsToMinimizeStalling: Bool {
|
|
get {
|
|
return self.player.automaticallyWaitsToMinimizeStalling
|
|
}
|
|
set {
|
|
self.player.automaticallyWaitsToMinimizeStalling = newValue
|
|
}
|
|
}
|
|
|
|
private func setupAirplay() {
|
|
#if os(iOS) || os(tvOS)
|
|
self.player.usesExternalPlaybackWhileExternalScreenIsActive = true
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Smooth Seeking
|
|
|
|
// Note: Smooth seeking follows the guide from Apple Technical Q&A: https://developer.apple.com/library/archive/qa/qa1820/_index.html
|
|
// Update the seek target and begin seeking if there is no seek currently in progress.
|
|
private func smoothSeek(to cmTime: CMTime) {
|
|
self.seekTarget = cmTime
|
|
|
|
guard self.isSeekInProgress == false else { return }
|
|
self.seekToTarget()
|
|
}
|
|
|
|
// Unconditionally seek to the current seek target.
|
|
private func seekToTarget() {
|
|
self.isSeekInProgress = true
|
|
|
|
guard self.player.status != .unknown else { return }
|
|
|
|
assert(CMTIME_IS_VALID(self.seekTarget))
|
|
let inProgressSeekTarget = self.seekTarget
|
|
|
|
let completion: (Bool) -> Void = { [weak self] _ in
|
|
guard let self = self else { return }
|
|
|
|
self.time = CMTimeGetSeconds(inProgressSeekTarget)
|
|
if CMTimeCompare(inProgressSeekTarget, self.seekTarget) == 0 {
|
|
self.isSeekInProgress = false
|
|
} else {
|
|
self.seekToTarget()
|
|
}
|
|
}
|
|
|
|
if let tolerance = self.seekTolerance {
|
|
self.player.seek(
|
|
to: inProgressSeekTarget,
|
|
toleranceBefore: tolerance,
|
|
toleranceAfter: tolerance,
|
|
completionHandler: completion
|
|
)
|
|
} else {
|
|
self.player.seek(to: inProgressSeekTarget, completionHandler: completion)
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Observers
|
|
|
|
private struct KeyPath {
|
|
struct Player {
|
|
static let Rate = "rate"
|
|
}
|
|
|
|
struct PlayerItem {
|
|
static let Status = "status"
|
|
static let PlaybackLikelyToKeepUp = "playbackLikelyToKeepUp"
|
|
static let LoadedTimeRanges = "loadedTimeRanges"
|
|
}
|
|
}
|
|
|
|
private var playerTimeObserver: Any?
|
|
|
|
private func addPlayerItemObservers(toPlayerItem playerItem: AVPlayerItem) {
|
|
playerItem.addObserver(self, forKeyPath: KeyPath.PlayerItem.Status, options: [.initial, .new], context: nil)
|
|
playerItem.addObserver(self, forKeyPath: KeyPath.PlayerItem.PlaybackLikelyToKeepUp, options: [.initial, .new], context: nil)
|
|
playerItem.addObserver(self, forKeyPath: KeyPath.PlayerItem.LoadedTimeRanges, options: [.initial, .new], context: nil)
|
|
}
|
|
|
|
private func removePlayerItemObservers(fromPlayerItem playerItem: AVPlayerItem) {
|
|
playerItem.removeObserver(self, forKeyPath: KeyPath.PlayerItem.Status, context: nil)
|
|
playerItem.removeObserver(self, forKeyPath: KeyPath.PlayerItem.PlaybackLikelyToKeepUp, context: nil)
|
|
playerItem.removeObserver(self, forKeyPath: KeyPath.PlayerItem.LoadedTimeRanges, context: nil)
|
|
}
|
|
|
|
private func addPlayerObservers() {
|
|
self.player.addObserver(self, forKeyPath: KeyPath.Player.Rate, options: [.initial, .new], context: nil)
|
|
|
|
let interval = CMTimeMakeWithSeconds(Constants.TimeUpdateInterval, preferredTimescale: Int32(NSEC_PER_SEC))
|
|
|
|
self.playerTimeObserver = self.player.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main, using: { [weak self] (cmTime) in
|
|
|
|
if let strongSelf = self, let time = cmTime.timeInterval {
|
|
strongSelf.time = time
|
|
}
|
|
})
|
|
}
|
|
|
|
private func removePlayerObservers() {
|
|
self.player.removeObserver(self, forKeyPath: KeyPath.Player.Rate, context: nil)
|
|
|
|
if let playerTimeObserver = self.playerTimeObserver {
|
|
self.player.removeTimeObserver(playerTimeObserver)
|
|
|
|
self.playerTimeObserver = nil
|
|
}
|
|
}
|
|
|
|
// MARK: Observation
|
|
|
|
override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
// Player Item Observers
|
|
|
|
if keyPath == KeyPath.PlayerItem.Status {
|
|
if let statusInt = change?[.newKey] as? Int, let status = AVPlayerItem.Status(rawValue: statusInt) {
|
|
self.playerItemStatusDidChange(status: status)
|
|
}
|
|
}
|
|
else if keyPath == KeyPath.PlayerItem.PlaybackLikelyToKeepUp {
|
|
if let playbackLikelyToKeepUp = change?[.newKey] as? Bool {
|
|
self.playerItemPlaybackLikelyToKeepUpDidChange(playbackLikelyToKeepUp: playbackLikelyToKeepUp)
|
|
}
|
|
}
|
|
else if keyPath == KeyPath.PlayerItem.LoadedTimeRanges {
|
|
if let loadedTimeRanges = change?[.newKey] as? [NSValue] {
|
|
self.playerItemLoadedTimeRangesDidChange(loadedTimeRanges: loadedTimeRanges)
|
|
}
|
|
}
|
|
|
|
// Player Observers
|
|
|
|
else if keyPath == KeyPath.Player.Rate {
|
|
if let rate = change?[.newKey] as? Float {
|
|
self.playerRateDidChange(rate: rate)
|
|
}
|
|
}
|
|
|
|
// Fall Through Observers
|
|
|
|
else {
|
|
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
|
}
|
|
}
|
|
|
|
// MARK: Observation Helpers
|
|
|
|
private func playerItemStatusDidChange(status: AVPlayerItem.Status) {
|
|
switch status {
|
|
case .unknown:
|
|
|
|
self.state = .loading
|
|
|
|
case .readyToPlay:
|
|
|
|
self.state = .ready
|
|
|
|
// If we tried to seek before the video was ready to play, resume seeking now.
|
|
if self.isSeekInProgress {
|
|
self.seekToTarget()
|
|
}
|
|
|
|
case .failed:
|
|
|
|
self.state = .failed
|
|
|
|
@unknown default:
|
|
|
|
self.state = .failed
|
|
}
|
|
}
|
|
|
|
private func playerRateDidChange(rate: Float) {
|
|
self.delegate?.playerDidUpdatePlaying(player: self)
|
|
}
|
|
|
|
private func playerItemPlaybackLikelyToKeepUpDidChange(playbackLikelyToKeepUp: Bool) {
|
|
let state: PlayerState = playbackLikelyToKeepUp ? .ready : .loading
|
|
|
|
self.state = state
|
|
}
|
|
|
|
private func playerItemLoadedTimeRangesDidChange(loadedTimeRanges: [NSValue]) {
|
|
guard let bufferedCMTime = loadedTimeRanges.first?.timeRangeValue.end, let bufferedTime = bufferedCMTime.timeInterval else {
|
|
return
|
|
}
|
|
|
|
self.bufferedTime = bufferedTime
|
|
}
|
|
|
|
// MARK: - Capability Protocol Helpers
|
|
|
|
#if os(iOS)
|
|
@available(iOS 9.0, *)
|
|
fileprivate lazy var _pictureInPictureController: AVPictureInPictureController? = {
|
|
AVPictureInPictureController(playerLayer: self.regularPlayerView.playerLayer)
|
|
}()
|
|
#endif
|
|
}
|
|
|
|
// MARK: Capability Protocols
|
|
|
|
extension RegularPlayer: AirPlayCapable
|
|
{
|
|
public var isAirPlayEnabled: Bool {
|
|
get {
|
|
return self.player.allowsExternalPlayback
|
|
}
|
|
set {
|
|
return self.player.allowsExternalPlayback = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
extension RegularPlayer: PictureInPictureCapable {
|
|
@available(iOS 9.0, *)
|
|
public var pictureInPictureController: AVPictureInPictureController? {
|
|
return self._pictureInPictureController
|
|
}
|
|
}
|
|
#endif
|
|
|
|
extension RegularPlayer: VolumeCapable
|
|
{
|
|
public var volume: Float {
|
|
get {
|
|
return self.player.volume
|
|
}
|
|
set {
|
|
self.player.volume = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RegularPlayer: FillModeCapable {
|
|
public var fillMode: FillMode {
|
|
get {
|
|
let gravity = (self.view.layer as! AVPlayerLayer).videoGravity
|
|
|
|
return gravity == .resizeAspect ? .fit : .fill
|
|
}
|
|
set {
|
|
let gravity: AVLayerVideoGravity
|
|
|
|
switch newValue {
|
|
case .fit:
|
|
|
|
gravity = .resizeAspect
|
|
|
|
case .fill:
|
|
|
|
gravity = .resizeAspectFill
|
|
}
|
|
|
|
(self.view.layer as! AVPlayerLayer).videoGravity = gravity
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RegularPlayer: TextTrackCapable {
|
|
public var selectedTextTrack: TextTrackMetadata? {
|
|
guard let group = self.player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
|
|
return nil
|
|
}
|
|
|
|
if #available(iOS 9.0, *) {
|
|
return self.player.currentItem?.currentMediaSelection.selectedMediaOption(in: group)
|
|
}
|
|
else {
|
|
return self.player.currentItem?.selectedMediaOption(in: group)
|
|
}
|
|
}
|
|
|
|
public var availableTextTracks: [TextTrackMetadata] {
|
|
guard let group = self.player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
|
|
return []
|
|
}
|
|
return group.options
|
|
}
|
|
|
|
public func fetchTextTracks(completion: @escaping ([TextTrackMetadata], TextTrackMetadata?) -> Void) {
|
|
self.player.currentItem?.asset.loadValuesAsynchronously(forKeys: [#keyPath(AVAsset.availableMediaCharacteristicsWithMediaSelectionOptions)]) { [weak self] in
|
|
guard let strongSelf = self, let group = strongSelf.player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
|
|
completion([], nil)
|
|
return
|
|
}
|
|
if #available(iOS 9.0, *) {
|
|
completion(group.options, strongSelf.player.currentItem?.currentMediaSelection.selectedMediaOption(in: group))
|
|
}
|
|
else {
|
|
completion(group.options, strongSelf.player.currentItem?.selectedMediaOption(in: group))
|
|
}
|
|
}
|
|
}
|
|
|
|
public func select(_ textTrack: TextTrackMetadata?) {
|
|
guard let group = self.player.currentItem?.asset.mediaSelectionGroup(forMediaCharacteristic: .legible) else {
|
|
return
|
|
}
|
|
|
|
guard let track = textTrack else {
|
|
self.player.currentItem?.select(nil, in: group)
|
|
return
|
|
}
|
|
|
|
let option = group.options.first(where: { option in
|
|
track.matches(option)
|
|
})
|
|
self.player.currentItem?.select(option, in: group)
|
|
}
|
|
}
|