UNPKG

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
/** * 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