npaw-plugin-adapters
Version:
NPAW's Plugin Adapters
580 lines (482 loc) • 18.5 kB
JavaScript
/* global YospaceAdManagement */
/**
* NP Player-SDK – Ads adapter for Yospace (SSAI).
*
* This adapter listens to events from the Yospace Ad Management SDK via an AnalyticEventObserver.
* To use this adapter, you must register it with the NPAW Plugin and then provide the Yospace session
* once it is initialized.
*
* Example:
* const adsAdapter = npawPlugin.registerAdsAdapter(player, YospaceAdsAdapter);
* // ... when Yospace session is ready ...
* adsAdapter.setSession(session);
*
*
*
*/
export default class YospaceAdsAdapter {
constructor() {
this._session = null;
this._observer = null;
this._currentAd = null;
this._adBreakActive = false;
}
isUsed() {
return true;
}
getVersion() {
return '7.0.1-yospace-ads-jsclass';
}
getPlayerName() {
return 'Yospace';
}
/* ──────────────────────────────────────────────────────────────── */
/* Public helper methods (called by the NP core) */
/* ──────────────────────────────────────────────────────────────── */
getPlayhead() {
// For Yospace SSAI, calculate ad-relative playhead
// Ads are stitched into the stream, so we need to calculate offset from ad start
if (!this._currentAd) {
return 0;
}
if (!this._adStartStreamTime && this._adStartStreamTime !== 0) {
return 0;
}
// Get current stream playhead from video element
// Try multiple ways to get the video element
let videoElement = null;
// For HLS.js, the video element is at player.media
if (this.player?.media) {
videoElement = this.player.media;
}
// Maybe player itself is the video element
else if (this.player && this.player.currentTime !== undefined) {
videoElement = this.player;
}
// Try through video adapter chain as fallback
else {
const video = this.getVideo();
const adapter = video?.getAdapter();
videoElement = adapter?.getVideoObject?.() || adapter?.player?.media;
}
if (!videoElement || typeof videoElement.currentTime !== 'number') {
return 0;
}
const currentStreamTime = videoElement.currentTime;
const adPlayhead = currentStreamTime - this._adStartStreamTime;
const adDuration = this._currentAd.duration || 0;
// Clamp to ad duration to avoid negative or over-duration values
const clampedPlayhead = Math.max(0, Math.min(adPlayhead, adDuration));
return clampedPlayhead;
}
getAdProvider() {
const provider = this._currentAd ? this._currentAd.adProvider : undefined;
return provider;
}
// Alias for compatibility
getProvider() {
return this.getAdProvider();
}
getIsSkippable() {
return this._currentAd ? this._currentAd.isSkippable : false;
}
getAdCampaign() {
return this._currentAd ? this._currentAd.adCampaign : undefined;
}
getCreativeId() {
return this._currentAd ? this._currentAd.creativeId : undefined;
}
getResource() {
return this._currentAd ? this._currentAd.resource : undefined;
}
getAdInsertionType() {
// Yospace is Server-Side Ad Insertion (SSAI) by definition
// All ads are stitched into the stream on the server side
const insertionType = this.getNpawReference().Constants.AdInsertionType.ServerSide;
return insertionType;
}
// Add flag to help video adapters know this is SSAI
getIsSSAI() {
return true;
}
// Add flag property that video adapters check
get isDAI() {
return true; // DAI = Dynamic Ad Insertion (another term for SSAI)
}
getPlayerVersion() {
return this._session.getVersion() || undefined;
}
getDuration() {
return this._currentAd ? this._currentAd.duration : undefined;
}
getExpectedAds() {
return this._expectedAds;
}
getGivenAds() {
// For Yospace SSAI, givenAds should match expectedAds from the current break
return this._expectedAds;
}
getExpectedBreaks() {
return this._expectedBreaks?.length || 0;
}
getGivenBreaks() {
// Return the number of ad breaks from the Yospace session
return this._expectedBreaks?.length || 0;
}
getBreaksTime() {
// Return array of playhead positions where each break starts (in seconds)
if (!this._expectedBreaks || this._expectedBreaks.length === 0) {
return undefined;
}
const breaksTime = this._expectedBreaks.map((br) => {
// Yospace breaks have getStart() method that returns milliseconds
if (br.getStart && typeof br.getStart === 'function') {
const startMs = br.getStart();
const startSec = startMs / 1000;
return startSec;
}
// Fallback: check for position property
if (br.position === 'preroll') return 0;
if (br.position === 'postroll') return -1;
return 0;
});
return breaksTime;
}
getExpectedPattern() {
// Return string format "pre-mid-post", e.g. "1-3-1"
if (!this._expectedBreaks || this._expectedBreaks.length === 0) return undefined;
let pre = 0;
let mid = 0;
let post = 0;
this._expectedBreaks.forEach((br) => {
if (br.position === 'preroll') {
pre++;
} else if (br.position === 'postroll') {
post++;
} else {
// Everything else counts as midroll (including 'midroll' and explicit timestamps)
mid++;
}
});
return `${pre}-${mid}-${post}`;
}
getPosition() {
switch (this._position) {
case 'preroll':
return this.getNpawReference().Constants.AdPosition.Preroll;
case 'midroll':
return this.getNpawReference().Constants.AdPosition.Midroll;
case 'postroll':
return this.getNpawReference().Constants.AdPosition.Postroll;
default:
return undefined;
}
}
getTitle() {
return this._currentAd ? this._currentAd.id : undefined;
}
/* ──────────────────────────────────────────────────────────────── */
/* Session Management */
/* ──────────────────────────────────────────────────────────────── */
/**
* Sets the Yospace session and registers the analytic observer.
* @param {Object} session The Yospace session object.
*/
setSession(session) {
if (!session) {
return;
}
this._session = session;
this._registerObserver();
// Now that we have the session, try to attach video element listeners
// This is when player should be initialized
this._attachVideoListeners();
}
_registerObserver() {
if (!this._session) return;
// We assume YospaceAdManagement global is available if a session is passed.
if (typeof YospaceAdManagement === 'undefined') {
return;
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const adapter = this;
class NpawYospaceObserver extends YospaceAdManagement.AnalyticEventObserver {
onAdvertBreakStart(adBreak, _session) {
adapter._onBreakStart(adBreak);
}
onAdvertBreakEnd(_session) {
adapter._onBreakEnd();
}
onAdvertStart(advert, session) {
adapter._onAdStart(advert, session);
}
onAdvertEnd(session) {
adapter._onAdEnd();
}
onTrackingEvent(type, _session) {
adapter._onTrackingEvent(type);
}
onSessionError(errorCode, _session) {
adapter._onSessionError(errorCode);
}
onTrackingError(error, _session) {
adapter._onTrackingError(error);
}
}
this._observer = new NpawYospaceObserver();
this._session.addAnalyticObserver(this._observer);
}
/* ──────────────────────────────────────────────────────────────── */
/* Event Handlers */
/* ──────────────────────────────────────────────────────────────── */
_onBreakStart(adBreak) {
this._adBreakActive = true;
this._expectedAds = adBreak?.adverts?.length || 0;
this._expectedBreaks = adBreak?.broker?.linearAdBreaks || [];
this._position = adBreak ? adBreak.position : undefined;
// Check video adapter state BEFORE we fire break start
const videoAdapter = this.getVideo()?.getAdapter();
if (videoAdapter) {
// For SSAI preroll: Fire video join BEFORE ad break starts
// This is per NPAW spec: "SSAI /joinTime is sent before the ad starts and before the pre-roll /adBreakStart"
if (this._position === 'preroll' && !videoAdapter.flags?.isJoined && videoAdapter.flags?.isStarted) {
videoAdapter.fireJoin({}, 'YospaceSSAIPreroll');
}
}
// Fire manifest to report ad break metadata (givenBreaks, breaksTime, etc.)
// This should be fired once per view when we first have the break information
if (!this._manifestFired && this._expectedBreaks && this._expectedBreaks.length > 0) {
this.fireManifest();
this._manifestFired = true;
}
this.fireBreakStart();
}
_onBreakEnd() {
this._adBreakActive = false;
this.fireBreakStop();
}
_onAdStart(advert, _session) {
if (!advert) {
return;
}
// Try to extract adProvider from various possible locations in the Yospace advert object
let adProvider = undefined;
// First try standard locations
if (advert.linearCreative?.adSystem) {
adProvider = advert.linearCreative.adSystem;
} else if (advert.getAdSystem && typeof advert.getAdSystem === 'function') {
adProvider = advert.getAdSystem();
} else if (advert.linearCreative?.advertiser) {
adProvider = advert.linearCreative.advertiser;
}
// If not found, check the properties array (VAST properties)
if (!adProvider && advert.properties && Array.isArray(advert.properties)) {
const adSystemProp = advert.properties.find((prop) => prop.name === 'AdSystem' || prop.key === 'AdSystem');
if (adSystemProp) {
adProvider = adSystemProp.value || adSystemProp.content;
}
}
// Get breakType from the parent break, not the advert
const breakType = this._position; // We already have position from _onBreakStart
this._currentAd = {
id: advert.linearCreative?.advertId,
duration: advert.linearCreative?.duration ? advert.linearCreative.duration / 1000 : 0, // Convert ms to seconds
adCampaign: advert.getLineItemIdentifier ? advert.getLineItemIdentifier() : undefined,
resource: advert.linearCreative?.assetUri,
adProvider: adProvider,
creativeId: advert.getCreativeIdentifier ? advert.getCreativeIdentifier() : undefined,
isSkippable: !!advert.linearCreative?.skipOffset,
breakType: breakType
};
// Fire /adStart here now that we have all the metadata
this.fireStart({}, 'onAdStart');
// Start performance monitoring for join time
this.t1 = performance.now();
}
_onAdEnd() {
const videoAdapter = this.getVideo()?.getAdapter();
if (videoAdapter) {
if (this._position === 'postroll') {
videoAdapter.fireStop({}, 'onAdEnd');
}
}
this.fireStop();
this._currentAd = null;
this._adStartStreamTime = null;
}
_onTrackingEvent(type) {
switch (type) {
case 'loaded':
// Ad is loaded - but we already fired fireStart() in _onAdStart
// This event is not needed for NPAW tracking since we fire start earlier
break;
case 'start': {
// Ad playback actually started - capture stream time for playhead calculation
// Capture the stream time when ad actually starts playing
// Try multiple ways to get the video element
let videoElement = null;
// For HLS.js, the video element is at player.media
if (this.player?.media) {
videoElement = this.player.media;
}
// Maybe player itself is the video element
else if (this.player && this.player.currentTime !== undefined) {
videoElement = this.player;
}
// Try through video adapter chain as fallback
else {
const video = this.getVideo();
const adapter = video?.getAdapter();
videoElement = adapter?.getVideoObject?.() || adapter?.player?.media;
}
if (videoElement && typeof videoElement.currentTime === 'number') {
this._adStartStreamTime = videoElement.currentTime;
} else {
this._adStartStreamTime = 0;
}
this.t2 = performance.now();
this.fireJoin({ adJoinDuration: this.t2 - this.t1 }, 'onTrackingEvent-start');
break;
}
case 'firstQuartile':
this.fireQuartile(1);
break;
case 'midpoint':
this.fireQuartile(2);
break;
case 'thirdQuartile':
this.fireQuartile(3);
break;
case 'pause':
this.firePause();
break;
case 'resume':
this.fireResume();
break;
default:
break;
}
}
_onSessionError(errorCode) {
// Fire error with proper code and message
const code = errorCode || 'YOSPACE_SESSION_ERROR';
const message = 'Yospace Session Error';
this.fireError(code, message);
}
_onTrackingError(error) {
// Fire error with proper code and message from the error object
const code = error?.code || error?.errorCode || 'YOSPACE_TRACKING_ERROR';
const message = error?.message || error?.errorMessage || 'Yospace Tracking Error';
this.fireError(code, message);
}
_onAdBufferStart() {
// Only fire buffer events if we're currently in an ad
if (this._currentAd && this._adBreakActive) {
this.fireBufferBegin();
}
}
_onAdBufferEnd() {
// Only fire buffer events if we're currently in an ad
if (this._currentAd && this._adBreakActive) {
this.fireBufferEnd();
}
}
/* ──────────────────────────────────────────────────────────────── */
/* Listeners (Not used for Yospace as we use Observer) */
/* ──────────────────────────────────────────────────────────────── */
registerListeners() {
// Attach video element listeners for buffer events
// Try immediately, but also set up with delay since video element might not be ready yet
this._attachVideoListeners();
// Also try after a short delay in case the video element isn't ready yet
setTimeout(() => {
if (!this._videoListenersAttached) {
this._attachVideoListeners();
}
}, 100);
}
_attachVideoListeners() {
if (this._videoListenersAttached) {
return;
}
// Set up video element listeners for buffer events during ads
// Since Yospace is SSAI, buffer events come from the video element
let videoElement = null;
// For HLS.js, try player.media
if (this.player?.media) {
videoElement = this.player.media;
}
// Maybe player itself is the video element
else if (this.player && this.player.addEventListener) {
videoElement = this.player;
}
// Try through video adapter chain
else {
const video = this.getVideo();
const adapter = video?.getAdapter();
videoElement = adapter?.getVideoObject?.() || adapter?.player?.media;
}
if (videoElement && videoElement.addEventListener) {
this._videoWaitingListener = () => {
if (this._currentAd && this._adBreakActive) {
this._onAdBufferStart();
}
};
this._videoPlayingListener = () => {
if (this._currentAd && this._adBreakActive) {
this._onAdBufferEnd();
}
};
// Also listen to 'stalled' event
this._videoStalledListener = () => {
if (this._currentAd && this._adBreakActive) {
this._onAdBufferStart();
}
};
videoElement.addEventListener('waiting', this._videoWaitingListener);
videoElement.addEventListener('playing', this._videoPlayingListener);
videoElement.addEventListener('stalled', this._videoStalledListener);
this._videoListenersAttached = true;
}
}
unregisterListeners() {
// Remove video element listeners
let videoElement = this.player?.media || this.player;
if (!videoElement) {
const video = this.getVideo();
const adapter = video?.getAdapter();
videoElement = adapter?.getVideoObject?.() || adapter?.player?.media;
}
if (videoElement && videoElement.removeEventListener) {
if (this._videoWaitingListener) {
videoElement.removeEventListener('waiting', this._videoWaitingListener);
this._videoWaitingListener = null;
}
if (this._videoPlayingListener) {
videoElement.removeEventListener('playing', this._videoPlayingListener);
this._videoPlayingListener = null;
}
if (this._videoStalledListener) {
videoElement.removeEventListener('stalled', this._videoStalledListener);
this._videoStalledListener = null;
}
}
if (this._session && this._observer) {
try {
// If Yospace SDK supports removing observers, we would do it here.
// The SDK documentation or type definitions would confirm this.
// As per samples, there doesn't seem to be a removeAnalyticObserver,
// but usually session shutdown cleans them up.
} catch (e) {
// Ignore
}
}
this._session = null;
this._observer = null;
this._currentAd = null;
this._adBreakActive = false;
this._manifestFired = false;
this._videoListenersAttached = false;
this._adStartStreamTime = null;
}
}