@playkit-js/kaltura-player-js
Version:
[](https://github.com/kaltura/kaltura-player-js/actions/workflows/run_canary.yaml) [ • 20.9 kB
text/typescript
import { KalturaPlayer as Player } from '../../kaltura-player';
import { Ad } from '../ads';
import { AdBreak } from '../ads';
import {
Error,
EventManager,
AdEventType,
FakeEvent,
FakeEventTarget,
Html5EventType,
CustomEventType,
getLogger,
BaseMiddleware,
Utils
} from '@playkit-js/playkit-js';
import { PrebidManager } from '../ads/prebid-manager';
import { AdLayoutMiddleware } from '../ads/ad-layout-middleware';
import { KPAdBreakObject, KPAdObject, KPAdPod } from '../../types/ads/advertising';
import { IAdsController } from '../../types/ads/ads-controller';
import { IAdsPluginController } from '../../types/ads/ads-plugin-controller';
interface RunTimeAdBreakObject extends KPAdBreakObject {
played: boolean;
loadedPromise: Promise<any>;
}
/**
* @class AdsController
* @param {Player} player - The player.
* @param {IAdsController} adsPluginController - The controller of the current ads plugin instance.
*/
class AdsController extends FakeEventTarget implements IAdsController {
private static _logger: any = getLogger('AdsController');
private readonly _player: Player;
private _adsPluginControllers: Array<IAdsPluginController>;
private _allAdsCompleted!: boolean;
private _eventManager: EventManager;
private _liveEventManager: EventManager;
private _adBreaksLayout!: Array<number | string>;
private _adBreak: AdBreak | undefined;
private _ad: Ad | undefined;
private _adPlayed!: boolean;
private _snapback!: number;
private _configAdBreaks!: Array<RunTimeAdBreakObject>;
private _adIsLoading!: boolean;
private _isAdPlaying!: boolean;
private _middleware!: AdLayoutMiddleware;
private readonly _prebidManager!: PrebidManager;
private _liveSeeking!: boolean;
public prerollReady!: Promise<any>;
constructor(player: Player, adsPluginControllers: Array<IAdsPluginController>) {
super();
this._player = player;
this._eventManager = new EventManager();
this._liveEventManager = new EventManager();
this._adsPluginControllers = adsPluginControllers;
this._prebidManager = new PrebidManager(this._player.config.advertising && this._player.config.advertising.prebid);
this._init();
}
/**
* @instance
* @memberof AdsController
* @returns {boolean} - Whether all ads completed.
*/
public get allAdsCompleted(): boolean {
return this._allAdsCompleted;
}
/**
* @instance
* @memberof AdsController
* @returns {boolean} - Whether an ad is playing.
*/
public isAdPlaying(): boolean {
return this.isAdBreak() && this._isAdPlaying;
}
/**
* @instance
* @memberof AdsController
* @returns {boolean} - Whether we're in an ad break.
*/
public isAdBreak(): boolean {
return !!this._adBreak;
}
/**
* @instance
* @memberof AdsController
* @returns {Array<number|string>} - The ad breaks layout (cue points).
*/
public getAdBreaksLayout(): Array<number | string> {
return this._adBreaksLayout;
}
/**
* @instance
* @memberof AdsController
* @returns {?AdBreak} - Gets the current ad break data.
*/
public getAdBreak(): AdBreak | undefined {
return this._adBreak;
}
/**
* @instance
* @memberof AdsController
* @returns {?Ad} - Gets the current ad data.
*/
public getAd(): Ad | undefined {
return this._ad;
}
/**
* Skip on an ad.
* @instance
* @memberof AdsController
* @returns {void}
*/
public skipAd(): void {
const activeController = this._adsPluginControllers.find((controller) => controller.active);
activeController && activeController.skipAd();
}
/**
* Play an ad on demand.
* @param {KPAdPod} adPod - The ad pod play.
* @instance
* @memberof AdsController
* @returns {void}
*/
public playAdNow(adPod: KPAdPod): void {
if (this.isAdBreak()) {
AdsController._logger.warn('Tried to call playAdNow during an ad break');
} else {
const loadPrebidAd = Promise.all(adPod.map((ad) => this._getPrebidAds(ad)));
this._playAdBreak({
position: this._player.currentTime || 0,
ads: adPod,
played: false,
loadedPromise: loadPrebidAd
});
}
}
public getMiddleware(): BaseMiddleware {
return this._middleware ? this._middleware : (this._middleware = new AdLayoutMiddleware(this));
}
private _init(): void {
this._initMembers();
this._addBindings();
}
private _initMembers(): void {
this._allAdsCompleted = true;
this._adBreaksLayout = [];
this._adBreak = undefined;
this._ad = undefined;
this._adPlayed = false;
this._snapback = 0;
this._adIsLoading = false;
this._isAdPlaying = false;
this._liveSeeking = false;
}
private _addBindings(): void {
this._eventManager.listen(this._player, CustomEventType.SOURCE_SELECTED, () => this._handleConfiguredAdBreaks());
this._eventManager.listen(this._player, AdEventType.AD_MANIFEST_LOADED, (event) => this._onAdManifestLoaded(event));
this._eventManager.listen(this._player, AdEventType.AD_BREAK_START, (event) => this._onAdBreakStart(event));
this._eventManager.listen(this._player, AdEventType.AD_LOADED, () => this._onAdLoaded());
this._eventManager.listen(this._player, AdEventType.AD_STARTED, (event) => this._onAdStarted(event));
this._eventManager.listen(this._player, AdEventType.AD_COMPLETED, () => (this._isAdPlaying = false));
this._eventManager.listen(this._player, AdEventType.AD_BREAK_END, () => this._onAdBreakEnd());
this._eventManager.listen(this._player, AdEventType.ADS_COMPLETED, () => this._onAdsCompleted());
this._eventManager.listen(this._player, AdEventType.AD_ERROR, (event) => this._onAdError(event));
this._eventManager.listen(this._player, CustomEventType.PLAYER_RESET, () => this._reset());
this._eventManager.listen(this._player, CustomEventType.PLAYER_DESTROY, () => this._destroy());
this._eventManager.listenOnce(this._player, Html5EventType.ENDED, () => this._onEnded());
this._eventManager.listenOnce(this._player, CustomEventType.PLAYBACK_ENDED, () => this._onPlaybackEnded());
this._eventManager.listen(this._player, AdEventType.AD_RESUMED, () => (this._isAdPlaying = true));
this._eventManager.listen(this._player, AdEventType.AD_PAUSED, () => (this._isAdPlaying = false));
}
private _handleConfiguredAdBreaks(): void {
const playAdsAfterTime = this._player.config.advertising.playAdsAfterTime || this._player.config.sources.startTime;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._configAdBreaks = this._player.config.advertising.adBreaks
.filter(
(adBreak) =>
(typeof adBreak.every === 'number' || typeof adBreak.position === 'number' || typeof adBreak.percentage === 'number') && adBreak.ads.length
)
.map((adBreak) => {
this._validateOneTimeConfig(adBreak);
let position = adBreak.position;
adBreak.percentage === 0 && (position = 0);
adBreak.percentage === 100 && (position = -1);
adBreak.every && (position = adBreak.every);
const played = this._player.isLive() ? position < playAdsAfterTime! : position <= playAdsAfterTime!;
return {
position,
percentage: adBreak.percentage,
every: adBreak.every,
ads: adBreak.ads.slice(),
played: -1 < position && played
};
});
if (this._configAdBreaks.length) {
this._dispatchAdManifestLoaded();
this._handlePrebidAdConfig();
this._handleConfiguredPreroll();
this._eventManager.listenOnce(this._player, Html5EventType.DURATION_CHANGE, () => {
this._player.isLive()
? this._eventManager.listenOnce(this._player, Html5EventType.SEEKING, () => {
this._pushNextAdsForLive(this._configAdBreaks, (adBreak) => this._player.currentTime + adBreak.every);
this._attachLiveSeekedHandler();
})
: this._handleEveryAndPercentage();
this._configAdBreaks.sort((a, b) => a.position - b.position);
if (this._configAdBreaks.some((adBreak) => adBreak.position > 0)) {
this._handleConfiguredMidrolls();
}
});
} else {
this.prerollReady = Promise.resolve();
}
}
private _validateOneTimeConfig(adBreak: KPAdBreakObject): void {
if (typeof adBreak.position === 'number') {
if (typeof adBreak.percentage === 'number') {
AdsController._logger.warn(`Validate ad break - ignore percentage ${adBreak.percentage} as position ${adBreak.position} configured`);
delete adBreak.percentage;
}
if (typeof adBreak.every === 'number') {
AdsController._logger.warn(`Validate ad break - ignore every ${adBreak.every} as position ${adBreak.position} configured`);
delete adBreak.every;
}
}
if (typeof adBreak.percentage === 'number' && typeof adBreak.every === 'number') {
AdsController._logger.warn(`Validate ad break - ignore every ${adBreak.every} as percentage ${adBreak.percentage} configured`);
delete adBreak.every;
}
}
private _dispatchAdManifestLoaded(): void {
const adBreaksPosition = Array.from(
new Set(
this._configAdBreaks.map(
(adBreak) =>
(adBreak.every && adBreak.every + 's') || (typeof adBreak.percentage === 'number' && adBreak.percentage + '%') || adBreak.position
)
)
);
AdsController._logger.debug(AdEventType.AD_MANIFEST_LOADED, adBreaksPosition);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._player.dispatchEvent(new FakeEvent(AdEventType.AD_MANIFEST_LOADED, { adBreaksPosition }));
if (this._player.hasService('timeline') && this._player.config.advertising.showAdBreakCuePoint) {
adBreaksPosition.forEach((position) => {
this._player.getService('timeline').addCuePoint({
time: position !== -1 ? position : Infinity,
...this._player.config.advertising.adBreakCuePointStyle
});
});
}
}
private _handlePrebidAdConfig(): void {
this._prebidManager &&
this._configAdBreaks
.filter((adBreak) => !adBreak.played)
.map((adBreak) => {
const loadPrebidAd = Promise.all(adBreak.ads.map((ad) => this._getPrebidAds(ad)));
adBreak.loadedPromise = loadPrebidAd;
loadPrebidAd.then((ads) => (adBreak.ads = ads));
});
}
private _getPrebidAds(ad: KPAdObject): Promise<any> {
return new Promise((resolve) => {
if (ad.prebid && this._prebidManager) {
const prebidConfig = Utils.Object.mergeDeep({}, ad.prebid, this._player.config.advertising.prebid);
const promiseLoad = this._prebidManager.load(prebidConfig);
promiseLoad
.then((bids) => {
const vastUrls = bids.map((bid) => bid && bid.vastUrl);
ad.url = vastUrls.concat(ad.url);
resolve(ad);
})
.catch(() => {
resolve(ad);
});
} else {
resolve(ad);
}
});
}
private _handleConfiguredPreroll(): void {
const prerolls = this._configAdBreaks.filter((adBreak) => adBreak.position === 0 && !adBreak.played);
const mergedPreroll = this._mergeAdBreaks(prerolls);
this.prerollReady = mergedPreroll && mergedPreroll.loadedPromise ? mergedPreroll.loadedPromise : Promise.resolve();
mergedPreroll && this._playAdBreak(mergedPreroll);
}
private _handleEveryAndPercentage(): void {
this._configAdBreaks.forEach((adBreak) => {
if (this._player.duration && adBreak.every) {
let currentPosition = 2 * adBreak.every;
while (currentPosition <= this._player.duration) {
this._configAdBreaks.push({
position: currentPosition,
ads: adBreak.ads,
played: false,
loadedPromise: Promise.resolve()
});
currentPosition += adBreak.every;
}
} else {
if (this._player.duration && adBreak.percentage && !adBreak.position) {
adBreak.position = Math.floor((this._player.duration * adBreak.percentage) / 100);
}
}
});
}
private _attachLiveSeekedHandler(): void {
this._eventManager.listenOnce(this._player, CustomEventType.FIRST_PLAYING, () => {
this._eventManager.listen(this._player, Html5EventType.SEEKING, () => {
this._liveSeeking = true;
});
this._eventManager.listen(this._player, Html5EventType.SEEKED, () => {
this._liveSeeking = false;
this._pushNextAdsForLive(this._configAdBreaks, (adBreak) => this._player.currentTime + adBreak.every);
});
});
}
private _pushNextAdsForLive(iterator: Array<RunTimeAdBreakObject>, calcPositionCallback: (params: any) => void): void {
this._liveEventManager.removeAll();
const liveConfigAdBreaks = [];
iterator.forEach((adBreak) => {
if (![-1, 0].includes(adBreak.position)) {
const { every, ads } = adBreak;
const position = calcPositionCallback(adBreak);
const nextAdBreak = {
every,
position,
ads,
played: false,
loadedPromise: Promise.resolve()
};
AdsController._logger.debug('Pushing next ad for live', nextAdBreak);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
liveConfigAdBreaks.push(nextAdBreak);
}
});
if (liveConfigAdBreaks.length) {
this._configAdBreaks = [...liveConfigAdBreaks, ...this._configAdBreaks.filter((adBreak) => adBreak.position === -1)];
}
}
private _handleConfiguredMidrolls(): void {
this._eventManager.listen(this._player, Html5EventType.TIME_UPDATE, () => {
if (!this._player.paused && !this._liveSeeking) {
const adBreaks = this._configAdBreaks.filter(
(adBreak) =>
!adBreak.played && this._player.currentTime && adBreak.position <= this._player.currentTime && adBreak.position > this._snapback
);
if (adBreaks.length) {
const maxPosition = adBreaks[adBreaks.length - 1].position;
const lastAdBreaks = adBreaks.filter((adBreak) => adBreak.position === maxPosition);
if (this._player.isLive()) {
const returnToLive =
!this._player.isDvr() ||
(this._player.isOnLiveEdge() &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this._player.config.advertising.returnToLive);
returnToLive
? this._handleReturnToLive(lastAdBreaks)
: this._pushNextAdsForLive(
lastAdBreaks,
(adBreak) => (this._player.isOnLiveEdge() ? this._player.currentTime : adBreak.position) + adBreak.every
);
} else {
this._snapback = maxPosition;
AdsController._logger.debug(`Set snapback value ${this._snapback}`);
this._eventManager.listen(this._player, Html5EventType.SEEKED, () => {
const nextPlayedAdBreakIndex = this._configAdBreaks.findIndex(
(adBreak) => adBreak.played && typeof this._player.currentTime === 'number' && this._player.currentTime < adBreak.position
);
if (nextPlayedAdBreakIndex > 0 && !this._configAdBreaks[nextPlayedAdBreakIndex - 1].played) {
this._snapback = 0;
AdsController._logger.debug('Reset snapback value');
}
});
}
const mergedAdBreak = this._mergeAdBreaks(lastAdBreaks);
mergedAdBreak && this._playAdBreak(mergedAdBreak);
}
}
});
}
private _handleReturnToLive(adBreaks: Array<RunTimeAdBreakObject>): void {
this._liveEventManager.listenOnce(this._player, AdEventType.AD_ERROR, () => {
this._pushNextAdsForLive(adBreaks, (adBreak) => (this._player.isOnLiveEdge() ? this._player.currentTime : adBreak.position) + adBreak.every);
});
this._liveEventManager.listenOnce(this._player, AdEventType.AD_BREAK_END, () => {
this._player.seekToLiveEdge();
});
}
private _playAdBreak(adBreak: RunTimeAdBreakObject): void {
const adController = this._adsPluginControllers.find((controller) => typeof controller.playAdNow === 'function');
if (adController) {
adBreak.played = true;
this._adIsLoading = true;
AdsController._logger.debug(`Playing ad break positioned in ${adBreak.position}`);
// $FlowFixMe
adBreak.loadedPromise.then(() => adController.playAdNow(adBreak.ads));
} else {
AdsController._logger.warn('No ads plugin registered');
}
}
private _onAdManifestLoaded(event: FakeEvent): void {
this._adBreaksLayout = Array.from(new Set(this._adBreaksLayout.concat(event.payload.adBreaksPosition))).sort();
this._allAdsCompleted = false;
}
private _onAdBreakStart(event: FakeEvent): void {
this._adBreak = event.payload.adBreak;
}
private _onAdLoaded(): void {
this._adIsLoading = false;
}
private _onAdStarted(event: FakeEvent): void {
this._ad = event.payload.ad;
this._adPlayed = true;
this._isAdPlaying = true;
}
private _onAdBreakEnd(): void {
this._adBreak = undefined;
this._ad = undefined;
}
private _onAdsCompleted(): void {
if (this._adsPluginControllers.every((controller) => controller.done) && this._configAdBreaks.every((adBreak) => adBreak.played)) {
this._allAdsCompleted = true;
AdsController._logger.debug(AdEventType.ALL_ADS_COMPLETED);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.dispatchEvent(new FakeEvent(AdEventType.ALL_ADS_COMPLETED));
}
}
private _onAdError(event: FakeEvent): void {
this._adIsLoading = false;
if (event.payload.severity === Error.Severity.CRITICAL) {
this._isAdPlaying = false;
if (this._adsPluginControllers.every((controller) => controller.done) && this._configAdBreaks.every((adBreak) => adBreak.played)) {
this._allAdsCompleted = true;
if (this._adPlayed) {
AdsController._logger.debug(AdEventType.ALL_ADS_COMPLETED);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.dispatchEvent(new FakeEvent(AdEventType.ALL_ADS_COMPLETED));
}
}
}
}
private _isBumper(controller: IAdsPluginController): boolean {
return controller.name === 'bumper';
}
private _onEnded(): void {
if (this._adIsLoading) {
return;
}
const bumperCtrl = this._adsPluginControllers.find((controller) => this._isBumper(controller));
const adCtrl = this._adsPluginControllers.find((controller) => !this._isBumper(controller) && !controller.done);
const bumperCompleted =
bumperCtrl && typeof bumperCtrl.onPlaybackEnded === 'function'
? (): Promise<any> => bumperCtrl.onPlaybackEnded()
: (): Promise<any> => Promise.resolve();
const adCompleted =
adCtrl && typeof adCtrl.onPlaybackEnded === 'function' ? (): Promise<any> => adCtrl.onPlaybackEnded() : (): Promise<any> => Promise.resolve();
if (!(this._adBreaksLayout.includes(-1) || this._adBreaksLayout.includes('100%'))) {
this._allAdsCompleted = true;
}
// $FlowFixMe
bumperCompleted().finally(() => {
// $FlowFixMe
adCompleted().finally(() => this._handleConfiguredPostroll());
});
}
private _onPlaybackEnded(): void {
this._configAdBreaks.forEach((adBreak) => (adBreak.played = true));
}
private _handleConfiguredPostroll(): void {
const postrolls = this._configAdBreaks.filter((adBreak) => !adBreak.played && adBreak.position === -1);
if (postrolls.length) {
const mergedPostroll = this._mergeAdBreaks(postrolls);
mergedPostroll && this._playAdBreak(mergedPostroll);
}
this._configAdBreaks.forEach((adBreak) => (adBreak.played = true));
}
private _reset(): void {
this._eventManager.removeAll();
this._liveEventManager.removeAll();
this._init();
}
private _destroy(): void {
this._adsPluginControllers = [];
this._eventManager.destroy();
this._liveEventManager.destroy();
}
private _mergeAdBreaks(adBreaks: Array<RunTimeAdBreakObject>): RunTimeAdBreakObject | undefined {
if (adBreaks.length) {
adBreaks.forEach((adBreak) => (adBreak.played = true));
return {
position: adBreaks[0].position,
ads: adBreaks.reduce(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(result, adBreak) => result.concat(adBreak.ads),
[]
),
played: false,
loadedPromise: Promise.all(adBreaks.map((adBreak) => adBreak.loadedPromise))
};
}
}
}
export { AdsController };