UNPKG

npaw-plugin-adapters

Version:
378 lines (329 loc) 14.6 kB
/** * 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(); } }