npaw-plugin-adapters
Version:
NPAW's Plugin Adapters
378 lines (329 loc) • 14.6 kB
JavaScript
/**
* NP Player-SDK – Ads adapter for AWS MediaTailor (self-contained, SINGLE class).
*
* This version avoids the use of the `async / await` syntax so it can be
* executed on older devices or browsers that only support ES5-level promises.
*
* The class fetches the EMT ad-metadata manifest, builds an internal ad
* timeline and, driven by the HTML5 `<video>` timeupdate events, fires the
* NP-Analytics callbacks (fireStart, fireJoin, fireQuartile, fireStop, …).
*/
export default class MediatailorAdsAdapter {
/* ──────────────────────────────────────────────────────────────── */
/* Public helper methods (called by the NP core) */
/* ──────────────────────────────────────────────────────────────── */
isUsed () {
return true; // always available – the integrator decides when to use it
}
getVersion () {
return '7.0.2-mediatailor-jsclass';
}
getPlayerName () {
return 'AWS MediaTailor';
}
/* Generic getters – many are optional but useful for analytics. */
getPlayhead () {
if (this._currentAd && this._videoCurrentTime() != null) {
return this._videoCurrentTime() - this._currentAd.start;
}
return undefined;
}
getDuration () {
return this._currentAd ? this._currentAd.duration : undefined;
}
getPosition () {
if (!this._currentAd) return undefined;
// Use the break position if we're in a break
if (this._currentBreak && this._currentBreak.position !== undefined) {
return this._currentBreak.position;
}
// Fallback to calculating position (shouldn't happen if break tracking works correctly)
const AdsPos = this.getNpawReference().Constants.AdPosition;
if (this._currentAd.start <= 1) return AdsPos.Preroll;
const totalDur = this._videoDuration();
if (!isNaN(totalDur) &&
(this._currentAd.start + this._currentAd.duration + 1 >= totalDur)) {
return AdsPos.Postroll;
}
return AdsPos.Midroll;
}
getTitle () {
return this._currentAd ? this._currentAd.id : undefined;
}
getResource () {
return this._manifestUrl;
}
/**
* Allows the integrator to provide (or update) the manifest URL at any
* moment after the adapter has been created – typically right after the
* `registerAdsAdapter()` call:
*
* const adsAdapter = npawPlugin.registerAdsAdapter(player, Mediatailor);
* adsAdapter.setManifestUrl('https://…/tracking-url.json');
*
* If the fetch loop was already running it is reset.
*/
setManifestUrl (url) {
if (!url || typeof url !== 'string') return;
/* Stop any ongoing fetch polling loop */
if (this._fetchLoopId) {
clearTimeout(this._fetchLoopId);
this._fetchLoopId = null;
}
/* Reset state related to manifest parsing */
this._manifestUrl = url;
this._adsReady = false;
this._ads = [];
this._currentAd = null;
this._currentBreak = null;
this._fetchAttempts = 0;
/* Kick-off the fetch process */
this._scheduleNextFetch();
}
/* ──────────────────────────────────────────────────────────────── */
/* Listener registration / clean-up */
/* ──────────────────────────────────────────────────────────────── */
registerListeners () {
/* The core utility monitorPlayhead remains enabled */
if (typeof this.monitorPlayhead === 'function') {
this.monitorPlayhead(true, false);
}
/* Configuration parameters (may be overridden later) */
this._pollMs = Number(this.options?.pollMs || 1000);
this._maxFetchRetries = (this.options?.maxFetchRetries == null)
? Infinity
: Number(this.options.maxFetchRetries);
/* Initialise tracker state */
this._ads = [];
this._adsReady = false;
this._currentAd = null;
this._currentBreak = null;
this._fetchAttempts = 0;
this._fetchLoopId = null;
/* Attach playback listeners */
this._onTimeUpdate = this._onTimeUpdate.bind(this);
this._onVideoPause = this._onVideoPause.bind(this);
this._onVideoPlay = this._onVideoPlay.bind(this);
const video = this._videoElement();
if (video && video.addEventListener) {
video.addEventListener('timeupdate', this._onTimeUpdate);
video.addEventListener('pause', this._onVideoPause);
video.addEventListener('playing', this._onVideoPlay);
video.addEventListener('play', this._onVideoPlay);
}
/* NOTE:
* We *do not* start the fetch loop here because the manifest URL is
* now provided later via `setManifestUrl()`. The loop will kick-off
* as soon as that method is invoked.
*/
}
unregisterListeners () {
const video = this._videoElement();
if (video && video.removeEventListener) {
video.removeEventListener('timeupdate', this._onTimeUpdate);
video.removeEventListener('pause', this._onVideoPause);
video.removeEventListener('playing', this._onVideoPlay);
video.removeEventListener('play', this._onVideoPlay);
}
if (this._fetchLoopId) clearTimeout(this._fetchLoopId);
if (this.monitor) this.monitor.stop?.();
/* Reset all internal state */
this._ads = [];
this._adsReady = false;
this._currentAd = null;
this._currentBreak = null;
this._fetchAttempts = 0;
this._fetchLoopId = null;
}
/* ──────────────────────────────────────────────────────────────── */
/* Internal helper – video element access */
/* ──────────────────────────────────────────────────────────────── */
_videoElement () {
// Depending on the integration, `player` might be an HTMLVideoElement or
// a video.js instance (player.el()).
let v = this.player;
if (v && typeof v.el === 'function') v = v.el();
return v && v.currentTime != null ? v : null;
}
_videoCurrentTime () {
const v = this._videoElement();
return v ? v.currentTime : null;
}
_videoDuration () {
const v = this._videoElement();
if (!v) return NaN;
return typeof v.duration === 'function' ? v.duration() : v.duration;
}
/* ──────────────────────────────────────────────────────────────── */
/* Manifest fetching / parsing (Promise-based, no async/await) */
/* ──────────────────────────────────────────────────────────────── */
_fetchAdsOnce () {
const fetchFn = (typeof fetch !== 'undefined' ? fetch : window.fetch);
return fetchFn(this._manifestUrl, {
method : 'GET',
headers : { 'Content-Type': 'application/json' }
})
.then(function (response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' ' + response.statusText);
}
return response.json();
})
.then(function (json) {
this._parseAds(json);
if (this._ads.length > 0) {
this._adsReady = true;
} else {
throw new Error('Manifest fetched but contained 0 valid ads.');
}
}.bind(this));
}
_parseAds (trackingResponse) {
const parsed = [];
if (trackingResponse && Array.isArray(trackingResponse.avails)) {
trackingResponse.avails.forEach(function (avail) {
if (avail && Array.isArray(avail.ads)) {
avail.ads.forEach(function (ad) {
const id = ad && ad.adId;
const start = Number(ad && ad.startTimeInSeconds);
const duration = Number(ad && ad.durationInSeconds);
if (id != null && !isNaN(start) && !isNaN(duration) && duration > 0) {
parsed.push({ id: String(id), start: start, duration: duration });
}
});
}
});
}
parsed.sort(function (a, b) { return a.start - b.start; });
this._ads = parsed;
}
_scheduleNextFetch () {
/* Bail-out if we still don’t have a URL. */
if (!this._manifestUrl) {
console.warn('[MediaTailorAdapter] setManifestUrl(url) must be called before fetching.');
return;
}
if (this._adsReady) return;
if (this._fetchAttempts >= this._maxFetchRetries) return;
const attemptFetch = () => {
this._fetchAdsOnce()
.catch(function (err) {
this._fetchAttempts += 1;
if (this._fetchAttempts < this._maxFetchRetries) {
this._fetchLoopId = setTimeout(attemptFetch, this._pollMs);
} else {
console.warn('[MediaTailorAdapter] Max manifest retries reached:', err.message);
}
}.bind(this));
};
attemptFetch();
}
/* ──────────────────────────────────────────────────────────────── */
/* Ad timeline helpers */
/* ──────────────────────────────────────────────────────────────── */
_findAd (time) {
for (var i = 0; i < this._ads.length; i++) {
var ad = this._ads[i];
if (time >= ad.start && time < (ad.start + ad.duration)) {
return ad;
}
}
return null;
}
_isCorrelative (prevAd, nextAd) {
if (!prevAd || !nextAd) return false;
var tolerance = 0.1;
return Math.abs((prevAd.start + prevAd.duration) - nextAd.start) < tolerance;
}
/* ──────────────────────────────────────────────────────────────── */
/* Ad position determination helper */
/* ──────────────────────────────────────────────────────────────── */
_determineAdPosition (adStart, adDuration) {
const AdsPos = this.getNpawReference().Constants.AdPosition;
// Preroll: ads that start within first second
if (adStart <= 1) return AdsPos.Preroll;
// Postroll: ads that end near the content end
const totalDur = this._videoDuration();
if (!isNaN(totalDur) && (adStart + adDuration + 1 >= totalDur)) {
return AdsPos.Postroll;
}
// Everything else is midroll
return AdsPos.Midroll;
}
/* ──────────────────────────────────────────────────────────────── */
/* Playback event handlers */
/* ──────────────────────────────────────────────────────────────── */
_onTimeUpdate () {
if (!this._adsReady) return;
var currentTime = this._videoCurrentTime();
if (currentTime == null) return;
var oldAd = this._currentAd;
var newAdRaw = this._findAd(currentTime);
/* ------------ state transition (enter / exit ads) ------------ */
if ((oldAd && oldAd.id) !== (newAdRaw && newAdRaw.id)) {
/* Leaving an ad */
if (oldAd) {
this.fireStop({ adPlayhead: oldAd.duration });
if (!newAdRaw || !this._isCorrelative(oldAd, newAdRaw)) {
this.fireBreakStop();
this._currentBreak = null;
}
}
/* Entering a new ad */
if (newAdRaw) {
// Ensure video analytics is initialized before firing ad events
if (this._npawVideo && !this._npawVideo.isInitiated) {
this._npawVideo.fireInit();
}
// Set current ad data BEFORE firing events so getDuration() and getTitle() work correctly
this._currentAd = {
id : newAdRaw.id,
start : newAdRaw.start,
duration : newAdRaw.duration,
q1 : false,
mid : false,
q3 : false
};
var isJoining = this._currentBreak !== null && this._isCorrelative(oldAd, newAdRaw);
if (!isJoining) {
// Starting a new break - determine and store the position for the entire break
var breakPosition = this._determineAdPosition(newAdRaw.start, newAdRaw.duration);
this._currentBreak = {
start: newAdRaw.start,
position: breakPosition
};
this.fireBreakStart();
}
this.fireStart({ adPlayhead: '0' });
this.fireJoin({ adPlayhead: '0' });
} else {
this._currentAd = null;
}
}
/* ------------------- quartile tracking ----------------------- */
if (this._currentAd && this._currentAd.duration > 0) {
var prog = currentTime - this._currentAd.start;
var rel = prog / this._currentAd.duration;
if (!this._currentAd.q1 && rel >= 0.25) {
this._currentAd.q1 = true;
this.fireQuartile(1);
}
if (!this._currentAd.mid && rel >= 0.50) {
this._currentAd.mid = true;
this.fireQuartile(2);
}
if (!this._currentAd.q3 &&
rel >= 0.75 &&
currentTime < (this._currentAd.start + this._currentAd.duration)) {
this._currentAd.q3 = true;
this.fireQuartile(3);
}
}
}
_onVideoPause () {
if (this._currentAd) this.firePause();
}
_onVideoPlay () {
if (this._currentAd) this.fireResume();
}
}