UNPKG

npaw-plugin-adapters

Version:
839 lines (742 loc) 26.3 kB
export default class ExpoAdapter { /** * Register Expo Video event listeners */ registerListeners() { this._listeners = {}; this._subscriptions = []; this.monitorPlayhead(true, false); // Initialize playback rate tracking this._playbackRate = 1; // Initialize seek detection tracking this._lastPlayhead = undefined; this._lastTimeUpdateTimestamp = undefined; this._seekInProgress = false; // Initialize playhead tracking for join event this._initialPlayhead = undefined; // Initialize resource from player source if available (race condition fix) // This ensures resource is set before any events fire if (!this.resource) { if (this.player?.source) { this.resource = this.player.source; } else if (this.player?.currentSource) { this.resource = this.player.currentSource; } else if (this.videoSource) { this.resource = this.videoSource; } } this._listeners['playingChange'] = this.onPlayingChange.bind(this); this._listeners['statusChange'] = this.onStatusChange.bind(this); this._listeners['videoTrackChange'] = this.onVideoTrackChange.bind(this); this._listeners['sourceChange'] = this.onSourceChange.bind(this); this._listeners['sourceLoad'] = this.onSourceLoad.bind(this); this._listeners['timeUpdate'] = this.onTimeUpdate.bind(this); this._listeners['playbackRateChange'] = this.onPlaybackRateChange.bind(this); this._listeners['playToEnd'] = this.onPlayToEnd.bind(this); for (const [event, listener] of Object.entries(this._listeners)) { const subscription = this.player.addListener(event, listener); this._subscriptions.push(subscription); } // Setup background detection for React Native this._setupBackgroundDetection(); // Setup device information in plugin options this._setupDeviceInfo(); // Note: We don't call fireInit() here. The plugin will automatically // send init when fireStart() is called (if init hasn't been sent yet). // This ensures init is sent with complete metadata and correct isLive detection. } /** * Setup device information in plugin options * Since the plugin doesn't call adapter device methods automatically, * we need to populate the options manually during initialization * @private */ _setupDeviceInfo() { try { // Check if we have access to the video object (needed to set options) if (this.getVideo && typeof this.getVideo === 'function') { const video = this.getVideo(); if (video && video.options) { // Only set if not already defined by user if (!video.options['device.brand']) { const brand = this.getDeviceBrand(); if (brand) video.options['device.brand'] = brand; } if (!video.options['device.model']) { const model = this.getDeviceModel(); if (model) video.options['device.model'] = model; } if (!video.options['device.type']) { const type = this.getDeviceType(); if (type) video.options['device.type'] = type; } if (!video.options['device.osName']) { const osName = this.getDeviceType(); // Platform.OS gives us the OS name if (osName) video.options['device.osName'] = osName; } if (!video.options['device.osVersion']) { const osVersion = this.getSystemVersion(); if (osVersion) video.options['device.osVersion'] = String(osVersion); } } } } catch (error) { console.warn('[ExpoAdapter] Could not set up device info:', error); } } /** * Setup React Native AppState listener for background detection * @private */ _setupBackgroundDetection() { try { const { AppState } = require('react-native'); this._appStateListener = (nextAppState) => { if (nextAppState === 'background' || nextAppState === 'inactive') { // App going to background - fire stop to close the view // Mobile OS restrictions prevent reliable background network activity (pings) if (this.flags.isStarted && !this.flags.isStopped) { this.fireStop({}, 'appStateChange'); } } }; this._appStateSubscription = AppState.addEventListener('change', this._appStateListener); } catch (_error) { // AppState not available (not React Native environment) // This is expected in web environments - fail silently } } unregisterListeners() { // Remove player event listeners for (const subscription of this._subscriptions) { subscription.remove(); } this._subscriptions = []; this._listeners = {}; // Remove AppState listener if (this._appStateSubscription) { this._appStateSubscription.remove(); this._appStateSubscription = null; } } // ==================== EVENT HANDLERS ==================== /** * Handle playing state changes (play/pause) */ onPlayingChange({ isPlaying }) { if (isPlaying) { this.handlePlay(); } else { this.handlePause(); } } /** * Handle play event */ handlePlay() { if (!this.flags.isStarted) { // Ensure resource is set before firing start (race condition fix) // Sometimes play is called before onSourceChange event fires if (!this.resource && this.player?.source) { this.resource = this.player.source; } // If no resource yet, try to get it from plugin options if (!this.getResource()) { try { const video = this.getVideo?.(); const optionsResource = video?.options?.['content.resource']; if (optionsResource) { this.resource = optionsResource; } } catch (err) { // Ignore errors } } this.fireStart({}, 'playListener'); // Store initial playhead to detect when first frame actually renders this._initialPlayhead = this.player?.currentTime || 0; this.flags.isStarted = true; } else if (this.flags.isPaused) { this.fireResume({}, 'playListener'); } this.flags.isPaused = false; } /** * Handle pause event */ handlePause() { // Don't fire pause if we're buffering or seeking (player is just temporarily paused) if (this.flags.isStarted && !this.flags.isBuffering && !this.flags.isSeeking) { this.firePause({}, 'pauseListener'); this.flags.isPaused = true; } } /** * Handle player status changes (loading, readyToPlay, error) */ onStatusChange(payload) { // Set faster time updates for better seek detection (100ms) this.player.timeUpdateEventInterval = this.player.timeUpdateEventInterval || 0.1; const { status, error } = payload; switch (status) { case 'loading': // Only handle loading after join has occurred to avoid interfering with startup if (!this.flags.isJoined) { break; } // Detect if this loading is due to a seek by checking playhead delta // Use raw player time for seek detection (not getPlayhead which returns undefined for live) const currentPlayhead = this.player?.currentTime; const isLikelySeek = this._detectSeekFromPlayhead(currentPlayhead); // Distinguish between seek and buffer loading if (this._seekInProgress || isLikelySeek) { this._seekInProgress = true; // Ensure flag is set this.handleSeekBegin({}, 'statusChange'); } else { // Only fire buffer begin if not already buffering this.handleBufferBegin({}, 'statusChange'); } break; case 'readyToPlay': // Don't fire join here - let timeUpdate handler fire it when playhead actually moves // This prevents join from being sent too quickly after start (single digit ms duration) // The timeUpdate handler will fire join when first frame is actually rendered // End either seek or buffer depending on which was in progress if (this.flags.isSeeking) { this.handleSeekEnd({}, 'statusChange'); } else if (this.flags.isBuffering) { this.handleBufferEnd({}, 'statusChange'); } break; case 'error': this.handleError(error); break; } } /** * Handle buffer begin */ handleBufferBegin() { if (!this.flags.isBuffering) { this.fireBufferBegin({}, 'bufferListener'); this.flags.isBuffering = true; } } /** * Handle buffer end */ handleBufferEnd() { if (this.flags.isBuffering) { this.fireBufferEnd({}, 'bufferListener'); this.flags.isBuffering = false; // Resume playback tracking after buffer ends if (this.flags.isPaused) { this.flags.isPaused = false; } } } /** * Handle seek begin */ handleSeekBegin(properties = {}, triggeredEvent = 'seekListener') { if (!this.flags.isSeeking) { this.fireSeekBegin(properties, triggeredEvent); this.flags.isSeeking = true; } } /** * Handle seek end */ handleSeekEnd(properties = {}, triggeredEvent = 'seekListener') { if (this.flags.isSeeking) { this.fireSeekEnd(properties, triggeredEvent); this.flags.isSeeking = false; // Clear the seek in progress flag this._seekInProgress = false; // Resume playback tracking after seek ends if (this.flags.isPaused) { this.flags.isPaused = false; } } } /** * Handle player error * * NOTE: Expo Video API limitation - PlayerError only provides a 'message' property. * There are no error codes documented or provided by the API. * See: https://docs.expo.dev/versions/latest/sdk/video/ */ handleError(error) { // Expo Video only provides error.message, no error.code const message = error?.message || 'An unknown error occurred'; // Use message as code for analytics (common pattern when codes aren't available) const code = message; // Determine if error is fatal by parsing the error message // Fatal errors stop the view and prevent pings from continuing const isFatal = this._isFatalError(message); this.fireError(code, message, {}, undefined, 'errorListener', isFatal); // For fatal errors, stop the view to prevent pings from continuing // This is especially important for startup errors (before join) if (isFatal && !this.flags.isStopped) { this.fireStop({}, 'errorListener'); } } /** * Determine if an error should be treated as fatal based on message content * * Since Expo Video doesn't provide error codes or severity levels, we must * parse the error message to infer severity. This is not ideal but necessary. * * @param {string} message - Error message from PlayerError * @returns {boolean} True if error appears to be fatal * @private */ _isFatalError(message) { if (!message) { // No message = unknown error = treat as fatal for safety return true; } // Convert to uppercase for case-insensitive matching const messageUpper = message.toString().toUpperCase(); // Fatal error patterns based on real-world Expo Video errors // These patterns match both user-facing messages and Java/native exceptions const fatalPatterns = [ // Network-related failures (always fatal) 'UNKNOWNHOSTEXCEPTION', // DNS resolution failure 'UNABLE TO RESOLVE', // DNS failure 'CONNECTION REFUSED', // Server refused connection 'CONNECTION FAILED', // Network connection failed 'SOCKETEXCEPTION', // Socket errors 'SSLEXCEPTION', // SSL/TLS errors 'NO ADDRESS', // DNS resolution failure 'NETWORK ERROR', // Generic network errors 'NETWORK FAILURE', // Load/source failures (always fatal) 'FILENOTFOUNDEXCEPTION', // Source file not found 'SOURCE ERROR', // Source loading errors 'LOAD FAILED', // Resource load failures 'FAILED TO LOAD', 'CANNOT LOAD', 'NOT FOUND', // 404 errors 'INVALID SOURCE', // Format/codec errors (always fatal) 'UNSUPPORTED', // Format/codec not supported 'DECODE ERROR', // Decoder failures 'DECODER FAILED', 'CODEC ERROR', 'ILLEGAL STATE', // Player in invalid state // Timeout errors (always fatal) 'TIMEOUTEXCEPTION', // Request timeout 'TIMEOUT', 'TIMED OUT', // Explicit fatal markers 'FATAL', 'ABORT' ]; for (const pattern of fatalPatterns) { if (messageUpper.includes(pattern)) { return true; } } // Default to non-fatal for unrecognized errors // (e.g., temporary buffering issues, quality switches, etc.) return false; } /** * Handle video track/quality changes */ onVideoTrackChange({ videoTrack }) { this._currentVideoTrack = videoTrack; } /** * Handle source changes */ onSourceChange({ source }) { this._setSource(source, 'sourceChange'); } /** * Handle source load event (fires when source is loaded and ready) */ onSourceLoad({ source }) { this._setSource(source, 'sourceLoad'); } /** * Internal method to handle source setting from any source event * @private */ _setSource(source, eventName) { // Don't overwrite existing resource with undefined/null // sourceLoad sometimes fires with undefined, we don't want to lose the resource if (!source || (!source.uri && typeof source !== 'string')) { return; } this.resource = source; if (this.flags.isStarted) { this.fireStop({}, eventName); // Reset started flag so the next play will trigger start/join this.flags.isStarted = false; } // Fire init explicitly when source is loaded // This ensures init is sent before start with correct metadata if (source && (source.uri || typeof source === 'string')) { this.fireInit({}, eventName); } } /** * Check if playhead has changed from initial position (first frame rendered) * @param {number} currentPlayhead - Current playhead position * @returns {boolean} True if playhead has progressed from initial position */ _hasPlayheadChanged(currentPlayhead) { if (this._initialPlayhead === undefined || currentPlayhead === undefined) { return false; } const isLive = this.getIsLive(); const initial = this._initialPlayhead || 0; // For live content: initial position might be non-zero, so check if it has progressed // For VOD: check if playhead has moved forward from initial position if (((initial !== 0 && isLive) || !isLive) && currentPlayhead > initial) { return true; } else if (isLive) { // Update initial playhead for live content this._initialPlayhead = currentPlayhead; } return false; } /** * Detect if playhead change indicates a seek * @param {number} currentPlayhead - Current playhead position * @returns {boolean} True if this appears to be a seek */ _detectSeekFromPlayhead(currentPlayhead) { // Can't detect seeks without valid playhead data if (currentPlayhead === undefined || currentPlayhead === null || this._lastPlayhead === undefined || this._lastTimeUpdateTimestamp === undefined) { return false; } const now = Date.now(); const realTimeElapsed = (now - this._lastTimeUpdateTimestamp) / 1000; // seconds const playheadDelta = currentPlayhead - this._lastPlayhead; // Expected playhead movement based on playback rate const expectedDelta = realTimeElapsed * this._playbackRate; // Calculate discontinuity (absolute difference between expected and actual) // Using 1.0 second as threshold - jumps larger than this are likely seeks const discontinuity = Math.abs(playheadDelta - expectedDelta); const SEEK_THRESHOLD = 1.0; return discontinuity > SEEK_THRESHOLD; } /** * Handle time updates */ onTimeUpdate(payload) { const { currentTime } = payload; // Fire join event when playhead actually changes (first frame rendered) if (!this.flags.isJoined && this.flags.isStarted) { if (this._hasPlayheadChanged(currentTime)) { this.fireJoin({}, 'timeUpdate'); } } // Detect playhead discontinuities (seeks) using the helper method if (this._detectSeekFromPlayhead(currentTime)) { this._seekInProgress = true; } // Update tracking variables this._lastPlayhead = currentTime; this._lastTimeUpdateTimestamp = Date.now(); // Check if video ended const duration = this.getDuration(); if (duration && currentTime >= duration && !this.flags.hasEnded) { this.fireStop({}, 'timeUpdate'); } } /** * Handle playback rate changes (speed changes like 1.5x, 2x) */ onPlaybackRateChange({ playbackRate, oldPlaybackRate }) { this.handlePlaybackRateChange(playbackRate, oldPlaybackRate); } /** * Handle playback rate change */ handlePlaybackRateChange(newRate, oldRate) { // Store the new playback rate this._playbackRate = newRate; } // ==================== GETTERS ==================== getVersion() { return '7.0.1-expo-jsclass'; } getPlayhead() { // Don't report playhead for live streams const isLive = this.getIsLive(); if (isLive) { return undefined; } return this.player?.currentTime || 0; } getDuration() { return this.player?.duration || null; } /** * Get the video resource URL. * * Priority order: * 1. this.resource (set from sourceChange event) * 2. this.videoSource (set during adapter construction) * 3. this.player.source (from Expo Video player API) * 4. video.options['content.resource'] (explicitly set via plugin options) * * The final fallback to options['content.resource'] is particularly useful * when the Expo Video API doesn't reliably expose the source URL, or when * you want to explicitly override the detected resource. * * @returns {string|undefined} The video resource URL */ getResource() { // Check if resource is a string (direct URL) if (this.resource && typeof this.resource === 'string') { return this.resource; } // Check if resource is an object with uri property if (this.resource && this.resource.uri && typeof this.resource.uri === 'string') { return this.resource.uri; } // Fallback to videoSource if (this.videoSource && typeof this.videoSource === 'string') { return this.videoSource; } // Fallback to player source const source = this.player?.source; if (typeof source === 'string') { return source; } if (source?.uri) { return source.uri; } // Final fallback: try to get from plugin options (content.resource) // This allows apps to set the resource explicitly if events are unreliable // Example usage: // plugin.registerAdapterFromClass(player, ExpoAdapter, { // 'content.resource': 'https://video-url.mp4' // }, 'videoPlayer'); try { if (this.getVideo) { const video = this.getVideo(); const optionsResource = video?.options?.['content.resource']; if (optionsResource) { return optionsResource; } } } catch (err) { // Ignore errors } return undefined; } getTitle() { // Try to get title from source metadata (Expo Video API) if (this.resource?.metadata?.title) { return this.resource.metadata.title; } if (this.player?.source?.metadata?.title) { return this.player.source.metadata.title; } // Fall back to title stored in adapter (if set externally) return this.title || null; } getURLToParse() { // Return the resource URL for the plugin to parse for CDN info and transport format // This populates the 'parsedResource' parameter in analytics return this.getResource(); } getIsLive() { // PRIORITY 1: Check if explicitly set in plugin options (most reliable during errors) // This allows apps to explicitly configure live streams and prevents misidentification // during startup errors when duration might not be available yet try { if (this.getVideo) { const video = this.getVideo(); if (video && video.options && typeof video.options['content.isLive'] === 'boolean') { return video.options['content.isLive']; } } } catch (_err) { // If getVideo isn't available or throws, continue to other methods } // PRIORITY 2: Check if player provides explicit isLive property if (typeof this.player?.isLive === 'boolean') { return this.player.isLive; } // PRIORITY 3: Fallback - detect from duration // Only consider it live if duration is explicitly infinite or null/undefined // A missing duration (0 or NaN) during errors should not be considered live const duration = this.getDuration(); // If duration is a valid positive number, it's VOD if (duration && isFinite(duration) && duration > 0) { return false; } // If duration is explicitly infinite, it's live if (duration === Infinity || !isFinite(duration)) { return true; } // For all other cases (null, undefined, 0, NaN), default to false (VOD) // This prevents errors/startup issues from being misidentified as live // when no explicit configuration is provided return false; } getRendition() { const track = this.player.videoTrack; if (!track?.size) return undefined; const { width, height } = track.size; const bitrate = track.bitrate ? Math.round(track.bitrate / 1000) : 0; return `${width}x${height}@${bitrate}`; } getPlayerName() { return 'Expo Video'; } getPlayerVersion() { return '3.0.14'; } getPlayrate() { // Return 0 if paused, otherwise return the stored playback rate if (this.flags.isPaused) { return 0; } return this._playbackRate; } getBitrate() { if (this.videoTrack?.bitrate) { return this.videoTrack.bitrate; } if (this._currentVideoTrack?.bitrate) { return this._currentVideoTrack.bitrate; } if (this.player.availableVideoTracks?.length > 0) { return this.player.availableVideoTracks[0].bitrate; } return null; } // ==================== DEVICE INFORMATION ==================== /** * Get device ID/name * @returns {string|null} Device identifier or name */ getDevice() { try { const Constants = require('expo-constants').default; // Try to get device name first (more user-friendly), fallback to device ID return Constants.deviceName || Constants.deviceId || null; } catch (_error) { return null; } } /** * Get device type (platform OS) * @returns {string|null} 'ios', 'android', 'web', etc. */ getDeviceType() { try { const { Platform } = require('react-native'); return Platform.OS || null; } catch (_error) { return null; } } /** * Get device model * @returns {string|null} Device model (e.g., "iPhone 14 Pro", "Pixel 7") */ getDeviceModel() { try { const Constants = require('expo-constants').default; return Constants.deviceModel || null; } catch (_error) { return null; } } /** * Get device brand/manufacturer * @returns {string|null} Device brand (e.g., "Apple", "Google", "Samsung") */ getDeviceBrand() { try { const Constants = require('expo-constants').default; return Constants.platform?.ios ? 'Apple' : Constants.deviceBrand || null; } catch (_error) { return null; } } /** * Get OS version * @returns {string|number|null} OS version (e.g., "17.0" for iOS, 33 for Android) */ getSystemVersion() { try { const Constants = require('expo-constants').default; const { Platform } = require('react-native'); // Constants.systemVersion provides the OS version // For Android, Platform.Version provides API level (number) if (Platform.OS === 'android') { return Platform.Version || Constants.systemVersion || null; } return Constants.systemVersion || null; } catch (_error) { return null; } } /** * Get device year class (performance tier) * Useful for analytics to understand device capability * @returns {number|null} Year class (e.g., 2020, 2021) */ getDeviceYearClass() { try { const Constants = require('expo-constants').default; return Constants.deviceYearClass || null; } catch (_error) { return null; } } /** * Check if device is a physical device or simulator/emulator * @returns {boolean|null} True if physical device, false if simulator/emulator */ getIsDevice() { try { const Constants = require('expo-constants').default; return Constants.isDevice; } catch (_error) { return null; } } /** * Get comprehensive device information object * Useful for debugging or detailed analytics * @returns {object|null} Object containing all device info */ getDeviceInfo() { try { const Constants = require('expo-constants').default; const { Platform } = require('react-native'); return { device: this.getDevice(), deviceType: this.getDeviceType(), deviceModel: this.getDeviceModel(), deviceBrand: this.getDeviceBrand(), systemVersion: this.getSystemVersion(), deviceYearClass: this.getDeviceYearClass(), isDevice: this.getIsDevice(), platformOS: Platform.OS, platformVersion: Platform.Version, appVersion: Constants.expoConfig?.version, appName: Constants.expoConfig?.name }; } catch (_error) { return null; } } onPlayToEnd() { if (this.flags.isStarted) { this.fireStop({}, 'playToEnd'); } } }