unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
1,405 lines (1,208 loc) • 396 kB
text/typescript
/**
* Web implementation of the video player with HLS and DASH support
*/
import { BasePlayer } from '../../core/dist/BasePlayer';
import {
VideoSource,
PlayerConfig,
Quality,
SubtitleTrack,
PlayerError,
ChapterManager as CoreChapterManager,
Chapter,
ChapterSegment
} from '../../core/dist/index';
import { ChapterManager } from './chapters/ChapterManager';
import {
ChapterConfig,
VideoChapters,
VideoSegment,
ChapterEvents
} from './chapters/types/ChapterTypes';
import type { FlashNewsTickerConfig } from './react/types/FlashNewsTickerTypes';
import YouTubeExtractor from './utils/YouTubeExtractor';
// Dynamic imports for streaming libraries
declare global {
interface Window {
Hls: any;
dashjs: any;
cast?: any;
chrome?: any;
YT?: any;
__onGCastApiAvailable?: (isAvailable: boolean) => void;
}
}
export class WebPlayer extends BasePlayer {
protected video: HTMLVideoElement | null = null;
private hls: any = null;
private dash: any = null;
private qualities: Quality[] = [];
private currentQualityIndex: number = -1;
private autoQuality: boolean = true;
private useCustomControls: boolean = true;
private controlsContainer: HTMLElement | null = null;
private volumeHideTimeout: NodeJS.Timeout | null = null;
private hideControlsTimeout: NodeJS.Timeout | null = null;
private isVolumeSliding: boolean = false;
private availableQualities: Array<{ value: string, label: string }> = [];
private availableSubtitles: Array<{ value: string, label: string }> = [];
private currentQuality = 'auto';
private currentSubtitle = 'off';
private currentPlaybackRate = 1;
private isDragging: boolean = false;
// Settings configuration
private settingsConfig = {
enabled: true, // Show settings button
speed: true, // Show playback speed options
quality: true, // Show quality options
subtitles: true // Show subtitle options
};
private watermarkCanvas: HTMLCanvasElement | null = null;
private playerWrapper: HTMLElement | null = null;
// Flash News Ticker
private flashTickerContainer: HTMLDivElement | null = null;
private flashTickerTopElement: HTMLDivElement | null = null;
private flashTickerBottomElement: HTMLDivElement | null = null;
// Free preview gate state
private previewGateHit: boolean = false;
private paymentSuccessTime: number = 0;
private paymentSuccessful: boolean = false;
// Security state to prevent paywall bypass
private isPaywallActive: boolean = false;
private authValidationInterval: any = null;
private overlayRemovalAttempts: number = 0;
private maxOverlayRemovalAttempts: number = 3;
private lastSecurityCheck: number = 0;
// Cast state
private castContext: any = null;
private remotePlayer: any = null;
private remoteController: any = null;
private isCasting: boolean = false;
private _castTrackIdByKey: Record<string, number> = {};
private selectedSubtitleKey: string = 'off';
private _kiTo: any = null;
// Paywall
private paywallController: any = null;
// Play/pause coordination to prevent race conditions
private _playPromise: Promise<void> | null = null;
private _deferredPause = false;
private _lastToggleAt = 0;
private _TOGGLE_DEBOUNCE_MS = 120;
// Fullscreen fallback tracking
private hasTriedButtonFallback: boolean = false;
private lastUserInteraction: number = 0;
// Progress bar tooltip state
private showTimeTooltip: boolean = false;
// Advanced tap handling state
private tapStartTime: number = 0;
private tapStartX: number = 0;
private tapStartY: number = 0;
private lastTapTime: number = 0;
private lastTapX: number = 0;
private tapCount: number = 0;
private longPressTimer: NodeJS.Timeout | null = null;
private isLongPressing: boolean = false;
private longPressPlaybackRate: number = 1;
private tapResetTimer: NodeJS.Timeout | null = null;
private fastBackwardInterval: NodeJS.Timeout | null = null;
private handleSingleTap: () => void = () => { };
private handleDoubleTap: (tapX: number) => void = () => { };
private handleLongPress: (tapX: number) => void = () => { };
private handleLongPressEnd: () => void = () => { };
// Autoplay enhancement state
private autoplayCapabilities: {
canAutoplay: boolean;
canAutoplayMuted: boolean;
canAutoplayUnmuted: boolean;
lastCheck: number;
} = {
canAutoplay: false,
canAutoplayMuted: false,
canAutoplayUnmuted: false,
lastCheck: 0
};
private autoplayRetryPending: boolean = false;
private autoplayRetryAttempts: number = 0;
private maxAutoplayRetries: number = 3;
// Chapter management
private chapterManager: ChapterManager | null = null;
// Start time tracking
private hasAppliedStartTime: boolean = false;
private coreChapterManager: CoreChapterManager | null = null;
private chapterConfig: ChapterConfig = { enabled: false };
// Quality filter
private qualityFilter: any = null;
// Premium qualities configuration
private premiumQualities: any = null;
// YouTube native controls configuration
private youtubeNativeControls: boolean = true;
// Ad playing state (set by Google Ads Manager)
private isAdPlaying: boolean = false;
// Fallback source management
private fallbackSourceIndex: number = -1;
private fallbackErrors: Array<{ url: string; error: any }> = [];
private isLoadingFallback: boolean = false;
private currentRetryAttempt: number = 0;
private lastFailedUrl: string = ''; // Track last failed URL to avoid duplicate error handling
private isFallbackPosterMode: boolean = false; // True when showing fallback poster (no playable sources)
// Debug logging helper
private debugLog(message: string, ...args: any[]): void {
if (this.config.debug) {
console.log(`[WebPlayer] ${message}`, ...args);
}
}
private debugError(message: string, ...args: any[]): void {
if (this.config.debug) {
console.error(`[WebPlayer] ${message}`, ...args);
}
}
private debugWarn(message: string, ...args: any[]): void {
if (this.config.debug) {
console.warn(`[WebPlayer] ${message}`, ...args);
}
}
async initialize(container: HTMLElement | string, config?: any): Promise<void> {
// Debug log the config being passed
console.log('WebPlayer.initialize called with config:', config);
// Set useCustomControls based on controls and customControls config
// Priority: customControls > controls
if (config) {
if (config.customControls !== undefined) {
// If customControls is explicitly set, use that
this.useCustomControls = config.customControls;
console.log('[WebPlayer] Custom controls set to:', this.useCustomControls);
} else if (config.controls !== undefined) {
// Otherwise, use the controls prop value
this.useCustomControls = config.controls;
console.log('[WebPlayer] Controls set to:', this.useCustomControls);
}
}
// Configure settings menu options
if (config && config.settings) {
console.log('Settings config found:', config.settings);
this.settingsConfig = {
enabled: config.settings.enabled !== undefined ? config.settings.enabled : true,
speed: config.settings.speed !== undefined ? config.settings.speed : true,
quality: config.settings.quality !== undefined ? config.settings.quality : true,
subtitles: config.settings.subtitles !== undefined ? config.settings.subtitles : true
};
console.log('Settings config applied:', this.settingsConfig);
} else {
console.log('No settings config found, using defaults:', this.settingsConfig);
}
// Configure chapters if provided
if (config && config.chapters) {
console.log('Chapter config found:', config.chapters);
this.chapterConfig = {
enabled: config.chapters.enabled || false,
data: config.chapters.data,
dataUrl: config.chapters.dataUrl,
autoHide: config.chapters.autoHide !== undefined ? config.chapters.autoHide : true,
autoHideDelay: config.chapters.autoHideDelay || 5000,
showChapterMarkers: config.chapters.showChapterMarkers !== undefined ? config.chapters.showChapterMarkers : true,
skipButtonPosition: config.chapters.skipButtonPosition || 'bottom-right',
customStyles: config.chapters.customStyles || {},
userPreferences: config.chapters.userPreferences || {
autoSkipIntro: false,
autoSkipRecap: false,
autoSkipCredits: false,
showSkipButtons: true,
skipButtonTimeout: 5000,
rememberChoices: true
}
};
console.log('Chapter config applied:', this.chapterConfig);
} else {
console.log('No chapter config found, chapters disabled');
}
// Configure quality filter if provided
if (config && config.qualityFilter) {
console.log('Quality filter config found:', config.qualityFilter);
this.qualityFilter = config.qualityFilter;
}
// Configure premium qualities if provided
if (config && config.premiumQualities) {
console.log('Premium qualities config found:', config.premiumQualities);
this.premiumQualities = config.premiumQualities;
}
// Configure YouTube native controls if provided
if (config && config.youtubeNativeControls !== undefined) {
console.log('YouTube native controls config found:', config.youtubeNativeControls);
this.youtubeNativeControls = config.youtubeNativeControls;
}
// Call parent initialize
await super.initialize(container, config);
}
protected async setupPlayer(): Promise<void> {
if (!this.container) {
throw new Error('Container element is required');
}
// Inject styles
this.injectStyles();
// Create wrapper
const wrapper = document.createElement('div');
wrapper.className = 'uvf-player-wrapper';
this.playerWrapper = wrapper;
// Create video container
const videoContainer = document.createElement('div');
videoContainer.className = 'uvf-video-container';
// Create video element
this.video = document.createElement('video');
this.video.className = 'uvf-video';
this.video.controls = false; // We'll use custom controls
// Don't set autoplay attribute - we'll handle it programmatically with intelligent detection
this.video.autoplay = false;
// Respect user's muted preference, intelligent autoplay will handle browser policies
this.video.muted = this.config.muted ?? false;
this.state.isMuted = this.video.muted;
this.video.loop = this.config.loop ?? false;
this.video.playsInline = this.config.playsInline ?? true;
this.video.preload = this.config.preload ?? 'metadata';
// Enable AirPlay for iOS devices
(this.video as any).webkitAllowsAirPlay = true;
this.video.setAttribute('x-webkit-airplay', 'allow');
if (this.config.crossOrigin) {
this.video.crossOrigin = this.config.crossOrigin;
}
// Add watermark canvas
this.watermarkCanvas = document.createElement('canvas');
this.watermarkCanvas.className = 'uvf-watermark-layer';
// Create flash news ticker container
this.flashTickerContainer = document.createElement('div');
this.flashTickerContainer.className = 'uvf-flash-ticker-container';
this.flashTickerContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
pointer-events: none;
`;
// Add video to container
videoContainer.appendChild(this.video);
videoContainer.appendChild(this.watermarkCanvas);
videoContainer.appendChild(this.flashTickerContainer);
// Always create custom controls
this.createCustomControls(videoContainer);
// Apply controls-disabled class if controls={false}
if (!this.useCustomControls) {
wrapper.classList.add('controls-disabled');
}
// Assemble the player
wrapper.appendChild(videoContainer);
// Add to container
this.container.innerHTML = '';
this.container.appendChild(wrapper);
// Apply scrollbar preferences from data attributes, if any
this.applyScrollbarPreferencesFromDataset();
// Setup event listeners
this.setupVideoEventListeners();
this.setupControlsEventListeners();
this.setupKeyboardShortcuts();
this.setupWatermark();
this.setupFullscreenListeners();
this.setupUserInteractionTracking();
// Initialize chapter manager if enabled
if (this.chapterConfig.enabled && this.video) {
this.setupChapterManager();
}
// Initialize paywall controller if provided
try {
const pw: any = (this.config as any).paywall || null;
if (pw && pw.enabled) {
const { PaywallController } = await import('./paywall/PaywallController');
this.paywallController = new (PaywallController as any)(pw, {
getOverlayContainer: () => this.playerWrapper,
onResume: () => {
try {
this.debugLog('onResume callback triggered - payment/auth successful');
// Reset all security state after successful payment/auth
this.previewGateHit = false;
this.paymentSuccessTime = Date.now();
this.paymentSuccessful = true;
this.isPaywallActive = false;
this.overlayRemovalAttempts = 0;
// Clear security monitoring immediately
if (this.authValidationInterval) {
this.debugLog('Clearing security monitoring interval');
clearInterval(this.authValidationInterval);
this.authValidationInterval = null;
}
// Force cleanup of any remaining overlays
this.forceCleanupOverlays();
this.debugLog('Payment successful - all security restrictions lifted, resuming playback');
// Give a small delay to ensure overlay is properly closed before resuming
setTimeout(() => {
this.play();
}, 150); // Slightly longer delay for complete cleanup
} catch (error) {
this.debugError('Error in onResume callback:', error);
}
},
onShow: () => {
// Activate security monitoring when paywall is shown
this.isPaywallActive = true;
this.startOverlayMonitoring();
// Use safe pause method to avoid race conditions
try { this.requestPause(); } catch (_) { }
},
onClose: () => {
this.debugLog('onClose callback triggered - paywall closing');
// Deactivate security monitoring when paywall is closed
this.isPaywallActive = false;
// Clear monitoring interval
if (this.authValidationInterval) {
this.debugLog('Clearing security monitoring interval on close');
clearInterval(this.authValidationInterval);
this.authValidationInterval = null;
}
// Reset overlay removal attempts counter
this.overlayRemovalAttempts = 0;
}
});
// When free preview ends, open overlay
this.on('onFreePreviewEnded' as any, () => {
this.debugLog('onFreePreviewEnded event triggered, calling paywallController.openOverlay()');
try {
this.paywallController?.openOverlay();
} catch (error) {
this.debugError('Error calling paywallController.openOverlay():', error);
}
});
}
} catch (_) { }
// Attempt to bind Cast context if available
this.setupCastContextSafe();
// Initialize metadata UI to hidden/empty by default
this.updateMetadataUI();
}
private autoplayAttempted: boolean = false;
private setupVideoEventListeners(): void {
if (!this.video) return;
this.video.addEventListener('play', () => {
// Don't enforce preview if payment was successful
if (!this.paymentSuccessful && this.config.freeDuration && this.config.freeDuration > 0) {
const lim = Number(this.config.freeDuration);
const cur = (this.video?.currentTime || 0);
if (!this.previewGateHit && cur >= lim) {
try { this.video?.pause(); } catch (_) { }
this.showNotification('Free preview ended. Please rent to continue.');
return;
}
}
this.state.isPlaying = true;
this.state.isPaused = false;
this.emit('onPlay');
});
this.video.addEventListener('playing', () => {
// Handle deferred pause requests
if (this._deferredPause) {
this._deferredPause = false;
try { this.video?.pause(); } catch (_) { }
}
// Stop buffering state
this.setBuffering(false);
});
this.video.addEventListener('pause', () => {
this.state.isPlaying = false;
this.state.isPaused = true;
this.emit('onPause');
});
this.video.addEventListener('ended', () => {
this.state.isEnded = true;
this.state.isPlaying = false;
this.emit('onEnded');
});
this.video.addEventListener('timeupdate', () => {
if (!this.video) return;
const t = this.video.currentTime || 0;
this.updateTime(t);
// Emit time update event for React components (e.g., commerce sync)
this.emit('onTimeUpdate', t);
// Debug: Log time update every ~5 seconds
if (Math.floor(t) % 5 === 0 && Math.floor(t) !== Math.floor((this.video?.currentTime || 0) - 0.1)) {
console.log(`[DEBUG] onTimeUpdate emitted: ${t.toFixed(2)}s`);
}
// Enforce free preview gate on local playback
this.enforceFreePreviewGate(t);
// Process chapter time updates
if (this.coreChapterManager) {
this.coreChapterManager.processTimeUpdate(t);
}
});
this.video.addEventListener('progress', () => {
this.updateBufferProgress();
});
this.video.addEventListener('waiting', () => {
this.setBuffering(true);
});
this.video.addEventListener('canplay', () => {
this.debugLog('📡 canplay event fired');
// Reset fallback tracking on successful load
if (this.isLoadingFallback) {
this.debugLog('✅ Fallback source loaded successfully!');
this.isLoadingFallback = false;
this.lastFailedUrl = '';
}
// Reset fallback poster mode when video successfully loads
if (this.isFallbackPosterMode) {
this.debugLog('✅ Exiting fallback poster mode - video source loaded');
this.isFallbackPosterMode = false;
// Remove fallback poster overlay if it exists
const posterOverlay = this.playerWrapper?.querySelector('#uvf-fallback-poster');
if (posterOverlay) {
posterOverlay.remove();
}
// Show video element again
if (this.video) {
this.video.style.display = '';
}
}
this.setBuffering(false);
this.emit('onReady');
// Update time display when video is ready to play
this.updateTimeDisplay();
// Handle deferred pause requests
if (this._deferredPause) {
this._deferredPause = false;
try { this.video?.pause(); } catch (_) { }
}
// Attempt autoplay once when video is ready to play
this.debugLog(`🎬 Autoplay check: config.autoPlay=${this.config.autoPlay}, autoplayAttempted=${this.autoplayAttempted}`);
if (this.config.autoPlay && !this.autoplayAttempted) {
this.debugLog('🎬 Starting intelligent autoplay attempt');
this.autoplayAttempted = true;
this.attemptIntelligentAutoplay().then(success => {
if (!success) {
this.debugWarn('❌ Intelligent autoplay failed - will retry on user interaction');
this.setupAutoplayRetry();
} else {
this.debugLog('✅ Intelligent autoplay succeeded');
}
}).catch(error => {
this.debugError('Autoplay failed:', error);
this.setupAutoplayRetry();
});
} else {
this.debugLog(`⛔ Skipping autoplay: autoPlay=${this.config.autoPlay}, attempted=${this.autoplayAttempted}`);
}
});
this.video.addEventListener('loadedmetadata', () => {
if (!this.video) return;
this.state.duration = this.video.duration || 0;
this.debugLog('Metadata loaded - duration:', this.video.duration);
// Update time display immediately when metadata loads
this.updateTimeDisplay();
// Apply startTime if configured and not yet applied
if (this.config.startTime !== undefined &&
this.config.startTime > 0 &&
!this.hasAppliedStartTime &&
this.video.duration > 0) {
const startTime = Math.min(this.config.startTime, this.video.duration);
this.debugLog(`⏩ Seeking to startTime: ${startTime}s`);
this.seek(startTime);
this.hasAppliedStartTime = true;
}
this.emit('onLoadedMetadata', {
duration: this.video.duration || 0,
width: this.video.videoWidth || 0,
height: this.video.videoHeight || 0
});
});
this.video.addEventListener('volumechange', () => {
if (!this.video) return;
this.state.volume = this.video.volume;
this.state.isMuted = this.video.muted;
this.emit('onVolumeChanged', this.video.volume);
});
this.video.addEventListener('error', async (e) => {
if (!this.video || !this.video.src) return;
const error = this.video.error;
if (error) {
const currentSrc = this.video.src || this.video.currentSrc || '';
// Avoid processing the same error multiple times
if (this.lastFailedUrl === currentSrc && this.isLoadingFallback) {
this.debugLog(`⚠️ Duplicate error for same URL while loading fallback, ignoring: ${currentSrc}`);
return;
}
this.lastFailedUrl = currentSrc;
this.debugLog(`Video error detected (code: ${error.code}):`, error.message);
this.debugLog(`Failed source: ${currentSrc}`);
this.debugLog(`Fallback check - isLoadingFallback: ${this.isLoadingFallback}`);
this.debugLog(`Fallback check - fallbackSources:`, this.source?.fallbackSources);
this.debugLog(`Fallback check - fallbackPoster:`, this.source?.fallbackPoster);
this.debugLog(`Fallback check - has fallbackSources: ${!!this.source?.fallbackSources?.length}`);
this.debugLog(`Fallback check - has fallbackPoster: ${!!this.source?.fallbackPoster}`);
// Try fallback sources if available and not already loading one
if (!this.isLoadingFallback && (this.source?.fallbackSources?.length || this.source?.fallbackPoster)) {
this.debugLog('✅ Attempting to load fallback sources...');
const fallbackLoaded = await this.tryFallbackSource(error);
if (fallbackLoaded) {
this.debugLog('✅ Fallback loaded successfully!');
// Successfully loaded fallback, don't call handleError
return;
}
this.debugLog('❌ All fallbacks failed');
} else {
this.debugLog('❌ No fallback sources available or already loading');
}
// No fallback available or all fallbacks failed - handle error normally
if (!this.isLoadingFallback) {
this.handleError({
code: `MEDIA_ERR_${error.code}`,
message: error.message || this.getMediaErrorMessage(error.code),
type: 'media',
fatal: true,
details: error
});
}
}
});
this.video.addEventListener('seeking', () => {
this.emit('onSeeking');
});
this.video.addEventListener('seeked', () => {
// Apply gate if user seeks beyond free preview
if (!this.video) return;
const t = this.video.currentTime || 0;
this.enforceFreePreviewGate(t, true);
this.emit('onSeeked');
});
}
private getMediaErrorMessage(code: number): string {
switch (code) {
case 1: return 'Media loading aborted';
case 2: return 'Network error';
case 3: return 'Media decoding failed';
case 4: return 'Media format not supported';
default: return 'Unknown media error';
}
}
private updateBufferProgress(): void {
if (!this.video) return;
const buffered = this.video.buffered;
if (buffered.length > 0) {
const bufferedEnd = buffered.end(buffered.length - 1);
const duration = this.video.duration;
const percentage = duration > 0 ? (bufferedEnd / duration) * 100 : 0;
this.updateBuffered(percentage);
}
}
async load(source: any): Promise<void> {
this.source = source as any;
this.subtitles = (source.subtitles || []) as any;
this.debugLog('Loading video source:', source.url);
this.debugLog('Fallback sources provided:', source.fallbackSources);
this.debugLog('Fallback poster provided:', source.fallbackPoster);
// Reset autoplay flag for new source
this.autoplayAttempted = false;
// Reset startTime flag for new source
this.hasAppliedStartTime = false;
// Reset fallback state for new source
this.fallbackSourceIndex = -1;
this.fallbackErrors = [];
this.isLoadingFallback = false;
this.currentRetryAttempt = 0;
this.lastFailedUrl = '';
this.isFallbackPosterMode = false; // Reset fallback poster mode
// Clean up previous instances
await this.cleanup();
if (!this.video) {
throw new Error('Video element not initialized');
}
// Detect source type
const sourceType = this.detectSourceType(source);
try {
await this.loadVideoSource(source.url, sourceType, source);
} catch (error) {
// Try fallback sources if available
const fallbackLoaded = await this.tryFallbackSource(error);
if (!fallbackLoaded) {
this.handleError({
code: 'LOAD_ERROR',
message: `Failed to load video: ${error}`,
type: 'network',
fatal: true,
details: error
});
throw error;
}
}
}
/**
* Load a video source (main or fallback)
*/
private async loadVideoSource(url: string, sourceType: string, source: any): Promise<void> {
switch (sourceType) {
case 'hls':
await this.loadHLS(url);
break;
case 'dash':
await this.loadDASH(url);
break;
case 'youtube':
await this.loadYouTube(url, source);
break;
default:
await this.loadNative(url);
}
// Load subtitles if provided
if (source.subtitles && source.subtitles.length > 0) {
this.loadSubtitles(source.subtitles);
}
// Apply metadata
if (source.metadata) {
if (source.metadata.posterUrl && this.video) {
this.video.poster = source.metadata.posterUrl;
}
// Update player UI with metadata (title, description, thumbnail)
this.updateMetadataUI();
} else {
// Clear to defaults if no metadata
this.updateMetadataUI();
}
}
/**
* Try loading the next fallback source
*/
private async tryFallbackSource(error: any): Promise<boolean> {
this.debugLog('🔄 tryFallbackSource called');
this.debugLog('🔄 isLoadingFallback:', this.isLoadingFallback);
this.debugLog('🔄 fallbackSources:', this.source?.fallbackSources);
this.debugLog('🔄 fallbackSources length:', this.source?.fallbackSources?.length);
this.debugLog('🔄 Current fallbackSourceIndex:', this.fallbackSourceIndex);
this.debugLog('🔄 Current retry attempt:', this.currentRetryAttempt);
if (this.isLoadingFallback) {
this.debugLog('⚠️ Already loading a fallback, skipping');
return false;
}
if (!this.source?.fallbackSources || this.source.fallbackSources.length === 0) {
this.debugLog('⚠️ No fallback sources available, trying fallback poster');
return this.showFallbackPoster();
}
this.debugLog('✅ Starting fallback loading process');
this.isLoadingFallback = true;
// Record current error
const currentUrl = this.fallbackSourceIndex === -1
? this.source.url
: this.source.fallbackSources[this.fallbackSourceIndex]?.url;
this.fallbackErrors.push({ url: currentUrl, error });
this.debugLog(`Source failed: ${currentUrl}`, error);
// Don't retry the main URL - go straight to first fallback
// Only retry actual fallback sources
const isMainUrl = this.fallbackSourceIndex === -1;
const maxRetries = this.source.fallbackRetryAttempts || 1;
if (!isMainUrl && this.currentRetryAttempt < maxRetries) {
// Only retry if this is a fallback source (not the main URL)
this.currentRetryAttempt++;
this.debugLog(`Retrying fallback source (attempt ${this.currentRetryAttempt}/${maxRetries}): ${currentUrl}`);
const retryDelay = this.source.fallbackRetryDelay || 1000;
await new Promise(resolve => setTimeout(resolve, retryDelay));
try {
const sourceType = this.detectSourceType({ url: currentUrl, type: this.source.type });
await this.loadVideoSource(currentUrl, sourceType, this.source);
// Don't mark as successful immediately - let it load and see if error happens
// Just continue and see what happens
this.debugLog(`Retry initiated for: ${currentUrl} - waiting for load confirmation...`);
// Return false to continue the fallback chain if this fails again
} catch (retryError) {
this.debugLog(`Retry failed for: ${currentUrl}`, retryError);
// Continue to next fallback
}
} else {
if (isMainUrl) {
this.debugLog(`⏭️ Skipping retry of main URL, moving to first fallback source`);
} else {
this.debugLog(`⏭️ Max retries (${maxRetries}) reached for ${currentUrl}, moving to next fallback`);
}
}
// Move to next fallback source
this.currentRetryAttempt = 0;
this.fallbackSourceIndex++;
if (this.fallbackSourceIndex >= this.source.fallbackSources.length) {
// All sources exhausted
this.isLoadingFallback = false;
this.debugLog('All video sources failed. Attempting to show fallback poster.');
// Trigger callback if provided
if (this.source.onAllSourcesFailed) {
try {
this.source.onAllSourcesFailed(this.fallbackErrors);
} catch (callbackError) {
console.error('Error in onAllSourcesFailed callback:', callbackError);
}
}
return this.showFallbackPoster();
}
// Try next fallback source
const fallbackSource = this.source.fallbackSources[this.fallbackSourceIndex];
this.debugLog(`Trying fallback source ${this.fallbackSourceIndex + 1}/${this.source.fallbackSources.length}: ${fallbackSource.url}`);
const retryDelay = this.source.fallbackRetryDelay || 1000;
await new Promise(resolve => setTimeout(resolve, retryDelay));
try {
const sourceType = this.detectSourceType(fallbackSource);
await this.loadVideoSource(fallbackSource.url, sourceType, this.source);
this.isLoadingFallback = false;
this.debugLog(`Successfully loaded fallback source: ${fallbackSource.url}`);
this.showNotification(`Switched to backup source ${this.fallbackSourceIndex + 1}`);
return true;
} catch (fallbackError) {
this.debugLog(`Fallback source failed: ${fallbackSource.url}`, fallbackError);
this.isLoadingFallback = false;
// Recursively try next fallback
return this.tryFallbackSource(fallbackError);
}
}
/**
* Show fallback poster image when all video sources fail
*/
private showFallbackPoster(): boolean {
if (!this.source?.fallbackPoster) {
this.debugLog('No fallback poster available');
return false;
}
this.debugLog('Showing fallback poster:', this.source.fallbackPoster);
// Set flag to indicate we're in fallback poster mode (no playable sources)
this.isFallbackPosterMode = true;
this.debugLog('✅ Fallback poster mode activated - playback disabled');
if (this.video) {
// Hide video element, show poster
this.video.style.display = 'none';
this.video.poster = this.source.fallbackPoster;
// Remove src to prevent play attempts
this.video.removeAttribute('src');
this.video.load(); // Reset video element
}
// Create poster overlay
const posterOverlay = document.createElement('div');
posterOverlay.id = 'uvf-fallback-poster';
posterOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('${this.source.fallbackPoster}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
`;
// Add error message overlay (if enabled)
const showErrorMessage = this.source.fallbackShowErrorMessage !== false; // Default to true
if (showErrorMessage) {
const errorMessage = document.createElement('div');
errorMessage.style.cssText = `
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px 30px;
border-radius: 8px;
text-align: center;
max-width: 400px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
`;
errorMessage.innerHTML = `
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="16" width="20" height="16" rx="2" />
<polygon points="34,20 42,16 42,32 34,28" />
<line x1="5" y1="8" x2="38" y2="40" stroke="currentColor" stroke-width="2"/>
</svg>
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">Video Unavailable</div>
<div style="font-size: 14px; opacity: 0.9;">This video cannot be played at the moment.</div>
`;
posterOverlay.appendChild(errorMessage);
}
// Remove existing fallback poster if any
const existingPoster = this.playerWrapper?.querySelector('#uvf-fallback-poster');
if (existingPoster) {
existingPoster.remove();
}
// Add to player
if (this.playerWrapper) {
this.playerWrapper.appendChild(posterOverlay);
}
this.showNotification('Video unavailable');
return true;
}
private detectSourceType(source: VideoSource): string {
if (source.type && source.type !== 'auto') {
return source.type;
}
const url = source.url.toLowerCase();
// Check for YouTube URLs
if (YouTubeExtractor.isYouTubeUrl(url)) {
return 'youtube';
}
if (url.includes('.m3u8')) return 'hls';
if (url.includes('.mpd')) return 'dash';
if (url.includes('.mp4')) return 'mp4';
if (url.includes('.webm')) return 'webm';
return 'mp4'; // default
}
private async loadHLS(url: string): Promise<void> {
// Check if HLS.js is available
if (!window.Hls) {
await this.loadScript('https://cdn.jsdelivr.net/npm/hls.js@latest');
}
if (window.Hls.isSupported()) {
this.hls = new window.Hls({
debug: this.config.debug,
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90
});
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
this.hls.on(window.Hls.Events.MANIFEST_PARSED, (event: any, data: any) => {
// Extract quality levels
this.qualities = data.levels.map((level: any, index: number) => ({
height: level.height,
width: level.width || 0,
bitrate: level.bitrate,
label: `${level.height}p`,
index: index
}));
// Update settings menu with detected qualities
this.updateSettingsMenu();
// Apply quality filter automatically if configured (for auto quality mode)
if (this.qualityFilter || (this.premiumQualities && this.premiumQualities.enabled)) {
this.debugLog('Applying quality filter on HLS manifest load');
this.applyHLSQualityFilter();
}
// Note: Autoplay is now handled in the 'canplay' event when video is ready
});
this.hls.on(window.Hls.Events.LEVEL_SWITCHED, (event: any, data: any) => {
if (this.qualities[data.level]) {
this.currentQualityIndex = data.level;
this.state.currentQuality = this.qualities[data.level] as any;
this.emit('onQualityChanged', this.qualities[data.level]);
}
});
this.hls.on(window.Hls.Events.ERROR, (event: any, data: any) => {
if (data.fatal) {
this.handleHLSError(data);
}
});
} else if (this.video!.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
this.video!.src = url;
} else {
throw new Error('HLS is not supported in this browser');
}
}
private handleHLSError(data: any): void {
const Hls = window.Hls;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Fatal network error, trying to recover');
this.hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Fatal media error, trying to recover');
this.hls.recoverMediaError();
break;
default:
console.error('Fatal error, cannot recover');
this.handleError({
code: 'HLS_ERROR',
message: data.details,
type: 'media',
fatal: true,
details: data
});
this.hls.destroy();
break;
}
}
private async loadDASH(url: string): Promise<void> {
// Check if dash.js is available
if (!window.dashjs) {
await this.loadScript('https://cdn.dashjs.org/latest/dash.all.min.js');
}
this.dash = window.dashjs.MediaPlayer().create();
this.dash.initialize(this.video, url, this.config.autoPlay);
// Configure DASH settings
this.dash.updateSettings({
streaming: {
abr: {
autoSwitchBitrate: {
video: this.config.enableAdaptiveBitrate ?? true,
audio: true
}
},
buffer: {
fastSwitchEnabled: true
}
}
});
// Listen for quality changes
this.dash.on(window.dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, (e: any) => {
if (e.mediaType === 'video') {
this.updateDASHQuality(e.newQuality);
}
});
// Extract available qualities
this.dash.on(window.dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => {
const bitrateList = this.dash.getBitrateInfoListFor('video');
if (bitrateList && bitrateList.length > 0) {
this.qualities = bitrateList.map((info: any, index: number) => ({
height: info.height || 0,
width: info.width || 0,
bitrate: info.bitrate,
label: `${info.height}p`,
index: index
}));
// Update settings menu with detected qualities
this.updateSettingsMenu();
// Apply quality filter automatically if configured (for auto quality mode)
if (this.qualityFilter || (this.premiumQualities && this.premiumQualities.enabled)) {
this.debugLog('Applying quality filter on DASH stream initialization');
this.applyDASHQualityFilter();
}
}
});
// Handle errors
this.dash.on(window.dashjs.MediaPlayer.events.ERROR, (e: any) => {
this.handleError({
code: 'DASH_ERROR',
message: e.error.message,
type: 'media',
fatal: true,
details: e
});
});
}
private updateDASHQuality(qualityIndex: number): void {
if (this.qualities[qualityIndex]) {
this.currentQualityIndex = qualityIndex;
this.state.currentQuality = this.qualities[qualityIndex] as any;
this.emit('onQualityChanged', this.qualities[qualityIndex]);
}
}
private async loadNative(url: string): Promise<void> {
if (!this.video) return;
this.video.src = url;
this.video.load();
}
private async loadYouTube(url: string, source: any): Promise<void> {
try {
this.debugLog('Loading YouTube video:', url);
// Extract video ID and fetch metadata
const videoId = YouTubeExtractor.extractVideoId(url);
if (!videoId) {
throw new Error('Invalid YouTube URL');
}
// Fetch YouTube metadata (title, thumbnail)
const metadata = await YouTubeExtractor.getVideoMetadata(url);
// Store metadata for later use
this.source = {
url: source.url || url,
...this.source,
metadata: {
...source.metadata,
title: metadata.title,
thumbnail: metadata.thumbnail,
duration: metadata.duration,
source: 'youtube',
videoId: videoId,
posterUrl: metadata.thumbnail
}
};
// Update player poster with thumbnail
if (this.video && metadata.thumbnail) {
this.video.poster = metadata.thumbnail;
}
// Create YouTube iframe player with custom controls integration
await this.createYouTubePlayer(videoId);
this.debugLog('✅ YouTube video loaded successfully');
} catch (error) {
this.debugError('Failed to load YouTube video:', error);
throw new Error(`YouTube video loading failed: ${error}`);
}
}
private youtubePlayer: any = null;
private youtubePlayerReady: boolean = false;
private youtubeIframe: HTMLIFrameElement | null = null;
private async createYouTubePlayer(videoId: string): Promise<void> {
const container = this.playerWrapper || this.video?.parentElement;
if (!container) {
throw new Error('No container found for YouTube player');
}
// Hide the regular video element
if (this.video) {
this.video.style.display = 'none';
}
// Create iframe container
const iframeContainer = document.createElement('div');
iframeContainer.id = `youtube-player-${videoId}`;
iframeContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
`;
// Remove existing YouTube player if any
const existingPlayer = container.querySelector(`#youtube-player-${videoId}`);
if (existingPlayer) {
existingPlayer.remove();
}
container.appendChild(iframeContainer);
// Load YouTube IFrame API if not loaded
if (!window.YT) {
await this.loadYouTubeAPI();
}
// Wait for API to be ready
await this.waitForYouTubeAPI();
// Create YouTube player
this.youtubePlayer = new window.YT.Player(iframeContainer.id, {
videoId: videoId,
width: '100%',
height: '100%',
playerVars: {
controls: this.youtubeNativeControls ? 1 : 0, // Show/hide YouTube native controls based on config
disablekb: 0, // Allow keyboard controls
fs: this.youtubeNativeControls ? 1 : 0, // Show/hide fullscreen button based on config
iv_load_policy: 3, // Hide annotations
modestbranding: 1, // Minimal YouTube branding
rel: 0, // Don't show related videos
showinfo: 0, // Hide video info
autoplay: this.config.autoPlay ? 1 : 0,
mute: this.config.muted ? 1 : 0,
loop: this.config.loop ? 1 : 0, // Enable/disable loop
playlist: this.config.loop ? videoId : undefined, // Required for loop to work with single video
widget_referrer: window.location.href // Hide YouTube recommendations
},
events: {
onReady: () => this.onYouTubePlayerReady(),
onStateChange: (event: any) => this.onYouTubePlayerStateChange(event),
onError: (event: any) => this.onYouTubePlayerError(event)
}
});
this.debugLog('YouTube player created');
}
private async loadYouTubeAPI(): Promise<void> {
return new Promise((resolve) => {
if (window.YT) {
resolve();
return;
}
// Set up the callback for when API loads
(window as any).onYouTubeIframeAPIReady = () => {
this.debugLog('YouTube IFrame API loaded');
resolve();
};
// Load the API script
const script = document.createElement('script');
script.src = 'https://www.youtube.com/iframe_api';
script.async = true;
document.body.appendChild(script);
});
}
private async waitForYouTubeAPI(): Promise<void> {
return new Promise((resolve) => {
const checkAPI = () => {
if (window.YT && window.YT.Player) {
resolve();
} else {
setTimeout(checkAPI, 100);
}
};
checkAPI();
});
}
private onYouTubePlayerReady(): void {
this.youtubePlayerReady = true;
this.debugLog('YouTube player ready');
// If YouTube native controls are enabled, hide custom controls
if (this.youtubeNativeControls && this.playerWrapper) {
this.debugLog('[YouTube] Native controls enabled - hiding custom controls');
this.playerWrapper.classList.add('youtube-native-controls-mode');
// Also hide center play button and custom controls
this.hideCustomControls();
}
// Set initial volume
if (this.youtubePlayer) {
const volume = this.config.volume ? this.config.volume * 100 : 100;
this.youtubePlayer.setVolume(volume);
if (this.config.muted) {
this.youtubePlayer.mute();
}
// Apply startTime if configured and not yet applied
if (this.config.startTime !== undefined &&
this.config.startTime > 0 &&
!this.hasAppliedStartTime) {
this.debugLog(`⏩ Seeking YouTube player to startTime: ${this.config.startTime}s`);
this.youtubePlayer.seekTo(this.config.startTime, true);
this.hasAppliedStartTime = true;
}
// Handle autoplay - YouTube's autoplay parameter alone isn't reliable
if (this.config.autoPlay && !this.autoplayAttempted) {
this.debugLog('🎬 Attempting YouTube autoplay');
this.autoplayAttempted = true;
try {
this.youtubePlayer.playVideo();
this.debugLog('✅ YouTube autoplay initiated');
} catch (error) {
this.debugWarn('❌ YouTube autoplay failed:', error);
}
}
}
// Start time tracking
this.startYouTubeTimeTracking();
this.emit('onReady');
}
private onYouTubePlayerStateChange(event: any): void {
const state = event.data;
switch (state) {
case window.YT.PlayerState.PLAYING:
this.state.isPlaying = true;
this.state.isPaused = false;
this.state.isBuffering = false;
this.updateYouTubeUI('playing');
this.emit('onPlay');
break;
case window.YT.PlayerState.PAUSED:
this.state.isPlaying = false;
this.state.isPaused = true;
this.state.isBuffering = false;
this.updateYouTubeUI('paused');
this.emit('onPause');
break;
case window.YT.PlayerState.BUFFERING:
this.state.isBuffering = true;
this.updateYouTubeUI('buffering');
this.emit('onBuffering', true);
break;
case window.YT.PlayerState.ENDED:
this.state.isPlaying = false;
this.state.isPaused = true;
this.state.isEnded = true;
this.updateYouTubeUI('ended');
this.emit('onEnded');
// Manual loop fallback for YouTube (in case loop parameter doesn't work)
if (this.config.loop && this.youtubePlayer) {
this.debugLog('🔁 Loop enabled - restarting YouTube video');
setTimeout(() => {
if (this.youtubePlayer && this.youtubePlayerReady) {
this.youtubePlayer.seekTo(0);
this.youtubePlayer.playVideo();
}
}, 100);
}
break;
case window.YT.PlayerState.CUED:
this.state.duration = this.youtubePlayer.getDuration();
this.updateYouTubeUI('cued');
break;
}
}
private updateYouTubeUI(state: string): void {
const playIcon = document.getElementById('uvf-play-icon');
const pauseIcon = document.getElementById('uvf-pause-icon');
const centerPlay = document.getElementById('uvf-center-play');
if (state === 'playing' || st