ipfs-hls-player
Version:
Video.js-based HLS player optimized for IPFS content-addressed storage
1,466 lines (1,254 loc) • 51.9 kB
JavaScript
/**
* IPFS HLS Player
* A Video.js-based HLS player optimized for IPFS content-addressed storage
*
* @author Mark Giles
* @license MIT
* @version 1.2.3
*
* This player solves the fundamental incompatibility between standard HLS
* (which uses relative paths) and IPFS (which requires absolute CID-based URLs).
* It provides pre-configured Video.js settings optimized for IPFS-hosted HLS streams.
*/
// Video.js core
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
// HLS Quality Selector plugin for Video.js
import 'videojs-hls-quality-selector';
/**
* IPFS HLS Player Service
* Provides automatic video enhancement with IPFS-optimized settings
*/
class IPFSHLSPlayer {
/**
* Check if URL is an IPFS URL
* @param {string} url - URL to check
* @returns {boolean} True if IPFS URL
*/
static isIPFSURL(url) {
if (!url) return false;
return url.includes('/ipfs/') ||
url.includes('ipfs.io') ||
url.includes('ipfs.dlux.io') ||
url.includes('gateway.pinata.cloud') ||
url.includes('dweb.link') ||
url.includes('cf-ipfs.com') ||
url.includes('cloudflare-ipfs.com');
}
/**
* MIME type normalization map
* Maps various MIME type variants to Video.js-compatible types
*/
static MIME_NORMALIZATION = {
// QuickTime/MOV → MP4 (same container)
'video/quicktime': 'video/mp4',
'video/x-quicktime': 'video/mp4',
// MP4 variants
'video/x-m4v': 'video/mp4',
'video/3gpp': 'video/mp4',
'video/3gpp2': 'video/mp4',
'video/mp4v-es': 'video/mp4',
// WebM variants
'video/x-webm': 'video/webm',
// HLS variants
'application/vnd.apple.mpegurl': 'application/x-mpegURL',
'audio/mpegurl': 'application/x-mpegURL',
'audio/x-mpegurl': 'application/x-mpegURL',
// AVI variants
'video/avi': 'video/x-msvideo',
'video/msvideo': 'video/x-msvideo',
'video/x-avi': 'video/x-msvideo',
// MPEG variants
'video/mpeg': 'video/mp2t',
'video/x-mpeg': 'video/mp2t',
// Keep standard types as-is
'video/mp4': 'video/mp4',
'video/webm': 'video/webm',
'video/ogg': 'video/ogg',
'video/x-msvideo': 'video/x-msvideo',
'video/mp2t': 'video/mp2t',
'application/x-mpegURL': 'application/x-mpegURL'
};
/**
* Detect MIME type from file content (magic bytes and text signatures)
* @param {string} url - URL to check
* @returns {Promise<string|null>} MIME type or null
*/
static async detectFromContent(url) {
const config = window.ipfsHLSPlayerConfig || {};
try {
if (config.debug) {
console.log('IPFSHLSPlayer: Starting type detection for:', url);
}
// Try Range request first (most efficient)
if (config.debug) {
console.log('IPFSHLSPlayer: Attempting Range request...');
}
let response = await fetch(url, {
headers: { 'Range': 'bytes=0-200' },
mode: 'cors'
});
if (config.debug) {
console.log('IPFSHLSPlayer: Range request completed, status:', response.status);
}
// If Range not supported (416) or other error, try without Range
if (response.status === 416 || !response.ok) {
if (config.debug) {
console.log('IPFSHLSPlayer: Range request failed, trying regular fetch');
}
response = await fetch(url, { mode: 'cors' });
}
// Read only what we need (up to 200 bytes)
let bytes;
if (response.headers.get('content-range')) {
// Range request succeeded
const buffer = await response.arrayBuffer();
bytes = new Uint8Array(buffer);
} else {
// Regular fetch - read only first 200 bytes
const reader = response.body.getReader();
const { value } = await reader.read();
await reader.cancel(); // Stop reading more
bytes = new Uint8Array(value).slice(0, 200);
}
// Check for HLS (text-based, starts with #EXTM3U)
if (bytes[0] === 0x23) { // '#' character
const text = new TextDecoder().decode(bytes);
if (text.startsWith('#EXTM3U')) {
if (config.debug) {
console.log('IPFSHLSPlayer: Detected HLS playlist');
}
return 'application/x-mpegURL';
}
}
// Check for MP4/MOV/QuickTime (all use ftyp box)
if (bytes.length > 7 &&
bytes[4] === 0x66 && bytes[5] === 0x74 &&
bytes[6] === 0x79 && bytes[7] === 0x70) {
// Extract brand for debugging
if (config.debug && bytes.length > 11) {
const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]).replace(/\0/g, ' ').trim();
console.log(`IPFSHLSPlayer: Detected MP4-compatible with brand: ${brand}`);
}
return 'video/mp4';
}
// Check for WebM/MKV (EBML header)
if (bytes.length > 3 &&
bytes[0] === 0x1A && bytes[1] === 0x45 &&
bytes[2] === 0xDF && bytes[3] === 0xA3) {
if (config.debug) {
console.log('IPFSHLSPlayer: Detected WebM/Matroska');
}
return 'video/webm';
}
// Check for Ogg
if (bytes.length > 3 &&
bytes[0] === 0x4F && bytes[1] === 0x67 &&
bytes[2] === 0x67 && bytes[3] === 0x53) {
if (config.debug) {
console.log('IPFSHLSPlayer: Detected Ogg');
}
return 'video/ogg';
}
// Check for AVI
if (bytes.length > 11 &&
bytes[0] === 0x52 && bytes[1] === 0x49 &&
bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x41 && bytes[9] === 0x56 &&
bytes[10] === 0x49 && bytes[11] === 0x20) {
if (config.debug) {
console.log('IPFSHLSPlayer: Detected AVI');
}
return 'video/x-msvideo';
}
// Check for MPEG-TS (sync byte 0x47)
if (bytes[0] === 0x47) {
// MPEG-TS has sync bytes every 188 bytes
if (bytes.length > 188 && bytes[188] === 0x47) {
if (config.debug) {
console.log('IPFSHLSPlayer: Detected MPEG-TS (confirmed by sync pattern)');
}
return 'video/mp2t';
}
// Single sync byte might still be TS
if (config.debug) {
console.log('IPFSHLSPlayer: Possible MPEG-TS (single sync byte)');
}
return 'video/mp2t';
}
// Fallback: Check Content-Type header
const contentType = response.headers.get('content-type');
if (contentType) {
const mimeType = contentType.split(';')[0].trim();
const normalized = this.MIME_NORMALIZATION[mimeType];
if (config.debug) {
if (normalized && normalized !== mimeType) {
console.log(`IPFSHLSPlayer: Normalized ${mimeType} to ${normalized}`);
}
}
return normalized || mimeType;
}
} catch (error) {
console.error('IPFSHLSPlayer: Content detection failed:', error, 'for URL:', url);
// Return null - don't guess!
}
return null;
}
/**
* Register Video.js middleware for IPFS MIME type detection
* Must be called before creating any players
*/
static registerIPFSMiddleware() {
// Check if middleware already registered
if (this._middlewareRegistered) return;
const self = this;
const config = window.ipfsHLSPlayerConfig || {};
// Register middleware for all sources
videojs.use('*', () => {
return {
async setSource(srcObj, next) {
const config = window.ipfsHLSPlayerConfig || {};
// Only process URLs without explicit type
if (!srcObj.type && self.shouldDetectContent(srcObj.src, srcObj.type)) {
if (config.debug) {
console.log('IPFSHLSPlayer Middleware: No type for extensionless URL:', srcObj.src);
}
try {
// Try query params first
let type = self.extractTypeFromURL(srcObj.src);
// If no query param hints, try content detection
if (!type) {
type = await self.detectFromContent(srcObj.src);
}
if (config.debug) {
console.log('IPFSHLSPlayer Middleware: Detected type:', type || 'unknown');
}
// Pass modified source with detected type
return next(null, {
src: srcObj.src,
type: type || 'video/mp4' // Last resort fallback
});
} catch (error) {
console.warn('IPFSHLSPlayer Middleware: Type detection failed:', error);
// On error, proceed with fallback type
return next(null, {
src: srcObj.src,
type: 'video/mp4'
});
}
}
// Pass through sources with type or recognized extensions
return next(null, srcObj);
}
};
});
this._middlewareRegistered = true;
if (config.debug) {
console.log('IPFSHLSPlayer: Middleware registered for IPFS MIME type detection');
}
}
/**
* Capability Detection System
* Detect browser capabilities to determine the best playback strategy
*/
// Cache capabilities to avoid repeated detection
static capabilities = null;
/**
* Detect all relevant browser capabilities
* @returns {Object} Browser capabilities
*/
static detectCapabilities() {
if (this.capabilities) return this.capabilities;
this.capabilities = {
hasNativeHLS: this.hasNativeHLSSupport(),
hasMSE: this.hasMSESupport(),
canOverrideNativeHLS: this.canOverrideNativeHLS(),
supportsAbsoluteURLsInHLS: this.supportsAbsoluteURLsInHLS()
};
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Detected capabilities:', this.capabilities);
}
return this.capabilities;
}
/**
* Check if browser has native HLS support
* @returns {boolean} True if native HLS is supported
*/
static hasNativeHLSSupport() {
const video = document.createElement('video');
const canPlayHLS = video.canPlayType('application/vnd.apple.mpegurl') !== '' ||
video.canPlayType('application/x-mpegURL') !== '';
return canPlayHLS;
}
/**
* Check if browser has MediaSource Extensions support
* @returns {boolean} True if MSE is supported
*/
static hasMSESupport() {
return !!(window.MediaSource || window.WebKitMediaSource);
}
/**
* Detect if the current browser is Safari/WebKit
* @returns {boolean} True if Safari is detected
*/
static isSafari() {
// Multiple detection methods for Safari/WebKit browsers
// Method 1: Check for webkit fullscreen APIs (most common)
const hasWebkitFullscreen = 'webkitEnterFullscreen' in HTMLVideoElement.prototype ||
'webkitEnterFullScreen' in HTMLVideoElement.prototype;
// Method 2: Check for webkit-specific video properties
const hasWebkitVideoProps = 'webkitSupportsFullscreen' in HTMLVideoElement.prototype ||
'webkitDisplayingFullscreen' in HTMLVideoElement.prototype;
// Method 3: Check for Safari-specific properties (not in Chrome)
const hasSafariSpecific = 'webkitConvertPointFromPageToNode' in window ||
(window.safari && typeof window.safari === 'object');
// Method 4: Check if it's not Chrome (Chrome has webkit but works fine)
const isNotChrome = !window.chrome && !window.navigator.userAgent.includes('Chrome');
// Safari detection: Has webkit properties AND is not Chrome
const isSafari = (hasWebkitFullscreen || hasWebkitVideoProps) &&
(hasSafariSpecific || isNotChrome);
if (window.ipfsHLSPlayerConfig?.debug && isSafari) {
console.log('IPFSHLSPlayer: Safari detected - using native player for all content');
}
return isSafari;
}
/**
* Check if Video.js can successfully override native HLS
* Safari cannot have its native HLS overridden by Video.js 8.x
* @returns {boolean} True if override will work
*/
static canOverrideNativeHLS() {
// If no native HLS, override isn't needed
if (!this.hasNativeHLSSupport()) return true;
// Safari's native HLS cannot be reliably overridden by Video.js 8.x
// This is a known limitation of Video.js with Safari
if (this.isSafari()) {
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Cannot override native HLS - Safari/WebKit detected');
}
return false;
}
// Other browsers can have their native HLS overridden
return true;
}
/**
* Check if native HLS supports absolute URLs in playlists
* Required for IPFS HLS content with CID-based URLs
* @returns {boolean} True if absolute URLs are supported
*/
static supportsAbsoluteURLsInHLS() {
// All known native HLS implementations support absolute URLs
// This could be tested more rigorously if edge cases are found
return this.hasNativeHLSSupport();
}
/**
* Determine which player strategy to use based on capabilities
* Safari always uses native player for consistency
* Other browsers use Video.js when possible
* @param {string} src - Source URL
* @param {string} type - MIME type (optional)
* @returns {string} 'native' or 'videojs'
*/
static getPlayerStrategy(src, type) {
const config = window.ipfsHLSPlayerConfig || {};
// Safari always uses native player for all content types
// This provides consistency and better integration with Safari/iOS features
if (this.isSafari()) {
if (config.debug) {
const contentType = this.isHLSContent(src, type) ? 'HLS' : 'progressive';
console.log(`IPFSHLSPlayer: Using native player for ${contentType} content (Safari always uses native)`);
}
return 'native';
}
// For non-Safari browsers, use capability-based selection
const caps = this.detectCapabilities();
const isHLS = this.isHLSContent(src, type);
if (isHLS) {
// For HLS content, prefer Video.js if MSE is available
if (caps.hasMSE) {
if (config.debug) {
console.log('IPFSHLSPlayer: Using Video.js HLS (MSE available)');
}
return 'videojs';
} else if (caps.hasNativeHLS) {
// Fallback to native if available
if (config.debug) {
console.log('IPFSHLSPlayer: Using native HLS (no MSE)');
}
return 'native';
} else {
// No HLS support at all
console.warn('IPFSHLSPlayer: No HLS support detected');
return 'videojs'; // Try Video.js anyway
}
}
// For non-HLS content, prefer Video.js if available
return caps.hasMSE ? 'videojs' : 'native';
}
/**
* Check if content is HLS
* @param {string} src - Source URL
* @param {string} type - MIME type (optional)
* @returns {boolean} True if HLS content
*/
static isHLSContent(src, type) {
if (!src) return false;
// Check explicit type (including detected type)
if (type === 'application/x-mpegURL' ||
type === 'application/vnd.apple.mpegurl' ||
type === 'application/x-mpegurl') {
return true;
}
// Check URL patterns
return src.includes('.m3u8') || src.includes('/hls/');
}
/**
* Initialize a Video.js player with IPFS-optimized settings
* @param {HTMLVideoElement} element - Video element to enhance
* @param {Object} options - Player configuration options
* @returns {Promise<Player>} Video.js player instance or native wrapper
*/
static async initializePlayer(element, options = {}) {
// Register middleware on first initialization (for Video.js path)
this.registerIPFSMiddleware();
const config = window.ipfsHLSPlayerConfig || {};
// Prevent double initialization
if (element._ipfsHLSPlayer || element.dataset.ipfsEnhanced === 'true') {
if (config.debug) {
console.log('IPFSHLSPlayer: Video already enhanced, skipping duplicate initialization for:', element.id || 'no-id');
}
return element._ipfsHLSPlayer;
}
// Detect source type if not provided
let sourceType = options.type;
// Try query parameter hints first (fastest)
if (!sourceType && options.src) {
sourceType = this.extractTypeFromURL(options.src);
if (sourceType && config.debug) {
console.log('IPFSHLSPlayer: Type detected from URL params:', sourceType);
}
}
// Try extension-based detection (fast)
if (!sourceType && options.src) {
sourceType = this.detectSourceType(options.src);
if (sourceType && config.debug) {
console.log('IPFSHLSPlayer: Type detected from extension:', sourceType);
}
}
// For ANY URL without extension and no type, try content detection (slower but universal)
if (!sourceType && options.src && this.shouldDetectContent(options.src, sourceType)) {
if (config.debug) {
console.log('IPFSHLSPlayer: No extension found, attempting content detection for:', options.src);
}
try {
sourceType = await this.detectFromContent(options.src);
if (config.debug) {
console.log('IPFSHLSPlayer: Content detection result:', sourceType || 'unknown');
}
} catch (error) {
if (config.debug) {
console.warn('IPFSHLSPlayer: Content detection failed:', error.message);
}
// Continue without type, let browser/player figure it out
}
// Update options with detected type for downstream use
if (sourceType) {
options.type = sourceType;
}
}
// Determine which player to use based on capabilities
// Use options.type which has the detected type
const strategy = this.getPlayerStrategy(options.src, options.type || sourceType);
if (config.debug) {
console.log('IPFSHLSPlayer: Player strategy:', strategy, 'for', options.src);
console.log('IPFSHLSPlayer: Final sourceType before player init:', sourceType);
console.log('IPFSHLSPlayer: Final options.type before player init:', options.type);
}
// Route to appropriate player implementation
if (strategy === 'native') {
return this.initializeNativePlayer(element, options);
} else {
return this.initializeVideoJSPlayer(element, options);
}
}
/**
* Initialize a native HTML5 player for browsers requiring native HLS
* @param {HTMLVideoElement} element - Video element to enhance
* @param {Object} options - Player configuration options
* @returns {Object} Native player wrapper with Video.js-compatible API
*/
static initializeNativePlayer(element, options = {}) {
const config = window.ipfsHLSPlayerConfig || {};
// Validate element is actually a video element
if (!element || element.tagName !== 'VIDEO') {
const error = `Element is not a valid video element: tagName=${element?.tagName}`;
if (config.debug) {
console.error('IPFSHLSPlayer:', error);
}
throw new Error(error);
}
if (config.debug) {
console.log('IPFSHLSPlayer: Initializing native player for:', options.src);
console.log('IPFSHLSPlayer: Options passed to native player:', {
src: options.src,
type: options.type,
preload: options.preload
});
console.log('IPFSHLSPlayer: Element state:', {
tagName: element.tagName,
id: element.id,
readyState: element.readyState,
networkState: element.networkState,
currentSrc: element.currentSrc,
canPlayType_HLS: (element.canPlayType ? element.canPlayType('application/vnd.apple.mpegurl') : 'canPlayType not available')
});
}
// Ensure element has an ID
if (!element.id) {
element.id = `ipfs-video-${Math.random().toString(36).substr(2, 9)}`;
}
// Configure native video element
element.controls = true;
// Set default preload if not specified
// For Safari with non-HLS content, use 'auto' to enable poster frame generation
const isHLS = this.isHLSContent(options.src, options.type);
const defaultPreload = (this.isSafari() && !isHLS) ? 'auto' : 'metadata';
element.preload = options.preload || defaultPreload;
if (config.debug) {
console.log('IPFSHLSPlayer: Set preload to:', element.preload,
isHLS ? '(HLS content)' : '(Progressive video - Safari will generate poster)');
}
// Log current element state before setting src
if (config.debug) {
console.log('IPFSHLSPlayer: Element state BEFORE src set:', {
currentSrc: element.src,
readyState: element.readyState,
networkState: element.networkState,
controls: element.controls,
preload: element.preload
});
}
// Set src first (if not already set) - critical for native HLS initialization
if (options.src && !element.src) {
element.src = options.src;
if (config.debug) {
console.log('IPFSHLSPlayer: Setting src on element (was missing):', options.src);
}
} else if (options.src && element.src !== options.src) {
element.src = options.src;
if (config.debug) {
console.log('IPFSHLSPlayer: Changed src from', element.src, 'to', options.src);
}
} else {
if (config.debug) {
console.log('IPFSHLSPlayer: Src already set, not changing:', element.src);
}
}
// Log state after setting src
if (config.debug) {
console.log('IPFSHLSPlayer: Element state AFTER src set:', {
src: element.src,
readyState: element.readyState,
networkState: element.networkState,
controls: element.controls
});
}
// For HLS content in native players, we need to explicitly trigger load
// This ensures the native HLS subsystem initializes properly
// (isHLS was already defined above for preload logic)
if (config.debug) {
console.log('IPFSHLSPlayer: Is HLS content?', isHLS, 'Type:', options.type);
}
if (isHLS) {
// Defensive check to ensure element is still a video element
if (element && element.tagName === 'VIDEO' && typeof element.load === 'function') {
if (config.debug) {
console.log('IPFSHLSPlayer: Calling load() for HLS content');
}
element.load();
if (config.debug) {
console.log('IPFSHLSPlayer: After load(), element state:', {
readyState: element.readyState,
networkState: element.networkState,
error: element.error
});
}
} else {
if (config.debug) {
console.warn('IPFSHLSPlayer: Cannot call load() - element check failed');
}
}
}
// Native players can use type attribute but don't require it - they can figure it out from URL/content
// Apply options
if (options.poster) element.poster = options.poster;
if (options.autoplay) element.autoplay = true;
if (options.muted) element.muted = true;
if (options.loop) element.loop = true;
// Don't apply Video.js CSS classes to native players - they break native controls
// Instead, add a native player class for any custom styling needs
element.classList.add('ipfs-native-player');
// Mark as enhanced
element.dataset.ipfsEnhanced = 'true';
// Set ready state when video metadata is loaded
const setReadyOnLoad = () => {
this.setLoadingState(element, 'ready');
if (config.debug) {
console.log('IPFSHLSPlayer: Native player ready, clearing loading state');
}
};
// Check if already loaded
if (element.readyState >= 1) {
setReadyOnLoad();
} else {
element.addEventListener('loadedmetadata', setReadyOnLoad, { once: true });
}
// Handle errors
element.addEventListener('error', () => {
this.setLoadingState(element, 'error');
if (config.debug) {
console.error('IPFSHLSPlayer: Native player error:', element.error);
}
}, { once: true });
// Add debug event listeners if in debug mode
if (config.debug) {
const events = ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'error', 'stalled', 'waiting'];
events.forEach(eventName => {
element.addEventListener(eventName, (e) => {
console.log(`IPFSHLSPlayer: Native player event: ${eventName}`, {
readyState: element.readyState,
networkState: element.networkState,
currentTime: element.currentTime,
duration: element.duration,
error: element.error
});
});
});
}
// Create wrapper with Video.js-compatible API
const wrapper = {
// Core methods
play: () => element.play(),
pause: () => element.pause(),
paused: () => element.paused,
currentTime: (time) => {
if (time !== undefined) element.currentTime = time;
return element.currentTime;
},
duration: () => element.duration,
volume: (vol) => {
if (vol !== undefined) element.volume = vol;
return element.volume;
},
muted: (mute) => {
if (mute !== undefined) element.muted = mute;
return element.muted;
},
// Source management
src: (source) => {
if (source) {
element.src = typeof source === 'string' ? source : source.src;
}
return element.src;
},
currentSrc: () => element.currentSrc,
currentType: () => options.type || 'application/x-mpegURL',
// Event handling
on: (event, handler) => element.addEventListener(event, handler),
off: (event, handler) => element.removeEventListener(event, handler),
one: (event, handler) => element.addEventListener(event, handler, { once: true }),
// Lifecycle
ready: (callback) => {
if (element.readyState >= 1) {
callback();
} else {
element.addEventListener('loadedmetadata', callback, { once: true });
}
},
dispose: () => {
// Get fresh element reference by ID to avoid stale closure issues
const currentElement = element.id ? document.getElementById(element.id) : element;
if (config.debug) {
console.log('IPFSHLSPlayer: Disposing native player, element check:', {
hasId: !!element.id,
foundById: !!currentElement,
tagName: currentElement?.tagName,
isSameElement: currentElement === element
});
}
// Check if element is still a valid video element
if (currentElement && currentElement.tagName === 'VIDEO') {
currentElement.src = '';
if (typeof currentElement.load === 'function') {
currentElement.load();
}
delete currentElement._ipfsHLSPlayer;
currentElement.dataset.ipfsEnhanced = 'false';
} else if (config.debug) {
console.warn('IPFSHLSPlayer: Cannot dispose - element is not a valid VIDEO element');
}
},
// Element access
el: () => element,
// Control bar (mock for compatibility)
controlBar: {
getChild: () => null,
show: () => {},
hide: () => {}
},
// Quality levels (mock for compatibility)
qualityLevels: () => ({
on: () => {},
off: () => {},
levels_: []
}),
// Type identification
isNativePlayer: true,
// Direct element access
element: element
};
// Store reference
element._ipfsHLSPlayer = wrapper;
if (config.debug) {
console.log('IPFSHLSPlayer: Native player initialized successfully', {
elementId: element.id,
src: element.src,
readyState: element.readyState,
networkState: element.networkState,
duration: element.duration,
paused: element.paused,
controls: element.controls,
hasWrapper: !!wrapper,
wrapperMethods: Object.keys(wrapper)
});
}
return wrapper;
}
/**
* Initialize a Video.js player with IPFS-optimized settings
* @param {HTMLVideoElement} element - Video element to enhance
* @param {Object} options - Player configuration options
* @returns {Promise<Player>} Video.js player instance
*/
static async initializeVideoJSPlayer(element, options = {}) {
// Ensure Video.js CSS is loaded before creating player
await ensureVideoJSStyles();
// Ensure element has an ID for Video.js
if (!element.id) {
element.id = `ipfs-video-${Math.random().toString(36).substr(2, 9)}`;
}
// Apply Video.js classes for consistent styling
this.applyVideoJSClasses(element, options);
// Ensure video has proper wrapper structure
this.ensureVideoWrapper(element);
// Default options optimized for IPFS HLS content
const defaultOptions = {
controls: true,
fluid: true,
responsive: true,
preload: 'auto',
playbackRates: [0.5, 1, 1.5, 2],
html5: {
vhs: {
// Critical for IPFS: Force Video.js's JavaScript HLS implementation
// instead of native browser HLS to handle CID-based URLs properly
overrideNative: true,
smoothQualityChange: true,
fastQualityChange: true
}
}
};
// Merge options
const playerOptions = { ...defaultOptions, ...options };
// Initialize Video.js
const player = videojs(element, playerOptions);
// Always initialize quality levels so it can track them as they load
player.qualityLevels();
// Set ready state when player is ready
player.ready(() => {
this.setLoadingState(element, 'ready');
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Video.js player ready, clearing loading state');
}
});
// Handle errors
player.on('error', () => {
this.setLoadingState(element, 'error');
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.error('IPFSHLSPlayer: Video.js player error:', player.error());
}
});
// Set source if provided
if (options.src) {
const sourceType = options.type || this.detectSourceType(options.src);
// Build source config
const sourceConfig = { src: options.src };
// Add type if we have one (middleware will handle IPFS URLs without type)
if (sourceType) {
sourceConfig.type = sourceType;
}
// Pass to Video.js - middleware will intercept if needed
player.src(sourceConfig);
// Add HLS quality selector if it's HLS content
if (sourceType === 'application/x-mpegURL' || options.src.includes('.m3u8')) {
// We know it's HLS from the start
player.hlsQualitySelector({
displayCurrentQuality: true,
placementIndex: 2
});
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Added HLS quality selector for known HLS content');
}
} else {
// For unknown types (like IPFS URLs), check as soon as the source type is determined
// Use loadstart which fires earlier than loadedmetadata
player.one('loadstart', () => {
const actualType = player.currentType();
// Check if it's HLS content detected by middleware
if (actualType === 'application/x-mpegURL' ||
actualType === 'application/vnd.apple.mpegurl') {
// Add quality selector immediately - quality levels haven't loaded yet
player.hlsQualitySelector({
displayCurrentQuality: true,
placementIndex: 2
});
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Added HLS quality selector after type detection:', actualType);
}
}
});
}
}
// Add error handling
player.on('error', (error) => {
console.error('IPFSHLSPlayer error:', error);
});
// Store player reference on element for later access
element._ipfsHLSPlayer = player;
// Mark as enhanced to prevent double initialization
element.dataset.ipfsEnhanced = 'true';
return player;
}
/**
* Destroy a player instance and clean up
* @param {HTMLVideoElement} element - Video element with player
*/
static destroyPlayer(element) {
const player = element._ipfsHLSPlayer || (element.id && videojs.getPlayer(element.id));
if (player && typeof player.dispose === 'function') {
player.dispose();
delete element._ipfsHLSPlayer;
// Clear the enhanced flag when destroying
delete element.dataset.ipfsEnhanced;
}
}
/**
* Extract type hints from URL query parameters
* @param {string} url - Video source URL
* @returns {string|null} MIME type hint or null
*/
static extractTypeFromURL(url) {
if (!url) return null;
try {
const urlObj = new URL(url, window.location.href); // Handle relative URLs
// Check for common type parameters
const typeParam = urlObj.searchParams.get('type') ||
urlObj.searchParams.get('format') ||
urlObj.searchParams.get('mime') ||
urlObj.searchParams.get('content-type');
// If no explicit type parameter, check for filename parameter
if (!typeParam) {
const filenameParam = urlObj.searchParams.get('filename');
if (filenameParam) {
// Reuse detectSourceType to get MIME type from filename extension
const typeFromFilename = this.detectSourceType(filenameParam);
if (typeFromFilename) {
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log(`IPFSHLSPlayer: Type detected from filename parameter '${filenameParam}': ${typeFromFilename}`);
}
return typeFromFilename;
}
}
return null;
}
// Normalize common variations
const paramLower = typeParam.toLowerCase();
// HLS variations
if (paramLower.includes('hls') || paramLower === 'm3u8' || paramLower === 'm3u') {
return 'application/x-mpegURL';
}
// DASH variations
if (paramLower.includes('dash') || paramLower === 'mpd') {
return 'application/dash+xml';
}
// MP4 variations
if (paramLower.includes('mp4') || paramLower === 'm4v') {
return 'video/mp4';
}
// WebM
if (paramLower.includes('webm')) {
return 'video/webm';
}
// MOV/QuickTime
if (paramLower.includes('mov') || paramLower.includes('quicktime')) {
return 'video/quicktime';
}
// If it looks like a MIME type already, return it
if (paramLower.includes('/')) {
return typeParam;
}
return null;
} catch (e) {
// Invalid URL, ignore
return null;
}
}
/**
* Check if content detection should be performed
* @param {string} src - Video source URL
* @param {string} type - Existing MIME type (if any)
* @returns {boolean} True if content detection should be performed
*/
static shouldDetectContent(src, type) {
// Already have type? No need to detect
if (type) return false;
// No source? Can't detect
if (!src) return false;
// Check if URL has a recognized video extension
const hasExtension = /\.(m3u8|m3u|mpd|mp4|m4v|webm|ogg|ogv|mov|avi|mkv|flv|ts|wmv|3gp)$/i.test(src);
// If no recognized extension, we should detect content
return !hasExtension;
}
/**
* Detect video source type from URL
* @param {string} src - Video source URL
* @returns {string|null} MIME type or null if unknown
*/
static detectSourceType(src) {
if (!src) return null;
const srcLower = src.toLowerCase();
// HLS/Streaming formats
if (srcLower.match(/\.(m3u8|m3u)$/)) return 'application/x-mpegURL';
if (srcLower.match(/\.mpd$/)) return 'application/dash+xml';
// Common video formats
if (srcLower.match(/\.(mp4|m4v)$/)) return 'video/mp4';
if (srcLower.match(/\.webm$/)) return 'video/webm';
if (srcLower.match(/\.(ogg|ogv)$/)) return 'video/ogg';
if (srcLower.match(/\.mov$/)) return 'video/mp4'; // Use mp4 for MOV files - Chrome won't play video/quicktime
if (srcLower.match(/\.avi$/)) return 'video/x-msvideo';
if (srcLower.match(/\.mkv$/)) return 'video/x-matroska';
if (srcLower.match(/\.flv$/)) return 'video/x-flv';
if (srcLower.match(/\.ts$/)) return 'video/mp2t';
if (srcLower.match(/\.wmv$/)) return 'video/x-ms-wmv';
if (srcLower.match(/\.3gp$/)) return 'video/3gpp';
return null; // Let browser detect type
}
/**
* Enhance an existing video element with IPFS HLS Player
* @param {HTMLVideoElement} video - Video element to enhance
* @param {Object} options - Enhancement options
* @returns {Promise<Player>} Video.js player instance
*/
static async enhanceVideoElement(video, options = {}) {
// Comprehensive DOM validation
if (!this.isVideoReady(video)) {
const state = this.getVideoState(video);
throw new Error(`Video not ready for enhancement: ${JSON.stringify(state)}`);
}
// Check if already enhanced
if (video.dataset.ipfsEnhanced === 'true') {
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Video already enhanced, returning existing player:', video.id || 'no-id');
}
return video._ipfsHLSPlayer;
}
try {
// Don't apply Video.js classes here - let each player type handle its own styling
// this.applyVideoJSClasses(video, options); // Removed - causes issues with native players
this.ensureVideoWrapper(video);
// Extract options from video element
const elementOptions = {
src: video.src || video.currentSrc,
type: video.getAttribute('type') || null,
poster: video.poster,
autoplay: video.autoplay,
loop: video.loop,
muted: video.muted,
...options
};
// Native HLS browsers: For extensionless URLs without type, detect content type
const caps = this.detectCapabilities();
if (!elementOptions.type && elementOptions.src && caps.hasNativeHLS && !caps.canOverrideNativeHLS) {
const hasExtension = /\.\w{2,4}($|\?)/.test(elementOptions.src);
if (!hasExtension) {
// Browsers with native HLS need to detect content type for proper initialization
if (window.ipfsHLSPlayerConfig?.debug) {
console.log('IPFSHLSPlayer: Native HLS browser - detecting content type for:', elementOptions.src);
}
// Try to detect if it's HLS
try {
const detectedType = await this.detectFromContent(elementOptions.src);
if (detectedType) {
elementOptions.type = detectedType;
if (window.ipfsHLSPlayerConfig?.debug) {
console.log('IPFSHLSPlayer: Native HLS browser - detected type:', detectedType);
}
}
} catch (error) {
if (window.ipfsHLSPlayerConfig?.debug) {
console.warn('IPFSHLSPlayer: Native HLS browser - detection failed:', error);
}
}
// Don't remove src from video element - native players need it present for initialization
}
}
// Initialize player
const player = await this.initializePlayer(video, elementOptions);
// Mark as enhanced
video.dataset.ipfsEnhanced = 'true';
return player;
} catch (error) {
console.error('IPFSHLSPlayer: Enhancement failed for video:', video.id || 'no-id', error);
// Set error state to show video anyway as fallback
this.setLoadingState(video, 'error');
throw error;
}
}
/**
* Check if video element is ready for enhancement
* @param {HTMLVideoElement} video - Video element to check
* @returns {boolean} True if ready
*/
static isVideoReady(video) {
return video &&
video.nodeType === Node.ELEMENT_NODE &&
video.tagName === 'VIDEO' &&
document.contains(video) &&
video.parentNode &&
!video.hasAttribute('data-ipfs-enhanced');
}
/**
* Get diagnostic state of video element
* @param {HTMLVideoElement} video - Video element to analyze
* @returns {Object} State information
*/
static getVideoState(video) {
return {
exists: !!video,
isElement: video && video.nodeType === Node.ELEMENT_NODE,
isVideo: video && video.tagName === 'VIDEO',
inDocument: video && document.contains(video),
hasParent: video && !!video.parentNode,
isEnhanced: video && video.hasAttribute('data-ipfs-enhanced'),
id: video && (video.id || 'no-id')
};
}
/**
* Apply required Video.js CSS classes to video element
* @param {HTMLVideoElement} video - Video element
* @param {Object} options - Configuration options
*/
static applyVideoJSClasses(video, options = {}) {
// Merge with defaults
const config = {
required: ['video-js', 'vjs-default-skin'],
optional: ['vjs-big-play-centered', 'vjs-fluid'],
custom: [],
...(options.cssClasses || {})
};
// Add required classes
config.required.forEach(className => {
if (!video.classList.contains(className)) {
video.classList.add(className);
}
});
// Add optional classes
config.optional.forEach(className => {
if (!video.classList.contains(className)) {
video.classList.add(className);
}
});
// Add custom classes
if (config.custom && config.custom.length > 0) {
config.custom.forEach(className => {
if (!video.classList.contains(className)) {
video.classList.add(className);
}
});
}
// No forced inline styles - let CSS handle sizing
}
/**
* Manage loading state transitions
* @param {HTMLVideoElement} video - Video element
* @param {string} state - State to transition to: 'loading', 'ready', 'error'
*/
static setLoadingState(video, state) {
const wrapper = video.closest('.ipfs-video-container');
if (!wrapper) return;
// Remove all state classes
wrapper.classList.remove('ipfs-video-loading', 'ipfs-video-ready', 'ipfs-video-error');
// Add the new state class
if (state === 'loading') {
wrapper.classList.add('ipfs-video-loading');
} else if (state === 'ready') {
wrapper.classList.add('ipfs-video-ready');
} else if (state === 'error') {
wrapper.classList.add('ipfs-video-error');
}
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log(`IPFSHLSPlayer: Set loading state to '${state}' for video:`, video.id || 'no-id');
}
}
/**
* Ensure video has proper wrapper structure for Video.js
* @param {HTMLVideoElement} video - Video element
* @returns {HTMLElement} Wrapper element
*/
static ensureVideoWrapper(video) {
// Validate video has parent node before attempting wrapper creation
if (!video.parentNode) {
throw new Error('Video element must have a parent node for wrapper creation');
}
// Check if video already has a proper wrapper
const existingWrapper = video.closest('.ipfs-video-container');
if (existingWrapper) {
// Existing wrapper found, just add player class and loading state
existingWrapper.classList.add('ipfs-hls-player', 'ipfs-video-loading');
return existingWrapper;
}
try {
// Create universal wrapper for standalone videos
const wrapper = document.createElement('div');
wrapper.className = 'ipfs-video-container ipfs-hls-player ipfs-video-loading';
// Safely insert wrapper before video and move video inside
const parent = video.parentNode;
parent.insertBefore(wrapper, video);
wrapper.appendChild(video);
const config = window.ipfsHLSPlayerConfig || {};
if (config.debug) {
console.log('IPFSHLSPlayer: Created universal wrapper with loading state for video:', video.id || 'no-id');
}
return wrapper;
} catch (error) {
console.error('IPFSHLSPlayer: Failed to create wrapper for video:', video.id || 'no-id', error);
throw error;
}
}
/**
* Enhance all unenhanced videos in a container
* @param {HTMLElement} container - Container to search within
* @returns {Promise<Array>} Array of player instances
*/
static async enhanceStaticVideos(container = document) {
const config = window.ipfsHLSPlayerConfig || {};
// Skip videos that are already enhanced or are Video.js tech elements
const videos = container.querySelectorAll('video:not([data-ipfs-enhanced]):not([id$="_html5_api"])');
const players = [];
if (config.debug) {
console.log(`IPFSHLSPlayer: Found ${videos.length} static videos to enhance`);
}
for (const video of videos) {
try {
const player = await this.enhanceVideoElement(video);
players.push(player);
if (config.debug) {
console.log('IPFSHLSPlayer: Successfully enhanced static video:', video.id || 'no-id');
}
} catch (error) {
console.error('Failed to enhance static video:', error);
}
}
return players;
}
/**
* Ensure Video.js styles are loaded
* @returns {Promise} Resolves when styles are ready
*/
static async ensureVideoJSStyles() {
return ensureVideoJSStyles();
}
}
/**
* Ensure Video.js CSS is loaded - enhanced fallback system for universal support
* @returns {Promise} Resolves when CSS is ready
*/
function ensureVideoJSStyles() {
const config = window.ipfsHLSPlayerConfig || {};
return new Promise((resolve) => {
// Check if Video.js styles are already loaded by URL or inline
const existingStyleLink = document.querySelector('link[href*="video-js"]');
const existingVjsFallback = document.querySelector('link[data-vjs-fallback="true"]');
if (existingStyleLink || existingVjsFallback) {
if (config.debug) {
console.log('IPFSHLSPlayer: Video.js CSS already loaded via link tag');
}
return resolve();
}
// More comprehensive test for Video.js CSS
const testElement = document.createElement('div');
testElement.className = 'video-js';
testElement.style.position = 'absolute';
testElement.style.left = '-9999px';
testElement.style.visibility = 'hidden';
document.body.appendChild(testElement);
const computedStyle = window.getComputedStyle(testElement);
// Enhanced Video.js CSS detection
const hasVideoJSStyles = (
computedStyle.position === 'relative' ||
computedStyle.display === 'inline-block' ||
computedStyle.fontSize === '10px' ||
computedStyle.boxSizing === 'border-box' ||
computedStyle.backgroundColor === 'rgb(0, 0, 0)' ||
computedStyle.color === 'rgb(255, 255, 255)'
);
document.body.removeChild(testElement);
if (hasVideoJSStyles) {
if (config.debug) {
console.log('IPFSHLSPlayer: Video.js CSS detected via computed styles');
}
return resolve();
}
// CSS not detected - load fallback
console.warn('IPFSHLSPlayer: Video.js CSS not detected, loading fallback');
// Try multiple fallback sources
const fallbackSources = [
'https://vjs.zencdn.net/8.6.1/video-js.css',
'https://cdnjs.cloudflare.com/ajax/libs/video.js/8.6.1/video-js.min.css'
];
let loadAttempts = 0;
function tryLoadCSS(sourceIndex = 0) {
if (sourceIndex >= fallbackSources.length) {
console.error('IPFSHLSPlayer: All CSS fallback sources failed, proceeding without external CSS');
return resolve();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fallbackSources[sourceIndex];
link.dataset.vjsFallback = 'true';
link.dataset.attempt = loadAttempts++;
link.onload = () => {
if (config.debug) {
console.log('IPFSHLSPlayer: Successfully loaded fallback CSS from:', link.href);
}
resolve();
};
link.onerror = () => {
console.warn('IPFSHLSPlayer: Failed to load CSS from:', link.href);
// Remove failed link and try next source
document.head.removeChild(link);
tryLoadCSS(sourceIndex + 1);
};
document.head.appendChild(link);
// Timeout fallback
setTimeout(() => {
if (!link.sheet) {