UNPKG

npaw-plugin-adapters

Version:
580 lines (482 loc) 18.5 kB
/* 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; } }