From a17199bf904738d83a3bb9fbd1a8ec77f9780085 Mon Sep 17 00:00:00 2001 From: Gal Orlanczyk Date: Mon, 17 Apr 2017 10:55:30 +0300 Subject: [PATCH 1/3] #FEM-1260 * Added timeout handling for `IMAPlugin`. * Added state machine for `IMAPlugin` for better handling of state changes and readability. * Renamed `AdsConfig` to `IMAConfig` * Added `enableDebugMode` for `IMAConfig` for better debug handling in IMA. --- Classes/PKStateMachine.swift | 72 ++++ Classes/PlayerEvent.swift | 21 +- Classes/Plugins/Ads/PKAdInfo.swift | 6 +- Plugins/IMA/AdsEnabledPlayerController.swift | 68 +-- Plugins/IMA/AdsPlugin.swift | 13 +- .../IMA/{AdsConfig.swift => IMAConfig.swift} | 20 +- Plugins/IMA/IMAExtensions.swift | 36 ++ Plugins/IMA/IMAPlugin.swift | 389 ++++++++++-------- 8 files changed, 417 insertions(+), 208 deletions(-) create mode 100644 Classes/PKStateMachine.swift rename Plugins/IMA/{AdsConfig.swift => IMAConfig.swift} (65%) create mode 100644 Plugins/IMA/IMAExtensions.swift diff --git a/Classes/PKStateMachine.swift b/Classes/PKStateMachine.swift new file mode 100644 index 00000000..3f4962bc --- /dev/null +++ b/Classes/PKStateMachine.swift @@ -0,0 +1,72 @@ +// +// PKStateMachine.swift +// Pods +// +// Created by Gal Orlanczyk on 02/04/2017. +// +// + +import Foundation + +protocol IntRawRepresentable: RawRepresentable { + var rawValue: Int { get } +} + +protocol StateProtocol: IntRawRepresentable, Hashable {} + +extension StateProtocol { + var hashValue: Int { + return rawValue + } +} + +class BasicStateMachine { + /// the current state. + private var state: T + /// the queue to make changes and fetches on. + let dispatchQueue: DispatchQueue + /// the initial state of the state machine. + let initialState: T + /// indicates whether it is allowed to change the state to the initial one. + var allowTransitionToInitialState: Bool + /// a block to perform on every state changing (performed on the main queue). + var onStateChange: ((T) -> Void)? + + init(initialState: T, allowTransitionToInitialState: Bool = true) { + self.state = initialState + self.initialState = initialState + self.allowTransitionToInitialState = allowTransitionToInitialState + self.dispatchQueue = DispatchQueue(label: "com.kaltura.playkit.dispatch-queue.\(String(describing: type(of: self)))") + } + + /// gets the current state. + func getState() -> T { + return self.dispatchQueue.sync { + return self.state + } + } + + /// sets the state to a new value. + func set(state: T) { + self.dispatchQueue.sync { + if state == self.initialState && !self.allowTransitionToInitialState { + PKLog.error("\(String(describing: type(of: self))) was set to initial state, this is not allowed") + return + } + self.state = state + DispatchQueue.main.async { + self.onStateChange?(state) + } + } + } + + /// sets the state machine to the initial value. + func reset() { + dispatchQueue.sync { + self.state = self.initialState + DispatchQueue.main.async { + self.onStateChange?(self.state) + } + } + } +} diff --git a/Classes/PlayerEvent.swift b/Classes/PlayerEvent.swift index e7ae6c5b..c7d357f9 100644 --- a/Classes/PlayerEvent.swift +++ b/Classes/PlayerEvent.swift @@ -133,7 +133,7 @@ import AVFoundation @objc public class AdEvent: PKEvent { @objc public static let allEventTypes: [AdEvent.Type] = [ - adBreakReady, adBreakEnded, adBreakStarted, allAdsCompleted, adComplete, adClicked, adCuePointsUpdate, adFirstQuartile, adLoaded, adLog, adMidpoint, adPaused, adResumed, adSkipped, adStarted, adStreamLoaded, adTapped, adThirdQuartile, adDidProgressToTime, adDidRequestPause, adDidRequestResume, adWebOpenerWillOpenExternalBrowser, adWebOpenerWillOpenInAppBrowser, adWebOpenerDidOpenInAppBrowser, adWebOpenerWillCloseInAppBrowser, adWebOpenerDidCloseInAppBrowser + adBreakReady, adBreakEnded, adBreakStarted, allAdsCompleted, adComplete, adClicked, adCuePointsUpdate, adFirstQuartile, adLoaded, adLog, adMidpoint, adPaused, adResumed, adSkipped, adStarted, adStreamLoaded, adTapped, adThirdQuartile, adDidProgressToTime, adDidRequestPause, adDidRequestResume, adWebOpenerWillOpenExternalBrowser, adWebOpenerWillOpenInAppBrowser, adWebOpenerDidOpenInAppBrowser, adWebOpenerWillCloseInAppBrowser, adWebOpenerDidCloseInAppBrowser, requestTimedOut ] @objc public static let adBreakReady: AdEvent.Type = AdBreakReady.self @@ -163,6 +163,8 @@ import AVFoundation @objc public static let adWebOpenerWillCloseInAppBrowser: AdEvent.Type = AdWebOpenerWillCloseInAppBrowser.self @objc public static let adWebOpenerDidCloseInAppBrowser: AdEvent.Type = AdWebOpenerDidCloseInAppBrowser.self @objc public static let adCuePointsUpdate: AdEvent.Type = AdCuePointsUpdate.self + /// Sent when the ads request timed out. + @objc public static let requestTimedOut: AdEvent.Type = RequestTimedOut.self /// Sent when an error occurs. @objc public static let error: AdEvent.Type = Error.self @@ -172,14 +174,24 @@ import AVFoundation } } - class AdBreakReady: AdEvent {} + class AdLoaded: AdEvent { + convenience init(adInfo: PKAdInfo) { + self.init([AdEventDataKeys.adInfo: adInfo]) + } + } + + class AdBreakReady: AdEvent { + convenience init(adInfo: PKAdInfo) { + self.init([AdEventDataKeys.adInfo: adInfo]) + } + } + class AdBreakEnded: AdEvent {} class AdBreakStarted: AdEvent {} class AllAdsCompleted: AdEvent {} class AdComplete: AdEvent {} class AdClicked: AdEvent {} class AdFirstQuartile: AdEvent {} - class AdLoaded: AdEvent {} class AdLog: AdEvent {} class AdMidpoint: AdEvent {} class AdPaused: AdEvent {} @@ -212,6 +224,9 @@ import AVFoundation class AdDidRequestPause: AdEvent {} class AdDidRequestResume: AdEvent {} + /// Sent when the ads request timed out. + class RequestTimedOut: AdEvent {} + class WebOpenerEvent: AdEvent { convenience init(webOpener: NSObject!) { self.init([AdEventDataKeys.webOpener: webOpener]) diff --git a/Classes/Plugins/Ads/PKAdInfo.swift b/Classes/Plugins/Ads/PKAdInfo.swift index 3dad2650..bae46134 100644 --- a/Classes/Plugins/Ads/PKAdInfo.swift +++ b/Classes/Plugins/Ads/PKAdInfo.swift @@ -30,10 +30,8 @@ import Foundation @objc public var width: Int @objc public var podCount: Int @objc public var podPosition: Int - /** - The position of the pod in the content in seconds. Pre-roll returns 0, - post-roll returns -1 and mid-rolls return the scheduled time of the pod. - */ + /// The position of the pod in the content in seconds. Pre-roll returns 0, + /// post-roll returns -1 and mid-rolls return the scheduled time of the pod. @objc public var podTimeOffset: TimeInterval /// returns the position type of the ad (pre, mid, post) diff --git a/Plugins/IMA/AdsEnabledPlayerController.swift b/Plugins/IMA/AdsEnabledPlayerController.swift index 1ec235e3..fab39870 100644 --- a/Plugins/IMA/AdsEnabledPlayerController.swift +++ b/Plugins/IMA/AdsEnabledPlayerController.swift @@ -13,7 +13,11 @@ import AVKit class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPluginDataSource { - var isAdPlayback = false + enum PlayType { + case play, resume + } + + /// indicates if play was used, if `play()` or `resume()` was called we set this to true. var isPlayEnabled = false /// when playing post roll google sends content resume when finished. @@ -37,86 +41,87 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl override var isPlaying: Bool { get { - if isAdPlayback { + if self.adsPlugin.isAdPlaying { return isPlayEnabled } return super.isPlaying } } + // TODO:: finilize prepare + override func prepare(_ config: MediaConfig) { + super.prepare(config) + + self.adsPlugin.requestAds() + } + override func play() { self.isPlayEnabled = true - if !self.adsPlugin.start(showLoadingView: true) { - super.play() - } + self.adsPlugin.didRequestPlay(ofType: .play) + } + + override func resume() { + self.isPlayEnabled = true + self.adsPlugin.didRequestPlay(ofType: .resume) } override func pause() { self.isPlayEnabled = false - if isAdPlayback { + if self.adsPlugin.isAdPlaying { self.adsPlugin.pause() } else { super.pause() } } - override func resume() { - self.isPlayEnabled = true - if isAdPlayback { - self.adsPlugin.resume() - } else { - super.resume() - } - } - override func stop() { self.adsPlugin.destroyManager() super.stop() - self.isAdPlayback = false self.isPlayEnabled = false self.shouldPreventContentResume = false } - // TODO:: finilize prepare - override func prepare(_ config: MediaConfig) { - super.prepare(config) - self.adsPlugin.requestAds() - } - @available(iOS 9.0, *) override func createPiPController(with delegate: AVPictureInPictureControllerDelegate) -> AVPictureInPictureController? { self.adsPlugin.pipDelegate = delegate return super.createPiPController(with: self.adsPlugin) } + /************************************************************/ + // MARK: - AdsPluginDataSource + /************************************************************/ func adsPluginShouldPlayAd(_ adsPlugin: AdsPlugin) -> Bool { return self.delegate!.playerShouldPlayAd(self) } + /************************************************************/ + // MARK: - AdsPluginDelegate + /************************************************************/ + func adsPlugin(_ adsPlugin: AdsPlugin, loaderFailedWith error: String) { if self.isPlayEnabled { super.play() + self.adsPlugin.didPlay() } } func adsPlugin(_ adsPlugin: AdsPlugin, managerFailedWith error: String) { super.play() - self.isAdPlayback = false + self.adsPlugin.didPlay() } func adsPlugin(_ adsPlugin: AdsPlugin, didReceive event: PKEvent) { switch event { case let e where type(of: e) == AdEvent.adDidRequestPause: - self.isAdPlayback = true super.pause() case let e where type(of: e) == AdEvent.adDidRequestResume: - self.isAdPlayback = false if !self.shouldPreventContentResume { super.resume() } case let e where type(of: e) == AdEvent.adResumed: self.isPlayEnabled = true - case let e where type(of: e) == AdEvent.adStarted: + case let e where type(of: e) == AdEvent.adLoaded || type(of: e) == AdEvent.adBreakReady: + if self.shouldPreventContentResume == true { return } // no need to handle twice if already true if event.adInfo?.positionType == .postRoll { self.shouldPreventContentResume = true } @@ -124,4 +129,15 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl default: break } } + + func adsRequestTimedOut(shouldPlay: Bool) { + if shouldPlay { + self.play() + } + } + + func play(_ playType: PlayType) { + playType == .play ? super.play() : super.resume() + self.adsPlugin.didPlay() + } } diff --git a/Plugins/IMA/AdsPlugin.swift b/Plugins/IMA/AdsPlugin.swift index 2583bf49..03b79f3d 100644 --- a/Plugins/IMA/AdsPlugin.swift +++ b/Plugins/IMA/AdsPlugin.swift @@ -17,18 +17,29 @@ protocol AdsPluginDelegate : class { func adsPlugin(_ adsPlugin: AdsPlugin, loaderFailedWith error: String) func adsPlugin(_ adsPlugin: AdsPlugin, managerFailedWith error: String) func adsPlugin(_ adsPlugin: AdsPlugin, didReceive event: PKEvent) + /// called when ads request was timed out, telling the player if it should start play afterwards. + func adsRequestTimedOut(shouldPlay: Bool) + /// called when the plugin wants the player to start play. + func play(_ playType: AdsEnabledPlayerController.PlayType) } protocol AdsPlugin: PKPlugin, AVPictureInPictureControllerDelegate { var dataSource: AdsPluginDataSource? { get set } var delegate: AdsPluginDelegate? { get set } var pipDelegate: AVPictureInPictureControllerDelegate? { get set } + var isAdPlaying: Bool { get } func requestAds() - func start(showLoadingView: Bool) -> Bool + /// called when plugin need to start the ad playback on first ad play only + func startAd() func resume() func pause() func contentComplete() func destroyManager() + /// called after player called `super.play()` + func didPlay() + /// called when play() or resume() was called. + /// used to make the neccery checks with the ads plugin if can play or resume the content. + func didRequestPlay(ofType type: AdsEnabledPlayerController.PlayType) } diff --git a/Plugins/IMA/AdsConfig.swift b/Plugins/IMA/IMAConfig.swift similarity index 65% rename from Plugins/IMA/AdsConfig.swift rename to Plugins/IMA/IMAConfig.swift index 0950e558..ed428c0f 100644 --- a/Plugins/IMA/AdsConfig.swift +++ b/Plugins/IMA/IMAConfig.swift @@ -9,20 +9,28 @@ import Foundation import GoogleInteractiveMediaAds -@objc public class AdsConfig: NSObject { +@objc public class IMAConfig: NSObject { + @objc public var language: String = "en" @objc public var enableBackgroundPlayback: Bool { return true } + // defaulted to false, because otherwise ad breaks events will not happen. + // we need to have control on whether ad break will start playing or not using `Loaded` event is not enough. + // (will also need more safety checks because loaded will happen more than once). @objc public var autoPlayAdBreaks: Bool { return false } @objc public var videoBitrate = kIMAAutodetectBitrate @objc public var videoMimeTypes: [Any]? - @objc public var adTagUrl: String? + @objc public var adTagUrl: String = "" @objc public var companionView: UIView? @objc public var webOpenerPresentingController: UIViewController? - + @objc public var requestTimeoutInterval: TimeInterval = 5 + /// enables debug mode on IMA SDK which will output detailed log information to the console. + /// The default value is false. + @objc public var enableDebugMode: Bool = false + // Builders @discardableResult @nonobjc public func set(language: String) -> Self { @@ -59,4 +67,10 @@ import GoogleInteractiveMediaAds self.webOpenerPresentingController = webOpenerPresentingController return self } + + @discardableResult + @nonobjc public func set(requestTimeoutInterval: TimeInterval) -> Self { + self.requestTimeoutInterval = requestTimeoutInterval + return self + } } diff --git a/Plugins/IMA/IMAExtensions.swift b/Plugins/IMA/IMAExtensions.swift new file mode 100644 index 00000000..98016d78 --- /dev/null +++ b/Plugins/IMA/IMAExtensions.swift @@ -0,0 +1,36 @@ +// +// IMAExtensions.swift +// Pods +// +// Created by Gal Orlanczyk on 17/04/2017. +// +// + +import Foundation + +import GoogleInteractiveMediaAds + +extension IMAAdsManager { + func getAdCuePoints() -> PKAdCuePoints { + return PKAdCuePoints(cuePoints: self.adCuePoints as? [TimeInterval] ?? []) + } +} + +extension PKAdInfo { + convenience init(ad: IMAAd) { + self.init( + adDescription: ad.adDescription, + adDuration: ad.duration, + title: ad.adTitle, + isSkippable: ad.isSkippable, + contentType: ad.contentType, + adId: ad.adId, + adSystem: ad.adSystem, + height: Int(ad.height), + width: Int(ad.width), + podCount: Int(ad.adPodInfo.totalAds), + podPosition: Int(ad.adPodInfo.adPosition), + podTimeOffset: ad.adPodInfo.timeOffset + ) + } +} diff --git a/Plugins/IMA/IMAPlugin.swift b/Plugins/IMA/IMAPlugin.swift index 539215af..89ffe35c 100644 --- a/Plugins/IMA/IMAPlugin.swift +++ b/Plugins/IMA/IMAPlugin.swift @@ -8,29 +8,26 @@ import GoogleInteractiveMediaAds -extension IMAAdsManager { - func getAdCuePoints() -> PKAdCuePoints { - return PKAdCuePoints(cuePoints: self.adCuePoints as? [TimeInterval] ?? []) - } -} - -extension PKAdInfo { - convenience init(ad: IMAAd) { - self.init( - adDescription: ad.adDescription, - adDuration: ad.duration, - title: ad.adTitle, - isSkippable: ad.isSkippable, - contentType: ad.contentType, - adId: ad.adId, - adSystem: ad.adSystem, - height: Int(ad.height), - width: Int(ad.width), - podCount: Int(ad.adPodInfo.totalAds), - podPosition: Int(ad.adPodInfo.adPosition), - podTimeOffset: ad.adPodInfo.timeOffset - ) - } +/// `IMAState` represents `IMAPlugin` state machine states. +enum IMAState: Int, StateProtocol { + /// initial state. + case start = 0 + /// ads request was made. + case adsRequested + /// ads request was made and play() was used. + case adsRequestedAndPlay + /// the ads request failed (loader failed to load ads and error was sent) + case adsRequestFailed + /// the ads request was timed out. + case adsRequestTimedOut + /// ads request was succeeded and loaded. + case adsLoaded + /// ads request was succeeded and loaded and play() was used. + case adsLoadedAndPlay + /// ads are playing. + case adsPlaying + /// content is playing. + case contentPlaying } @objc public class IMAPlugin: BasePlugin, PKPluginWarmUp, PlayerDecoratorProvider, AdsPlugin, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMAWebOpenerDelegate, IMAContentPlayhead { @@ -43,6 +40,8 @@ extension PKAdInfo { weak var delegate: AdsPluginDelegate? weak var pipDelegate: AVPictureInPictureControllerDelegate? + var stateMachine = BasicStateMachine(initialState: IMAState.start, allowTransitionToInitialState: false) + private var adsManager: IMAAdsManager? private var renderingSettings: IMAAdsRenderingSettings! = IMAAdsRenderingSettings() private static var loader: IMAAdsLoader! @@ -50,12 +49,14 @@ extension PKAdInfo { private var pictureInPictureProxy: IMAPictureInPictureProxy? private var loadingView: UIView? // we must have config error will be thrown otherwise - private var config: AdsConfig! - - private var isAdPlayback = false - private var startAdCalled = false - private var loaderFailed = false + private var config: IMAConfig! + private var timer: Timer? + /// timer for checking IMA requests timeout. + private var requestTimeoutTimer: Timer? + /// the request timeout interval + private var requestTimeoutInterval: TimeInterval = 5 + /************************************************************/ // MARK: - IMAContentPlayhead /************************************************************/ @@ -76,8 +77,7 @@ extension PKAdInfo { public static func warmUp() { // load adsLoader in order to make IMA download the needed objects before initializing. // will setup the instance when first player is loaded - let imaSettings: IMASettings = IMASettings() - let imaLoader = IMAAdsLoader(settings: imaSettings) + _ = IMAAdsLoader(settings: IMASettings()) } /************************************************************/ @@ -88,12 +88,11 @@ extension PKAdInfo { public required init(player: Player, pluginConfig: Any?, messageBus: MessageBus) throws { try super.init(player: player, pluginConfig: pluginConfig, messageBus: messageBus) - if let adsConfig = pluginConfig as? AdsConfig { + if let adsConfig = pluginConfig as? IMAConfig { self.config = adsConfig if IMAPlugin.loader == nil { self.setupLoader(with: adsConfig) } - IMAPlugin.loader.contentComplete() IMAPlugin.loader.delegate = self } else { @@ -111,7 +110,7 @@ extension PKAdInfo { super.onUpdateConfig(pluginConfig: pluginConfig) - if let adsConfig = pluginConfig as? AdsConfig { + if let adsConfig = pluginConfig as? IMAConfig { self.config = adsConfig } } @@ -124,6 +123,8 @@ extension PKAdInfo { public override func destroy() { super.destroy() + self.requestTimeoutTimer?.invalidate() + self.requestTimeoutTimer = nil self.destroyManager() } @@ -139,50 +140,47 @@ extension PKAdInfo { // MARK: - AdsPlugin /************************************************************/ + var isAdPlaying: Bool { + return self.stateMachine.getState() == .adsPlaying + } + func requestAds() { - guard let playerView = player?.view else { return } + guard let player = self.player else { return } - if self.config.adTagUrl != nil && self.config.adTagUrl != "" { - self.startAdCalled = false - - // setup ad display container and companion if exists, needs to create a new ad container for each request. - var companionAdSlot: IMACompanionAdSlot? = nil - let adDisplayContainer: IMAAdDisplayContainer - if let companionView = self.config?.companionView { - companionAdSlot = IMACompanionAdSlot(view: companionView, width: Int32(companionView.frame.size.width), height: Int32(companionView.frame.size.height)) - adDisplayContainer = IMAAdDisplayContainer(adContainer: playerView, companionSlots: [companionAdSlot!]) - } else { - adDisplayContainer = IMAAdDisplayContainer(adContainer: playerView, companionSlots: []) + let adDisplayContainer = self.createAdDisplayContainer(forView: player.view) + let request = IMAAdsRequest(adTagUrl: self.config.adTagUrl, adDisplayContainer: adDisplayContainer, contentPlayhead: self, userContext: nil) + // sets the state to adsRequest + self.stateMachine.set(state: .adsRequested) + + self.requestTimeoutTimer = Timer.after(self.requestTimeoutInterval) { [unowned self] in + if self.adsManager == nil { + self.showLoadingView(false, alpha: 0) + + switch self.stateMachine.getState() { + case .adsRequested: self.delegate?.adsRequestTimedOut(shouldPlay: false) + case .adsRequestedAndPlay: self.delegate?.adsRequestTimedOut(shouldPlay: true) + default: break // should not receive timeout for any other state + } + // set state to request failure + self.stateMachine.set(state: .adsRequestTimedOut) + + self.invalidateRequestTimer() + // post ads request timeout event + self.notify(event: AdEvent.RequestTimedOut()) } - - var request: IMAAdsRequest - request = IMAAdsRequest(adTagUrl: self.config.adTagUrl, adDisplayContainer: adDisplayContainer, contentPlayhead: self, userContext: nil) - - IMAPlugin.loader.requestAds(with: request) - PKLog.trace("request Ads") } + + IMAPlugin.loader.requestAds(with: request) + PKLog.trace("request Ads") } - @discardableResult - func start(showLoadingView: Bool) -> Bool { - if self.loaderFailed { - return false - } - - if self.config.adTagUrl != nil && self.config.adTagUrl != "" { - if showLoadingView { - self.showLoadingView(true, alpha: 1) - } - - if let adsManager = self.adsManager { - adsManager.initialize(with: self.renderingSettings) - self.notifyAdCuePoints(fromAdsManager: adsManager) - } else { - self.startAdCalled = true - } - return true + func startAd() { + self.stateMachine.set(state: .adsLoadedAndPlay) + if self.adsManager == nil { + self.adsManager!.initialize(with: self.renderingSettings) + PKLog.debug("ads manager set") + self.notifyAdCuePoints(fromAdsManager: self.adsManager!) } - return false } func resume() { @@ -197,111 +195,58 @@ extension PKAdInfo { IMAPlugin.loader.contentComplete() } - /************************************************************/ - // MARK: - Private - /************************************************************/ - - private func setupLoader(with config: AdsConfig) { - let imaSettings: IMASettings! = IMASettings() - imaSettings.language = config.language - imaSettings.enableBackgroundPlayback = config.enableBackgroundPlayback - imaSettings.autoPlayAdBreaks = config.autoPlayAdBreaks - IMAPlugin.loader = IMAAdsLoader(settings: imaSettings) - } - - private func setupLoadingView() { - self.loadingView = UIView(frame: CGRect.zero) - self.loadingView!.translatesAutoresizingMaskIntoConstraints = false - self.loadingView!.backgroundColor = UIColor.black - self.loadingView!.isHidden = true - - let indicator = UIActivityIndicatorView() - indicator.translatesAutoresizingMaskIntoConstraints = false - indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyle.whiteLarge - indicator.startAnimating() - - self.loadingView!.addSubview(indicator) - self.loadingView!.addConstraint(NSLayoutConstraint(item: self.loadingView!, attribute: NSLayoutAttribute.centerX, relatedBy: NSLayoutRelation.equal, toItem: indicator, attribute: NSLayoutAttribute.centerX, multiplier: 1, constant: 0)) - self.loadingView!.addConstraint(NSLayoutConstraint(item: self.loadingView!, attribute: NSLayoutAttribute.centerY, relatedBy: NSLayoutRelation.equal, toItem: indicator, attribute: NSLayoutAttribute.centerY, multiplier: 1, constant: 0)) - - if let videoView = self.player?.view { - videoView.addSubview(self.loadingView!) - videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.top, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.top, multiplier: 1, constant: 0)) - videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.left, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.left, multiplier: 1, constant: 0)) - videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.bottom, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.bottom, multiplier: 1, constant: 0)) - videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.right, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.right, multiplier: 1, constant: 0)) - } - } - - private func createRenderingSettings() { - self.renderingSettings.webOpenerDelegate = self - if let webOpenerPresentingController = self.config?.webOpenerPresentingController { - self.renderingSettings.webOpenerPresentingController = webOpenerPresentingController - } - if let bitrate = self.config?.videoBitrate { - self.renderingSettings.bitrate = bitrate - } - if let mimeTypes = self.config?.videoMimeTypes { - self.renderingSettings.mimeTypes = mimeTypes - } - } - - private func showLoadingView(_ show: Bool, alpha: CGFloat) { - if self.loadingView == nil { - self.setupLoadingView() - } - - self.loadingView!.alpha = alpha - self.loadingView!.isHidden = !show - - self.player?.view?.bringSubview(toFront: self.loadingView!) - } - - private func notify(event: AdEvent) { - self.delegate?.adsPlugin(self, didReceive: event) - self.messageBus?.post(event) - } - - private func notifyAdCuePoints(fromAdsManager adsManager: IMAAdsManager) { - // send ad cue points if exists and request is url type - let adCuePoints = adsManager.getAdCuePoints() - if self.config.adTagUrl != nil && adCuePoints.count > 0 { - self.notify(event: AdEvent.AdCuePointsUpdate(adCuePoints: adCuePoints)) - } - } - func destroyManager() { - self.isAdPlayback = false - self.startAdCalled = false - self.loaderFailed = false self.adsManager?.delegate = nil self.adsManager?.destroy() - // In order to make multiple ad requests, AdsManager instance should be destroyed, and then contentComplete() should be called on AdsLoader. + // In order to make multiple ad requests, AdsManager instance should be destroyed, and then contentComplete() should be called on AdsLoader. // This will "reset" the SDK. self.contentComplete() self.adsManager = nil + // reset the state machine + self.stateMachine.reset() + } + + // when play() was used set state to content playing + func didPlay() { + self.stateMachine.set(state: .contentPlaying) } - + + func didRequestPlay(ofType type: AdsEnabledPlayerController.PlayType) { + switch self.stateMachine.getState() { + case .adsLoaded: self.startAd() + case .adsRequested: self.stateMachine.set(state: .adsRequestedAndPlay) + case .adsPlaying: self.resume() + default: self.delegate?.play(type) + } + } + /************************************************************/ // MARK: - AdsLoaderDelegate /************************************************************/ public func adsLoader(_ loader: IMAAdsLoader!, adsLoadedWith adsLoadedData: IMAAdsLoadedData!) { - self.loaderFailed = false + switch self.stateMachine.getState() { + case .adsRequested: self.stateMachine.set(state: .adsLoaded) + case .adsRequestedAndPlay: self.stateMachine.set(state: .adsLoadedAndPlay) + default: self.invalidateRequestTimer() + } self.adsManager = adsLoadedData.adsManager adsLoadedData.adsManager.delegate = self self.createRenderingSettings() - if self.startAdCalled { - self.adsManager!.initialize(with: self.renderingSettings) - self.notifyAdCuePoints(fromAdsManager: self.adsManager!) + // initialize on ads manager starts the ads loading process, we want to initialize it only after play. + // `adsLoaded` state is when ads request succeeded but play haven't been received yet, + // we don't want to initialize ads manager until play() will be used. + if self.stateMachine.getState() != .adsLoaded { + self.initAdsManager() } - PKLog.debug("ads manager set") } public func adsLoader(_ loader: IMAAdsLoader!, failedWith adErrorData: IMAAdLoadingErrorData!) { - self.loaderFailed = true + // cancel the request timer + self.invalidateRequestTimer() + self.stateMachine.set(state: .adsRequestFailed) self.showLoadingView(false, alpha: 0) PKLog.error(adErrorData.adError.message) self.messageBus?.post(AdEvent.Error(nsError: IMAPluginError(adError: adErrorData.adError).asNSError)) @@ -321,29 +266,30 @@ extension PKAdInfo { } public func adsManager(_ adsManager: IMAAdsManager!, didReceive event: IMAAdEvent!) { - PKLog.debug("ads manager event: " + String(describing: event)) + PKLog.trace("ads manager event: " + String(describing: event)) + let currentState = self.stateMachine.getState() + switch event.type { // Ad break, will be called before each scheduled ad break. Ad breaks may contain more than 1 ad. case .AD_BREAK_READY: - self.notify(event: AdEvent.AdBreakReady()) - let canPlay = self.dataSource?.adsPluginShouldPlayAd(self) - if canPlay == nil || canPlay == true { - adsManager.start() + if let ad = event.ad, PKAdInfo(ad: ad).positionType == .preRoll, currentState == .adsRequestTimedOut || currentState == .contentPlaying { + adsManager.discardAdBreak() + } else { + let event = event.ad != nil ? AdEvent.AdBreakReady(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdBreakReady() + self.notify(event: event) + guard currentState == .adsLoadedAndPlay || currentState == .contentPlaying else { return } + self.start(adsManager: adsManager) } case .LOADED: - self.notify(event: AdEvent.AdLoaded()) + let event = event.ad != nil ? AdEvent.AdLoaded(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdLoaded() + self.notify(event: event) // single ad only fires `LOADED` without `AD_BREAK_READY`. // if we have more than one ad don't handle the event, it will be handled in `AD_BREAK_READY` - if adsManager.adCuePoints.count == 0 { - let canPlay = self.dataSource?.adsPluginShouldPlayAd(self) - if canPlay == nil || canPlay == true { - adsManager.start() - } else { - adsManager.skip() - self.adsManagerDidRequestContentResume(adsManager) - } - } + guard adsManager.adCuePoints.count == 0 else { return } + guard self.stateMachine.getState() == .adsLoadedAndPlay || currentState == .contentPlaying else { return } + self.start(adsManager: adsManager) case .STARTED: + self.stateMachine.set(state: .adsPlaying) let event = event.ad != nil ? AdEvent.AdStarted(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdStarted() self.notify(event: event) self.showLoadingView(false, alpha: 0) @@ -379,12 +325,12 @@ extension PKAdInfo { } public func adsManagerDidRequestContentPause(_ adsManager: IMAAdsManager!) { - self.isAdPlayback = true + self.stateMachine.set(state: .adsPlaying) self.notify(event: AdEvent.AdDidRequestPause()) } public func adsManagerDidRequestContentResume(_ adsManager: IMAAdsManager!) { - self.isAdPlayback = false + self.stateMachine.set(state: .contentPlaying) self.showLoadingView(false, alpha: 0) self.notify(event: AdEvent.AdDidRequestResume()) } @@ -393,6 +339,107 @@ extension PKAdInfo { self.notify(event: AdEvent.AdDidProgressToTime(mediaTime: mediaTime, totalTime: totalTime)) } + /************************************************************/ + // MARK: - Private + /************************************************************/ + + private func setupLoader(with config: IMAConfig) { + let imaSettings: IMASettings! = IMASettings() + imaSettings.language = config.language + imaSettings.enableBackgroundPlayback = config.enableBackgroundPlayback + imaSettings.autoPlayAdBreaks = config.autoPlayAdBreaks + imaSettings.enableDebugMode = config.enableDebugMode + IMAPlugin.loader = IMAAdsLoader(settings: imaSettings) + } + + private func createAdDisplayContainer(forView view: UIView) -> IMAAdDisplayContainer { + // setup ad display container and companion if exists, needs to create a new ad container for each request. + if let companionView = self.config?.companionView { + let companionAdSlot = IMACompanionAdSlot(view: companionView, width: Int32(companionView.frame.size.width), height: Int32(companionView.frame.size.height)) + return IMAAdDisplayContainer(adContainer: view, companionSlots: [companionAdSlot!]) + } else { + return IMAAdDisplayContainer(adContainer: view, companionSlots: []) + } + } + + private func setupLoadingView() { + self.loadingView = UIView(frame: CGRect.zero) + self.loadingView!.translatesAutoresizingMaskIntoConstraints = false + self.loadingView!.backgroundColor = UIColor.black + self.loadingView!.isHidden = true + + let indicator = UIActivityIndicatorView() + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyle.whiteLarge + indicator.startAnimating() + + self.loadingView!.addSubview(indicator) + self.loadingView!.addConstraint(NSLayoutConstraint(item: self.loadingView!, attribute: NSLayoutAttribute.centerX, relatedBy: NSLayoutRelation.equal, toItem: indicator, attribute: NSLayoutAttribute.centerX, multiplier: 1, constant: 0)) + self.loadingView!.addConstraint(NSLayoutConstraint(item: self.loadingView!, attribute: NSLayoutAttribute.centerY, relatedBy: NSLayoutRelation.equal, toItem: indicator, attribute: NSLayoutAttribute.centerY, multiplier: 1, constant: 0)) + + if let videoView = self.player?.view { + videoView.addSubview(self.loadingView!) + videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.top, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.top, multiplier: 1, constant: 0)) + videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.left, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.left, multiplier: 1, constant: 0)) + videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.bottom, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.bottom, multiplier: 1, constant: 0)) + videoView.addConstraint(NSLayoutConstraint(item: videoView, attribute: NSLayoutAttribute.right, relatedBy: NSLayoutRelation.equal, toItem: self.loadingView!, attribute: NSLayoutAttribute.right, multiplier: 1, constant: 0)) + } + } + + private func createRenderingSettings() { + self.renderingSettings.webOpenerDelegate = self + if let webOpenerPresentingController = self.config?.webOpenerPresentingController { + self.renderingSettings.webOpenerPresentingController = webOpenerPresentingController + } + if let bitrate = self.config?.videoBitrate { + self.renderingSettings.bitrate = bitrate + } + if let mimeTypes = self.config?.videoMimeTypes { + self.renderingSettings.mimeTypes = mimeTypes + } + } + + private func showLoadingView(_ show: Bool, alpha: CGFloat) { + if self.loadingView == nil { + self.setupLoadingView() + } + + self.loadingView!.alpha = alpha + self.loadingView!.isHidden = !show + + self.player?.view?.bringSubview(toFront: self.loadingView!) + } + + private func notify(event: AdEvent) { + self.delegate?.adsPlugin(self, didReceive: event) + self.messageBus?.post(event) + } + + private func notifyAdCuePoints(fromAdsManager adsManager: IMAAdsManager) { + // send ad cue points if exists and request is url type + let adCuePoints = adsManager.getAdCuePoints() + if adCuePoints.count > 0 { + self.notify(event: AdEvent.AdCuePointsUpdate(adCuePoints: adCuePoints)) + } + } + + private func start(adsManager: IMAAdsManager) { + if let canPlay = self.dataSource?.adsPluginShouldPlayAd(self), canPlay == true { + adsManager.start() + } + } + + private func initAdsManager() { + self.adsManager!.initialize(with: self.renderingSettings) + PKLog.debug("ads manager set") + self.notifyAdCuePoints(fromAdsManager: self.adsManager!) + } + + private func invalidateRequestTimer() { + self.requestTimeoutTimer?.invalidate() + self.requestTimeoutTimer = nil + } + /************************************************************/ // MARK: - AVPictureInPictureControllerDelegate /************************************************************/ From d6f076748791b503d027107b28bd717788651aa8 Mon Sep 17 00:00:00 2001 From: Gal Orlanczyk Date: Mon, 17 Apr 2017 22:16:47 +0300 Subject: [PATCH 2/3] * Fixed issue with IMAPlugin * Renamed 3 properties in `PKAdInfo` according to their naming in IMA SDK to be aligned. --- Classes/Player/PlayerConfig.swift | 10 ++++- Classes/PlayerEvent.swift | 7 +--- Classes/Plugins/Ads/PKAdInfo.swift | 22 +++++------ Plugins/IMA/AdsPlugin.swift | 6 +-- Plugins/IMA/IMAExtensions.swift | 6 +-- Plugins/IMA/IMAPlugin.swift | 60 +++++++++++++++++++----------- 6 files changed, 64 insertions(+), 47 deletions(-) diff --git a/Classes/Player/PlayerConfig.swift b/Classes/Player/PlayerConfig.swift index 77d42d04..126c6e17 100644 --- a/Classes/Player/PlayerConfig.swift +++ b/Classes/Player/PlayerConfig.swift @@ -37,6 +37,14 @@ import Foundation } } +extension MediaConfig: NSCopying { + + public func copy(with zone: NSZone? = nil) -> Any { + let copy = MediaConfig(mediaEntry: self.mediaEntry, startTime: self.startTime) + return copy + } +} + /// A `PluginConfig` object defines config to use when loading a plugin object. @objc public class PluginConfig: NSObject { /// Plugins config dictionary holds [plugin name : plugin config] @@ -59,7 +67,7 @@ import Foundation extension PluginConfig: NSCopying { public func copy(with zone: NSZone? = nil) -> Any { - let copy = PluginConfig(config: config) + let copy = PluginConfig(config: self.config) return copy } } diff --git a/Classes/PlayerEvent.swift b/Classes/PlayerEvent.swift index c7d357f9..dde9b4f7 100644 --- a/Classes/PlayerEvent.swift +++ b/Classes/PlayerEvent.swift @@ -180,12 +180,7 @@ import AVFoundation } } - class AdBreakReady: AdEvent { - convenience init(adInfo: PKAdInfo) { - self.init([AdEventDataKeys.adInfo: adInfo]) - } - } - + class AdBreakReady: AdEvent {} class AdBreakEnded: AdEvent {} class AdBreakStarted: AdEvent {} class AllAdsCompleted: AdEvent {} diff --git a/Classes/Plugins/Ads/PKAdInfo.swift b/Classes/Plugins/Ads/PKAdInfo.swift index bae46134..d99487bd 100644 --- a/Classes/Plugins/Ads/PKAdInfo.swift +++ b/Classes/Plugins/Ads/PKAdInfo.swift @@ -28,17 +28,17 @@ import Foundation @objc public var adSystem: String @objc public var height: Int @objc public var width: Int - @objc public var podCount: Int - @objc public var podPosition: Int + @objc public var totalAds: Int + @objc public var adPosition: Int /// The position of the pod in the content in seconds. Pre-roll returns 0, /// post-roll returns -1 and mid-rolls return the scheduled time of the pod. - @objc public var podTimeOffset: TimeInterval + @objc public var timeOffset: TimeInterval /// returns the position type of the ad (pre, mid, post) @objc public var positionType: AdPositionType { - if podTimeOffset > 0 { + if timeOffset > 0 { return .midRoll - } else if podTimeOffset < 0 { + } else if timeOffset < 0 { return .postRoll } else { return .preRoll @@ -54,9 +54,9 @@ import Foundation adSystem: String, height: Int, width: Int, - podCount: Int, - podPosition: Int, - podTimeOffset: TimeInterval) { + totalAds: Int, + adPosition: Int, + timeOffset: TimeInterval) { self.adDescription = adDescription self.duration = adDuration @@ -67,9 +67,9 @@ import Foundation self.adSystem = adSystem self.height = height self.width = width - self.podCount = podCount - self.podPosition = podPosition - self.podTimeOffset = podTimeOffset + self.totalAds = totalAds + self.adPosition = adPosition + self.timeOffset = timeOffset } } diff --git a/Plugins/IMA/AdsPlugin.swift b/Plugins/IMA/AdsPlugin.swift index 03b79f3d..074ac836 100644 --- a/Plugins/IMA/AdsPlugin.swift +++ b/Plugins/IMA/AdsPlugin.swift @@ -24,14 +24,12 @@ protocol AdsPluginDelegate : class { } protocol AdsPlugin: PKPlugin, AVPictureInPictureControllerDelegate { - var dataSource: AdsPluginDataSource? { get set } - var delegate: AdsPluginDelegate? { get set } + weak var dataSource: AdsPluginDataSource? { get set } + weak var delegate: AdsPluginDelegate? { get set } var pipDelegate: AVPictureInPictureControllerDelegate? { get set } var isAdPlaying: Bool { get } func requestAds() - /// called when plugin need to start the ad playback on first ad play only - func startAd() func resume() func pause() func contentComplete() diff --git a/Plugins/IMA/IMAExtensions.swift b/Plugins/IMA/IMAExtensions.swift index 98016d78..099748ea 100644 --- a/Plugins/IMA/IMAExtensions.swift +++ b/Plugins/IMA/IMAExtensions.swift @@ -28,9 +28,9 @@ extension PKAdInfo { adSystem: ad.adSystem, height: Int(ad.height), width: Int(ad.width), - podCount: Int(ad.adPodInfo.totalAds), - podPosition: Int(ad.adPodInfo.adPosition), - podTimeOffset: ad.adPodInfo.timeOffset + totalAds: Int(ad.adPodInfo.totalAds), + adPosition: Int(ad.adPodInfo.adPosition), + timeOffset: ad.adPodInfo.timeOffset ) } } diff --git a/Plugins/IMA/IMAPlugin.swift b/Plugins/IMA/IMAPlugin.swift index 89ffe35c..befffea5 100644 --- a/Plugins/IMA/IMAPlugin.swift +++ b/Plugins/IMA/IMAPlugin.swift @@ -40,7 +40,8 @@ enum IMAState: Int, StateProtocol { weak var delegate: AdsPluginDelegate? weak var pipDelegate: AVPictureInPictureControllerDelegate? - var stateMachine = BasicStateMachine(initialState: IMAState.start, allowTransitionToInitialState: false) + /// The IMA plugin state machine + private var stateMachine = BasicStateMachine(initialState: IMAState.start, allowTransitionToInitialState: false) private var adsManager: IMAAdsManager? private var renderingSettings: IMAAdsRenderingSettings! = IMAAdsRenderingSettings() @@ -174,15 +175,6 @@ enum IMAState: Int, StateProtocol { PKLog.trace("request Ads") } - func startAd() { - self.stateMachine.set(state: .adsLoadedAndPlay) - if self.adsManager == nil { - self.adsManager!.initialize(with: self.renderingSettings) - PKLog.debug("ads manager set") - self.notifyAdCuePoints(fromAdsManager: self.adsManager!) - } - } - func resume() { self.adsManager?.resume() } @@ -271,23 +263,23 @@ enum IMAState: Int, StateProtocol { switch event.type { // Ad break, will be called before each scheduled ad break. Ad breaks may contain more than 1 ad. + // `event.ad` is not available at this point do not use it here. case .AD_BREAK_READY: - if let ad = event.ad, PKAdInfo(ad: ad).positionType == .preRoll, currentState == .adsRequestTimedOut || currentState == .contentPlaying { + self.notify(event: AdEvent.AdBreakReady()) + guard canPlayAd(forState: currentState) else { return } + self.start(adsManager: adsManager) + case .LOADED: + if shouldDiscard(ad: event.ad, currentState: currentState) { adsManager.discardAdBreak() } else { - let event = event.ad != nil ? AdEvent.AdBreakReady(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdBreakReady() - self.notify(event: event) - guard currentState == .adsLoadedAndPlay || currentState == .contentPlaying else { return } + let adEvent = event.ad != nil ? AdEvent.AdLoaded(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdLoaded() + self.notify(event: adEvent) + // single ad only fires `LOADED` without `AD_BREAK_READY`. + // if we have more than one ad don't start the manager, it will be handled in `AD_BREAK_READY` + guard adsManager.adCuePoints.count == 0 else { return } + guard canPlayAd(forState: currentState) else { return } self.start(adsManager: adsManager) } - case .LOADED: - let event = event.ad != nil ? AdEvent.AdLoaded(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdLoaded() - self.notify(event: event) - // single ad only fires `LOADED` without `AD_BREAK_READY`. - // if we have more than one ad don't handle the event, it will be handled in `AD_BREAK_READY` - guard adsManager.adCuePoints.count == 0 else { return } - guard self.stateMachine.getState() == .adsLoadedAndPlay || currentState == .contentPlaying else { return } - self.start(adsManager: adsManager) case .STARTED: self.stateMachine.set(state: .adsPlaying) let event = event.ad != nil ? AdEvent.AdStarted(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdStarted() @@ -440,6 +432,30 @@ enum IMAState: Int, StateProtocol { self.requestTimeoutTimer = nil } + /// called when plugin need to start the ad playback on first ad play only + private func startAd() { + self.stateMachine.set(state: .adsLoadedAndPlay) + self.initAdsManager() + } + + /// protects against cases where the ads manager will load after timeout. + /// this way we will only start ads when ads loaded and play() was used or when we came from content playing. + private func canPlayAd(forState state: IMAState) -> Bool { + if state == .adsLoadedAndPlay || state == .contentPlaying { + return true + } + return false + } + + private func shouldDiscard(ad: IMAAd, currentState: IMAState) -> Bool { + let adInfo = PKAdInfo(ad: ad) + let isPreRollInvalid = adInfo.positionType == .preRoll && (currentState == .adsRequestTimedOut || currentState == .contentPlaying) + if isPreRollInvalid { + return true + } + return false + } + /************************************************************/ // MARK: - AVPictureInPictureControllerDelegate /************************************************************/ From b1a31b86e16b52e2c62edeaf543fc54cc3098626 Mon Sep 17 00:00:00 2001 From: Gal Orlanczyk Date: Tue, 18 Apr 2017 16:46:26 +0300 Subject: [PATCH 3/3] #FEM-1261 * Added delayed prepare for ads player decorator. * Added discard checks for IMAPlugin ads playing to know when to discard ads. --- Classes/PKStateMachine.swift | 9 ++- Plugins/IMA/AdsEnabledPlayerController.swift | 63 ++++++++++++++++++-- Plugins/IMA/AdsPlugin.swift | 8 +++ Plugins/IMA/IMAConfig.swift | 16 +++-- Plugins/IMA/IMAPlugin.swift | 42 +++++++++---- 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/Classes/PKStateMachine.swift b/Classes/PKStateMachine.swift index 3f4962bc..4cf67645 100644 --- a/Classes/PKStateMachine.swift +++ b/Classes/PKStateMachine.swift @@ -53,9 +53,12 @@ class BasicStateMachine { PKLog.error("\(String(describing: type(of: self))) was set to initial state, this is not allowed") return } - self.state = state - DispatchQueue.main.async { - self.onStateChange?(state) + // only set state when changed + if self.state != state { + self.state = state + DispatchQueue.main.async { + self.onStateChange?(state) + } } } } diff --git a/Plugins/IMA/AdsEnabledPlayerController.swift b/Plugins/IMA/AdsEnabledPlayerController.swift index fab39870..cc8db43e 100644 --- a/Plugins/IMA/AdsEnabledPlayerController.swift +++ b/Plugins/IMA/AdsEnabledPlayerController.swift @@ -11,14 +11,36 @@ import UIKit import AVFoundation import AVKit +/// `AdsPlayerState` represents `AdsEnabledPlayerController` state machine states. +enum AdsPlayerState: Int, StateProtocol { + /// initial state. + case start = 0 + /// when prepare was requested for the first time and it is stalled until ad started (preroll) / faliure or content resume + case waitingForPrepare + /// a moment before we called prepare until prepare() was finished (the sychornos code only not async tasks) + case preparing + /// Indicates when prepare() was finished (the sychornos code only not async tasks) + case prepared +} + class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPluginDataSource { enum PlayType { case play, resume } + /// The ads player state machine. + private var stateMachine = BasicStateMachine(initialState: AdsPlayerState.start, allowTransitionToInitialState: true) + + /// The media config to prepare the player with. + /// Uses @NSCopying in order to make a copy whenever set with new value. + @NSCopying private var prepareMediaConfig: MediaConfig! + /// indicates if play was used, if `play()` or `resume()` was called we set this to true. - var isPlayEnabled = false + private var isPlayEnabled = false + + /// a semaphore to make sure prepare calling will not be reached from 2 threads by mistake. + private let prepareSemaphore = DispatchSemaphore(value: 1) /// when playing post roll google sends content resume when finished. /// In our case we need to prevent sending play/resume to the player because the content already ended. @@ -48,10 +70,10 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl } } - // TODO:: finilize prepare override func prepare(_ config: MediaConfig) { - super.prepare(config) - + self.stop() + self.stateMachine.set(state: .waitingForPrepare) + self.prepareMediaConfig = config self.adsPlugin.requestAds() } @@ -75,8 +97,9 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl } override func stop() { - self.adsPlugin.destroyManager() + self.stateMachine.set(state: .start) super.stop() + self.adsPlugin.destroyManager() self.isPlayEnabled = false self.shouldPreventContentResume = false } @@ -95,18 +118,24 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl return self.delegate!.playerShouldPlayAd(self) } + var adsPluginStartTime: TimeInterval { + return self.prepareMediaConfig?.startTime ?? 0 + } + /************************************************************/ // MARK: - AdsPluginDelegate /************************************************************/ func adsPlugin(_ adsPlugin: AdsPlugin, loaderFailedWith error: String) { if self.isPlayEnabled { + self.preparePlayerIfNeeded() super.play() self.adsPlugin.didPlay() } } func adsPlugin(_ adsPlugin: AdsPlugin, managerFailedWith error: String) { + self.preparePlayerIfNeeded() super.play() self.adsPlugin.didPlay() } @@ -117,9 +146,15 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl super.pause() case let e where type(of: e) == AdEvent.adDidRequestResume: if !self.shouldPreventContentResume { + self.preparePlayerIfNeeded() super.resume() } case let e where type(of: e) == AdEvent.adResumed: self.isPlayEnabled = true + case let e where type(of: e) == AdEvent.adStarted: + // when starting to play pre roll start preparing the player. + if event.adInfo?.positionType == .preRoll { + self.preparePlayerIfNeeded() + } case let e where type(of: e) == AdEvent.adLoaded || type(of: e) == AdEvent.adBreakReady: if self.shouldPreventContentResume == true { return } // no need to handle twice if already true if event.adInfo?.positionType == .postRoll { @@ -132,12 +167,30 @@ class AdsEnabledPlayerController : PlayerDecoratorBase, AdsPluginDelegate, AdsPl func adsRequestTimedOut(shouldPlay: Bool) { if shouldPlay { + self.preparePlayerIfNeeded() self.play() } } func play(_ playType: PlayType) { + self.preparePlayerIfNeeded() playType == .play ? super.play() : super.resume() self.adsPlugin.didPlay() } + + /************************************************************/ + // MARK: - Private + /************************************************************/ + + /// prepare the player only if wasn't prepared yet. + private func preparePlayerIfNeeded() { + self.prepareSemaphore.wait() // use semaphore to make sure will not be called from more than one thread by mistake. + if self.stateMachine.getState() == .waitingForPrepare { + self.stateMachine.set(state: .preparing) + PKLog.debug("will prepare player") + super.prepare(self.prepareMediaConfig) + self.stateMachine.set(state: .prepared) + } + self.prepareSemaphore.signal() + } } diff --git a/Plugins/IMA/AdsPlugin.swift b/Plugins/IMA/AdsPlugin.swift index 074ac836..3643bee2 100644 --- a/Plugins/IMA/AdsPlugin.swift +++ b/Plugins/IMA/AdsPlugin.swift @@ -11,6 +11,8 @@ import AVKit protocol AdsPluginDataSource : class { func adsPluginShouldPlayAd(_ adsPlugin: AdsPlugin) -> Bool + /// the player's media config start time. + var adsPluginStartTime: TimeInterval { get } } protocol AdsPluginDelegate : class { @@ -27,12 +29,18 @@ protocol AdsPlugin: PKPlugin, AVPictureInPictureControllerDelegate { weak var dataSource: AdsPluginDataSource? { get set } weak var delegate: AdsPluginDelegate? { get set } var pipDelegate: AVPictureInPictureControllerDelegate? { get set } + /// is ad playing currently. var isAdPlaying: Bool { get } + /// request ads from the server. func requestAds() + /// resume ad func resume() + /// pause ad func pause() + /// ad content complete func contentComplete() + /// destroy the ads manager func destroyManager() /// called after player called `super.play()` func didPlay() diff --git a/Plugins/IMA/IMAConfig.swift b/Plugins/IMA/IMAConfig.swift index ed428c0f..7332f3be 100644 --- a/Plugins/IMA/IMAConfig.swift +++ b/Plugins/IMA/IMAConfig.swift @@ -11,22 +11,20 @@ import GoogleInteractiveMediaAds @objc public class IMAConfig: NSObject { - @objc public var language: String = "en" - @objc public var enableBackgroundPlayback: Bool { - return true - } + @objc public let enableBackgroundPlayback = true // defaulted to false, because otherwise ad breaks events will not happen. // we need to have control on whether ad break will start playing or not using `Loaded` event is not enough. - // (will also need more safety checks because loaded will happen more than once). - @objc public var autoPlayAdBreaks: Bool { - return false - } + // (will also need more safety checks for loaded because loaded will happen more than once). + @objc public let autoPlayAdBreaks = false + + @objc public var language: String = "en" @objc public var videoBitrate = kIMAAutodetectBitrate @objc public var videoMimeTypes: [Any]? @objc public var adTagUrl: String = "" @objc public var companionView: UIView? @objc public var webOpenerPresentingController: UIViewController? - @objc public var requestTimeoutInterval: TimeInterval = 5 + /// ads request timeout interval, when ads request will take more then this time will resume content. + @objc public var requestTimeoutInterval: TimeInterval = IMAPlugin.defaultTimeoutInterval /// enables debug mode on IMA SDK which will output detailed log information to the console. /// The default value is false. @objc public var enableDebugMode: Bool = false diff --git a/Plugins/IMA/IMAPlugin.swift b/Plugins/IMA/IMAPlugin.swift index befffea5..8d7eb6e5 100644 --- a/Plugins/IMA/IMAPlugin.swift +++ b/Plugins/IMA/IMAPlugin.swift @@ -32,6 +32,9 @@ enum IMAState: Int, StateProtocol { @objc public class IMAPlugin: BasePlugin, PKPluginWarmUp, PlayerDecoratorProvider, AdsPlugin, IMAAdsLoaderDelegate, IMAAdsManagerDelegate, IMAWebOpenerDelegate, IMAContentPlayhead { + /// the default timeout interval for ads request. + static let defaultTimeoutInterval: TimeInterval = 5 + weak var dataSource: AdsPluginDataSource? { didSet { PKLog.debug("data source set") @@ -43,20 +46,19 @@ enum IMAState: Int, StateProtocol { /// The IMA plugin state machine private var stateMachine = BasicStateMachine(initialState: IMAState.start, allowTransitionToInitialState: false) + private static var loader: IMAAdsLoader! private var adsManager: IMAAdsManager? private var renderingSettings: IMAAdsRenderingSettings! = IMAAdsRenderingSettings() - private static var loader: IMAAdsLoader! - private var pictureInPictureProxy: IMAPictureInPictureProxy? private var loadingView: UIView? + // we must have config error will be thrown otherwise private var config: IMAConfig! - private var timer: Timer? /// timer for checking IMA requests timeout. private var requestTimeoutTimer: Timer? /// the request timeout interval - private var requestTimeoutInterval: TimeInterval = 5 + private var requestTimeoutInterval: TimeInterval = IMAPlugin.defaultTimeoutInterval /************************************************************/ // MARK: - IMAContentPlayhead @@ -91,6 +93,7 @@ enum IMAState: Int, StateProtocol { try super.init(player: player, pluginConfig: pluginConfig, messageBus: messageBus) if let adsConfig = pluginConfig as? IMAConfig { self.config = adsConfig + self.requestTimeoutInterval = adsConfig.requestTimeoutInterval if IMAPlugin.loader == nil { self.setupLoader(with: adsConfig) } @@ -118,7 +121,6 @@ enum IMAState: Int, StateProtocol { // TODO:: finilize update config & updateMedia logic public override func onUpdateMedia(mediaConfig: MediaConfig) { - PKLog.debug("mediaConfig: " + String(describing: mediaConfig)) super.onUpdateMedia(mediaConfig: mediaConfig) } @@ -265,16 +267,20 @@ enum IMAState: Int, StateProtocol { // Ad break, will be called before each scheduled ad break. Ad breaks may contain more than 1 ad. // `event.ad` is not available at this point do not use it here. case .AD_BREAK_READY: - self.notify(event: AdEvent.AdBreakReady()) - guard canPlayAd(forState: currentState) else { return } - self.start(adsManager: adsManager) + if shouldDiscardAd() { + PKLog.debug("discard Ad Break") + } else { + self.notify(event: AdEvent.AdBreakReady()) + guard canPlayAd(forState: currentState) else { return } + self.start(adsManager: adsManager) + } + // single ad only fires `LOADED` without `AD_BREAK_READY`. case .LOADED: if shouldDiscard(ad: event.ad, currentState: currentState) { - adsManager.discardAdBreak() + self.discardAdBreak(adsManager: adsManager) } else { let adEvent = event.ad != nil ? AdEvent.AdLoaded(adInfo: PKAdInfo(ad: event.ad)) : AdEvent.AdLoaded() self.notify(event: adEvent) - // single ad only fires `LOADED` without `AD_BREAK_READY`. // if we have more than one ad don't start the manager, it will be handled in `AD_BREAK_READY` guard adsManager.adCuePoints.count == 0 else { return } guard canPlayAd(forState: currentState) else { return } @@ -447,15 +453,29 @@ enum IMAState: Int, StateProtocol { return false } + private func shouldDiscardAd() -> Bool { + if currentTime < self.dataSource?.adsPluginStartTime ?? 0 { + return true + } + return false + } + private func shouldDiscard(ad: IMAAd, currentState: IMAState) -> Bool { let adInfo = PKAdInfo(ad: ad) + let isStartTimeInvalid = adInfo.positionType != .postRoll && adInfo.timeOffset < self.dataSource?.adsPluginStartTime ?? 0 let isPreRollInvalid = adInfo.positionType == .preRoll && (currentState == .adsRequestTimedOut || currentState == .contentPlaying) - if isPreRollInvalid { + if isStartTimeInvalid || isPreRollInvalid { return true } return false } + private func discardAdBreak(adsManager: IMAAdsManager) { + PKLog.debug("discard Ad Break") + adsManager.discardAdBreak() + self.adsManagerDidRequestContentResume(adsManager) + } + /************************************************************/ // MARK: - AVPictureInPictureControllerDelegate /************************************************************/