UNPKG

npaw-plugin-adapters

Version:
650 lines (572 loc) 18.6 kB
/** * 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'); } }