npaw-plugin-adapters
Version:
NPAW's Plugin Adapters
650 lines (572 loc) • 18.6 kB
JavaScript
/**
* NanoPlayer Adapter for NPAW Plugin v7
*
* This adapter integrates the nanoStream H5Live player with the NPAW analytics plugin.
* nanoPlayer is primarily designed for low-latency live streaming.
*
* @see https://docs.nanocosmos.de/docs/nanoplayer/nanoplayer_api/
*/
export default class NanoPlayerAdapter {
/**
* Check if player element exists in DOM
* @returns {boolean} True if player exists in page
*/
checkExistsPlayer() {
try {
return !!this.player;
} catch (err) {
return false;
}
}
/**
* Get adapter version
* @returns {string} Version string in format: version-name-tech
*/
getVersion() {
return '7.0.1-nanoplayer-jsclass';
}
/**
* Get current playhead position in seconds
* @returns {number|null} Current playhead time
*/
getPlayhead() {
return this.playhead || null;
}
/**
* Get frames per second
* @returns {number|null} Current FPS
*/
getFramesPerSecond() {
return this.framerate || null;
}
/**
* Get dropped frames count
* @returns {number|null} Cumulative dropped frames
*/
getDroppedFrames() {
return this.droppedframes || null;
}
/**
* Get current bitrate in bps
* @returns {number|null} Current bitrate
*/
getBitrate() {
return this.bitrate || null;
}
/**
* Get current rendition string (widthxheight@bitrate)
* @returns {string|null} Rendition string like "1920x1080@3000Kbps" or "320x240@105Kbps"
*/
getRendition() {
// Include bitrate in rendition to detect quality changes even with same resolution
return this.getNpawUtils().buildRenditionString(this.width, this.height, this.bitrate);
}
/**
* Get content title from player config
*
* Note: Retrieves streamname from internal player config. If this returns null,
* it's recommended to set 'content.title' via NPAW analytics options during
* adapter registration instead.
*
* @returns {string|null} Stream name or title
*/
getTitle() {
const config = this._getRealPlayerConfig();
if (config?.source?.h5live?.rtmp?.streamname) {
return config.source.h5live.rtmp.streamname;
}
return null;
}
/**
* Check if content is live
*
* Note: nanoPlayer is designed exclusively for live streaming, not VOD.
*
* @returns {boolean} Always true - nanoPlayer only supports live streams
*/
getIsLive() {
return true;
}
/**
* Get resource URL
* @returns {string|null} Stream URL
*/
getResource() {
// Try to get from stored source first
if (this.source) {
return this.source;
}
// Fall back to config URL
const config = this._getRealPlayerConfig();
if (config?.url) {
return config.url;
}
return null;
}
/**
* Get player version
* @returns {string|null} Player version string
*/
getPlayerVersion() {
return this.player?.version || null;
}
/**
* Get player name
* @returns {string} Player identifier
*/
getPlayerName() {
const playerType = this.player?.type || 'unknown';
return `nanoStreamH5Live-${playerType}`;
}
/**
* Get throughput/bandwidth in bps
*
* Note: nanoPlayer does not expose actual network throughput metrics.
* This returns average bitrate (over 10s) as a proxy for bandwidth estimation.
*
* @returns {number|null} Average bitrate in bps (not true network throughput)
*/
getThroughput() {
return this.throughput || null;
}
/**
* Get playback rate
* @returns {number|null} Current playback rate (1.0 = normal speed)
*/
getPlayrate() {
return this.playrate || null;
}
/**
* Get latency measurement
*
* Note: nanoPlayer does not expose RTT/network latency directly.
* This returns buffer delay (ms) which is useful for live streaming QoE analysis.
*
* @returns {number|null} Current buffer delay in milliseconds (not network RTT)
*/
getLatency() {
return this.latency || null;
}
/**
* Get video codec
*
* Note: nanoPlayer v4/v5 API does not expose codec information.
*
* @returns {null} Always null - codec info not available in nanoPlayer
*/
getVideoCodec() {
return null;
}
/**
* Get audio codec
*
* Note: nanoPlayer v4/v5 API does not expose codec information.
*
* @returns {null} Always null - codec info not available in nanoPlayer
*/
getAudioCodec() {
return null;
}
/**
* Get content duration
*
* Note: Not applicable for live streams. nanoPlayer is designed for live streaming only.
*
* @returns {null} Always null - duration not applicable for live content
*/
getDuration() {
return null;
}
/**
* Get underlying player config (internal helper)
*
* WARNING: This accesses private nanoPlayer internals (_core._realPlayer.config).
* This is necessary because nanoPlayer does not expose a public API to retrieve
* configuration after setup. This may break in future nanoPlayer versions.
*
* Recommended: Pass 'content.title' via NPAW analytics options during registration
* to avoid relying on this internal access for metadata.
*
* @private
* @returns {object|null} Player configuration object, or null if unavailable
*/
_getRealPlayerConfig() {
if (this.player?._core?._realPlayer?.config) {
return this.player._core._realPlayer.config;
}
return null;
}
/**
* Register event listeners on the player
*
* This intercepts player.setup() to automatically inject event handlers
* while preserving any user-defined event handlers.
*/
registerListeners() {
if (!this.player) {
return;
}
// NanoPlayer event name constants (defined as local const for JSON adapter compatibility)
const NANOPLAYER_EVENTS = {
ON_READY: 'onReady',
ON_PLAY: 'onPlay',
ON_PAUSE: 'onPause',
ON_LOADING: 'onLoading',
ON_START_BUFFERING: 'onStartBuffering',
ON_STOP_BUFFERING: 'onStopBuffering',
ON_ERROR: 'onError',
ON_STATS: 'onStats',
ON_STREAM_INFO: 'onStreamInfo',
ON_DESTROY: 'onDestroy'
};
// Store for use in fireEvent
this._NANOPLAYER_EVENTS = NANOPLAYER_EVENTS;
// Store original setup for restoration during cleanup (prevents memory leaks)
this._originalSetup = this.player.setup.bind(this.player);
this.player.setup = (config) => {
// Wrap existing events to call both user handlers and our handlers
const userEvents = config.events || {};
config.events = {
[NANOPLAYER_EVENTS.ON_PLAY]: this._wrapEvent(userEvents[NANOPLAYER_EVENTS.ON_PLAY], NANOPLAYER_EVENTS.ON_PLAY),
[NANOPLAYER_EVENTS.ON_PAUSE]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_PAUSE],
NANOPLAYER_EVENTS.ON_PAUSE
),
[NANOPLAYER_EVENTS.ON_LOADING]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_LOADING],
NANOPLAYER_EVENTS.ON_LOADING
),
[NANOPLAYER_EVENTS.ON_START_BUFFERING]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_START_BUFFERING],
NANOPLAYER_EVENTS.ON_START_BUFFERING
),
[NANOPLAYER_EVENTS.ON_STOP_BUFFERING]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_STOP_BUFFERING],
NANOPLAYER_EVENTS.ON_STOP_BUFFERING
),
[NANOPLAYER_EVENTS.ON_ERROR]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_ERROR],
NANOPLAYER_EVENTS.ON_ERROR
),
[NANOPLAYER_EVENTS.ON_STATS]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_STATS],
NANOPLAYER_EVENTS.ON_STATS
),
[NANOPLAYER_EVENTS.ON_STREAM_INFO]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_STREAM_INFO],
NANOPLAYER_EVENTS.ON_STREAM_INFO
),
[NANOPLAYER_EVENTS.ON_DESTROY]: this._wrapEvent(
userEvents[NANOPLAYER_EVENTS.ON_DESTROY],
NANOPLAYER_EVENTS.ON_DESTROY
),
// Preserve any other user events we don't track
...Object.keys(userEvents).reduce((acc, key) => {
if (!Object.values(NANOPLAYER_EVENTS).includes(key)) {
acc[key] = userEvents[key];
}
return acc;
}, {})
};
// Call original setup with modified config
return this._originalSetup(config);
};
// Monitor playhead for buffering detection
// Params: enableMonitor=true, detectSeeks=false (N/A for live), checkInterval=1200ms
this.monitorPlayhead(true, false, 1200);
}
/**
* Wraps a user event handler to call both user handler and adapter handler
*
* @private
* @param {function} userHandler - User's event handler (may be undefined)
* @param {string} eventName - Event name to fire in adapter
* @returns {function} Wrapped event handler
*/
_wrapEvent(userHandler, eventName) {
return (e) => {
// Call user's handler first (if it exists)
if (typeof userHandler === 'function') {
try {
userHandler(e);
} catch (err) {
console.error(`[NanoPlayer] Error in user's ${eventName} handler:`, err);
}
}
// Then call our adapter handler
this.fireEvent(eventName, e);
};
}
/**
* Main event dispatcher - called from player config events
* This is the v6 pattern that nanoPlayer requires
* @param {string} eventName - Event name (onPlay, onPause, etc.)
* @param {object} eventData - Event data from player
*/
fireEvent(eventName, eventData) {
switch (eventName) {
case this._NANOPLAYER_EVENTS.ON_PLAY:
this._onPlayListener();
break;
case this._NANOPLAYER_EVENTS.ON_PAUSE:
this._onPauseListener(eventData);
break;
case this._NANOPLAYER_EVENTS.ON_LOADING:
this._onLoadingListener();
break;
case this._NANOPLAYER_EVENTS.ON_START_BUFFERING:
this._onStartBufferingListener();
break;
case this._NANOPLAYER_EVENTS.ON_STOP_BUFFERING:
this._onStopBufferingListener();
break;
case this._NANOPLAYER_EVENTS.ON_ERROR:
this._onErrorListener(eventData);
break;
case this._NANOPLAYER_EVENTS.ON_STATS:
this._onStatsListener(eventData);
break;
case this._NANOPLAYER_EVENTS.ON_STREAM_INFO:
this._onStreamInfoListener(eventData);
break;
case this._NANOPLAYER_EVENTS.ON_DESTROY:
this._onDestroyListener();
break;
}
}
/**
* Unregister event listeners from the player
*
* Performs comprehensive cleanup to prevent memory leaks:
* - Stops playhead monitor
* - Restores original player.setup to break closure chain
* - Clears all instance properties
* - Nullifies player reference
*/
unregisterListeners() {
// Stop playhead monitor to prevent memory leaks
if (this.monitor) {
this.monitor.stop();
this.monitor = null;
}
// Restore original setup function to break closure chain
// This is critical for preventing memory leaks when adapter is destroyed
if (this._originalSetup && this.player) {
this.player.setup = this._originalSetup;
this._originalSetup = null;
}
// Clear all metric properties to release memory
this.bitrate = null;
this.framerate = null;
this.droppedframes = null;
this.throughput = null;
this.playrate = null;
this.latency = null;
this.playhead = null;
this.height = null;
this.width = null;
this.source = null;
// Clear player reference last (after restoring setup)
this.player = null;
}
/**
* Handle play event
* @private
*/
_onPlayListener() {
this.firePlayerLog('onPlay', {});
// Only fire join if not already joined
if (!this.flags?.isJoined) {
this.fireJoin({}, 'onPlay');
}
// Only fire resume if paused
if (this.flags?.isPaused) {
this.fireResume({}, 'onPlay');
}
}
/**
* Handle pause event
* @private
* @param {object} event - Pause event object
*/
_onPauseListener(event) {
this.firePlayerLog('onPause', event);
this.firePause({}, 'onPause');
}
/**
* Handle loading event (indicates start)
* Note: fireStart() will automatically call fireInit() if not yet initiated
* @private
*/
_onLoadingListener() {
this.firePlayerLog('onLoading', {});
this.fireStart({}, 'onLoading');
}
/**
* Handle error event
* Categorizes errors as fatal or non-fatal based on nanoPlayer error code ranges
* @private
* @param {object} event - Error event object
*/
_onErrorListener(event) {
this.firePlayerLog('onError', event);
try {
// Defensive: handle malformed error events
if (!event || !event.data) {
this.fireError('UNKNOWN_ERROR', 'Malformed error event received', {}, false, 'onError');
return;
}
const errorCode = event.data.code || 'UNKNOWN_ERROR';
const errorMessage = event.data.message || 'Unknown error occurred';
const metadata = event.data || {};
// List of error codes to ignore (non-critical)
const ignoredCodes = [
1007 // Playback suspended by external reason
];
// Convert to number for consistent comparison (error codes may be strings)
if (ignoredCodes.includes(Number(errorCode))) {
return;
}
// Determine if error is fatal based on nanoPlayer error code ranges
const isFatal = this._isFatalError(errorCode);
if (isFatal) {
// Fatal errors: stop the view
this.fireFatalError(errorCode, errorMessage, metadata, undefined, 'onError');
this.fireStop({}, 'onError');
} else {
// Non-fatal errors: log but continue playback
this.fireError(errorCode, errorMessage, metadata, undefined, 'onError');
}
} catch (err) {
// Last resort: ensure we report something even if error processing fails
try {
this.fireError(
'ERROR_HANDLER_EXCEPTION',
'Exception in error handler: ' + (err.message || 'unknown'),
{},
false,
'onError'
);
} catch (finalErr) {
// Silent fail
}
}
}
/**
* Determine if an error code is fatal based on nanoPlayer documentation
*
* Error code ranges from nanoPlayer v5 API:
* @see https://docs.nanocosmos.de/docs/nanoplayer/nanoplayer_api#NanoPlayer..event_onError
*
* @private
* @param {number|string} errorCode - Error code from nanoPlayer
* @returns {boolean} True if fatal error
*/
_isFatalError(errorCode) {
const code = Number(errorCode);
// 2000-2999: Stream errors (stream not found, stopped, no media) - FATAL
if (code >= 2000 && code < 3000) return true;
// 3000-3999: Media errors (decode, unsupported format) - FATAL
if (code >= 3000 && code < 4000) return true;
// 5000-5999: Setup errors (config, browser support) - ALWAYS FATAL
if (code >= 5000 && code < 6000) return true;
// Specific fatal player errors
const fatalPlayerErrors = [
1008, // Playback error
1009 // Playback failed (hidden visibility)
];
if (fatalPlayerErrors.includes(code)) return true;
// Network errors (4000-4999): Most allow reconnect, not fatal
// Player errors (1000-1999): Most are non-fatal
return false;
}
/**
* Handle stats event (contains playback metrics)
* @private
* @param {object} event - Stats event object
*/
_onStatsListener(event) {
this.firePlayerLog('onStats', event);
const stats = event?.data?.stats;
if (!stats) return;
// Store previous rendition string for change detection
const previousRendition = this.getRendition();
// Update metrics from stats (per official v5 API docs)
this.bitrate = stats.bitrate?.current || null;
this.framerate = stats.framerate?.current || null;
this.droppedframes = stats.quality?.droppedvideoFrames || null;
// Throughput = average bitrate over last 10 seconds
this.throughput = stats.bitrate?.avg || null;
// Playback rate (since v4.14.1)
this.playrate = stats.playbackrate?.current || null;
// Latency = buffer delay current (useful for live streaming QoE)
this.latency = stats.buffer?.delay?.current || null;
// Detect rendition change by comparing rendition strings
const newRendition = this.getRendition();
if (newRendition && previousRendition && newRendition !== previousRendition) {
if (this.storeNewRendition) {
this.storeNewRendition(newRendition);
}
}
// Update playhead and check for join
const currentTime = stats.currentTime;
if (currentTime > 0 && !this.flags?.isJoined) {
this.fireJoin({}, 'onStats');
}
this.playhead = currentTime;
}
/**
* Handle stream info event (contains stream metadata)
* @private
* @param {object} event - Stream info event object
*/
_onStreamInfoListener(event) {
this.firePlayerLog('onStreamInfo', event);
const streamInfo = event?.data?.streamInfo;
if (!streamInfo) return;
// Store previous rendition string for change detection
const previousRendition = this.getRendition();
// Update video dimensions (per official v5 API docs)
this.height = streamInfo.videoInfo?.height || null;
this.width = streamInfo.videoInfo?.width || null;
// Update source URL
this.source = streamInfo.url || null;
// Detect rendition change by comparing rendition strings
const newRendition = this.getRendition();
if (newRendition && previousRendition && newRendition !== previousRendition) {
if (this.storeNewRendition) {
this.storeNewRendition(newRendition);
}
}
// Note: Codecs are NOT available in nanoPlayer v4/v5 API
}
/**
* Handle start buffering event
* @private
*/
_onStartBufferingListener() {
this.firePlayerLog('onStartBuffering', {});
// Fire buffer begin when native buffering event fires
if (!this.flags?.isSeeking && !this.flags?.isPaused) {
this.fireBufferBegin({}, false, 'onStartBuffering');
}
}
/**
* Handle stop buffering event
* @private
*/
_onStopBufferingListener() {
this.firePlayerLog('onStopBuffering', {});
// Fire buffer end when native buffering event fires
this.fireBufferEnd({}, 'onStopBuffering');
}
/**
* Handle destroy event
* @private
*/
_onDestroyListener() {
this.firePlayerLog('onDestroy', {});
// Player is being destroyed, stop the analytics view
this.fireStop({}, 'onDestroy');
}
}