npaw-plugin-adapters
Version:
NPAW's Plugin Adapters
839 lines (742 loc) • 26.3 kB
JavaScript
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');
}
}
}